From 4977ba53d2b7ade4d49b0ebc2c56f21340041877 Mon Sep 17 00:00:00 2001 From: stack72 Date: Tue, 7 Apr 2026 23:17:14 +0100 Subject: [PATCH 1/3] feat: auto-move new GitHub issues to swamp.club lab 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) --- .github/workflows/auto-response.yml | 116 ++++++++++++++++++--------- extensions/models/README.md | 4 + extensions/models/_lib/swamp_club.ts | 55 ++++++++----- extensions/models/issue_lifecycle.ts | 6 +- 4 files changed, 121 insertions(+), 60 deletions(-) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index e92f9d38..99eb954c 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -9,54 +9,98 @@ permissions: contents: read jobs: - triage: - name: Triage New Issue + automove: + name: Move issue to swamp.club lab runs-on: ubuntu-latest steps: - - name: Check if author is a maintainer - id: check-role + - name: Move to lab and close GitHub issue uses: actions/github-script@v7 + env: + SWAMP_API_KEY: ${{ secrets.SWAMP_API_KEY }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const login = context.payload.issue.user.login; - let isMaintainer = false; - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: login, - }); - core.info(`Permission level for ${login}: ${data.permission}`); - isMaintainer = ['admin', 'write'].includes(data.permission); - } catch (error) { - core.info(`Could not fetch permission level for ${login}: ${error.message}`); + const issue = context.payload.issue; + const repo = `${context.repo.owner}/${context.repo.repo}`; + + const apiKey = process.env.SWAMP_API_KEY; + if (!apiKey) { + core.setFailed("SWAMP_API_KEY secret is not set"); + return; } - core.setOutput('is_maintainer', isMaintainer.toString()); - - name: Add needs-triage label - if: steps.check-role.outputs.is_maintainer == 'false' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - await github.rest.issues.addLabels({ + // Build the lab issue body: original body + auto-move footer. + const originalBody = (issue.body ?? "").trim(); + const footer = `Automoved by swampadmin from GitHub issue #${issue.number}`; + const labBody = originalBody.length > 0 + ? `${originalBody}\n\n---\n${footer}` + : footer; + + // Create (or fetch) the issue in the swamp.club lab. + const ensureRes = await fetch( + "https://swamp.club/api/v1/lab/issues/ensure", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + githubRepoFullName: repo, + githubIssueNumber: issue.number, + title: issue.title, + body: labBody, + type: "feature", + githubAuthorLogin: issue.user.login, + }), + signal: AbortSignal.timeout(15_000), + }, + ); + + if (!ensureRes.ok) { + const text = await ensureRes.text().catch(() => ""); + core.setFailed( + `swamp.club ensure failed: ${ensureRes.status} ${text}`, + ); + return; + } + + const data = await ensureRes.json(); + // Lab issues use a sequential numeric id (LabIssueData.number) as + // the human-facing identifier — see swamp-club commit 9f12761c. + // The lab UI route and all API paths use /lab/{number} after #369. + // Mirror swamp-club's parseLabIssueNumberParam validator: require + // a safe positive integer. + const labIssueNumber = data?.issue?.number; + if ( + typeof labIssueNumber !== "number" || + !Number.isSafeInteger(labIssueNumber) || + labIssueNumber <= 0 + ) { + core.setFailed( + `swamp.club ensure returned no lab issue number: ${ + JSON.stringify(data) + }`, + ); + return; + } + + // We have a confirmed lab issue number — safe to comment and close. + const labUrl = `https://swamp.club/lab/${labIssueNumber}`; + + // Post the auto-responder comment linking to the lab issue. + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.payload.issue.number, - labels: ['needs-triage'], + issue_number: issue.number, + body: + `Thank you for opening an issue. This issue is now managed in the Claude lab.\n\n${labUrl}`, }); - - name: Post welcome comment - if: steps.check-role.outputs.is_maintainer == 'false' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const author = context.payload.issue.user.login; - await github.rest.issues.createComment({ + // Close the GitHub issue. + await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: context.payload.issue.number, - body: `Hi @${author}, thanks for opening this issue!\n\nWe appreciate you taking the time to report it. A maintainer will review it as soon as possible.\n\nIn the meantime, please make sure you've included all relevant details (steps to reproduce, expected vs actual behaviour, swamp version, etc.) to help us triage it quickly.`, + issue_number: issue.number, + state: "closed", }); diff --git a/extensions/models/README.md b/extensions/models/README.md index 7e1fb203..171541f3 100644 --- a/extensions/models/README.md +++ b/extensions/models/README.md @@ -234,6 +234,10 @@ structured lifecycle entry to the swamp-club API. The issue is created in swamp-club on first contact via the `/ensure` endpoint, which matches by GitHub repo + issue number. +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/`. + Key status transitions in swamp-club: | Method | swamp-club status | diff --git a/extensions/models/_lib/swamp_club.ts b/extensions/models/_lib/swamp_club.ts index 398040fb..79a33d52 100644 --- a/extensions/models/_lib/swamp_club.ts +++ b/extensions/models/_lib/swamp_club.ts @@ -42,8 +42,10 @@ export class SwampClubClient { private issueNumber: number; private log: (msg: string, props: Record) => void; - /** Cached swamp-club issue ID, resolved on first call. */ - private issueId: string | null = null; + /** Cached swamp-club lab issue number (sequential, human-facing id), + * resolved on first call. Used as the path segment for all lab issue + * endpoints (`/api/v1/lab/issues/{number}`). */ + private labIssueNumber: number | null = null; constructor( baseUrl: string, @@ -64,16 +66,17 @@ export class SwampClubClient { /** * Ensure the issue exists in swamp-club by GitHub repo + issue number. - * Creates it if needed. Caches the issue ID for subsequent calls. + * Creates it if needed. Caches the lab issue number for subsequent calls. * Must be called with the issue data (title, body, type) the first time. + * Returns the swamp-club lab issue number, or null on failure. */ async ensureIssue(params: { title: string; body: string; type?: string; githubAuthorLogin?: string; - }): Promise { - if (this.issueId) return this.issueId; + }): Promise { + if (this.labIssueNumber !== null) return this.labIssueNumber; try { const url = `${this.baseUrl}/api/v1/lab/issues/ensure`; @@ -102,23 +105,26 @@ export class SwampClubClient { return null; } const data = await res.json() as { - issue: { id: string }; - created: boolean; + issue?: { number?: unknown }; + created?: boolean; }; - const resolvedId = data.issue.id; - // Validate UUID format to prevent path traversal + const resolvedNumber = data?.issue?.number; + // Mirror swamp-club's parseLabIssueNumberParam validator (PR #369): + // require a safe positive integer. Number.isSafeInteger rejects + // NaN/Infinity, fractions, and anything beyond 2^53-1, so we never + // emit a path segment the server would 400 on. if ( - !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - resolvedId, - ) + typeof resolvedNumber !== "number" || + !Number.isSafeInteger(resolvedNumber) || + resolvedNumber <= 0 ) { - this.log("swamp-club returned invalid issue ID: {id}", { - id: resolvedId, + this.log("swamp-club returned invalid lab issue number: {number}", { + number: resolvedNumber, }); return null; } - this.issueId = resolvedId; - return this.issueId; + this.labIssueNumber = resolvedNumber; + return this.labIssueNumber; } catch (err) { this.log("swamp-club ensure issue error: {error}", { error: String(err), @@ -127,11 +133,18 @@ export class SwampClubClient { } } + /** Build the public lab URL for the cached issue, or null if not yet ensured. */ + labUrl(): string | null { + if (this.labIssueNumber === null) return null; + return `${this.baseUrl}/lab/${this.labIssueNumber}`; + } + /** Post a structured lifecycle entry. Best-effort. Requires ensureIssue() first. */ async postLifecycleEntry(params: LifecycleEntryParams): Promise { - if (!this.issueId) return; + if (this.labIssueNumber === null) return; try { - const url = `${this.baseUrl}/api/v1/lab/issues/${this.issueId}/lifecycle`; + const url = + `${this.baseUrl}/api/v1/lab/issues/${this.labIssueNumber}/lifecycle`; const res = await fetch(url, { method: "POST", headers: { @@ -165,9 +178,9 @@ export class SwampClubClient { /** Transition the issue status. Best-effort. Requires ensureIssue() first. */ async transitionStatus(status: string): Promise { - if (!this.issueId) return; + if (this.labIssueNumber === null) return; try { - const url = `${this.baseUrl}/api/v1/lab/issues/${this.issueId}`; + const url = `${this.baseUrl}/api/v1/lab/issues/${this.labIssueNumber}`; const res = await fetch(url, { method: "PATCH", headers: { @@ -230,7 +243,7 @@ async function loadAuthFile(): Promise< /** * Create a SwampClubClient if URL and API key are available. * Precedence: explicit global args > SWAMP_API_KEY env var > auth.json file. - * The issue ID is resolved lazily — no swampClubIssueId arg needed. + * The lab issue number is resolved lazily — no extra arg needed. */ export async function createSwampClubClient( globalArgs: { diff --git a/extensions/models/issue_lifecycle.ts b/extensions/models/issue_lifecycle.ts index 1853c516..62b6af86 100644 --- a/extensions/models/issue_lifecycle.ts +++ b/extensions/models/issue_lifecycle.ts @@ -63,7 +63,7 @@ let _swampClub: SwampClubClient | null | undefined; /** * Get the swamp-club client and ensure the issue exists. - * Each method run is a separate process, so the issueId cache is lost. + * Each method run is a separate process, so the lab issue number cache is lost. * This helper must be called before postLifecycleEntry/transitionStatus. */ async function ensureSwampClub( @@ -75,12 +75,12 @@ async function ensureSwampClub( ): Promise { const sc = await getSwampClub(globalArgs, logger); if (!sc) return null; - const id = await sc.ensureIssue({ + const labIssueNumber = await sc.ensureIssue({ title: `Issue #${globalArgs.issueNumber}`, body: `GitHub issue #${globalArgs.issueNumber} in ${globalArgs.repo}`, type: "feature", }); - if (!id) return null; + if (labIssueNumber === null) return null; return sc; } From e8cdbdcf0e0be2c578c389b43bb95a3085fd9fd8 Mon Sep 17 00:00:00 2001 From: Paul Stack Date: Tue, 7 Apr 2026 23:21:09 +0100 Subject: [PATCH 2/3] Correct naming of lab --- .github/workflows/auto-response.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 99eb954c..0306e228 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -94,7 +94,7 @@ jobs: repo: context.repo.repo, issue_number: issue.number, body: - `Thank you for opening an issue. This issue is now managed in the Claude lab.\n\n${labUrl}`, + `Thank you for opening an issue. This issue is now managed in Swamp Club lab.\n\n${labUrl}`, }); // Close the GitHub issue. From 7f4a819753c464d7119c0d33dc16c3a9a492df79 Mon Sep 17 00:00:00 2001 From: Paul Stack Date: Tue, 7 Apr 2026 23:23:53 +0100 Subject: [PATCH 3/3] Update API key reference in auto-response workflows --- .github/workflows/auto-response.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 0306e228..b91cf829 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -16,16 +16,16 @@ jobs: - name: Move to lab and close GitHub issue uses: actions/github-script@v7 env: - SWAMP_API_KEY: ${{ secrets.SWAMP_API_KEY }} + SWAMP_CLUB_API_KEY: ${{ secrets.SWAMP_CLUB_API_KEY }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const issue = context.payload.issue; const repo = `${context.repo.owner}/${context.repo.repo}`; - const apiKey = process.env.SWAMP_API_KEY; + const apiKey = process.env.SWAMP_CLUB_API_KEY; if (!apiKey) { - core.setFailed("SWAMP_API_KEY secret is not set"); + core.setFailed("SWAMP_CLUB_API_KEY secret is not set"); return; }