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
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ 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
there first.

### Auto-assignment

`start` automatically assigns the issue to you in swamp-club. It reads your
username from local auth (`~/.config/swamp/auth.json`, written by
`swamp auth login`), resolves it to a userId via the eligible-assignees
endpoint, and PATCHes the issue's assignees. Existing assignees are preserved
(additive). If assignment fails for any reason (missing credentials, lookup
error, API failure), `start` still succeeds — it logs a warning and continues
with triage.

## 3. Read the Issue Context and Codebase

Read the model output, then explore the codebase.
Expand Down
2 changes: 1 addition & 1 deletion issue-lifecycle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ swamp model output search issue-42 --json

| Method | Description | State Transition |
| -------------------- | ----------------------------------------------- | -------------------------------- |
| `start` | Fetch issue from swamp-club | \* -> triaging |
| `start` | Fetch issue from swamp-club, auto-assign to you | \* -> triaging |
| `triage` | Classify as bug/feature/security | triaging -> classified |
| `plan` | Generate implementation plan | classified -> plan_generated |
| `review` | Display current plan (read-only) | no change |
Expand Down
76 changes: 73 additions & 3 deletions issue-lifecycle/extensions/models/_lib/swamp_club.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,19 @@ export interface LifecycleEntryParams {
isVerbose?: boolean;
}

export interface EligibleAssignee {
userId: string;
username: string;
}

export interface FetchedIssue {
number: number;
type: IssueType;
status: string;
title: string;
body: string;
comments: { author: string; body: string; createdAt: string }[];
assignees: { userId: string; username: string }[];
}

/**
Expand Down Expand Up @@ -105,6 +111,10 @@ export class SwampClubClient {
body?: string;
createdAt?: string;
}[];
assignees?: {
userId?: string;
username?: string;
}[];
};
};
const issue = data?.issue;
Expand All @@ -120,6 +130,10 @@ export class SwampClubClient {
body: c.body ?? "",
createdAt: c.createdAt ?? "",
})),
assignees: (issue.assignees ?? []).filter(
(a): a is { userId: string; username: string } =>
typeof a.userId === "string" && typeof a.username === "string",
),
};
} catch (err) {
this.log("swamp-club fetch issue error: {error}", {
Expand Down Expand Up @@ -175,6 +189,59 @@ export class SwampClubClient {
await this.patchIssue({ type });
}

/**
* Fetch the list of eligible assignees. Returns null on any error
* (401/403/network) — callers should treat failure as a warning, not a gate.
*/
async fetchEligibleAssignees(): Promise<EligibleAssignee[] | null> {
try {
const url = `${this.baseUrl}/api/v1/lab/assignees`;
const res = await fetch(url, {
method: "GET",
headers: {
"Authorization": `Bearer ${this.#apiKey}`,
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
this.log("swamp-club fetch assignees failed: {status} {text}", {
status: res.status,
text,
});
return null;
}
const data = await res.json() as {
assignees?: { userId?: string; username?: string }[];
};
return (data.assignees ?? []).filter(
(a): a is EligibleAssignee =>
typeof a.userId === "string" && typeof a.username === "string",
);
} catch (err) {
this.log("swamp-club fetch assignees error: {error}", {
error: String(err),
});
return null;
}
}

/**
* Resolve a username to a userId via the eligible-assignees endpoint.
* Returns null if the lookup fails or the username is not found.
*/
async resolveUserId(username: string): Promise<string | null> {
const assignees = await this.fetchEligibleAssignees();
if (!assignees) return null;
const match = assignees.find((a) => a.username === username);
return match?.userId ?? null;
}

/** Update the issue's assignees. Best-effort (same as other PATCH helpers). */
async updateAssignees(userIds: string[]): Promise<void> {
await this.patchIssue({ assignees: userIds });
}

/** PATCH the issue with a partial set of fields. Best-effort. */
private async patchIssue(
patch: Record<string, unknown>,
Expand Down Expand Up @@ -207,10 +274,11 @@ export class SwampClubClient {

/**
* Load credentials from ~/.config/swamp/auth.json (or $XDG_CONFIG_HOME/swamp/auth.json).
* Returns { serverUrl, apiKey } or null if the file doesn't exist.
* Returns { serverUrl, apiKey, username? } or null if the file doesn't exist.
* Username is optional — older auth.json files and env-var auth may not have it.
*/
async function loadAuthFile(): Promise<
{ serverUrl: string; apiKey: string } | null
export async function loadAuthFile(): Promise<
{ serverUrl: string; apiKey: string; username?: string } | null
> {
try {
const xdg = Deno.env.get("XDG_CONFIG_HOME");
Expand All @@ -227,11 +295,13 @@ async function loadAuthFile(): Promise<
const creds = JSON.parse(content) as {
serverUrl?: string;
apiKey?: string;
username?: string;
};
if (creds.apiKey) {
return {
serverUrl: creds.serverUrl ?? "https://swamp.club",
apiKey: creds.apiKey,
username: creds.username || undefined,
};
}
return null;
Expand Down
55 changes: 53 additions & 2 deletions issue-lifecycle/extensions/models/issue_lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
StateSchema,
TRANSITIONS,
} from "./_lib/schemas.ts";
import { createSwampClubClient } from "./_lib/swamp_club.ts";
import { createSwampClubClient, loadAuthFile } from "./_lib/swamp_club.ts";

/** Global args type for the issue-lifecycle model. */
type GlobalArgs = {
Expand Down Expand Up @@ -70,7 +70,7 @@ async function readState(

export const model = {
type: "@swamp/issue-lifecycle",
version: "2026.04.09.1",
version: "2026.04.12.1",
globalArguments: GlobalArgsSchema,

upgrades: [
Expand Down Expand Up @@ -118,6 +118,15 @@ export const model = {
"complete now accepts releasing for backwards compatibility.",
upgradeAttributes: (old: Record<string, unknown>) => old,
},
{
toVersion: "2026.04.12.1",
description:
"Auto-assign issue to the authenticated user during start(). " +
"Reads username from local auth, resolves userId via the " +
"eligible-assignees endpoint, and PATCHes the issue's assignees. " +
"Best-effort — assignment failures are warnings, never errors.",
upgradeAttributes: (old: Record<string, unknown>) => old,
},
],

resources: {
Expand Down Expand Up @@ -479,6 +488,48 @@ export const model = {
isVerbose: false,
});

// Auto-assign the issue to the current authenticated user.
// All failures are warnings — assignment must never break the triage flow.
const authFile = await loadAuthFile();
const authUsername = authFile?.username;
if (!authUsername) {
context.logger.warning(
"Cannot determine your username — issue will not be auto-assigned. " +
"Run `swamp auth login` to enable auto-assignment.",
{},
);
} else {
const resolvedUserId = await sc.resolveUserId(authUsername);
if (!resolvedUserId) {
context.logger.warning(
"Could not resolve your swamp-club identity — issue will not be auto-assigned.",
{},
);
} else {
const existingIds = issue.assignees.map((a) => a.userId);
if (existingIds.includes(resolvedUserId)) {
context.logger.info(
"Already assigned to {username}",
{ username: authUsername },
);
} else {
await sc.updateAssignees([...existingIds, resolvedUserId]);
await sc.postLifecycleEntry({
step: "assigned",
targetStatus: "open",
summary: `Assigned to ${authUsername}`,
emoji: "\u{1F464}",
payload: { username: authUsername, userId: resolvedUserId },
isVerbose: false,
});
context.logger.info(
"Auto-assigned issue to {username}",
{ username: authUsername },
);
}
}
}

return { dataHandles: handles };
},
},
Expand Down
Loading
Loading