Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/implementation/guarded-merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Merging blocks when any plan item is unreviewed or stale, an attributed file is

The GitHub adapter reads the current PR base/head, effective branch rulesets, classic branch protection, required check contexts and app identities, and issue cross-references. It ignores review records that are not required checks. Cross-referenced PRs are read in one GraphQL request; repository identity is preserved with each PR number, and cross-repository references fail closed. More than 100 references also fail closed as unknown. Malformed pagination or PR references, partial referenced-PR records, rules without a type, malformed protection objects, missing or malformed check-app identities, and inconsistent check status/conclusion pairs fail closed, while an explicit `null` remains an unbound check. Display status is cached for five seconds and the combined inspection has a 12-second deadline. Each of the two sequential merge-validation inspections has a six-second deadline, keeping their combined validation budget below the 15-second serving request budget. A deadline aborts the underlying GitHub CLI process before releasing the shared in-flight request. An unreadable or incomplete rule source, a pending/missing/failing check, another open or merged PR for the issue, a conflict, or a closed PR blocks merging.

Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected.
Automatic merging also requires strict required status checks as a server-enforced current-base policy. A client-side fetch cannot supply that guarantee. Merge-queue branches were blocked in this increment because `gh pr merge` can report a successful enqueue before the pull request is merged. Queue support has since landed (#24; see `merge-queue.md`): a queue-capable adapter tracks each attempt until GitHub confirms the merge, removal or failure, and queue rules count as the server-side current-base guard. An adapter without queue inspection still fails closed with a `merge-queue` blocker. The coordinator performs the complete gate twice without using the display cache, verifies the same base/head pair, then re-reads the local review generation immediately before invoking `gh pr merge --match-head-commit` with literal argv. Each `gh` process gets only the allowlisted variables in `github/gh-env.ts`, plus fixed settings that turn off prompts, the pager, colour and update checks. No other server variables are passed on. A timeout or abort sends the `gh` process SIGTERM, then SIGKILL after a quarter of a second, and the call returns only after that process has exited (or 0.15 s after it exits, if a process it started keeps its output open). So a merge click aborted at its 14-second deadline settles by 14.4 s, inside the 14.5-second shutdown drain and below the 15-second request budget. Stopping `gh` cannot recall a merge request it had already sent: GitHub can still apply it, so a cancelled merge is reported as unknown and its attempt stays in flight, with the action disabled. A direct attempt is settled only when GitHub shows the pull request merged; a queued attempt also settles when GitHub reports it removed or failed (`merge-queue.md`). The adapter invalidates its pre-action cache before the command and after either success or refusal. A generation guard prevents inspections started before or during the command from restoring stale cache entries afterward. After command success, the server returns “merge submitted” even if its best-effort status reload fails, and the browser keeps the action disabled until Refresh confirms GitHub state. GitHub's command refusal text is returned to the local UI, and a failed attempt also remains disabled until Refresh loads fresh state. There is no “merge anyway” path around missing atomic protection. Demo mode never constructs the coordinator, including when a gateway is injected.

Shutdown sets a terminal admission flag before inspecting active work and checks it again after partial request bodies are received. It then stops HTTP admission, aborts and awaits an active merge CLI process, drains requests, and closes the review service. A request admitted before shutdown cannot start a new merge afterward, and a cancelled command cannot outlive the local state that authorized it.

Expand Down
9 changes: 9 additions & 0 deletions docs/implementation/issue-prioritization.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ dedicated read-only gateway with these boundaries:

- Codeboost invokes `gh` with literal arguments; issue text is parsed only as
data and is never interpolated into a shell command or prompt.
- Each `gh` process gets only the allowlisted variables in
`github/gh-env.ts`, plus fixed settings that turn off prompts, the pager,
colour and update checks. Other variables from the server, such as
unrelated credentials, are not passed on.
- When the fetch deadline passes or the fetch is cancelled, the `gh` process
gets SIGTERM, then SIGKILL after half a second. The fetch returns only after
that process has exited, or a quarter of a second after it exits if a process
it started keeps its output open. So a fetch aborted at its 12-second
deadline settles by 12.75 s, below the 15-second request timeout.
- The gateway fetches open issues and the repository's current collaborators,
excludes pull requests, follows bounded pagination for both collections, and
validates every field used for normalization, trust, or ranking. If the
Expand Down
18 changes: 13 additions & 5 deletions github/issues.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { ghEnvironment } from './gh-env.ts';
import { runWithInput } from './run-with-input.ts';

const runFile = promisify(execFile);
const PAGE_SIZE = 100;
const MAX_PAGES = 10;
const MAX_ISSUES = PAGE_SIZE * MAX_PAGES;
const MAX_COLLABORATORS = PAGE_SIZE * MAX_PAGES;
const MAX_BODY_LENGTH = 65_536;
// Covers one bounded 100-record page, including JSON-escaped bodies, labels and response overhead.
export const ISSUE_PAGE_MAX_BYTES = 64 * 1024 * 1024;
/**
* How long a stopped `gh` gets after SIGTERM before SIGKILL, and how long its inherited output pipes may stay open after
* it exits. A fetch settles only when gh has stopped, so a fetch aborted at its 12 s deadline (web/issues.ts) settles up
* to 0.75 s later: 12.75 s, below the 15-second serving request budget.
*/
export const ISSUE_KILL_GRACE_MS = 500, ISSUE_PIPE_GRACE_MS = 250;

export type IssueAuthorAssociation =
| 'OWNER' | 'MEMBER' | 'COLLABORATOR' | 'CONTRIBUTOR'
Expand Down Expand Up @@ -153,10 +158,13 @@ export class GhIssueGateway implements IssueGateway {
constructor(repository: string, run?: RunGh, now: () => Date = () => new Date()) {
if (!repositoryName(repository)) throw new Error('A GitHub repository is required for issue retrieval.');
this.repository = repository;
this.run = run ?? (async (args, options) => (await runFile('gh', [...args], {
this.run = run ?? ((args, options) => runWithInput('gh', args, {
maxBuffer: ISSUE_PAGE_MAX_BYTES,
signal: options?.signal,
})).stdout);
env: ghEnvironment(),
killGraceMs: ISSUE_KILL_GRACE_MS,
pipeGraceMs: ISSUE_PIPE_GRACE_MS,
}));
this.now = now;
}

Expand Down
26 changes: 17 additions & 9 deletions github/merge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { ghEnvironment } from './gh-env.ts';
import { runWithInput } from './run-with-input.ts';

const runFile = promisify(execFile);
/**
* How long a stopped `gh` gets after SIGTERM before SIGKILL, and how long its inherited output pipes may stay open after
* it exits. Every gh call of this gateway settles only when gh has stopped, so a call aborted at a deadline settles up
* to 0.4 s later. That keeps a merge click (14 s deadline in runner/merge.ts) within 14.4 s, inside the 14.5 s shutdown
* drain and below the 15-second serving request budget, and an inspection (at most 12 s) within 12.4 s.
*/
export const MERGE_KILL_GRACE_MS = 250, MERGE_PIPE_GRACE_MS = 150;
/** The longest deadline of any inspection, and the default for merge-state and queue-state inspections. */
export const MERGE_INSPECTION_TIMEOUT_MS = 12_000;

export interface RequiredCheck {
context: string;
Expand Down Expand Up @@ -87,7 +95,7 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway {
throw new Error('A GitHub repository, pull request, and issue are required for merging.');
if (config.method !== undefined && !['merge','squash','rebase'].includes(config.method)) throw new Error('GitHub merge method must be merge, squash, or rebase.');
this.config = config;
this.run = run ?? (async (args, options) => (await runFile('gh', [...args], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal })).stdout);
this.run = run ?? ((args, options) => runWithInput('gh', args, { timeout: 30_000, maxBuffer: 8 * 1024 * 1024, signal: options?.signal, env: ghEnvironment(), killGraceMs: MERGE_KILL_GRACE_MS, pipeGraceMs: MERGE_PIPE_GRACE_MS }));
}

async #json(args: readonly string[], signal?: AbortSignal): Promise<unknown> {
Expand Down Expand Up @@ -240,8 +248,8 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway {
}

async inspect(options: { fresh?: boolean; timeoutMs?: number; signal?: AbortSignal } = {}): Promise<RemoteMergeState> {
const timeoutMs = options.timeoutMs ?? 12_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 12_000) throw new Error('Invalid GitHub inspection timeout.');
const timeoutMs = options.timeoutMs ?? MERGE_INSPECTION_TIMEOUT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MERGE_INSPECTION_TIMEOUT_MS) throw new Error('Invalid GitHub inspection timeout.');
if (!options.fresh && this.#cache && this.#cache.expiresAt > Date.now()) return this.#cache.state;
const generation = this.#generation;
if (!options.fresh && this.#inflight?.generation === generation) return this.#inflight.promise;
Expand All @@ -263,7 +271,7 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway {
async queueWatermark(expectedHead: string, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<string | null> {
fullSha(expectedHead, 'expected head SHA');
const timeoutMs = options.timeoutMs ?? 6_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 12_000) throw new Error('Invalid GitHub queue watermark timeout.');
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MERGE_INSPECTION_TIMEOUT_MS) throw new Error('Invalid GitHub queue watermark timeout.');
const [owner, name] = this.config.repository.split('/') as [string, string];
const query = `query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){number headRefOid timelineItems(last:1,itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT,REMOVED_FROM_MERGE_QUEUE_EVENT]){edges{cursor node{id}}}}}}`;
const timeout = new AbortController();
Expand Down Expand Up @@ -298,8 +306,8 @@ export class GhMergeGateway implements MergeGateway, MergeQueueGateway {

async inspectQueue(expectedHead: string, options: { signal?: AbortSignal; timeoutMs?: number; afterCursor?: string | null } = {}): Promise<MergeQueueObservation> {
fullSha(expectedHead, 'expected head SHA');
const timeoutMs = options.timeoutMs ?? 12_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 12_000) throw new Error('Invalid GitHub queue inspection timeout.');
const timeoutMs = options.timeoutMs ?? MERGE_INSPECTION_TIMEOUT_MS;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MERGE_INSPECTION_TIMEOUT_MS) throw new Error('Invalid GitHub queue inspection timeout.');
const correlated = Object.hasOwn(options, 'afterCursor');
if (options.afterCursor !== undefined && options.afterCursor !== null && (typeof options.afterCursor !== 'string' || !options.afterCursor || options.afterCursor.length > 512))
throw new Error('Invalid merge-queue event cursor.');
Expand Down
10 changes: 6 additions & 4 deletions runner/merge.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { ReviewService } from './review.ts';
import { mergeActionResponse, type MergeAttempt } from './store.ts';
import { ActionIdReused, GuardRefusal, MERGEABLE_STATUSES, ShuttingDownError, assertUuidV4, settleWith, type ShutdownCapability } from './lifecycle.ts';
import { MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type MergeQueueObservation, type MergeResult, type RemoteMergeState } from '../github/merge.ts';
import { MERGE_INSPECTION_TIMEOUT_MS, MergeSubmissionError, type MergeGateway, type MergeQueueGateway, type MergeQueueObservation, type MergeResult, type RemoteMergeState } from '../github/merge.ts';

type ReviewView = ReturnType<ReviewService['load']>;
type QueueGateway = MergeGateway & MergeQueueGateway;
export interface MergeBlocker { code: string; message: string; }
/** The longest a merge click may run before it is aborted; its last gh call's stop wait comes on top (github/merge.ts). */
export const MERGE_OPERATION_TIMEOUT_MS = 14_000;
const storageError = (error: unknown) => (error as { code?: string } | null)?.code === 'ERR_SQLITE_ERROR';
/** The merge was not applied for a passing reason (deadline, shutdown); the same click may be sent again. */
export class MergeNotApplied extends Error {}
Expand Down Expand Up @@ -44,8 +46,8 @@ export class MergeCoordinator {
readonly operationTimeoutMs: number;
/** Settlement of an irreversible merge keeps its writes after the Store gate closes; request-path reconciliation does not. */
#settle: <T>(fn: () => T) => T;
constructor(service: ReviewService, gateway: MergeGateway, operationTimeoutMs = 14_000, capability?: ShutdownCapability) {
if (!Number.isSafeInteger(operationTimeoutMs) || operationTimeoutMs < 1 || operationTimeoutMs > 14_000) throw new Error('Invalid merge operation deadline.');
constructor(service: ReviewService, gateway: MergeGateway, operationTimeoutMs = MERGE_OPERATION_TIMEOUT_MS, capability?: ShutdownCapability) {
if (!Number.isSafeInteger(operationTimeoutMs) || operationTimeoutMs < 1 || operationTimeoutMs > MERGE_OPERATION_TIMEOUT_MS) throw new Error('Invalid merge operation deadline.');
this.service = service; this.gateway = gateway; this.operationTimeoutMs = operationTimeoutMs;
this.#settle = settleWith(capability);
}
Expand Down Expand Up @@ -361,7 +363,7 @@ export class MergeCoordinator {

async #pollQueue(attempt: MergeAttempt, signal: AbortSignal): Promise<MergeQueueStatus | null> {
try {
const observation = await (this.gateway as QueueGateway).inspectQueue(attempt.reviewedHead, { signal, timeoutMs: 12_000, afterCursor: attempt.queueWatermark ?? null });
const observation = await (this.gateway as QueueGateway).inspectQueue(attempt.reviewedHead, { signal, timeoutMs: MERGE_INSPECTION_TIMEOUT_MS, afterCursor: attempt.queueWatermark ?? null });
this.#publishQueueObservation(attempt, observation);
return this.#queueStatus();
} catch (error) {
Expand Down
Loading
Loading