Spaces:
Paused
Paused
| import { Command } from "commander"; | |
| import { writeFile } from "node:fs/promises"; | |
| import { | |
| addIssueCommentSchema, | |
| checkoutIssueSchema, | |
| createIssueSchema, | |
| type FeedbackTrace, | |
| updateIssueSchema, | |
| type Issue, | |
| type IssueComment, | |
| } from "@paperclipai/shared"; | |
| import { | |
| addCommonClientOptions, | |
| formatInlineRecord, | |
| handleCommandError, | |
| printOutput, | |
| resolveCommandContext, | |
| type BaseClientOptions, | |
| } from "./common.js"; | |
| import { | |
| buildFeedbackTraceQuery, | |
| normalizeFeedbackTraceExportFormat, | |
| serializeFeedbackTraces, | |
| } from "./feedback.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; | |
| } | |
| interface IssueFeedbackOptions extends BaseClientOptions { | |
| targetType?: string; | |
| vote?: string; | |
| status?: string; | |
| from?: string; | |
| to?: string; | |
| sharedOnly?: boolean; | |
| includePayload?: boolean; | |
| out?: string; | |
| format?: 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("feedback:list") | |
| .description("List feedback traces for an issue") | |
| .argument("<issueId>", "Issue ID") | |
| .option("--target-type <type>", "Filter by target type") | |
| .option("--vote <vote>", "Filter by vote value") | |
| .option("--status <status>", "Filter by trace status") | |
| .option("--from <iso8601>", "Only include traces created at or after this timestamp") | |
| .option("--to <iso8601>", "Only include traces created at or before this timestamp") | |
| .option("--shared-only", "Only include traces eligible for sharing/export") | |
| .option("--include-payload", "Include stored payload snapshots in the response") | |
| .action(async (issueId: string, opts: IssueFeedbackOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts); | |
| const traces = (await ctx.api.get<FeedbackTrace[]>( | |
| `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, | |
| )) ?? []; | |
| if (ctx.json) { | |
| printOutput(traces, { json: true }); | |
| return; | |
| } | |
| printOutput( | |
| traces.map((trace) => ({ | |
| id: trace.id, | |
| issue: trace.issueIdentifier ?? trace.issueId, | |
| vote: trace.vote, | |
| status: trace.status, | |
| targetType: trace.targetType, | |
| target: trace.targetSummary.label, | |
| })), | |
| { json: false }, | |
| ); | |
| } catch (err) { | |
| handleCommandError(err); | |
| } | |
| }), | |
| ); | |
| addCommonClientOptions( | |
| issue | |
| .command("feedback:export") | |
| .description("Export feedback traces for an issue") | |
| .argument("<issueId>", "Issue ID") | |
| .option("--target-type <type>", "Filter by target type") | |
| .option("--vote <vote>", "Filter by vote value") | |
| .option("--status <status>", "Filter by trace status") | |
| .option("--from <iso8601>", "Only include traces created at or after this timestamp") | |
| .option("--to <iso8601>", "Only include traces created at or before this timestamp") | |
| .option("--shared-only", "Only include traces eligible for sharing/export") | |
| .option("--include-payload", "Include stored payload snapshots in the export") | |
| .option("--out <path>", "Write export to a file path instead of stdout") | |
| .option("--format <format>", "Export format: json or ndjson", "ndjson") | |
| .action(async (issueId: string, opts: IssueFeedbackOptions) => { | |
| try { | |
| const ctx = resolveCommandContext(opts); | |
| const traces = (await ctx.api.get<FeedbackTrace[]>( | |
| `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, | |
| )) ?? []; | |
| const serialized = serializeFeedbackTraces(traces, opts.format); | |
| if (opts.out?.trim()) { | |
| await writeFile(opts.out, serialized, "utf8"); | |
| if (ctx.json) { | |
| printOutput( | |
| { out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) }, | |
| { json: true }, | |
| ); | |
| return; | |
| } | |
| console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`); | |
| return; | |
| } | |
| process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`); | |
| } 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); | |
| }); | |
| } | |