diff --git a/issue-lifecycle/.claude/skills/issue-lifecycle/SKILL.md b/issue-lifecycle/.claude/skills/issue-lifecycle/SKILL.md index 2a742504..a5fd72cd 100644 --- a/issue-lifecycle/.claude/skills/issue-lifecycle/SKILL.md +++ b/issue-lifecycle/.claude/skills/issue-lifecycle/SKILL.md @@ -108,14 +108,57 @@ swamp model output search issue- --json ## Resuming a Session -If the human comes back to an in-progress issue, check the current state: +If the human comes back to an in-progress issue, check the current phase: ``` -swamp model get issue- --json +swamp data get issue- state-main --json ``` -Then read the reference file for whatever phase the state shows and pick up from -there. +Read the `phase` field from the response. **Do NOT call `start` to resume** — +`start` unconditionally resets the phase to `triaging`, destroying progress. + +Use this table to determine what to do next: + +| Phase | Action | +| ---------------- | ------------------------------------------------------------------------- | +| `triaging` | Read [references/triage.md](references/triage.md) | +| `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` | 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 +`extensions/models/_lib/schemas.ts`. + +## Closing Out a Shipped Issue + +When a PR has already merged and the lifecycle just needs to be marked done: + +1. Check the current phase: + ``` + swamp data get issue- state-main --json + ``` +2. If the phase is `implementing`, link the PR first: + ``` + swamp model method run issue- link_pr --input url= + ``` +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 + ``` ## Key Rules @@ -130,7 +173,9 @@ there. 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/issue-lifecycle/.claude/skills/issue-lifecycle/references/implementation.md b/issue-lifecycle/.claude/skills/issue-lifecycle/references/implementation.md index 4812e649..926e3187 100644 --- a/issue-lifecycle/.claude/skills/issue-lifecycle/references/implementation.md +++ b/issue-lifecycle/.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/issue-lifecycle/.claude/skills/issue-lifecycle/references/triage.md b/issue-lifecycle/.claude/skills/issue-lifecycle/references/triage.md index 2b5deaf2..98f05109 100644 --- a/issue-lifecycle/.claude/skills/issue-lifecycle/references/triage.md +++ b/issue-lifecycle/.claude/skills/issue-lifecycle/references/triage.md @@ -3,6 +3,21 @@ Steps 1–5 of the issue lifecycle. Read this when starting a new triage or resuming an issue in the `triaging` phase. +## Before You Start + +Check whether the model instance already exists and is past triage: + +``` +swamp data get issue- state-main --json +``` + +- If this **returns data** and the `phase` is anything other than `created` or + `triaging`, the issue is already in flight. **Do NOT call `start`** — go to + the "Resuming a Session" section in SKILL.md and use the phase-to-action table + to pick up where the issue left off. +- If the command **fails** (no data found), the model instance hasn't been + created yet — proceed with step 1 below. + ## 1. Create the Model Instance The swamp-club issue must already exist — create it in the swamp-club UI first, @@ -25,6 +40,10 @@ commands in this skill also need `--repo-dir`. swamp model method run issue- start ``` +> **Warning:** `start` unconditionally resets the phase to `triaging`. Only call +> it once when beginning a new lifecycle. Never use it to resume an in-progress +> issue — it will destroy all progress (classification, plan, approvals). + This fetches the issue from swamp-club via `GET /api/v1/lab/issues/` and writes the title, body, type, status, and comments to the `context` resource. If the issue doesn't exist in swamp-club, `start` fails loudly — create the issue diff --git a/issue-lifecycle/.swamp.yaml b/issue-lifecycle/.swamp.yaml index eab9343a..da1b0eae 100644 --- a/issue-lifecycle/.swamp.yaml +++ b/issue-lifecycle/.swamp.yaml @@ -1,5 +1,5 @@ swampVersion: 20260406.211743.0 -initializedAt: '2026-04-07T17:13:59.002Z' +initializedAt: "2026-04-07T17:13:59.002Z" repoId: 7fa9e15c-844d-48d8-a8a6-d0ac8c5f8695 tool: claude gitignoreManaged: true diff --git a/issue-lifecycle/CLAUDE.md b/issue-lifecycle/CLAUDE.md index bb417cb4..ae69561d 100644 --- a/issue-lifecycle/CLAUDE.md +++ b/issue-lifecycle/CLAUDE.md @@ -1,23 +1,55 @@ + # Project This repository is managed with [swamp](https://github.com/systeminit/swamp). ## Rules -1. **Search before you build.** When automating AWS, APIs, or any external service: (a) search local types with `swamp model type search `, (b) search community extensions with `swamp extension search `, (c) if a community extension exists, install it with `swamp extension pull ` instead of building from scratch, (d) only create a custom extension model in `extensions/models/` if nothing exists. Use the `swamp-extension-model` skill for guidance. The `command/shell` model is ONLY for ad-hoc one-off shell commands, NEVER for wrapping CLI tools or building integrations. -2. **Extend, don't be clever.** When a model covers the domain but lacks the method you need, extend it with `export const extension` — don't bypass it with shell scripts, CLI tools, or multi-step hacks. One method, one purpose. Use `swamp model type describe --json` to check available methods. -3. **Use the data model.** Once data exists in a model (via `lookup`, `start`, `sync`, etc.), reference it with CEL expressions. Don't re-fetch data that's already available. -4. **CEL expressions everywhere.** Wire models together with CEL expressions. Always prefer `data.latest("", "").attributes.` over the deprecated `model..resource...attributes.` pattern. -5. **Verify before destructive operations.** Always `swamp model get --json` and verify resource IDs before running delete/stop/destroy methods. -6. **Prefer fan-out methods over loops.** When operating on multiple targets, use a single method that handles all targets internally (factory pattern) rather than looping N separate `swamp model method run` calls against the same model. Multiple parallel calls against the same model contend on the per-model lock, causing timeouts. A single fan-out method acquires the lock once and produces all outputs in one execution. Check `swamp model type describe` for methods that accept filters or produce multiple outputs. -7. **Extension npm deps are bundled, not lockfile-tracked.** Swamp's bundler inlines all npm packages (except zod) into extension bundles at bundle time. `deno.lock` and `package.json` do NOT cover extension model dependencies — this is by design. Always pin explicit versions in `npm:` import specifiers (e.g., `npm:lodash-es@4.17.21`). -8. **Reports for reusable data pipelines.** When the task involves building a repeatable pipeline to transform, aggregate, or analyze model output (security reports, cost analysis, compliance checks, summaries), create a report extension. Use the `swamp-report` skill for guidance. +1. **Search before you build.** When automating AWS, APIs, or any external + service: (a) search local types with `swamp model type search `, (b) + search community extensions with `swamp extension search `, (c) if a + community extension exists, install it with `swamp extension pull ` + instead of building from scratch, (d) only create a custom extension model in + `extensions/models/` if nothing exists. Use the `swamp-extension-model` skill + for guidance. The `command/shell` model is ONLY for ad-hoc one-off shell + commands, NEVER for wrapping CLI tools or building integrations. +2. **Extend, don't be clever.** When a model covers the domain but lacks the + method you need, extend it with `export const extension` — don't bypass it + with shell scripts, CLI tools, or multi-step hacks. One method, one purpose. + Use `swamp model type describe --json` to check available methods. +3. **Use the data model.** Once data exists in a model (via `lookup`, `start`, + `sync`, etc.), reference it with CEL expressions. Don't re-fetch data that's + already available. +4. **CEL expressions everywhere.** Wire models together with CEL expressions. + Always prefer `data.latest("", "").attributes.` over + the deprecated `model..resource...attributes.` + pattern. +5. **Verify before destructive operations.** Always + `swamp model get --json` and verify resource IDs before running + delete/stop/destroy methods. +6. **Prefer fan-out methods over loops.** When operating on multiple targets, + use a single method that handles all targets internally (factory pattern) + rather than looping N separate `swamp model method run` calls against the + same model. Multiple parallel calls against the same model contend on the + per-model lock, causing timeouts. A single fan-out method acquires the lock + once and produces all outputs in one execution. Check + `swamp model type describe` for methods that accept filters or produce + multiple outputs. +7. **Extension npm deps are bundled, not lockfile-tracked.** Swamp's bundler + inlines all npm packages (except zod) into extension bundles at bundle time. + `deno.lock` and `package.json` do NOT cover extension model dependencies — + this is by design. Always pin explicit versions in `npm:` import specifiers + (e.g., `npm:lodash-es@4.17.21`). +8. **Reports for reusable data pipelines.** When the task involves building a + repeatable pipeline to transform, aggregate, or analyze model output + (security reports, cost analysis, compliance checks, summaries), create a + report extension. Use the `swamp-report` skill for guidance. ## Skills -**IMPORTANT:** Always load swamp skills, even when in plan mode. The skills provide -essential context for working with this repository. +**IMPORTANT:** Always load swamp skills, even when in plan mode. The skills +provide essential context for working with this repository. - `swamp-model` - Work with swamp models (creating, editing, validating) - `swamp-workflow` - Work with workflows (creating, editing, running) @@ -39,4 +71,5 @@ Always start by using the `swamp-model` skill to work with swamp models. ## Commands Use `swamp --help` to see available commands. + diff --git a/issue-lifecycle/extensions/models/_lib/schemas.ts b/issue-lifecycle/extensions/models/_lib/schemas.ts index 949d764e..754c5190 100644 --- a/issue-lifecycle/extensions/models/_lib/schemas.ts +++ b/issue-lifecycle/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/issue-lifecycle/extensions/models/_lib/schemas_test.ts b/issue-lifecycle/extensions/models/_lib/schemas_test.ts index 2baf7757..a62efab0 100644 --- a/issue-lifecycle/extensions/models/_lib/schemas_test.ts +++ b/issue-lifecycle/extensions/models/_lib/schemas_test.ts @@ -64,7 +64,7 @@ Deno.test("start is allowed from every non-terminal phase (restart invariant)", } }); -Deno.test("happy path: created → triaging → classified → plan_generated → approved → implementing → pr_open → done", () => { +Deno.test("happy path: created → triaging → classified → plan_generated → approved → implementing → pr_open → releasing → done", () => { // Walk the linear happy path one method at a time and verify each method's // required source phase is in its TRANSITIONS entry. This catches the case // where someone reorders the state machine and forgets to update an edge. @@ -75,7 +75,8 @@ Deno.test("happy path: created → triaging → classified → plan_generated { method: "approve", from: "plan_generated" }, { method: "implement", from: "approved" }, { method: "link_pr", from: "implementing" }, - { method: "complete", from: "pr_open" }, + { method: "pr_merged", from: "pr_open" }, + { method: "ship", from: "releasing" }, ]; for (const { method, from } of happyPath) { const allowed = TRANSITIONS[method]; @@ -111,38 +112,41 @@ Deno.test("approval gate: approve only allowed from plan_generated", () => { assertEquals(TRANSITIONS.approve, ["plan_generated"]); }); -Deno.test("implementation gate: implement only allowed from approved", () => { - assertEquals(TRANSITIONS.implement, ["approved"]); +Deno.test("implementation gate: implement allowed from approved and pr_failed", () => { + assertEquals(TRANSITIONS.implement, ["approved", "pr_failed"]); }); -Deno.test("completion gate: complete allowed from implementing and pr_open", () => { +Deno.test("completion gate: complete allowed from implementing, pr_open, and releasing", () => { // complete is the exit path from the implementation span. It accepts - // both 'implementing' (legacy/no-PR path) and 'pr_open' (the new - // link_pr → pr_open → complete path). No ci_review phase or fix loop. - assertEquals(TRANSITIONS.complete, ["implementing", "pr_open"]); + // 'implementing' (legacy/no-PR path), 'pr_open' (the link_pr → pr_open + // → complete path), and 'releasing' (backwards compat for new flow). + assertEquals(TRANSITIONS.complete, ["implementing", "pr_open", "releasing"]); }); // --------------------------------------------------------------------------- // PR linkage additions (v2026.04.08.2) // --------------------------------------------------------------------------- -Deno.test("Phase: pr_open sits between implementing and done", () => { +Deno.test("Phase: pr_open, pr_failed, releasing sit 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", () => { - // Accepting both source phases is what makes link_pr re-callable: the +Deno.test("TRANSITIONS: link_pr accepts implementing, pr_open, and pr_failed", () => { + // Accepting all three source phases makes link_pr re-callable: the // first call moves implementing → pr_open, subsequent calls overwrite - // the pullRequest resource while staying in pr_open. This supports URL - // corrections, replacement PRs, and force-push workflows without a - // separate phase. - assertEquals(TRANSITIONS.link_pr, ["implementing", "pr_open"]); + // the pullRequest resource while staying in pr_open. pr_failed allows + // recovery by linking a replacement PR after failure. + assertEquals(TRANSITIONS.link_pr, ["implementing", "pr_open", "pr_failed"]); }); Deno.test("TRANSITIONS: link_pr is rejected from earlier lifecycle phases", () => { @@ -176,6 +180,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); @@ -185,6 +190,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/issue-lifecycle/extensions/models/issue_lifecycle.ts b/issue-lifecycle/extensions/models/issue_lifecycle.ts index 57911122..31f6fab5 100644 --- a/issue-lifecycle/extensions/models/issue_lifecycle.ts +++ b/issue-lifecycle/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/issue-lifecycle/extensions/models/issue_lifecycle_test.ts b/issue-lifecycle/extensions/models/issue_lifecycle_test.ts index 631c35e8..14a12ab2 100644 --- a/issue-lifecycle/extensions/models/issue_lifecycle_test.ts +++ b/issue-lifecycle/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); });