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:
262
public/gh-chip.ts
Normal file
262
public/gh-chip.ts
Normal 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.
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user