mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents delegate work by creating follow-up tasks through the issue API, the CLI, and the chat `create_task` tool > - An agent can omit `parentId` when it creates a follow-up from an ordinary execution task. The API accepts the task as a top-level task. The task hierarchy does not show where the task came from, even though the run provenance is recorded > - The operator must read run history to learn why the task exists. This is slow and it hides delegated work from the parent task > - This pull request keeps the parent link for delegated follow-ups by default, keeps intentional standalone tasks possible, and shows an access-checked "Created from <task> by <agent>" note on the task page from the recorded run provenance > - The benefit is that each agent-created task shows its source without log inspection, while structural parentage, provenance, and blockers stay separate concepts ## Linked Issues or Issue Description Refs #13241 (shows tasks created from a task on the source task page; this PR shows the reverse direction on the created task) Refs #9727 (open proposal for creation provenance tracking) **What existing behavior does this improve?** Issue creation by agent runs, and the task detail page. **Subsystem affected** Server (`POST /api/companies/:companyId/issues`, `GET /api/issues/:id`), shared types, UI task detail page, CLI `issue create`, Paperclip skill docs. **Current behavior** When an agent run bound to an execution task creates a task and omits `parentId`, the task is created with no parent. The task page shows no source. The operator must inspect the run log to find the task that the agent was working on. The source run and the privacy parent are recorded, but the UI does not use them. **Proposed behavior** - When an agent run bound to an ordinary execution task omits `parentId`, the server sets `parentId` to that task. The `issue.created` activity records `parentDefaultedFromRunIssue: true`. - An explicit `parentId: null` keeps the task standalone. Conversation runs (chat handoffs), board or user creates, watchdog follow-ups, a run task the agent may not mutate, a default parent that would cause a delegation cycle, and a default parent the agent may not create a child under (for example a protected assignment policy without a grant) also stay standalone. The server runs the same `tasks:assign` decision with the defaulted parent in scope before it accepts the default. No request that succeeded before now fails. An explicit `parentId` is still checked as before. - `GET /api/issues/:id` returns `createdFrom` with the source task, the run, and the agent. The server resolves it from `origin_run_id`, or from the recorded creation activity for historical rows. It returns `null` when the viewer may not read the source task or the run, when the run is from another company, or when provenance is missing. - The task page shows "Created from <task> by <agent>" in the classic breadcrumb area and in the streamlined Tasks tab. The note is not a blocker and it is shown with or without a parent. - The CLI `issue create` command gets `--standalone`, which sends `parentId: null`. **Reason and benefit** Operators see why a task exists on the task itself. Delegated follow-ups stay inside the parent task tree, so parent completion and child wake-ups keep working. Private and cross-company sources stay hidden. **Breaking changes** None for the API schema. Agents that create tasks from a run and relied on the omitted `parentId` to mean "top-level" must now send `parentId: null` (CLI: `--standalone`). The skill and the spec document this. ## What Changed - `server/src/services/issue-creation-source.ts` (new): resolves a task's creation source from the origin run or the creation activity, scoped to the company, and loads the run-bound task by UUID or identifier. - `server/src/routes/issues.ts`: `POST /companies/:companyId/issues` defaults `parentId` to the run's task when the body omits it (with the exclusions above) and records the default in the activity log. `GET /issues/:id` adds the access-checked `createdFrom` field. - `packages/shared/src/types/issue.ts` and index exports: new `IssueCreationSource` type and the optional `createdFrom` field on `Issue`. - `ui/src/components/task-detail/IssueCreatedFromNote.tsx` (new), `TaskDetailTasksPanel.tsx`, and `IssueDetail.tsx`: show the creation source in both task page layouts and count it in the Tasks tab badge. - `cli/src/commands/client/issue.ts`: `issue create --standalone`. - Docs: `doc/SPEC-implementation.md`, `doc/CLI.md`, `skills/paperclip/SKILL.md`. - Tests: `server/src/__tests__/issue-creation-source-routes.test.ts` (new), `TaskDetailTasksPanel.test.tsx`, `IssueDetail.test.tsx`. ## Verification Run the new server route test (embedded Postgres): ```sh cd server && pnpm exec vitest run src/__tests__/issue-creation-source-routes.test.ts ``` It covers: ordinary delegation gets the parent and `createdFrom`; explicit `parentId: null` stays standalone but still shows the source; conversation handoff and board create stay standalone; a would-be delegation cycle falls back to standalone; a protected source task without a grant falls back to standalone while an explicit parent still returns 403; an explicit cycle still returns 409; historical provenance from the creation activity; missing provenance, a private source for an outsider, and a cross-company run all return `null`. Related existing suites that passed locally (11 files, 313 tests): `issue-created-from-routes`, `issue-create-deduplication-routes`, `issue-agent-mutation-ownership-routes`, `chat-project-tools`, `issue-assignee-invokability-routes`, `issue-watchdogs-routes`, `issue-dependency-wakeups-routes`, `public-mcp`, `native-runtime/task-creation-backlog`, `issue-access-grants-routes`, `issue-list-assignee-filter-routes`. UI: ```sh cd ui && pnpm exec vitest run src/components/task-detail/TaskDetailTasksPanel.test.tsx src/pages/IssueDetail.test.tsx pnpm check:token-gates ``` Manual check: as an agent run bound to a task, `POST /api/companies/:companyId/issues` with only a title. The response has `parentId` set to the run's task. Open the new task; the page shows "Created from <task> by <agent>". The related server suites pass locally (`issue-created-from`, `issue-create-deduplication`, `issue-agent-mutation-ownership`, `chat-project-tools`, `issue-watchdogs`, `public-mcp`, `task-creation-backlog`, `issue-access-grants`, `permissions-upgrade-boundary`). The full `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` were not run locally because the sandbox has a 4 GB memory limit and the TypeScript compiler is killed on the server project; the CI gates on this PR cover them. ## Risks - Behavior shift for agent clients: a run-created task without `parentId` now becomes a child of the run's task. Clients that want a top-level task must send `parentId: null`. The watchdog, conversation, and board paths are excluded, and the default never adds a blocker. - The default parent inherits the parent's execution workspace, which run-created tasks already inherited from the run's task. - `createdFrom` adds one run lookup, one issue lookup, one access decision, and one agent lookup to the task detail request. It is skipped when the task has no origin run and no creation activity with a run. - Privacy: the field returns `null` unless the viewer can read the source task and the run, so a private source is indistinguishable from missing provenance. ## Model Used Claude Fable 5.1 (`claude-fable-5-1`), Anthropic, run through Claude Code in a Paperclip-managed execution workspace with tool use, shell execution, and a local embedded Postgres for tests. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1465 lines
52 KiB
TypeScript
1465 lines
52 KiB
TypeScript
import { Command } from "commander";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import {
|
|
addIssueCommentSchema,
|
|
acceptIssueThreadInteractionSchema,
|
|
cancelIssueThreadInteractionSchema,
|
|
checkoutIssueSchema,
|
|
createChildIssueSchema,
|
|
createIssueLabelSchema,
|
|
createIssueSchema,
|
|
createIssueThreadInteractionSchema,
|
|
createIssueTreeHoldSchema,
|
|
createIssueWorkProductSchema,
|
|
type FeedbackTrace,
|
|
type HeartbeatRun,
|
|
linkIssueApprovalSchema,
|
|
previewIssueTreeControlSchema,
|
|
rejectIssueThreadInteractionSchema,
|
|
releaseIssueTreeHoldSchema,
|
|
respondIssueThreadInteractionSchema,
|
|
resolveIssueRecoveryActionSchema,
|
|
restoreIssueDocumentRevisionSchema,
|
|
updateIssueSchema,
|
|
updateIssueWorkProductSchema,
|
|
type Issue,
|
|
type IssueComment,
|
|
upsertIssueDocumentSchema,
|
|
upsertIssueFeedbackVoteSchema,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
addCommonClientOptions,
|
|
apiPath,
|
|
formatInlineRecord,
|
|
handleCommandError,
|
|
inferContentTypeFromPath,
|
|
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;
|
|
standalone?: boolean;
|
|
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;
|
|
attachmentId?: string[];
|
|
reopen?: boolean;
|
|
resume?: boolean;
|
|
}
|
|
|
|
interface IssueCommentListOptions extends BaseClientOptions {
|
|
afterCommentId?: string;
|
|
order?: string;
|
|
limit?: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
interface IssueDeleteOptions extends BaseClientOptions {
|
|
yes?: boolean;
|
|
}
|
|
|
|
interface JsonPayloadOptions extends BaseClientOptions {
|
|
payloadJson: string;
|
|
}
|
|
|
|
interface IssueDocumentPutOptions extends BaseClientOptions {
|
|
title?: string;
|
|
format?: string;
|
|
body?: string;
|
|
bodyFile?: string;
|
|
changeSummary?: string;
|
|
baseRevisionId?: string;
|
|
}
|
|
|
|
interface IssueAttachmentUploadOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
file: string;
|
|
commentId?: string;
|
|
}
|
|
|
|
interface IssueAttachmentDownloadOptions extends BaseClientOptions {
|
|
out?: string;
|
|
}
|
|
|
|
interface IssueLabelCreateOptions extends BaseClientOptions {
|
|
companyId?: string;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
interface IssueRecoveryResolveOptions extends BaseClientOptions {
|
|
actionId?: string;
|
|
outcome: string;
|
|
sourceIssueStatus: string;
|
|
resolutionNote?: string;
|
|
}
|
|
|
|
interface InteractionAcceptOptions extends BaseClientOptions {
|
|
selectedClientKeys?: string;
|
|
selectedOptionIds?: string;
|
|
}
|
|
|
|
interface InteractionReasonOptions extends BaseClientOptions {
|
|
reason?: string;
|
|
}
|
|
|
|
interface InteractionRespondOptions extends BaseClientOptions {
|
|
answersJson: string;
|
|
summaryMarkdown?: string;
|
|
}
|
|
|
|
interface TreeHoldListOptions extends BaseClientOptions {
|
|
status?: string;
|
|
mode?: string;
|
|
includeMembers?: boolean;
|
|
}
|
|
|
|
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 = `${apiPath`/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>(apiPath`/api/issues/${idOrIdentifier}`);
|
|
printOutput(row, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("delete")
|
|
.description("Delete an issue")
|
|
.argument("<issueId>", "Issue ID")
|
|
.option("--yes", "Confirm deletion")
|
|
.action(async (issueId: string, opts: IssueDeleteOptions) => {
|
|
try {
|
|
if (!opts.yes) throw new Error("Refusing to delete without --yes");
|
|
const ctx = resolveCommandContext(opts);
|
|
const deleted = await ctx.api.delete<Issue>(apiPath`/api/issues/${issueId}`);
|
|
printOutput(deleted, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("heartbeat-context")
|
|
.description("Get heartbeat context for an issue")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const context = await ctx.api.get(apiPath`/api/issues/${issueId}/heartbeat-context`);
|
|
printOutput(context, { 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(
|
|
"--standalone",
|
|
"Create a top-level issue. Without --parent-id, an agent run's issue otherwise becomes the parent.",
|
|
)
|
|
.option("--request-depth <n>", "Request depth integer")
|
|
.option("--billing-code <code>", "Billing code")
|
|
.action(async (opts: IssueCreateOptions) => {
|
|
try {
|
|
if (opts.standalone && opts.parentId) {
|
|
throw new Error("--standalone cannot be combined with --parent-id");
|
|
}
|
|
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.standalone ? null : opts.parentId,
|
|
requestDepth: parseOptionalInt(opts.requestDepth),
|
|
billingCode: opts.billingCode,
|
|
});
|
|
|
|
const created = await ctx.api.post<Issue>(apiPath`/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 }>(apiPath`/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(
|
|
"--attachment-id <id...>",
|
|
"Bind uploaded issue attachments to this comment",
|
|
)
|
|
.option("--reopen", "Reopen if issue is done/cancelled")
|
|
.option("--resume", "Request explicit follow-up and wake the assignee when resumable")
|
|
.action(async (issueId: string, opts: IssueCommentOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = addIssueCommentSchema.parse({
|
|
body: opts.body,
|
|
attachmentIds: opts.attachmentId,
|
|
reopen: opts.reopen,
|
|
resume: opts.resume,
|
|
});
|
|
const comment = await ctx.api.post<IssueComment>(apiPath`/api/issues/${issueId}/comments`, payload);
|
|
printOutput(comment, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("comments")
|
|
.description("List issue comments")
|
|
.argument("<issueId>", "Issue ID")
|
|
.option("--after-comment-id <id>", "Only return comments after this comment ID")
|
|
.option("--order <order>", "asc or desc")
|
|
.option("--limit <n>", "Maximum comments to return")
|
|
.action(async (issueId: string, opts: IssueCommentListOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const params = new URLSearchParams();
|
|
if (opts.afterCommentId) params.set("afterCommentId", opts.afterCommentId);
|
|
if (opts.order) params.set("order", opts.order);
|
|
if (opts.limit) params.set("limit", opts.limit);
|
|
const query = params.toString();
|
|
const comments = (await ctx.api.get<IssueComment[]>(
|
|
`${apiPath`/api/issues/${issueId}/comments`}${query ? `?${query}` : ""}`,
|
|
)) ?? [];
|
|
printOutput(comments, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("comment:get")
|
|
.description("Get one issue comment")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<commentId>", "Comment ID")
|
|
.action(async (issueId: string, commentId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const comment = await ctx.api.get<IssueComment>(apiPath`/api/issues/${issueId}/comments/${commentId}`);
|
|
printOutput(comment, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("comment:delete")
|
|
.description("Delete or cancel one issue comment")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<commentId>", "Comment ID")
|
|
.action(async (issueId: string, commentId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const deleted = await ctx.api.delete<IssueComment>(apiPath`/api/issues/${issueId}/comments/${commentId}`);
|
|
printOutput(deleted, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("approvals")
|
|
.description("List approvals linked to an issue")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const approvals = await ctx.api.get(apiPath`/api/issues/${issueId}/approvals`);
|
|
printOutput(approvals, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("approval:link")
|
|
.description("Link an approval to an issue")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<approvalId>", "Approval ID")
|
|
.action(async (issueId: string, approvalId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = linkIssueApprovalSchema.parse({ approvalId });
|
|
const approvals = await ctx.api.post(apiPath`/api/issues/${issueId}/approvals`, payload);
|
|
printOutput(approvals, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("approval:unlink")
|
|
.description("Unlink an approval from an issue")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<approvalId>", "Approval ID")
|
|
.action(async (issueId: string, approvalId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const result = await ctx.api.delete(apiPath`/api/issues/${issueId}/approvals/${approvalId}`);
|
|
printOutput(result, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addIssuePostDeleteMarkerCommand(issue, "read", "Mark an issue as read", "post", "/read");
|
|
addIssuePostDeleteMarkerCommand(issue, "unread", "Mark an issue as unread", "delete", "/read");
|
|
addIssuePostDeleteMarkerCommand(issue, "archive", "Archive an issue from the inbox", "post", "/inbox-archive");
|
|
addIssuePostDeleteMarkerCommand(issue, "unarchive", "Unarchive an issue from the inbox", "delete", "/inbox-archive");
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("recovery-actions")
|
|
.description("List active recovery actions for an issue")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const result = await ctx.api.get(apiPath`/api/issues/${issueId}/recovery-actions`);
|
|
printOutput(result, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("recovery:resolve")
|
|
.description("Resolve an issue recovery action")
|
|
.argument("<issueId>", "Issue ID")
|
|
.requiredOption("--outcome <outcome>", "restored, false_positive, blocked, or cancelled")
|
|
.requiredOption("--source-issue-status <status>", "todo, done, or in_review for restored outcomes; blocked is only valid for blocked outcomes")
|
|
.option("--action-id <id>", "Specific recovery action ID")
|
|
.option("--resolution-note <text>", "Resolution note")
|
|
.action(async (issueId: string, opts: IssueRecoveryResolveOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = resolveIssueRecoveryActionSchema.parse({
|
|
actionId: opts.actionId,
|
|
outcome: opts.outcome,
|
|
sourceIssueStatus: opts.sourceIssueStatus,
|
|
resolutionNote: opts.resolutionNote,
|
|
});
|
|
const result = await ctx.api.post(apiPath`/api/issues/${issueId}/recovery-actions/resolve`, payload);
|
|
printOutput(result, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("child:create")
|
|
.description("Create a child issue from a JSON payload")
|
|
.argument("<issueId>", "Parent issue ID")
|
|
.requiredOption("--payload-json <json>", "CreateChildIssue JSON payload")
|
|
.action(async (issueId: string, opts: JsonPayloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = createChildIssueSchema.parse(parseJson(opts.payloadJson));
|
|
const child = await ctx.api.post<Issue>(apiPath`/api/issues/${issueId}/children`, payload);
|
|
printOutput(child, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("force-release")
|
|
.description("Force-release an issue from an agent checkout")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const result = await ctx.api.post(apiPath`/api/issues/${issueId}/admin/force-release`, {});
|
|
printOutput(result, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("work-products")
|
|
.description("List issue work products")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const rows = await ctx.api.get(apiPath`/api/issues/${issueId}/work-products`);
|
|
printOutput(rows, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("work-product:create")
|
|
.description("Create an issue work product from JSON")
|
|
.argument("<issueId>", "Issue ID")
|
|
.requiredOption("--payload-json <json>", "CreateIssueWorkProduct JSON payload")
|
|
.action(async (issueId: string, opts: JsonPayloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = createIssueWorkProductSchema.parse(parseJson(opts.payloadJson));
|
|
const product = await ctx.api.post(apiPath`/api/issues/${issueId}/work-products`, payload);
|
|
printOutput(product, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("work-product:update")
|
|
.description("Update a work product from JSON")
|
|
.argument("<workProductId>", "Work product ID")
|
|
.requiredOption("--payload-json <json>", "UpdateIssueWorkProduct JSON payload")
|
|
.action(async (workProductId: string, opts: JsonPayloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = updateIssueWorkProductSchema.parse(parseJson(opts.payloadJson));
|
|
const product = await ctx.api.patch(apiPath`/api/work-products/${workProductId}`, payload);
|
|
printOutput(product, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("work-product:delete")
|
|
.description("Delete a work product")
|
|
.argument("<workProductId>", "Work product ID")
|
|
.action(async (workProductId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const product = await ctx.api.delete(apiPath`/api/work-products/${workProductId}`);
|
|
printOutput(product, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("documents")
|
|
.description("List issue documents")
|
|
.argument("<issueId>", "Issue ID")
|
|
.option("--include-system", "Include system documents")
|
|
.action(async (issueId: string, opts: BaseClientOptions & { includeSystem?: boolean }) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const query = opts.includeSystem ? "?includeSystem=true" : "";
|
|
const docs = await ctx.api.get(`${apiPath`/api/issues/${issueId}/documents`}${query}`);
|
|
printOutput(docs, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("document:get")
|
|
.description("Get an issue document")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<key>", "Document key")
|
|
.action(async (issueId: string, key: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const doc = await ctx.api.get(apiPath`/api/issues/${issueId}/documents/${key}`);
|
|
printOutput(doc, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("document:put")
|
|
.description("Create or update an issue document")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<key>", "Document key")
|
|
.option("--title <title>", "Document title")
|
|
.option("--format <format>", "Document format", "markdown")
|
|
.option("--body <markdown>", "Document body")
|
|
.option("--body-file <path>", "Read document body from a file")
|
|
.option("--change-summary <text>", "Change summary")
|
|
.option("--base-revision-id <id>", "Expected base revision ID")
|
|
.action(async (issueId: string, key: string, opts: IssueDocumentPutOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const body = opts.bodyFile ? await readFile(opts.bodyFile, "utf8") : opts.body;
|
|
const payload = upsertIssueDocumentSchema.parse({
|
|
title: opts.title,
|
|
format: opts.format,
|
|
body,
|
|
changeSummary: opts.changeSummary,
|
|
baseRevisionId: opts.baseRevisionId,
|
|
});
|
|
const doc = await ctx.api.put(apiPath`/api/issues/${issueId}/documents/${key}`, payload);
|
|
printOutput(doc, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("interactions")
|
|
.description("List issue thread interactions")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const interactions = await ctx.api.get(apiPath`/api/issues/${issueId}/interactions`);
|
|
printOutput(interactions, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("interaction:create")
|
|
.description("Create an issue thread interaction from JSON")
|
|
.argument("<issueId>", "Issue ID")
|
|
.requiredOption("--payload-json <json>", "CreateIssueThreadInteraction JSON payload")
|
|
.action(async (issueId: string, opts: JsonPayloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = createIssueThreadInteractionSchema.parse(parseJson(opts.payloadJson));
|
|
const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions`, payload);
|
|
printOutput(interaction, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("interaction:accept")
|
|
.description("Accept an issue thread interaction")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<interactionId>", "Interaction ID")
|
|
.option("--selected-client-keys <csv>", "Client keys to accept")
|
|
.option("--selected-option-ids <csv>", "Checkbox option IDs to accept")
|
|
.action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = acceptIssueThreadInteractionSchema.parse({
|
|
selectedClientKeys: opts.selectedClientKeys === undefined ? undefined : parseCsv(opts.selectedClientKeys),
|
|
selectedOptionIds: opts.selectedOptionIds === undefined ? undefined : parseCsv(opts.selectedOptionIds),
|
|
});
|
|
const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/accept`, payload);
|
|
printOutput(interaction, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
for (const [name, action, schema, description] of [
|
|
["interaction:reject", "reject", rejectIssueThreadInteractionSchema, "Reject an issue thread interaction"],
|
|
["interaction:cancel", "cancel", cancelIssueThreadInteractionSchema, "Cancel an issue thread interaction"],
|
|
] as const) {
|
|
addCommonClientOptions(
|
|
issue
|
|
.command(name)
|
|
.description(description)
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<interactionId>", "Interaction ID")
|
|
.option("--reason <text>", "Reason")
|
|
.action(async (issueId: string, interactionId: string, opts: InteractionReasonOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = schema.parse({ reason: opts.reason });
|
|
const interaction = await ctx.api.post(`${apiPath`/api/issues/${issueId}/interactions/${interactionId}`}/${action}`, payload);
|
|
printOutput(interaction, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("interaction:respond")
|
|
.description("Respond to an issue question interaction")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<interactionId>", "Interaction ID")
|
|
.requiredOption("--answers-json <json>", "Answers array JSON")
|
|
.option("--summary-markdown <markdown>", "Optional response summary")
|
|
.action(async (issueId: string, interactionId: string, opts: InteractionRespondOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = respondIssueThreadInteractionSchema.parse({
|
|
answers: parseJson(opts.answersJson),
|
|
summaryMarkdown: opts.summaryMarkdown,
|
|
});
|
|
const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/respond`, payload);
|
|
printOutput(interaction, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("tree-state")
|
|
.description("Get issue tree control state")
|
|
.argument("<issueId>", "Root issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const state = await ctx.api.get(apiPath`/api/issues/${issueId}/tree-control/state`);
|
|
printOutput(state, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("tree-preview")
|
|
.description("Preview issue tree control changes")
|
|
.argument("<issueId>", "Root issue ID")
|
|
.requiredOption("--payload-json <json>", "PreviewIssueTreeControl JSON payload")
|
|
.action(async (issueId: string, opts: JsonPayloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = previewIssueTreeControlSchema.parse(parseJson(opts.payloadJson));
|
|
const preview = await ctx.api.post(apiPath`/api/issues/${issueId}/tree-control/preview`, payload);
|
|
printOutput(preview, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("tree-holds")
|
|
.description("List issue tree holds")
|
|
.argument("<issueId>", "Root issue ID")
|
|
.option("--status <status>", "active or released")
|
|
.option("--mode <mode>", "pause, resume, cancel, or restore")
|
|
.option("--include-members", "Include hold members")
|
|
.action(async (issueId: string, opts: TreeHoldListOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const params = new URLSearchParams();
|
|
if (opts.status) params.set("status", opts.status);
|
|
if (opts.mode) params.set("mode", opts.mode);
|
|
if (opts.includeMembers) params.set("includeMembers", "true");
|
|
const query = params.toString();
|
|
const holds = await ctx.api.get(`${apiPath`/api/issues/${issueId}/tree-holds`}${query ? `?${query}` : ""}`);
|
|
printOutput(holds, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("tree-hold:create")
|
|
.description("Create an issue tree hold from JSON")
|
|
.argument("<issueId>", "Root issue ID")
|
|
.requiredOption("--payload-json <json>", "CreateIssueTreeHold JSON payload")
|
|
.action(async (issueId: string, opts: JsonPayloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = createIssueTreeHoldSchema.parse(parseJson(opts.payloadJson));
|
|
const hold = await ctx.api.post(apiPath`/api/issues/${issueId}/tree-holds`, payload);
|
|
printOutput(hold, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("tree-hold:get")
|
|
.description("Get an issue tree hold")
|
|
.argument("<issueId>", "Root issue ID")
|
|
.argument("<holdId>", "Hold ID")
|
|
.action(async (issueId: string, holdId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const hold = await ctx.api.get(apiPath`/api/issues/${issueId}/tree-holds/${holdId}`);
|
|
printOutput(hold, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("tree-hold:release")
|
|
.description("Release an issue tree hold")
|
|
.argument("<issueId>", "Root issue ID")
|
|
.argument("<holdId>", "Hold ID")
|
|
.option("--payload-json <json>", "ReleaseIssueTreeHold JSON payload", "{}")
|
|
.action(async (issueId: string, holdId: string, opts: JsonPayloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = releaseIssueTreeHoldSchema.parse(parseJson(opts.payloadJson));
|
|
const hold = await ctx.api.post(apiPath`/api/issues/${issueId}/tree-holds/${holdId}/release`, payload);
|
|
printOutput(hold, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("attachments")
|
|
.description("List issue attachments")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const attachments = await ctx.api.get(apiPath`/api/issues/${issueId}/attachments`);
|
|
printOutput(attachments, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("attachment:upload")
|
|
.description("Upload an issue attachment")
|
|
.argument("<issueId>", "Issue ID")
|
|
.option("-C, --company-id <id>", "Company ID")
|
|
.requiredOption("--file <path>", "File to upload")
|
|
.option("--comment-id <id>", "Attach to an issue comment")
|
|
.action(async (issueId: string, opts: IssueAttachmentUploadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const attachment = await uploadAttachment(ctx.api.apiBase, ctx.api.apiKey, {
|
|
companyId: ctx.companyId ?? "",
|
|
issueId,
|
|
filePath: opts.file,
|
|
commentId: opts.commentId,
|
|
runId: ctx.api.runId,
|
|
});
|
|
printOutput(attachment, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: false },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("attachment:download")
|
|
.description("Download an attachment")
|
|
.argument("<attachmentId>", "Attachment ID")
|
|
.option("--out <path>", "Output file path; prints to stdout when omitted")
|
|
.action(async (attachmentId: string, opts: IssueAttachmentDownloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const bytes = await downloadAttachment(ctx.api.apiBase, ctx.api.apiKey, attachmentId);
|
|
if (opts.out) {
|
|
await writeFile(opts.out, bytes);
|
|
if (ctx.json) printOutput({ out: opts.out, bytes: bytes.byteLength }, { json: true });
|
|
else console.log(`Wrote ${bytes.byteLength} byte(s) to ${opts.out}`);
|
|
return;
|
|
}
|
|
process.stdout.write(bytes);
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("attachment:delete")
|
|
.description("Delete an attachment")
|
|
.argument("<attachmentId>", "Attachment ID")
|
|
.action(async (attachmentId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const result = await ctx.api.delete(apiPath`/api/attachments/${attachmentId}`);
|
|
printOutput(result, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("label:list")
|
|
.description("List issue labels in a company")
|
|
.option("-C, --company-id <id>", "Company ID")
|
|
.action(async (opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const labels = await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/labels`);
|
|
printOutput(labels, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: false },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("label:create")
|
|
.description("Create an issue label")
|
|
.option("-C, --company-id <id>", "Company ID")
|
|
.requiredOption("--name <name>", "Label name")
|
|
.requiredOption("--color <hex>", "Label color, e.g. #4f46e5")
|
|
.action(async (opts: IssueLabelCreateOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
const payload = createIssueLabelSchema.parse({ name: opts.name, color: opts.color });
|
|
const label = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/labels`, payload);
|
|
printOutput(label, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
{ includeCompany: false },
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("label:delete")
|
|
.description("Delete an issue label")
|
|
.argument("<labelId>", "Label ID")
|
|
.action(async (labelId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const result = await ctx.api.delete(apiPath`/api/labels/${labelId}`);
|
|
printOutput(result, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("feedback:votes")
|
|
.description("List feedback votes for an issue")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const votes = await ctx.api.get(apiPath`/api/issues/${issueId}/feedback-votes`);
|
|
printOutput(votes, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("feedback:vote")
|
|
.description("Create or update a feedback vote")
|
|
.argument("<issueId>", "Issue ID")
|
|
.requiredOption("--payload-json <json>", "UpsertIssueFeedbackVote JSON payload")
|
|
.action(async (issueId: string, opts: JsonPayloadOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = upsertIssueFeedbackVoteSchema.parse(parseJson(opts.payloadJson));
|
|
const vote = await ctx.api.post(apiPath`/api/issues/${issueId}/feedback-votes`, payload);
|
|
printOutput(vote, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
for (const [name, pathSuffix, description] of [
|
|
["document:delete", "", "Delete an issue document"],
|
|
["document:lock", "/lock", "Lock an issue document"],
|
|
["document:unlock", "/unlock", "Unlock an issue document"],
|
|
] as const) {
|
|
addCommonClientOptions(
|
|
issue
|
|
.command(name)
|
|
.description(description)
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<key>", "Document key")
|
|
.action(async (issueId: string, key: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const path = `${apiPath`/api/issues/${issueId}/documents/${key}`}${pathSuffix}`;
|
|
const result = name === "document:delete" ? await ctx.api.delete(path) : await ctx.api.post(path, {});
|
|
printOutput(result, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("document:revisions")
|
|
.description("List issue document revisions")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<key>", "Document key")
|
|
.action(async (issueId: string, key: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const revisions = await ctx.api.get(apiPath`/api/issues/${issueId}/documents/${key}/revisions`);
|
|
printOutput(revisions, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("document:restore")
|
|
.description("Restore an issue document revision")
|
|
.argument("<issueId>", "Issue ID")
|
|
.argument("<key>", "Document key")
|
|
.argument("<revisionId>", "Revision ID")
|
|
.action(async (issueId: string, key: string, revisionId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const payload = restoreIssueDocumentRevisionSchema.parse({});
|
|
const doc = await ctx.api.post(
|
|
apiPath`/api/issues/${issueId}/documents/${key}/revisions/${revisionId}/restore`,
|
|
payload,
|
|
);
|
|
printOutput(doc, { 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[]>(
|
|
`${apiPath`/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("runs")
|
|
.description("List heartbeat runs associated with an issue")
|
|
.argument("<issueId>", "Issue ID or identifier")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const rows = (await ctx.api.get<unknown[]>(apiPath`/api/issues/${issueId}/runs`)) ?? [];
|
|
printOutput(rows, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("live-runs")
|
|
.description("List queued and running heartbeat runs associated with an issue")
|
|
.argument("<issueId>", "Issue ID or identifier")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const rows = (await ctx.api.get<HeartbeatRun[]>(apiPath`/api/issues/${issueId}/live-runs`)) ?? [];
|
|
printOutput(rows, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("active-run")
|
|
.description("Show the active heartbeat run associated with an issue")
|
|
.argument("<issueId>", "Issue ID or identifier")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const run = await ctx.api.get<HeartbeatRun | null>(apiPath`/api/issues/${issueId}/active-run`);
|
|
printOutput(run, { json: ctx.json });
|
|
} 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[]>(
|
|
`${apiPath`/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>(apiPath`/api/issues/${issueId}/checkout`, payload);
|
|
printOutput(updated, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
addCommonClientOptions(
|
|
issue
|
|
.command("release")
|
|
.description("Release issue execution locks; clear the assignee only for unfinished issues")
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const updated = await ctx.api.post<Issue>(apiPath`/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 addIssuePostDeleteMarkerCommand(
|
|
issue: Command,
|
|
name: string,
|
|
description: string,
|
|
method: "post" | "delete",
|
|
pathSuffix: string,
|
|
): void {
|
|
addCommonClientOptions(
|
|
issue
|
|
.command(name)
|
|
.description(description)
|
|
.argument("<issueId>", "Issue ID")
|
|
.action(async (issueId: string, opts: BaseClientOptions) => {
|
|
try {
|
|
const ctx = resolveCommandContext(opts);
|
|
const result = method === "post"
|
|
? await ctx.api.post(`${apiPath`/api/issues/${issueId}`}${pathSuffix}`, {})
|
|
: await ctx.api.delete(`${apiPath`/api/issues/${issueId}`}${pathSuffix}`);
|
|
printOutput(result, { json: ctx.json });
|
|
} catch (err) {
|
|
handleCommandError(err);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
function parseJson(value: string): unknown {
|
|
return JSON.parse(value) as unknown;
|
|
}
|
|
|
|
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 buildApiUrl(apiBase: string, path: string): string {
|
|
const url = new URL(apiBase);
|
|
url.pathname = `${url.pathname.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
return url.toString();
|
|
}
|
|
|
|
async function uploadAttachment(
|
|
apiBase: string,
|
|
apiKey: string | undefined,
|
|
input: { companyId: string; issueId: string; filePath: string; commentId?: string; runId?: string },
|
|
): Promise<unknown> {
|
|
const bytes = await readFile(input.filePath);
|
|
const form = new FormData();
|
|
form.set("file", new Blob([bytes], { type: inferContentTypeFromPath(input.filePath) }), input.filePath.split(/[\\/]/).pop() ?? "attachment");
|
|
if (input.commentId) form.set("issueCommentId", input.commentId);
|
|
// This multipart upload uses a hand-rolled fetch rather than PaperclipApiClient,
|
|
// so it must forward the agent run-id header itself — otherwise an
|
|
// agent-authenticated upload is rejected with "401 Agent run id required"
|
|
// (the client injects x-paperclip-run-id automatically for JSON requests).
|
|
const headers: Record<string, string> = {};
|
|
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
|
if (input.runId) headers["x-paperclip-run-id"] = input.runId;
|
|
const response = await fetch(buildApiUrl(apiBase, apiPath`/api/companies/${input.companyId}/issues/${input.issueId}/attachments`), {
|
|
method: "POST",
|
|
headers,
|
|
body: form,
|
|
});
|
|
return parseFetchResponse(response);
|
|
}
|
|
|
|
async function downloadAttachment(
|
|
apiBase: string,
|
|
apiKey: string | undefined,
|
|
attachmentId: string,
|
|
): Promise<Buffer> {
|
|
const response = await fetch(buildApiUrl(apiBase, apiPath`/api/attachments/${attachmentId}/content`), {
|
|
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
await parseFetchResponse(response);
|
|
}
|
|
return Buffer.from(await response.arrayBuffer());
|
|
}
|
|
|
|
async function parseFetchResponse(response: Response): Promise<unknown> {
|
|
const text = await response.text();
|
|
const parsed = text.trim() ? safeJson(text) : null;
|
|
if (!response.ok) {
|
|
const message =
|
|
typeof parsed === "object" && parsed !== null && "error" in parsed && typeof parsed.error === "string"
|
|
? parsed.error
|
|
: `Request failed with status ${response.status}`;
|
|
throw new Error(`API error ${response.status}: ${message}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function safeJson(text: string): unknown {
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|