mirror of
https://github.com/cline/cline.git
synced 2026-09-08 22:13:11 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a66cf19c | ||
|
|
99c999564f | ||
|
|
22a7d8cdcf | ||
|
|
8eef095ad9 | ||
|
|
b286c0db5f | ||
|
|
6ce328e17a |
@@ -18,6 +18,7 @@ What a plugin can do:
|
||||
| [custom-compaction.ts](./custom-compaction.ts) | Provider-message compaction via `registerMessageBuilder` | Rewrites oversized provider-bound message history by preserving the first user message and recent context, then replacing older middle history with a structured summary of roles, tools, files, and highlights. |
|
||||
| [background-terminal.ts](./background-terminal.ts) | Detached shell jobs with persisted logs and session steering | Registers `start_background_command`, `get_background_command`, and `delete_background_command` so agents can launch long-running shell commands, poll stdout/stderr tails, clean up job metadata, and receive completion summaries as steer messages. |
|
||||
| [automation-events.ts](./automation-events.ts) | Plugin-emitted automation events | Registers a normalized `local.plugin_event` automation event type and, when `CLINE_LOCAL_EVENT_INTERVAL_MS` is set, periodically emits demo events into Cline automation. |
|
||||
| [github-pr-dashboard/](./github-pr-dashboard/) | Scheduled GitHub PR dashboard via pre-run hook gate | Fetches PR metrics before inference, paginates open PRs for accurate counts, stops when the dashboard snapshot is unchanged, and asks the agent to update a Markdown dashboard only when counts, review wait times, trends, authors, or reviewers changed. Includes a `run-once` preview that writes Markdown and HTML without a model call. |
|
||||
| [gitignore-read-files-guard.ts](./gitignore-read-files-guard.ts) | Runtime hook policy for workspace `.gitignore` boundaries | Uses `beforeTool` to inspect `read_files`, `editor`, and `apply_patch` requests and skips them when target paths match workspace `.gitignore` rules, preventing ignored files from being read or modified. |
|
||||
| [env-blocker.ts](./env-blocker.ts) | Deterministic secret protection via `beforeTool` | Uses `beforeTool` to block `read_files` and `run_commands` (e.g. `cat .env`) calls that read `.env` secret files, while leaving `.env.example`/`.env.sample`/`.env.template` readable. A hard guarantee where an AGENTS.md rule is only a suggestion. |
|
||||
| [web-search.ts](./web-search.ts) | `web_search` tool backed by an Exa API key | Adds a `web_search` tool that queries Exa for current public web results, with optional result limits, domain filters, recency windows, and country localization. Requires `EXA_API_KEY`. |
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# GitHub PR Dashboard Gate Plugin
|
||||
|
||||
A scheduled GitHub PR dashboard example that uses a deterministic `beforeRun`
|
||||
hook to decide whether an agent should run.
|
||||
|
||||
The hook fetches PR data from GitHub, computes dashboard metrics, hashes the
|
||||
snapshot, and exits before inference if nothing changed:
|
||||
|
||||
```ts
|
||||
{ stop: true, reason: "no GitHub PR dashboard changes, exiting" }
|
||||
```
|
||||
|
||||
When metrics changed, the plugin injects a dashboard-update handoff before the
|
||||
first model request. The agent can then update the requested dashboard file and
|
||||
summarize what changed.
|
||||
|
||||
## Fastest working demo, no agent required
|
||||
|
||||
This writes a Markdown dashboard and a browser-friendly HTML dashboard directly.
|
||||
It does not call a model.
|
||||
|
||||
```bash
|
||||
# From the cline repository root:
|
||||
|
||||
GITHUB_TOKEN="$(gh auth token)" \
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline --open
|
||||
```
|
||||
|
||||
`--repo` is the only required input for the preview command. `GITHUB_TOKEN` is
|
||||
not required for public repositories, but using `gh auth token` avoids GitHub's
|
||||
low unauthenticated API rate limit.
|
||||
|
||||
This preview does not install the plugin or create a schedule. It only proves the
|
||||
deterministic gate and dashboard rendering work locally.
|
||||
|
||||
For a disposable smoke test that writes under `/tmp`:
|
||||
|
||||
```bash
|
||||
# From the cline repository root:
|
||||
|
||||
export GITHUB_PR_DASHBOARD_PATH=/tmp/github-pr-dashboard.md
|
||||
export GITHUB_PR_DASHBOARD_HTML_PATH=/tmp/github-pr-dashboard.html
|
||||
export GITHUB_PR_DASHBOARD_STATE_PATH=/tmp/github-pr-dashboard-state.json
|
||||
|
||||
rm -f "$GITHUB_PR_DASHBOARD_STATE_PATH"
|
||||
GITHUB_TOKEN="$(gh auth token)" \
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline --open
|
||||
```
|
||||
|
||||
If `--open` is omitted, open the generated file manually:
|
||||
|
||||
```bash
|
||||
open /tmp/github-pr-dashboard.html
|
||||
```
|
||||
|
||||
Run the same command again without deleting the state file. If the PR metrics did
|
||||
not change, the JSON output will include:
|
||||
|
||||
```json
|
||||
{ "changed": false, "stop": true }
|
||||
```
|
||||
|
||||
The preview still rewrites the Markdown/HTML files so you can inspect the latest
|
||||
snapshot even when the scheduled hook would skip the model call.
|
||||
|
||||
When dashboard data changes after a previous run, the JSON output and scheduled
|
||||
agent handoff include a deterministic change summary, for example:
|
||||
|
||||
```text
|
||||
- Open PRs: 587 → 591 (+4)
|
||||
- Recently closed PRs: 188 → 193 (+5)
|
||||
- Newly waiting for review: cline/cline#123 Example PR title
|
||||
```
|
||||
|
||||
## What the dashboard covers
|
||||
|
||||
- Open PR count, fetched with pagination so large repositories are counted
|
||||
accurately
|
||||
- New open PR count in the recent window
|
||||
- Recently closed PR count, fetched separately from the bounded recent activity
|
||||
sample
|
||||
- How long open PRs have been waiting for review
|
||||
- PR volume trend over time
|
||||
- Leading PR authors this week and this month
|
||||
- Leading PR reviewers this week and this month
|
||||
- Per-repository breakdown
|
||||
|
||||
## Configuration
|
||||
|
||||
Required:
|
||||
|
||||
```bash
|
||||
# Preview CLI:
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline
|
||||
|
||||
# Installed plugin / scheduled runs:
|
||||
export GITHUB_REPOSITORIES=cline/cline,owner/other-repo
|
||||
```
|
||||
|
||||
Optional:
|
||||
|
||||
```bash
|
||||
export GITHUB_TOKEN=github_pat_...
|
||||
export GH_TOKEN=github_pat_...
|
||||
|
||||
export GITHUB_PR_DASHBOARD_PATH=github-pr-dashboard.md
|
||||
export GITHUB_PR_DASHBOARD_HTML_PATH=github-pr-dashboard.html
|
||||
|
||||
# Recent activity sample size for closed/review/trend detail. Defaults to 25.
|
||||
# This is not an open PR count cap; open PRs are paginated separately.
|
||||
export GITHUB_PR_DASHBOARD_MAX_PRS=25
|
||||
|
||||
# Pagination caps for exact open counts and recently closed scans. Default 10.
|
||||
export GITHUB_PR_DASHBOARD_MAX_OPEN_PAGES=10
|
||||
export GITHUB_PR_DASHBOARD_MAX_CLOSED_PAGES=10
|
||||
|
||||
export GITHUB_PR_DASHBOARD_NEW_HOURS=24
|
||||
export GITHUB_PR_DASHBOARD_RECENTLY_CLOSED_DAYS=7
|
||||
export GITHUB_PR_DASHBOARD_TREND_DAYS=14
|
||||
|
||||
# Default:
|
||||
# ${CLINE_DATA_DIR:-~/.cline/data}/plugins/github-pr-dashboard/state.json
|
||||
export GITHUB_PR_DASHBOARD_STATE_PATH=/tmp/github-pr-dashboard-state.json
|
||||
```
|
||||
|
||||
The state file stores the last dashboard snapshot hash, timestamp, and bounded
|
||||
rendered snapshot. It does not store GitHub tokens or raw GitHub API responses.
|
||||
The previous snapshot is used only to decide whether to wake the agent and to
|
||||
produce the change summary for day-to-day dashboard updates.
|
||||
|
||||
## Scheduled Cline usage
|
||||
|
||||
Nothing is scheduled by default. To make this run automatically, install the
|
||||
plugin into a workspace and create a Cline schedule.
|
||||
|
||||
Install the plugin into the workspace first:
|
||||
|
||||
```bash
|
||||
cline plugin install ./sdk/examples/plugins/github-pr-dashboard --cwd /path/to/workspace
|
||||
```
|
||||
|
||||
Then create a schedule with any cron pattern you want:
|
||||
|
||||
```bash
|
||||
cline schedule create "GitHub PR Dashboard" \
|
||||
--cron "0 9 * * MON-FRI" \
|
||||
--workspace /path/to/workspace \
|
||||
--mode act \
|
||||
--prompt "Update the GitHub PR dashboard if the pre-run hook provides changed dashboard data. Only edit the dashboard file requested by the hook."
|
||||
```
|
||||
|
||||
The schedule can wake as often as you want. The `beforeRun` hook determines
|
||||
whether the agent should actually run.
|
||||
|
||||
## Manual gate smoke test
|
||||
|
||||
This exercises the deterministic gate without writing dashboard files and without
|
||||
starting an agent/model:
|
||||
|
||||
```bash
|
||||
# From the cline repository root:
|
||||
|
||||
export GITHUB_REPOSITORIES=cline/cline
|
||||
export GITHUB_PR_DASHBOARD_STATE_PATH=/tmp/github-pr-dashboard-state.json
|
||||
rm -f "$GITHUB_PR_DASHBOARD_STATE_PATH"
|
||||
|
||||
bun -e '
|
||||
import { runGitHubPrDashboardGate } from "./sdk/examples/plugins/github-pr-dashboard/src/gate.ts";
|
||||
const result = await runGitHubPrDashboardGate();
|
||||
console.log(JSON.stringify({
|
||||
stop: result.stop ?? false,
|
||||
reason: result.reason,
|
||||
dashboardPath: result.dashboardPath,
|
||||
snapshotHash: result.snapshotHash,
|
||||
summary: result.snapshot?.summary,
|
||||
hasHandoff: Boolean(result.handoffText)
|
||||
}, null, 2));
|
||||
'
|
||||
```
|
||||
|
||||
Run it once to get a handoff, then run it again without deleting state. The
|
||||
second run should stop if the PR metrics did not change.
|
||||
|
||||
## Verify locally
|
||||
|
||||
```bash
|
||||
# From the cline repository root:
|
||||
bun -F cline-github-pr-dashboard-plugin test
|
||||
bun -F cline-github-pr-dashboard-plugin typecheck
|
||||
bun biome check sdk/examples/plugins/github-pr-dashboard sdk/examples/plugins/README.md --diagnostic-level=error
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "cline-github-pr-dashboard-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Pre-run hook gate that checks GitHub PR dashboard metrics and only wakes an agent when dashboard data changes.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preview": "bun run src/preview.ts",
|
||||
"run-once": "bun run src/preview.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf node_modules dist"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"cline": {
|
||||
"plugins": [
|
||||
{
|
||||
"paths": [
|
||||
"./src/index.ts"
|
||||
],
|
||||
"capabilities": [
|
||||
"hooks"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*",
|
||||
"@cline/shared": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cline/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@cline/shared": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.13",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
markGitHubPrDashboardSnapshotApplied,
|
||||
runGitHubPrDashboardGate,
|
||||
} from "../gate";
|
||||
|
||||
const env = {
|
||||
GITHUB_REPOSITORIES: "cline/cline",
|
||||
GITHUB_PR_DASHBOARD_MAX_PRS: "5",
|
||||
GITHUB_PR_DASHBOARD_PATH: "docs/pr-dashboard.md",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const pull = {
|
||||
number: 1,
|
||||
title: "Dashboard PR",
|
||||
state: "open",
|
||||
draft: false,
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-09T00:00:00Z",
|
||||
updated_at: "2026-06-09T12:00:00Z",
|
||||
requested_reviewers: [{ login: "amy" }],
|
||||
};
|
||||
|
||||
describe("github PR dashboard gate", () => {
|
||||
it("returns handoff when dashboard snapshot changes", async () => {
|
||||
const writeState = vi.fn();
|
||||
const result = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => ({ version: 1 }),
|
||||
writeState,
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(result.stop).toBeUndefined();
|
||||
expect(result.dashboardPath).toBe("docs/pr-dashboard.md");
|
||||
expect(result.handoffText).toContain(
|
||||
"Dashboard path to update: docs/pr-dashboard.md",
|
||||
);
|
||||
expect(result.handoffText).toContain("# GitHub PR Dashboard");
|
||||
expect(result.changeSummary).toEqual([
|
||||
"Initial dashboard snapshot captured; future runs will summarize changes from this baseline.",
|
||||
]);
|
||||
expect(writeState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pendingSnapshotHash: result.snapshotHash,
|
||||
pendingSnapshot: result.snapshot,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("stops before model when snapshot hash is unchanged", async () => {
|
||||
let state: import("../state").GitHubPrDashboardState = { version: 1 };
|
||||
const first = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: first.snapshotHash ?? "",
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
});
|
||||
const second = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: vi.fn(),
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(first.stop).toBeUndefined();
|
||||
expect(second.stop).toBe(true);
|
||||
expect(second.reason).toBe("no GitHub PR dashboard changes, exiting");
|
||||
expect(second.changeSummary).toEqual([]);
|
||||
expect(second.handoffText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stops before model when an identical snapshot is already pending", async () => {
|
||||
let state: import("../state").GitHubPrDashboardState = { version: 1 };
|
||||
const first = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
const writeState = vi.fn((nextState) => {
|
||||
state = nextState;
|
||||
});
|
||||
const second = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState,
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(first.stop).toBeUndefined();
|
||||
expect(state.pendingSnapshotHash).toBe(first.snapshotHash);
|
||||
expect(second.stop).toBe(true);
|
||||
expect(second.handoffText).toBeUndefined();
|
||||
expect(second.changeSummary).toEqual([]);
|
||||
expect(writeState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pendingSnapshotHash: first.snapshotHash,
|
||||
}),
|
||||
);
|
||||
expect(state.lastSnapshotHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes deterministic change summary from previous snapshot", async () => {
|
||||
let state: import("../state").GitHubPrDashboardState = { version: 1 };
|
||||
const first = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: first.snapshotHash ?? "",
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
});
|
||||
const second = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: () => undefined,
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) =>
|
||||
url.includes("/reviews")
|
||||
? []
|
||||
: [
|
||||
pull,
|
||||
{
|
||||
...pull,
|
||||
number: 2,
|
||||
title: "Second PR",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(second.stop).toBeUndefined();
|
||||
expect(second.changeSummary).toContain("Open PRs: 1 → 2 (+1)");
|
||||
expect(second.handoffText).toContain(
|
||||
"What changed since the previous run:",
|
||||
);
|
||||
expect(second.handoffText).toContain("Open PRs: 1 → 2 (+1)");
|
||||
});
|
||||
|
||||
it("treats an unreadable state file as empty state", async () => {
|
||||
const result = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => ({ version: 1 }),
|
||||
writeState: () => undefined,
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(result.stop).toBeUndefined();
|
||||
expect(result.handoffText).toContain("# GitHub PR Dashboard");
|
||||
});
|
||||
|
||||
it("does not mark a changed snapshot as applied until explicitly promoted", async () => {
|
||||
let state: import("../state").GitHubPrDashboardState = { version: 1 };
|
||||
const result = await runGitHubPrDashboardGate({
|
||||
env,
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
now: () => new Date("2026-06-10T00:00:00Z"),
|
||||
fetchJson: async (url) => (url.includes("/reviews") ? [] : [pull]),
|
||||
});
|
||||
|
||||
expect(state.lastSnapshotHash).toBeUndefined();
|
||||
expect(state.pendingSnapshotHash).toBe(result.snapshotHash);
|
||||
expect(
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: result.snapshotHash ?? "",
|
||||
readState: () => state,
|
||||
writeState: (nextState) => {
|
||||
state = nextState;
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(state.lastSnapshotHash).toBe(result.snapshotHash);
|
||||
expect(state.pendingSnapshotHash).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
fetchGitHubPrDashboardData,
|
||||
normalizePullRequest,
|
||||
normalizeReview,
|
||||
} from "../github";
|
||||
|
||||
describe("github PR dashboard GitHub client", () => {
|
||||
it("normalizes pull requests", () => {
|
||||
expect(
|
||||
normalizePullRequest("cline/cline", {
|
||||
number: 12,
|
||||
title: "Dashboard",
|
||||
html_url: "https://github.com/cline/cline/pull/12",
|
||||
state: "open",
|
||||
draft: false,
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-02T00:00:00Z",
|
||||
requested_reviewers: [{ login: "amy" }],
|
||||
requested_teams: [{ slug: "platform" }],
|
||||
}),
|
||||
).toEqual({
|
||||
number: 12,
|
||||
title: "Dashboard",
|
||||
url: "https://github.com/cline/cline/pull/12",
|
||||
state: "open",
|
||||
draft: false,
|
||||
author: "john",
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-02T00:00:00Z",
|
||||
requestedReviewers: ["amy"],
|
||||
requestedTeams: ["platform"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes reviews", () => {
|
||||
expect(
|
||||
normalizeReview("cline/cline", 12, {
|
||||
user: { login: "amy" },
|
||||
state: "APPROVED",
|
||||
submitted_at: "2026-06-02T00:00:00Z",
|
||||
}),
|
||||
).toEqual({
|
||||
repository: "cline/cline",
|
||||
prNumber: 12,
|
||||
reviewer: "amy",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-06-02T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches open pulls separately so open counts are not limited by recent activity", async () => {
|
||||
const urls: string[] = [];
|
||||
const result = await fetchGitHubPrDashboardData({
|
||||
env: {
|
||||
GITHUB_REPOSITORIES: "cline/cline",
|
||||
GITHUB_TOKEN: "token-1",
|
||||
GITHUB_PR_DASHBOARD_MAX_PRS: "5",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchJson: async (url) => {
|
||||
urls.push(url);
|
||||
if (url.includes("/reviews")) {
|
||||
return [
|
||||
{
|
||||
user: { login: "amy" },
|
||||
state: "APPROVED",
|
||||
submitted_at: "2026-06-03T00:00:00Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (url.includes("state=open")) {
|
||||
return [
|
||||
{
|
||||
number: 1,
|
||||
title: "Open PR 1",
|
||||
state: "open",
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Open PR 2",
|
||||
state: "open",
|
||||
user: { login: "amy" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-01T12:00:00Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (url.includes("state=closed")) {
|
||||
return [
|
||||
{
|
||||
number: 3,
|
||||
title: "Recently closed PR",
|
||||
state: "closed",
|
||||
user: { login: "sam" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-03T00:00:00Z",
|
||||
closed_at: "2026-06-03T00:00:00Z",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
number: 4,
|
||||
title: "Recent activity PR",
|
||||
state: "open",
|
||||
user: { login: "lee" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-03T00:00:00Z",
|
||||
},
|
||||
];
|
||||
},
|
||||
now: new Date("2026-06-04T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(
|
||||
result.pullsByRepo["cline/cline"]?.map((pull) => pull.number),
|
||||
).toEqual([4, 3, 1, 2]);
|
||||
expect(result.reviewsByRepo["cline/cline"]?.[0]?.reviewer).toBe("amy");
|
||||
expect(urls.some((url) => url.includes("state=open"))).toBe(true);
|
||||
expect(urls.some((url) => url.includes("state=closed"))).toBe(true);
|
||||
expect(urls.some((url) => url.includes("state=all"))).toBe(true);
|
||||
expect(urls.some((url) => url.includes("/pulls/4/reviews"))).toBe(true);
|
||||
expect(urls.some((url) => url.includes("/pulls/1/reviews"))).toBe(false);
|
||||
});
|
||||
|
||||
it("caps open PR pagination and returns a warning when the cap is reached", async () => {
|
||||
const urls: string[] = [];
|
||||
const fullPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
title: `Open PR ${index + 1}`,
|
||||
state: "open",
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-02T00:00:00Z",
|
||||
}));
|
||||
const result = await fetchGitHubPrDashboardData({
|
||||
env: {
|
||||
GITHUB_REPOSITORIES: "cline/cline",
|
||||
GITHUB_PR_DASHBOARD_MAX_OPEN_PAGES: "2",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchJson: async (url) => {
|
||||
urls.push(url);
|
||||
if (url.includes("state=open")) return fullPage;
|
||||
return [];
|
||||
},
|
||||
now: new Date("2026-06-04T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(result.pullsByRepo["cline/cline"]).toHaveLength(100);
|
||||
expect(
|
||||
urls
|
||||
.filter((url) => url.includes("state=open"))
|
||||
.map((url) => new URL(url).searchParams.get("page")),
|
||||
).toEqual(["1", "2"]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
repository: "cline/cline",
|
||||
type: "open-pr-page-limit",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps recently closed pagination and returns a warning when the cap is reached", async () => {
|
||||
const urls: string[] = [];
|
||||
const fullClosedPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
number: index + 1,
|
||||
title: `Closed PR ${index + 1}`,
|
||||
state: "closed",
|
||||
user: { login: "john" },
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-03T00:00:00Z",
|
||||
closed_at: "2026-06-03T00:00:00Z",
|
||||
}));
|
||||
const result = await fetchGitHubPrDashboardData({
|
||||
env: {
|
||||
GITHUB_REPOSITORIES: "cline/cline",
|
||||
GITHUB_PR_DASHBOARD_MAX_CLOSED_PAGES: "2",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchJson: async (url) => {
|
||||
urls.push(url);
|
||||
if (url.includes("state=closed")) return fullClosedPage;
|
||||
return [];
|
||||
},
|
||||
now: new Date("2026-06-04T00:00:00Z"),
|
||||
});
|
||||
|
||||
expect(result.pullsByRepo["cline/cline"]).toHaveLength(100);
|
||||
expect(
|
||||
urls
|
||||
.filter((url) => url.includes("state=closed"))
|
||||
.map((url) => new URL(url).searchParams.get("page")),
|
||||
).toEqual(["1", "2"]);
|
||||
expect(result.warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
repository: "cline/cline",
|
||||
type: "closed-pr-page-limit",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderDashboardHtml, summarizeDashboardChanges } from "../format";
|
||||
import { buildDashboardSnapshot, hashDashboardSnapshot } from "../metrics";
|
||||
|
||||
describe("github PR dashboard metrics", () => {
|
||||
it("computes summary, waiting list, trends, authors, and reviewers", () => {
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 48,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 3,
|
||||
pullsByRepo: {
|
||||
"cline/cline": [
|
||||
{
|
||||
number: 1,
|
||||
title: "Open waiting",
|
||||
url: "https://github.com/cline/cline/pull/1",
|
||||
state: "open",
|
||||
draft: false,
|
||||
author: "john",
|
||||
createdAt: "2026-06-09T00:00:00Z",
|
||||
updatedAt: "2026-06-09T12:00:00Z",
|
||||
requestedReviewers: ["amy"],
|
||||
requestedTeams: [],
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: "Merged",
|
||||
url: "https://github.com/cline/cline/pull/2",
|
||||
state: "closed",
|
||||
draft: false,
|
||||
author: "sam",
|
||||
createdAt: "2026-06-08T00:00:00Z",
|
||||
updatedAt: "2026-06-09T00:00:00Z",
|
||||
closedAt: "2026-06-09T01:00:00Z",
|
||||
mergedAt: "2026-06-09T01:00:00Z",
|
||||
requestedReviewers: [],
|
||||
requestedTeams: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
reviewsByRepo: {
|
||||
"cline/cline": [
|
||||
{
|
||||
repository: "cline/cline",
|
||||
prNumber: 2,
|
||||
reviewer: "amy",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-06-09T00:30:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.summary.openCount).toBe(1);
|
||||
expect(snapshot.summary.newOpenCount).toBe(1);
|
||||
expect(snapshot.summary.recentlyClosedCount).toBe(1);
|
||||
expect(snapshot.waitingForReview[0]?.waitingHours).toBe(24);
|
||||
expect(snapshot.leadingAuthors.week).toEqual([
|
||||
{ login: "john", count: 1 },
|
||||
{ login: "sam", count: 1 },
|
||||
]);
|
||||
expect(snapshot.leadingReviewers.week).toEqual([
|
||||
{ login: "amy", count: 1 },
|
||||
]);
|
||||
expect(snapshot.volumeTrend.at(-2)).toEqual({
|
||||
date: "2026-06-09",
|
||||
opened: 1,
|
||||
closed: 1,
|
||||
merged: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("de-duplicates reviewer counts per repository and PR number", () => {
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline", "cline/sdk"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: { "cline/cline": [], "cline/sdk": [] },
|
||||
reviewsByRepo: {
|
||||
"cline/cline": [
|
||||
{
|
||||
repository: "cline/cline",
|
||||
prNumber: 12,
|
||||
reviewer: "amy",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-06-09T00:00:00Z",
|
||||
},
|
||||
{
|
||||
repository: "cline/cline",
|
||||
prNumber: 12,
|
||||
reviewer: "amy",
|
||||
state: "COMMENTED",
|
||||
submittedAt: "2026-06-09T01:00:00Z",
|
||||
},
|
||||
],
|
||||
"cline/sdk": [
|
||||
{
|
||||
repository: "cline/sdk",
|
||||
prNumber: 12,
|
||||
reviewer: "amy",
|
||||
state: "APPROVED",
|
||||
submittedAt: "2026-06-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.leadingReviewers.week).toEqual([
|
||||
{ login: "amy", count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hash ignores generatedAt and time-derived age fields", () => {
|
||||
const base = {
|
||||
generatedAt: "2026-06-10T00:00:00Z",
|
||||
repositories: ["cline/cline"],
|
||||
window: { newPrHours: 24, recentlyClosedDays: 7, trendDays: 1 },
|
||||
summary: {
|
||||
openCount: 0,
|
||||
newOpenCount: 0,
|
||||
recentlyClosedCount: 0,
|
||||
avgOpenAgeHours: 1,
|
||||
avgWaitingForReviewHours: 2,
|
||||
},
|
||||
waitingForReview: [
|
||||
{
|
||||
repository: "cline/cline",
|
||||
number: 1,
|
||||
title: "Waiting",
|
||||
url: "https://github.com/cline/cline/pull/1",
|
||||
author: "john",
|
||||
waitingHours: 3,
|
||||
requestedReviewers: ["amy"],
|
||||
requestedTeams: [],
|
||||
updatedAt: "2026-06-10T00:00:00Z",
|
||||
},
|
||||
],
|
||||
volumeTrend: [{ date: "2026-06-10", opened: 0, closed: 0, merged: 0 }],
|
||||
leadingAuthors: { week: [], month: [] },
|
||||
leadingReviewers: { week: [], month: [] },
|
||||
repositoryBreakdown: [
|
||||
{
|
||||
repository: "cline/cline",
|
||||
openCount: 0,
|
||||
newOpenCount: 0,
|
||||
recentlyClosedCount: 0,
|
||||
avgOpenAgeHours: 4,
|
||||
avgWaitingForReviewHours: 5,
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(hashDashboardSnapshot(base)).toBe(
|
||||
hashDashboardSnapshot({
|
||||
...base,
|
||||
generatedAt: "2026-06-11T00:00:00Z",
|
||||
summary: {
|
||||
...base.summary,
|
||||
avgOpenAgeHours: 10,
|
||||
avgWaitingForReviewHours: 20,
|
||||
},
|
||||
waitingForReview: base.waitingForReview.map((pull) => ({
|
||||
...pull,
|
||||
waitingHours: 30,
|
||||
})),
|
||||
repositoryBreakdown: base.repositoryBreakdown.map((repository) => ({
|
||||
...repository,
|
||||
avgOpenAgeHours: 40,
|
||||
avgWaitingForReviewHours: 50,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a standalone HTML dashboard", () => {
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: { "cline/cline": [] },
|
||||
reviewsByRepo: { "cline/cline": [] },
|
||||
});
|
||||
|
||||
const html = renderDashboardHtml(snapshot);
|
||||
expect(html).toContain("<!doctype html>");
|
||||
expect(html).toContain("GitHub PR Dashboard");
|
||||
expect(html).toContain("cline/cline");
|
||||
expect(html).not.toContain("Checkpoint:");
|
||||
});
|
||||
|
||||
it("renders checkpoint status in the standalone HTML dashboard", () => {
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: { "cline/cline": [] },
|
||||
reviewsByRepo: { "cline/cline": [] },
|
||||
});
|
||||
|
||||
const html = renderDashboardHtml(snapshot, {
|
||||
checkpointStatus: "unchanged",
|
||||
checkpointReason: "no GitHub PR dashboard changes, exiting",
|
||||
snapshotHash: "abcdef1234567890",
|
||||
});
|
||||
|
||||
expect(html).toContain("Checkpoint: no dashboard changes");
|
||||
expect(html).toContain("no GitHub PR dashboard changes, exiting");
|
||||
expect(html).toContain("abcdef123456");
|
||||
});
|
||||
|
||||
it("summarizes dashboard deltas from a previous snapshot", () => {
|
||||
const previous = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: { "cline/cline": [] },
|
||||
reviewsByRepo: { "cline/cline": [] },
|
||||
});
|
||||
const current = buildDashboardSnapshot({
|
||||
generatedAt: new Date("2026-06-10T00:00:00Z"),
|
||||
repositories: ["cline/cline"],
|
||||
newPrHours: 24,
|
||||
recentlyClosedDays: 7,
|
||||
trendDays: 1,
|
||||
pullsByRepo: {
|
||||
"cline/cline": [
|
||||
{
|
||||
number: 1,
|
||||
title: "New dashboard PR",
|
||||
url: "https://github.com/cline/cline/pull/1",
|
||||
state: "open",
|
||||
draft: false,
|
||||
author: "john",
|
||||
createdAt: "2026-06-10T00:00:00Z",
|
||||
updatedAt: "2026-06-10T00:00:00Z",
|
||||
requestedReviewers: ["amy"],
|
||||
requestedTeams: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
reviewsByRepo: { "cline/cline": [] },
|
||||
});
|
||||
|
||||
expect(summarizeDashboardChanges(previous, current)).toContain(
|
||||
"Open PRs: 0 → 1 (+1)",
|
||||
);
|
||||
expect(summarizeDashboardChanges(previous, current)).toContain(
|
||||
"Newly waiting for review: cline/cline#1 New dashboard PR",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDashboardHandoffKey } from "../index";
|
||||
|
||||
describe("github PR dashboard plugin", () => {
|
||||
it("uses runtime identifiers for pending handoff keys without a shared default", () => {
|
||||
expect(
|
||||
resolveDashboardHandoffKey({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
runId: "run-1",
|
||||
}),
|
||||
).toBe("run-1");
|
||||
expect(
|
||||
resolveDashboardHandoffKey({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
}),
|
||||
).toBe("conversation-1");
|
||||
expect(resolveDashboardHandoffKey({ agentId: "agent-1" })).toBe("agent-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
import type { AgentMessage } from "@cline/shared";
|
||||
import type { GitHubPrDashboardRun, GitHubPrDashboardSnapshot } from "./schema";
|
||||
|
||||
export interface DashboardHtmlRenderOptions {
|
||||
checkpointStatus?: "changed" | "unchanged";
|
||||
checkpointReason?: string;
|
||||
snapshotHash?: string;
|
||||
changeSummary?: string[];
|
||||
}
|
||||
|
||||
function tableRows(rows: string[][]): string {
|
||||
return rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
|
||||
}
|
||||
|
||||
export function renderDashboardMarkdown(
|
||||
snapshot: GitHubPrDashboardSnapshot,
|
||||
): string {
|
||||
return [
|
||||
"# GitHub PR Dashboard",
|
||||
"",
|
||||
`Generated: ${snapshot.generatedAt}`,
|
||||
`Repositories: ${snapshot.repositories.join(", ")}`,
|
||||
"",
|
||||
"## Summary",
|
||||
tableRows([
|
||||
["Metric", "Value"],
|
||||
["Open PRs", String(snapshot.summary.openCount)],
|
||||
[
|
||||
`New open PRs (${snapshot.window.newPrHours}h)`,
|
||||
String(snapshot.summary.newOpenCount),
|
||||
],
|
||||
[
|
||||
`Recently closed (${snapshot.window.recentlyClosedDays}d)`,
|
||||
String(snapshot.summary.recentlyClosedCount),
|
||||
],
|
||||
["Average open age", `${snapshot.summary.avgOpenAgeHours}h`],
|
||||
[
|
||||
"Average waiting for review",
|
||||
`${snapshot.summary.avgWaitingForReviewHours}h`,
|
||||
],
|
||||
]),
|
||||
"",
|
||||
"## Waiting for Review",
|
||||
...(snapshot.waitingForReview.length > 0
|
||||
? [
|
||||
tableRows([
|
||||
["PR", "Title", "Author", "Waiting", "Requested"],
|
||||
...snapshot.waitingForReview.map((pr) => [
|
||||
`[${pr.repository}#${pr.number}](${pr.url})`,
|
||||
pr.title.replaceAll("|", "\\|"),
|
||||
pr.author,
|
||||
`${pr.waitingHours}h`,
|
||||
[...pr.requestedReviewers, ...pr.requestedTeams].join(", ") ||
|
||||
"—",
|
||||
]),
|
||||
]),
|
||||
]
|
||||
: ["No open PRs are currently waiting for requested reviewers."]),
|
||||
"",
|
||||
"## Volume Trend",
|
||||
tableRows([
|
||||
["Date", "Opened", "Closed", "Merged"],
|
||||
...snapshot.volumeTrend.map((day) => [
|
||||
day.date,
|
||||
String(day.opened),
|
||||
String(day.closed),
|
||||
String(day.merged),
|
||||
]),
|
||||
]),
|
||||
"",
|
||||
"## Leading Authors",
|
||||
"### This Week",
|
||||
snapshot.leadingAuthors.week
|
||||
.map((item) => `- ${item.login}: ${item.count}`)
|
||||
.join("\n") || "- none",
|
||||
"### This Month",
|
||||
snapshot.leadingAuthors.month
|
||||
.map((item) => `- ${item.login}: ${item.count}`)
|
||||
.join("\n") || "- none",
|
||||
"",
|
||||
"## Leading Reviewers",
|
||||
"### This Week",
|
||||
snapshot.leadingReviewers.week
|
||||
.map((item) => `- ${item.login}: ${item.count}`)
|
||||
.join("\n") || "- none",
|
||||
"### This Month",
|
||||
snapshot.leadingReviewers.month
|
||||
.map((item) => `- ${item.login}: ${item.count}`)
|
||||
.join("\n") || "- none",
|
||||
"",
|
||||
"## Repository Breakdown",
|
||||
tableRows([
|
||||
[
|
||||
"Repository",
|
||||
"Open",
|
||||
"New",
|
||||
"Recently Closed",
|
||||
"Avg Open Age",
|
||||
"Avg Review Wait",
|
||||
],
|
||||
...snapshot.repositoryBreakdown.map((repo) => [
|
||||
repo.repository,
|
||||
String(repo.openCount),
|
||||
String(repo.newOpenCount),
|
||||
String(repo.recentlyClosedCount),
|
||||
`${repo.avgOpenAgeHours}h`,
|
||||
`${repo.avgWaitingForReviewHours}h`,
|
||||
]),
|
||||
]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function signedDelta(current: number, previous: number): string {
|
||||
const delta = current - previous;
|
||||
if (delta === 0) return "no change";
|
||||
return `${previous} → ${current} (${delta > 0 ? "+" : ""}${delta})`;
|
||||
}
|
||||
|
||||
function itemKey(item: { repository: string; number: number }): string {
|
||||
return `${item.repository}#${item.number}`;
|
||||
}
|
||||
|
||||
function topLogin(items: Array<{ login: string; count: number }>): string {
|
||||
const item = items[0];
|
||||
return item ? `${item.login} (${item.count})` : "none";
|
||||
}
|
||||
|
||||
function summarizeWaitingChanges(
|
||||
previous: GitHubPrDashboardSnapshot,
|
||||
current: GitHubPrDashboardSnapshot,
|
||||
): string[] {
|
||||
const previousWaiting = new Map(
|
||||
previous.waitingForReview.map((item) => [itemKey(item), item]),
|
||||
);
|
||||
const currentWaiting = new Map(
|
||||
current.waitingForReview.map((item) => [itemKey(item), item]),
|
||||
);
|
||||
const newlyWaiting = [...currentWaiting.entries()]
|
||||
.filter(([key]) => !previousWaiting.has(key))
|
||||
.slice(0, 5)
|
||||
.map(([key, item]) => `${key} ${item.title}`);
|
||||
const noLongerWaiting = [...previousWaiting.entries()]
|
||||
.filter(([key]) => !currentWaiting.has(key))
|
||||
.slice(0, 5)
|
||||
.map(([key, item]) => `${key} ${item.title}`);
|
||||
|
||||
return [
|
||||
...(newlyWaiting.length > 0
|
||||
? [`Newly waiting for review: ${newlyWaiting.join("; ")}`]
|
||||
: []),
|
||||
...(noLongerWaiting.length > 0
|
||||
? [`No longer waiting for review: ${noLongerWaiting.join("; ")}`]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export function summarizeDashboardChanges(
|
||||
previous: GitHubPrDashboardSnapshot | undefined,
|
||||
current: GitHubPrDashboardSnapshot,
|
||||
): string[] {
|
||||
if (!previous) {
|
||||
return [
|
||||
"Initial dashboard snapshot captured; future runs will summarize changes from this baseline.",
|
||||
];
|
||||
}
|
||||
|
||||
const changes = [
|
||||
`Open PRs: ${signedDelta(current.summary.openCount, previous.summary.openCount)}`,
|
||||
`New open PRs: ${signedDelta(current.summary.newOpenCount, previous.summary.newOpenCount)}`,
|
||||
`Recently closed PRs: ${signedDelta(current.summary.recentlyClosedCount, previous.summary.recentlyClosedCount)}`,
|
||||
...summarizeWaitingChanges(previous, current),
|
||||
];
|
||||
|
||||
const previousTopAuthor = topLogin(previous.leadingAuthors.week);
|
||||
const currentTopAuthor = topLogin(current.leadingAuthors.week);
|
||||
if (previousTopAuthor !== currentTopAuthor) {
|
||||
changes.push(
|
||||
`Top author this week: ${previousTopAuthor} → ${currentTopAuthor}`,
|
||||
);
|
||||
}
|
||||
|
||||
const previousTopReviewer = topLogin(previous.leadingReviewers.week);
|
||||
const currentTopReviewer = topLogin(current.leadingReviewers.week);
|
||||
if (previousTopReviewer !== currentTopReviewer) {
|
||||
changes.push(
|
||||
`Top reviewer this week: ${previousTopReviewer} → ${currentTopReviewer}`,
|
||||
);
|
||||
}
|
||||
|
||||
return changes.filter((change) => !change.endsWith("no change"));
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function htmlTable(headers: string[], rows: string[][]): string {
|
||||
return [
|
||||
'<div class="table-wrap"><table>',
|
||||
`<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead>`,
|
||||
`<tbody>${rows
|
||||
.map(
|
||||
(row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join("")}</tr>`,
|
||||
)
|
||||
.join("")}</tbody>`,
|
||||
"</table></div>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function metricCard(label: string, value: string): string {
|
||||
return `<section class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></section>`;
|
||||
}
|
||||
|
||||
function topList(items: Array<{ login: string; count: number }>): string {
|
||||
if (items.length === 0) return '<p class="muted">none</p>';
|
||||
return `<ol>${items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li><span>${escapeHtml(item.login)}</span><strong>${item.count}</strong></li>`,
|
||||
)
|
||||
.join("")}</ol>`;
|
||||
}
|
||||
|
||||
export function renderDashboardHtml(
|
||||
snapshot: GitHubPrDashboardSnapshot,
|
||||
options: DashboardHtmlRenderOptions = {},
|
||||
): string {
|
||||
const waitingRows = snapshot.waitingForReview.map((pr) => [
|
||||
`<a href="${escapeHtml(pr.url)}">${escapeHtml(`${pr.repository}#${pr.number}`)}</a>`,
|
||||
escapeHtml(pr.title),
|
||||
escapeHtml(pr.author),
|
||||
escapeHtml(`${pr.waitingHours}h`),
|
||||
escapeHtml(
|
||||
[...pr.requestedReviewers, ...pr.requestedTeams].join(", ") || "—",
|
||||
),
|
||||
]);
|
||||
const trendRows = snapshot.volumeTrend.map((day) => [
|
||||
escapeHtml(day.date),
|
||||
escapeHtml(String(day.opened)),
|
||||
escapeHtml(String(day.closed)),
|
||||
escapeHtml(String(day.merged)),
|
||||
]);
|
||||
const repoRows = snapshot.repositoryBreakdown.map((repo) => [
|
||||
escapeHtml(repo.repository),
|
||||
escapeHtml(String(repo.openCount)),
|
||||
escapeHtml(String(repo.newOpenCount)),
|
||||
escapeHtml(String(repo.recentlyClosedCount)),
|
||||
escapeHtml(`${repo.avgOpenAgeHours}h`),
|
||||
escapeHtml(`${repo.avgWaitingForReviewHours}h`),
|
||||
]);
|
||||
|
||||
const repositoryPills = snapshot.repositories
|
||||
.map(
|
||||
(repository) =>
|
||||
`<span class="repo-pill">${escapeHtml(repository)}</span>`,
|
||||
)
|
||||
.join("");
|
||||
const checkpointBanner = options.checkpointStatus
|
||||
? (() => {
|
||||
const checkpointChanged = options.checkpointStatus === "changed";
|
||||
const checkpointTitle = checkpointChanged
|
||||
? "Checkpoint: dashboard data changed"
|
||||
: "Checkpoint: no dashboard changes";
|
||||
const checkpointDescription =
|
||||
options.checkpointReason ??
|
||||
(checkpointChanged
|
||||
? "The gate found a new dashboard snapshot and would wake the agent."
|
||||
: "The gate matched the previous applied snapshot and skipped the agent run.");
|
||||
const checkpointChanges = options.changeSummary?.length
|
||||
? `<ul class="checkpoint-list">${options.changeSummary.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`
|
||||
: "";
|
||||
const checkpointHash = options.snapshotHash
|
||||
? `<code>${escapeHtml(options.snapshotHash.slice(0, 12))}</code>`
|
||||
: "";
|
||||
return `<section class="checkpoint ${checkpointChanged ? "checkpoint-changed" : "checkpoint-unchanged"}">
|
||||
<div><span class="checkpoint-kicker">${checkpointChanged ? "Agent wake" : "Checkpoint hit"}</span><h2>${checkpointTitle}</h2><p>${escapeHtml(checkpointDescription)}</p>${checkpointChanges}</div>
|
||||
<div class="checkpoint-hash"><span>Snapshot</span>${checkpointHash}</div>
|
||||
</section>`;
|
||||
})()
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>GitHub PR Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
--card: rgba(24, 24, 27, 0.86);
|
||||
--card-strong: rgba(39, 39, 42, 0.88);
|
||||
--muted: #a1a1aa;
|
||||
--muted-strong: #d4d4d8;
|
||||
--divider: rgba(255, 255, 255, 0.10);
|
||||
--divider-strong: rgba(255, 255, 255, 0.16);
|
||||
--purple: #c084fc;
|
||||
--purple-strong: #a855f7;
|
||||
--fuchsia: #e879f9;
|
||||
--emerald: #34d399;
|
||||
--amber: #fbbf24;
|
||||
--radius-card: 14px;
|
||||
--shadow-card: 0 18px 70px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: normal;
|
||||
background:
|
||||
radial-gradient(circle at 18% 10%, rgba(168, 85, 247, 0.24), transparent 34rem),
|
||||
radial-gradient(circle at 88% 4%, rgba(217, 70, 239, 0.16), transparent 28rem),
|
||||
linear-gradient(180deg, #111113 0%, var(--background) 46%, #050506 100%);
|
||||
color: var(--foreground);
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
||||
}
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 40px 24px 56px; position: relative; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 4rem); line-height: 0.95; letter-spacing: -0.055em; margin: 0; }
|
||||
h2 { font-size: 1rem; line-height: 1.1; letter-spacing: -0.02em; margin: 0; }
|
||||
h3 { color: var(--muted-strong); font-size: 0.8rem; letter-spacing: 0.06em; margin: 18px 0 10px; text-transform: uppercase; }
|
||||
a { color: #d8b4fe; font-weight: 650; text-decoration: none; }
|
||||
a:hover { color: white; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.muted { color: var(--muted); }
|
||||
.eyebrow { align-items: center; background: rgba(251, 191, 36, 0.16); border: 1px solid rgba(251, 191, 36, 0.26); border-radius: 999px; color: #fcd34d; display: inline-flex; font-size: 0.72rem; font-weight: 800; gap: 7px; letter-spacing: 0.12em; padding: 7px 10px; text-transform: uppercase; width: fit-content; }
|
||||
.eyebrow::before { content: "✦"; color: var(--amber); }
|
||||
.hero { background: radial-gradient(circle at 18% 18%, rgba(168,85,247,0.24), transparent 62%), linear-gradient(135deg, rgba(88,28,135,0.38), rgba(76,29,149,0.24) 56%, rgba(17,24,39,0.34)); border: 1px solid var(--divider); border-radius: 24px; box-shadow: var(--shadow-card); overflow: hidden; padding: clamp(24px, 5vw, 42px); position: relative; }
|
||||
.hero::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(135deg, rgba(255,255,255,0.12), transparent 32%, rgba(255,255,255,0.04)); }
|
||||
.hero-content { display: grid; gap: 26px; position: relative; z-index: 1; }
|
||||
.hero-top { display: flex; flex-wrap: wrap; gap: 18px; justify-content: space-between; }
|
||||
.subtitle { color: var(--muted); font-size: 1rem; line-height: 1.65; margin: 18px 0 0; max-width: 760px; }
|
||||
.repo-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.repo-pill { background: rgba(255, 255, 255, 0.06); border: 1px solid var(--divider); border-radius: 999px; color: var(--muted-strong); font-size: 0.78rem; font-weight: 700; padding: 7px 10px; }
|
||||
.timestamp { color: var(--muted); font-size: 0.82rem; margin: 0; text-align: right; }
|
||||
.checkpoint { align-items: center; border: 1px solid var(--divider); border-radius: var(--radius-card); display: flex; gap: 18px; justify-content: space-between; margin-top: 18px; padding: 18px 20px; }
|
||||
.checkpoint-unchanged { background: linear-gradient(135deg, rgba(16,185,129,0.20), rgba(20,184,166,0.10) 52%, rgba(24,24,27,0.78)); border-color: rgba(52,211,153,0.28); }
|
||||
.checkpoint-changed { background: linear-gradient(135deg, rgba(168,85,247,0.24), rgba(232,121,249,0.10) 52%, rgba(24,24,27,0.78)); border-color: rgba(192,132,252,0.32); }
|
||||
.checkpoint-kicker { color: var(--emerald); display: block; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.14em; margin-bottom: 8px; text-transform: uppercase; }
|
||||
.checkpoint-changed .checkpoint-kicker { color: #f0abfc; }
|
||||
.checkpoint p { color: var(--muted); font-size: 0.9rem; line-height: 1.55; margin: 8px 0 0; max-width: 700px; }
|
||||
.checkpoint-list { color: var(--muted-strong); font-size: 0.85rem; margin: 10px 0 0; padding-left: 18px; }
|
||||
.checkpoint-hash { align-items: flex-end; display: flex; flex-direction: column; gap: 6px; white-space: nowrap; }
|
||||
.checkpoint-hash span { color: var(--muted); font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.checkpoint-hash code { background: rgba(0,0,0,0.24); border: 1px solid var(--divider); border-radius: 8px; color: var(--muted-strong); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.82rem; padding: 6px 8px; }
|
||||
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.metric, .panel, .section-card { background: var(--card); border: 1px solid var(--divider); border-radius: var(--radius-card); box-shadow: 0 12px 42px rgba(0,0,0,0.22); }
|
||||
.metric { min-height: 132px; padding: 20px; position: relative; overflow: hidden; }
|
||||
.metric::after { background: linear-gradient(135deg, rgba(168,85,247,0.18), rgba(232,121,249,0.08)); border-radius: 999px; content: ""; height: 92px; position: absolute; right: -32px; top: -34px; width: 92px; }
|
||||
.metric span { color: var(--muted); display: block; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; margin-bottom: 14px; max-width: 150px; text-transform: uppercase; }
|
||||
.metric strong { display: block; font-size: clamp(2rem, 5vw, 3.6rem); font-weight: 750; letter-spacing: -0.06em; line-height: 0.95; }
|
||||
.section-card { margin-top: 18px; overflow: hidden; }
|
||||
.section-header { align-items: center; border-bottom: 1px solid var(--divider); display: flex; justify-content: space-between; min-height: 68px; padding: 20px 24px 16px; }
|
||||
.section-body { padding: 0; }
|
||||
.panel { padding: 22px 24px; }
|
||||
.panel h2 { margin-bottom: 12px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border-bottom: 1px solid var(--divider); padding: 14px 16px; text-align: left; vertical-align: top; }
|
||||
th { background: rgba(255,255,255,0.035); color: var(--muted); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; }
|
||||
td { color: var(--muted-strong); font-size: 0.9rem; }
|
||||
tbody tr:hover { background: rgba(255,255,255,0.035); }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
ol { margin: 0; padding-left: 22px; }
|
||||
li { color: var(--muted-strong); margin: 8px 0; }
|
||||
li strong { background: rgba(168,85,247,0.16); border: 1px solid rgba(168,85,247,0.22); border-radius: 999px; color: #e9d5ff; margin-left: 8px; padding: 2px 8px; }
|
||||
.panel-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); margin-top: 18px; }
|
||||
.empty { padding: 22px 24px; }
|
||||
section.dashboard-section { margin-top: 28px; }
|
||||
@media (max-width: 720px) {
|
||||
main { padding: 22px 14px 36px; }
|
||||
.hero { border-radius: 18px; }
|
||||
.timestamp { text-align: left; }
|
||||
.checkpoint { align-items: flex-start; flex-direction: column; }
|
||||
.checkpoint-hash { align-items: flex-start; }
|
||||
.section-header { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
th, td { padding: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-top">
|
||||
<span class="eyebrow">Cline PR Intelligence</span>
|
||||
<p class="timestamp">Generated ${escapeHtml(snapshot.generatedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1>GitHub PR Dashboard</h1>
|
||||
<p class="subtitle">A scheduled Cline dashboard for review load, PR velocity, and repository health. Metrics are generated by the deterministic before-run gate and styled after the Cline dashboard UI.</p>
|
||||
</div>
|
||||
<div class="repo-list">${repositoryPills}</div>
|
||||
<div class="grid">
|
||||
${metricCard("Open PRs", String(snapshot.summary.openCount))}
|
||||
${metricCard(`New open PRs (${snapshot.window.newPrHours}h)`, String(snapshot.summary.newOpenCount))}
|
||||
${metricCard(`Recently closed (${snapshot.window.recentlyClosedDays}d)`, String(snapshot.summary.recentlyClosedCount))}
|
||||
${metricCard("Avg review wait", `${snapshot.summary.avgWaitingForReviewHours}h`)}
|
||||
</div>
|
||||
${checkpointBanner}
|
||||
</div>
|
||||
</section>
|
||||
<section class="dashboard-section section-card"><div class="section-header"><h2>Waiting for Review</h2><span class="muted">${snapshot.waitingForReview.length} PRs</span></div><div class="section-body">${
|
||||
waitingRows.length > 0
|
||||
? htmlTable(
|
||||
["PR", "Title", "Author", "Waiting", "Requested"],
|
||||
waitingRows,
|
||||
)
|
||||
: '<p class="muted empty">No open PRs are currently waiting for requested reviewers.</p>'
|
||||
}</div></section>
|
||||
<section class="dashboard-section section-card"><div class="section-header"><h2>Volume Trend</h2><span class="muted">Last ${snapshot.window.trendDays} days</span></div><div class="section-body">${htmlTable(["Date", "Opened", "Closed", "Merged"], trendRows)}</div></section>
|
||||
<section class="panel-grid">
|
||||
<div class="panel"><h2>Leading Authors</h2><h3>This Week</h3>${topList(snapshot.leadingAuthors.week)}<h3>This Month</h3>${topList(snapshot.leadingAuthors.month)}</div>
|
||||
<div class="panel"><h2>Leading Reviewers</h2><h3>This Week</h3>${topList(snapshot.leadingReviewers.week)}<h3>This Month</h3>${topList(snapshot.leadingReviewers.month)}</div>
|
||||
</section>
|
||||
<section class="dashboard-section section-card"><div class="section-header"><h2>Repository Breakdown</h2><span class="muted">${snapshot.repositories.length} repositories</span></div><div class="section-body">${htmlTable(["Repository", "Open", "New", "Recently Closed", "Avg Open Age", "Avg Review Wait"], repoRows)}</div></section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function formatDashboardHandoff(run: GitHubPrDashboardRun): string {
|
||||
return [
|
||||
"GitHub PR dashboard gate found changed dashboard data.",
|
||||
`Run ID: ${run.runId}`,
|
||||
`Snapshot hash: ${run.snapshotHash}`,
|
||||
`Dashboard path to update: ${run.dashboardPath}`,
|
||||
"",
|
||||
"What changed since the previous run:",
|
||||
...(run.changeSummary.length > 0
|
||||
? run.changeSummary.map((item) => `- ${item}`)
|
||||
: [
|
||||
"- Dashboard data changed, but no high-level summary fields changed.",
|
||||
]),
|
||||
"",
|
||||
"Task:",
|
||||
"1. Update the dashboard file at the exact path above with the Markdown dashboard below.",
|
||||
"2. Keep the update focused on the dashboard file only.",
|
||||
"3. Briefly summarize what changed in the PR metrics after writing the file.",
|
||||
"4. Do not edit unrelated files.",
|
||||
"",
|
||||
"# Dashboard Markdown",
|
||||
"```md",
|
||||
renderDashboardMarkdown(run.snapshot),
|
||||
"```",
|
||||
"",
|
||||
"# Raw Snapshot JSON",
|
||||
"```json",
|
||||
JSON.stringify(run.snapshot, null, 2),
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function makeDashboardHandoffMessage(text: string): AgentMessage {
|
||||
const createdAt = Date.now();
|
||||
return {
|
||||
id: `msg_github_pr_dashboard_${createdAt}`,
|
||||
role: "user",
|
||||
createdAt,
|
||||
content: [{ type: "text", text }],
|
||||
metadata: { source: "github-pr-dashboard" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { BasicLogger } from "@cline/core";
|
||||
import { formatDashboardHandoff, summarizeDashboardChanges } from "./format";
|
||||
import {
|
||||
type FetchJson,
|
||||
fetchGitHubPrDashboardData,
|
||||
type GitHubPrDashboardDataWarning,
|
||||
} from "./github";
|
||||
import { buildDashboardSnapshot, hashDashboardSnapshot } from "./metrics";
|
||||
import type { GitHubPrDashboardRun, GitHubPrDashboardSnapshot } from "./schema";
|
||||
import {
|
||||
type GitHubPrDashboardState,
|
||||
markSnapshotApplied,
|
||||
readState,
|
||||
resolveStatePath,
|
||||
writeState,
|
||||
} from "./state";
|
||||
|
||||
export interface GitHubPrDashboardGateOptions {
|
||||
logger?: BasicLogger;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
readState?: () => GitHubPrDashboardState;
|
||||
writeState?: (state: GitHubPrDashboardState) => void;
|
||||
fetchJson?: FetchJson;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export interface GitHubPrDashboardGateResult {
|
||||
stop?: boolean;
|
||||
reason: string;
|
||||
snapshot?: GitHubPrDashboardSnapshot;
|
||||
snapshotHash?: string;
|
||||
dashboardPath?: string;
|
||||
handoffText?: string;
|
||||
changeSummary?: string[];
|
||||
run?: GitHubPrDashboardRun;
|
||||
statePath?: string;
|
||||
warnings?: GitHubPrDashboardDataWarning[];
|
||||
}
|
||||
|
||||
export interface ApplyGitHubPrDashboardSnapshotOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
statePath?: string;
|
||||
snapshotHash: string;
|
||||
readState?: () => GitHubPrDashboardState;
|
||||
writeState?: (state: GitHubPrDashboardState) => void;
|
||||
}
|
||||
|
||||
function log(
|
||||
logger: BasicLogger | undefined,
|
||||
message: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): void {
|
||||
logger?.log?.(message, metadata);
|
||||
}
|
||||
|
||||
export async function runGitHubPrDashboardGate(
|
||||
options: GitHubPrDashboardGateOptions = {},
|
||||
): Promise<GitHubPrDashboardGateResult> {
|
||||
const generatedAt = (options.now?.() ?? new Date()).toISOString();
|
||||
const statePath = resolveStatePath(options.env ?? process.env);
|
||||
const persistState =
|
||||
options.writeState ??
|
||||
((nextState: GitHubPrDashboardState) => writeState(nextState, statePath));
|
||||
const state = options.readState?.() ?? readState(statePath);
|
||||
const data = await fetchGitHubPrDashboardData({
|
||||
env: options.env,
|
||||
fetchJson: options.fetchJson,
|
||||
now: new Date(generatedAt),
|
||||
});
|
||||
const snapshot = buildDashboardSnapshot({
|
||||
generatedAt: new Date(generatedAt),
|
||||
repositories: data.config.repositories,
|
||||
pullsByRepo: data.pullsByRepo,
|
||||
reviewsByRepo: data.reviewsByRepo,
|
||||
newPrHours: data.config.newPrHours,
|
||||
recentlyClosedDays: data.config.recentlyClosedDays,
|
||||
trendDays: data.config.trendDays,
|
||||
});
|
||||
const snapshotHash = hashDashboardSnapshot(snapshot);
|
||||
const changeSummary = summarizeDashboardChanges(state.lastSnapshot, snapshot);
|
||||
|
||||
const hasAppliedSnapshot = state.lastSnapshotHash === snapshotHash;
|
||||
const hasPendingSnapshot = state.pendingSnapshotHash === snapshotHash;
|
||||
|
||||
if (hasAppliedSnapshot || hasPendingSnapshot) {
|
||||
persistState({
|
||||
version: 1,
|
||||
...(hasAppliedSnapshot
|
||||
? { lastSnapshotHash: snapshotHash }
|
||||
: state.lastSnapshotHash
|
||||
? { lastSnapshotHash: state.lastSnapshotHash }
|
||||
: {}),
|
||||
...(hasAppliedSnapshot
|
||||
? { lastGeneratedAt: generatedAt }
|
||||
: state.lastGeneratedAt
|
||||
? { lastGeneratedAt: state.lastGeneratedAt }
|
||||
: {}),
|
||||
...(hasAppliedSnapshot
|
||||
? { lastSnapshot: snapshot }
|
||||
: state.lastSnapshot
|
||||
? { lastSnapshot: state.lastSnapshot }
|
||||
: {}),
|
||||
...(state.pendingSnapshotHash
|
||||
? { pendingSnapshotHash: state.pendingSnapshotHash }
|
||||
: {}),
|
||||
...(state.pendingGeneratedAt
|
||||
? { pendingGeneratedAt: state.pendingGeneratedAt }
|
||||
: {}),
|
||||
...(state.pendingSnapshot
|
||||
? { pendingSnapshot: state.pendingSnapshot }
|
||||
: {}),
|
||||
});
|
||||
log(options.logger, "github-pr-dashboard: no dashboard changes, exiting", {
|
||||
repositories: data.config.repositories,
|
||||
snapshotHash,
|
||||
warnings: data.warnings,
|
||||
});
|
||||
return {
|
||||
stop: true,
|
||||
reason: "no GitHub PR dashboard changes, exiting",
|
||||
snapshot,
|
||||
snapshotHash,
|
||||
dashboardPath: data.config.dashboardPath,
|
||||
changeSummary: [],
|
||||
statePath,
|
||||
warnings: data.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
persistState({
|
||||
version: 1,
|
||||
...(state.lastSnapshotHash
|
||||
? { lastSnapshotHash: state.lastSnapshotHash }
|
||||
: {}),
|
||||
...(state.lastGeneratedAt
|
||||
? { lastGeneratedAt: state.lastGeneratedAt }
|
||||
: {}),
|
||||
...(state.lastSnapshot ? { lastSnapshot: state.lastSnapshot } : {}),
|
||||
pendingSnapshotHash: snapshotHash,
|
||||
pendingGeneratedAt: generatedAt,
|
||||
pendingSnapshot: snapshot,
|
||||
});
|
||||
const run: GitHubPrDashboardRun = {
|
||||
runId: `github-pr-dashboard-${generatedAt}`,
|
||||
snapshotHash,
|
||||
dashboardPath: data.config.dashboardPath,
|
||||
snapshot,
|
||||
changeSummary,
|
||||
};
|
||||
const handoffText = formatDashboardHandoff(run);
|
||||
log(options.logger, "github-pr-dashboard: dashboard changes found", {
|
||||
repositories: data.config.repositories,
|
||||
snapshotHash,
|
||||
openCount: snapshot.summary.openCount,
|
||||
warnings: data.warnings,
|
||||
});
|
||||
return {
|
||||
reason: "GitHub PR dashboard data changed",
|
||||
snapshot,
|
||||
snapshotHash,
|
||||
dashboardPath: data.config.dashboardPath,
|
||||
changeSummary,
|
||||
handoffText,
|
||||
run,
|
||||
statePath,
|
||||
warnings: data.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function markGitHubPrDashboardSnapshotApplied(
|
||||
options: ApplyGitHubPrDashboardSnapshotOptions,
|
||||
): boolean {
|
||||
const statePath =
|
||||
options.statePath ?? resolveStatePath(options.env ?? process.env);
|
||||
const currentState = options.readState?.() ?? readState(statePath);
|
||||
const nextState = markSnapshotApplied(currentState, options.snapshotHash);
|
||||
if (nextState === currentState) return false;
|
||||
(
|
||||
options.writeState ??
|
||||
((state: GitHubPrDashboardState) => writeState(state, statePath))
|
||||
)(nextState);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import type {
|
||||
GitHubPullRequestRecord,
|
||||
GitHubPullRequestReviewRecord,
|
||||
} from "./schema";
|
||||
|
||||
export interface GitHubPrDashboardConfig {
|
||||
repositories: string[];
|
||||
maxPullsPerRepo: number;
|
||||
maxOpenPages: number;
|
||||
maxClosedPages: number;
|
||||
newPrHours: number;
|
||||
recentlyClosedDays: number;
|
||||
trendDays: number;
|
||||
dashboardPath: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface GitHubPrDashboardDataWarning {
|
||||
repository: string;
|
||||
type: "closed-pr-page-limit" | "open-pr-page-limit";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitHubPullApiRecord {
|
||||
number: number;
|
||||
title?: string;
|
||||
html_url?: string;
|
||||
state?: string;
|
||||
draft?: boolean;
|
||||
user?: { login?: string | null } | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
closed_at?: string | null;
|
||||
merged_at?: string | null;
|
||||
requested_reviewers?: Array<{ login?: string | null }>;
|
||||
requested_teams?: Array<{ name?: string | null; slug?: string | null }>;
|
||||
}
|
||||
|
||||
export interface GitHubReviewApiRecord {
|
||||
user?: { login?: string | null } | null;
|
||||
state?: string;
|
||||
submitted_at?: string | null;
|
||||
}
|
||||
|
||||
export type FetchJson = (
|
||||
url: string,
|
||||
init: { headers: Record<string, string> },
|
||||
) => Promise<unknown>;
|
||||
|
||||
type PullRequestStateFilter = "open" | "closed" | "all";
|
||||
|
||||
function splitCsv(value: string | undefined): string[] {
|
||||
return value
|
||||
? value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function positiveInt(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
max: number,
|
||||
): number {
|
||||
const parsed = Number(value ?? fallback);
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? Math.min(Math.trunc(parsed), max)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function resolveGitHubPrDashboardConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): GitHubPrDashboardConfig {
|
||||
const repositories = splitCsv(env.GITHUB_REPOSITORIES);
|
||||
if (repositories.length === 0) {
|
||||
throw new Error(
|
||||
"Set GITHUB_REPOSITORIES=owner/repo[,owner/repo] to use github-pr-dashboard",
|
||||
);
|
||||
}
|
||||
return {
|
||||
repositories,
|
||||
maxPullsPerRepo: positiveInt(env.GITHUB_PR_DASHBOARD_MAX_PRS, 25, 100),
|
||||
maxOpenPages: positiveInt(env.GITHUB_PR_DASHBOARD_MAX_OPEN_PAGES, 10, 50),
|
||||
maxClosedPages: positiveInt(
|
||||
env.GITHUB_PR_DASHBOARD_MAX_CLOSED_PAGES,
|
||||
10,
|
||||
50,
|
||||
),
|
||||
newPrHours: positiveInt(env.GITHUB_PR_DASHBOARD_NEW_HOURS, 24, 24 * 30),
|
||||
recentlyClosedDays: positiveInt(
|
||||
env.GITHUB_PR_DASHBOARD_RECENTLY_CLOSED_DAYS,
|
||||
7,
|
||||
365,
|
||||
),
|
||||
trendDays: positiveInt(env.GITHUB_PR_DASHBOARD_TREND_DAYS, 14, 365),
|
||||
dashboardPath:
|
||||
env.GITHUB_PR_DASHBOARD_PATH?.trim() || "github-pr-dashboard.md",
|
||||
token: env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultFetchJson(
|
||||
url: string,
|
||||
init: { headers: Record<string, string> },
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API request failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function headersFor(config: GitHubPrDashboardConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/vnd.github+json",
|
||||
"user-agent": "cline-github-pr-dashboard-plugin",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
};
|
||||
if (config.token) headers.authorization = `Bearer ${config.token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function mergePullsByNumber(
|
||||
primary: GitHubPullRequestRecord[],
|
||||
secondary: GitHubPullRequestRecord[],
|
||||
): GitHubPullRequestRecord[] {
|
||||
const merged = new Map<number, GitHubPullRequestRecord>();
|
||||
for (const pull of [...primary, ...secondary]) {
|
||||
merged.set(pull.number, pull);
|
||||
}
|
||||
return [...merged.values()].sort((left, right) => {
|
||||
const rightUpdated = new Date(right.updatedAt).getTime();
|
||||
const leftUpdated = new Date(left.updatedAt).getTime();
|
||||
return rightUpdated - leftUpdated || right.number - left.number;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPullsPage(options: {
|
||||
repository: string;
|
||||
state: PullRequestStateFilter;
|
||||
page: number;
|
||||
perPage: number;
|
||||
config: GitHubPrDashboardConfig;
|
||||
fetchJson: FetchJson;
|
||||
}): Promise<GitHubPullRequestRecord[]> {
|
||||
const pullsParams = new URLSearchParams({
|
||||
state: options.state,
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: String(options.perPage),
|
||||
page: String(options.page),
|
||||
});
|
||||
const pullsPayload = await options.fetchJson(
|
||||
`https://api.github.com/repos/${options.repository}/pulls?${pullsParams}`,
|
||||
{ headers: headersFor(options.config) },
|
||||
);
|
||||
if (!Array.isArray(pullsPayload)) {
|
||||
throw new Error(
|
||||
`GitHub API returned a non-array pulls payload for ${options.repository}`,
|
||||
);
|
||||
}
|
||||
return (pullsPayload as GitHubPullApiRecord[])
|
||||
.map((pull) => normalizePullRequest(options.repository, pull))
|
||||
.filter((pull): pull is GitHubPullRequestRecord => Boolean(pull));
|
||||
}
|
||||
|
||||
async function fetchAllOpenPulls(options: {
|
||||
repository: string;
|
||||
config: GitHubPrDashboardConfig;
|
||||
fetchJson: FetchJson;
|
||||
}): Promise<{
|
||||
pulls: GitHubPullRequestRecord[];
|
||||
warnings: GitHubPrDashboardDataWarning[];
|
||||
}> {
|
||||
const perPage = 100;
|
||||
const pulls: GitHubPullRequestRecord[] = [];
|
||||
const warnings: GitHubPrDashboardDataWarning[] = [];
|
||||
for (let page = 1; page <= options.config.maxOpenPages; page += 1) {
|
||||
const pagePulls = await fetchPullsPage({
|
||||
...options,
|
||||
state: "open",
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
pulls.push(...pagePulls);
|
||||
if (pagePulls.length < perPage) break;
|
||||
if (page === options.config.maxOpenPages) {
|
||||
warnings.push({
|
||||
repository: options.repository,
|
||||
type: "open-pr-page-limit",
|
||||
message: `Open PR pagination reached ${options.config.maxOpenPages} pages for ${options.repository}; dashboard counts may be capped. Increase GITHUB_PR_DASHBOARD_MAX_OPEN_PAGES if needed.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { pulls, warnings };
|
||||
}
|
||||
|
||||
async function fetchRecentlyClosedPulls(options: {
|
||||
repository: string;
|
||||
config: GitHubPrDashboardConfig;
|
||||
fetchJson: FetchJson;
|
||||
now: Date;
|
||||
}): Promise<{
|
||||
pulls: GitHubPullRequestRecord[];
|
||||
warnings: GitHubPrDashboardDataWarning[];
|
||||
}> {
|
||||
const perPage = 100;
|
||||
const closedSinceMs =
|
||||
options.now.getTime() - options.config.recentlyClosedDays * 24 * 3_600_000;
|
||||
const pulls: GitHubPullRequestRecord[] = [];
|
||||
const warnings: GitHubPrDashboardDataWarning[] = [];
|
||||
for (let page = 1; page <= options.config.maxClosedPages; page += 1) {
|
||||
const pagePulls = await fetchPullsPage({
|
||||
...options,
|
||||
state: "closed",
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
if (pagePulls.length === 0) break;
|
||||
pulls.push(
|
||||
...pagePulls.filter((pull) => {
|
||||
const closedAt = pull.closedAt ?? pull.mergedAt;
|
||||
return closedAt ? new Date(closedAt).getTime() >= closedSinceMs : false;
|
||||
}),
|
||||
);
|
||||
|
||||
const oldestUpdatedMs = Math.min(
|
||||
...pagePulls.map((pull) => new Date(pull.updatedAt).getTime()),
|
||||
);
|
||||
if (pagePulls.length < perPage || oldestUpdatedMs < closedSinceMs) break;
|
||||
if (page === options.config.maxClosedPages) {
|
||||
warnings.push({
|
||||
repository: options.repository,
|
||||
type: "closed-pr-page-limit",
|
||||
message: `Recently closed PR pagination reached ${options.config.maxClosedPages} pages for ${options.repository}; recently closed counts may be capped. Increase GITHUB_PR_DASHBOARD_MAX_CLOSED_PAGES if needed.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { pulls, warnings };
|
||||
}
|
||||
|
||||
async function fetchRecentActivityPulls(options: {
|
||||
repository: string;
|
||||
config: GitHubPrDashboardConfig;
|
||||
fetchJson: FetchJson;
|
||||
}): Promise<GitHubPullRequestRecord[]> {
|
||||
return fetchPullsPage({
|
||||
...options,
|
||||
state: "all",
|
||||
page: 1,
|
||||
perPage: options.config.maxPullsPerRepo,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizePullRequest(
|
||||
repository: string,
|
||||
pull: GitHubPullApiRecord,
|
||||
): GitHubPullRequestRecord | undefined {
|
||||
if (!Number.isFinite(pull.number)) return undefined;
|
||||
if (!pull.created_at || !pull.updated_at) return undefined;
|
||||
return {
|
||||
number: pull.number,
|
||||
title: pull.title ?? `Pull request #${pull.number}`,
|
||||
url:
|
||||
pull.html_url ?? `https://github.com/${repository}/pull/${pull.number}`,
|
||||
state: pull.state ?? "open",
|
||||
draft: pull.draft === true,
|
||||
author: pull.user?.login ?? "unknown",
|
||||
createdAt: pull.created_at,
|
||||
updatedAt: pull.updated_at,
|
||||
...(pull.closed_at ? { closedAt: pull.closed_at } : {}),
|
||||
...(pull.merged_at ? { mergedAt: pull.merged_at } : {}),
|
||||
requestedReviewers: (pull.requested_reviewers ?? [])
|
||||
.map((reviewer) => reviewer.login)
|
||||
.filter((login): login is string => Boolean(login)),
|
||||
requestedTeams: (pull.requested_teams ?? [])
|
||||
.map((team) => team.slug ?? team.name)
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeReview(
|
||||
repository: string,
|
||||
prNumber: number,
|
||||
review: GitHubReviewApiRecord,
|
||||
): GitHubPullRequestReviewRecord | undefined {
|
||||
if (!review.submitted_at || !review.user?.login) return undefined;
|
||||
return {
|
||||
repository,
|
||||
prNumber,
|
||||
reviewer: review.user.login,
|
||||
state: review.state ?? "COMMENTED",
|
||||
submittedAt: review.submitted_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGitHubPrDashboardData(options: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetchJson?: FetchJson;
|
||||
now?: Date;
|
||||
}): Promise<{
|
||||
config: GitHubPrDashboardConfig;
|
||||
pullsByRepo: Record<string, GitHubPullRequestRecord[]>;
|
||||
reviewsByRepo: Record<string, GitHubPullRequestReviewRecord[]>;
|
||||
warnings: GitHubPrDashboardDataWarning[];
|
||||
}> {
|
||||
const config = resolveGitHubPrDashboardConfig(options.env ?? process.env);
|
||||
const fetchJson = options.fetchJson ?? defaultFetchJson;
|
||||
const now = options.now ?? new Date();
|
||||
const pullsByRepo: Record<string, GitHubPullRequestRecord[]> = {};
|
||||
const reviewsByRepo: Record<string, GitHubPullRequestReviewRecord[]> = {};
|
||||
const warnings: GitHubPrDashboardDataWarning[] = [];
|
||||
|
||||
for (const repository of config.repositories) {
|
||||
// Open PR count must be exact, so fetch and paginate open PRs separately.
|
||||
// Review calls remain bounded to the recent activity sample to avoid one
|
||||
// extra API request per open PR on large repositories.
|
||||
const [openPullsResult, recentlyClosedPullsResult, recentActivityPulls] =
|
||||
await Promise.all([
|
||||
fetchAllOpenPulls({ repository, config, fetchJson }),
|
||||
fetchRecentlyClosedPulls({ repository, config, fetchJson, now }),
|
||||
fetchRecentActivityPulls({ repository, config, fetchJson }),
|
||||
]);
|
||||
warnings.push(...openPullsResult.warnings);
|
||||
warnings.push(...recentlyClosedPullsResult.warnings);
|
||||
const pulls = mergePullsByNumber(
|
||||
mergePullsByNumber(
|
||||
openPullsResult.pulls,
|
||||
recentlyClosedPullsResult.pulls,
|
||||
),
|
||||
recentActivityPulls,
|
||||
);
|
||||
pullsByRepo[repository] = pulls;
|
||||
|
||||
const reviews: GitHubPullRequestReviewRecord[] = [];
|
||||
for (const pull of recentActivityPulls) {
|
||||
const reviewsPayload = await fetchJson(
|
||||
`https://api.github.com/repos/${repository}/pulls/${pull.number}/reviews?per_page=100`,
|
||||
{ headers: headersFor(config) },
|
||||
);
|
||||
if (!Array.isArray(reviewsPayload)) continue;
|
||||
for (const review of reviewsPayload as GitHubReviewApiRecord[]) {
|
||||
const normalized = normalizeReview(repository, pull.number, review);
|
||||
if (normalized) reviews.push(normalized);
|
||||
}
|
||||
}
|
||||
reviewsByRepo[repository] = reviews;
|
||||
}
|
||||
|
||||
return { config, pullsByRepo, reviewsByRepo, warnings };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { AgentPlugin, BasicLogger } from "@cline/core";
|
||||
import { makeDashboardHandoffMessage } from "./format";
|
||||
import {
|
||||
markGitHubPrDashboardSnapshotApplied,
|
||||
runGitHubPrDashboardGate,
|
||||
} from "./gate";
|
||||
|
||||
let setupLogger: BasicLogger | undefined;
|
||||
|
||||
interface PendingDashboardHandoff {
|
||||
text: string;
|
||||
snapshotHash: string;
|
||||
statePath: string;
|
||||
injected: boolean;
|
||||
}
|
||||
|
||||
const pendingDashboardHandoffs = new Map<string, PendingDashboardHandoff>();
|
||||
|
||||
export function resolveDashboardHandoffKey(snapshot: {
|
||||
runId?: string;
|
||||
conversationId?: string;
|
||||
agentId: string;
|
||||
}): string {
|
||||
return snapshot.runId ?? snapshot.conversationId ?? snapshot.agentId;
|
||||
}
|
||||
|
||||
const plugin: AgentPlugin = {
|
||||
name: "github-pr-dashboard-gate",
|
||||
manifest: {
|
||||
capabilities: ["hooks"],
|
||||
},
|
||||
|
||||
setup(_api, ctx) {
|
||||
setupLogger = ctx.logger;
|
||||
},
|
||||
|
||||
hooks: {
|
||||
async beforeRun({ snapshot }) {
|
||||
const result = await runGitHubPrDashboardGate({ logger: setupLogger });
|
||||
const key = resolveDashboardHandoffKey(snapshot);
|
||||
if (result.stop) {
|
||||
pendingDashboardHandoffs.delete(key);
|
||||
return { stop: true, reason: result.reason };
|
||||
}
|
||||
if (result.handoffText) {
|
||||
pendingDashboardHandoffs.set(key, {
|
||||
text: result.handoffText,
|
||||
snapshotHash: result.snapshotHash ?? "",
|
||||
statePath: result.statePath ?? "",
|
||||
injected: false,
|
||||
});
|
||||
} else {
|
||||
pendingDashboardHandoffs.delete(key);
|
||||
}
|
||||
return { reason: result.reason };
|
||||
},
|
||||
|
||||
beforeModel({ request, snapshot }) {
|
||||
const key = resolveDashboardHandoffKey(snapshot);
|
||||
const pending = pendingDashboardHandoffs.get(key);
|
||||
if (!pending || pending.injected) return undefined;
|
||||
pending.injected = true;
|
||||
return {
|
||||
messages: [
|
||||
...request.messages,
|
||||
makeDashboardHandoffMessage(pending.text),
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
afterRun({ result, snapshot }) {
|
||||
const key = resolveDashboardHandoffKey(snapshot);
|
||||
const pending = pendingDashboardHandoffs.get(key);
|
||||
if (result.status !== "completed") {
|
||||
pendingDashboardHandoffs.delete(key);
|
||||
return;
|
||||
}
|
||||
if (!pending?.snapshotHash || !pending.statePath) return;
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: pending.snapshotHash,
|
||||
statePath: pending.statePath,
|
||||
});
|
||||
pendingDashboardHandoffs.delete(key);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { plugin };
|
||||
export default plugin;
|
||||
export {
|
||||
formatDashboardHandoff,
|
||||
renderDashboardHtml,
|
||||
renderDashboardMarkdown,
|
||||
} from "./format";
|
||||
export type {
|
||||
ApplyGitHubPrDashboardSnapshotOptions,
|
||||
GitHubPrDashboardGateOptions,
|
||||
GitHubPrDashboardGateResult,
|
||||
} from "./gate";
|
||||
export {
|
||||
markGitHubPrDashboardSnapshotApplied,
|
||||
runGitHubPrDashboardGate,
|
||||
} from "./gate";
|
||||
export {
|
||||
fetchGitHubPrDashboardData,
|
||||
normalizePullRequest,
|
||||
normalizeReview,
|
||||
} from "./github";
|
||||
export { buildDashboardSnapshot, hashDashboardSnapshot } from "./metrics";
|
||||
export type { GitHubPrDashboardRun, GitHubPrDashboardSnapshot } from "./schema";
|
||||
export type { GitHubPrDashboardState } from "./state";
|
||||
@@ -0,0 +1,252 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
GitHubPrDashboardSnapshot,
|
||||
GitHubPullRequestRecord,
|
||||
GitHubPullRequestReviewRecord,
|
||||
} from "./schema";
|
||||
|
||||
function timeMs(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function hoursBetween(start: string, end: Date): number {
|
||||
const startMs = timeMs(start) ?? end.getTime();
|
||||
return Math.max(0, (end.getTime() - startMs) / 3_600_000);
|
||||
}
|
||||
|
||||
function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function average(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return round1(values.reduce((sum, value) => sum + value, 0) / values.length);
|
||||
}
|
||||
|
||||
function dateKey(value: string): string {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function increment(map: Map<string, number>, key: string, amount = 1): void {
|
||||
map.set(key, (map.get(key) ?? 0) + amount);
|
||||
}
|
||||
|
||||
function topCounts(
|
||||
map: Map<string, number>,
|
||||
): Array<{ login: string; count: number }> {
|
||||
return [...map.entries()]
|
||||
.map(([login, count]) => ({ login, count }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.count - left.count || left.login.localeCompare(right.login),
|
||||
)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function countByAuthor(
|
||||
pulls: Array<{ author: string; createdAt: string }>,
|
||||
sinceMs: number,
|
||||
): Array<{ login: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const pull of pulls) {
|
||||
const createdMs = timeMs(pull.createdAt);
|
||||
if (createdMs !== undefined && createdMs >= sinceMs)
|
||||
increment(counts, pull.author);
|
||||
}
|
||||
return topCounts(counts);
|
||||
}
|
||||
|
||||
function countByReviewer(
|
||||
reviews: GitHubPullRequestReviewRecord[],
|
||||
sinceMs: number,
|
||||
): Array<{ login: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
const unique = new Set<string>();
|
||||
for (const review of reviews) {
|
||||
const submittedMs = timeMs(review.submittedAt);
|
||||
if (submittedMs === undefined || submittedMs < sinceMs) continue;
|
||||
const key = `${review.repository}:${review.prNumber}:${review.reviewer}`;
|
||||
if (unique.has(key)) continue;
|
||||
unique.add(key);
|
||||
increment(counts, review.reviewer);
|
||||
}
|
||||
return topCounts(counts);
|
||||
}
|
||||
|
||||
function emptyTrend(now: Date, trendDays: number) {
|
||||
return Array.from({ length: trendDays }, (_, index) => {
|
||||
const date = new Date(now);
|
||||
date.setUTCDate(date.getUTCDate() - (trendDays - index - 1));
|
||||
return {
|
||||
date: date.toISOString().slice(0, 10),
|
||||
opened: 0,
|
||||
closed: 0,
|
||||
merged: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDashboardSnapshot(input: {
|
||||
generatedAt: Date;
|
||||
repositories: string[];
|
||||
pullsByRepo: Record<string, GitHubPullRequestRecord[]>;
|
||||
reviewsByRepo: Record<string, GitHubPullRequestReviewRecord[]>;
|
||||
newPrHours: number;
|
||||
recentlyClosedDays: number;
|
||||
trendDays: number;
|
||||
}): GitHubPrDashboardSnapshot {
|
||||
const now = input.generatedAt;
|
||||
const newSinceMs = now.getTime() - input.newPrHours * 3_600_000;
|
||||
const closedSinceMs =
|
||||
now.getTime() - input.recentlyClosedDays * 24 * 3_600_000;
|
||||
const weekSinceMs = now.getTime() - 7 * 24 * 3_600_000;
|
||||
const monthSinceMs = now.getTime() - 30 * 24 * 3_600_000;
|
||||
const trendSinceMs = now.getTime() - input.trendDays * 24 * 3_600_000;
|
||||
|
||||
const allPulls = input.repositories.flatMap((repository) =>
|
||||
(input.pullsByRepo[repository] ?? []).map((pull) => ({ repository, pull })),
|
||||
);
|
||||
const allReviews = input.repositories.flatMap(
|
||||
(repository) => input.reviewsByRepo[repository] ?? [],
|
||||
);
|
||||
const openPulls = allPulls.filter(({ pull }) => pull.state === "open");
|
||||
const newOpenPulls = openPulls.filter(
|
||||
({ pull }) => (timeMs(pull.createdAt) ?? 0) >= newSinceMs,
|
||||
);
|
||||
const recentlyClosedPulls = allPulls.filter(({ pull }) => {
|
||||
const closedMs = timeMs(pull.closedAt ?? pull.mergedAt);
|
||||
return closedMs !== undefined && closedMs >= closedSinceMs;
|
||||
});
|
||||
|
||||
const waitingForReview = openPulls
|
||||
.filter(
|
||||
({ pull }) =>
|
||||
!pull.draft &&
|
||||
(pull.requestedReviewers.length > 0 || pull.requestedTeams.length > 0),
|
||||
)
|
||||
.map(({ repository, pull }) => ({
|
||||
repository,
|
||||
number: pull.number,
|
||||
title: pull.title,
|
||||
url: pull.url,
|
||||
author: pull.author,
|
||||
waitingHours: round1(hoursBetween(pull.createdAt, now)),
|
||||
requestedReviewers: pull.requestedReviewers,
|
||||
requestedTeams: pull.requestedTeams,
|
||||
updatedAt: pull.updatedAt,
|
||||
}))
|
||||
.sort((left, right) => right.waitingHours - left.waitingHours)
|
||||
.slice(0, 25);
|
||||
|
||||
const trend = emptyTrend(now, input.trendDays);
|
||||
const trendByDate = new Map(trend.map((day) => [day.date, day]));
|
||||
for (const { pull } of allPulls) {
|
||||
const createdMs = timeMs(pull.createdAt);
|
||||
if (createdMs !== undefined && createdMs >= trendSinceMs) {
|
||||
const day = trendByDate.get(dateKey(pull.createdAt));
|
||||
if (day) day.opened += 1;
|
||||
}
|
||||
const closedAt = pull.closedAt ?? pull.mergedAt;
|
||||
const closedMs = timeMs(closedAt);
|
||||
if (closedAt && closedMs !== undefined && closedMs >= trendSinceMs) {
|
||||
const day = trendByDate.get(dateKey(closedAt));
|
||||
if (day) {
|
||||
day.closed += 1;
|
||||
if (pull.mergedAt) day.merged += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const repositories = input.repositories.map((repository) => {
|
||||
const pulls = input.pullsByRepo[repository] ?? [];
|
||||
const repoOpen = pulls.filter((pull) => pull.state === "open");
|
||||
const repoWaiting = repoOpen.filter(
|
||||
(pull) =>
|
||||
!pull.draft &&
|
||||
(pull.requestedReviewers.length > 0 || pull.requestedTeams.length > 0),
|
||||
);
|
||||
return {
|
||||
repository,
|
||||
openCount: repoOpen.length,
|
||||
newOpenCount: repoOpen.filter(
|
||||
(pull) => (timeMs(pull.createdAt) ?? 0) >= newSinceMs,
|
||||
).length,
|
||||
recentlyClosedCount: pulls.filter((pull) => {
|
||||
const closedMs = timeMs(pull.closedAt ?? pull.mergedAt);
|
||||
return closedMs !== undefined && closedMs >= closedSinceMs;
|
||||
}).length,
|
||||
avgOpenAgeHours: average(
|
||||
repoOpen.map((pull) => hoursBetween(pull.createdAt, now)),
|
||||
),
|
||||
avgWaitingForReviewHours: average(
|
||||
repoWaiting.map((pull) => hoursBetween(pull.createdAt, now)),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
repositories: input.repositories,
|
||||
window: {
|
||||
newPrHours: input.newPrHours,
|
||||
recentlyClosedDays: input.recentlyClosedDays,
|
||||
trendDays: input.trendDays,
|
||||
},
|
||||
summary: {
|
||||
openCount: openPulls.length,
|
||||
newOpenCount: newOpenPulls.length,
|
||||
recentlyClosedCount: recentlyClosedPulls.length,
|
||||
avgOpenAgeHours: average(
|
||||
openPulls.map(({ pull }) => hoursBetween(pull.createdAt, now)),
|
||||
),
|
||||
avgWaitingForReviewHours: average(
|
||||
waitingForReview.map((pull) => pull.waitingHours),
|
||||
),
|
||||
},
|
||||
waitingForReview,
|
||||
volumeTrend: trend,
|
||||
leadingAuthors: {
|
||||
week: countByAuthor(
|
||||
allPulls.map(({ pull }) => pull),
|
||||
weekSinceMs,
|
||||
),
|
||||
month: countByAuthor(
|
||||
allPulls.map(({ pull }) => pull),
|
||||
monthSinceMs,
|
||||
),
|
||||
},
|
||||
leadingReviewers: {
|
||||
week: countByReviewer(allReviews, weekSinceMs),
|
||||
month: countByReviewer(allReviews, monthSinceMs),
|
||||
},
|
||||
repositoryBreakdown: repositories,
|
||||
};
|
||||
}
|
||||
|
||||
export function hashDashboardSnapshot(
|
||||
snapshot: GitHubPrDashboardSnapshot,
|
||||
): string {
|
||||
const stableSnapshot = {
|
||||
...snapshot,
|
||||
generatedAt: undefined,
|
||||
summary: {
|
||||
...snapshot.summary,
|
||||
avgOpenAgeHours: 0,
|
||||
avgWaitingForReviewHours: 0,
|
||||
},
|
||||
waitingForReview: snapshot.waitingForReview.map((pull) => ({
|
||||
...pull,
|
||||
waitingHours: 0,
|
||||
})),
|
||||
repositoryBreakdown: snapshot.repositoryBreakdown.map((repository) => ({
|
||||
...repository,
|
||||
avgOpenAgeHours: 0,
|
||||
avgWaitingForReviewHours: 0,
|
||||
})),
|
||||
};
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(stableSnapshot))
|
||||
.digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, extname, resolve } from "node:path";
|
||||
import { renderDashboardHtml, renderDashboardMarkdown } from "./format";
|
||||
import {
|
||||
markGitHubPrDashboardSnapshotApplied,
|
||||
runGitHubPrDashboardGate,
|
||||
} from "./gate";
|
||||
|
||||
function readFlag(name: string): string | undefined {
|
||||
const args = process.argv.slice(2);
|
||||
const prefix = `${name}=`;
|
||||
const inline = args.find((arg) => arg.startsWith(prefix));
|
||||
if (inline) return inline.slice(prefix.length).trim() || undefined;
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return undefined;
|
||||
const value = args[index + 1];
|
||||
return value && !value.startsWith("--")
|
||||
? value.trim() || undefined
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function applyCliOverrides(): void {
|
||||
const repositories = readFlag("--repo") ?? readFlag("--repos");
|
||||
if (repositories) process.env.GITHUB_REPOSITORIES = repositories;
|
||||
|
||||
const markdownPath = readFlag("--output");
|
||||
if (markdownPath) process.env.GITHUB_PR_DASHBOARD_PATH = markdownPath;
|
||||
|
||||
const htmlPath = readFlag("--html-output");
|
||||
if (htmlPath) process.env.GITHUB_PR_DASHBOARD_HTML_PATH = htmlPath;
|
||||
|
||||
const statePath = readFlag("--state");
|
||||
if (statePath) process.env.GITHUB_PR_DASHBOARD_STATE_PATH = statePath;
|
||||
|
||||
const maxRecent = readFlag("--max-recent");
|
||||
if (maxRecent) process.env.GITHUB_PR_DASHBOARD_MAX_PRS = maxRecent;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`GitHub PR Dashboard preview
|
||||
|
||||
Fetch GitHub PR metrics and write a dashboard Markdown + HTML file without
|
||||
starting an agent/model. This uses the same deterministic gate as the plugin.
|
||||
|
||||
Minimal usage:
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo owner/repo
|
||||
|
||||
Recommended when GitHub rate-limits unauthenticated requests:
|
||||
GITHUB_TOKEN=$(gh auth token) bun -F cline-github-pr-dashboard-plugin run-once -- --repo owner/repo
|
||||
|
||||
Advanced optional env:
|
||||
GITHUB_PR_DASHBOARD_PATH=github-pr-dashboard.md
|
||||
GITHUB_PR_DASHBOARD_HTML_PATH=github-pr-dashboard.html
|
||||
GITHUB_PR_DASHBOARD_STATE_PATH=/tmp/github-pr-dashboard-state.json
|
||||
GITHUB_PR_DASHBOARD_MAX_PRS=25 # recent activity sample size, not open PR cap
|
||||
GITHUB_PR_DASHBOARD_NEW_HOURS=24
|
||||
GITHUB_PR_DASHBOARD_RECENTLY_CLOSED_DAYS=7
|
||||
GITHUB_PR_DASHBOARD_TREND_DAYS=14
|
||||
|
||||
Usage:
|
||||
GITHUB_TOKEN="$(gh auth token)" \
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline --open
|
||||
|
||||
bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline
|
||||
|
||||
Flags:
|
||||
--repo owner/repo[,owner/repo] Repositories to inspect. Also accepts --repos.
|
||||
--output path Markdown output path.
|
||||
--html-output path HTML output path.
|
||||
--state path State/cache file path.
|
||||
--max-recent count Recent activity sample size for review details.
|
||||
--open Open the generated HTML dashboard on macOS.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function resolveOutputPath(path: string): string {
|
||||
return resolve(process.cwd(), path);
|
||||
}
|
||||
|
||||
function defaultHtmlPath(markdownPath: string): string {
|
||||
const ext = extname(markdownPath);
|
||||
return ext
|
||||
? `${markdownPath.slice(0, -ext.length)}.html`
|
||||
: `${markdownPath}.html`;
|
||||
}
|
||||
|
||||
function writeTextFile(path: string, text: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, text);
|
||||
}
|
||||
|
||||
async function openIfRequested(
|
||||
path: string,
|
||||
requested: boolean,
|
||||
): Promise<void> {
|
||||
if (!requested) return;
|
||||
if (process.platform !== "darwin") {
|
||||
console.warn(
|
||||
"--open is only implemented for macOS; open the HTML path manually.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const child = Bun.spawn(["open", path], {
|
||||
stdout: "ignore",
|
||||
stderr: "inherit",
|
||||
});
|
||||
await child.exited;
|
||||
}
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
if (args.has("--help") || args.has("-h")) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
applyCliOverrides();
|
||||
|
||||
if (!process.env.GITHUB_REPOSITORIES?.trim()) {
|
||||
console.error(
|
||||
"Missing repository. Pass --repo owner/repo or set GITHUB_REPOSITORIES=owner/repo[,owner/repo].",
|
||||
);
|
||||
console.error(
|
||||
'Example: GITHUB_TOKEN="$(gh auth token)" bun -F cline-github-pr-dashboard-plugin run-once -- --repo cline/cline --open',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await runGitHubPrDashboardGate();
|
||||
|
||||
if (!result.snapshot || !result.dashboardPath) {
|
||||
throw new Error(
|
||||
"GitHub PR dashboard gate did not return a dashboard snapshot",
|
||||
);
|
||||
}
|
||||
|
||||
const markdownPath = resolveOutputPath(result.dashboardPath);
|
||||
const htmlPath = resolveOutputPath(
|
||||
process.env.GITHUB_PR_DASHBOARD_HTML_PATH?.trim() ||
|
||||
defaultHtmlPath(result.dashboardPath),
|
||||
);
|
||||
|
||||
writeTextFile(markdownPath, renderDashboardMarkdown(result.snapshot));
|
||||
writeTextFile(
|
||||
htmlPath,
|
||||
renderDashboardHtml(result.snapshot, {
|
||||
checkpointStatus: result.stop ? "unchanged" : "changed",
|
||||
checkpointReason: result.reason,
|
||||
changeSummary: result.changeSummary,
|
||||
snapshotHash: result.snapshotHash,
|
||||
}),
|
||||
);
|
||||
if (result.snapshotHash) {
|
||||
markGitHubPrDashboardSnapshotApplied({
|
||||
snapshotHash: result.snapshotHash,
|
||||
statePath: result.statePath,
|
||||
});
|
||||
}
|
||||
await openIfRequested(htmlPath, args.has("--open"));
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
changed: result.stop !== true,
|
||||
stop: result.stop ?? false,
|
||||
reason: result.reason,
|
||||
snapshotHash: result.snapshotHash,
|
||||
markdownPath,
|
||||
htmlPath,
|
||||
summary: result.snapshot.summary,
|
||||
changes: result.changeSummary,
|
||||
warnings: result.warnings?.map((warning) => warning.message),
|
||||
next: args.has("--open")
|
||||
? undefined
|
||||
: `Open ${htmlPath} in a browser to view the dashboard.`,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface GitHubUserRef {
|
||||
login: string;
|
||||
}
|
||||
|
||||
export interface GitHubPullRequestRecord {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: "open" | "closed" | string;
|
||||
draft: boolean;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
closedAt?: string;
|
||||
mergedAt?: string;
|
||||
requestedReviewers: string[];
|
||||
requestedTeams: string[];
|
||||
}
|
||||
|
||||
export interface GitHubPullRequestReviewRecord {
|
||||
repository: string;
|
||||
prNumber: number;
|
||||
reviewer: string;
|
||||
state: string;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
export interface GitHubPrDashboardSnapshot {
|
||||
generatedAt: string;
|
||||
repositories: string[];
|
||||
window: {
|
||||
newPrHours: number;
|
||||
recentlyClosedDays: number;
|
||||
trendDays: number;
|
||||
};
|
||||
summary: {
|
||||
openCount: number;
|
||||
newOpenCount: number;
|
||||
recentlyClosedCount: number;
|
||||
avgOpenAgeHours: number;
|
||||
avgWaitingForReviewHours: number;
|
||||
};
|
||||
waitingForReview: Array<{
|
||||
repository: string;
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
author: string;
|
||||
waitingHours: number;
|
||||
requestedReviewers: string[];
|
||||
requestedTeams: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
volumeTrend: Array<{
|
||||
date: string;
|
||||
opened: number;
|
||||
closed: number;
|
||||
merged: number;
|
||||
}>;
|
||||
leadingAuthors: {
|
||||
week: Array<{ login: string; count: number }>;
|
||||
month: Array<{ login: string; count: number }>;
|
||||
};
|
||||
leadingReviewers: {
|
||||
week: Array<{ login: string; count: number }>;
|
||||
month: Array<{ login: string; count: number }>;
|
||||
};
|
||||
repositoryBreakdown: Array<{
|
||||
repository: string;
|
||||
openCount: number;
|
||||
newOpenCount: number;
|
||||
recentlyClosedCount: number;
|
||||
avgOpenAgeHours: number;
|
||||
avgWaitingForReviewHours: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GitHubPrDashboardRun {
|
||||
runId: string;
|
||||
snapshotHash: string;
|
||||
dashboardPath: string;
|
||||
snapshot: GitHubPrDashboardSnapshot;
|
||||
changeSummary: string[];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { GitHubPrDashboardSnapshot } from "./schema";
|
||||
|
||||
export interface GitHubPrDashboardState {
|
||||
version: 1;
|
||||
lastSnapshotHash?: string;
|
||||
lastGeneratedAt?: string;
|
||||
lastSnapshot?: GitHubPrDashboardSnapshot;
|
||||
pendingSnapshotHash?: string;
|
||||
pendingGeneratedAt?: string;
|
||||
pendingSnapshot?: GitHubPrDashboardSnapshot;
|
||||
}
|
||||
|
||||
export const EMPTY_GITHUB_PR_DASHBOARD_STATE: GitHubPrDashboardState = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
function dataDirFromEnv(env: NodeJS.ProcessEnv): string {
|
||||
return env.CLINE_DATA_DIR?.trim() || join(homedir(), ".cline", "data");
|
||||
}
|
||||
|
||||
export function resolveStatePath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return (
|
||||
env.GITHUB_PR_DASHBOARD_STATE_PATH?.trim() ||
|
||||
join(dataDirFromEnv(env), "plugins", "github-pr-dashboard", "state.json")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeState(value: unknown): GitHubPrDashboardState {
|
||||
if (!value || typeof value !== "object") return { version: 1 };
|
||||
const input = value as Partial<GitHubPrDashboardState>;
|
||||
return {
|
||||
version: 1,
|
||||
...(typeof input.lastSnapshotHash === "string"
|
||||
? { lastSnapshotHash: input.lastSnapshotHash }
|
||||
: {}),
|
||||
...(typeof input.lastGeneratedAt === "string"
|
||||
? { lastGeneratedAt: input.lastGeneratedAt }
|
||||
: {}),
|
||||
...(input.lastSnapshot && typeof input.lastSnapshot === "object"
|
||||
? { lastSnapshot: input.lastSnapshot }
|
||||
: {}),
|
||||
...(typeof input.pendingSnapshotHash === "string"
|
||||
? { pendingSnapshotHash: input.pendingSnapshotHash }
|
||||
: {}),
|
||||
...(typeof input.pendingGeneratedAt === "string"
|
||||
? { pendingGeneratedAt: input.pendingGeneratedAt }
|
||||
: {}),
|
||||
...(input.pendingSnapshot && typeof input.pendingSnapshot === "object"
|
||||
? { pendingSnapshot: input.pendingSnapshot }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function markSnapshotApplied(
|
||||
state: GitHubPrDashboardState,
|
||||
snapshotHash: string,
|
||||
): GitHubPrDashboardState {
|
||||
if (state.pendingSnapshotHash !== snapshotHash || !state.pendingSnapshot) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
lastSnapshotHash: state.pendingSnapshotHash,
|
||||
lastGeneratedAt: state.pendingGeneratedAt,
|
||||
lastSnapshot: state.pendingSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export function readState(path = resolveStatePath()): GitHubPrDashboardState {
|
||||
if (!existsSync(path)) return { version: 1 };
|
||||
try {
|
||||
return normalizeState(JSON.parse(readFileSync(path, "utf8")));
|
||||
} catch {
|
||||
return { version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeState(
|
||||
state: GitHubPrDashboardState,
|
||||
path = resolveStatePath(),
|
||||
): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify(normalizeState(state), null, 2)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user