diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8ec3ea4..4c80a2f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ on: description: "Package to publish (only used when run directly, not via release)" type: choice default: context-tree - options: [context-tree] + options: [context-tree, git-stats] permissions: id-token: write # mint the OIDC token npm verifies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cfe31a..bd0c418 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ on: description: "Package to release" type: choice default: context-tree - options: [context-tree] + options: [context-tree, git-stats] bump: description: "Version bump relative to the latest -v* tag" type: choice diff --git a/README.md b/README.md index be24190..a607308 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ plugins. Each publishable package lives under `packages//` with its own | Package | Description | |---|---| | [`packages/context-tree`](packages/context-tree/README.md) | Pi-style context tree for OpenCode: branch, merge, crop, undo, plus a trajectory view | +| [`packages/git-stats`](packages/git-stats/README.md) | Sidebar card: working-tree diff figures, plus a GitHub-coloured chip per pull request the session touched | To add a new plugin, create `packages//` with its own `package.json` and release scripts, then add it to the `workflow_dispatch.inputs.package` choice diff --git a/bun.lock b/bun.lock index 4a0c719..19c130b 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,34 @@ "solid-js", ], }, + "packages/git-stats": { + "name": "opencode-git-stats", + "version": "0.0.0-dev", + "dependencies": { + "@opencode-ai/plugin": "1.18.26", + "@opencode-ai/sdk": "1.18.26", + }, + "devDependencies": { + "@opentui/core": "^0.5.10", + "@opentui/keymap": "^0.5.10", + "@opentui/solid": "^0.5.10", + "@types/bun": "latest", + "solid-js": "^1.9.12", + "typescript": "^5.8.2", + }, + "peerDependencies": { + "@opentui/core": ">=0.5.10", + "@opentui/keymap": ">=0.5.10", + "@opentui/solid": ">=0.5.10", + "solid-js": ">=1.9.12", + }, + "optionalPeers": [ + "@opentui/core", + "@opentui/keymap", + "@opentui/solid", + "solid-js", + ], + }, }, "packages": { "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], @@ -263,6 +291,8 @@ "opencode-context-tree": ["opencode-context-tree@workspace:packages/context-tree"], + "opencode-git-stats": ["opencode-git-stats@workspace:packages/git-stats"], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], diff --git a/packages/git-stats/CHANGELOG.md b/packages/git-stats/CHANGELOG.md new file mode 100644 index 0000000..958fa5f --- /dev/null +++ b/packages/git-stats/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +## 0.1.0 — 2026-09-10 + +First release. + +A `sidebar_content` card, at order 450 so it sits directly above the host's own +**Modified Files** list, holding two things: + +- **The working tree's figures** — `+123 -45 · 7 files` for the whole folder, from + `client.vcs.status()`, refreshed on OpenCode's `session.diff` and `session.idle` + events and on a 10 s tick so an edit made in another terminal shows up too. This + is deliberately *not* `state.session.diff()`, which accumulates only the files + the current session touched. + +- **A chip per pull request the session touched** — `#20 Merged`, painted with + GitHub's own state colours (`#59636e` draft, `#1f883d` open, `#cf222e` closed, + `#8250df` merged) and white text, the way the badge on the pull request page is. + Sightings come from completed tool parts (`state.session.messages()` + + `state.part()`), so a chip only exists because a command actually printed the + URL; the state behind it comes from `gh pr view --json state,isDraft`, the one + question OpenCode's API cannot answer. A merged PR is never re-fetched, and a + `gh` that is missing or logged out is reported in the card once instead of being + retried every tick. + +Chips close: click the `×`, or run `/prs` to hide one, hide all, or bring the +hidden ones back. Dismissals live in `kv`, keyed per session, so they survive a +restart and do not leak between sessions. Each chip's label is also an OSC 8 +hyperlink to the pull request. + +Two limits worth knowing about. Only `github.com` is trusted unless a host is +named in the plugin's `hosts` option: the URLs are found in *tool output*, which +can carry a `github.evil.example` a fetched page put there, and the host goes +straight to `gh --repo`, which would treat an unknown one as Enterprise and send +it a request with an Enterprise token attached. And at most 24 chips are tracked +per session, so a `gh pr list --json url` cannot mint a chip — and a `gh` +process — per pull request in the repository. Failing `gh` calls back off +exponentially to about 16 minutes, and reset at the end of a turn in case the +user has just installed or logged into it. diff --git a/packages/git-stats/LICENSE b/packages/git-stats/LICENSE new file mode 100644 index 0000000..3076d67 --- /dev/null +++ b/packages/git-stats/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Naveen (navbytes) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/git-stats/README.md b/packages/git-stats/README.md new file mode 100644 index 0000000..f04406f --- /dev/null +++ b/packages/git-stats/README.md @@ -0,0 +1,134 @@ +
+ +# opencode-git-stats + +**What the working tree looks like right now, and a GitHub-coloured chip for every +pull request your [OpenCode](https://opencode.ai) session touched.** + +[![npm](https://img.shields.io/npm/v/opencode-git-stats?color=cb3837&logo=npm)](https://www.npmjs.com/package/opencode-git-stats) +[![CI](https://github.com/navbytes/opencode-tree/actions/workflows/ci.yml/badge.svg)](https://github.com/navbytes/opencode-tree/actions/workflows/ci.yml) +[![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) +[![OpenCode](https://img.shields.io/badge/OpenCode-%E2%89%A5%201.18-black)](https://opencode.ai) + +
+ +``` +Git Stats +⎇ fix-release-prerelease-bump ++123 -45 · 7 files + #20 Merged × #21 Draft × +``` + + +- **Working-tree figures** — total additions, deletions and changed files for the + whole folder, not just the files this session edited. They come from OpenCode's + own VCS status endpoint, so they agree with `git status`. +- **Pull request chips** — every GitHub PR whose URL turned up in this session's + tool output, with GitHub's own state colour: grey **Draft**, green **Open**, + red **Closed**, purple **Merged**. States refresh on their own as the session + runs, so a PR you merge mid-session turns purple without a restart. +- **Close a chip** — click the `×` beside it, or run `/prs` to hide one, hide them + all, or bring the hidden ones back. Dismissals are per session and survive a + restart. +- **Open the PR** — each chip is a terminal hyperlink, so clicking its label opens + the pull request in your browser (in terminals that support OSC 8; elsewhere the + chip is simply plain text). + +## Install + +This is a **TUI** plugin, so it goes in `tui.json` (not `opencode.json`): + +`~/.config/opencode/tui.json`, or `.opencode/tui.json` in a project — + +```json +{ + "$schema": "https://opencode.ai/tui.json", + "plugin": ["opencode-git-stats@latest"] +} +``` + +Restart OpenCode. The card appears in the sidebar, just above **Modified Files**. + +Requires OpenCode ≥ 1.18. Chip *states* additionally need the +[GitHub CLI](https://cli.github.com) on your `PATH` and logged in (`gh auth +login`); without it the chips still appear, uncoloured, and the card says why. + +## What counts as "a PR this session touched" + +Any `https://github.com///pull/` URL that appears in the +output — or the command line — of a tool call in this session. In practice that +is the URL `gh pr create` prints, plus anything a later `gh pr view` or +`gh pr merge` names. Prose in the assistant's replies is deliberately *not* +scanned: only what a command actually produced. + +Pull requests the session **created** (the URL came out of a `gh pr create`) sort +ahead of ones it merely referenced. At most 24 chips are tracked per session, so +a stray `gh pr list --json url` cannot fill the sidebar. + +### GitHub Enterprise + +Only `github.com` is trusted by default, and that is on purpose: the text being +scanned is tool output, which can come from a fetched page or a pasted file, and +the host in a URL is handed to `gh --repo /…`. `gh` treats an unknown host +as Enterprise and would make a request to it — with your Enterprise token +attached. So `github.evil.example` and `github.com.evil.example` are ignored, and +a real Enterprise host has to be named: + +```json +{ + "plugin": [["opencode-git-stats@latest", { "hosts": ["github.acme.com"] }]] +} +``` + +## Commands + +| Command | Slash | What it does | +|---|---|---| +| `gitstats.chips` | `/prs`, `/chips` | Hide one chip, hide all of them, or show the hidden ones again | + +To put it on a key, add a binding for `gitstats.chips` in your OpenCode keybinds. + +## How it works + +The plugin is TUI-only and reads everything through OpenCode's official plugin +API: + +| What | API | +|---|---| +| working-tree figures | `client.vcs.status()` | +| branch name | `state.vcs.branch` | +| PR sightings | `event.on("message.part.updated")`, plus a catch-up scan of `state.session.messages()` / `state.part()` | +| refresh triggers | `event.on("session.diff")`, `event.on("session.idle")`, a 10 s tick | +| dismissals | `kv` | +| the card | a `sidebar_content` slot at order 450 | + +The one thing OpenCode cannot answer is whether a pull request is draft, open, +closed or merged, so the plugin shells out to `gh pr view --json state,isDraft`. +That is the only external call it makes, and it is spawned with an argv array — +never a shell string — for a host on the allow-list above. + +A merged pull request is terminal and never re-fetched. Open and draft ones age +out after 90 seconds, closed ones after five minutes, and the end of every turn +marks them all due again. A `gh` call that fails backs that chip off +exponentially, up to about 16 minutes, so a missing or logged-out `gh` is not +re-spawned on every tick; the backoff clears at the end of a turn, in case you +have just installed it. Chips you have dismissed are never fetched at all. + +## Development + +```sh +bun install +bun run build # -> dist/tui.js +bun run typecheck +bun test +``` + +To try a local build, point `tui.json` at the built file: + +```json +{ "plugin": ["/absolute/path/to/packages/git-stats/dist/tui.js"] } +``` + +## License + +MIT diff --git a/packages/git-stats/package.json b/packages/git-stats/package.json new file mode 100644 index 0000000..3478f71 --- /dev/null +++ b/packages/git-stats/package.json @@ -0,0 +1,82 @@ +{ + "name": "opencode-git-stats", + "version": "0.0.0-dev", + "private": false, + "type": "module", + "description": "OpenCode sidebar: working-tree diff stats and GitHub-coloured chips for the pull requests your session touched", + "license": "MIT", + "exports": { + "./tui": { + "types": "./src/tui/index.tsx", + "import": "./dist/tui.js" + } + }, + "files": [ + "dist", + "src", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "build": "bun run scripts/build.ts", + "prepack": "bun run build", + "typecheck": "bunx tsc --noEmit -p tsconfig.json", + "test": "bun test" + }, + "engines": { + "opencode": ">=1.18.0" + }, + "dependencies": { + "@opencode-ai/plugin": "1.18.26", + "@opencode-ai/sdk": "1.18.26" + }, + "devDependencies": { + "@opentui/core": "^0.5.10", + "@opentui/keymap": "^0.5.10", + "@opentui/solid": "^0.5.10", + "@types/bun": "latest", + "solid-js": "^1.9.12", + "typescript": "^5.8.2" + }, + "peerDependencies": { + "@opentui/core": ">=0.5.10", + "@opentui/keymap": ">=0.5.10", + "@opentui/solid": ">=0.5.10", + "solid-js": ">=1.9.12" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + }, + "solid-js": { + "optional": true + } + }, + "author": "Naveen (navbytes)", + "repository": { + "type": "git", + "url": "git+https://github.com/navbytes/opencode-tree.git", + "directory": "packages/git-stats" + }, + "homepage": "https://github.com/navbytes/opencode-tree/tree/main/packages/git-stats#readme", + "keywords": [ + "opencode", + "opencode-plugin", + "git", + "diff", + "github", + "pull-request", + "sidebar", + "tui" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/packages/git-stats/scripts/build.ts b/packages/git-stats/scripts/build.ts new file mode 100644 index 0000000..69ec128 --- /dev/null +++ b/packages/git-stats/scripts/build.ts @@ -0,0 +1,23 @@ +/** + * One entry point: src/tui/index.tsx -> dist/tui.js (Solid JSX, via @opentui/solid's + * bun plugin). @opencode-ai/*, @opentui/* and solid-js are provided by the host TUI. + */ +import solidPlugin from "@opentui/solid/bun-plugin" +import pkg from "../package.json" + +const result = await Bun.build({ + entrypoints: ["src/tui/index.tsx"], + outdir: "dist", + target: "bun", + format: "esm", + naming: "tui.js", + plugins: [solidPlugin], + external: ["@opencode-ai/*", "@opentui/*", "solid-js", "solid-js/*"], + define: { __GIT_STATS_VERSION__: JSON.stringify(pkg.version) }, +}) +if (!result.success) { + console.error("build failed: src/tui/index.tsx") + for (const log of result.logs) console.error(log) + process.exit(1) +} +for (const output of result.outputs) console.log(`${output.path} ${output.size}B`) diff --git a/packages/git-stats/src/core/gh.ts b/packages/git-stats/src/core/gh.ts new file mode 100644 index 0000000..534e6a3 --- /dev/null +++ b/packages/git-stats/src/core/gh.ts @@ -0,0 +1,69 @@ +/** + * The one place the plugin leaves OpenCode's API: asking the GitHub CLI what state a + * pull request is in. `gh` is spawned with an argv array (never a shell string), and only + * for a host on the caller's allow-list (see `findPrRefs`), so neither a repo name nor a + * host taken from tool output can steer it. + */ +import { execFile } from "node:child_process" +import { stateFromGh, type PrRef, type PrState } from "./pr.js" + +export type GhLookup = { state: PrState; title?: string } + +/** Why the plugin has no state to show — surfaced in the card rather than swallowed. */ +export type GhFailure = "missing" | "unauthenticated" | "timeout" | "error" + +export type GhResult = { ok: true; value: GhLookup } | { ok: false; failure: GhFailure; message: string } + +const TIMEOUT_MS = 10_000 + +type ExecFailure = NodeJS.ErrnoException & { code?: number | string | null; killed?: boolean; signal?: NodeJS.Signals | null } + +type RunResult = { code: number; stdout: string; stderr: string; spawnError?: ExecFailure; killed?: boolean } + +function run(args: string[], cwd: string, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + execFile( + "gh", + args, + { cwd, timeout: TIMEOUT_MS, maxBuffer: 1024 * 1024, signal, env: { ...process.env, GH_PAGER: "", NO_COLOR: "1" } }, + (error, stdout, stderr) => { + const err = error as ExecFailure | null + if (!err) return resolve({ code: 0, stdout, stderr }) + if (err.code === "ENOENT") return resolve({ code: -1, stdout, stderr, spawnError: err }) + // A child killed by the timeout or the abort signal never produced output. Reading + // its absent exit code as 0 would send empty stdout down the success path, and the + // failure would surface as "unreadable JSON" — the runtimes disagree on which of + // `killed` / `signal` / `code` they set, so any of them counts. + const aborted = err.killed || Boolean(err.signal) || err.code === "ABORT_ERR" || err.code === "ETIMEDOUT" || err.name === "AbortError" + if (aborted) return resolve({ code: -1, stdout, stderr, killed: true }) + if (typeof err.code === "string") return resolve({ code: -1, stdout, stderr, spawnError: err }) + resolve({ code: typeof err.code === "number" ? err.code : -1, stdout, stderr }) + }, + ) + }) +} + +/** `gh pr view --repo // --json state,isDraft,title`. */ +export async function fetchPrState(ref: PrRef, cwd: string, signal?: AbortSignal): Promise { + if (signal?.aborted) return { ok: false, failure: "timeout", message: "cancelled" } + const { code, stdout, stderr, spawnError, killed } = await run( + ["pr", "view", String(ref.number), "--repo", `${ref.host}/${ref.owner}/${ref.repo}`, "--json", "state,isDraft,title"], + cwd, + signal, + ) + if (killed) return { ok: false, failure: "timeout", message: signal?.aborted ? "cancelled" : "gh timed out" } + if (spawnError?.code === "ENOENT") return { ok: false, failure: "missing", message: "gh not found on PATH" } + if (spawnError) return { ok: false, failure: "error", message: spawnError.message || String(spawnError.code) } + if (code !== 0) { + const message = (stderr || stdout).trim().split("\n")[0] || `gh exited ${code}` + // `gh` says this whenever the token is absent or lacks the scope for this host. + const unauth = /auth login|not logged|authentication|HTTP 401|gh auth/i.test(message) + return { ok: false, failure: unauth ? "unauthenticated" : "error", message } + } + try { + const parsed = JSON.parse(stdout) as { state?: string; isDraft?: boolean; title?: string } + return { ok: true, value: { state: stateFromGh(parsed), title: parsed.title } } + } catch { + return { ok: false, failure: "error", message: "gh returned unreadable JSON" } + } +} diff --git a/packages/git-stats/src/core/pr.ts b/packages/git-stats/src/core/pr.ts new file mode 100644 index 0000000..c3c8bcb --- /dev/null +++ b/packages/git-stats/src/core/pr.ts @@ -0,0 +1,118 @@ +/** + * Pull-request identity, state and colour — the half of the plugin that is pure + * functions over strings, so it can be tested without a terminal or a network. + */ + +export type PrState = "draft" | "open" | "closed" | "merged" | "unknown" + +/** A pull request as named by a URL: enough to ask `gh` about it again. */ +export type PrRef = { + /** `github.com`, or an Enterprise host. `gh --repo host/owner/repo` accepts both. */ + host: string + owner: string + repo: string + number: number + url: string +} + +export type PrChip = PrRef & { + key: string + state: PrState + title?: string + /** True when we saw the URL come out of a `gh pr create` (as opposed to merely referenced). */ + created: boolean + /** ms epoch of the first sighting — chips keep session order. */ + seen: number + /** ms epoch of the last `gh` attempt, successful or not; 0 before the first one. */ + checked: number + /** Consecutive failed `gh` attempts — what the refresh backoff is computed from. */ + failures: number +} + +export function prKey(ref: Pick): string { + return `${ref.host}/${ref.owner}/${ref.repo}#${ref.number}` +} + +// `/pull/` is followed by nothing, `/files`, `/commits/`, a quote or a `)`, so the +// only thing the trailing guard has to rule out is a longer number. +const PR_URL = /https?:\/\/([\w.-]+)\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?!\d)/g + +/** The hosts trusted with no configuration. */ +export const DEFAULT_HOSTS: readonly string[] = ["github.com", "www.github.com"] + +/** + * Every distinct PR URL in a blob of text, in first-seen order. + * + * `allowed` is an exact-match allow-list, and that is the point: the text being scanned is + * tool output, which is attacker-reachable (a fetched page, a README, a paste). A host + * from it is handed to `gh --repo /…`, and `gh` treats an unknown host as GitHub + * Enterprise — it would make an HTTPS request to whatever it was given, with an Enterprise + * token attached if the user has one. So `github.evil.example` and `github.com.evil.example` + * are dropped, and an Enterprise host has to be named in the plugin's own options. + */ +export function findPrRefs(text: string | undefined, allowed: readonly string[] = DEFAULT_HOSTS): PrRef[] { + if (!text) return [] + const hosts = new Set(allowed.map((h) => h.toLowerCase())) + const out: PrRef[] = [] + const seen = new Set() + for (const m of text.matchAll(PR_URL)) { + const [, host, owner, repo, number] = m as unknown as [string, string, string, string, string] + if (!hosts.has(host.toLowerCase())) continue + const ref: PrRef = { host, owner, repo, number: Number(number), url: `https://${host}/${owner}/${repo}/pull/${number}` } + const key = prKey(ref) + if (seen.has(key)) continue + seen.add(key) + out.push(ref) + } + return out +} + +/** + * Whether a shell command is a `gh pr create`. Used only to mark a chip as *created + * here* rather than merely mentioned; detection of the PR itself is by URL. + */ +export function isPrCreateCommand(command: string | undefined): boolean { + if (!command) return false + // Quoted spans go first, so a commit message that merely *says* "gh pr create" doesn't + // count. `[^;&|\n]*?` keeps the match inside one command of one line — a heredoc body + // that happens to contain both words on different lines is not a `gh pr create` — while + // allowing the flags that can sit between `gh` and its subcommand (`gh --repo a/b pr create`). + const bare = command.replace(/'[^']*'|"[^"]*"/g, " ") + return /(^|[\s;&|(])gh\b[^;&|\n]*?\bpr\s+create\b/.test(bare) +} + +/** `gh pr view --json state,isDraft` → the state GitHub paints. */ +export function stateFromGh(input: { state?: string; isDraft?: boolean } | undefined): PrState { + const raw = input?.state?.toUpperCase() + if (raw === "MERGED") return "merged" + if (raw === "CLOSED") return "closed" + if (raw === "OPEN") return input?.isDraft ? "draft" : "open" + return "unknown" +} + +export const PR_STATE_LABEL: Record = { + draft: "Draft", + open: "Open", + closed: "Closed", + merged: "Merged", + unknown: "…", +} + +/** + * GitHub's own state colours (Primer `*-emphasis` tokens), so a chip here reads as the + * same badge as the one on the PR page. + */ +export const PR_STATE_COLOR: Record, string> = { + draft: "#59636e", + open: "#1f883d", + closed: "#cf222e", + merged: "#8250df", +} + +/** + * Pull requests this session *made* lead; ones it merely referenced follow. Within each + * group, the order the session saw them, then by number. + */ +export function sortChips(chips: readonly PrChip[]): PrChip[] { + return [...chips].sort((a, b) => Number(b.created) - Number(a.created) || a.seen - b.seen || a.number - b.number) +} diff --git a/packages/git-stats/src/core/stats.ts b/packages/git-stats/src/core/stats.ts new file mode 100644 index 0000000..b4c825f --- /dev/null +++ b/packages/git-stats/src/core/stats.ts @@ -0,0 +1,39 @@ +/** + * Working-tree diff figures. The numbers come from OpenCode's own VCS status + * endpoint (`client.vcs.status()`), so they agree with what the host sidebar shows. + */ + +export type FileStat = { file: string; additions: number; deletions: number; status?: string } + +export type DiffSummary = { files: number; additions: number; deletions: number } + +export const EMPTY_SUMMARY: DiffSummary = { files: 0, additions: 0, deletions: 0 } + +export function summarize(files: readonly FileStat[] | undefined): DiffSummary { + if (!files?.length) return EMPTY_SUMMARY + let additions = 0 + let deletions = 0 + for (const f of files) { + additions += f.additions || 0 + deletions += f.deletions || 0 + } + return { files: files.length, additions, deletions } +} + +/** `7 files` / `1 file` — the noun that goes beside the +/− pair. */ +export function fileCountLabel(files: number): string { + return `${files} file${files === 1 ? "" : "s"}` +} + +/** Cut to `max` columns, ellipsis included, so a long branch name cannot wrap the card. */ +export function clip(text: string, max: number): string { + if (max <= 0) return "" + return text.length <= max ? text : `${text.slice(0, max - 1)}…` +} + +/** Thousands get a `k` so the line never outgrows the sidebar: `1.2k`. */ +export function compact(n: number): string { + if (n < 1000) return String(n) + const k = n / 1000 + return `${k < 10 ? k.toFixed(1).replace(/\.0$/, "") : Math.round(k)}k` +} diff --git a/packages/git-stats/src/core/tracker.ts b/packages/git-stats/src/core/tracker.ts new file mode 100644 index 0000000..b307d00 --- /dev/null +++ b/packages/git-stats/src/core/tracker.ts @@ -0,0 +1,121 @@ +/** + * Which pull requests this session has touched, and when each chip is worth asking + * `gh` about again. Pure over its inputs so the policy can be tested without a session. + */ +import { DEFAULT_HOSTS, findPrRefs, isPrCreateCommand, prKey, type PrChip, type PrState } from "./pr.js" + +/** One finished tool call, reduced to the two fields a PR can hide in. */ +export type ToolCall = { command?: string; output?: string } + +/** + * No sidebar wants more than this, and it is the backstop against one `gh pr list --json url` + * minting a chip — and a `gh` process — per pull request in the repository. + */ +export const MAX_CHIPS_PER_SESSION = 24 + +/** Chips found in this pass, in first-seen order, all with state still unknown. */ +export function collectChips(calls: readonly ToolCall[], now: number, hosts: readonly string[] = DEFAULT_HOSTS): PrChip[] { + const found = new Map() + for (const call of calls) { + const created = isPrCreateCommand(call.command) + // The command itself carries a PR URL for `gh pr merge ` and friends. + for (const ref of [...findPrRefs(call.output, hosts), ...findPrRefs(call.command, hosts)]) { + const key = prKey(ref) + const prior = found.get(key) + if (prior) { + if (created) prior.created = true + continue + } + found.set(key, { ...ref, key, state: "unknown", created, seen: now, checked: 0, failures: 0 }) + } + } + return [...found.values()] +} + +/** + * Fold a fresh scan into what we already knew: sightings and states already fetched + * survive, so a re-render never resets a chip to "…" or reorders the row. Past the cap, + * new pull requests are dropped rather than pushing the earlier ones out. + */ +export function mergeChips(prev: readonly PrChip[], next: readonly PrChip[], max = MAX_CHIPS_PER_SESSION): PrChip[] { + const out = prev.map((c) => ({ ...c })) + const byKey = new Map(out.map((c) => [c.key, c])) + for (const chip of next) { + const prior = byKey.get(chip.key) + if (prior) { + if (chip.created) prior.created = true + continue + } + if (out.length >= max) continue + const copy = { ...chip } + out.push(copy) + byKey.set(copy.key, copy) + } + return out +} + +/** + * How stale a state may get before it is worth another `gh` call. A merged PR is done, so + * it is never re-fetched; everything else is cheap enough to keep honest. A session that + * just went idle refreshes out of band (see `staleAfterTurn`), so these are floors, not + * the only way a chip updates. + */ +export const REFRESH_MS: Record = { + unknown: 15_000, + open: 90_000, + draft: 90_000, + closed: 300_000, + merged: Number.POSITIVE_INFINITY, +} + +/** Six doublings: a chip whose `gh` call keeps failing settles at ~16 minutes, not ~15 seconds. */ +const MAX_BACKOFF_DOUBLINGS = 6 + +export function refreshDelay(chip: Pick): number { + const base = REFRESH_MS[chip.state] + if (!Number.isFinite(base)) return base + return base * 2 ** Math.min(chip.failures, MAX_BACKOFF_DOUBLINGS) +} + +export function dueForRefresh(chip: PrChip, now: number): boolean { + return now - chip.checked >= refreshDelay(chip) +} + +/** After a turn ends, anything not already merged may have just changed. */ +export function staleAfterTurn(chip: PrChip): boolean { + return chip.state !== "merged" +} + +/** + * Due again now. The backoff is cleared with it: a turn boundary is also when the user + * may have installed `gh` or logged in, and one retry per turn is not a spawn storm. + */ +export function markStale(chip: PrChip): PrChip { + return { ...chip, checked: 0, failures: 0 } +} + +export function applyLookup(chip: PrChip, state: PrState, title: string | undefined, now: number): PrChip { + return { ...chip, state, title: title ?? chip.title, checked: now, failures: 0 } +} + +/** A `gh` call that did not answer: stamp the attempt and lengthen the next wait. */ +export function applyFailure(chip: PrChip, now: number): PrChip { + return { ...chip, checked: now, failures: chip.failures + 1 } +} + +/** Dismissal is per session and per chip; the set is what the TUI persists in `api.kv`. */ +export function visibleChips(chips: readonly PrChip[], dismissed: ReadonlySet): PrChip[] { + return chips.filter((c) => !dismissed.has(c.key)) +} + +/** Replace one chip wherever it appears, across every session that tracks it. */ +export function mapChips(prev: Record, key: string, fn: (chip: PrChip) => PrChip): Record { + let changed = false + const next: Record = {} + for (const [sessionID, list] of Object.entries(prev)) { + const mapped = list.map((c) => (c.key === key ? fn(c) : c)) + if (mapped.some((c, i) => c !== list[i])) changed = true + next[sessionID] = mapped + } + return changed ? next : prev +} diff --git a/packages/git-stats/src/tui/card.tsx b/packages/git-stats/src/tui/card.tsx new file mode 100644 index 0000000..408fe71 --- /dev/null +++ b/packages/git-stats/src/tui/card.tsx @@ -0,0 +1,116 @@ +/** @jsxImportSource @opentui/solid */ +/** + * The sidebar card: one line of working-tree figures, then a GitHub-coloured chip per + * pull request the session has touched. + */ +import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" +import type { RGBA } from "@opentui/core" +import type { JSX } from "@opentui/solid" +import { For, Show } from "solid-js" +import { PR_STATE_COLOR, PR_STATE_LABEL, type PrChip } from "../core/pr.js" +import { clip, compact, fileCountLabel, type DiffSummary } from "../core/stats.js" + +type Color = string | RGBA + +/** `@opentui/solid`'s `SpanProps` omits `fg`/`bg`, though the renderable behind it takes both. */ +function Span(props: { fg?: Color; bg?: Color; children?: JSX.Element }) { + return +} + +/** Same cast, for the link span: an OSC 8 hyperlink, so the chip opens the PR. */ +function Link(props: { href: string; fg?: Color; bg?: Color; children?: JSX.Element }) { + return +} + +/** + * The host sidebar is 42 columns with 2 of padding each side; a longer branch name wraps + * and orphans its tail. + */ +export const CARD_COLUMNS = 38 + +export type ChipRowProps = { + chip: PrChip + theme: TuiThemeCurrent + onDismiss: (key: string) => void +} + +/** + * `#20 Merged ×` — the badge takes GitHub's own state colour with white text, the way the + * badge on the pull request page does; `×` beside it is the close affordance. + */ +export function ChipRow(props: ChipRowProps): JSX.Element { + const color = () => (props.chip.state === "unknown" ? undefined : PR_STATE_COLOR[props.chip.state]) + const label = () => `#${props.chip.number} ${PR_STATE_LABEL[props.chip.state]}` + return ( + + + {` ${label()} `} + + } + > + + {` ${label()} `} + + + props.onDismiss(props.chip.key)}> + {" ×"} + + + ) +} + +export type CardProps = { + theme: TuiThemeCurrent + branch?: string + /** Undefined until OpenCode has actually answered — draws no figures at all. */ + summary?: DiffSummary + /** False in a folder OpenCode does not see as a repo: no branch, no figures, no claim. */ + git: boolean + chips: readonly PrChip[] + /** Set when `gh` could not answer — shown once, under the chips, instead of silence. */ + note?: string + onDismiss: (key: string) => void +} + +export function Card(props: CardProps): JSX.Element { + return ( + + + Git Stats + + + + {/* the glyph carries the colour, the branch name is text to read */} + ⎇ + {clip(props.branch!, CARD_COLUMNS - 2)} + + + + 0} + fallback={working tree clean} + > + {/* the same two theme tokens the host's own Modified Files section paints with */} + + {`+${compact(props.summary!.additions)} `} + {`-${compact(props.summary!.deletions)} `} + {`· ${fileCountLabel(props.summary!.files)}`} + + + + + + {(chip) => } + + + + {clip(props.note!, CARD_COLUMNS)} + + + ) +} diff --git a/packages/git-stats/src/tui/index.tsx b/packages/git-stats/src/tui/index.tsx new file mode 100644 index 0000000..1ac1e09 --- /dev/null +++ b/packages/git-stats/src/tui/index.tsx @@ -0,0 +1,303 @@ +/** @jsxImportSource @opentui/solid */ +/** + * TUI plugin: a sidebar card with the working tree's diff figures and a chip per pull + * request this session touched. + * + * Everything it knows comes from OpenCode's own TUI API — `client.vcs.status()` for the + * figures, `state.session.messages` / `state.part` for the tool output a PR URL appears + * in, `kv` for what the user has dismissed. The single outside call is `gh pr view`, + * which is the only way to learn whether a PR is draft, open, closed or merged. + */ +import type { TuiPlugin } from "@opencode-ai/plugin/tui" +import { createEffect, createMemo, createSignal } from "solid-js" +import { fetchPrState, type GhFailure } from "../core/gh.js" +import { DEFAULT_HOSTS, sortChips, type PrChip } from "../core/pr.js" +import { summarize, type DiffSummary, type FileStat } from "../core/stats.js" +import { applyFailure, applyLookup, collectChips, dueForRefresh, mapChips, markStale as staleChip, mergeChips, staleAfterTurn, visibleChips, type ToolCall } from "../core/tracker.js" +import { Card } from "./card.js" + +/** How often the plugin looks for chips whose state has aged out, and for tree changes. */ +const TICK_MS = 10_000 +const STATUS_TIMEOUT_MS = 5_000 +/** A `gh` failure is reported once per state, not once per tick. */ +const NOTE: Record = { + missing: "gh not installed", + unauthenticated: "gh: run `gh auth login`", + timeout: "gh timed out", + error: "gh could not read PR state", +} + +/** + * A GitHub Enterprise host has to be named here to be trusted, because the text these + * URLs are found in is tool output — see `findPrRefs`. + */ +function parseHosts(raw: unknown): string[] { + const extra = (raw as { hosts?: unknown } | undefined)?.hosts + const list = Array.isArray(extra) ? extra.filter((h): h is string => typeof h === "string" && h.length > 0) : [] + return [...DEFAULT_HOSTS, ...list] +} + +type ToolPart = { id: string; type: string; state?: { status?: string; input?: Record; output?: string } } + +/** What picking a row of the `/prs` dialog does. */ +type ChipAction = { kind: "hide"; key: string } | { kind: "hideAll" } | { kind: "restore" } + +const tui: TuiPlugin = async (api, options) => { + const directory = api.state.path.directory + const hosts = parseHosts(options) + + // ── pull requests ──────────────────────────────────────────────────────────── + // Chips are per session (the sidebar is), but a PR's *state* is fetched once per key + // per pass, so the same PR open in two sessions costs one `gh` call. + const [chips, setChips] = createSignal>({}) + const [note, setNote] = createSignal() + const [dismissedTick, bumpDismissed] = createSignal(0) + // Parts already read, per session: a re-render must not re-scan a whole transcript. + const scanned = new Map>() + // Message count at the last catch-up scan, per session. + const counted = new Map() + + // Fallback for the window before `kv` has loaded: a dismissal still takes effect, it + // just does not survive a restart. + const volatileDismissed = new Map>() + + const dismissedKey = (sessionID: string) => `gitstats.dismissed.${sessionID}` + const dismissedFor = (sessionID: string): Set => { + dismissedTick() + // The union, not one or the other: a chip dismissed before `kv` loaded must not come + // back the moment it does. + const out = new Set(volatileDismissed.get(sessionID) ?? []) + if (api.kv.ready) for (const key of api.kv.get(dismissedKey(sessionID), []) ?? []) out.add(key) + return out + } + const setDismissedFor = (sessionID: string, keys: readonly string[]) => { + if (api.kv.ready) { + api.kv.set(dismissedKey(sessionID), [...keys]) + volatileDismissed.delete(sessionID) + } else { + volatileDismissed.set(sessionID, new Set(keys)) + } + bumpDismissed((n) => n + 1) + } + + const updateSession = (sessionID: string, fn: (list: PrChip[]) => PrChip[]) => { + setChips((prev) => { + const before = prev[sessionID] ?? [] + const after = fn(before) + return after === before ? prev : { ...prev, [sessionID]: after } + }) + } + + /** Read any tool part we have not read before, and fold whatever PRs it names into the session. */ + const ingest = (sessionID: string, parts: readonly ToolPart[]) => { + let read = scanned.get(sessionID) + if (!read) scanned.set(sessionID, (read = new Set())) + const calls: ToolCall[] = [] + for (const part of parts) { + if (part.type !== "tool" || part.state?.status !== "completed" || read.has(part.id)) continue + read.add(part.id) + const command = part.state?.input?.["command"] + calls.push({ command: typeof command === "string" ? command : undefined, output: part.state?.output }) + } + if (!calls.length) return + const found = collectChips(calls, Date.now(), hosts) + if (!found.length) return + updateSession(sessionID, (list) => mergeChips(list, found)) + // Detached: `ingest` runs inside the catch-up effect, and `refresh` reads `chips()`. + queueMicrotask(() => void refresh()) + } + + // One `gh` pass over every chip whose state has aged out. Serial on purpose: a handful + // of chips is not worth a scheduler, and a burst of `gh` processes is worth avoiding. + let refreshing = false + const refresh = async () => { + if (refreshing) return + refreshing = true + try { + const now = Date.now() + const due = new Map() + for (const [sessionID, list] of Object.entries(chips())) { + // A chip the user closed costs nothing to keep, but must cost no `gh` calls. + const hidden = dismissedFor(sessionID) + for (const chip of list) if (!hidden.has(chip.key) && dueForRefresh(chip, now) && !due.has(chip.key)) due.set(chip.key, chip) + } + if (!due.size) return + let failure: GhFailure | undefined + for (const chip of due.values()) { + if (api.lifecycle.signal.aborted) return + const result = await fetchPrState(chip, directory, api.lifecycle.signal) + const at = Date.now() + if (!result.ok) { + failure ??= result.failure + // Stamp the attempt and lengthen the next wait, so a missing or logged-out `gh` + // backs off to minutes instead of being re-spawned on every tick forever. + setChips((prev) => mapChips(prev, chip.key, (c) => applyFailure(c, at))) + continue + } + setChips((prev) => mapChips(prev, chip.key, (c) => applyLookup(c, result.value.state, result.value.title, at))) + } + setNote(failure ? NOTE[failure] : undefined) + } finally { + refreshing = false + } + } + + /** A finished turn may have merged or closed something: let the next tick re-ask. */ + const markStale = (sessionID: string) => { + updateSession(sessionID, (list) => { + const next = list.map((c) => (staleAfterTurn(c) ? staleChip(c) : c)) + return next.some((c, i) => c !== list[i]) ? next : list + }) + void refresh() + } + + // ── working tree ───────────────────────────────────────────────────────────── + // `undefined` until the first answer: a card that has not been told anything says + // nothing, rather than claiming a dirty tree is clean. + const [summary, setSummary] = createSignal() + let loadingStatus = false + const readTree = async () => { + if (loadingStatus) return + loadingStatus = true + try { + // A hung server must not wedge the poll; the next tick tries again. + const res = await api.client.vcs.status({ directory }, { signal: AbortSignal.timeout(STATUS_TIMEOUT_MS) }).catch(() => undefined) + const files = res?.data as FileStat[] | undefined + // A failed or aborted call answers `undefined`, which is not the same as an empty + // working tree: keep the last figures we were actually told. + if (files) setSummary(summarize(files)) + } finally { + loadingStatus = false + } + } + + // ── wiring ─────────────────────────────────────────────────────────────────── + void readTree() + // `session.diff` fires whenever OpenCode notices the tree moved; the tick covers edits + // made outside it (a rebase in another terminal), and ages chip states out. + const offDiff = api.event.on("session.diff", () => void readTree()) + const offIdle = api.event.on("session.idle", (event) => { + void readTree() + markStale(event.properties.sessionID) + }) + // A chip should appear the moment the command that made the PR finishes, and reading one + // part per event is O(1) — walking the whole transcript on every streamed part is not. + const offPart = api.event.on("message.part.updated", (event) => { + ingest(event.properties.sessionID, [event.properties.part as unknown as ToolPart]) + }) + const timer = setInterval(() => { + void readTree() + void refresh() + }, TICK_MS) + api.lifecycle.onDispose(() => { + clearInterval(timer) + offDiff() + offIdle() + offPart() + }) + + const currentSession = (): string | undefined => { + const route = api.route.current + return route.name === "session" ? ((route.params as { sessionID?: string } | undefined)?.sessionID ?? undefined) : undefined + } + + const shownFor = (sessionID: string): PrChip[] => sortChips(visibleChips(chips()[sessionID] ?? [], dismissedFor(sessionID))) + + const detached = (fn: () => Promise) => () => { + void fn().catch((e) => api.ui.toast({ variant: "error", message: e instanceof Error ? e.message : String(e) })) + } + + const dismiss = (sessionID: string, key: string) => { + const next = dismissedFor(sessionID) + next.add(key) + setDismissedFor(sessionID, [...next]) + } + + api.keymap.registerLayer({ + commands: [ + { + namespace: "palette", + name: "gitstats.chips", + title: "Hide a pull request chip", + description: "Close one chip, all of them, or bring the hidden ones back", + category: "Git", + slashName: "prs", + slashAliases: ["chips"], + enabled: () => Boolean(currentSession()), + // A palette `run` must return synchronously, and the palette clears any dialog open + // when it closes — so the picker is opened detached, one tick after that close. + run: detached(async () => { + const sessionID = currentSession() + if (!sessionID) return + await new Promise((r) => setTimeout(r, 30)) + const shown = shownFor(sessionID) + const hidden = (chips()[sessionID] ?? []).length - shown.length + const options: { title: string; value: ChipAction; description?: string }[] = shown.map((c) => ({ + title: `Hide #${c.number} · ${c.state === "unknown" ? "checking" : c.state}`, + value: { kind: "hide", key: c.key }, + description: c.title, + })) + if (shown.length > 1) options.push({ title: `Hide all ${shown.length} chips`, value: { kind: "hideAll" } }) + if (hidden > 0) options.push({ title: `Show ${hidden} hidden chip${hidden === 1 ? "" : "s"} again`, value: { kind: "restore" } }) + if (!options.length) { + api.ui.toast({ message: "no pull requests seen in this session yet" }) + api.ui.dialog.clear() + return + } + api.ui.dialog.replace(() => + api.ui.DialogSelect({ + title: "Pull request chips", + options, + onSelect: (option) => { + const action = option.value + // Union with what is already hidden: "hide all" must not un-hide. + if (action.kind === "hideAll") setDismissedFor(sessionID, [...dismissedFor(sessionID), ...shown.map((c) => c.key)]) + else if (action.kind === "restore") setDismissedFor(sessionID, []) + else dismiss(sessionID, action.key) + api.ui.dialog.clear() + }, + }), + ) + }), + }, + ], + }) + + api.slots.register({ + // The host's own sections are 100 Context … 400 Todo, 500 Modified Files, drawn + // lowest first: the worktree totals belong directly above that file list. + order: 450, + slots: { + sidebar_content: (_ctx, props: { session_id: string }) => { + // Catch-up for everything that happened before this card mounted — a session + // reopened after a restart, or one whose messages were still loading. Guarded by + // the message count so a streaming turn does not re-walk the transcript; live + // parts arrive through `message.part.updated` instead. + createEffect(() => { + const sessionID = props.session_id + const messages = api.state.session.messages(sessionID) + if (messages.length === counted.get(sessionID)) return + counted.set(sessionID, messages.length) + for (const message of messages) ingest(sessionID, api.state.part(message.id) as unknown as ToolPart[]) + }) + const shown = createMemo(() => shownFor(props.session_id)) + return ( + c.state === "unknown") ? note() : undefined} + onDismiss={(key) => dismiss(props.session_id, key)} + /> + ) + }, + }, + }) +} + +export { tui } +export default { id: "opencode-git-stats", tui } satisfies { id: string; tui: TuiPlugin } diff --git a/packages/git-stats/test/gh.test.ts b/packages/git-stats/test/gh.test.ts new file mode 100644 index 0000000..3948d7e --- /dev/null +++ b/packages/git-stats/test/gh.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fetchPrState } from "../src/core/gh.js" + +const REF = { host: "github.com", owner: "navbytes", repo: "opencode-tree", number: 20, url: "https://github.com/navbytes/opencode-tree/pull/20" } +const PATH = process.env.PATH + +afterEach(() => { + process.env.PATH = PATH +}) + +/** Put a scripted `gh` at the front of PATH, so the classifier can be tested without GitHub. */ +function stubGh(body: string): void { + const dir = mkdtempSync(join(tmpdir(), "git-stats-gh-")) + const bin = join(dir, "gh") + writeFileSync(bin, `#!/bin/sh\n${body}\n`) + chmodSync(bin, 0o755) + process.env.PATH = `${dir}:${PATH}` +} + +describe("fetchPrState", () => { + test("reports a missing gh rather than throwing", async () => { + process.env.PATH = "/nonexistent-for-tests" + expect(await fetchPrState(REF, process.cwd())).toEqual({ ok: false, failure: "missing", message: "gh not found on PATH" }) + }) + + test("reads the state and title out of gh's JSON", async () => { + stubGh(`echo '{"state":"OPEN","isDraft":true,"title":"A draft"}'`) + expect(await fetchPrState(REF, process.cwd())).toEqual({ ok: true, value: { state: "draft", title: "A draft" } }) + }) + + test("classifies a logged-out gh as unauthenticated, not a generic error", async () => { + stubGh(`echo 'To get started with GitHub CLI, please run: gh auth login' >&2; exit 4`) + const result = await fetchPrState(REF, process.cwd()) + expect(result).toMatchObject({ ok: false, failure: "unauthenticated" }) + }) + + test("a deleted PR is an error, and keeps gh's own message", async () => { + stubGh(`echo 'GraphQL: Could not resolve to a PullRequest with the number of 20.' >&2; exit 1`) + const result = await fetchPrState(REF, process.cwd()) + expect(result).toMatchObject({ ok: false, failure: "error" }) + if (!result.ok) expect(result.message).toContain("Could not resolve") + }) + + test("output that is not JSON is an error, not a crash", async () => { + stubGh(`echo 'not json at all'`) + expect(await fetchPrState(REF, process.cwd())).toEqual({ ok: false, failure: "error", message: "gh returned unreadable JSON" }) + }) + + test("a killed gh reads as a timeout, not as an empty success", async () => { + // The child is aborted mid-run: exit code is null and only `signal`/`killed` say so. + stubGh(`sleep 30`) + const controller = new AbortController() + setTimeout(() => controller.abort(), 50) + const result = await fetchPrState(REF, process.cwd(), controller.signal) + expect(result).toMatchObject({ ok: false, failure: "timeout" }) + }) + + test("an already-aborted signal never spawns gh at all", async () => { + expect(await fetchPrState(REF, process.cwd(), AbortSignal.abort())).toEqual({ ok: false, failure: "timeout", message: "cancelled" }) + }) + + // Opt-in: this one talks to GitHub. `GIT_STATS_GH=1 bun test` to include it. + test.skipIf(process.env.GIT_STATS_GH !== "1")("reads a real merged pull request", async () => { + const result = await fetchPrState(REF, process.cwd()) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value.state).toBe("merged") + }) +}) diff --git a/packages/git-stats/test/pr.test.ts b/packages/git-stats/test/pr.test.ts new file mode 100644 index 0000000..a8be674 --- /dev/null +++ b/packages/git-stats/test/pr.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import { PR_STATE_COLOR, PR_STATE_LABEL, findPrRefs, isPrCreateCommand, prKey, sortChips, stateFromGh, type PrChip } from "../src/core/pr.js" + +describe("findPrRefs", () => { + test("picks the URL `gh pr create` prints", () => { + const refs = findPrRefs("https://github.com/navbytes/opencode-tree/pull/20\n") + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ host: "github.com", owner: "navbytes", repo: "opencode-tree", number: 20 }) + }) + + test("finds PRs embedded in prose and markdown links", () => { + const refs = findPrRefs("Opened [#7](https://github.com/a/b/pull/7) and see https://github.com/a/b/pull/8).") + expect(refs.map((r) => r.number)).toEqual([7, 8]) + }) + + test("dedupes repeats but keeps distinct PRs of the same repo", () => { + const refs = findPrRefs("https://github.com/a/b/pull/1 https://github.com/a/b/pull/1 https://github.com/a/b/pull/2") + expect(refs.map((r) => r.number)).toEqual([1, 2]) + }) + + test("does not truncate a longer number into a shorter match", () => { + expect(findPrRefs("https://github.com/a/b/pull/123")[0]!.number).toBe(123) + }) + + test("follows a PR URL that points at a sub-page", () => { + expect(findPrRefs("https://github.com/a/b/pull/12/files")[0]).toMatchObject({ number: 12 }) + expect(findPrRefs("https://github.com/a/b/pull/12/commits/abc123")[0]).toMatchObject({ number: 12 }) + expect(findPrRefs("https://github.com/a/b/pull/12/checks")[0]).toMatchObject({ number: 12 }) + }) + + test("ignores /pull/ paths on non-GitHub hosts", () => { + expect(findPrRefs("https://gitlab.com/a/b/pull/3")).toEqual([]) + }) + + // Tool output is attacker-reachable, and the host goes straight to `gh --repo`: a + // look-alike must never become a request (and possibly a token) sent to that host. + test.each([ + "https://github.evil.example/a/b/pull/1", + "https://github.com.evil.example/a/b/pull/1", + "https://notgithub.com/a/b/pull/1", + "https://evil.example/github.com/a/b/pull/1", + ])("rejects the look-alike host in %p", (url) => { + expect(findPrRefs(url)).toEqual([]) + }) + + test("accepts a GitHub Enterprise host only when it is explicitly allowed", () => { + expect(findPrRefs("https://github.acme.com/a/b/pull/4")).toEqual([]) + const refs = findPrRefs("https://github.acme.com/a/b/pull/4", ["github.com", "github.acme.com"]) + expect(refs[0]).toMatchObject({ host: "github.acme.com", number: 4 }) + }) + + test("the allow-list is matched case-insensitively", () => { + expect(findPrRefs("https://GitHub.com/a/b/pull/5")[0]).toMatchObject({ number: 5 }) + }) + + test("ignores the compare URL `git push` suggests", () => { + expect(findPrRefs("remote: https://github.com/a/b/pull/new/my-branch")).toEqual([]) + }) + + test("ignores issues and the PR list page", () => { + expect(findPrRefs("https://github.com/a/b/issues/5 https://github.com/a/b/pulls")).toEqual([]) + }) + + test("survives empty and undefined input", () => { + expect(findPrRefs(undefined)).toEqual([]) + expect(findPrRefs("")).toEqual([]) + }) +}) + +describe("isPrCreateCommand", () => { + test.each([ + "gh pr create --fill", + "gh pr create -t 'x' -b 'y'", + "cd repo && gh pr create --draft", + "git push -u origin hi; gh pr create --fill", + "gh --repo a/b pr create", + ])("recognises %p", (cmd) => { + expect(isPrCreateCommand(cmd)).toBe(true) + }) + + test.each(["gh pr view 20", "gh pr list", "echo gh-pr-create", "git commit -m 'gh pr create'"])("rejects %p", (cmd) => { + expect(isPrCreateCommand(cmd)).toBe(false) + }) + + test("a heredoc body that mentions both words on different lines is not a create", () => { + expect(isPrCreateCommand("gh pr view 1 < { + expect(isPrCreateCommand(undefined)).toBe(false) + }) +}) + +describe("stateFromGh", () => { + test("maps every gh state, draft included", () => { + expect(stateFromGh({ state: "MERGED" })).toBe("merged") + expect(stateFromGh({ state: "CLOSED" })).toBe("closed") + expect(stateFromGh({ state: "OPEN", isDraft: false })).toBe("open") + expect(stateFromGh({ state: "OPEN", isDraft: true })).toBe("draft") + }) + + test("a merged PR that is still flagged draft reads as merged", () => { + expect(stateFromGh({ state: "MERGED", isDraft: true })).toBe("merged") + }) + + test("unknown for anything it cannot read", () => { + expect(stateFromGh(undefined)).toBe("unknown") + expect(stateFromGh({})).toBe("unknown") + expect(stateFromGh({ state: "WAT" })).toBe("unknown") + }) +}) + +describe("colours and labels", () => { + test("every paintable state has GitHub's own colour", () => { + expect(PR_STATE_COLOR).toEqual({ draft: "#59636e", open: "#1f883d", closed: "#cf222e", merged: "#8250df" }) + }) + + test("every state has a label", () => { + for (const s of ["draft", "open", "closed", "merged", "unknown"] as const) expect(PR_STATE_LABEL[s]).toBeTruthy() + }) +}) + +describe("prKey and sortChips", () => { + const chip = (n: number, seen: number): PrChip => ({ + host: "github.com", + owner: "a", + repo: "b", + number: n, + url: `https://github.com/a/b/pull/${n}`, + key: prKey({ host: "github.com", owner: "a", repo: "b", number: n }), + state: "open", + created: false, + seen, + checked: 0, + failures: 0, + }) + + test("key separates same-numbered PRs of different repos", () => { + expect(prKey({ host: "github.com", owner: "a", repo: "b", number: 1 })).not.toBe(prKey({ host: "github.com", owner: "a", repo: "c", number: 1 })) + }) + + test("orders by first sighting, then number", () => { + const sorted = sortChips([chip(9, 200), chip(3, 100), chip(1, 100)]) + expect(sorted.map((c) => c.number)).toEqual([1, 3, 9]) + }) + + test("pull requests this session made lead the ones it only referenced", () => { + const made = { ...chip(9, 300), created: true } + const sorted = sortChips([chip(1, 100), made, chip(2, 200)]) + expect(sorted.map((c) => c.number)).toEqual([9, 1, 2]) + }) + + test("does not mutate its input", () => { + const input = [chip(2, 200), chip(1, 100)] + sortChips(input) + expect(input.map((c) => c.number)).toEqual([2, 1]) + }) +}) diff --git a/packages/git-stats/test/stats.test.ts b/packages/git-stats/test/stats.test.ts new file mode 100644 index 0000000..0199094 --- /dev/null +++ b/packages/git-stats/test/stats.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { EMPTY_SUMMARY, clip, compact, fileCountLabel, summarize } from "../src/core/stats.js" + +describe("summarize", () => { + test("adds up a working tree", () => { + expect(summarize([ + { file: "a.ts", additions: 10, deletions: 2 }, + { file: "b.ts", additions: 1, deletions: 0 }, + ])).toEqual({ files: 2, additions: 11, deletions: 2 }) + }) + + test("a clean tree is zero, not a crash", () => { + expect(summarize([])).toEqual(EMPTY_SUMMARY) + expect(summarize(undefined)).toEqual(EMPTY_SUMMARY) + }) + + test("counts a file the endpoint reported without figures", () => { + expect(summarize([{ file: "bin", additions: 0, deletions: 0, status: "added" }])).toEqual({ files: 1, additions: 0, deletions: 0 }) + }) +}) + +describe("fileCountLabel", () => { + test("singular and plural", () => { + expect(fileCountLabel(1)).toBe("1 file") + expect(fileCountLabel(0)).toBe("0 files") + expect(fileCountLabel(7)).toBe("7 files") + }) +}) + +describe("clip", () => { + test("leaves anything that fits alone", () => { + expect(clip("main", 38)).toBe("main") + expect(clip("exactly-ten", 11)).toBe("exactly-ten") + }) + + test("never exceeds the width it was given", () => { + const out = clip("a-very-long-feature-branch-name-indeed", 12) + expect(out).toBe("a-very-long…") + expect(out).toHaveLength(12) + }) + + test("degenerate widths do not produce garbage", () => { + expect(clip("main", 0)).toBe("") + expect(clip("main", -3)).toBe("") + expect(clip("main", 1)).toBe("…") + }) +}) + +describe("compact", () => { + test("leaves small numbers alone", () => { + expect(compact(0)).toBe("0") + expect(compact(999)).toBe("999") + }) + + test("shortens thousands so the line fits a narrow sidebar", () => { + expect(compact(1000)).toBe("1k") + expect(compact(1234)).toBe("1.2k") + expect(compact(12345)).toBe("12k") + }) +}) diff --git a/packages/git-stats/test/tracker.test.ts b/packages/git-stats/test/tracker.test.ts new file mode 100644 index 0000000..4cfb942 --- /dev/null +++ b/packages/git-stats/test/tracker.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test" +import { applyFailure, applyLookup, collectChips, dueForRefresh, mapChips, markStale, MAX_CHIPS_PER_SESSION, mergeChips, refreshDelay, REFRESH_MS, staleAfterTurn, visibleChips } from "../src/core/tracker.js" +import type { PrChip } from "../src/core/pr.js" + +const NOW = 1_000_000 + +describe("collectChips", () => { + test("marks the PR a `gh pr create` printed as created here", () => { + const [chip] = collectChips([{ command: "gh pr create --fill", output: "https://github.com/a/b/pull/3\n" }], NOW) + expect(chip).toMatchObject({ number: 3, created: true, state: "unknown", seen: NOW, checked: 0 }) + }) + + test("a PR merely viewed is tracked, but not as created here", () => { + const [chip] = collectChips([{ command: "gh pr view 3", output: "https://github.com/a/b/pull/3" }], NOW) + expect(chip).toMatchObject({ number: 3, created: false }) + }) + + test("finds a PR named only in the command", () => { + const [chip] = collectChips([{ command: "gh pr merge https://github.com/a/b/pull/9 --squash", output: "Merged" }], NOW) + expect(chip!.number).toBe(9) + }) + + test("one chip per PR even when several calls mention it, and create wins", () => { + const chips = collectChips( + [ + { command: "gh pr view 3", output: "https://github.com/a/b/pull/3" }, + { command: "gh pr create --fill", output: "https://github.com/a/b/pull/3" }, + ], + NOW, + ) + expect(chips).toHaveLength(1) + expect(chips[0]!.created).toBe(true) + }) + + test("keeps first-seen order across calls", () => { + const chips = collectChips( + [{ output: "https://github.com/a/b/pull/2" }, { output: "https://github.com/a/b/pull/1" }], + NOW, + ) + expect(chips.map((c) => c.number)).toEqual([2, 1]) + }) + + test("no tool calls, no chips", () => { + expect(collectChips([], NOW)).toEqual([]) + expect(collectChips([{ command: "ls", output: "a b c" }], NOW)).toEqual([]) + }) +}) + +describe("mergeChips", () => { + const seed = (over: Partial = {}): PrChip => ({ + host: "github.com", + owner: "a", + repo: "b", + number: 1, + url: "https://github.com/a/b/pull/1", + key: "github.com/a/b#1", + state: "merged", + created: false, + seen: 500, + checked: 900, + failures: 0, + ...over, + }) + + test("a rescan never resets a state we already fetched", () => { + const merged = mergeChips([seed()], collectChips([{ output: "https://github.com/a/b/pull/1" }], NOW)) + expect(merged).toHaveLength(1) + expect(merged[0]).toMatchObject({ state: "merged", seen: 500, checked: 900 }) + }) + + test("a rescan can promote a chip to created here", () => { + const merged = mergeChips([seed()], collectChips([{ command: "gh pr create", output: "https://github.com/a/b/pull/1" }], NOW)) + expect(merged[0]!.created).toBe(true) + }) + + test("new PRs are appended after the ones already on screen", () => { + const merged = mergeChips([seed()], collectChips([{ output: "https://github.com/a/b/pull/2" }], NOW)) + expect(merged.map((c) => c.number)).toEqual([1, 2]) + }) + + test("does not mutate the previous array or its chips", () => { + const prev = [seed()] + mergeChips(prev, collectChips([{ command: "gh pr create", output: "https://github.com/a/b/pull/1" }], NOW)) + expect(prev[0]!.created).toBe(false) + }) + + test("stops at the cap rather than letting one command mint a chip per PR in the repo", () => { + const flood = collectChips([{ output: Array.from({ length: 200 }, (_, i) => `https://github.com/a/b/pull/${i + 1}`).join("\n") }], NOW) + expect(flood.length).toBe(200) + const merged = mergeChips([], flood) + expect(merged).toHaveLength(MAX_CHIPS_PER_SESSION) + // the cap keeps the earliest sightings, it does not evict them + expect(merged[0]!.number).toBe(1) + }) + + test("a chip already on screen still updates once the cap is reached", () => { + const full = mergeChips([], collectChips([{ output: Array.from({ length: 40 }, (_, i) => `https://github.com/a/b/pull/${i + 1}`).join("\n") }], NOW)) + const again = mergeChips(full, collectChips([{ command: "gh pr create", output: "https://github.com/a/b/pull/1" }], NOW)) + expect(again).toHaveLength(MAX_CHIPS_PER_SESSION) + expect(again[0]!.created).toBe(true) + }) +}) + +describe("refresh policy", () => { + const chip = (state: PrChip["state"], checked: number): PrChip => ({ + host: "github.com", + owner: "a", + repo: "b", + number: 1, + url: "u", + key: "k", + state, + created: false, + seen: 0, + checked, + failures: 0, + }) + + test("a brand new chip is due immediately", () => { + expect(dueForRefresh(chip("unknown", 0), NOW)).toBe(true) + }) + + test("a merged PR is never re-fetched", () => { + expect(REFRESH_MS.merged).toBe(Number.POSITIVE_INFINITY) + expect(dueForRefresh(chip("merged", 0), NOW)).toBe(false) + }) + + test("an open PR waits out its interval, then is due", () => { + expect(dueForRefresh(chip("open", NOW - 1_000), NOW)).toBe(false) + expect(dueForRefresh(chip("open", NOW - REFRESH_MS.open), NOW)).toBe(true) + }) + + test("a finished turn restates everything except merged", () => { + expect(staleAfterTurn(chip("open", NOW))).toBe(true) + expect(staleAfterTurn(chip("closed", NOW))).toBe(true) + expect(staleAfterTurn(chip("draft", NOW))).toBe(true) + expect(staleAfterTurn(chip("merged", NOW))).toBe(false) + }) + + test("applyLookup stamps the state and keeps a title it already had", () => { + const updated = applyLookup({ ...chip("unknown", 0), title: "Old" }, "open", undefined, NOW) + expect(updated).toMatchObject({ state: "open", title: "Old", checked: NOW }) + }) + + test("applyLookup takes a fresh title when gh returns one", () => { + expect(applyLookup(chip("unknown", 0), "draft", "New", NOW).title).toBe("New") + }) + + test("a failing gh backs a chip off instead of being re-spawned every tick", () => { + let c = chip("unknown", 0) + expect(refreshDelay(c)).toBe(REFRESH_MS.unknown) + c = applyFailure(c, NOW) + expect(c.failures).toBe(1) + expect(c.checked).toBe(NOW) + expect(refreshDelay(c)).toBe(REFRESH_MS.unknown * 2) + // it is not due again the moment the un-backed-off interval elapses + expect(dueForRefresh(c, NOW + REFRESH_MS.unknown)).toBe(false) + }) + + test("the backoff settles rather than growing without bound", () => { + let c = chip("unknown", 0) + for (let i = 0; i < 50; i++) c = applyFailure(c, NOW) + expect(refreshDelay(c)).toBe(REFRESH_MS.unknown * 64) + }) + + test("a merged PR stays never-refetched however many failures preceded it", () => { + const c = applyFailure({ ...chip("merged", 0) }, NOW) + expect(refreshDelay(c)).toBe(Number.POSITIVE_INFINITY) + }) + + test("a finished turn clears the backoff so a just-fixed gh is retried", () => { + let c = chip("open", NOW) + for (let i = 0; i < 5; i++) c = applyFailure(c, NOW) + const fresh = markStale(c) + expect(fresh).toMatchObject({ checked: 0, failures: 0 }) + expect(dueForRefresh(fresh, NOW)).toBe(true) + }) + + test("a success clears the backoff", () => { + const failed = applyFailure(applyFailure(chip("unknown", 0), NOW), NOW) + expect(applyLookup(failed, "open", undefined, NOW).failures).toBe(0) + }) +}) + +describe("mapChips", () => { + const chip = (key: string, state: PrChip["state"] = "unknown"): PrChip => ({ host: "github.com", owner: "a", repo: "b", number: 1, url: "u", key, state, created: false, seen: 0, checked: 0, failures: 0 }) + + test("updates the same PR in every session that tracks it", () => { + const before = { s1: [chip("k"), chip("other")], s2: [chip("k")] } + const after = mapChips(before, "k", (c) => ({ ...c, state: "merged" })) + expect(after.s1![0]!.state).toBe("merged") + expect(after.s2![0]!.state).toBe("merged") + expect(after.s1![1]!.state).toBe("unknown") + }) + + test("returns the identical object when the key is not tracked, so nothing re-renders", () => { + const before = { s1: [chip("k")] } + expect(mapChips(before, "absent", (c) => ({ ...c, state: "merged" }))).toBe(before) + }) + + test("does not mutate the previous state", () => { + const before = { s1: [chip("k")] } + mapChips(before, "k", (c) => ({ ...c, state: "closed" })) + expect(before.s1[0]!.state).toBe("unknown") + }) +}) + +describe("visibleChips", () => { + const chip = (key: string): PrChip => ({ host: "github.com", owner: "a", repo: "b", number: 1, url: "u", key, state: "open", created: false, seen: 0, checked: 0, failures: 0 }) + + test("hides exactly what was dismissed", () => { + expect(visibleChips([chip("x"), chip("y")], new Set(["x"])).map((c) => c.key)).toEqual(["y"]) + }) + + test("nothing dismissed, nothing hidden", () => { + expect(visibleChips([chip("x")], new Set())).toHaveLength(1) + }) +}) diff --git a/packages/git-stats/tsconfig.json b/packages/git-stats/tsconfig.json new file mode 100644 index 0000000..0789a30 --- /dev/null +++ b/packages/git-stats/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@opentui/solid" + }, + "include": ["src", "scripts", "test"] +}