| 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); |
| }); |
| } |
|
|