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:
175
test/integration/pr-status.test.ts
Normal file
175
test/integration/pr-status.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Integration test for GET /projects/pr (W3 PR + CI status chip).
|
||||
*
|
||||
* Starts a real HTTP server against a temp git repo and stubs `gh` with a PATH
|
||||
* shim: an executable script named `gh` in a temp bin/ dir prepended to PATH, so
|
||||
* the server's execFile('gh', …) resolves OUR script (which echoes canned JSON or
|
||||
* exits 1 with a canned stderr). Asserts:
|
||||
* - missing ?path → 400
|
||||
* - non-git ?path → 404 (isValidGitDir three-prong)
|
||||
* - gh emits valid PR → 200 {availability:'ok', checks:…}
|
||||
* - gh exits 1 (no PR) → 200 {availability:'no-pr'}
|
||||
* - GH_ENABLED=0 → 200 {availability:'disabled'} and gh is NEVER spawned
|
||||
*
|
||||
* Determinism: PROJECT_SCAN_TTL=0 disables the gh module cache; _clearPrCache()
|
||||
* runs in afterEach; each test writes the exact gh shim it needs.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { loadConfig } from '../../src/config.js'
|
||||
import { startServer } from '../../src/server.js'
|
||||
import { _clearPrCache } from '../../src/http/gh.js'
|
||||
import type { PrStatus } from '../../src/types.js'
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = net.createServer()
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
srv.close()
|
||||
reject(new Error('unexpected address type'))
|
||||
return
|
||||
}
|
||||
const port = addr.port
|
||||
srv.close(() => resolve(port))
|
||||
})
|
||||
srv.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function makeFakeGitRepo(parentDir: string, repoName: string): Promise<string> {
|
||||
const repoPath = path.join(parentDir, repoName)
|
||||
await fs.mkdir(path.join(repoPath, '.git'), { recursive: true })
|
||||
await fs.writeFile(path.join(repoPath, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8')
|
||||
return repoPath
|
||||
}
|
||||
|
||||
/** Write an executable `gh` shim into binDir with the given /bin/sh body. */
|
||||
async function writeGhShim(binDir: string, body: string): Promise<void> {
|
||||
const file = path.join(binDir, 'gh')
|
||||
await fs.writeFile(file, `#!/bin/sh\n${body}\n`, 'utf8')
|
||||
await fs.chmod(file, 0o755)
|
||||
}
|
||||
|
||||
const okJson = JSON.stringify({
|
||||
number: 7,
|
||||
state: 'OPEN',
|
||||
title: 'A pull request',
|
||||
url: 'https://github.com/o/r/pull/7',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
headRefName: 'main',
|
||||
baseRefName: 'main',
|
||||
statusCheckRollup: [
|
||||
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
|
||||
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
|
||||
{ __typename: 'CheckRun', status: 'IN_PROGRESS', conclusion: null },
|
||||
],
|
||||
})
|
||||
|
||||
// ── suite ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /projects/pr — integration', () => {
|
||||
let tmpRoot: string
|
||||
let repoPath: string
|
||||
let binDir: string
|
||||
let originalPath: string
|
||||
let serverHandle: { close(): Promise<void> } | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-pr-test-'))
|
||||
repoPath = await makeFakeGitRepo(tmpRoot, 'fake-repo')
|
||||
binDir = path.join(tmpRoot, 'bin')
|
||||
await fs.mkdir(binDir, { recursive: true })
|
||||
// Prepend our shim dir so execFile('gh', …) resolves OUR gh, not the host's.
|
||||
originalPath = process.env['PATH'] ?? ''
|
||||
process.env['PATH'] = `${binDir}${path.delimiter}${originalPath}`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
process.env['PATH'] = originalPath
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (serverHandle !== null) {
|
||||
await serverHandle.close()
|
||||
serverHandle = null
|
||||
}
|
||||
_clearPrCache()
|
||||
})
|
||||
|
||||
/** Start a server with the given env overrides; returns its base URL. */
|
||||
async function start(overrides: Record<string, string> = {}): Promise<string> {
|
||||
const port = await getFreePort()
|
||||
const cfg = loadConfig({
|
||||
PORT: String(port),
|
||||
BIND_HOST: '127.0.0.1',
|
||||
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
|
||||
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
|
||||
USE_TMUX: '0',
|
||||
IDLE_TTL: '86400',
|
||||
PROJECT_SCAN_TTL: '0', // no gh cache between requests
|
||||
...overrides,
|
||||
})
|
||||
serverHandle = startServer(cfg)
|
||||
await new Promise<void>((r) => setTimeout(r, 100))
|
||||
return `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
it('returns 400 when ?path is missing', async () => {
|
||||
const base = await start()
|
||||
const res = await fetch(`${base}/projects/pr`)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for a non-git directory path', async () => {
|
||||
const base = await start()
|
||||
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(tmpRoot)}`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 200 {availability:ok} with a checks summary when gh emits PR JSON', async () => {
|
||||
await writeGhShim(binDir, `cat <<'JSON'\n${okJson}\nJSON`)
|
||||
const base = await start()
|
||||
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PrStatus
|
||||
expect(body.availability).toBe('ok')
|
||||
expect(body.number).toBe(7)
|
||||
expect(body.state).toBe('open')
|
||||
expect(body.checks).toEqual({ total: 3, passing: 2, failing: 0, pending: 1 })
|
||||
})
|
||||
|
||||
it('returns 200 {availability:no-pr} when gh exits 1 with a no-PR stderr', async () => {
|
||||
await writeGhShim(binDir, 'echo "no pull requests found for branch \\"main\\"" 1>&2\nexit 1')
|
||||
const base = await start()
|
||||
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PrStatus
|
||||
expect(body.availability).toBe('no-pr')
|
||||
})
|
||||
|
||||
it('returns 200 {availability:disabled} and never spawns gh when GH_ENABLED=0', async () => {
|
||||
const marker = path.join(tmpRoot, 'gh-was-spawned')
|
||||
await fs.rm(marker, { force: true })
|
||||
// This shim would create a marker if ever invoked.
|
||||
await writeGhShim(binDir, `touch "${marker}"\necho '{}'`)
|
||||
const base = await start({ GH_ENABLED: '0' })
|
||||
const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PrStatus
|
||||
expect(body).toEqual({ availability: 'disabled' })
|
||||
// gh must never have run.
|
||||
await expect(fs.stat(marker)).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user