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

262
public/gh-chip.ts Normal file
View File

@@ -0,0 +1,262 @@
/**
* public/gh-chip.ts (W3 PR + CI status chip) — render-only, mirrors public/diff.ts.
*
* Receives a pre-parsed PrStatus from the server (GET /projects/pr) and renders a
* compact chip: PR state · N checks passing · mergeable. Zero PR parsing lives
* here (parsing is in src/http/gh.ts). It NEVER throws and degrades to a
* self-explaining chip when gh is missing / unauthenticated / has no PR.
*
* Security: SEC-H4 — ALL text content is set via textContent / el(). Zero
* innerHTML anywhere in this file. A PR `title` is attacker-controllable (anyone
* who can open a PR on a repo the host can read); it appears as literal text.
*/
import type { PrAvailability, PrCheckSummary, PrStatus } from '../src/types.js'
/* ── constants ───────────────────────────────────────────────────────────────── */
const GH_INSTALL_URL = 'https://cli.github.com'
const VALID_AVAILABILITY: ReadonlySet<string> = new Set<PrAvailability>([
'ok',
'no-pr',
'not-installed',
'unauthenticated',
'disabled',
'error',
])
/* ── DOM helper ──────────────────────────────────────────────────────────────── */
/** Create an element with an optional CSS class and text content. */
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
cls?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag)
if (cls) node.className = cls
if (text !== undefined) node.textContent = text
return node
}
/* ── normalizePrStatus ───────────────────────────────────────────────────────── */
function normalizeChecks(raw: unknown): PrCheckSummary | undefined {
if (raw === null || typeof raw !== 'object') return undefined
const o = raw as Record<string, unknown>
if (
typeof o['total'] !== 'number' ||
typeof o['passing'] !== 'number' ||
typeof o['failing'] !== 'number' ||
typeof o['pending'] !== 'number'
) {
return undefined
}
return {
total: o['total'],
passing: o['passing'],
failing: o['failing'],
pending: o['pending'],
}
}
/**
* Coerce an untrusted API response into a PrStatus, or return null. Never throws.
* Mirrors normalizeDiffResult (diff.ts): a non-object or an unknown `availability`
* ⇒ null; otherwise the known optional fields are copied when well-typed.
*/
export function normalizePrStatus(raw: unknown): PrStatus | null {
if (raw === null || typeof raw !== 'object') return null
const o = raw as Record<string, unknown>
if (typeof o['availability'] !== 'string' || !VALID_AVAILABILITY.has(o['availability'])) {
return null
}
const status: PrStatus = { availability: o['availability'] as PrAvailability }
if (typeof o['number'] === 'number') status.number = o['number']
if (typeof o['title'] === 'string') status.title = o['title']
if (typeof o['url'] === 'string') status.url = o['url']
if (o['state'] === 'open' || o['state'] === 'closed' || o['state'] === 'merged') {
status.state = o['state']
}
if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft']
if (
o['mergeable'] === 'mergeable' ||
o['mergeable'] === 'conflicting' ||
o['mergeable'] === 'unknown'
) {
status.mergeable = o['mergeable']
}
if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName']
if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName']
const checks = normalizeChecks(o['checks'])
if (checks !== undefined) status.checks = checks
return status
}
/* ── fetchPrStatus ───────────────────────────────────────────────────────────── */
/**
* Fetch the PR + CI status for a repo path. Returns null on any error or invalid
* response (mirrors fetchDiff) — the caller degrades that to an 'error' chip.
*/
export async function fetchPrStatus(repoPath: string): Promise<PrStatus | null> {
try {
const res = await fetch(`/projects/pr?path=${encodeURIComponent(repoPath)}`)
if (!res.ok) return null
const data: unknown = await res.json()
return normalizePrStatus(data)
} catch {
return null
}
}
/* ── chipText (pure) ─────────────────────────────────────────────────────────── */
/** The rendered chip's label, modifier CSS classes, and tooltip title. `null`
* when the chip should be hidden (availability 'disabled'). */
export interface ChipText {
label: string
cls: string
title: string
}
/** PR-number prefix, reflecting draft / merged / closed state. */
function statePrefix(status: PrStatus): string {
const n = typeof status.number === 'number' ? `#${status.number}` : ''
if (status.isDraft === true) return `Draft ${n}`.trim()
if (status.state === 'merged') return `Merged ${n}`.trim()
if (status.state === 'closed') return `Closed ${n}`.trim()
return `PR ${n}`.trim()
}
/** State → modifier class (draft wins over the raw open/closed/merged state). */
function stateClass(status: PrStatus): string {
if (status.isDraft === true) return 'proj-pr-draft'
if (status.state === 'merged') return 'proj-pr-merged'
if (status.state === 'closed') return 'proj-pr-closed'
return 'proj-pr-open'
}
/** Checks segment (glyph + passing/total) and its class; null when total === 0. */
function checksSegment(checks: PrCheckSummary): { text: string; cls: string } | null {
if (checks.total <= 0) return null
if (checks.failing > 0) {
return { text: `${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-fail' }
}
if (checks.pending > 0) {
return { text: `${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-pending' }
}
return { text: `${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-ok' }
}
const DEGRADED: Record<Exclude<PrAvailability, 'ok' | 'disabled'>, ChipText> = {
'no-pr': { label: 'No PR', cls: 'proj-pr-none', title: 'No pull request for the current branch' },
'not-installed': {
label: 'gh not installed',
cls: 'proj-pr-unavailable',
title: `Install the GitHub CLI to see PR status: ${GH_INSTALL_URL}`,
},
unauthenticated: {
label: 'gh auth login',
cls: 'proj-pr-unavailable',
title: 'Run `gh auth login` on the host to see PR status',
},
error: {
label: 'PR status unavailable',
cls: 'proj-pr-unavailable',
title: 'Could not read PR status',
},
}
/**
* Pure map from a PrStatus to the chip's {label, cls, title}. Returns null for
* 'disabled' (the chip is hidden). For 'ok' the label combines the PR-state
* prefix, the checks glyph (✓ / ✕ / ⧗ passing/total), and a "⚠ conflicts" marker
* when mergeable is 'conflicting'; cls collects the matching modifier classes.
*/
export function chipText(status: PrStatus): ChipText | null {
if (status.availability === 'disabled') return null
if (status.availability !== 'ok') return DEGRADED[status.availability]
const parts: string[] = [statePrefix(status)]
const classes: string[] = [stateClass(status)]
if (status.checks !== undefined) {
const seg = checksSegment(status.checks)
if (seg !== null) {
parts.push(seg.text)
classes.push(seg.cls)
}
}
if (status.mergeable === 'conflicting') {
parts.push('⚠ conflicts')
classes.push('proj-pr-conflict')
}
return {
label: parts.join(' '),
cls: classes.join(' '),
title: status.title !== undefined && status.title !== '' ? status.title : parts[0] ?? 'PR',
}
}
/* ── renderPrChip ────────────────────────────────────────────────────────────── */
/**
* Render a PrStatus into a chip element. 'disabled' ⇒ an empty display:none span.
*
* Security: SEC-H4 — all text via textContent. The PR `title` (attacker-
* controllable) is set as the tooltip attribute AND appended as an inert text
* span; it can never inject an element.
*/
export function renderPrChip(status: PrStatus): HTMLElement {
const info = chipText(status)
if (info === null) {
const hidden = el('span', 'proj-pr-chip proj-pr-hidden')
hidden.style.display = 'none'
return hidden
}
const chip = el('span', `proj-pr-chip ${info.cls}`)
chip.title = info.title // attribute — never HTML-parsed (SEC-H4)
chip.append(el('span', 'proj-pr-label', info.label))
// The PR title is attacker-controllable; render it as literal text (SEC-H4).
if (status.availability === 'ok' && typeof status.title === 'string' && status.title !== '') {
chip.append(el('span', 'proj-pr-title', status.title))
}
return chip
}
/* ── mountPrChip ─────────────────────────────────────────────────────────────── */
/** Handle returned by mountPrChip for cleanup. */
export interface PrChipHandle {
destroy(): void
}
/**
* Mount a PR-status chip into `container`: show a loading placeholder, fetch the
* status, then swap in the resolved chip. A fetch failure degrades to an 'error'
* chip (never throws). destroy() removes the node and cancels the swap.
*/
export function mountPrChip(container: HTMLElement, repoPath: string): PrChipHandle {
let destroyed = false
container.textContent = ''
container.append(el('span', 'proj-pr-chip proj-pr-loading', '…'))
void (async () => {
const status = await fetchPrStatus(repoPath)
if (destroyed) return
container.textContent = ''
container.append(renderPrChip(status ?? { availability: 'error' }))
})()
return {
destroy() {
destroyed = true
container.textContent = ''
},
}
}

Binary file not shown.

View File

@@ -1534,6 +1534,64 @@ body {
flex: none;
}
/* W3: PR + CI status chip (mirrors the .proj-branch chip look). */
.proj-pr-host {
display: inline-flex;
flex: none;
}
.proj-pr-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
border-radius: 5px;
padding: 2px 7px;
white-space: nowrap;
max-width: 260px;
color: var(--text-faint);
background: var(--accent-soft);
}
.proj-pr-chip .proj-pr-title {
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-faint);
}
.proj-pr-loading {
opacity: 0.6;
}
/* PR-state modifiers */
.proj-pr-open .proj-pr-label {
color: var(--green);
}
.proj-pr-draft .proj-pr-label {
color: var(--text-faint);
}
.proj-pr-merged .proj-pr-label {
color: var(--accent);
}
.proj-pr-closed .proj-pr-label {
color: var(--red);
}
.proj-pr-none,
.proj-pr-unavailable {
color: var(--text-faint);
background: transparent;
}
/* Checks-rollup modifiers (colour the whole chip label) */
.proj-pr-checks-ok .proj-pr-label {
color: var(--green);
}
.proj-pr-checks-fail .proj-pr-label {
color: var(--red);
}
.proj-pr-checks-pending .proj-pr-label {
color: var(--amber);
}
/* Mergeable = conflicting */
.proj-pr-conflict {
box-shadow: inset 0 0 0 1px var(--red);
}
/* Meta line (last-active time) */
.proj-meta {
font-size: 11px;

View File

@@ -59,6 +59,8 @@ const DEFAULT_STUCK_TTL_SEC = 600 // 10 minutes (env var in seconds)
const DEFAULT_DIFF_TIMEOUT_MS = 2_000
const DEFAULT_DIFF_MAX_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_DIFF_MAX_FILES = 300
// W3 PR + CI status chip (gh) — larger timeout than diff: gh hits the network
const DEFAULT_GH_TIMEOUT_MS = 8_000
// B2 statusline telemetry
const DEFAULT_STATUSLINE_TTL_MS = 30_000
// B3 worktrees
@@ -365,6 +367,10 @@ export function loadConfig(env: EnvLike): Config {
DEFAULT_DIFF_MAX_FILES,
)
// W3 PR + CI status chip (gh)
const ghEnabled = parseBool(env['GH_ENABLED'], true)
const ghTimeoutMs = parseNonNegativeInt(env['GH_TIMEOUT_MS'], 'GH_TIMEOUT_MS', DEFAULT_GH_TIMEOUT_MS)
// B2 statusLine telemetry
const statuslineTtlMs = parseNonNegativeInt(
env['STATUSLINE_TTL_MS'],
@@ -443,6 +449,8 @@ export function loadConfig(env: EnvLike): Config {
diffTimeoutMs,
diffMaxBytes,
diffMaxFiles,
ghEnabled,
ghTimeoutMs,
statuslineTtlMs,
worktreeEnabled,
worktreeRoot,

316
src/http/gh.ts Normal file
View File

@@ -0,0 +1,316 @@
/**
* src/http/gh.ts (W3 PR + CI status chip) — read-only PR / CI status via `gh`.
*
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
* `gh pr view --json …` in a repo directory and PARSES its JSON into a PrStatus.
* Structure mirrors src/http/diff.ts:
* - runGh: execFile('gh', [fixed argv]) — NO shell; timeout + maxBuffer bound
* DoS (SEC-M9). The ONLY user-influenced input reaching gh is `cwd`, the
* already-validated repoPath (route layer, SEC-H7). No untrusted string ever
* enters argv — gh derives the PR from the current branch.
* - pure, exported: parsePrView / summarizeChecks / classifyGhFailure. They
* NEVER throw: malformed / unknown input degrades to {availability:'error'}.
* - getPrStatus: module-scope short-TTL cache (cfg.projectScanTtlMs) keyed by
* repoPath + current branch (so a branch switch busts before TTL), with
* in-flight dedupe (cache-stampede guard) — caps outbound GitHub-API calls.
*
* Network egress note: unlike every other side-channel (all local), gh talks to
* GitHub's API using the host's existing gh / GH_TOKEN credential. This module
* NEVER accepts or forwards a token — it only triggers gh's own auth. It also
* NEVER logs gh stdout (private PR titles) or the token.
*/
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { Config, PrAvailability, PrCheckSummary, PrStatus } from '../types.js'
const execFileAsync = promisify(execFile)
/** One `gh pr view` spawn: exactly the fields parsePrView / summarizeChecks read. */
const PR_VIEW_FIELDS =
'number,state,title,url,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup'
/** Config subset getPrStatus needs; the full Config satisfies this Pick. */
export type GhOptions = Pick<Config, 'ghEnabled' | 'ghTimeoutMs' | 'projectScanTtlMs' | 'diffMaxBytes'>
// ── gh runner (injectable seam — default real, overridable in tests) ──────────
/** Normalized outcome of one gh spawn. `code` carries a spawn/system error code
* ('ENOENT', 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') or the numeric exit code. */
export interface GhExecResult {
ok: boolean // process exited 0
stdout: string
stderr: string
code?: string | number
}
/** A gh runner: same seam idea as getDiff's runGit, so getPrStatus is unit-testable
* without spawning gh. */
export type GhRunner = (
repoPath: string,
args: readonly string[],
timeoutMs: number,
maxBytes: number,
) => Promise<GhExecResult>
function asString(v: unknown): string {
return typeof v === 'string' ? v : ''
}
/**
* Real gh runner: execFile('gh', [...]) with NO shell, bounded by timeout +
* maxBuffer. A spawn ENOENT (gh not installed), a non-zero exit (unauth / no PR),
* a timeout, or a maxBuffer overflow all resolve (never reject) with ok:false so
* the classifier can degrade — mirroring diff.ts's best-effort house style.
*/
async function realRunGh(
repoPath: string,
args: readonly string[],
timeoutMs: number,
maxBytes: number,
): Promise<GhExecResult> {
try {
const { stdout, stderr } = await execFileAsync('gh', [...args], {
cwd: repoPath,
timeout: timeoutMs,
maxBuffer: maxBytes,
})
return { ok: true, stdout, stderr, code: 0 }
} catch (err: unknown) {
const e = err as { code?: unknown; stdout?: unknown; stderr?: unknown }
const code =
typeof e.code === 'string' || typeof e.code === 'number' ? e.code : undefined
return { ok: false, stdout: asString(e.stdout), stderr: asString(e.stderr), code }
}
}
// ── classifyGhFailure (pure) ──────────────────────────────────────────────────
/** A gh non-zero / spawn failure, reduced to what the classifier reads. */
export interface GhFailure {
code?: string | number
stderr: string
}
/**
* Map a gh failure to a degrade reason (regex on lower-cased stderr). ENOENT ⇒
* gh isn't installed; an auth pattern ⇒ not logged in; a "no PR / no remote"
* pattern ⇒ no PR for the branch; anything else (timeout, maxBuffer overflow,
* unknown) ⇒ generic error. Never throws.
*/
export function classifyGhFailure(f: GhFailure): PrAvailability {
if (f.code === 'ENOENT') return 'not-installed'
const s = asString(f.stderr).toLowerCase()
if (/gh auth login|not logged|authentication|http 401/.test(s)) return 'unauthenticated'
if (/no pull requests found|no default remote|no git remote/.test(s)) return 'no-pr'
return 'error'
}
// ── summarizeChecks (pure) ────────────────────────────────────────────────────
type CheckBucket = 'passing' | 'failing' | 'pending'
const CHECKRUN_PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
const CHECKRUN_FAIL = new Set([
'FAILURE',
'TIMED_OUT',
'CANCELLED',
'ACTION_REQUIRED',
'STARTUP_FAILURE',
'STALE',
])
const CONTEXT_FAIL = new Set(['FAILURE', 'ERROR'])
/** A CheckRun's status/conclusion → bucket. A non-COMPLETED status (QUEUED,
* IN_PROGRESS, WAITING, PENDING, REQUESTED) is pending regardless of conclusion;
* a completed/absent status is bucketed by conclusion (null/unknown → pending). */
function bucketCheckRun(status: unknown, conclusion: unknown): CheckBucket {
const st = asString(status).toUpperCase()
if (st !== '' && st !== 'COMPLETED') return 'pending'
const c = asString(conclusion).toUpperCase()
if (CHECKRUN_PASS.has(c)) return 'passing'
if (CHECKRUN_FAIL.has(c)) return 'failing'
return 'pending'
}
/** A StatusContext's state → bucket (SUCCESS pass, FAILURE/ERROR fail, else pending). */
function bucketContext(state: unknown): CheckBucket {
const s = asString(state).toUpperCase()
if (s === 'SUCCESS') return 'passing'
if (CONTEXT_FAIL.has(s)) return 'failing'
return 'pending' // PENDING, EXPECTED, unknown
}
/** Classify one rollup item. Prefers __typename, else infers from present keys.
* Anything unrecognized still counts toward total, treated as pending. */
function bucketItem(raw: unknown): CheckBucket {
if (raw === null || typeof raw !== 'object') return 'pending'
const o = raw as Record<string, unknown>
const typename = asString(o['__typename'])
const isContext =
typename === 'StatusContext' ||
(typename !== 'CheckRun' &&
o['state'] !== undefined &&
o['status'] === undefined &&
o['conclusion'] === undefined)
if (isContext) return bucketContext(o['state'])
if (typename === 'CheckRun' || o['status'] !== undefined || o['conclusion'] !== undefined) {
return bucketCheckRun(o['status'], o['conclusion'])
}
return 'pending'
}
/**
* Roll up gh's `statusCheckRollup` (a mix of CheckRun + StatusContext items) into
* total / passing / failing / pending counts. Non-array / empty / undefined ⇒
* all-zero. Every item lands in exactly one bucket so total === pass+fail+pending.
* Never throws.
*/
export function summarizeChecks(rollup: unknown): PrCheckSummary {
const summary: PrCheckSummary = { total: 0, passing: 0, failing: 0, pending: 0 }
if (!Array.isArray(rollup)) return summary
for (const item of rollup) {
summary.total += 1
summary[bucketItem(item)] += 1
}
return summary
}
// ── parsePrView (pure) ────────────────────────────────────────────────────────
function mapState(v: unknown): PrStatus['state'] {
if (typeof v !== 'string') return undefined
const s = v.toLowerCase()
return s === 'open' || s === 'closed' || s === 'merged' ? s : undefined
}
function mapMergeable(v: unknown): PrStatus['mergeable'] {
if (typeof v !== 'string') return undefined
const s = v.toLowerCase()
return s === 'mergeable' || s === 'conflicting' || s === 'unknown' ? s : undefined
}
/**
* Parse `gh pr view --json …` output into a PrStatus. Valid PR JSON ⇒
* availability:'ok' with lower-cased state/mergeable and a checks summary; a
* malformed / non-object payload ⇒ {availability:'error'} (never throws — the
* diff.ts "never throws" house style). Attacker-controllable strings (title/url)
* are carried VERBATIM — the FE renders them inert via textContent (SEC-H4).
*/
export function parsePrView(json: string): PrStatus {
let raw: unknown
try {
raw = JSON.parse(json)
} catch {
return { availability: 'error' }
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
return { availability: 'error' }
}
const o = raw as Record<string, unknown>
const status: PrStatus = { availability: 'ok' }
if (typeof o['number'] === 'number') status.number = o['number']
if (typeof o['title'] === 'string') status.title = o['title']
if (typeof o['url'] === 'string') status.url = o['url']
const state = mapState(o['state'])
if (state !== undefined) status.state = state
if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft']
const mergeable = mapMergeable(o['mergeable'])
if (mergeable !== undefined) status.mergeable = mergeable
if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName']
if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName']
status.checks = summarizeChecks(o['statusCheckRollup'])
return status
}
// ── current-branch read (cheap; cache-key input) ──────────────────────────────
/** Current branch from `<repo>/.git/HEAD` (ref line only). Detached HEAD / junk /
* unreadable ⇒ null. Cheap: no spawn (mirrors projects.ts readBranch/parseGitHead). */
async function readHeadBranch(repoPath: string): Promise<string | null> {
try {
const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8')
const match = /^ref:\s+refs\/heads\/(.+)$/.exec(head.trim())
const branch = match?.[1]?.trim()
return branch !== undefined && branch !== '' ? branch : null
} catch {
return null
}
}
// ── getPrStatus (cached runner) ───────────────────────────────────────────────
interface PrCacheEntry {
expiresAt: number
status: PrStatus
}
const prCache = new Map<string, PrCacheEntry>()
/** Shared in-flight promises so concurrent cache-miss callers join one gh run. */
const inflightPr = new Map<string, Promise<PrStatus>>()
/** repoPath + branch: a branch switch changes the key, busting stale PR data. */
function cacheKey(repoPath: string, branch: string | null): string {
return `${repoPath}\n${branch ?? ''}`
}
async function runPrStatus(
repoPath: string,
cfg: GhOptions,
runner: GhRunner,
): Promise<PrStatus> {
const exec = await runner(
repoPath,
['pr', 'view', '--json', PR_VIEW_FIELDS],
cfg.ghTimeoutMs,
cfg.diffMaxBytes,
)
if (exec.ok) return parsePrView(exec.stdout)
return { availability: classifyGhFailure({ code: exec.code, stderr: exec.stderr }) }
}
/**
* Read a repo's PR + CI status. `repoPath` must already be a validated absolute
* git directory (route layer, SEC-H7). Best-effort: gh missing / unauthed / no PR
* / timeout all resolve to a PrStatus whose `availability` names the reason —
* NEVER throws, never blocks the caller. Cached at module scope with
* cfg.projectScanTtlMs and in-flight dedupe. `runner` is injectable for tests.
*/
export async function getPrStatus(
repoPath: string,
cfg: GhOptions,
runner: GhRunner = realRunGh,
): Promise<PrStatus> {
if (!cfg.ghEnabled) return { availability: 'disabled' }
const branch = await readHeadBranch(repoPath)
const key = cacheKey(repoPath, branch)
const now = Date.now()
const cached = prCache.get(key)
if (cached !== undefined && cached.expiresAt > now) return cached.status
const inflight = inflightPr.get(key)
if (inflight !== undefined) return inflight
const promise = runPrStatus(repoPath, cfg, runner)
.then((status) => {
prCache.set(key, { expiresAt: Date.now() + cfg.projectScanTtlMs, status })
inflightPr.delete(key)
return status
})
.catch((e: unknown) => {
inflightPr.delete(key)
throw e
})
inflightPr.set(key, promise)
return promise
}
/** Test-only: drop the PR cache and any in-flight run so each test sees a fresh gh call. */
export function _clearPrCache(): void {
prCache.clear()
inflightPr.clear()
}

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 ?? '')) {

View File

@@ -61,6 +61,9 @@ export interface Config {
readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000
readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB
readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300
// W3 PR + CI status chip (gh)
readonly ghEnabled: boolean; // GH_ENABLED, default true (false → never spawns gh)
readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff)
// B2 statusLine telemetry
readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
// B3 git worktree creation
@@ -535,6 +538,39 @@ export interface DiffResult {
base?: string; // echoed when the diff was against a base revision (?base=<rev>)
}
/* ── 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;
}
/* ── B3 worktree creation (§3.5) ── */
/** Result of POST /projects/worktree (B3). `error` carries a safe message only

237
test/gh-chip.test.ts Normal file
View File

@@ -0,0 +1,237 @@
// @vitest-environment jsdom
/**
* test/gh-chip.test.ts (W3 PR + CI status chip) — render-only frontend module.
*
* Covers normalizePrStatus, chipText, renderPrChip, fetchPrStatus, mountPrChip.
* Security: SEC-H4 — all chip content via textContent; a PR title with markup is
* rendered as literal text (no element injection).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { PrStatus } from '../src/types.js'
const {
normalizePrStatus,
chipText,
renderPrChip,
fetchPrStatus,
mountPrChip,
} = await import('../public/gh-chip.js')
function okStatus(over: Partial<PrStatus> = {}): PrStatus {
return {
availability: 'ok',
number: 12,
title: 'A PR',
url: 'https://github.com/o/r/pull/12',
state: 'open',
isDraft: false,
mergeable: 'mergeable',
checks: { total: 5, passing: 5, failing: 0, pending: 0 },
...over,
}
}
// ── normalizePrStatus ─────────────────────────────────────────────────────────
describe('normalizePrStatus', () => {
it('round-trips a valid ok object', () => {
const s = normalizePrStatus(okStatus())
expect(s).not.toBeNull()
expect(s!.availability).toBe('ok')
expect(s!.number).toBe(12)
expect(s!.state).toBe('open')
expect(s!.mergeable).toBe('mergeable')
expect(s!.checks).toEqual({ total: 5, passing: 5, failing: 0, pending: 0 })
})
it('accepts every degrade availability', () => {
for (const availability of ['no-pr', 'not-installed', 'unauthenticated', 'disabled', 'error']) {
expect(normalizePrStatus({ availability })?.availability).toBe(availability)
}
})
it('returns null for a non-object or an unknown availability', () => {
expect(normalizePrStatus(null)).toBeNull()
expect(normalizePrStatus(42)).toBeNull()
expect(normalizePrStatus('ok')).toBeNull()
expect(normalizePrStatus({})).toBeNull()
expect(normalizePrStatus({ availability: 'bogus' })).toBeNull()
})
it('drops malformed optional fields but keeps availability', () => {
const s = normalizePrStatus({ availability: 'ok', number: 'nope', state: 'weird', checks: 5 })
expect(s?.availability).toBe('ok')
expect(s?.number).toBeUndefined()
expect(s?.state).toBeUndefined()
expect(s?.checks).toBeUndefined()
})
})
// ── chipText ──────────────────────────────────────────────────────────────────
describe('chipText', () => {
it('renders an open PR with all checks passing', () => {
const t = chipText(okStatus())!
expect(t.label).toBe('PR #12 ✓ 5/5')
expect(t.cls).toContain('proj-pr-open')
expect(t.cls).toContain('proj-pr-checks-ok')
})
it('marks failing checks with ✕ and the fail class', () => {
const t = chipText(okStatus({ checks: { total: 5, passing: 3, failing: 2, pending: 0 } }))!
expect(t.label).toBe('PR #12 ✕ 3/5')
expect(t.cls).toContain('proj-pr-checks-fail')
})
it('marks pending checks with ⧗ and the pending class', () => {
const t = chipText(okStatus({ checks: { total: 4, passing: 1, failing: 0, pending: 3 } }))!
expect(t.label).toBe('PR #12 ⧗ 1/4')
expect(t.cls).toContain('proj-pr-checks-pending')
})
it('adds a "⚠ conflicts" marker + class for a conflicting PR', () => {
const t = chipText(okStatus({ mergeable: 'conflicting' }))!
expect(t.label).toContain('⚠ conflicts')
expect(t.cls).toContain('proj-pr-conflict')
})
it('labels a draft PR', () => {
const t = chipText(okStatus({ isDraft: true }))!
expect(t.label).toContain('Draft #12')
expect(t.cls).toContain('proj-pr-draft')
})
it('labels merged / closed PRs', () => {
expect(chipText(okStatus({ state: 'merged' }))!.label).toContain('Merged #12')
expect(chipText(okStatus({ state: 'closed' }))!.cls).toContain('proj-pr-closed')
})
it('omits the checks segment when there are zero checks', () => {
const t = chipText(okStatus({ checks: { total: 0, passing: 0, failing: 0, pending: 0 } }))!
expect(t.label).toBe('PR #12')
})
it('maps each degrade availability to its label', () => {
expect(chipText({ availability: 'no-pr' })!.label).toBe('No PR')
const notInstalled = chipText({ availability: 'not-installed' })!
expect(notInstalled.label).toBe('gh not installed')
expect(notInstalled.title).toContain('cli.github.com')
expect(chipText({ availability: 'unauthenticated' })!.label).toBe('gh auth login')
expect(chipText({ availability: 'error' })!.label).toBe('PR status unavailable')
})
it('returns null for a disabled status (chip hidden)', () => {
expect(chipText({ availability: 'disabled' })).toBeNull()
})
})
// ── renderPrChip ──────────────────────────────────────────────────────────────
describe('renderPrChip', () => {
it('renders a chip element with the label and state class', () => {
const chip = renderPrChip(okStatus())
expect(chip.className).toContain('proj-pr-chip')
expect(chip.className).toContain('proj-pr-open')
expect(chip.querySelector('.proj-pr-label')?.textContent).toBe('PR #12 ✓ 5/5')
})
it('renders a hidden chip for a disabled status', () => {
const chip = renderPrChip({ availability: 'disabled' })
expect(chip.style.display).toBe('none')
expect(chip.querySelector('.proj-pr-label')).toBeNull()
})
it('renders an attacker PR title as LITERAL text — no element injection (SEC-H4)', () => {
const evil = '<img src=x onerror=alert(1)>'
const chip = renderPrChip(okStatus({ title: evil }))
expect(chip.textContent).toContain(evil)
expect(chip.querySelector('img')).toBeNull()
// The tooltip attribute is also inert (attributes are never HTML-parsed).
expect(chip.title).toBe(evil)
})
})
// ── fetchPrStatus ─────────────────────────────────────────────────────────────
describe('fetchPrStatus', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('normalizes a 200 JSON response', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
)
const s = await fetchPrStatus('/p/repo')
expect(s?.availability).toBe('ok')
expect(s?.number).toBe(12)
})
it('returns null on a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) })))
expect(await fetchPrStatus('/p/repo')).toBeNull()
})
it('returns null when fetch rejects (never throws)', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('network down')
}),
)
expect(await fetchPrStatus('/p/repo')).toBeNull()
})
})
// ── mountPrChip ───────────────────────────────────────────────────────────────
describe('mountPrChip', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
let host: HTMLElement
beforeEach(() => {
host = document.createElement('span')
document.body.append(host)
})
it('shows a loading placeholder, then swaps in the resolved chip', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
)
const handle = mountPrChip(host, '/p/repo')
expect(host.querySelector('.proj-pr-loading')).not.toBeNull()
await new Promise((r) => setTimeout(r, 0))
expect(host.querySelector('.proj-pr-loading')).toBeNull()
expect(host.querySelector('.proj-pr-open')).not.toBeNull()
handle.destroy()
})
it('degrades to an error chip when fetch rejects (no throw)', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('boom')
}),
)
mountPrChip(host, '/p/repo')
await new Promise((r) => setTimeout(r, 0))
expect(host.querySelector('.proj-pr-unavailable')).not.toBeNull()
expect(host.textContent).toContain('PR status unavailable')
})
it('destroy() removes the chip node', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json: async () => okStatus() })),
)
const handle = mountPrChip(host, '/p/repo')
handle.destroy()
await new Promise((r) => setTimeout(r, 0))
expect(host.childNodes.length).toBe(0)
})
})

285
test/http/gh.test.ts Normal file
View File

@@ -0,0 +1,285 @@
/**
* test/http/gh.test.ts (W3 PR + CI status chip) — pure parsers + getPrStatus.
*
* Two layers (mirrors diff.test.ts):
* 1. Pure, deterministic: summarizeChecks / parsePrView / classifyGhFailure fed
* canned gh output — the bulk of gh.ts logic, fully covered without spawning
* gh. Never throws; <script> in a PR title survives verbatim.
* 2. getPrStatus with an INJECTED fake runner (no real gh) against a throwaway
* .git/HEAD in os.tmpdir — cache TTL, in-flight dedupe, branch-keyed busting,
* and the GH_ENABLED=false short-circuit.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs/promises'
import {
summarizeChecks,
parsePrView,
classifyGhFailure,
getPrStatus,
_clearPrCache,
type GhExecResult,
type GhOptions,
type GhRunner,
} from '../../src/http/gh.js'
const CFG: GhOptions = {
ghEnabled: true,
ghTimeoutMs: 8000,
projectScanTtlMs: 10_000,
diffMaxBytes: 2 * 1024 * 1024,
}
// ── summarizeChecks ───────────────────────────────────────────────────────────
describe('summarizeChecks', () => {
it('counts a completed CheckRun SUCCESS as passing', () => {
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' }])
expect(s).toEqual({ total: 1, passing: 1, failing: 0, pending: 0 })
})
it('counts failing CheckRun conclusions as failing', () => {
for (const conclusion of ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED']) {
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion }])
expect(s.failing).toBe(1)
expect(s.total).toBe(1)
}
})
it('counts an in-progress / queued CheckRun (null conclusion) as pending', () => {
for (const status of ['IN_PROGRESS', 'QUEUED']) {
const s = summarizeChecks([{ __typename: 'CheckRun', status, conclusion: null }])
expect(s.pending).toBe(1)
expect(s.total).toBe(1)
}
})
it('treats NEUTRAL and SKIPPED conclusions as passing (do not block)', () => {
for (const conclusion of ['NEUTRAL', 'SKIPPED']) {
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion }])
expect(s.passing).toBe(1)
}
})
it('buckets StatusContext by state', () => {
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'SUCCESS' }]).passing).toBe(1)
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'PENDING' }]).pending).toBe(1)
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'FAILURE' }]).failing).toBe(1)
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'ERROR' }]).failing).toBe(1)
})
it('returns all-zero for an empty / undefined / non-array rollup', () => {
const zero = { total: 0, passing: 0, failing: 0, pending: 0 }
expect(summarizeChecks([])).toEqual(zero)
expect(summarizeChecks(undefined)).toEqual(zero)
expect(summarizeChecks(null)).toEqual(zero)
expect(summarizeChecks('nope')).toEqual(zero)
})
it('counts an unknown-shape item in total, treated as pending', () => {
const s = summarizeChecks([{ __typename: 'Mystery', foo: 1 }, 42, null])
expect(s.total).toBe(3)
expect(s.pending).toBe(3)
})
it('keeps total === passing + failing + pending across a mixed rollup', () => {
const s = summarizeChecks([
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'FAILURE' },
{ __typename: 'CheckRun', status: 'IN_PROGRESS', conclusion: null },
{ __typename: 'StatusContext', state: 'SUCCESS' },
{ __typename: 'StatusContext', state: 'PENDING' },
{ weird: true },
])
expect(s.total).toBe(6)
expect(s.passing + s.failing + s.pending).toBe(s.total)
expect(s).toEqual({ total: 6, passing: 2, failing: 1, pending: 3 })
})
it('buckets a bare CheckRun conclusion (no status field) by conclusion', () => {
expect(summarizeChecks([{ conclusion: 'FAILURE' }]).failing).toBe(1)
expect(summarizeChecks([{ conclusion: 'SUCCESS' }]).passing).toBe(1)
})
})
// ── parsePrView ───────────────────────────────────────────────────────────────
const okJson = JSON.stringify({
number: 12,
state: 'OPEN',
title: 'Add the thing',
url: 'https://github.com/o/r/pull/12',
isDraft: false,
mergeable: 'MERGEABLE',
headRefName: 'feature/x',
baseRefName: 'main',
statusCheckRollup: [
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
],
})
describe('parsePrView', () => {
it('maps a valid PR JSON to availability:ok with lower-cased state/mergeable', () => {
const s = parsePrView(okJson)
expect(s.availability).toBe('ok')
expect(s.number).toBe(12)
expect(s.title).toBe('Add the thing')
expect(s.url).toBe('https://github.com/o/r/pull/12')
expect(s.state).toBe('open')
expect(s.isDraft).toBe(false)
expect(s.mergeable).toBe('mergeable')
expect(s.headRefName).toBe('feature/x')
expect(s.baseRefName).toBe('main')
expect(s.checks).toEqual({ total: 2, passing: 2, failing: 0, pending: 0 })
})
it('keeps availability:ok for a draft PR and lower-cases UNKNOWN mergeable', () => {
const s = parsePrView(
JSON.stringify({ number: 3, state: 'OPEN', isDraft: true, mergeable: 'UNKNOWN' }),
)
expect(s.availability).toBe('ok')
expect(s.isDraft).toBe(true)
expect(s.mergeable).toBe('unknown')
})
it('degrades malformed / non-object JSON to availability:error (never throws)', () => {
expect(parsePrView('not json {').availability).toBe('error')
expect(parsePrView('42').availability).toBe('error')
expect(parsePrView('null').availability).toBe('error')
expect(parsePrView('[1,2,3]').availability).toBe('error')
expect(parsePrView('').availability).toBe('error')
})
it('carries a <script> PR title VERBATIM (no parsing-side mangling — SEC-H4)', () => {
const evil = '<script>alert(1)</script>'
const s = parsePrView(JSON.stringify({ number: 1, state: 'OPEN', title: evil }))
expect(s.availability).toBe('ok')
expect(s.title).toBe(evil)
})
})
// ── classifyGhFailure ─────────────────────────────────────────────────────────
describe('classifyGhFailure', () => {
it('maps a spawn ENOENT to not-installed', () => {
expect(classifyGhFailure({ code: 'ENOENT', stderr: '' })).toBe('not-installed')
})
it('maps auth-related stderr to unauthenticated', () => {
for (const stderr of [
'run gh auth login to authenticate',
'you are not logged into any GitHub hosts',
'authentication required',
'HTTP 401: Bad credentials',
]) {
expect(classifyGhFailure({ code: 1, stderr })).toBe('unauthenticated')
}
})
it('maps no-PR / no-remote stderr to no-pr', () => {
for (const stderr of [
'no pull requests found for branch "feature/x"',
'no default remote repository has been set',
'no git remotes found',
]) {
expect(classifyGhFailure({ code: 1, stderr })).toBe('no-pr')
}
})
it('maps any other non-zero exit (incl. maxBuffer overflow) to error', () => {
expect(classifyGhFailure({ code: 1, stderr: 'something exploded' })).toBe('error')
expect(classifyGhFailure({ code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', stderr: '' })).toBe('error')
expect(classifyGhFailure({ code: undefined, stderr: '' })).toBe('error')
})
})
// ── getPrStatus (cache / dedupe / branch-key / disabled) ──────────────────────
/** A runner that resolves canned exec results and records call count. */
function fakeRunner(result: GhExecResult): { runner: GhRunner; calls: () => number } {
const spy = vi.fn<GhRunner>(async () => result)
return { runner: spy, calls: () => spy.mock.calls.length }
}
async function writeHead(repoPath: string, branch: string): Promise<void> {
await fs.writeFile(path.join(repoPath, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
}
describe('getPrStatus', () => {
let repoPath: string
beforeEach(async () => {
_clearPrCache()
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gh-test-'))
await fs.mkdir(path.join(repoPath, '.git'), { recursive: true })
await writeHead(repoPath, 'main')
})
afterEach(async () => {
_clearPrCache()
await fs.rm(repoPath, { recursive: true, force: true })
})
it('resolves availability:disabled WITHOUT invoking the runner when ghEnabled:false', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
const s = await getPrStatus(repoPath, { ...CFG, ghEnabled: false }, runner)
expect(s).toEqual({ availability: 'disabled' })
expect(calls()).toBe(0)
})
it('returns parsed status and caches it (second call does not re-run gh)', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
const first = await getPrStatus(repoPath, CFG, runner)
expect(first.availability).toBe('ok')
expect(first.number).toBe(12)
await getPrStatus(repoPath, CFG, runner)
expect(calls()).toBe(1) // served from cache
})
it('shares one in-flight run across two concurrent calls for the same path', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
const [a, b] = await Promise.all([
getPrStatus(repoPath, CFG, runner),
getPrStatus(repoPath, CFG, runner),
])
expect(a.availability).toBe('ok')
expect(b.availability).toBe('ok')
expect(calls()).toBe(1)
})
it('re-runs gh after _clearPrCache', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
await getPrStatus(repoPath, CFG, runner)
_clearPrCache()
await getPrStatus(repoPath, CFG, runner)
expect(calls()).toBe(2)
})
it('busts the cache when the branch (.git/HEAD) changes', async () => {
const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 })
await getPrStatus(repoPath, CFG, runner)
await writeHead(repoPath, 'other-branch')
await getPrStatus(repoPath, CFG, runner)
expect(calls()).toBe(2) // key includes the branch
})
it('degrades to no-pr when gh exits non-zero with a no-PR stderr (never throws)', async () => {
const { runner } = fakeRunner({
ok: false,
stdout: '',
stderr: 'no pull requests found for branch "main"',
code: 1,
})
const s = await getPrStatus(repoPath, CFG, runner)
expect(s).toEqual({ availability: 'no-pr' })
})
it('degrades to not-installed on a spawn ENOENT', async () => {
const { runner } = fakeRunner({ ok: false, stdout: '', stderr: '', code: 'ENOENT' })
const s = await getPrStatus(repoPath, CFG, runner)
expect(s).toEqual({ availability: 'not-installed' })
})
})

View File

@@ -0,0 +1,175 @@
/**
* Integration test for GET /projects/pr (W3 PR + CI status chip).
*
* Starts a real HTTP server against a temp git repo and stubs `gh` with a PATH
* shim: an executable script named `gh` in a temp bin/ dir prepended to PATH, so
* the server's execFile('gh', …) resolves OUR script (which echoes canned JSON or
* exits 1 with a canned stderr). Asserts:
* - missing ?path → 400
* - non-git ?path → 404 (isValidGitDir three-prong)
* - gh emits valid PR → 200 {availability:'ok', checks:…}
* - gh exits 1 (no PR) → 200 {availability:'no-pr'}
* - GH_ENABLED=0 → 200 {availability:'disabled'} and gh is NEVER spawned
*
* Determinism: PROJECT_SCAN_TTL=0 disables the gh module cache; _clearPrCache()
* runs in afterEach; each test writes the exact gh shim it needs.
*/
import fs from 'node:fs/promises'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import { _clearPrCache } from '../../src/http/gh.js'
import type { PrStatus } from '../../src/types.js'
// ── helpers ───────────────────────────────────────────────────────────────────
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
async function makeFakeGitRepo(parentDir: string, repoName: string): Promise<string> {
const repoPath = path.join(parentDir, repoName)
await fs.mkdir(path.join(repoPath, '.git'), { recursive: true })
await fs.writeFile(path.join(repoPath, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8')
return repoPath
}
/** Write an executable `gh` shim into binDir with the given /bin/sh body. */
async function writeGhShim(binDir: string, body: string): Promise<void> {
const file = path.join(binDir, 'gh')
await fs.writeFile(file, `#!/bin/sh\n${body}\n`, 'utf8')
await fs.chmod(file, 0o755)
}
const okJson = JSON.stringify({
number: 7,
state: 'OPEN',
title: 'A pull request',
url: 'https://github.com/o/r/pull/7',
isDraft: false,
mergeable: 'MERGEABLE',
headRefName: 'main',
baseRefName: 'main',
statusCheckRollup: [
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
{ __typename: 'CheckRun', status: 'IN_PROGRESS', conclusion: null },
],
})
// ── suite ─────────────────────────────────────────────────────────────────────
describe('GET /projects/pr — integration', () => {
let tmpRoot: string
let repoPath: string
let binDir: string
let originalPath: string
let serverHandle: { close(): Promise<void> } | null = null
beforeAll(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-pr-test-'))
repoPath = await makeFakeGitRepo(tmpRoot, 'fake-repo')
binDir = path.join(tmpRoot, 'bin')
await fs.mkdir(binDir, { recursive: true })
// Prepend our shim dir so execFile('gh', …) resolves OUR gh, not the host's.
originalPath = process.env['PATH'] ?? ''
process.env['PATH'] = `${binDir}${path.delimiter}${originalPath}`
})
afterAll(async () => {
process.env['PATH'] = originalPath
await fs.rm(tmpRoot, { recursive: true, force: true })
})
afterEach(async () => {
if (serverHandle !== null) {
await serverHandle.close()
serverHandle = null
}
_clearPrCache()
})
/** Start a server with the given env overrides; returns its base URL. */
async function start(overrides: Record<string, string> = {}): Promise<string> {
const port = await getFreePort()
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
PROJECT_SCAN_TTL: '0', // no gh cache between requests
...overrides,
})
serverHandle = startServer(cfg)
await new Promise<void>((r) => setTimeout(r, 100))
return `http://127.0.0.1:${port}`
}
it('returns 400 when ?path is missing', async () => {
const base = await start()
const res = await fetch(`${base}/projects/pr`)
expect(res.status).toBe(400)
})
it('returns 404 for a non-git directory path', async () => {
const base = await start()
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(tmpRoot)}`)
expect(res.status).toBe(404)
})
it('returns 200 {availability:ok} with a checks summary when gh emits PR JSON', async () => {
await writeGhShim(binDir, `cat <<'JSON'\n${okJson}\nJSON`)
const base = await start()
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
expect(res.status).toBe(200)
const body = (await res.json()) as PrStatus
expect(body.availability).toBe('ok')
expect(body.number).toBe(7)
expect(body.state).toBe('open')
expect(body.checks).toEqual({ total: 3, passing: 2, failing: 0, pending: 1 })
})
it('returns 200 {availability:no-pr} when gh exits 1 with a no-PR stderr', async () => {
await writeGhShim(binDir, 'echo "no pull requests found for branch \\"main\\"" 1>&2\nexit 1')
const base = await start()
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
expect(res.status).toBe(200)
const body = (await res.json()) as PrStatus
expect(body.availability).toBe('no-pr')
})
it('returns 200 {availability:disabled} and never spawns gh when GH_ENABLED=0', async () => {
const marker = path.join(tmpRoot, 'gh-was-spawned')
await fs.rm(marker, { force: true })
// This shim would create a marker if ever invoked.
await writeGhShim(binDir, `touch "${marker}"\necho '{}'`)
const base = await start({ GH_ENABLED: '0' })
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
expect(res.status).toBe(200)
const body = (await res.json()) as PrStatus
expect(body).toEqual({ availability: 'disabled' })
// gh must never have run.
await expect(fs.stat(marker)).rejects.toBeDefined()
})
})

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')

View File

@@ -35,6 +35,11 @@ const mockTimelineHandle = { dispose: vi.fn() }
const mockMountTimeline = vi.fn(() => mockTimelineHandle)
vi.mock('../public/timeline.js', () => ({ mountTimeline: mockMountTimeline }))
// ── Stub PR-status chip (W3) — renderProjectDetail mounts it for git repos ──────
const mockPrChipHandle = { destroy: vi.fn() }
const mockMountPrChip = vi.fn(() => mockPrChipHandle)
vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip }))
// ── Import AFTER mocks ─────────────────────────────────────────────────────────
const { validateBranchNameClient, renderNewWorktreeForm, renderProjectDetail } =
await import('../public/projects.js')