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

@@ -40,6 +40,7 @@ import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail } from './http/projects.js'
import { openInEditor, openFileInEditor } from './http/editor.js'
import { getDiff, isPlausibleRev } from './http/diff.js'
import { getPrStatus } from './http/gh.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree } from './http/worktrees.js'
import { createSessionManager } from './session/manager.js'
@@ -819,6 +820,32 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
}
})
// ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─
// Out-of-band side-channel: spawns the host's `gh` CLI to read the current
// branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes
// a NETWORK call to GitHub using the host's own gh/GH_TOKEN credential — this
// route NEVER accepts or forwards a token, only triggers gh's own auth, and
// GH_ENABLED=0 disables it entirely. Always 200 on a valid git dir: every
// degrade (gh missing / unauthed / no PR / disabled) lives in the response body
// (PrStatus.availability), not the HTTP status, so the FE renders one chip.
app.get('/projects/pr', async (req, res) => {
const target = req.query['path']
if (typeof target !== 'string' || target === '') {
res.status(400).json({ error: 'path query parameter is required' })
return
}
if (!(await isValidGitDir(target))) {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
try {
res.json(await getPrStatus(target, cfg))
} catch (err) {
console.error('[server] /projects/pr failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to read PR status' })
}
})
// ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ────────────────
app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {