314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
import { Command } from "commander";
|
|
import {
|
|
addIssueCommentSchema,
|
|
checkoutIssueSchema,
|
|
createIssueSchema,
|
|
updateIssueSchema,
|
|
type Issue,
|
|
type IssueComment,
|
|
} from "@paperclip/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);
|
|
});
|
|
}
|