Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,73 @@ Tool reference for the `sdlc-server` MCP. The authoritative list is the register

Tools below are listed in alphabetic order. To add a tool: drop a file in `handlers/<name>.ts`; the next CI run regenerates the registry and exposes it.

## pr_wait_ci

**Purpose.** Block until a PR/MR's status checks settle. Server-side polling with configurable interval (default `30s`, hard floor `5s`) and timeout (default `1800s`). Used by `/scpmmr`, the wave-pattern Flight finalizer, and any caller that needs a deterministic "wait for CI to finish" with a typed terminal.

**Inputs.**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `number` | number | (required) | PR (GitHub) or MR (GitLab) iid. |
| `poll_interval_sec` | number | `30` | Seconds between snapshots. Hard floor `5`. |
| `timeout_sec` | number | `1800` | Maximum wall-clock wait. |
| `repo` | string | (cwd remote) | `owner/repo` slug for cross-repo dispatch. GitLab nested groups (`group/subgroup/repo`) are accepted. |

**Returns — polling-loop path** (one or more checks configured on the head ref).

```json
{
"ok": true,
"number": 42,
"final_state": "passed" | "failed" | "timed_out",
"checks": { "total": 3, "passed": 3, "failed": 0, "pending": 0, "summary": "3/3 passed" },
"waited_sec": 8,
"url": "https://github.com/org/repo/pull/42"
}
```

`final_state: 'passed'` requires `total > 0 && pending === 0 && failed === 0`. All-skipped checks (every workflow's `if:` guard didn't match) count as `passed` — see #221.

