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:
@@ -59,6 +59,8 @@ const DEFAULT_STUCK_TTL_SEC = 600 // 10 minutes (env var in seconds)
|
||||
const DEFAULT_DIFF_TIMEOUT_MS = 2_000
|
||||
const DEFAULT_DIFF_MAX_BYTES = 2 * 1024 * 1024 // 2 MB
|
||||
const DEFAULT_DIFF_MAX_FILES = 300
|
||||
// W3 PR + CI status chip (gh) — larger timeout than diff: gh hits the network
|
||||
const DEFAULT_GH_TIMEOUT_MS = 8_000
|
||||
// B2 statusline telemetry
|
||||
const DEFAULT_STATUSLINE_TTL_MS = 30_000
|
||||
// B3 worktrees
|
||||
@@ -365,6 +367,10 @@ export function loadConfig(env: EnvLike): Config {
|
||||
DEFAULT_DIFF_MAX_FILES,
|
||||
)
|
||||
|
||||
// W3 PR + CI status chip (gh)
|
||||
const ghEnabled = parseBool(env['GH_ENABLED'], true)
|
||||
const ghTimeoutMs = parseNonNegativeInt(env['GH_TIMEOUT_MS'], 'GH_TIMEOUT_MS', DEFAULT_GH_TIMEOUT_MS)
|
||||
|
||||
// B2 statusLine telemetry
|
||||
const statuslineTtlMs = parseNonNegativeInt(
|
||||
env['STATUSLINE_TTL_MS'],
|
||||
@@ -443,6 +449,8 @@ export function loadConfig(env: EnvLike): Config {
|
||||
diffTimeoutMs,
|
||||
diffMaxBytes,
|
||||
diffMaxFiles,
|
||||
ghEnabled,
|
||||
ghTimeoutMs,
|
||||
statuslineTtlMs,
|
||||
worktreeEnabled,
|
||||
worktreeRoot,
|
||||
|
||||
316
src/http/gh.ts
Normal file
316
src/http/gh.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* src/http/gh.ts (W3 PR + CI status chip) — read-only PR / CI status via `gh`.
|
||||
*
|
||||
* The server stays a byte-shuttle: this is an out-of-band side-channel that runs
|
||||
* `gh pr view --json …` in a repo directory and PARSES its JSON into a PrStatus.
|
||||
* Structure mirrors src/http/diff.ts:
|
||||
* - runGh: execFile('gh', [fixed argv]) — NO shell; timeout + maxBuffer bound
|
||||
* DoS (SEC-M9). The ONLY user-influenced input reaching gh is `cwd`, the
|
||||
* already-validated repoPath (route layer, SEC-H7). No untrusted string ever
|
||||
* enters argv — gh derives the PR from the current branch.
|
||||
* - pure, exported: parsePrView / summarizeChecks / classifyGhFailure. They
|
||||
* NEVER throw: malformed / unknown input degrades to {availability:'error'}.
|
||||
* - getPrStatus: module-scope short-TTL cache (cfg.projectScanTtlMs) keyed by
|
||||
* repoPath + current branch (so a branch switch busts before TTL), with
|
||||
* in-flight dedupe (cache-stampede guard) — caps outbound GitHub-API calls.
|
||||
*
|
||||
* Network egress note: unlike every other side-channel (all local), gh talks to
|
||||
* GitHub's API using the host's existing gh / GH_TOKEN credential. This module
|
||||
* NEVER accepts or forwards a token — it only triggers gh's own auth. It also
|
||||
* NEVER logs gh stdout (private PR titles) or the token.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { Config, PrAvailability, PrCheckSummary, PrStatus } from '../types.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
/** One `gh pr view` spawn: exactly the fields parsePrView / summarizeChecks read. */
|
||||
const PR_VIEW_FIELDS =
|
||||
'number,state,title,url,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup'
|
||||
|
||||
/** Config subset getPrStatus needs; the full Config satisfies this Pick. */
|
||||
export type GhOptions = Pick<Config, 'ghEnabled' | 'ghTimeoutMs' | 'projectScanTtlMs' | 'diffMaxBytes'>
|
||||
|
||||
// ── gh runner (injectable seam — default real, overridable in tests) ──────────
|
||||
|
||||
/** Normalized outcome of one gh spawn. `code` carries a spawn/system error code
|
||||
* ('ENOENT', 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') or the numeric exit code. */
|
||||
export interface GhExecResult {
|
||||
ok: boolean // process exited 0
|
||||
stdout: string
|
||||
stderr: string
|
||||
code?: string | number
|
||||
}
|
||||
|
||||
/** A gh runner: same seam idea as getDiff's runGit, so getPrStatus is unit-testable
|
||||
* without spawning gh. */
|
||||
export type GhRunner = (
|
||||
repoPath: string,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
maxBytes: number,
|
||||
) => Promise<GhExecResult>
|
||||
|
||||
function asString(v: unknown): string {
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Real gh runner: execFile('gh', [...]) with NO shell, bounded by timeout +
|
||||
* maxBuffer. A spawn ENOENT (gh not installed), a non-zero exit (unauth / no PR),
|
||||
* a timeout, or a maxBuffer overflow all resolve (never reject) with ok:false so
|
||||
* the classifier can degrade — mirroring diff.ts's best-effort house style.
|
||||
*/
|
||||
async function realRunGh(
|
||||
repoPath: string,
|
||||
args: readonly string[],
|
||||
timeoutMs: number,
|
||||
maxBytes: number,
|
||||
): Promise<GhExecResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('gh', [...args], {
|
||||
cwd: repoPath,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: maxBytes,
|
||||
})
|
||||
return { ok: true, stdout, stderr, code: 0 }
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: unknown; stdout?: unknown; stderr?: unknown }
|
||||
const code =
|
||||
typeof e.code === 'string' || typeof e.code === 'number' ? e.code : undefined
|
||||
return { ok: false, stdout: asString(e.stdout), stderr: asString(e.stderr), code }
|
||||
}
|
||||
}
|
||||
|
||||
// ── classifyGhFailure (pure) ──────────────────────────────────────────────────
|
||||
|
||||
/** A gh non-zero / spawn failure, reduced to what the classifier reads. */
|
||||
export interface GhFailure {
|
||||
code?: string | number
|
||||
stderr: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a gh failure to a degrade reason (regex on lower-cased stderr). ENOENT ⇒
|
||||
* gh isn't installed; an auth pattern ⇒ not logged in; a "no PR / no remote"
|
||||
* pattern ⇒ no PR for the branch; anything else (timeout, maxBuffer overflow,
|
||||
* unknown) ⇒ generic error. Never throws.
|
||||
*/
|
||||
export function classifyGhFailure(f: GhFailure): PrAvailability {
|
||||
if (f.code === 'ENOENT') return 'not-installed'
|
||||
const s = asString(f.stderr).toLowerCase()
|
||||
if (/gh auth login|not logged|authentication|http 401/.test(s)) return 'unauthenticated'
|
||||
if (/no pull requests found|no default remote|no git remote/.test(s)) return 'no-pr'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
// ── summarizeChecks (pure) ────────────────────────────────────────────────────
|
||||
|
||||
type CheckBucket = 'passing' | 'failing' | 'pending'
|
||||
|
||||
const CHECKRUN_PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
|
||||
const CHECKRUN_FAIL = new Set([
|
||||
'FAILURE',
|
||||
'TIMED_OUT',
|
||||
'CANCELLED',
|
||||
'ACTION_REQUIRED',
|
||||
'STARTUP_FAILURE',
|
||||
'STALE',
|
||||
])
|
||||
const CONTEXT_FAIL = new Set(['FAILURE', 'ERROR'])
|
||||
|
||||
/** A CheckRun's status/conclusion → bucket. A non-COMPLETED status (QUEUED,
|
||||
* IN_PROGRESS, WAITING, PENDING, REQUESTED) is pending regardless of conclusion;
|
||||
* a completed/absent status is bucketed by conclusion (null/unknown → pending). */
|
||||
function bucketCheckRun(status: unknown, conclusion: unknown): CheckBucket {
|
||||
const st = asString(status).toUpperCase()
|
||||
if (st !== '' && st !== 'COMPLETED') return 'pending'
|
||||
const c = asString(conclusion).toUpperCase()
|
||||
if (CHECKRUN_PASS.has(c)) return 'passing'
|
||||
if (CHECKRUN_FAIL.has(c)) return 'failing'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
/** A StatusContext's state → bucket (SUCCESS pass, FAILURE/ERROR fail, else pending). */
|
||||
function bucketContext(state: unknown): CheckBucket {
|
||||
const s = asString(state).toUpperCase()
|
||||
if (s === 'SUCCESS') return 'passing'
|
||||
if (CONTEXT_FAIL.has(s)) return 'failing'
|
||||
return 'pending' // PENDING, EXPECTED, unknown
|
||||
}
|
||||
|
||||
/** Classify one rollup item. Prefers __typename, else infers from present keys.
|
||||
* Anything unrecognized still counts toward total, treated as pending. */
|
||||
function bucketItem(raw: unknown): CheckBucket {
|
||||
if (raw === null || typeof raw !== 'object') return 'pending'
|
||||
const o = raw as Record<string, unknown>
|
||||
const typename = asString(o['__typename'])
|
||||
const isContext =
|
||||
typename === 'StatusContext' ||
|
||||
(typename !== 'CheckRun' &&
|
||||
o['state'] !== undefined &&
|
||||
o['status'] === undefined &&
|
||||
o['conclusion'] === undefined)
|
||||
if (isContext) return bucketContext(o['state'])
|
||||
if (typename === 'CheckRun' || o['status'] !== undefined || o['conclusion'] !== undefined) {
|
||||
return bucketCheckRun(o['status'], o['conclusion'])
|
||||
}
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll up gh's `statusCheckRollup` (a mix of CheckRun + StatusContext items) into
|
||||
* total / passing / failing / pending counts. Non-array / empty / undefined ⇒
|
||||
* all-zero. Every item lands in exactly one bucket so total === pass+fail+pending.
|
||||
* Never throws.
|
||||
*/
|
||||
export function summarizeChecks(rollup: unknown): PrCheckSummary {
|
||||
const summary: PrCheckSummary = { total: 0, passing: 0, failing: 0, pending: 0 }
|
||||
if (!Array.isArray(rollup)) return summary
|
||||
for (const item of rollup) {
|
||||
summary.total += 1
|
||||
summary[bucketItem(item)] += 1
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// ── parsePrView (pure) ────────────────────────────────────────────────────────
|
||||
|
||||
function mapState(v: unknown): PrStatus['state'] {
|
||||
if (typeof v !== 'string') return undefined
|
||||
const s = v.toLowerCase()
|
||||
return s === 'open' || s === 'closed' || s === 'merged' ? s : undefined
|
||||
}
|
||||
|
||||
function mapMergeable(v: unknown): PrStatus['mergeable'] {
|
||||
if (typeof v !== 'string') return undefined
|
||||
const s = v.toLowerCase()
|
||||
return s === 'mergeable' || s === 'conflicting' || s === 'unknown' ? s : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `gh pr view --json …` output into a PrStatus. Valid PR JSON ⇒
|
||||
* availability:'ok' with lower-cased state/mergeable and a checks summary; a
|
||||
* malformed / non-object payload ⇒ {availability:'error'} (never throws — the
|
||||
* diff.ts "never throws" house style). Attacker-controllable strings (title/url)
|
||||
* are carried VERBATIM — the FE renders them inert via textContent (SEC-H4).
|
||||
*/
|
||||
export function parsePrView(json: string): PrStatus {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(json)
|
||||
} catch {
|
||||
return { availability: 'error' }
|
||||
}
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { availability: 'error' }
|
||||
}
|
||||
const o = raw as Record<string, unknown>
|
||||
const status: PrStatus = { availability: 'ok' }
|
||||
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']
|
||||
const state = mapState(o['state'])
|
||||
if (state !== undefined) status.state = state
|
||||
if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft']
|
||||
const mergeable = mapMergeable(o['mergeable'])
|
||||
if (mergeable !== undefined) status.mergeable = mergeable
|
||||
if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName']
|
||||
if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName']
|
||||
status.checks = summarizeChecks(o['statusCheckRollup'])
|
||||
return status
|
||||
}
|
||||
|
||||
// ── current-branch read (cheap; cache-key input) ──────────────────────────────
|
||||
|
||||
/** Current branch from `<repo>/.git/HEAD` (ref line only). Detached HEAD / junk /
|
||||
* unreadable ⇒ null. Cheap: no spawn (mirrors projects.ts readBranch/parseGitHead). */
|
||||
async function readHeadBranch(repoPath: string): Promise<string | null> {
|
||||
try {
|
||||
const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8')
|
||||
const match = /^ref:\s+refs\/heads\/(.+)$/.exec(head.trim())
|
||||
const branch = match?.[1]?.trim()
|
||||
return branch !== undefined && branch !== '' ? branch : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── getPrStatus (cached runner) ───────────────────────────────────────────────
|
||||
|
||||
interface PrCacheEntry {
|
||||
expiresAt: number
|
||||
status: PrStatus
|
||||
}
|
||||
|
||||
const prCache = new Map<string, PrCacheEntry>()
|
||||
/** Shared in-flight promises so concurrent cache-miss callers join one gh run. */
|
||||
const inflightPr = new Map<string, Promise<PrStatus>>()
|
||||
|
||||
/** repoPath + branch: a branch switch changes the key, busting stale PR data. */
|
||||
function cacheKey(repoPath: string, branch: string | null): string {
|
||||
return `${repoPath}\n${branch ?? ''}`
|
||||
}
|
||||
|
||||
async function runPrStatus(
|
||||
repoPath: string,
|
||||
cfg: GhOptions,
|
||||
runner: GhRunner,
|
||||
): Promise<PrStatus> {
|
||||
const exec = await runner(
|
||||
repoPath,
|
||||
['pr', 'view', '--json', PR_VIEW_FIELDS],
|
||||
cfg.ghTimeoutMs,
|
||||
cfg.diffMaxBytes,
|
||||
)
|
||||
if (exec.ok) return parsePrView(exec.stdout)
|
||||
return { availability: classifyGhFailure({ code: exec.code, stderr: exec.stderr }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a repo's PR + CI status. `repoPath` must already be a validated absolute
|
||||
* git directory (route layer, SEC-H7). Best-effort: gh missing / unauthed / no PR
|
||||
* / timeout all resolve to a PrStatus whose `availability` names the reason —
|
||||
* NEVER throws, never blocks the caller. Cached at module scope with
|
||||
* cfg.projectScanTtlMs and in-flight dedupe. `runner` is injectable for tests.
|
||||
*/
|
||||
export async function getPrStatus(
|
||||
repoPath: string,
|
||||
cfg: GhOptions,
|
||||
runner: GhRunner = realRunGh,
|
||||
): Promise<PrStatus> {
|
||||
if (!cfg.ghEnabled) return { availability: 'disabled' }
|
||||
|
||||
const branch = await readHeadBranch(repoPath)
|
||||
const key = cacheKey(repoPath, branch)
|
||||
const now = Date.now()
|
||||
|
||||
const cached = prCache.get(key)
|
||||
if (cached !== undefined && cached.expiresAt > now) return cached.status
|
||||
|
||||
const inflight = inflightPr.get(key)
|
||||
if (inflight !== undefined) return inflight
|
||||
|
||||
const promise = runPrStatus(repoPath, cfg, runner)
|
||||
.then((status) => {
|
||||
prCache.set(key, { expiresAt: Date.now() + cfg.projectScanTtlMs, status })
|
||||
inflightPr.delete(key)
|
||||
return status
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
inflightPr.delete(key)
|
||||
throw e
|
||||
})
|
||||
inflightPr.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Test-only: drop the PR cache and any in-flight run so each test sees a fresh gh call. */
|
||||
export function _clearPrCache(): void {
|
||||
prCache.clear()
|
||||
inflightPr.clear()
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { listSessions } from './http/history.js'
|
||||
import { buildProjects, buildProjectDetail } from './http/projects.js'
|
||||
import { openInEditor, openFileInEditor } from './http/editor.js'
|
||||
import { getDiff, isPlausibleRev } from './http/diff.js'
|
||||
import { getPrStatus } from './http/gh.js'
|
||||
import { parseStatusLine } from './http/statusline.js'
|
||||
import { createWorktree } from './http/worktrees.js'
|
||||
import { createSessionManager } from './session/manager.js'
|
||||
@@ -819,6 +820,32 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
}
|
||||
})
|
||||
|
||||
// ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─
|
||||
// Out-of-band side-channel: spawns the host's `gh` CLI to read the current
|
||||
// branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes
|
||||
// a NETWORK call to GitHub using the host's own gh/GH_TOKEN credential — this
|
||||
// route NEVER accepts or forwards a token, only triggers gh's own auth, and
|
||||
// GH_ENABLED=0 disables it entirely. Always 200 on a valid git dir: every
|
||||
// degrade (gh missing / unauthed / no PR / disabled) lives in the response body
|
||||
// (PrStatus.availability), not the HTTP status, so the FE renders one chip.
|
||||
app.get('/projects/pr', async (req, res) => {
|
||||
const target = req.query['path']
|
||||
if (typeof target !== 'string' || target === '') {
|
||||
res.status(400).json({ error: 'path query parameter is required' })
|
||||
return
|
||||
}
|
||||
if (!(await isValidGitDir(target))) {
|
||||
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
|
||||
return
|
||||
}
|
||||
try {
|
||||
res.json(await getPrStatus(target, cfg))
|
||||
} catch (err) {
|
||||
console.error('[server] /projects/pr failed:', err instanceof Error ? err.message : String(err))
|
||||
res.status(500).json({ error: 'failed to read PR status' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ────────────────
|
||||
app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => {
|
||||
if (!isLoopback(req.socket.remoteAddress ?? '')) {
|
||||
|
||||
36
src/types.ts
36
src/types.ts
@@ -61,6 +61,9 @@ export interface Config {
|
||||
readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000
|
||||
readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB
|
||||
readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300
|
||||
// W3 PR + CI status chip (gh)
|
||||
readonly ghEnabled: boolean; // GH_ENABLED, default true (false → never spawns gh)
|
||||
readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff)
|
||||
// B2 statusLine telemetry
|
||||
readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
|
||||
// B3 git worktree creation
|
||||
@@ -535,6 +538,39 @@ export interface DiffResult {
|
||||
base?: string; // echoed when the diff was against a base revision (?base=<rev>)
|
||||
}
|
||||
|
||||
/* ── W3 PR + CI status chip (gh) ── */
|
||||
|
||||
/** Why a PrStatus has (or lacks) PR data. Drives the FE chip's degraded text. */
|
||||
export type PrAvailability =
|
||||
| 'ok' // a PR exists for the current branch; fields below are populated
|
||||
| 'no-pr' // gh works but the branch has no PR (or no remote/default repo)
|
||||
| 'not-installed' // `gh` binary not found on PATH (ENOENT)
|
||||
| 'unauthenticated' // gh present but not logged in (needs `gh auth login`)
|
||||
| 'disabled' // GH_ENABLED=0 — feature off, never spawns gh
|
||||
| 'error'; // gh spawned but failed for another reason (timeout, etc.)
|
||||
|
||||
/** Rolled-up CI check counts from gh's statusCheckRollup (CheckRun + StatusContext). */
|
||||
export interface PrCheckSummary {
|
||||
total: number;
|
||||
passing: number; // CheckRun conclusion SUCCESS/NEUTRAL/SKIPPED | StatusContext SUCCESS
|
||||
failing: number; // FAILURE/TIMED_OUT/CANCELLED/ACTION_REQUIRED | ERROR/FAILURE
|
||||
pending: number; // QUEUED/IN_PROGRESS/WAITING | PENDING/EXPECTED
|
||||
}
|
||||
|
||||
/** GET /projects/pr result. Only present-when-'ok' fields are optional. */
|
||||
export interface PrStatus {
|
||||
availability: PrAvailability;
|
||||
number?: number;
|
||||
title?: string;
|
||||
url?: string;
|
||||
state?: 'open' | 'closed' | 'merged'; // lower-cased from gh OPEN/CLOSED/MERGED
|
||||
isDraft?: boolean;
|
||||
mergeable?: 'mergeable' | 'conflicting' | 'unknown'; // lower-cased from gh
|
||||
headRefName?: string;
|
||||
baseRefName?: string;
|
||||
checks?: PrCheckSummary;
|
||||
}
|
||||
|
||||
/* ── B3 worktree creation (§3.5) ── */
|
||||
|
||||
/** Result of POST /projects/worktree (B3). `error` carries a safe message only
|
||||
|
||||
Reference in New Issue
Block a user