/** * 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 = new Set([ 'ok', 'no-pr', 'not-installed', 'unauthenticated', 'disabled', 'error', ]) /* ── DOM helper ──────────────────────────────────────────────────────────────── */ /** Create an element with an optional CSS class and text content. */ function el( 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 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 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 { 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, 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 = '' }, } }