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.
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
/**
|
|
* 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' })
|
|
})
|
|
})
|