18 KiB
PR + CI/checks status chip via gh
A per-project chip in the project-detail view that shows, for the repo's current branch: PR state (open / draft / merged / closed / none), N checks passing (from statusCheckRollup), and mergeable (clean / conflicting). It is a read-only, out-of-band side-channel — exactly like getDiff (src/http/diff.ts): execFile('gh', …) (no shell), timeout + maxBuffer bound, parses gh's --json output, and capability-degrades (chip explains itself) when gh is missing, unauthenticated, or the branch has no PR. Cached at module scope with a short TTL (reuses cfg.projectScanTtlMs) so opening/refreshing a project detail doesn't hammer the GitHub API.
Grounding: buildProjectDetail (src/http/projects.ts:420) surfaces branch/dirty/worktrees but nothing PR. getDiff/runGit (src/http/diff.ts:289) is the runner+degrade pattern to mirror. isValidGitDir (src/server.ts:123) + the /projects/diff route (src/server.ts:661) are the exact route mirror. The FE detail header is renderProjectDetail (public/projects.ts:672, header at lines 696-706); buildDiffSection (public/projects.ts:612) and public/diff.ts fetchDiff/normalizeDiffResult (lines 110/38) are the FE fetch+degrade+textContent pattern.
Contract
New route
GET /projects/pr?path=<abs-repo-dir> — read-only, no Origin guard (same threat model as /projects and /projects/diff; see src/server.ts:660 comment).
400 {error}—pathmissing/empty (mirrorsrc/server.ts:662-666).404 {error:'project not found'}—!isValidGitDir(target)(mirrorsrc/server.ts:667-670, SEC-H7 three-prong).200 PrStatus— always on a valid git dir, including all degrade cases (the availability lives in the body, not the HTTP status — so the FE renders one chip regardless).500 {error}only on an unexpected throw (mirrorsrc/server.ts:674-677).
New message type — src/types.ts (coordination edit, next to DiffResult at line 475)
/* ── W3 PR + CI status chip (gh) ── */
/** Why a PrStatus has (or lacks) PR data. Drives the FE chip's degraded text. */
export type PrAvailability =
| 'ok' // a PR exists for the current branch; fields below are populated
| 'no-pr' // gh works but the branch has no PR (or no remote/default repo)
| 'not-installed' // `gh` binary not found on PATH (ENOENT)
| 'unauthenticated' // gh present but not logged in (needs `gh auth login`)
| 'disabled' // GH_ENABLED=0 — feature off, never spawns gh
| 'error'; // gh spawned but failed for another reason (timeout, etc.)
/** Rolled-up CI check counts from gh's statusCheckRollup (CheckRun + StatusContext). */
export interface PrCheckSummary {
total: number;
passing: number; // CheckRun conclusion SUCCESS/NEUTRAL/SKIPPED | StatusContext SUCCESS
failing: number; // FAILURE/TIMED_OUT/CANCELLED/ACTION_REQUIRED | ERROR/FAILURE
pending: number; // QUEUED/IN_PROGRESS/WAITING | PENDING/EXPECTED
}
/** GET /projects/pr result. Only present-when-'ok' fields are optional. */
export interface PrStatus {
availability: PrAvailability;
number?: number;
title?: string;
url?: string;
state?: 'open' | 'closed' | 'merged'; // lower-cased from gh OPEN/CLOSED/MERGED
isDraft?: boolean;
mergeable?: 'mergeable' | 'conflicting' | 'unknown'; // lower-cased from gh
headRefName?: string;
baseRefName?: string;
checks?: PrCheckSummary;
}
gh invocation (single spawn — KISS)
One command; statusCheckRollup already carries per-check state, so no second gh pr checks spawn:
gh pr view --json number,state,title,url,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup
Run with cwd = repoPath. gh resolves the PR from the current branch. statusCheckRollup items are a mix of {__typename:'CheckRun', status, conclusion} and {__typename:'StatusContext', state} — the pure parser handles both.
New env vars — src/config.ts + Config in src/types.ts (coordination edit)
| Env var | Default | Purpose |
|---|---|---|
GH_ENABLED |
true (parseBool) |
Feature flag. false → route returns {availability:'disabled'}, never spawns gh. |
GH_TIMEOUT_MS |
8000 (parseNonNegativeInt) |
Hard-kill timeout for the gh spawn. Larger than diffTimeoutMs (2 s) because gh hits the network. |
Cache TTL reuses cfg.projectScanTtlMs (src/config.ts:311, default 10 000 ms) — no new TTL var. Add the two fields to the assembled object in loadConfig (src/config.ts:390-438) and to the Config interface.
Files to change
| Path | Concrete change |
|---|---|
src/types.ts |
Coordination edit. Add PrAvailability, PrCheckSummary, PrStatus near DiffResult (line 475). Add ghEnabled: boolean + ghTimeoutMs: number to the Config interface (find Config — used by loadConfig). |
src/config.ts |
Add const DEFAULT_GH_TIMEOUT_MS = 8000 near line 65; parse GH_ENABLED via parseBool(env['GH_ENABLED'], true) and GH_TIMEOUT_MS via parseNonNegativeInt(...); add both to the frozen object (lines 413-438). |
src/http/gh.ts |
New file (~180 lines), mirrors diff.ts structure: a runGh runner (execFileAsync('gh', args, {cwd, timeout, maxBuffer}) that captures stdout/stderr/code/spawn-ENOENT), a pure parsePrView(json): PrStatus-core + summarizeChecks(rollup): PrCheckSummary, a classifyGhFailure(exec): PrAvailability, module-scope short-TTL cache mirroring discoverCache (projects.ts:239-317) with in-flight dedupe, getPrStatus(repoPath, cfg): Promise<PrStatus>, and a _clearPrCache() test hook (mirror _clearProjectCache, projects.ts:314). |
src/server.ts |
Add import { getPrStatus } from './http/gh.js' (next to line 41). Add GET /projects/pr route immediately after /projects/diff (after line 678), copying the path-missing → 400 and !isValidGitDir → 404 guards, then res.json(await getPrStatus(target, cfg)) in a try/catch → 500 (mirror lines 671-677). |
public/gh-chip.ts |
New file (~120 lines), render-only, mirrors public/diff.ts: normalizePrStatus(raw): PrStatus | null, fetchPrStatus(repoPath): Promise<PrStatus | null> (mirror fetchDiff, diff.ts:110), chipText(status): {label, cls, title} (pure, unit-tested), renderPrChip(status): HTMLElement (all text via textContent, zero innerHTML — SEC-H4), mountPrChip(container, repoPath): {destroy()} that shows a loading placeholder then swaps in the resolved chip. |
public/projects.ts |
Add import { mountPrChip } from './gh-chip.js' (near line 27). In renderProjectDetail, after the dirty indicator (line 704) inside the if (detail.isGit) guard, append the chip host and mount it; track the handle in a prRef and destroy() it in the back click handler (lines 683-688) alongside diffRef.h?.destroy(). |
public/styles.css (or the existing project-detail CSS file) |
Add .proj-pr-chip + state modifier classes (.proj-pr-open/.draft/.merged/.closed/.none/.unavailable, .proj-pr-checks-ok/.fail/.pending, .proj-pr-conflict). Reuse the existing .proj-branch chip look (line 699) as the base. |
test/http/gh.test.ts |
New (node) — pure parsers + getPrStatus classification. |
test/integration/pr-status.test.ts |
New (node) — real startServer, gh stubbed via a PATH shim. |
test/gh-chip.test.ts |
New (jsdom) — normalizePrStatus/chipText/renderPrChip/mountPrChip. |
TDD steps
Ordered; each "write test → run RED → implement → GREEN". Backend-pure first (cheap, deterministic), then route, then FE. Keeps the 80 % gate because the pure parser + classifier are the bulk of the logic and are fully covered without spawning gh.
Backend — test/http/gh.test.ts (node; mirror test/http/diff.test.ts:1-33). Import from ../../src/http/gh.js.
summarizeChecks— cannedstatusCheckRolluparrays:- CheckRun
{status:'COMPLETED',conclusion:'SUCCESS'}→ passing++. ImplementsummarizeChecks. - CheckRun
conclusion:'FAILURE'→ failing++;TIMED_OUT/CANCELLED/ACTION_REQUIRED→ failing. - CheckRun
status:'IN_PROGRESS'/'QUEUED'(null conclusion) → pending. - StatusContext
{state:'SUCCESS'}→ passing;'PENDING'→ pending;'FAILURE'/'ERROR'→ failing. NEUTRAL/SKIPPED→ passing (don't block). Empty/undefinedrollup → all-zero. Unknown shape → counted intotalonly, treated as pending. Asserttotal === passing+failing+pending.
- CheckRun
parsePrView— feed a full canned JSON string:- Valid PR JSON →
availability:'ok', lower-casedstate/mergeable,number/title/url/isDraft/headRefName/baseRefNamemapped,checksfromsummarizeChecks. ImplementparsePrView(never throws —try/JSON.parse; malformed →{availability:'error'}, mirroringdiff.ts"never throws" house style). isDraft:truestillavailability:'ok'(FE decides the "draft" label);mergeable:'UNKNOWN'→'unknown'.- Malformed / non-object JSON →
{availability:'error'}. - Security assert: a PR
titlecontaining<script>alert(1)</script>survives verbatim inPrStatus.title(proves no parsing-side mangling; FE renders it inert).
- Valid PR JSON →
classifyGhFailure— given synthetic exec results:- spawn ENOENT (
code:'ENOENT') →'not-installed'. - stderr containing
gh auth login/not logged/authentication/HTTP 401→'unauthenticated'. - stderr containing
no pull requests found/no default remote/no git remote→'no-pr'. - other non-zero exit →
'error'. ImplementclassifyGhFailure(regex on lower-cased stderr).
- spawn ENOENT (
getPrStatuscache/dedupe — inject a fake runner (or spy) so no real gh spawns:ghEnabled:falsein cfg → resolves{availability:'disabled'}without invoking the runner.- Two rapid calls for the same path share one in-flight run (assert runner called once); after
_clearPrCache(), it runs again. Mirrorprojects.ts:289-311. Implement the module cache +_clearPrCache. - Cache key includes the current branch (cheap read of
.git/HEADlikereadBranch,projects.ts:88) so a branch switch busts the cache before TTL. Test: same path, different HEAD branch → runner re-invoked.
To keep
getPrStatusunit-testable without gh, factor the spawn into an injectablerunGh(default real, overridable in tests) — same seam idea asgetDiff'srunGit.parsePrView/summarizeChecks/classifyGhFailurestay pure and exported.
Route — test/integration/pr-status.test.ts (node; mirror test/integration/projects-endpoint.test.ts:1-55). Use getFreePort + a temp dir with a fake .git repo (reuse makeFakeGitRepo shape). Stub gh with a PATH shim: write an executable script named gh into a temp bin/ that echoes canned JSON (or exits 1 with a canned stderr), then set process.env.PATH = binDir + ':' + process.env.PATH before startServer (execFile resolves gh via PATH). Restore PATH + _clearPrCache() in afterEach.
- Missing
path→ 400. Implement route guard 1. - Non-git dir path → 404 (
isValidGitDirfails). Implement guard 2. - gh shim emits valid PR JSON → 200 with
availability:'ok', correctchecks. Wireres.json(await getPrStatus(...)). - gh shim exits 1 with
no pull requests foundon stderr → 200{availability:'no-pr'}. GH_ENABLED='0'env → 200{availability:'disabled'}, and (assert via a shim that writes a marker file) gh is never spawned.
Frontend — test/gh-chip.test.ts (jsdom; mirror test/diff.test.ts:1-14, // @vitest-environment jsdom, dynamic await import('../public/gh-chip.js'), vi.stubGlobal('fetch', …)).
normalizePrStatus— valid object round-trips; non-object / badavailability→null(mirrornormalizeDiffResult,diff.ts:38). Implement.chipText(status)— pure map:ok+open →"PR #12 ✓ 5/5"; failing checks →"PR #12 ✕ 3/5";mergeable:'conflicting'adds a⚠ conflictsmarker/class;no-pr→"No PR";not-installed→"gh not installed"+titlelink to cli.github.com;unauthenticated→"gh auth login";disabled→ chip hidden (returns null/display:none). Implement.renderPrChip— SEC-H4 assert: atitleof<img src=x onerror=...>appears as literal text (el.textContentcontains it,el.querySelector('img')is null). ZeroinnerHTML.mountPrChip—fetchstubbed to resolveokJSON → placeholder replaced by the chip;fetchrejects → degrades to anerrorchip (no throw);destroy()removes the node. MirrormountDiffViewer(diff.ts:253-).
Frontend wiring — extend test/projects.test.ts (jsdom; renderProjectDetail is already exported/tested there).
renderProjectDetailwithdetail.isGit:true→ a.proj-pr-chiphost is present in the header; withisGit:false→ absent. (Mockgh-chip'smountPrChipviavi.mockso the DOM assertion doesn't depend on fetch.) Implement therenderProjectDetailedit +prRef.destroy()in the back handler.
Run npm test after each GREEN. The pure backend parser tests (steps 1-3) carry most of the coverage weight for gh.ts; FE steps 10-13 cover gh-chip.ts.
Edge cases & failure modes
ghnot installed → spawn ENOENT →'not-installed'. Chip shows "gh not installed" (never a 500, never a stack trace).ghpresent, not authenticated → stderr auth pattern →'unauthenticated'→ "gh auth login".- No PR for branch / no remote / detached HEAD →
'no-pr'→ "No PR". (Detached HEAD:.git/HEADisn'tref:→ cache-key branch isnull; gh itself errors →no-pr.) - PR exists but checks not started / all pending →
checks.total>0, passing=0, pending=total→ "⧗ 0/N". mergeable:'UNKNOWN'(GitHub computes mergeability async right after a push) →'unknown'→ neutral marker, not a red "conflict". Only'conflicting'shows the ⚠.- Draft PR →
isDraft:true→ "Draft" styling; stillavailability:'ok'. - Merged/closed PR still on the branch →
state:'merged'/'closed'badge (gh returns the most recent PR). - gh timeout (network hang) →
execFileAsynckills atghTimeoutMs→'error'→ generic "PR status unavailable". Bounded, never hangs the request. - Huge
statusCheckRollup(100s of checks / monorepo) → bounded bymaxBuffer(reusediffMaxBytes); overflow →'error'(don't try to parse a truncated JSON). Counts are aggregate so the chip stays tiny. - Malformed
--jsonoutput (gh version drift) →parsePrViewJSON.parsethrows → caught →'error'. - Branch switch within TTL → cache key includes HEAD branch, so it busts immediately rather than showing the previous branch's PR for up to 10 s.
- FE fetch/network error →
fetchPrStatusreturnsnull→ chip renders an'error'state, never throws (mirrorfetchDiff,diff.ts:117).
Security
- No shell:
execFile('gh', [fixed argv])— identical guarantee torunGit(diff.ts:296, SEC-M9). The only user-influenced input reaching gh iscwd, which is the already-validatedrepoPath. No untrusted string is ever placed in argv (gh derives the PR from the branch; we never pass a branch/base/rev). This sidesteps the?base=deferral rationale indiff.ts:18-20. - Path containment: route calls
isValidGitDir(target)(server.ts:123) before spawning — absolute + is-dir + has.git(SEC-H7). Path traversal / arbitrary-cwd is blocked exactly as/projects/diff. - DoS bounds:
timeout: ghTimeoutMs(hard kill) +maxBuffer: cfg.diffMaxBytesbound a slow/huge gh. Module-scope TTL cache + in-flight dedupe cap outbound GitHub-API calls to ≈1 per repo perprojectScanTtlMseven under rapid detail-view refreshes (the panel auto-refreshes every 5 s,projects.ts:33). - Network egress note: unlike every other side-channel (all local), gh talks to GitHub's API using the host's existing
gh/GH_TOKENcredential. The endpoint never accepts or forwards a token — it only triggers gh's own auth. Document this in the route comment and in TECH_DOC §7 (this is the first feature to make an outbound call on behalf of a LAN client; theGH_ENABLED=0kill-switch lets a cautious operator disable it entirely). - No secret leakage: never log gh stdout (may contain private PR titles) or the token. If logging a failure, log only
availability+sanitizeForLog(stderr.slice(0,200))(reuseserver.ts:162) — never raw stderr, mirroring the worktree audit line (server.ts:714) and SEC-M10 "never raw git stderr". - Origin/CSRF: GET is read-only and non-mutating → no Origin guard, consistent with
/projectsand/projects/diff(server.ts:660). It spawns a subprocess but performs no state change, so CSWH/CSRF risk is limited to triggering a cached, rate-bounded read. - XSS: PR
titleis attacker-controllable (anyone who can open a PR on a repo the host has access to). It is carried verbatim server-side and rendered only viatextContentingh-chip.ts(SEC-H4, same discipline asdiff.tsline-rendering). Explicit jsdom test (step 12) asserts no element injection. - Rate-limit: no per-IP limiter needed (read-only, same as
/projects/diff); the TTL cache is the effective throttle. If desired later, thecreateRateLimiterhelper (server.ts:109) is available.
Effort & dependencies
- Effort: ~1.5–2 days. Backend
gh.ts+ route ≈ 0.75 day (the runner/cache pattern is a near-copy ofdiff.ts/projects.ts; the pureparsePrView/summarizeChecksclassifier is the real work). FEgh-chip.ts+ wiring + CSS ≈ 0.5 day. Tests (3 files) ≈ 0.5 day, with the PATH-shim integration harness the only novel piece. - Depends on: nothing hard-blocking —
Config/src/types.ts/config.tscoordination edits andisValidGitDirall exist today. Requiresghon the host for the live path, but the feature is designed to degrade cleanly without it (so it ships regardless). - Unlocks: the W3 "Quick wins" chip (task #11 — sync/ahead-behind chip, recent commits) can reuse the same
gh.ts/gh-chip.tsside-channel + short-TTL-cache scaffold (e.g.gh pr status,git rev-list --count @{u}...HEAD). The PATH-shim gh-integration harness is reusable by any future gh-backed feature. - Interacts with (no conflict): W3
?base=diff (task #9) touchesdiff.ts//projects/diffonly; this feature is an independent file (gh.ts) and route (/projects/pr). Both add a chip/section to the samerenderProjectDetailheader — coordinate the header layout (place the PR chip after the branch chip, before or beside the diff toggle) but they do not edit the same functions.