Files
web-terminal/public/gh-chip.ts
Yaojia Wang 7551f8a4b2 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.
2026-07-12 21:01:18 +02:00

263 lines
10 KiB
TypeScript

/**
* 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 = ''
},
}
}