feat(projects): PR + CI/checks status chip via gh (W3)

Per-project chip: PR state · N checks passing · mergeable — glance from the phone,
re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`.

- src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft,
  mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd =
  isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed
  CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws —
  degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled.
  Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe.
- src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED
  (default on) + GH_TIMEOUT_MS (8s).
- GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only
  chip mounted in the project detail header (git repos only).

Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only
the validated cwd); gh stdout/token never logged; the attacker-controllable PR title
is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean,
1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake.
This commit is contained in:
Yaojia Wang
2026-07-12 21:01:18 +02:00
parent b119c31019
commit 7551f8a4b2
12 changed files with 1423 additions and 0 deletions

View File

@@ -20,6 +20,10 @@ class FakeTerminal {
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
// Stub the PR-status chip (W3) so renderProjectDetail's header mount doesn't hit
// the network; the chip's own logic is covered in test/gh-chip.test.ts.
vi.mock('../public/gh-chip.js', () => ({ mountPrChip: vi.fn(() => ({ destroy: vi.fn() })) }))
// Dynamic import AFTER mock declaration so the factory is in place.
const {
filterProjects,
@@ -489,6 +493,16 @@ describe('renderProjectDetail', () => {
expect(root.textContent).toContain('Not a git repository')
})
it('mounts a PR-status chip host in the header for a git repo (W3)', () => {
const root = renderProjectDetail(detail(), hooks(), cbs())
expect(root.querySelector('.proj-pr-host')).not.toBeNull()
})
it('omits the PR-status chip host for a non-git directory (W3)', () => {
const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs())
expect(root.querySelector('.proj-pr-host')).toBeNull()
})
it('shows CLAUDE.md content + an Update button when present', () => {
const root = renderProjectDetail(detail({ hasClaudeMd: true, claudeMd: '# Rules\nbe nice' }), hooks(), cbs())
expect(root.querySelector('.proj-claudemd')?.textContent).toContain('be nice')