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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 80 additions & 36 deletions .github/workflows/auto-response.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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_CLUB_API_KEY: ${{ secrets.SWAMP_CLUB_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_CLUB_API_KEY;
if (!apiKey) {
core.setFailed("SWAMP_CLUB_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 Swamp Club 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",
});
4 changes: 4 additions & 0 deletions extensions/models/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<number>`.

Key status transitions in swamp-club:

| Method | swamp-club status |
Expand Down
55 changes: 34 additions & 21 deletions extensions/models/_lib/swamp_club.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ export class SwampClubClient {
private issueNumber: number;
private log: (msg: string, props: Record<string, unknown>) => 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,
Expand All @@ -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<string | null> {
if (this.issueId) return this.issueId;
}): Promise<number | null> {
if (this.labIssueNumber !== null) return this.labIssueNumber;

try {
const url = `${this.baseUrl}/api/v1/lab/issues/ensure`;
Expand Down Expand Up @@ -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),
Expand All @@ -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<void> {
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: {
Expand Down Expand Up @@ -165,9 +178,9 @@ export class SwampClubClient {

/** Transition the issue status. Best-effort. Requires ensureIssue() first. */
async transitionStatus(status: string): Promise<void> {
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: {
Expand Down Expand Up @@ -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: {
Expand Down
6 changes: 3 additions & 3 deletions extensions/models/issue_lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -75,12 +75,12 @@ async function ensureSwampClub(
): Promise<SwampClubClient | null> {
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;
}

Expand Down
Loading