Skip to content

feat: auto-move new GitHub issues to swamp.club lab#1134

Merged
stack72 merged 3 commits intomainfrom
worktree-auto-move-issues
Apr 7, 2026
Merged

feat: auto-move new GitHub issues to swamp.club lab#1134
stack72 merged 3 commits intomainfrom
worktree-auto-move-issues

Conversation

@stack72
Copy link
Copy Markdown
Contributor

@stack72 stack72 commented Apr 7, 2026

Summary

Adds a GitHub Action that auto-moves every new issue filed against systeminit/swamp into the swamp.club lab, posts a comment on the GitHub issue linking to the new lab home, and closes the GitHub issue. Also migrates the existing @swamp/issue-lifecycle extension client to the numeric lab issue id contract introduced by systeminit/swamp-club#364 and #369.

User-visible behaviour change

When anyone files an issue against systeminit/swamp:

  1. The issue body (with an Automoved by swampadmin from GitHub issue #<n> footer appended) is POSTed to https://swamp.club/api/v1/lab/issues/ensure.
  2. A comment is posted on the GitHub issue that reads:

    Thank you for opening an issue. This issue is now managed in the Claude lab.

    https://swamp.club/lab/

  3. The GitHub issue is closed.

If the swamp-club call fails for any reason, the action fails and the GitHub issue stays open — there is no failure mode in which we close a GitHub issue without confirming the lab counterpart exists. See "Why the close is gated" below.

The previous behaviour (welcome comment + needs-triage label, with a maintainer skip) is removed. Every new issue gets moved unconditionally — issues filed by maintainers go through the same path as community issues, by design.

Contract change in swamp-club#369

systeminit/swamp-club#364 introduced a sequential, human-friendly number on every lab issue (allocated by an atomic Mongo counter) and migrated the UI route from /lab/{uuid} to /lab/{number}.

systeminit/swamp-club#369 finished the job: it collapsed /api/v1/lab/issues/{uuid} and /api/v1/lab/issues/by-number/{n} into a single /api/v1/lab/issues/{number} tree, so every endpoint that used to take a UUID now takes the sequential number. The internal UUID is still the Mongo foreign key (comments, events, lifecycle entries reference it) but it never appears in a URL.

Endpoints affected, all of which this client now hits via the new shape:

Endpoint Method
/api/v1/lab/issues/{number} PATCH (used by transitionStatus)
/api/v1/lab/issues/{number}/lifecycle POST (used by postLifecycleEntry)
/api/v1/lab/issues/ensure POST (unchanged — still no path id)

This client previously cached the UUID it received from /ensure and interpolated it into the lifecycle/PATCH paths. After #369, those calls would 400 with \"Invalid issue number\" because the server-side parseLabIssueNumberParam validator (^\d+$ + Number.isSafeInteger) rejects UUIDs. The migration in this PR is necessary to keep the issue-lifecycle integration working at all.

Tracking issue

This PR closes the client-side half of the work I tracked in systeminit/swamp-club#367, which I filed when I first noticed the contract drift. The server-side half shipped as #369 (already merged).

Safe-integer validation rationale

swamp-club#369 added a strict validator lib/lab-issue-number-param.ts that uses Number.isSafeInteger instead of Number.isInteger. The PR description explains why: during live testing, Number.parseInt(\"7d03f113-80a5-...\", 10) silently truncated a UUID to 7 and routed a request meant for one issue onto issue #7, clobbering its title. The strict validator catches that hazard.

This client doesn't parse strings (it consumes a JSON number from /ensure and emits it back into a URL path), so the parseInt hazard is not directly reachable. But mirroring the server's safe-integer bound is still the right call for two reasons:

  1. Contract symmetry. If the server ever returns a value the client would happily emit but the next request would 400 on, we'd have a confusing failure mode. Validating with the same predicate the server uses means "if the client accepts it, the server accepts it."
  2. Defence against upstream contract drift. The client previously asserted the response shape with a TypeScript as cast — runtime-trivial. The new code reads data?.issue?.number defensively (typed as unknown) and runs it through the same Number.isSafeInteger + positive check. A malformed response can no longer throw a TypeError before we hit our validator, and a numerically-suspicious response (NaN, Infinity, fractions, anything past 2^53-1) is rejected with a structured log line.

Why the GitHub Action close is gated on a valid lab number

The action's only safety property is: a GitHub issue must never be closed unless its lab counterpart exists. If we close before confirming, an issue can disappear from GitHub without ever being filed in the lab — losing the report.

The flow is therefore strictly sequential and short-circuits on every error:

  1. POST to /api/v1/lab/issues/ensure. If non-2xx → core.setFailed(...) and return (issue stays open).
  2. Parse JSON. If data.issue.number is missing or not a safe positive integer → core.setFailed(...) and return (issue stays open).
  3. Post the auto-responder comment with the lab URL.
  4. Only then close the GitHub issue.

core.setFailed does not stop execution by itself in actions/github-script, so each guard is paired with an explicit return. Steps 3 and 4 are unreachable unless we have a confirmed labIssueNumber.

Files changed

.github/workflows/auto-response.yml

Repurposed end-to-end:

  • Removed the maintainer-permission probe and the maintainer-only skip path.
  • Removed the needs-triage label step and the welcome-comment step.
  • Single new step (actions/github-script@v7, Node 20 fetch) that:
    • Reads the issue title, body, and author from context.payload.issue.
    • Builds the lab body: original body + \n\n---\nAutomoved by swampadmin from GitHub issue #<n> footer (handles empty bodies).
    • POSTs to https://swamp.club/api/v1/lab/issues/ensure with { githubRepoFullName, githubIssueNumber, title, body, type: \"feature\", githubAuthorLogin }, 15s AbortSignal.timeout.
    • Validates the response: defensive data?.issue?.number access, Number.isSafeInteger, > 0. Mirrors swamp-club's parseLabIssueNumberParam.
    • Posts a comment on the GitHub issue: \"Thank you for opening an issue. This issue is now managed in the Claude lab.\\n\\nhttps://swamp.club/lab/<number>\".
    • Closes the GitHub issue via issues.update({ state: \"closed\" }).
  • Reads the API key from the repo secret SWAMP_API_KEY (already configured).
  • Trigger is unchanged: on.issues.types: [opened]. Permissions are unchanged: issues: write, contents: read.

extensions/models/_lib/swamp_club.ts

  • Renamed private issueId: string | nullprivate labIssueNumber: number | null. The cached value is now the sequential lab id, not the UUID.
  • ensureIssue now returns Promise<number | null> (was Promise<string | null>).
    • Reads data?.issue?.number with unknown typing (was data.issue.id with a { id: string } cast).
    • Validates with typeof === \"number\" + Number.isSafeInteger + positive (was a UUID regex). Mirrors swamp-club's parseLabIssueNumberParam.
    • Logs \"swamp-club returned invalid lab issue number: {number}\" on validation failure (was \"...invalid issue ID...\").
  • postLifecycleEntry now builds ${baseUrl}/api/v1/lab/issues/${this.labIssueNumber}/lifecycle and gates on this.labIssueNumber === null (was !this.issueId).
  • transitionStatus now builds ${baseUrl}/api/v1/lab/issues/${this.labIssueNumber} and gates on this.labIssueNumber === null (was !this.issueId).
  • New public labUrl() helper: returns ${baseUrl}/lab/${labIssueNumber} if the issue has been ensured, or null otherwise. Lets callers display the public lab URL without re-fetching anything.
  • Updated createSwampClubClient doc comment: "The lab issue number is resolved lazily — no extra arg needed." (was "The issue ID is resolved lazily — no swampClubIssueId arg needed.")

extensions/models/issue_lifecycle.ts

  • ensureSwampClub: renamed local idlabIssueNumber, replaced if (!id) with if (labIssueNumber === null). The strict null check guards against a future 0 being misread as missing — the server validates > 0, so 0 is currently unreachable, but === null is the right pattern for a number | null return type either way.
  • Updated the doc comment from "the issueId cache is lost" to "the lab issue number cache is lost."
  • The other ensureIssue call site (triage_started lifecycle entry, line ~435) discards the return value, so no change needed.

extensions/models/README.md

  • Added a paragraph in the "Swamp Club Integration / How it works" section:

    Each lab issue is assigned a sequential, human-friendly number (#1, #2, ...) that is used in every lab URL — both the dashboard and the API. After the model has run against an issue you can find it at https://swamp.club/lab/<number>.

Test plan

Local verification

  • deno fmt --check — clean (1114 files)
  • deno lint — clean (1008 files)
  • deno run test4193 passed, 0 failed (3m30s)
  • deno check extensions/models/_lib/swamp_club.ts extensions/models/issue_lifecycle.ts — clean

Client behaviour (manual / by-inspection)

  • ensureIssue happy path: server returns { issue: { number: 42, ... }, created: true } → cache populated, 42 returned.
  • ensureIssue cache hit: second call short-circuits via this.labIssueNumber !== null and returns the cached number without re-fetching.
  • ensureIssue rejects malformed responses without throwing: missing issue key, missing number key, number: \"42\" (string), number: 0, number: -1, number: 1.5, number: NaN, number: 2 ** 53 (past safe integer). Each path logs a structured warning and returns null.
  • postLifecycleEntry and transitionStatus no-op when labIssueNumber === null (i.e. before ensureIssue succeeds).
  • labUrl() returns null before ensureIssue, then ${baseUrl}/lab/{number} after.

GitHub Action

  • Smoke test: file a throwaway issue against systeminit/swamp after this PR merges. Expect:
    • The issue closes within seconds of being filed.
    • A comment appears with the auto-responder text and a https://swamp.club/lab/<n> link.
    • The lab issue exists at that URL with the original body plus the Automoved by swampadmin from GitHub issue #<n> footer.
  • Failure-mode test: temporarily revoke the SWAMP_API_KEY secret value (or point at an unreachable host) and file a throwaway issue. Expect:
    • The action run fails with a core.setFailed message.
    • The GitHub issue stays open.
    • No auto-responder comment is posted.
    • Restore the secret afterwards.
  • Validator test: if a future swamp-club regression returns a non-number id field, expect the action to fail with \"swamp.club ensure returned no lab issue number: ...\" and the issue to stay open.

I have not pre-merged the smoke tests because they require the workflow to be on main to fire on real issues: opened events. Suggest doing them as the first action after merge with throwaway issues.

Related

  • systeminit/swamp-club#367 — issue I filed to track migrating the lab API endpoints from UUID to sequential number (closed by feat: add support for Nix via a flake #369).
  • systeminit/swamp-club#369 — the merged server-side PR that shipped the contract this client now consumes. Adds parseLabIssueNumberParam and caught a real data-corruption hazard with Number.parseInt.
  • systeminit/swamp-club#364 — the original PR that introduced the sequential number field on lab issues and migrated the UI route to /lab/{number}.

🤖 Generated with Claude Code

stack72 and others added 3 commits April 7, 2026 23:17
Repurposes the issue auto-responder workflow into an auto-mover and updates
the swamp-club extension client to consume the numeric lab issue ids
introduced by systeminit/swamp-club#364 and #369.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Blocking Issues

None.

Suggestions

  1. Stale class-level doc comment (extensions/models/_lib/swamp_club.ts:34-36): The SwampClubClient class doc still says "Resolves the swamp-club issue ID lazily" — should say "lab issue number" to match the rest of the migration.

  2. All issues hardcoded as type: "feature" (.github/workflows/auto-response.yml:54): Every auto-moved issue is sent with type: "feature" regardless of content. If the lab supports bug reports, consider inferring the type from GitHub labels or defaulting to something more neutral. Fine for now if the lab always triages type separately.

  3. No error handling on the comment/close calls (.github/workflows/auto-response.yml:92-106): If createComment succeeds but issues.update (close) throws, the issue stays open with a "now managed in lab" comment but isn't actually closed. Unlikely but worth noting — the current fail-open behavior is safe, just potentially confusing for the reporter.

Overall this is a clean, well-reasoned migration. The sequential gating in the GitHub Action (ensure → validate → comment → close) correctly preserves the safety invariant that no issue is closed without a confirmed lab counterpart. The Number.isSafeInteger validation mirrors the server contract. The TypeScript client changes are straightforward renames with tighter runtime validation replacing the old UUID regex. DDD-wise, the SwampClubClient remains a well-scoped infrastructure/integration service — no domain concerns here.

Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Security Review

Critical / High

None.

Medium

None.

Low

None.

Verdict

PASS — The workflow changes are security-clean. Issue data is handled safely via JavaScript (context.payload.issue) rather than GitHub Actions expression interpolation, secrets are properly scoped, permissions are minimal, the GitHub-owned action uses an acceptable tag pin, and the lab API response is validated with Number.isSafeInteger before use in URL construction. The close-after-confirm flow correctly prevents closing a GitHub issue without a confirmed lab counterpart.

@stack72 stack72 merged commit da1de24 into main Apr 7, 2026
11 checks passed
@stack72 stack72 deleted the worktree-auto-move-issues branch April 7, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant