c0d0d03bce
Co-Authored-By: Paperclip <noreply@paperclip.ing>
433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
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";
|
|
|
|
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,
|
|
includePayload: 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: normalizeExportFormat(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);
|
|
});
|
|
}
|
|
|
|
function buildFeedbackTraceQuery(opts: IssueFeedbackOptions): string {
|
|
const params = new URLSearchParams();
|
|
if (opts.targetType) params.set("targetType", opts.targetType);
|
|
if (opts.vote) params.set("vote", opts.vote);
|
|
if (opts.status) params.set("status", opts.status);
|
|
if (opts.from) params.set("from", opts.from);
|
|
if (opts.to) params.set("to", opts.to);
|
|
if (opts.sharedOnly) params.set("sharedOnly", "true");
|
|
if (opts.includePayload) params.set("includePayload", "true");
|
|
const query = params.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
function normalizeExportFormat(value: string | undefined): "json" | "ndjson" {
|
|
if (!value || value === "ndjson") return "ndjson";
|
|
if (value === "json") return "json";
|
|
throw new Error(`Unsupported export format: ${value}`);
|
|
}
|
|
|
|
function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string {
|
|
if (normalizeExportFormat(format) === "json") {
|
|
return JSON.stringify(traces, null, 2);
|
|
}
|
|
return traces.map((trace) => JSON.stringify(trace)).join("\n");
|
|
}
|