diff --git a/.claude/skills/issue-lifecycle/SKILL.md b/.claude/skills/issue-lifecycle/SKILL.md index 2a2a3c92..a5fd72cd 100644 --- a/.claude/skills/issue-lifecycle/SKILL.md +++ b/.claude/skills/issue-lifecycle/SKILL.md @@ -125,8 +125,10 @@ Use this table to determine what to do next: | `classified` | Read [references/planning.md](references/planning.md) | | `plan_generated` | Read [references/adversarial-review.md](references/adversarial-review.md) | | `approved` | Read [references/implementation.md](references/implementation.md) | -| `implementing` | Check PR status or call `complete` | -| `pr_open` | Check PR status or call `complete` | +| `implementing` | Link a PR with `link_pr` or call `complete` | +| `pr_open` | Wait 3 min, then check PR: `pr_merged` if merged, `pr_failed` if failed | +| `pr_failed` | Fix the issue, then `link_pr` (new PR) or `implement` (major rework) | +| `releasing` | Check release build: `ship` when done, or `complete` as fallback | | `done` | Nothing to do — lifecycle is complete | The canonical phase list lives in the `TRANSITIONS` constant in @@ -140,11 +142,20 @@ When a PR has already merged and the lifecycle just needs to be marked done: ``` swamp data get issue- state-main --json ``` -2. If the phase is `implementing` and you have a PR URL, link it first: +2. If the phase is `implementing`, link the PR first: ``` swamp model method run issue- link_pr --input url= ``` -3. Call `complete` (accepts both `implementing` and `pr_open` as source phases): +3. If the phase is `pr_open`, record the merge: + ``` + swamp model method run issue- pr_merged + ``` +4. If the phase is `releasing`, ship it: + ``` + swamp model method run issue- ship + ``` +5. For quick close-out, `complete` still works from `implementing`, `pr_open`, + or `releasing`: ``` swamp model method run issue- complete ``` @@ -162,7 +173,9 @@ When a PR has already merged and the lifecycle just needs to be marked done: reference specific files, functions, and test paths. 6. **Follow the planning conventions for this repository.** Read `agent-constraints/planning-conventions.md` if it exists. -7. **File unrelated issues immediately.** If you discover a bug, code smell, or +7. **Never open a PR without asking first.** Present the changes summary and + wait for the human to confirm before creating the pull request. +8. **File unrelated issues immediately.** If you discover a bug, code smell, or problem during investigation that is NOT related to the current issue, file it as a new swamp-club issue. Do not try to fix it in the current work span — keep the scope focused. diff --git a/.claude/skills/issue-lifecycle/references/implementation.md b/.claude/skills/issue-lifecycle/references/implementation.md index 4812e649..926e3187 100644 --- a/.claude/skills/issue-lifecycle/references/implementation.md +++ b/.claude/skills/issue-lifecycle/references/implementation.md @@ -37,6 +37,10 @@ Report verification results to the human before creating the PR: ## 4. Create a PR +**Always ask the human before opening a PR.** Do not create the PR automatically +— present a summary of the changes and ask if they are ready to open it. Only +proceed after explicit confirmation. + Use the repository's normal PR tooling. Read `agent-constraints/implementation-conventions.md` for repo-specific PR conventions. @@ -69,13 +73,56 @@ Calling `link_pr` is **encouraged but not enforced** — `complete` still accept records. Prefer the `implementing → link_pr → complete` flow for all new work so the lifecycle record carries the PR link. -## 5. Complete the Issue +## 4b. Wait for CI and Check PR Status + +After linking the PR, wait at least 3 minutes for CI to run. The model enforces +a `pr-cooldown` check — calling `pr_merged` or `pr_failed` within 3 minutes of +`link_pr` will be rejected. + +Check the PR status externally (e.g., +`gh pr view --json state,statusCheckRollup`): + +- **PR merged**: call `pr_merged` to transition to `releasing` + ``` + swamp model method run issue- pr_merged + ``` +- **PR failed** (CI red, changes requested): call `pr_failed` with the reason + ``` + swamp model method run issue- pr_failed --input reason="CI failed: type check errors" + ``` +- **PR still open and passing**: wait and check again later. + +## 4c. Handle PR Failure + +When in `pr_failed`, diagnose and fix the issue. Then either: + +- Push fixes and call `link_pr` again (same or new PR URL) to return to + `pr_open`: + ``` + swamp model method run issue- link_pr --input url= + ``` +- Call `implement` to go back to the implementing phase for major rework: + ``` + swamp model method run issue- implement + ``` -Once the PR has merged and the work is done, call `complete`: +## 5. Ship the Release + +After `pr_merged` transitions to `releasing`, wait for the release build to +complete. Once the release is out, call `ship`: + +``` +swamp model method run issue- ship +``` + +Optionally pass release metadata: ``` -swamp model method run issue- complete +swamp model method run issue- ship --input releaseUrl= --input releaseNotes="Bug fix release" ``` This transitions the phase to `done`, transitions the swamp-club status to -`shipped`, and posts a `complete` lifecycle entry. +`shipped`, and posts a `shipped` lifecycle entry. + +For quick close-out (e.g., the PR merged and you just want to wrap up), +`complete` still works from `implementing`, `pr_open`, or `releasing`. diff --git a/extensions/models/_lib/schemas.ts b/extensions/models/_lib/schemas.ts index 949d764e..754c5190 100644 --- a/extensions/models/_lib/schemas.ts +++ b/extensions/models/_lib/schemas.ts @@ -44,11 +44,16 @@ export const Phase = z.enum([ "approved", "implementing", "pr_open", + "pr_failed", + "releasing", "done", ]); export type Phase = z.infer; +/** Minimum time (ms) between link_pr and pr_merged/pr_failed to allow CI to run. */ +export const PR_COOLDOWN_MS = 3 * 60 * 1000; + /** Valid transitions: method name → allowed source phases */ export const TRANSITIONS: Record = { start: [ @@ -59,16 +64,21 @@ export const TRANSITIONS: Record = { "approved", "implementing", "pr_open", + "pr_failed", + "releasing", ], triage: ["triaging"], plan: ["classified"], iterate: ["plan_generated"], approve: ["plan_generated"], - implement: ["approved"], + implement: ["approved", "pr_failed"], adversarial_review: ["plan_generated"], resolve_findings: ["plan_generated"], - link_pr: ["implementing", "pr_open"], - complete: ["implementing", "pr_open"], + link_pr: ["implementing", "pr_open", "pr_failed"], + pr_merged: ["pr_open"], + pr_failed: ["pr_open"], + ship: ["releasing"], + complete: ["implementing", "pr_open", "releasing"], }; // --------------------------------------------------------------------------- @@ -170,10 +180,23 @@ export const PullRequestSchema = z.object({ "Canonical URL of the pull request. Opaque to the model — the agent " + "supplies whatever URL their git host produced.", ), + attempt: z.number().describe( + "Sequential attempt number. Starts at 1 on the first link_pr call, " + + "incremented on each subsequent link_pr call after a pr_failed cycle.", + ), linkedAt: z.string().describe( "ISO-8601 timestamp of when link_pr was called. Updated on every " + "subsequent link_pr call so the record reflects the latest link.", ), + mergedAt: z.string().optional().describe( + "ISO-8601 timestamp of when pr_merged was called. Set once.", + ), + failedAt: z.string().optional().describe( + "ISO-8601 timestamp of when pr_failed was called. Cleared on next link_pr.", + ), + failureReason: z.string().optional().describe( + "Why the PR failed (CI failure, review rejection, etc.). Cleared on next link_pr.", + ), }); export type PullRequestData = z.infer; diff --git a/extensions/models/_lib/schemas_test.ts b/extensions/models/_lib/schemas_test.ts index cc1deb43..df65c368 100644 --- a/extensions/models/_lib/schemas_test.ts +++ b/extensions/models/_lib/schemas_test.ts @@ -17,29 +17,35 @@ import { assertEquals } from "@std/assert"; import { Phase, PullRequestSchema, TRANSITIONS } from "./schemas.ts"; -Deno.test("Phase: includes pr_open between implementing and done", () => { +Deno.test("Phase: includes pr_open, pr_failed, releasing between implementing and done", () => { const phases = Phase.options; const implementingIdx = phases.indexOf("implementing"); const prOpenIdx = phases.indexOf("pr_open"); + const prFailedIdx = phases.indexOf("pr_failed"); + const releasingIdx = phases.indexOf("releasing"); const doneIdx = phases.indexOf("done"); assertEquals(prOpenIdx, implementingIdx + 1); - assertEquals(doneIdx, prOpenIdx + 1); + assertEquals(prFailedIdx, prOpenIdx + 1); + assertEquals(releasingIdx, prFailedIdx + 1); + assertEquals(doneIdx, releasingIdx + 1); }); -Deno.test("TRANSITIONS: link_pr is idempotent from implementing and pr_open", () => { - assertEquals(TRANSITIONS.link_pr, ["implementing", "pr_open"]); +Deno.test("TRANSITIONS: link_pr accepts implementing, pr_open, and pr_failed", () => { + assertEquals(TRANSITIONS.link_pr, ["implementing", "pr_open", "pr_failed"]); }); -Deno.test("TRANSITIONS: complete accepts both implementing (legacy) and pr_open (new)", () => { - // Accepting both keeps existing records (created before this phase was - // introduced) able to finish without being forced through link_pr. - assertEquals(TRANSITIONS.complete, ["implementing", "pr_open"]); +Deno.test("TRANSITIONS: complete accepts implementing, pr_open, and releasing", () => { + // Accepting all three keeps backwards compatibility while allowing + // the new releasing phase to be closed out directly. + assertEquals(TRANSITIONS.complete, ["implementing", "pr_open", "releasing"]); }); -Deno.test("TRANSITIONS: start (resume) includes pr_open so in-flight issues can be picked up", () => { +Deno.test("TRANSITIONS: start (resume) includes pr_open, pr_failed, and releasing", () => { const startPhases = TRANSITIONS.start; assertEquals(startPhases.includes("pr_open"), true); + assertEquals(startPhases.includes("pr_failed"), true); + assertEquals(startPhases.includes("releasing"), true); }); Deno.test("TRANSITIONS: link_pr is rejected from earlier lifecycle phases", () => { @@ -73,6 +79,7 @@ Deno.test("PullRequestSchema: accepts any non-empty URL string", () => { for (const url of samples) { const parsed = PullRequestSchema.parse({ url, + attempt: 1, linkedAt: "2026-04-08T15:00:00.000Z", }); assertEquals(parsed.url, url); @@ -82,6 +89,7 @@ Deno.test("PullRequestSchema: accepts any non-empty URL string", () => { Deno.test("PullRequestSchema: rejects empty url string", () => { const result = PullRequestSchema.safeParse({ url: "", + attempt: 1, linkedAt: "2026-04-08T15:00:00.000Z", }); assertEquals(result.success, false); diff --git a/extensions/models/issue_lifecycle.ts b/extensions/models/issue_lifecycle.ts index 57911122..31f6fab5 100644 --- a/extensions/models/issue_lifecycle.ts +++ b/extensions/models/issue_lifecycle.ts @@ -27,6 +27,8 @@ import { type PlanData, PlanSchema, PlanStepSchema, + PR_COOLDOWN_MS, + type PullRequestData, PullRequestSchema, type StateData, StateSchema, @@ -68,7 +70,7 @@ async function readState( export const model = { type: "@swamp/issue-lifecycle", - version: "2026.04.08.2", + version: "2026.04.09.1", globalArguments: GlobalArgsSchema, upgrades: [ @@ -105,6 +107,17 @@ export const model = { "complete now accepts pr_open as a valid source phase alongside implementing.", upgradeAttributes: (old: Record) => old, }, + { + toVersion: "2026.04.09.1", + description: "Add post-PR lifecycle phases: pr_failed, releasing. " + + "New methods: pr_merged (pr_open → releasing), pr_failed (pr_open → pr_failed), " + + "ship (releasing → done). " + + "New check: pr-cooldown enforces 3-minute wait after link_pr before status checks. " + + "link_pr now accepts pr_failed as source phase for recovery. " + + "implement now accepts pr_failed for rework scenarios. " + + "complete now accepts releasing for backwards compatibility.", + upgradeAttributes: (old: Record) => old, + }, ], resources: { @@ -254,11 +267,59 @@ export const model = { if (!state) { return { pass: false, errors: ["No state found."] }; } - if (state.phase !== "approved") { + if (state.phase !== "approved" && state.phase !== "pr_failed") { return { pass: false, errors: [ - "Plan must be approved before implementation can begin.", + "Plan must be approved (or PR must have failed) before implementation can begin.", + ], + }; + } + return { pass: true }; + }, + }, + + "pr-cooldown": { + description: + "Enforces a minimum cooldown after link_pr before the PR status can be " + + "reported, giving CI time to run", + labels: ["policy"], + appliesTo: ["pr_merged", "pr_failed"], + execute: async (context: { + dataRepository: { + getContent: ( + type: string, + modelId: string, + dataName: string, + ) => Promise; + }; + modelType: string; + modelId: string; + }) => { + const content = await context.dataRepository.getContent( + context.modelType, + context.modelId, + "pullRequest-main", + ); + if (!content) { + return { + pass: false, + errors: ["No pull request linked. Call link_pr first."], + }; + } + const pr = JSON.parse( + new TextDecoder().decode(content), + ) as PullRequestData; + const linkedAt = new Date(pr.linkedAt).getTime(); + const now = Date.now(); + const elapsed = now - linkedAt; + if (elapsed < PR_COOLDOWN_MS) { + const remaining = Math.ceil((PR_COOLDOWN_MS - elapsed) / 1000); + return { + pass: false, + errors: [ + `PR was linked ${Math.floor(elapsed / 1000)}s ago. ` + + `Wait ${remaining}s more before checking PR status (3-minute cooldown for CI).`, ], }; } @@ -1106,7 +1167,7 @@ export const model = { description: "Link a pull request to the implementation. Idempotent — calling " + "again overwrites the recorded URL with the latest link. " + - "Transitions the phase to pr_open if currently implementing.", + "Transitions the phase to pr_open from implementing or pr_failed.", arguments: z.object({ url: z.string().min(1).describe( "Canonical pull request URL. Opaque to the model — pass whatever " + @@ -1126,16 +1187,26 @@ export const model = { instanceName: string, data: Record, ) => Promise<{ name: string }>; + readResource: ( + instanceName: string, + version?: number, + ) => Promise | null>; }, ) => { const { issueNumber } = context.globalArgs; const now = new Date().toISOString(); + const existing = await context.readResource("pullRequest-main") as + | PullRequestData + | null; + const attempt = existing ? (existing.attempt ?? 0) + 1 : 1; + const prHandle = await context.writeResource( "pullRequest", "pullRequest-main", { url: args.url, + attempt, linkedAt: now, }, ); @@ -1146,7 +1217,10 @@ export const model = { updatedAt: now, }); - context.logger.info("PR linked: {url}", { url: args.url }); + context.logger.info("PR linked (attempt {attempt}): {url}", { + attempt, + url: args.url, + }); const sc = await createSwampClubClient( context.globalArgs, @@ -1155,9 +1229,9 @@ export const model = { await sc?.postLifecycleEntry({ step: "pr_linked", targetStatus: "in_progress", - summary: `PR linked: ${args.url}`, + summary: `PR linked (attempt ${attempt}): ${args.url}`, emoji: "\u{1F517}", - payload: { url: args.url }, + payload: { url: args.url, attempt }, isVerbose: false, }); @@ -1165,6 +1239,234 @@ export const model = { }, }, + pr_merged: { + description: + "Record that the linked PR has been merged. Transitions to releasing.", + arguments: z.object({ + mergedAt: z.string().optional().describe( + "ISO-8601 timestamp of when the PR was merged. Defaults to now.", + ), + }), + execute: async ( + args: { mergedAt?: string }, + context: { + globalArgs: GlobalArgs; + logger: { + info: (msg: string, props: Record) => void; + warning: (msg: string, props: Record) => void; + }; + writeResource: ( + specName: string, + instanceName: string, + data: Record, + ) => Promise<{ name: string }>; + readResource: ( + instanceName: string, + version?: number, + ) => Promise | null>; + }, + ) => { + const { issueNumber } = context.globalArgs; + const now = new Date().toISOString(); + const handles = []; + + const prContent = await context.readResource("pullRequest-main") as + | PullRequestData + | null; + if (!prContent) { + throw new Error("No pull request linked. Call link_pr first."); + } + + const attempt = prContent.attempt ?? 1; + + handles.push( + await context.writeResource("pullRequest", "pullRequest-main", { + url: prContent.url, + attempt, + linkedAt: prContent.linkedAt, + mergedAt: args.mergedAt ?? now, + }), + ); + + handles.push( + await context.writeResource("state", "state-main", { + phase: "releasing", + issueNumber, + updatedAt: now, + }), + ); + + context.logger.info( + "PR merged (attempt {attempt}) \u2014 awaiting release build", + { attempt }, + ); + + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); + await sc?.postLifecycleEntry({ + step: "pr_merged", + targetStatus: "in_progress", + summary: + `PR merged (attempt ${attempt}): ${prContent.url} \u2014 awaiting release`, + emoji: "\u{1F389}", + payload: { + url: prContent.url, + attempt, + mergedAt: args.mergedAt ?? now, + }, + isVerbose: false, + }); + + return { dataHandles: handles }; + }, + }, + + pr_failed: { + description: + "Record that the linked PR has failed (CI failure, review rejection, etc.). " + + "Transitions to pr_failed so the agent knows to fix and re-link.", + arguments: z.object({ + reason: z.string().min(1).describe( + "Why the PR failed: CI failure details, review rejection reason, etc.", + ), + }), + execute: async ( + args: { reason: string }, + context: { + globalArgs: GlobalArgs; + logger: { + info: (msg: string, props: Record) => void; + warning: (msg: string, props: Record) => void; + }; + writeResource: ( + specName: string, + instanceName: string, + data: Record, + ) => Promise<{ name: string }>; + readResource: ( + instanceName: string, + version?: number, + ) => Promise | null>; + }, + ) => { + const { issueNumber } = context.globalArgs; + const now = new Date().toISOString(); + const handles = []; + + const prContent = await context.readResource("pullRequest-main") as + | PullRequestData + | null; + if (!prContent) { + throw new Error("No pull request linked. Call link_pr first."); + } + + const attempt = prContent.attempt ?? 1; + + handles.push( + await context.writeResource("pullRequest", "pullRequest-main", { + url: prContent.url, + attempt, + linkedAt: prContent.linkedAt, + failedAt: now, + failureReason: args.reason, + }), + ); + + handles.push( + await context.writeResource("state", "state-main", { + phase: "pr_failed", + issueNumber, + updatedAt: now, + }), + ); + + context.logger.info("PR failed (attempt {attempt}): {reason}", { + attempt, + reason: args.reason, + }); + + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); + await sc?.postLifecycleEntry({ + step: "pr_failed", + targetStatus: "in_progress", + summary: `PR failed (attempt ${attempt}): ${args.reason}`, + emoji: "\u{274C}", + payload: { url: prContent.url, attempt, reason: args.reason }, + isVerbose: false, + }); + + return { dataHandles: handles }; + }, + }, + + ship: { + description: + "Mark the release as shipped after the release build completes. " + + "Transitions to done and sets swamp-club status to shipped.", + arguments: z.object({ + releaseUrl: z.string().optional().describe( + "URL of the release (e.g., GitHub release page, package registry). Optional.", + ), + releaseNotes: z.string().optional().describe( + "Brief release notes or summary. Optional.", + ), + }), + execute: async ( + args: { releaseUrl?: string; releaseNotes?: string }, + context: { + globalArgs: GlobalArgs; + logger: { + info: (msg: string, props: Record) => void; + warning: (msg: string, props: Record) => void; + }; + writeResource: ( + specName: string, + instanceName: string, + data: Record, + ) => Promise<{ name: string }>; + }, + ) => { + const { issueNumber } = context.globalArgs; + const now = new Date().toISOString(); + + const stateHandle = await context.writeResource("state", "state-main", { + phase: "done", + issueNumber, + updatedAt: now, + }); + + context.logger.info("Shipped", {}); + + const sc = await createSwampClubClient( + context.globalArgs, + context.logger, + ); + if (sc) { + await sc.postLifecycleEntry({ + step: "shipped", + targetStatus: "shipped", + summary: args.releaseUrl + ? `Shipped: ${args.releaseUrl}` + : "Shipped", + emoji: "\u{1F680}", + payload: { + releaseUrl: args.releaseUrl, + releaseNotes: args.releaseNotes, + }, + isVerbose: false, + }); + await sc.transitionStatus("shipped"); + } + + return { dataHandles: [stateHandle] }; + }, + }, + complete: { description: "Mark the issue lifecycle as done", arguments: z.object({}), diff --git a/extensions/models/issue_lifecycle_test.ts b/extensions/models/issue_lifecycle_test.ts index 631c35e8..14a12ab2 100644 --- a/extensions/models/issue_lifecycle_test.ts +++ b/extensions/models/issue_lifecycle_test.ts @@ -14,8 +14,9 @@ // You should have received a copy of the GNU Affero General Public License along // with Swamp. If not, see . -import { assertEquals, assertRejects } from "@std/assert"; +import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; import { model } from "./issue_lifecycle.ts"; +import { PR_COOLDOWN_MS } from "./_lib/schemas.ts"; // --------------------------------------------------------------------------- // Test helpers @@ -36,12 +37,16 @@ interface RecordedWrite { */ async function buildTestContext( issueNumber: number, + opts?: { resources?: Record> }, ): Promise<{ - context: Parameters[1]; + context: Parameters[1]; writes: RecordedWrite[]; restore: () => Promise; }> { const writes: RecordedWrite[] = []; + const resources: Record> = { + ...opts?.resources, + }; const tempDir = await Deno.makeTempDir({ prefix: "issue_lifecycle_test_" }); const original = { @@ -79,8 +84,15 @@ async function buildTestContext( data: Record, ) => { writes.push({ specName, instanceName, data }); + resources[instanceName] = data; return Promise.resolve({ name: instanceName }); }, + readResource: ( + instanceName: string, + _version?: number, + ) => { + return Promise.resolve(resources[instanceName] ?? null); + }, }; return { context, writes, restore }; @@ -90,7 +102,7 @@ async function buildTestContext( // link_pr // --------------------------------------------------------------------------- -Deno.test("link_pr: writes pullRequest-main resource with url and linkedAt", async () => { +Deno.test("link_pr: writes pullRequest-main resource with url, attempt, and linkedAt", async () => { const { context, writes, restore } = await buildTestContext(42); try { await model.methods.link_pr.execute( @@ -109,6 +121,7 @@ Deno.test("link_pr: writes pullRequest-main resource with url and linkedAt", asy prWrite!.data.url, "https://github.com/systeminit/swamp/pull/1141", ); + assertEquals(prWrite!.data.attempt, 1); assertEquals(typeof prWrite!.data.linkedAt, "string"); } finally { await restore(); @@ -133,7 +146,7 @@ Deno.test("link_pr: transitions state to pr_open", async () => { } }); -Deno.test("link_pr: is idempotent — second call overwrites the pullRequest resource", async () => { +Deno.test("link_pr: is idempotent — second call increments attempt", async () => { const { context, writes, restore } = await buildTestContext(42); try { await model.methods.link_pr.execute( @@ -156,6 +169,8 @@ Deno.test("link_pr: is idempotent — second call overwrites the pullRequest res prWrites[1].data.url, "https://github.com/systeminit/swamp/pull/1142", ); + assertEquals(prWrites[0].data.attempt, 1); + assertEquals(prWrites[1].data.attempt, 2); } finally { await restore(); } @@ -167,6 +182,123 @@ Deno.test("link_pr: rejects empty url via zod schema", async () => { ); }); +Deno.test("link_pr: from pr_failed clears failure fields and increments attempt", async () => { + const { context, writes, restore } = await buildTestContext(42, { + resources: { + "pullRequest-main": { + url: "https://github.com/systeminit/swamp/pull/1141", + attempt: 1, + linkedAt: "2026-04-09T10:00:00.000Z", + failedAt: "2026-04-09T10:05:00.000Z", + failureReason: "CI failed", + }, + }, + }); + try { + await model.methods.link_pr.execute( + { url: "https://github.com/systeminit/swamp/pull/1142" }, + context, + ); + + const prWrite = writes.find((w) => w.specName === "pullRequest"); + assertEquals(prWrite !== undefined, true); + assertEquals( + prWrite!.data.url, + "https://github.com/systeminit/swamp/pull/1142", + ); + assertEquals(prWrite!.data.attempt, 2); + // link_pr overwrites the entire resource — failure fields are absent + assertEquals(prWrite!.data.failedAt, undefined); + assertEquals(prWrite!.data.failureReason, undefined); + } finally { + await restore(); + } +}); + +// --------------------------------------------------------------------------- +// pr-cooldown check +// --------------------------------------------------------------------------- + +Deno.test("pr-cooldown: rejects when PR was linked too recently", async () => { + const recentLinkedAt = new Date().toISOString(); + const checkContext = { + methodName: "pr_merged", + dataRepository: { + getContent: ( + _type: string, + _modelId: string, + dataName: string, + ) => { + if (dataName === "pullRequest-main") { + return Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + url: "https://github.com/systeminit/swamp/pull/1", + linkedAt: recentLinkedAt, + }), + ), + ); + } + return Promise.resolve(null); + }, + }, + modelType: "@swamp/issue-lifecycle", + modelId: "issue-42", + }; + + const result = await model.checks["pr-cooldown"].execute(checkContext); + assertEquals(result.pass, false); + assertStringIncludes(result.errors![0], "Wait"); +}); + +Deno.test("pr-cooldown: passes when enough time has elapsed", async () => { + const oldLinkedAt = new Date( + Date.now() - PR_COOLDOWN_MS - 1000, + ).toISOString(); + const checkContext = { + methodName: "pr_merged", + dataRepository: { + getContent: ( + _type: string, + _modelId: string, + dataName: string, + ) => { + if (dataName === "pullRequest-main") { + return Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + url: "https://github.com/systeminit/swamp/pull/1", + linkedAt: oldLinkedAt, + }), + ), + ); + } + return Promise.resolve(null); + }, + }, + modelType: "@swamp/issue-lifecycle", + modelId: "issue-42", + }; + + const result = await model.checks["pr-cooldown"].execute(checkContext); + assertEquals(result.pass, true); +}); + +Deno.test("pr-cooldown: rejects when no pullRequest is linked", async () => { + const checkContext = { + methodName: "pr_merged", + dataRepository: { + getContent: () => Promise.resolve(null), + }, + modelType: "@swamp/issue-lifecycle", + modelId: "issue-42", + }; + + const result = await model.checks["pr-cooldown"].execute(checkContext); + assertEquals(result.pass, false); + assertStringIncludes(result.errors![0], "No pull request linked"); +}); + // --------------------------------------------------------------------------- // Model registration smoke tests // --------------------------------------------------------------------------- @@ -187,6 +319,185 @@ Deno.test("model: exposes the new link_pr method definition", () => { ); }); -Deno.test("model: version bumped to 2026.04.08.2", () => { - assertEquals(model.version, "2026.04.08.2"); +Deno.test("model: version bumped to 2026.04.09.1", () => { + assertEquals(model.version, "2026.04.09.1"); +}); + +// --------------------------------------------------------------------------- +// pr_merged +// --------------------------------------------------------------------------- + +Deno.test("pr_merged: transitions state to releasing and writes mergedAt with attempt", async () => { + const { context, writes, restore } = await buildTestContext(42, { + resources: { + "pullRequest-main": { + url: "https://github.com/systeminit/swamp/pull/1141", + attempt: 2, + linkedAt: "2026-04-09T10:00:00.000Z", + }, + }, + }); + try { + await model.methods.pr_merged.execute({}, context); + + const stateWrite = writes.find((w) => w.specName === "state"); + assertEquals(stateWrite !== undefined, true); + assertEquals(stateWrite!.data.phase, "releasing"); + assertEquals(stateWrite!.data.issueNumber, 42); + + const prWrite = writes.find((w) => w.specName === "pullRequest"); + assertEquals(prWrite !== undefined, true); + assertEquals( + prWrite!.data.url, + "https://github.com/systeminit/swamp/pull/1141", + ); + assertEquals(prWrite!.data.attempt, 2); + assertEquals(typeof prWrite!.data.mergedAt, "string"); + } finally { + await restore(); + } +}); + +Deno.test("pr_merged: uses provided mergedAt when given", async () => { + const { context, writes, restore } = await buildTestContext(42, { + resources: { + "pullRequest-main": { + url: "https://github.com/systeminit/swamp/pull/1141", + attempt: 1, + linkedAt: "2026-04-09T10:00:00.000Z", + }, + }, + }); + try { + await model.methods.pr_merged.execute( + { mergedAt: "2026-04-09T12:00:00.000Z" }, + context, + ); + + const prWrite = writes.find((w) => w.specName === "pullRequest"); + assertEquals(prWrite!.data.mergedAt, "2026-04-09T12:00:00.000Z"); + } finally { + await restore(); + } +}); + +Deno.test("pr_merged: throws if no pullRequest linked", async () => { + const { context, restore } = await buildTestContext(42); + try { + await assertRejects( + () => model.methods.pr_merged.execute({}, context), + Error, + "No pull request linked", + ); + } finally { + await restore(); + } +}); + +// --------------------------------------------------------------------------- +// pr_failed +// --------------------------------------------------------------------------- + +Deno.test("pr_failed: transitions state to pr_failed and writes failure info with attempt", async () => { + const { context, writes, restore } = await buildTestContext(42, { + resources: { + "pullRequest-main": { + url: "https://github.com/systeminit/swamp/pull/1141", + attempt: 1, + linkedAt: "2026-04-09T10:00:00.000Z", + }, + }, + }); + try { + await model.methods.pr_failed.execute( + { reason: "CI failed: type check errors" }, + context, + ); + + const stateWrite = writes.find((w) => w.specName === "state"); + assertEquals(stateWrite !== undefined, true); + assertEquals(stateWrite!.data.phase, "pr_failed"); + assertEquals(stateWrite!.data.issueNumber, 42); + + const prWrite = writes.find((w) => w.specName === "pullRequest"); + assertEquals(prWrite !== undefined, true); + assertEquals(prWrite!.data.attempt, 1); + assertEquals(prWrite!.data.failureReason, "CI failed: type check errors"); + assertEquals(typeof prWrite!.data.failedAt, "string"); + } finally { + await restore(); + } +}); + +Deno.test("pr_failed: throws if no pullRequest linked", async () => { + const { context, restore } = await buildTestContext(42); + try { + await assertRejects( + () => + model.methods.pr_failed.execute( + { reason: "CI failed" }, + context, + ), + Error, + "No pull request linked", + ); + } finally { + await restore(); + } +}); + +Deno.test("pr_failed: rejects empty reason via zod schema", async () => { + await assertRejects( + () => model.methods.pr_failed.arguments.parseAsync({ reason: "" }), + ); +}); + +// --------------------------------------------------------------------------- +// ship +// --------------------------------------------------------------------------- + +Deno.test("ship: transitions state to done", async () => { + const { context, writes, restore } = await buildTestContext(42); + try { + await model.methods.ship.execute({}, context); + + const stateWrite = writes.find((w) => w.specName === "state"); + assertEquals(stateWrite !== undefined, true); + assertEquals(stateWrite!.data.phase, "done"); + assertEquals(stateWrite!.data.issueNumber, 42); + } finally { + await restore(); + } +}); + +Deno.test("ship: accepts optional releaseUrl and releaseNotes", async () => { + const { context, restore } = await buildTestContext(42); + try { + // Should not throw + await model.methods.ship.execute( + { + releaseUrl: "https://github.com/systeminit/swamp/releases/tag/v1.0.0", + releaseNotes: "Bug fix release", + }, + context, + ); + } finally { + await restore(); + } +}); + +// --------------------------------------------------------------------------- +// Model registration smoke tests (new methods) +// --------------------------------------------------------------------------- + +Deno.test("model: exposes pr_merged method definition", () => { + assertEquals("pr_merged" in model.methods, true); +}); + +Deno.test("model: exposes pr_failed method definition", () => { + assertEquals("pr_failed" in model.methods, true); +}); + +Deno.test("model: exposes ship method definition", () => { + assertEquals("ship" in model.methods, true); });