Spaces:
Paused
Paused
| import { Command } from "commander"; | |
| import { | |
| addIssueCommentSchema, | |
| checkoutIssueSchema, | |
| createIssueSchema, | |
| updateIssueSchema, | |
| type Issue, | |
| type IssueComment, | |
| } from "@paperclipai/shared"; | |
| import { | |
| addCommonClientOptions, | |
| formatInlineRecord, | |
| handleCommandError, | |
| printOutput, | |
| resolveCommandContext, | |
| type BaseClientOptions, | |
| } from "./common.js"; | |
| interface IssueBaseOptions extends BaseClientOptions { | |
| status?: string; | |
| assigneeAgentId?: string; | |
| projectId?: string; | |
| match?: string; | |
| } | |
| interface IssueCreateOptions extends BaseClientOptions { | |
| title: string; | |
| description?: string; | |
| status?: string; | |
| priority?: string; | |
| assigneeAgentId?: string; | |
| projectId?: string; | |
| goalId?: string; | |
| parentId?: string; | |
| requestDepth?: string; | |
| billingCode?: string; | |
| } | |
| interface IssueUpdateOptions extends BaseClientOptions { | |
| title?: string; | |
| description?: string; | |
| status?: string; | |
| priority?: string; | |
| assigneeAgentId?: string; | |
| projectId?: string; | |
| goalId?: string; | |
| parentId?: string; | |
| requestDepth?: string; | |
| billingCode?: string; | |
| comment?: string; | |
| hiddenAt?: string; | |
| } | |
| interface IssueCommentOptions extends BaseClientOptions { | |
| body: string; | |
| reopen?: boolean; | |
| } | |
| interface IssueCheckoutOptions extends BaseClientOptions { | |
| agentId: string; | |
| expectedStatuses?: string; | |
| } | |
| export function registerIssueCommands(program: Command): void { | |
| const issue = program.command("issue").description("Issue operations"); | |
| addCommonClientOptions( | |
| issue | |
| .command("list") | |
| .description("List issues for a company") | |
| .option("-C, --company-id <id>", "Company ID") | |
| .option("--status <csv>", "Comma-separated statuses") | |
| .option("--assignee-agent-id <id>", "Filter by assignee agent ID") | |
| .option("--project-id <id>", "Filter by project ID") | |
| .option("--match <text>", "Local text match on identifier/title/description") | |
| .action(async (opts: IssueBaseOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts, { requireCompany: true }); | |
| const params = new URLSearchParams(); | |
| if (opts.status) params.set("status", opts.status); | |
| if (opts.assigneeAgentId) params.set("assigneeAgentId", opts.assigneeAgentId); | |
| if (opts.projectId) params.set("projectId", opts.projectId); | |
| const query = params.toString(); | |
| const path = `/api/companies/${ctx.companyId}/issues${query ? `?${query}` : ""}`; | |
| const rows = (await ctx.api.get<Issue[]>(path)) ?? []; | |
| const filtered = filterIssueRows(rows, opts.match); | |
| if (ctx.json) { | |
| printOutput(filtered, { json: true }); | |
| return; | |
| } | |
| if (filtered.length === 0) { | |
| printOutput([], { json: false }); | |
| return; | |
| } | |
| for (const item of filtered) { | |
| console.log( | |
| formatInlineRecord({ | |
| identifier: item.identifier, | |
| id: item.id, | |
| status: item.status, | |
| priority: item.priority, | |
| assigneeAgentId: item.assigneeAgentId, | |
| title: item.title, | |
| projectId: item.projectId, | |
| }), | |
| ); | |
| } | |
| } catch (err) { | |
| handleCommandError(err); | |
| } | |
| }), | |
| { includeCompany: false }, | |
| ); | |
| addCommonClientOptions( | |
| issue | |
| .command("get") | |
| .description("Get an issue by UUID or identifier (e.g. PC-12)") | |
| .argument("<idOrIdentifier>", "Issue ID or identifier") | |
| .action(async (idOrIdentifier: string, opts: BaseClientOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts); | |
| const row = await ctx.api.get<Issue>(`/api/issues/${idOrIdentifier}`); | |
| printOutput(row, { json: ctx.json }); | |
| } catch (err) { | |
| handleCommandError(err); | |
| } | |
| }), | |
| ); | |
| addCommonClientOptions( | |
| issue | |
| .command("create") | |
| .description("Create an issue") | |
| .requiredOption("-C, --company-id <id>", "Company ID") | |
| .requiredOption("--title <title>", "Issue title") | |
| .option("--description <text>", "Issue description") | |
| .option("--status <status>", "Issue status") | |
| .option("--priority <priority>", "Issue priority") | |
| .option("--assignee-agent-id <id>", "Assignee agent ID") | |
| .option("--project-id <id>", "Project ID") | |
| .option("--goal-id <id>", "Goal ID") | |
| .option("--parent-id <id>", "Parent issue ID") | |
| .option("--request-depth <n>", "Request depth integer") | |
| .option("--billing-code <code>", "Billing code") | |
| .action(async (opts: IssueCreateOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts, { requireCompany: true }); | |
| const payload = createIssueSchema.parse({ | |
| title: opts.title, | |
| description: opts.description, | |
| status: opts.status, | |
| priority: opts.priority, | |
| assigneeAgentId: opts.assigneeAgentId, | |
| projectId: opts.projectId, | |
| goalId: opts.goalId, | |
| parentId: opts.parentId, | |
| requestDepth: parseOptionalInt(opts.requestDepth), | |
| billingCode: opts.billingCode, | |
| }); | |
| const created = await ctx.api.post<Issue>(`/api/companies/${ctx.companyId}/issues`, payload); | |
| printOutput(created, { json: ctx.json }); | |
| } catch (err) { | |
| handleCommandError(err); | |
| } | |
| }), | |
| { includeCompany: false }, | |
| ); | |
| addCommonClientOptions( | |
| issue | |
| .command("update") | |
| .description("Update an issue") | |
| .argument("<issueId>", "Issue ID") | |
| .option("--title <title>", "Issue title") | |
| .option("--description <text>", "Issue description") | |
| .option("--status <status>", "Issue status") | |
| .option("--priority <priority>", "Issue priority") | |
| .option("--assignee-agent-id <id>", "Assignee agent ID") | |
| .option("--project-id <id>", "Project ID") | |
| .option("--goal-id <id>", "Goal ID") | |
| .option("--parent-id <id>", "Parent issue ID") | |
| .option("--request-depth <n>", "Request depth integer") | |
| .option("--billing-code <code>", "Billing code") | |
| .option("--comment <text>", "Optional comment to add with update") | |
| .option("--hidden-at <iso8601|null>", "Set hiddenAt timestamp or literal 'null'") | |
| .action(async (issueId: string, opts: IssueUpdateOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts); | |
| const payload = updateIssueSchema.parse({ | |
| title: opts.title, | |
| description: opts.description, | |
| status: opts.status, | |
| priority: opts.priority, | |
| assigneeAgentId: opts.assigneeAgentId, | |
| projectId: opts.projectId, | |
| goalId: opts.goalId, | |
| parentId: opts.parentId, | |
| requestDepth: parseOptionalInt(opts.requestDepth), | |
| billingCode: opts.billingCode, | |
| comment: opts.comment, | |
| hiddenAt: parseHiddenAt(opts.hiddenAt), | |
| }); | |
| const updated = await ctx.api.patch<Issue & { comment?: IssueComment | null }>(`/api/issues/${issueId}`, payload); | |
| printOutput(updated, { json: ctx.json }); | |
| } catch (err) { | |
| handleCommandError(err); | |
| } | |
| }), | |
| ); | |
| addCommonClientOptions( | |
| issue | |
| .command("comment") | |
| .description("Add comment to issue") | |
| .argument("<issueId>", "Issue ID") | |
| .requiredOption("--body <text>", "Comment body") | |
| .option("--reopen", "Reopen if issue is done/cancelled") | |
| .action(async (issueId: string, opts: IssueCommentOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts); | |
| const payload = addIssueCommentSchema.parse({ | |
| body: opts.body, | |
| reopen: opts.reopen, | |
| }); | |
| const comment = await ctx.api.post<IssueComment>(`/api/issues/${issueId}/comments`, payload); | |
| printOutput(comment, { json: ctx.json }); | |
| } catch (err) { | |
| handleCommandError(err); | |
| } | |
| }), | |
| ); | |
| addCommonClientOptions( | |
| issue | |
| .command("checkout") | |
| .description("Checkout issue for an agent") | |
| .argument("<issueId>", "Issue ID") | |
| .requiredOption("--agent-id <id>", "Agent ID") | |
| .option( | |
| "--expected-statuses <csv>", | |
| "Expected current statuses", | |
| "todo,backlog,blocked", | |
| ) | |
| .action(async (issueId: string, opts: IssueCheckoutOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts); | |
| const payload = checkoutIssueSchema.parse({ | |
| agentId: opts.agentId, | |
| expectedStatuses: parseCsv(opts.expectedStatuses), | |
| }); | |
| const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/checkout`, payload); | |
| printOutput(updated, { json: ctx.json }); | |
| } catch (err) { | |
| handleCommandError(err); | |
| } | |
| }), | |
| ); | |
| addCommonClientOptions( | |
| issue | |
| .command("release") | |
| .description("Release issue back to todo and clear assignee") | |
| .argument("<issueId>", "Issue ID") | |
| .action(async (issueId: string, opts: BaseClientOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts); | |
| const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/release`, {}); | |
| printOutput(updated, { json: ctx.json }); | |
| } catch (err) { | |
| handleCommandError(err); | |
| } | |
| }), | |
| ); | |
| } | |
| function parseCsv(value: string | undefined): string[] { | |
| if (!value) return []; | |
| return value.split(",").map((v) => v.trim()).filter(Boolean); | |
| } | |
| function parseOptionalInt(value: string | undefined): number | undefined { | |
| if (value === undefined) return undefined; | |
| const parsed = Number.parseInt(value, 10); | |
| if (!Number.isFinite(parsed)) { | |
| throw new Error(`Invalid integer value: ${value}`); | |
| } | |
| return parsed; | |
| } | |
| function parseHiddenAt(value: string | undefined): string | null | undefined { | |
| if (value === undefined) return undefined; | |
| if (value.trim().toLowerCase() === "null") return null; | |
| return value; | |
| } | |
| function filterIssueRows(rows: Issue[], match: string | undefined): Issue[] { | |
| if (!match?.trim()) return rows; | |
| const needle = match.trim().toLowerCase(); | |
| return rows.filter((row) => { | |
| const text = [row.identifier, row.title, row.description] | |
| .filter((part): part is string => Boolean(part)) | |
| .join("\n") | |
| .toLowerCase(); | |
| return text.includes(needle); | |
| }); | |
| } | |