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.
263 lines
10 KiB
TypeScript
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 = ''
|
|
},
|
|
}
|
|
}
|