**Returns — empty-rollup short-circuit** (#416). When the head ref has no required status checks, the handler returns immediately at t=0 instead of spinning to timeout. The semantics is "wait until CI is settled" — if there are no checks to settle, that condition is satisfied at t=0.

```json
{
"ok": true,
"number": 42,
"status": "no_checks_required",
"elapsed_sec": 0,
"mergeable": true,
"url": "https://github.com/org/repo/pull/42"
}
```

When the rollup is empty AND the PR/MR is obstructed (draft, closed, conflicts, …), `mergeable` is `false` and a `blocker` field names the obstruction:

```json
{
"ok": true,
"number": 42,
"status": "no_checks_required",
"elapsed_sec": 0,
"mergeable": false,
"blocker": "draft" | "closed" | "merged" | "conflicts" | "not_mergeable" | "locked",
"url": "https://github.com/org/repo/pull/42"
}
```

**Discriminator.** Callers should branch on the response shape via either field:

- `status === 'no_checks_required'` → empty-rollup short-circuit (`final_state` absent).
- `final_state` present → polling-loop result (`status` absent).

**Platform notes.**

- GitHub: probe is `gh pr view <num> --json statusCheckRollup,url,state,isDraft,mergeable,mergeStateStatus`. Polling-loop snapshot uses the slimmer `--json statusCheckRollup,url` (per-iteration cost stays minimal). `gh pr checks --json` is NOT used — it broke on Ubuntu 24.04's gh 2.45 (#220).
- GitLab: probe is `glab api projects/<encoded-slug>/merge_requests/<iid>`. Empty-rollup means `head_pipeline === null && pipeline === null`. Polling-loop status mapping: `success → passed`, `failed/canceled → failed`, `running/pending/created/preparing/waiting_for_resource/scheduled/manual → pending`, anything else → uncounted (loop times out).

**See also.** `pattern_decorative_ac_and_stub_orphan.md` for the failure mode this short-circuit closes (autopilot callers losing 30-minute timeout windows on docs-only PRs with no CI).

## wave_wait_for_signal

**Purpose.** Sanctioned idle-wait for wave-pattern Orchestrators (and Primes) that have dispatched work and need to block until filesystem-bus artifacts appear. Replaces ad-hoc polling loops, `Bash(sleep)` invocations, and the agent-anxiety failure mode where idle loops are exited prematurely with non-canonical lines like `"Sleep is still running. Let me wait for the notification."` See `pattern_sanctioned_fidget_tool.md` (cc-workflow memory) for the design rationale.
Expand Down
191 changes: 191 additions & 0 deletions lib/adapters/pr-wait-ci-github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const {
prWaitCiGithub,
classifyRollupItem,
snapshotGithub,
emptyRollupBlocker,
probeGithub,
} = await import('./pr-wait-ci-github.ts');

function on(match: string, respond: string | (() => string)): void {
Expand Down Expand Up @@ -243,6 +245,9 @@ describe('prWaitCiGithub — #221 all-skipped regression', () => {
if (!('ok' in result) || !result.ok) {
throw new Error(`expected ok result, got ${JSON.stringify(result)}`);
}
if (!('final_state' in result.data)) {
throw new Error(`expected polled-response shape, got ${JSON.stringify(result.data)}`);
}
expect(result.data.final_state).toBe('passed');
expect(result.data.checks.passed).toBe(0);
expect(result.data.checks.total).toBe(3); // total counts SKIPPED
Expand All @@ -251,6 +256,192 @@ describe('prWaitCiGithub — #221 all-skipped regression', () => {
});
});

// --- #416: empty-rollup short-circuit at the adapter boundary -----------
describe('prWaitCiGithub — empty-rollup short-circuit (#416)', () => {
test('empty rollup + mergeable → no_checks_required immediately', async () => {
on(
'gh pr view',
JSON.stringify({
url: 'https://github.com/org/repo/pull/42',
statusCheckRollup: [],
state: 'OPEN',
isDraft: false,
mergeable: 'MERGEABLE',
mergeStateStatus: 'CLEAN',
}),
);

const result = await prWaitCiGithub({
number: 42,
poll_interval_sec: 5,
timeout_sec: 1800, // huge — proves we don't enter the poll loop
});
if (!('ok' in result) || !result.ok) {
throw new Error(`expected ok result, got ${JSON.stringify(result)}`);
}
const data = result.data as { status?: string; mergeable?: boolean; blocker?: string; elapsed_sec?: number; url?: string };
expect(data.status).toBe('no_checks_required');
expect(data.mergeable).toBe(true);
expect(data.blocker).toBeUndefined();
expect(data.elapsed_sec).toBeLessThan(5);
expect(data.url).toBe('https://github.com/org/repo/pull/42');
// Probe argv must include the new fields the short-circuit needs.
const viewCall = execCalls.find((c) => c.startsWith('gh pr view')) ?? '';
expect(viewCall).toContain('mergeable');
expect(viewCall).toContain('isDraft');
expect(viewCall).toContain('state');
// #220 regression — never use the broken `gh pr checks --json` form.
expect(execCalls.some((c) => c.startsWith('gh pr checks'))).toBe(false);
});

test('empty rollup + CONFLICTING → no_checks_required + conflicts blocker', async () => {
on(
'gh pr view',
JSON.stringify({
url: 'https://github.com/org/repo/pull/43',
statusCheckRollup: [],
state: 'OPEN',
isDraft: false,
mergeable: 'CONFLICTING',
mergeStateStatus: 'DIRTY',
}),
);
const result = await prWaitCiGithub({
number: 43,
poll_interval_sec: 5,
timeout_sec: 1800,
});
if (!('ok' in result) || !result.ok) throw new Error('expected ok');
const data = result.data as { status?: string; mergeable?: boolean; blocker?: string };
expect(data.status).toBe('no_checks_required');
expect(data.mergeable).toBe(false);
expect(data.blocker).toBe('conflicts');
});

test('empty rollup + draft → no_checks_required + draft blocker', async () => {
on(
'gh pr view',
JSON.stringify({
url: 'https://github.com/org/repo/pull/44',
statusCheckRollup: [],
state: 'OPEN',
isDraft: true,
mergeable: 'MERGEABLE',
mergeStateStatus: 'CLEAN',
}),
);
const result = await prWaitCiGithub({ number: 44, poll_interval_sec: 5, timeout_sec: 60 });
if (!('ok' in result) || !result.ok) throw new Error('expected ok');
const data = result.data as { status?: string; mergeable?: boolean; blocker?: string };
expect(data.status).toBe('no_checks_required');
expect(data.mergeable).toBe(false);
expect(data.blocker).toBe('draft');
});

test('empty rollup + closed PR → no_checks_required + closed blocker', async () => {
on(
'gh pr view',
JSON.stringify({
url: 'https://github.com/org/repo/pull/45',
statusCheckRollup: [],
state: 'CLOSED',
isDraft: false,
mergeable: 'UNKNOWN',
mergeStateStatus: 'UNKNOWN',
}),
);
const result = await prWaitCiGithub({ number: 45, poll_interval_sec: 5, timeout_sec: 60 });
if (!('ok' in result) || !result.ok) throw new Error('expected ok');
const data = result.data as { status?: string; mergeable?: boolean; blocker?: string };
expect(data.status).toBe('no_checks_required');
expect(data.mergeable).toBe(false);
expect(data.blocker).toBe('closed');
});

test('non-empty rollup does NOT short-circuit — polled-response shape returned', async () => {
// Regression — proves the addition is conditional on rollup.length === 0.
on(
'gh pr view',
JSON.stringify({
url: 'https://github.com/org/repo/pull/46',
statusCheckRollup: [
{ __typename: 'CheckRun', name: 'ci', status: 'COMPLETED', conclusion: 'SUCCESS' },
],
state: 'OPEN',
isDraft: false,
mergeable: 'MERGEABLE',
mergeStateStatus: 'CLEAN',
}),
);
const result = await prWaitCiGithub({ number: 46, poll_interval_sec: 5, timeout_sec: 10 });
if (!('ok' in result) || !result.ok) throw new Error('expected ok');
const data = result.data as { status?: string; final_state?: string };
expect(data.final_state).toBe('passed');
expect(data.status).toBeUndefined();
});
});

// --- pure mapper tests for the empty-rollup blocker classifier (#416) ------
describe('emptyRollupBlocker — pure mapping table', () => {
test('OPEN + mergeable + not draft → null (no blocker)', () => {
expect(emptyRollupBlocker({ state: 'OPEN', isDraft: false, mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' })).toBeNull();
});

test('OPEN + mergeable=true (boolean) → null', () => {
expect(emptyRollupBlocker({ state: 'OPEN', isDraft: false, mergeable: true })).toBeNull();
});

test('CLOSED → closed', () => {
expect(emptyRollupBlocker({ state: 'CLOSED', mergeable: 'MERGEABLE' })).toBe('closed');
});

test('MERGED → merged', () => {
expect(emptyRollupBlocker({ state: 'MERGED', mergeable: 'MERGEABLE' })).toBe('merged');
});

test('isDraft=true → draft (even when mergeable)', () => {
expect(emptyRollupBlocker({ state: 'OPEN', isDraft: true, mergeable: 'MERGEABLE' })).toBe('draft');
});

test('CONFLICTING → conflicts', () => {
expect(emptyRollupBlocker({ state: 'OPEN', isDraft: false, mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' })).toBe('conflicts');
});

test('mergeable=UNKNOWN → not_mergeable (defensive)', () => {
// GitHub returns UNKNOWN while it's still computing mergeability. We don't
// promise the caller "yes mergeable" until GitHub has actually decided.
expect(emptyRollupBlocker({ state: 'OPEN', isDraft: false, mergeable: 'UNKNOWN' })).toBe('not_mergeable');
});

test('mergeable=false (boolean) → not_mergeable', () => {
expect(emptyRollupBlocker({ state: 'OPEN', isDraft: false, mergeable: false })).toBe('not_mergeable');
});
});

describe('probeGithub — argv shape', () => {
test('asks for statusCheckRollup,url,state,isDraft,mergeable,mergeStateStatus', () => {
on(
'gh pr view',
JSON.stringify({
url: 'https://github.com/org/repo/pull/1',
statusCheckRollup: [],
state: 'OPEN',
isDraft: false,
mergeable: 'MERGEABLE',
mergeStateStatus: 'CLEAN',
}),
);
probeGithub(1);
const viewCall = execCalls.find((c) => c.startsWith('gh pr view')) ?? '';
expect(viewCall).toContain('statusCheckRollup');
expect(viewCall).toContain('url');
expect(viewCall).toContain('state');
expect(viewCall).toContain('isDraft');
expect(viewCall).toContain('mergeable');
expect(viewCall).toContain('mergeStateStatus');
});
});

describe('prWaitCiGithub — failure surfaces as AdapterResult', () => {
test('gh failure → ok:false, code unexpected_error', async () => {
on('gh pr view', () => {
Expand Down
71 changes: 71 additions & 0 deletions lib/adapters/pr-wait-ci-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import type {
AdapterResult,
PrWaitCiArgs,
PrWaitCiNoChecksResponse,
PrWaitCiResponse,
} from './types.js';

Expand Down Expand Up @@ -61,6 +62,15 @@ interface PrViewResponse {
statusCheckRollup?: RollupItem[];
}

interface PrProbeResponse {
url?: string;
statusCheckRollup?: RollupItem[];
state?: string; // OPEN | CLOSED | MERGED
isDraft?: boolean;
mergeable?: string | boolean; // MERGEABLE | CONFLICTING | UNKNOWN | bool
mergeStateStatus?: string; // CLEAN | DIRTY | BLOCKED | UNSTABLE | UNKNOWN
}

type Bucket = 'pass' | 'fail' | 'pending' | 'skipping';

/**
Expand Down Expand Up @@ -139,12 +149,73 @@ export function snapshotGithub(number: number, repo?: string): ChecksSnapshot {
};
}

/**
* Initial probe — single `gh pr view` that pulls rollup + mergeability fields
* in one shot (#416). Used to detect the empty-rollup short-circuit case
* before the polling loop even starts. Separate from `snapshotGithub` because
* the polling loop's per-iteration query stays minimal (no need to repeat
* mergeability on every poll).
*/
export function probeGithub(number: number, repo?: string): PrProbeResponse {
const raw = exec(
`gh pr view ${number} --json statusCheckRollup,url,state,isDraft,mergeable,mergeStateStatus${repoFlag(repo)}`,
);
return JSON.parse(raw) as PrProbeResponse;
}

/**
* Resolve the empty-rollup short-circuit blocker (#416). Returns `null` when
* the PR is mergeable today (no checks AND no obstructions). Otherwise returns
* a short string naming the obstruction — `draft`, `closed`, `merged`,
* `conflicts`, or `not_mergeable` — for inclusion in the typed response.
*
* `mergeable` is a tri-state on GitHub: `MERGEABLE | CONFLICTING | UNKNOWN`
* (or boolean on older REST shapes). We treat anything that isn't an explicit
* "yes, mergeable" as a blocker so callers never get a false-positive on a PR
* that GitHub is still computing.
*/
export function emptyRollupBlocker(probe: PrProbeResponse): string | null {
const state = (probe.state ?? '').toUpperCase();
if (state === 'CLOSED') return 'closed';
if (state === 'MERGED') return 'merged';
if (probe.isDraft === true) return 'draft';

const mergeableRaw =
typeof probe.mergeable === 'string' ? probe.mergeable.toUpperCase() : probe.mergeable;
const mergeable = mergeableRaw === true || mergeableRaw === 'MERGEABLE';
if (!mergeable) {
const mergeState = (probe.mergeStateStatus ?? '').toUpperCase();
if (mergeState === 'DIRTY' || mergeableRaw === 'CONFLICTING') return 'conflicts';
return 'not_mergeable';
}
return null;
}

export async function prWaitCiGithub(
args: PrWaitCiArgs,
): Promise<AdapterResult<PrWaitCiResponse>> {
// Bound any exception that escapes the snapshot helper into a typed result —
// adapter callers must not have to try/catch.
try {
// #416 short-circuit. One probe BEFORE entering the polling loop: if the
// PR's rollup is empty there is nothing to settle and we return at t=0.
const probeStart = Date.now();
const probe = probeGithub(args.number, args.repo);
const rollup = probe.statusCheckRollup ?? [];
if (rollup.length === 0) {
const blocker = emptyRollupBlocker(probe);
const elapsedSec = Math.max(0, Math.floor((Date.now() - probeStart) / 1000));
const data: PrWaitCiNoChecksResponse = {
number: args.number,
status: 'no_checks_required',
elapsed_sec: elapsedSec,
mergeable: blocker === null,
url: probe.url ?? '',
...(blocker !== null ? { blocker } : {}),
};
return { ok: true, data };
}

const pollArgs: PollArgs = {
number: args.number,
poll_interval_sec: args.poll_interval_sec,
Expand Down
Loading
Loading