Compare commits

...
17 changed files with 2669 additions and 0 deletions
+1
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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"],
},
});