From 758d802bb0fffc7ecd9246338b4abd6db8603049 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:41:03 +0200 Subject: [PATCH 01/13] docs(usage): recommend --no-interactive for agents Add a note in the root usage overview and the README example agent prompt telling agents to pass --no-interactive on every call. --- README.md | 17 +++++++++++++++++ src/common/usage.ts | 3 +++ 2 files changed, 20 insertions(+) diff --git a/README.md b/README.md index dd2c1027..87f26905 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,23 @@ linearis issues read ENG-42 For the complete reference of every command and flag, run `linearis usage`. +### Interactive prompts + +In a real terminal, Linearis can prompt for missing input instead of erroring — pickers for entities (issue, project, document, ...) and field wizards for create/update. The final stdout is always the same JSON; prompts are drawn on stderr. + +```bash +# Auto-launches a wizard when a required arg is missing (TTY only) +linearis issues create + +# Force interactive; only the gaps are prompted, flags win +linearis issues create "Fix login" -i + +# Opt out entirely (also the default for pipes, CI, and non-TTY) +linearis issues create "Fix login" --team ENG --no-interactive +``` + +Prompts are hard-gated off whenever stdin/stdout is not a TTY, `CI` or `LINEARIS_NO_INTERACTIVE` is set, `--no-interactive` is passed, or `--compact`/`--fields` is used — so agents and pipes never hang and stdout stays pure JSON. + ### Discussions Discussions are modeled as root threads with replies, rather than a flat comment list: diff --git a/src/common/usage.ts b/src/common/usage.ts index eba67bc5..a502cbda 100644 --- a/src/common/usage.ts +++ b/src/common/usage.ts @@ -19,6 +19,9 @@ export function formatOverview(version: string, metas: DomainMeta[]): string { ); lines.push("output: JSON"); lines.push("ids: UUID or human-readable (team key, issue ABC-123, name)"); + lines.push( + "agents: pass --no-interactive on every call to disable prompts (recommended for scripts/LLMs)", + ); lines.push(""); lines.push("domains:"); for (const meta of metas) { From 3415583168fbfdc49bfd410e7dbc7b058a59e0c1 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:55:05 +0200 Subject: [PATCH 02/13] feat(interactive): add interactive prompting for missing input across domains Add an interactive mode (-i/--interactive, --no-interactive) backed by a prompt engine, gating, and choice helpers using @clack/prompts. Wire interactive specs into issues, projects, labels, milestones, cycles, comments, documents, attachments, files, teams, and initiatives, and add a workflow-state-service plus supporting resolver/output/auth/error helpers. --- graphql/queries/issues.graphql | 17 + package-lock.json | 38 +- package.json | 1 + src/commands/attachments.ts | 176 ++++++- src/commands/comments.ts | 327 ++++++++++-- src/commands/cycles.ts | 111 ++++- src/commands/documents.ts | 200 +++++++- src/commands/files.ts | 85 +++- src/commands/initiatives/entity.ts | 269 +++++++++- src/commands/initiatives/projects.ts | 96 +++- src/commands/initiatives/relations.ts | 95 +++- src/commands/initiatives/updates.ts | 159 +++++- src/commands/issues.ts | 469 ++++++++++++++++-- src/commands/labels.ts | 243 +++++++-- src/commands/milestones.ts | 211 +++++++- src/commands/projects.ts | 346 ++++++++++++- src/commands/teams.ts | 43 +- src/common/auth.ts | 6 + src/common/errors.ts | 7 + src/common/interactive/choices.ts | 286 +++++++++++ src/common/interactive/clack-io.ts | 153 ++++++ src/common/interactive/emoji-choices.ts | 37 ++ src/common/interactive/engine.ts | 189 +++++++ src/common/interactive/gating.ts | 35 ++ src/common/interactive/types.ts | 117 +++++ src/common/output.ts | 19 + src/main.ts | 4 +- src/services/project-service.ts | 17 + src/services/workflow-state-service.ts | 32 ++ tests/unit/commands/comments.test.ts | 2 +- tests/unit/interactive/choices.test.ts | 272 ++++++++++ tests/unit/interactive/content-specs.test.ts | 100 ++++ tests/unit/interactive/coverage-sweep.test.ts | 126 +++++ tests/unit/interactive/cycle-specs.test.ts | 13 + tests/unit/interactive/engine.test.ts | 329 ++++++++++++ tests/unit/interactive/gating.test.ts | 101 ++++ .../unit/interactive/initiative-specs.test.ts | 36 ++ tests/unit/interactive/issue-specs.test.ts | 52 ++ tests/unit/interactive/label-specs.test.ts | 51 ++ .../unit/interactive/milestone-specs.test.ts | 39 ++ tests/unit/interactive/project-specs.test.ts | 49 ++ 41 files changed, 4722 insertions(+), 236 deletions(-) create mode 100644 src/common/interactive/choices.ts create mode 100644 src/common/interactive/clack-io.ts create mode 100644 src/common/interactive/emoji-choices.ts create mode 100644 src/common/interactive/engine.ts create mode 100644 src/common/interactive/gating.ts create mode 100644 src/common/interactive/types.ts create mode 100644 src/services/workflow-state-service.ts create mode 100644 tests/unit/interactive/choices.test.ts create mode 100644 tests/unit/interactive/content-specs.test.ts create mode 100644 tests/unit/interactive/coverage-sweep.test.ts create mode 100644 tests/unit/interactive/cycle-specs.test.ts create mode 100644 tests/unit/interactive/engine.test.ts create mode 100644 tests/unit/interactive/gating.test.ts create mode 100644 tests/unit/interactive/initiative-specs.test.ts create mode 100644 tests/unit/interactive/issue-specs.test.ts create mode 100644 tests/unit/interactive/label-specs.test.ts create mode 100644 tests/unit/interactive/milestone-specs.test.ts create mode 100644 tests/unit/interactive/project-specs.test.ts diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 32d936d2..4ca78c2f 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -851,6 +851,23 @@ query FindWorkflowStates($filter: WorkflowStateFilter, $first: Int = 1) { } } +# List workflow states for a single team, ordered by position +# +# Used by the interactive status picker to present every status a team +# offers. Mirrors the WorkflowStateFilter team-scoping used by +# FindWorkflowStates, but returns the full node set with type and position +# so the caller can order and label them. +query ListWorkflowStatesForTeam($teamId: ID!, $first: Int = 50) { + workflowStates(filter: { team: { id: { eq: $teamId } } }, first: $first) { + nodes { + id + name + type + position + } + } +} + # Find issues by a dynamic filter for ID resolution # # The filter is supplied by the caller so the resolver can preserve both the diff --git a/package-lock.json b/package-lock.json index e60618b6..18f1f2bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2026.6.0", "license": "MIT", "dependencies": { + "@clack/prompts": "1.6.0", "commander": "14.0.3", "graphql": "16.12.0", "node-emoji": "2.2.0" @@ -601,6 +602,34 @@ "node": ">=14.21.3" } }, + "node_modules/@clack/core": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.2.tgz", + "integrity": "sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.6.0.tgz", + "integrity": "sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.2", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -6300,14 +6329,12 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, "license": "MIT" }, "node_modules/fast-string-width": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, "license": "MIT", "dependencies": { "fast-string-truncated-width": "^3.0.2" @@ -6334,7 +6361,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, "license": "MIT", "dependencies": { "fast-string-width": "^3.0.2" @@ -11943,6 +11969,12 @@ "node": ">=18" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", diff --git a/package.json b/package.json index 3d505686..6e45ecd5 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ }, "homepage": "https://github.com/linearis-oss/linearis#readme", "dependencies": { + "@clack/prompts": "1.6.0", "commander": "14.0.3", "graphql": "16.12.0", "node-emoji": "2.2.0" diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index 90003f6d..3d3aa7a2 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -1,7 +1,17 @@ import type { Command } from "commander"; -import { createContext, getRootOpts } from "../common/context.js"; -import { invalidParameterError } from "../common/errors.js"; +import { + type CommandContext, + createContext, + getRootOpts, +} from "../common/context.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; import { asUuid } from "../common/identifier.js"; +import { issueChoices } from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; @@ -22,6 +32,8 @@ export const ATTACHMENTS_META: DomainMeta = { "title, subtitle, sourceType (e.g. 'github', 'slack'), and metadata", "with integration-specific data. creating an attachment with the same", "url on the same issue updates the existing record (idempotent).", + "in a terminal, run with -i (or omit a required arg) to pick the issue", + "or attachment and enter title/url interactively.", ].join("\n"), arguments: { issue: "issue identifier (UUID or ABC-123)", @@ -47,6 +59,105 @@ interface CreateOptions { iconUrl?: string; } +/** Create-wizard shape: create options with an index signature. */ +type CreateWizardOptions = Partial & Record; + +/** + * Interactive wizard for `attachments create`. `--title` and `--url` become + * required text fields; the `[issue]` positional is filled by the issue picker. + */ +export const attachmentCreateSpec: PromptSpec = { + intro: "Create an attachment on an issue", + fields: [ + { name: "title", kind: "text", message: "Title", required: true }, + { name: "url", kind: "text", message: "URL", required: true }, + { name: "subtitle", kind: "text", message: "Subtitle" }, + ], +}; + +/** Entity picker for an absent `[issue]` positional (shared loader). */ +async function issuePicker(ctx: CommandContext, io: PromptIO): Promise { + const options = await issueChoices(ctx); + const answer = await io.select({ message: "Issue", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +/** + * Cross-field picker for an absent attachment ``. First picks the parent + * issue, then lists that issue's attachments and returns the selected + * attachment's UUID (which `asUuid` accepts unchanged downstream). + */ +async function attachmentPicker( + ctx: CommandContext, + io: PromptIO, +): Promise { + const issueIdentifier = await issuePicker(ctx, io); + const issueId = await resolveIssueId(ctx.gql, issueIdentifier); + const attachments = await listAttachments(ctx.gql, issueId); + const options = attachments.map((att) => ({ + value: att.id, + label: att.title || att.url, + ...(att.sourceType ? { hint: att.sourceType } : {}), + })); + if (options.length === 0) { + throw invalidParameterError("id", "the selected issue has no attachments"); + } + const answer = await io.select({ message: "Attachment", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +const EMPTY_SPEC: PromptSpec> = { fields: [] }; + +/** Fill an absent `[issue]` positional via the issue picker when gating allows. */ +async function resolveIssuePositional( + ctx: CommandContext, + command: Command, + issue: string | undefined, +): Promise { + const filled = await maybeCollectInteractive, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: issue === undefined, + positional: { name: "issue", value: issue, picker: issuePicker }, + }, + ); + return filled.positional; +} + +/** + * Fill an absent attachment `` via {@link attachmentPicker} when gating + * allows, else preserve the old missing-argument error. + */ +async function resolveAttachmentPositional( + ctx: CommandContext, + command: Command, + id: string | undefined, +): Promise { + const filled = await maybeCollectInteractive, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: id === undefined, + positional: { name: "id", value: id, picker: attachmentPicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("id", "is required"); + } + return filled.positional; +} + function resolveIssueArgument( positionalIssue: string | undefined, optionIssue: string | undefined, @@ -86,13 +197,17 @@ export function setupAttachmentsCommands(program: Command): void { .option("--created-before ", "created before date (YYYY-MM-DD)") .action( handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ + const [issueArg, options, command] = args as [ string | undefined, ListOptions, Command, ]; - const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); + const issue = + issueArg === undefined && options.issue === undefined + ? await resolveIssuePositional(ctx, command, issueArg) + : issueArg; + const issueIdentifier = resolveIssueArgument(issue, options.issue); const issueId = await resolveIssueId(ctx.gql, issueIdentifier); const filter = buildAttachmentFilter(options); const result = await listAttachments(ctx.gql, issueId, filter); @@ -104,20 +219,54 @@ export function setupAttachmentsCommands(program: Command): void { .command("create [issue]") .description("create an attachment on an issue") .option("--issue ", "issue identifier (alias for positional issue)") - .requiredOption("--title ", "attachment title") - .requiredOption("--url <url>", "attachment URL") + .option("--title <title>", "attachment title (required)") + .option("--url <url>", "attachment URL (required)") .option("--subtitle <text>", "attachment subtitle") .option("--comment <text>", "comment body to create with the attachment") .option("--icon-url <url>", "attachment icon URL") .action( handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ + const [issueArg, rawOptions, command] = args as [ string | undefined, - CreateOptions, + Partial<CreateOptions>, Command, ]; - const issueIdentifier = resolveIssueArgument(issue, options.issue); const ctx = createContext(getRootOpts(command)); + + const filled = await maybeCollectInteractive< + CreateWizardOptions, + string + >(ctx, getRootOpts(command), { + spec: attachmentCreateSpec, + options: rawOptions as CreateWizardOptions, + missingRequired: + (issueArg === undefined && rawOptions.issue === undefined) || + rawOptions.title === undefined || + rawOptions.url === undefined, + // Only offer the issue picker when the issue was not already supplied + // via --issue; otherwise the picked value would collide with + // options.issue in resolveIssueArgument. + ...(rawOptions.issue === undefined + ? { + positional: { + name: "issue", + value: issueArg, + picker: issuePicker, + }, + } + : {}), + }); + const options = filled.options as CreateOptions; + const issue = filled.positional; + + if (options.title === undefined) { + throw invalidParameterError("--title", "is required"); + } + if (options.url === undefined) { + throw invalidParameterError("--url", "is required"); + } + + const issueIdentifier = resolveIssueArgument(issue, options.issue); const issueId = await resolveIssueId(ctx.gql, issueIdentifier); const input: CreateAttachmentInput = { issueId, @@ -133,12 +282,17 @@ export function setupAttachmentsCommands(program: Command): void { ); attachments - .command("delete <id>") + .command("delete [id]") .description("delete an attachment by UUID") .action( handleCommand(async (...args: unknown[]) => { - const [id, , command] = args as [string, unknown, Command]; + const [idArg, , command] = args as [ + string | undefined, + unknown, + Command, + ]; const ctx = createContext(getRootOpts(command)); + const id = await resolveAttachmentPositional(ctx, command, idArg); const result = await deleteAttachment(ctx.gql, asUuid(id)); outputSuccess(result); }), diff --git a/src/commands/comments.ts b/src/commands/comments.ts index 3a233279..623feb6a 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -1,12 +1,19 @@ import type { Command } from "commander"; import { + type CommandContext, type CommandOptions, createContext, getRootOpts, } from "../common/context.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; -import { invalidParameterError } from "../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; import { asUuid } from "../common/identifier.js"; +import { emojiChoices, issueChoices } from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -43,12 +50,159 @@ interface ReactionOptions extends CommandOptions { shortcode?: string; } +/** Create-wizard shape: the create options plus the `issue` positional. */ +type CreateWizardOptions = CreateCommentOptions & + Record<string, unknown> & { body?: string }; + +/** + * Interactive wizard for `comments create`. The `<issue>` positional is filled + * by the shared issue picker (see {@link issuePicker}); `--body` becomes a + * required text field. The command body downstream is unchanged. + */ +export const commentCreateSpec: PromptSpec<CreateWizardOptions> = { + intro: "Add a comment to an issue", + fields: [ + { name: "body", kind: "multiline", message: "Body", required: true }, + ], +}; + +/** Reply/edit wizard shape: `--body` required text. */ +type BodyWizardOptions = { body?: string } & Record<string, unknown>; + +export const commentReplySpec: PromptSpec<BodyWizardOptions> = { + intro: "Reply to a discussion thread", + fields: [ + { name: "body", kind: "multiline", message: "Body", required: true }, + ], +}; + +export const commentEditSpec: PromptSpec<BodyWizardOptions> = { + intro: "Edit a comment", + fields: [ + { name: "body", kind: "multiline", message: "Body", required: true }, + ], +}; + +/** + * Entity picker for an absent `<issue>` positional. Returns the selected + * issue's identifier (which `resolveIssueId` accepts). Shared loader in + * choices.ts keeps it in sync with the issues domain. + */ +async function issuePicker(ctx: CommandContext, io: PromptIO): Promise<string> { + const options = await issueChoices(ctx); + const answer = await io.select({ message: "Issue", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +/** + * Cross-field picker for an absent comment/thread positional. First picks the + * parent issue, then lists that issue's root discussion threads and returns the + * selected comment's UUID (which `asUuid` accepts unchanged downstream). + */ +async function commentPicker( + ctx: CommandContext, + io: PromptIO, +): Promise<string> { + const issueIdentifier = await issuePicker(ctx, io); + const issueId = await resolveIssueId(ctx.gql, issueIdentifier); + const { nodes } = await listDiscussionsForIssue(ctx.gql, issueId, { + limit: 50, + }); + const options = nodes.map((thread) => ({ + value: thread.id, + label: thread.body.split("\n")[0]?.slice(0, 72) || thread.id, + ...(thread.user?.displayName ? { hint: thread.user.displayName } : {}), + })); + if (options.length === 0) { + throw invalidParameterError( + "comment", + "the selected issue has no discussion threads", + ); + } + const answer = await io.select({ message: "Comment", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +/** Emoji picker for an absent `[emoji]` positional. */ +async function emojiPicker( + _ctx: CommandContext, + io: PromptIO, +): Promise<string> { + const answer = await io.select({ + message: "Reaction", + options: emojiChoices(), + }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; + +/** + * Fill an absent comment/thread positional via {@link commentPicker} when + * gating allows, else preserve the old missing-argument error. + */ +async function resolveCommentPositional( + ctx: CommandContext, + command: Command, + argName: string, + value: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: value === undefined, + positional: { name: argName, value, picker: commentPicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError(argName, "is required"); + } + return filled.positional; +} + +/** + * Fill an absent `[emoji]` positional via the emoji picker when gating allows. + * Returns the (possibly still-undefined) emoji so the existing + * `resolveReactionEmojiInput` keeps ownership of the emoji-or-shortcode + * validation for the non-interactive path. + */ +async function resolveEmojiPositional( + ctx: CommandContext, + command: Command, + emoji: string | undefined, + shortcode: string | undefined, +): Promise<string | undefined> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: emoji === undefined && shortcode === undefined, + positional: { name: "emoji", value: emoji, picker: emojiPicker }, + }, + ); + return filled.positional; +} + export const COMMENTS_META: DomainMeta = { name: "comments", summary: "deprecated compatibility facade for issue discussions with root-thread-only reply support", context: - "the comments domain remains operational as an intentionally narrowed compatibility layer. compatibility mode supports replying by root thread ID only, nested-reply targets are not supported in compatibility mode, and edit/delete accept either root thread IDs or reply IDs for backward compatibility. new workflows should migrate to domain-centric issues discussion commands (issues discuss/discussions/replies/reply/edit-reply/delete-reply).", + "the comments domain remains operational as an intentionally narrowed compatibility layer. compatibility mode supports replying by root thread ID only, nested-reply targets are not supported in compatibility mode, and edit/delete accept either root thread IDs or reply IDs for backward compatibility. new workflows should migrate to domain-centric issues discussion commands (issues discuss/discussions/replies/reply/edit-reply/delete-reply). Run in a terminal with -i (or omit a required arg) to pick the issue/comment and enter the body interactively.", arguments: { issue: "issue identifier (UUID or ABC-123)", comment: "thread/reply identifier (UUID only)", @@ -77,7 +231,7 @@ export function setupCommentsCommands(program: Command): void { comments.action(() => comments.help()); comments - .command("list <issue>") + .command("list [issue]") .description( "deprecated compatibility: list root issue discussions (migrate to `issues discussions <issue>`)", ) @@ -90,12 +244,13 @@ export function setupCommentsCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .action( handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, + const [issueArg, options, command] = args as [ + string | undefined, ListCommentOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); const limit = parseLimit(options.limit || "25"); const resolvedIssueId = await resolveIssueId(ctx.gql, issue); @@ -110,7 +265,7 @@ export function setupCommentsCommands(program: Command): void { ); comments - .command("create <issue>") + .command("create [issue]") .description( "deprecated compatibility: start an issue discussion (migrate to `issues discuss <issue> --body <text>`)", ) @@ -122,21 +277,36 @@ export function setupCommentsCommands(program: Command): void { .option("--body <text>", "comment body (required, markdown supported)") .action( handleCommand(async (...args: unknown[]) => { - const [issue, options, command] = args as [ - string, + const [issueArg, options, command] = args as [ + string | undefined, CreateCommentOptions, Command, ]; const ctx = createContext(getRootOpts(command)); - if (!options.body) { + const filled = await maybeCollectInteractive< + CreateWizardOptions, + string + >(ctx, getRootOpts(command), { + spec: commentCreateSpec, + options: options as CreateWizardOptions, + missingRequired: issueArg === undefined || options.body === undefined, + positional: { name: "issue", value: issueArg, picker: issuePicker }, + }); + if (filled.positional === undefined) { + throw invalidParameterError("issue", "is required"); + } + const issue = filled.positional; + const body = filled.options.body; + + if (!body) { throw invalidParameterError("--body", "is required"); } const resolvedIssueId = await resolveIssueId(ctx.gql, issue); const result = await startIssueDiscussion(ctx.gql, { issueId: resolvedIssueId, - body: options.body, + body, }); outputSuccess(result); @@ -144,7 +314,7 @@ export function setupCommentsCommands(program: Command): void { ); comments - .command("reply <thread>") + .command("reply [thread]") .description( "deprecated compatibility: reply to a root discussion thread (requires root thread ID; nested-reply targets are not supported in compatibility mode; migrate to `issues reply <thread> --body <text>`)", ) @@ -160,20 +330,41 @@ export function setupCommentsCommands(program: Command): void { .option("--body <text>", "reply body (required, markdown supported)") .action( handleCommand(async (...args: unknown[]) => { - const [thread, options, command] = args as [ - string, + const [threadArg, options, command] = args as [ + string | undefined, ReplyCommentOptions, Command, ]; const ctx = createContext(getRootOpts(command)); - if (!options.body) { + const filled = await maybeCollectInteractive<BodyWizardOptions, string>( + ctx, + getRootOpts(command), + { + spec: commentReplySpec, + options: options as BodyWizardOptions, + missingRequired: + threadArg === undefined || options.body === undefined, + positional: { + name: "thread", + value: threadArg, + picker: commentPicker, + }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("thread", "is required"); + } + const thread = filled.positional; + const body = filled.options.body; + + if (!body) { throw invalidParameterError("--body", "is required"); } const result = await replyToDiscussion(ctx.gql, { threadId: asUuid(thread), - body: options.body, + body, entityKind: "issue", }); @@ -182,7 +373,7 @@ export function setupCommentsCommands(program: Command): void { ); comments - .command("edit <comment>") + .command("edit [comment]") .description( "deprecated compatibility: edit a discussion comment (accepts root thread ID or reply ID; migrate reply workflows to `issues edit-reply <reply> --body <text>`)", ) @@ -190,19 +381,40 @@ export function setupCommentsCommands(program: Command): void { .option("--body <text>", "new comment body (required, markdown supported)") .action( handleCommand(async (...args: unknown[]) => { - const [comment, options, command] = args as [ - string, + const [commentArg, options, command] = args as [ + string | undefined, EditCommentOptions, Command, ]; const ctx = createContext(getRootOpts(command)); - if (!options.body) { + const filled = await maybeCollectInteractive<BodyWizardOptions, string>( + ctx, + getRootOpts(command), + { + spec: commentEditSpec, + options: options as BodyWizardOptions, + missingRequired: + commentArg === undefined || options.body === undefined, + positional: { + name: "comment", + value: commentArg, + picker: commentPicker, + }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("comment", "is required"); + } + const comment = filled.positional; + const body = filled.options.body; + + if (!body) { throw invalidParameterError("--body", "is required"); } const result = await editDiscussionComment(ctx.gql, asUuid(comment), { - body: options.body, + body, }); outputSuccess(result); @@ -210,15 +422,25 @@ export function setupCommentsCommands(program: Command): void { ); comments - .command("delete <comment>") + .command("delete [comment]") .description( "deprecated compatibility: delete a discussion comment (accepts root thread ID or reply ID; migrate reply workflows to `issues delete-reply <reply>`)", ) .addHelpText("after", "\nPrefer: `issues delete-reply <reply>`") .action( handleCommand(async (...args: unknown[]) => { - const [comment, , command] = args as [string, unknown, Command]; + const [commentArg, , command] = args as [ + string | undefined, + unknown, + Command, + ]; const ctx = createContext(getRootOpts(command)); + const comment = await resolveCommentPositional( + ctx, + command, + "comment", + commentArg, + ); const result = await deleteDiscussionComment(ctx.gql, asUuid(comment)); @@ -227,7 +449,7 @@ export function setupCommentsCommands(program: Command): void { ); comments - .command("react <comment> [emoji]") + .command("react [comment] [emoji]") .description( "DEPRECATED compatibility command. Prefer: `issues threads react <thread>` or `issues replies react <reply>`.", ) @@ -238,13 +460,25 @@ export function setupCommentsCommands(program: Command): void { .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( handleCommand(async (...args: unknown[]) => { - const [comment, emoji, options, command] = args as [ - string, + const [commentArg, emojiArg, options, command] = args as [ + string | undefined, string | undefined, ReactionOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const comment = await resolveCommentPositional( + ctx, + command, + "comment", + commentArg, + ); + const emoji = await resolveEmojiPositional( + ctx, + command, + emojiArg, + options.shortcode, + ); const result = await createIssueDiscussionCommentReaction(ctx.gql, { commentId: asUuid(comment), @@ -256,7 +490,7 @@ export function setupCommentsCommands(program: Command): void { ); comments - .command("unreact <comment> [emoji]") + .command("unreact [comment] [emoji]") .description( "DEPRECATED compatibility command. Prefer: `issues threads unreact <thread>` or `issues replies unreact <reply>`.", ) @@ -267,13 +501,25 @@ export function setupCommentsCommands(program: Command): void { .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( handleCommand(async (...args: unknown[]) => { - const [comment, emoji, options, command] = args as [ - string, + const [commentArg, emojiArg, options, command] = args as [ + string | undefined, string | undefined, ReactionOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const comment = await resolveCommentPositional( + ctx, + command, + "comment", + commentArg, + ); + const emoji = await resolveEmojiPositional( + ctx, + command, + emojiArg, + options.shortcode, + ); const result = await deleteIssueDiscussionCommentReactionByEmoji( ctx.gql, @@ -322,3 +568,28 @@ export function setupCommentsCommands(program: Command): void { console.log(formatDomainUsage(comments, COMMENTS_META)); }); } + +/** + * Fill an absent `[issue]` positional via the shared issue picker when gating + * allows, else preserve the old missing-argument error. + */ +async function resolveIssuePositional( + ctx: CommandContext, + command: Command, + issue: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: issue === undefined, + positional: { name: "issue", value: issue, picker: issuePicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("issue", "is required"); + } + return filled.positional; +} diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index 7efbd0f7..9a6cfad6 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -1,14 +1,23 @@ import type { Command } from "commander"; +import type { CommandContext } from "../common/context.js"; import { type CommandOptions, createContext, getRootOpts, } from "../common/context.js"; import { + InteractiveCancelledError, invalidParameterError, notFoundError, requiresParameterError, } from "../common/errors.js"; +import { + cycleChoices, + teamChoices, + withNoneChoice, +} from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -29,6 +38,65 @@ interface CycleReadOptions extends CommandOptions { limit?: string; } +/** List-wizard shape: offers a team select to fill `--team` when interactive. */ +interface CycleListWizardOptions extends Record<string, unknown> { + team?: string; +} + +/** + * Interactive spec for `cycles list`. Cycles are team-scoped, so offering a team + * select lets an interactive user narrow the listing. The team choice value is a + * UUID (see choices.ts) which the resolver passes through via `isUuid(...)`. + */ +export const cycleListSpec: PromptSpec<CycleListWizardOptions> = { + intro: "List cycles", + fields: [ + { + name: "team", + kind: "select", + message: "Team", + choices: async (ctx) => + withNoneChoice(await teamChoices(ctx), "— all teams —"), + }, + ], +}; + +/** + * Entity picker for an absent `[cycle]` positional. Cycles are team-scoped, so + * this first resolves/prompts the parent team (via `--team` or a team select), + * then loads that team's cycles via `cycleChoices({ team })`. This is the + * cross-field-dependency case for the cycles domain: the cycle list is only + * fetched once the parent team UUID is known. + * + * Returns the selected cycle UUID (which the resolver accepts). + */ +function makeCyclePicker( + teamHint: string | undefined, +): (ctx: CommandContext, io: PromptIO) => Promise<string> { + return async (ctx, io) => { + let teamId = teamHint; + if (teamId === undefined) { + const teamAnswer = await io.select({ + message: "Team", + options: await teamChoices(ctx), + }); + if (io.isCancel(teamAnswer)) { + throw new InteractiveCancelledError(); + } + teamId = teamAnswer as string; + } else { + teamId = await resolveTeamId(ctx.gql, teamId); + } + + const options = await cycleChoices(ctx, { team: teamId }); + const answer = await io.select({ message: "Cycle", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + }; +} + export const CYCLES_META: DomainMeta = { name: "cycles", summary: "time-boxed iterations (sprints) per team", @@ -70,9 +138,24 @@ export function setupCyclesCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); + // Offer a team select when interactive to narrow the listing. `--window` + // already requires a team, so only prompt when it was not requested. + const filled = options.window + ? { options } + : await maybeCollectInteractive<CycleListWizardOptions, never>( + ctx, + getRootOpts(command), + { + spec: cycleListSpec, + options: { ...options } as CycleListWizardOptions, + missingRequired: false, + }, + ); + const listOptions = filled.options as CycleListOptions; + // Resolve team filter if provided - const teamId = options.team - ? await resolveTeamId(ctx.gql, options.team) + const teamId = listOptions.team + ? await resolveTeamId(ctx.gql, listOptions.team) : undefined; // Fetch cycles @@ -117,19 +200,37 @@ export function setupCyclesCommands(program: Command): void { ); cycles - .command("read <cycle>") + .command("read [cycle]") .description("get cycle details including issues") .option("--team <team>", "scope name lookup to team") .option("--limit <n>", "max issues to fetch", "50") .action( handleCommand(async (...args: unknown[]) => { - const [cycle, options, command] = args as [ - string, + const [cycleArg, options, command] = args as [ + string | undefined, CycleReadOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + Record<string, never>, + string + >(ctx, getRootOpts(command), { + spec: { fields: [] }, + options: {}, + missingRequired: cycleArg === undefined, + positional: { + name: "cycle", + value: cycleArg, + picker: makeCyclePicker(options.team), + }, + }); + if (filled.positional === undefined) { + throw invalidParameterError("cycle", "is required"); + } + const cycle = filled.positional; + const cycleId = await resolveCycleId(ctx.gql, cycle, options.team); const cycleResult = await getCycle( diff --git a/src/commands/documents.ts b/src/commands/documents.ts index e9392eba..e11e3e84 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,7 +1,21 @@ import type { Command } from "commander"; -import { createContext, getRootOpts } from "../common/context.js"; -import { invalidParameterError } from "../common/errors.js"; +import { + type CommandContext, + createContext, + getRootOpts, +} from "../common/context.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; import { asUuid, type UUID } from "../common/identifier.js"; +import { + documentChoices, + projectChoices, + teamChoices, +} from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -76,12 +90,117 @@ function extractDocumentIdFromUrl(url: string): string | null { } } +/** Create-wizard shape: the create options plus the `title` positional. */ +type DocumentCreateWizardOptions = Partial<DocumentCreateOptions> & + Record<string, unknown>; + +/** Update-wizard shape: the update options with an index signature. */ +type DocumentUpdateWizardOptions = DocumentUpdateOptions & + Record<string, unknown>; + +/** + * Interactive wizard for `documents create`. `--title` is a required text + * field; project/team choice values are UUIDs (see choices.ts), which the + * `resolveProjectId`/`resolveTeamId` resolvers pass through unchanged. + */ +export const documentCreateSpec: PromptSpec<DocumentCreateWizardOptions> = { + intro: "Create a new document", + fields: [ + { name: "title", kind: "text", message: "Title", required: true }, + { name: "content", kind: "multiline", message: "Content (markdown)" }, + { + name: "project", + kind: "select", + message: "Project", + choices: projectChoices, + }, + { name: "team", kind: "select", message: "Team", choices: teamChoices }, + { name: "icon", kind: "text", message: "Icon" }, + { name: "color", kind: "text", message: "Icon color" }, + ], +}; + +/** + * Interactive wizard for `documents update`. All fields optional; current + * option values seed each field and prevent re-prompting for provided flags. + */ +export const documentUpdateSpec: PromptSpec<DocumentUpdateWizardOptions> = { + intro: "Update a document", + fields: [ + { name: "title", kind: "text", message: "Title", default: (d) => d.title }, + { + name: "content", + kind: "multiline", + message: "Content (markdown)", + default: (d) => d.content, + }, + { + name: "project", + kind: "select", + message: "Project", + choices: projectChoices, + }, + { name: "icon", kind: "text", message: "Icon", default: (d) => d.icon }, + { + name: "color", + kind: "text", + message: "Icon color", + default: (d) => d.color, + }, + ], +}; + +/** + * Entity picker for an absent `[document]` positional. Lists recent documents + * and returns the selected document's UUID (which `asUuid` accepts). + */ +async function documentPicker( + ctx: CommandContext, + io: PromptIO, +): Promise<string> { + const options = await documentChoices(ctx); + const answer = await io.select({ message: "Document", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; + +/** + * Fill an absent `[document]` positional via {@link documentPicker} when gating + * allows, else preserve the old missing-argument error. + */ +async function resolveDocumentPositional( + ctx: CommandContext, + command: Command, + document: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: document === undefined, + positional: { name: "document", value: document, picker: documentPicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("document", "is required"); + } + return filled.positional; +} + export const DOCUMENTS_META: DomainMeta = { name: "documents", summary: "long-form markdown docs attached to projects or issues", context: [ "a document is a markdown page. it can belong to a project and/or be", "attached to an issue. documents support icons and colors.", + "in a terminal, run with -i (or omit a required arg) to pick the", + "document and enter fields interactively.", ].join("\n"), arguments: { document: "document identifier (UUID)", @@ -159,13 +278,22 @@ export function setupDocumentsCommands(program: Command): void { ); documents - .command("read <document>") + .command("read [document]") .description("get document content") .action( handleCommand(async (...args: unknown[]) => { - const [document, , command] = args as [string, unknown, Command]; + const [documentArg, , command] = args as [ + string | undefined, + unknown, + Command, + ]; const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); + const document = await resolveDocumentPositional( + ctx, + command, + documentArg, + ); const documentResult = await getDocument(ctx.gql, asUuid(document)); outputSuccess(documentResult); @@ -175,7 +303,7 @@ export function setupDocumentsCommands(program: Command): void { documents .command("create") .description("create a new document") - .requiredOption("--title <title>", "document title (required)") + .option("--title <title>", "document title (required)") .option("--content <text>", "document content (markdown)") .option("--project <project>", "project name or ID") .option("--team <team>", "team key or name") @@ -185,7 +313,27 @@ export function setupDocumentsCommands(program: Command): void { .option("--attach-to <issue>", "alias for --issue") .action( handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [DocumentCreateOptions, Command]; + const [rawOptions, command] = args as [ + Partial<DocumentCreateOptions>, + Command, + ]; + const rootOpts = getRootOpts(command); + const ctx = createContext(rootOpts); + + const filled = await maybeCollectInteractive< + DocumentCreateWizardOptions, + never + >(ctx, rootOpts, { + spec: documentCreateSpec, + options: rawOptions as DocumentCreateWizardOptions, + missingRequired: rawOptions.title === undefined, + }); + const options = filled.options as DocumentCreateOptions; + + if (options.title === undefined) { + throw invalidParameterError("--title", "is required"); + } + if (options.issue && options.attachTo) { throw invalidParameterError( "--attach-to", @@ -194,8 +342,6 @@ export function setupDocumentsCommands(program: Command): void { } const issueIdentifier = options.issue ?? options.attachTo; - const rootOpts = getRootOpts(command); - const ctx = createContext(rootOpts); const projectId = options.project ? await resolveProjectId(ctx.gql, options.project) @@ -222,7 +368,7 @@ export function setupDocumentsCommands(program: Command): void { ); documents - .command("update <document>") + .command("update [document]") .description("update an existing document") .option("--title <title>", "new title") .option("--content <text>", "new content (markdown)") @@ -231,14 +377,33 @@ export function setupDocumentsCommands(program: Command): void { .option("--color <color>", "new icon color") .action( handleCommand(async (...args: unknown[]) => { - const [document, options, command] = args as [ - string, + const [documentArg, rawOptions, command] = args as [ + string | undefined, DocumentUpdateOptions, Command, ]; const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); + const filled = await maybeCollectInteractive< + DocumentUpdateWizardOptions, + string + >(ctx, rootOpts, { + spec: documentUpdateSpec, + options: rawOptions as DocumentUpdateWizardOptions, + missingRequired: documentArg === undefined, + positional: { + name: "document", + value: documentArg, + picker: documentPicker, + }, + }); + if (filled.positional === undefined) { + throw invalidParameterError("document", "is required"); + } + const document = filled.positional; + const options = filled.options as DocumentUpdateOptions; + const input: UpdateDocumentInput = {}; if (options.title) input.title = options.title; if (options.content) input.content = options.content; @@ -258,13 +423,22 @@ export function setupDocumentsCommands(program: Command): void { ); documents - .command("delete <document>") + .command("delete [document]") .description("trash a document") .action( handleCommand(async (...args: unknown[]) => { - const [document, , command] = args as [string, unknown, Command]; + const [documentArg, , command] = args as [ + string | undefined, + unknown, + Command, + ]; const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); + const document = await resolveDocumentPositional( + ctx, + command, + documentArg, + ); const result = await deleteDocument(ctx.gql, asUuid(document)); outputSuccess(result); diff --git a/src/commands/files.ts b/src/commands/files.ts index 261f0bfa..90b97796 100644 --- a/src/commands/files.ts +++ b/src/commands/files.ts @@ -1,11 +1,68 @@ import type { Command } from "commander"; import { type CommandOptions, getApiToken } from "../common/auth.js"; -import { getRootOpts } from "../common/context.js"; +import { + type CommandContext, + createContext, + getRootOpts, +} from "../common/context.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { FileService } from "../services/file-service.js"; +const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; + +/** + * A text picker for a free-form positional (local file path or storage URL). + * There is no entity list to enumerate, so — unlike the entity pickers in other + * domains — this simply prompts for the value with a `text` field when gating + * passes, preserving the old missing-argument error otherwise. + */ +function makeTextPicker( + message: string, +): (ctx: CommandContext, io: PromptIO) => Promise<string> { + return async (_ctx, io) => { + const answer = await io.text({ message }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + }; +} + +/** + * Fill an absent free-form positional via a text prompt when gating allows, + * else preserve the old missing-argument error. + */ +async function resolveTextPositional( + command: Command, + name: string, + value: string | undefined, + message: string, +): Promise<string> { + const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: value === undefined, + positional: { name, value, picker: makeTextPicker(message) }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError(name, "is required"); + } + return filled.positional; +} + export const FILES_META: DomainMeta = { name: "files", summary: "upload/download file attachments", @@ -28,17 +85,23 @@ export function setupFilesCommands(program: Command): void { files.action(() => files.help()); files - .command("download <url>") + .command("download [url]") .description("download a file from Linear storage") .option("--output <path>", "output file path") .option("--overwrite", "overwrite existing file", false) .action( handleCommand(async (...args: unknown[]) => { - const [url, options, command] = args as [ - string, + const [urlArg, options, command] = args as [ + string | undefined, CommandOptions & { output?: string; overwrite?: boolean }, Command, ]; + const url = await resolveTextPositional( + command, + "url", + urlArg, + "Linear storage URL", + ); const apiToken = getApiToken(getRootOpts(command)); const fileService = new FileService(apiToken); const result = await fileService.downloadFile( @@ -61,11 +124,21 @@ export function setupFilesCommands(program: Command): void { ); files - .command("upload <file>") + .command("upload [file]") .description("upload a file to Linear storage") .action( handleCommand(async (...args: unknown[]) => { - const [filePath, , command] = args as [string, CommandOptions, Command]; + const [fileArg, , command] = args as [ + string | undefined, + CommandOptions, + Command, + ]; + const filePath = await resolveTextPositional( + command, + "file", + fileArg, + "Local file path", + ); const apiToken = getApiToken(getRootOpts(command)); const fileService = new FileService(apiToken); const result = await fileService.uploadFile(filePath); diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 8466ef57..d2dfa93b 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -1,9 +1,23 @@ import type { Command } from "commander"; import type { GraphQLClient } from "../../client/graphql-client.js"; +import type { CommandContext } from "../../common/context.js"; import { createContext, getRootOpts } from "../../common/context.js"; import { resolveReactionEmojiInput } from "../../common/emoji.js"; -import { invalidParameterError } from "../../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../../common/errors.js"; import { asUuid } from "../../common/identifier.js"; +import { + initiativeChoices, + userChoices, +} from "../../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../../common/interactive/engine.js"; +import type { + Choice, + PromptIO, + PromptSpec, +} from "../../common/interactive/types.js"; import { omitUndefined } from "../../common/object.js"; import { commandAction, @@ -190,6 +204,145 @@ interface InitiativeUpdateOptions { sortOrder?: string; } +/** Create-wizard shape: create options plus the `name` positional. */ +interface InitiativeCreateWizardOptions + extends InitiativeCreateOptions, + Record<string, unknown> { + name?: string; +} + +/** Update-wizard shape: update options with an index signature. */ +type InitiativeUpdateWizardOptions = InitiativeUpdateOptions & + Record<string, unknown>; + +/** Static initiative status scale. */ +function initiativeStatusChoices(): Choice[] { + return [ + { value: "Planned", label: "Planned" }, + { value: "Active", label: "Active" }, + { value: "Completed", label: "Completed" }, + ]; +} + +/** + * Interactive wizard for `initiatives create`. Entity choice values are UUIDs + * (see choices.ts); the resolvers pass those through unchanged via + * `isUuid(...)`. + */ +export const initiativeCreateSpec: PromptSpec<InitiativeCreateWizardOptions> = { + intro: "Create a new initiative", + fields: [ + { name: "name", kind: "text", message: "Name", required: true }, + { name: "description", kind: "multiline", message: "Description" }, + { name: "content", kind: "multiline", message: "Content (markdown)" }, + { + name: "owner", + kind: "select", + message: "Owner", + choices: userChoices, + }, + { + name: "status", + kind: "select", + message: "Status", + choices: async () => initiativeStatusChoices(), + }, + { name: "targetDate", kind: "text", message: "Target date (YYYY-MM-DD)" }, + ], +}; + +/** + * Interactive wizard for `initiatives update`. All fields optional; the current + * option values seed each field. + */ +export const initiativeUpdateSpec: PromptSpec<InitiativeUpdateWizardOptions> = { + intro: "Update an initiative", + fields: [ + { + name: "name", + kind: "text", + message: "Name", + default: (draft) => draft.name, + }, + { + name: "description", + kind: "multiline", + message: "Description", + default: (draft) => draft.description, + }, + { + name: "content", + kind: "multiline", + message: "Content (markdown)", + default: (draft) => draft.content, + }, + { + name: "owner", + kind: "select", + message: "Owner", + choices: userChoices, + }, + { + name: "status", + kind: "select", + message: "Status", + choices: async () => initiativeStatusChoices(), + }, + { + name: "targetDate", + kind: "text", + message: "Target date (YYYY-MM-DD)", + default: (draft) => draft.targetDate, + }, + ], +}; + +/** + * Entity picker for an absent `[initiative]` positional. Lists recent + * initiatives and returns the selected initiative's UUID (which the resolver + * accepts). + */ +async function initiativePicker( + ctx: CommandContext, + io: PromptIO, +): Promise<string> { + const options = await initiativeChoices(ctx); + const answer = await io.select({ message: "Initiative", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +/** + * Fill an absent `[initiative]` positional via the picker when gating allows, + * else require it (preserving the old missing-argument error for agents/pipes). + */ +async function resolveInitiativePositional( + ctx: CommandContext, + command: Command, + initiative: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: { fields: [] }, + options: {}, + missingRequired: initiative === undefined, + positional: { + name: "initiative", + value: initiative, + picker: initiativePicker, + }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("initiative", "is required"); + } + return filled.positional; +} + function parseSortOrder(value?: string): "asc" | "desc" | undefined { if (!value) return undefined; const normalized = value.toLowerCase(); @@ -390,7 +543,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("read <initiative>") + .command("read [initiative]") .description("get initiative details") .option("--with-projects", "include linked projects in read output") .option( @@ -406,9 +559,14 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--with-history", "include history in read output") .option("--with-documents", "include documents in read output") .action( - commandAction<[string, InitiativeReadOptions, Command]>( - async (initiative, options, command) => { + commandAction<[string | undefined, InitiativeReadOptions, Command]>( + async (initiativeArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const initiative = await resolveInitiativePositional( + ctx, + command, + initiativeArg, + ); const initiativeId = await resolveInitiativeId(ctx.gql, initiative); // Read query already returns expanded fields. Keep flags accepted for @@ -422,18 +580,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("discuss <initiative>") + .command("discuss [initiative]") .description("start a discussion thread on an initiative") .option("--body <text>", "discussion body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( - async (initiative, options, command) => { + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( + async (initiativeArg, options, command) => { const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); } + const initiative = await resolveInitiativePositional( + ctx, + command, + initiativeArg, + ); const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await startInitiativeDiscussion(ctx.gql, { initiativeId, @@ -446,16 +609,21 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("discussions <initiative>") + .command("discussions [initiative]") .description("list root discussion threads on an initiative") .option("-l, --limit <n>", "max results", "25") .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - commandAction<[string, DiscussionsOptions, Command]>( - async (initiative, options, command) => { + commandAction<[string | undefined, DiscussionsOptions, Command]>( + async (initiativeArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const initiative = await resolveInitiativePositional( + ctx, + command, + initiativeArg, + ); const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "25"), @@ -680,7 +848,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("create <name>") + .command("create [name]") .description("create a new initiative") .option("--description <text>", "initiative description") .option("--content <text>", "initiative content (markdown)") @@ -689,10 +857,27 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--target-date <date>", "target date (YYYY-MM-DD)") .option("--sort-order <n>", "display sort order") .action( - commandAction<[string, InitiativeCreateOptions, Command]>( - async (name, options, command) => { + commandAction<[string | undefined, InitiativeCreateOptions, Command]>( + async (nameArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + InitiativeCreateWizardOptions, + never + >(ctx, getRootOpts(command), { + spec: initiativeCreateSpec, + options: { + ...options, + ...(nameArg !== undefined ? { name: nameArg } : {}), + } as InitiativeCreateWizardOptions, + missingRequired: nameArg === undefined, + }); + const name = (filled.options.name as string | undefined) ?? nameArg; + if (name === undefined) { + throw invalidParameterError("name", "is required"); + } + options = filled.options as InitiativeCreateOptions; + const input: CreateInitiativeInput = { name }; if (options.description !== undefined) { @@ -728,7 +913,7 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("update <initiative>") + .command("update [initiative]") .description("update an initiative") .option("--name <name>", "new name") .option("--description <text>", "new description") @@ -738,9 +923,28 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { .option("--target-date <date>", "new target date (YYYY-MM-DD)") .option("--sort-order <n>", "new display sort order") .action( - commandAction<[string, InitiativeUpdateOptions, Command]>( - async (initiative, options, command) => { + commandAction<[string | undefined, InitiativeUpdateOptions, Command]>( + async (initiativeArg, options, command) => { const ctx = createContext(getRootOpts(command)); + + const filled = await maybeCollectInteractive< + InitiativeUpdateWizardOptions, + string + >(ctx, getRootOpts(command), { + spec: initiativeUpdateSpec, + options: options as InitiativeUpdateWizardOptions, + missingRequired: initiativeArg === undefined, + positional: { + name: "initiative", + value: initiativeArg, + picker: initiativePicker, + }, + }); + options = filled.options as InitiativeUpdateOptions; + if (filled.positional === undefined) { + throw invalidParameterError("initiative", "is required"); + } + const initiative = filled.positional; const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const input: UpdateInitiativeInput = {}; @@ -789,12 +993,17 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("archive <initiative>") + .command("archive [initiative]") .description("archive an initiative") .action( - commandAction<[string, unknown, Command]>( - async (initiative, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (initiativeArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const initiative = await resolveInitiativePositional( + ctx, + command, + initiativeArg, + ); const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await archiveInitiative(ctx.gql, initiativeId); outputSuccess(result); @@ -803,12 +1012,17 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("unarchive <initiative>") + .command("unarchive [initiative]") .description("unarchive an initiative") .action( - commandAction<[string, unknown, Command]>( - async (initiative, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (initiativeArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const initiative = await resolveInitiativePositional( + ctx, + command, + initiativeArg, + ); const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await unarchiveInitiative(ctx.gql, initiativeId); outputSuccess(result); @@ -817,12 +1031,17 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("delete <initiative>") + .command("delete [initiative]") .description("delete an initiative") .action( - commandAction<[string, unknown, Command]>( - async (initiative, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (initiativeArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const initiative = await resolveInitiativePositional( + ctx, + command, + initiativeArg, + ); const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await deleteInitiative(ctx.gql, initiativeId); outputSuccess(result); diff --git a/src/commands/initiatives/projects.ts b/src/commands/initiatives/projects.ts index 13b35fc8..f817cab2 100644 --- a/src/commands/initiatives/projects.ts +++ b/src/commands/initiatives/projects.ts @@ -1,5 +1,13 @@ import type { Command } from "commander"; +import type { CommandContext } from "../../common/context.js"; import { createContext, getRootOpts } from "../../common/context.js"; +import { InteractiveCancelledError } from "../../common/errors.js"; +import { + initiativeChoices, + projectChoices, +} from "../../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../../common/interactive/engine.js"; +import type { Choice, PromptIO } from "../../common/interactive/types.js"; import { handleCommand, outputSuccess } from "../../common/output.js"; import { resolveInitiativeId, @@ -11,20 +19,74 @@ import { deleteInitiativeProjectLink, } from "../../services/initiative-project-service.js"; +/** Picker for one positional, backed by the supplied choice loader. */ +function makePicker( + label: string, + loader: (ctx: CommandContext) => Promise<Choice[]>, +): (ctx: CommandContext, io: PromptIO) => Promise<string> { + return async (ctx, io) => { + const options = await loader(ctx); + const answer = await io.select({ message: label, options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + }; +} + +/** Fill an absent positional via a picker when gating allows. */ +async function resolvePositional( + ctx: CommandContext, + command: Command, + value: string | undefined, + label: string, + loader: (ctx: CommandContext) => Promise<Choice[]>, +): Promise<string | undefined> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: { fields: [] }, + options: {}, + missingRequired: value === undefined, + positional: { name: label, value, picker: makePicker(label, loader) }, + }, + ); + return filled.positional; +} + export function setupInitiativeProjectCommands(initiatives: Command): void { initiatives - .command("add-project <initiative> <project>") + .command("add-project [initiative] [project]") .description("link a project to an initiative") .action( handleCommand(async (...args: unknown[]) => { - const [initiative, project, , command] = args as [ - string, - string, + const [initiativeArg, projectArg, , command] = args as [ + string | undefined, + string | undefined, unknown, Command, ]; const ctx = createContext(getRootOpts(command)); + const initiative = await resolvePositional( + ctx, + command, + initiativeArg, + "Initiative", + initiativeChoices, + ); + const project = await resolvePositional( + ctx, + command, + projectArg, + "Project", + projectChoices, + ); + if (initiative === undefined || project === undefined) { + throw new Error("both <initiative> and <project> are required"); + } + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const projectId = await resolveProjectId(ctx.gql, project); @@ -38,18 +100,36 @@ export function setupInitiativeProjectCommands(initiatives: Command): void { ); initiatives - .command("remove-project <initiative> <project>") + .command("remove-project [initiative] [project]") .description("unlink a project from an initiative") .action( handleCommand(async (...args: unknown[]) => { - const [initiative, project, , command] = args as [ - string, - string, + const [initiativeArg, projectArg, , command] = args as [ + string | undefined, + string | undefined, unknown, Command, ]; const ctx = createContext(getRootOpts(command)); + const initiative = await resolvePositional( + ctx, + command, + initiativeArg, + "Initiative", + initiativeChoices, + ); + const project = await resolvePositional( + ctx, + command, + projectArg, + "Project", + projectChoices, + ); + if (initiative === undefined || project === undefined) { + throw new Error("both <initiative> and <project> are required"); + } + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const projectId = await resolveProjectId(ctx.gql, project); diff --git a/src/commands/initiatives/relations.ts b/src/commands/initiatives/relations.ts index 9ecaf746..dbc39c68 100644 --- a/src/commands/initiatives/relations.ts +++ b/src/commands/initiatives/relations.ts @@ -1,5 +1,10 @@ import type { Command } from "commander"; +import type { CommandContext } from "../../common/context.js"; import { createContext, getRootOpts } from "../../common/context.js"; +import { InteractiveCancelledError } from "../../common/errors.js"; +import { initiativeChoices } from "../../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../../common/interactive/engine.js"; +import type { PromptIO } from "../../common/interactive/types.js"; import { handleCommand, outputSuccess } from "../../common/output.js"; import { resolveInitiativeId, @@ -10,20 +15,78 @@ import { deleteInitiativeRelation, } from "../../services/initiative-relation-service.js"; +/** Picker for one initiative positional, labelled for its role (parent/child). */ +function makeInitiativePicker( + label: string, +): (ctx: CommandContext, io: PromptIO) => Promise<string> { + return async (ctx, io) => { + const options = await initiativeChoices(ctx); + const answer = await io.select({ message: label, options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + }; +} + +/** + * Fill an absent initiative positional via a labelled picker when gating + * allows, else return the (still-undefined) value so the old required-arg + * behavior is preserved for agents/pipes. + */ +async function resolveRelationPositional( + ctx: CommandContext, + command: Command, + value: string | undefined, + label: string, +): Promise<string | undefined> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: { fields: [] }, + options: {}, + missingRequired: value === undefined, + positional: { + name: label, + value, + picker: makeInitiativePicker(label), + }, + }, + ); + return filled.positional; +} + export function setupInitiativeRelationCommands(initiatives: Command): void { initiatives - .command("relate <parent> <child>") + .command("relate [parent] [child]") .description("create a parent/child initiative relation") .action( handleCommand(async (...args: unknown[]) => { - const [parent, child, , command] = args as [ - string, - string, + const [parentArg, childArg, , command] = args as [ + string | undefined, + string | undefined, unknown, Command, ]; const ctx = createContext(getRootOpts(command)); + const parent = await resolveRelationPositional( + ctx, + command, + parentArg, + "Parent initiative", + ); + const child = await resolveRelationPositional( + ctx, + command, + childArg, + "Child initiative", + ); + if (parent === undefined || child === undefined) { + throw new Error("both <parent> and <child> are required"); + } + const parentId = await resolveInitiativeId(ctx.gql, parent); const childId = await resolveInitiativeId(ctx.gql, child); @@ -37,18 +100,34 @@ export function setupInitiativeRelationCommands(initiatives: Command): void { ); initiatives - .command("unrelate <parent> <child>") + .command("unrelate [parent] [child]") .description("delete a parent/child initiative relation") .action( handleCommand(async (...args: unknown[]) => { - const [parent, child, , command] = args as [ - string, - string, + const [parentArg, childArg, , command] = args as [ + string | undefined, + string | undefined, unknown, Command, ]; const ctx = createContext(getRootOpts(command)); + const parent = await resolveRelationPositional( + ctx, + command, + parentArg, + "Parent initiative", + ); + const child = await resolveRelationPositional( + ctx, + command, + childArg, + "Child initiative", + ); + if (parent === undefined || child === undefined) { + throw new Error("both <parent> and <child> are required"); + } + const parentId = await resolveInitiativeId(ctx.gql, parent); const childId = await resolveInitiativeId(ctx.gql, child); diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index ddc4159c..41ac99ce 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -1,7 +1,14 @@ import type { Command } from "commander"; +import type { CommandContext } from "../../common/context.js"; import { createContext, getRootOpts } from "../../common/context.js"; -import { invalidParameterError } from "../../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../../common/errors.js"; import { asUuid } from "../../common/identifier.js"; +import { initiativeChoices } from "../../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../../common/interactive/engine.js"; +import type { PromptIO } from "../../common/interactive/types.js"; import { handleCommand, outputSuccess, @@ -21,15 +28,105 @@ import { updateInitiativeUpdate, } from "../../services/initiative-update-service.js"; +/** + * Fill an absent `--initiative` value via the initiative picker when gating + * allows, else return the (still-undefined) value so the required-option check + * fires for agents/pipes. + */ +async function resolveInitiativeOption( + ctx: CommandContext, + command: Command, + value: string | undefined, +): Promise<string | undefined> { + const filled = await maybeCollectInteractive< + { initiative?: string } & Record<string, unknown>, + never + >(ctx, getRootOpts(command), { + spec: { + fields: [ + { + name: "initiative", + kind: "select", + message: "Initiative", + required: true, + choices: initiativeChoices, + }, + ], + }, + options: value !== undefined ? { initiative: value } : {}, + missingRequired: value === undefined, + }); + return filled.options.initiative; +} + +/** + * Entity picker for an absent `[update]` positional. Updates are initiative- + * scoped, so this first prompts for an initiative, then lists that initiative's + * updates (cross-field dependency). Returns the selected update UUID. + */ +async function updatePicker( + ctx: CommandContext, + io: PromptIO, +): Promise<string> { + const initiativeAnswer = await io.select({ + message: "Initiative", + options: await initiativeChoices(ctx), + }); + if (io.isCancel(initiativeAnswer)) { + throw new InteractiveCancelledError(); + } + const initiativeId = asUuid(initiativeAnswer as string); + + const { nodes } = await listInitiativeUpdates(ctx.gql, { + initiativeId, + limit: 50, + }); + const options = nodes.map((update) => ({ + value: update.id, + label: (update.body ?? "").slice(0, 60) || update.id, + ...(update.health ? { hint: String(update.health) } : {}), + })); + const answer = await io.select({ message: "Update", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +/** + * Fill an absent `[update]` positional via the update picker when gating + * allows, else require it (preserving the old missing-argument error). + */ +async function resolveUpdatePositional( + ctx: CommandContext, + command: Command, + update: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: { fields: [] }, + options: {}, + missingRequired: update === undefined, + positional: { name: "update", value: update, picker: updatePicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("update", "is required"); + } + return filled.positional; +} + interface InitiativeUpdatesListOptions { - initiative: string; + initiative?: string; limit: string; after?: string; includeArchived?: boolean; } interface InitiativeUpdatesCreateOptions { - initiative: string; + initiative?: string; body?: string; health?: string; } @@ -49,7 +146,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { updates .command("list") .description("list initiative updates") - .requiredOption("--initiative <initiative>", "initiative name or UUID") + .option("--initiative <initiative>", "initiative name or UUID (required)") .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page") .option("--include-archived", "include archived updates") @@ -61,10 +158,15 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId( - ctx.gql, + const initiative = await resolveInitiativeOption( + ctx, + command, options.initiative, ); + if (initiative === undefined) { + throw invalidParameterError("--initiative", "is required"); + } + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await listInitiativeUpdates(ctx.gql, { initiativeId, @@ -77,12 +179,17 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ); updates - .command("read <update>") + .command("read [update]") .description("get initiative update details") .action( handleCommand(async (...args: unknown[]) => { - const [updateId, , command] = args as [string, unknown, Command]; + const [updateArg, , command] = args as [ + string | undefined, + unknown, + Command, + ]; const ctx = createContext(getRootOpts(command)); + const updateId = await resolveUpdatePositional(ctx, command, updateArg); const result = await getInitiativeUpdate(ctx.gql, asUuid(updateId)); outputSuccess(result); }), @@ -91,7 +198,7 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { updates .command("create") .description("create an initiative update") - .requiredOption("--initiative <initiative>", "initiative name or UUID") + .option("--initiative <initiative>", "initiative name or UUID (required)") .option("--body <text>", "update body (markdown)") .option("--health <health>", "onTrack, atRisk, offTrack") .action( @@ -102,10 +209,15 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId( - ctx.gql, + const initiative = await resolveInitiativeOption( + ctx, + command, options.initiative, ); + if (initiative === undefined) { + throw invalidParameterError("--initiative", "is required"); + } + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const input: CreateInitiativeUpdateInput = { initiativeId }; @@ -124,18 +236,19 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ); updates - .command("update <update>") + .command("update [update]") .description("update an initiative update") .option("--body <text>", "new body (markdown)") .option("--health <health>", "onTrack, atRisk, offTrack") .action( handleCommand(async (...args: unknown[]) => { - const [updateId, options, command] = args as [ - string, + const [updateArg, options, command] = args as [ + string | undefined, InitiativeUpdatesUpdateOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const updateId = await resolveUpdatePositional(ctx, command, updateArg); const input: UpdateInitiativeUpdateInput = {}; @@ -165,24 +278,34 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ); updates - .command("archive <update>") + .command("archive [update]") .description("archive an initiative update") .action( handleCommand(async (...args: unknown[]) => { - const [updateId, , command] = args as [string, unknown, Command]; + const [updateArg, , command] = args as [ + string | undefined, + unknown, + Command, + ]; const ctx = createContext(getRootOpts(command)); + const updateId = await resolveUpdatePositional(ctx, command, updateArg); const result = await archiveInitiativeUpdate(ctx.gql, asUuid(updateId)); outputSuccess(result); }), ); updates - .command("unarchive <update>") + .command("unarchive [update]") .description("unarchive an initiative update") .action( handleCommand(async (...args: unknown[]) => { - const [updateId, , command] = args as [string, unknown, Command]; + const [updateArg, , command] = args as [ + string | undefined, + unknown, + Command, + ]; const ctx = createContext(getRootOpts(command)); + const updateId = await resolveUpdatePositional(ctx, command, updateArg); const result = await unarchiveInitiativeUpdate( ctx.gql, asUuid(updateId), diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 9e37b872..2284e357 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -4,7 +4,10 @@ import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; import { parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; -import { invalidParameterError } from "../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; import { asUuid, @@ -13,6 +16,22 @@ import { parseIssueIdentifier, type UUID, } from "../common/identifier.js"; +import { + assigneeChoices, + cycleChoices, + emojiChoices, + estimateChoices, + issueChoices, + labelChoices, + milestoneChoices, + optionalProjectChoices, + priorityChoices, + statusChoices, + teamChoices, +} from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import { shouldPrompt } from "../common/interactive/gating.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import type { RawFilterFlags } from "../common/issue-filter.js"; import { parseEstimateOption, @@ -141,6 +160,326 @@ interface UpdateOptions { removeRelation?: string; } +/** Create-wizard shape: the options interface plus the `title` positional. */ +interface CreateWizardOptions extends Record<string, unknown> { + description?: string; + assignee?: string; + priority?: string; + estimate?: string; + project?: string; + team?: string; + labels?: string; + projectMilestone?: string; + cycle?: string; + status?: string; + parentTicket?: string; + dueDate?: string; + title?: string; +} + +/** + * Update-wizard shape: the update options plus a synthetic `team` the command + * seeds from the resolved issue (never a CLI flag) so team-scoped pickers work. + */ +type UpdateWizardOptions = UpdateOptions & { + team?: string; +} & Record<string, unknown>; + +function validateDueDate(value: string): string | undefined { + if (!value) return undefined; + try { + parseDueDate(value); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : "invalid date"; + } +} + +/** + * Interactive wizard for `issues create`. Fields are ordered so cross-field + * deps resolve (team before cycle/status; project before milestone). Entity + * choice values are UUIDs (see choices.ts); the resolvers pass those through + * unchanged. + */ +export const issueCreateSpec: PromptSpec<CreateWizardOptions> = { + intro: "Create a new issue", + fields: [ + { + name: "team", + kind: "select", + message: "Team", + required: true, + searchable: true, + choices: teamChoices, + }, + { name: "title", kind: "text", message: "Title", required: true }, + { name: "description", kind: "multiline", message: "Description" }, + { + name: "assignee", + kind: "select", + message: "Assignee", + searchable: true, + choices: assigneeChoices, + }, + { + name: "priority", + kind: "select", + message: "Priority", + choices: async () => priorityChoices(), + }, + { + name: "project", + kind: "select", + message: "Project", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: optionalProjectChoices, + }, + { + name: "projectMilestone", + kind: "select", + message: "Milestone", + searchable: true, + when: (draft) => draft.project !== undefined, + choices: milestoneChoices, + }, + { + name: "cycle", + kind: "select", + message: "Cycle", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: cycleChoices, + }, + { + name: "status", + kind: "select", + message: "Status", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: statusChoices, + }, + { + name: "labels", + kind: "multiselect", + message: "Labels", + required: false, + searchable: true, + choices: labelChoices, + }, + { + name: "estimate", + kind: "select", + message: "Estimate", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: estimateChoices, + }, + { + name: "dueDate", + kind: "text", + message: "Due date (YYYY-MM-DD)", + validate: validateDueDate, + }, + ], +}; + +/** + * Interactive wizard for `issues update`. Mirrors {@link issueCreateSpec} so + * the update prompts behave identically: the selected issue's `team` UUID is + * seeded into the draft by the command (see the `update` action) before this + * runs, which is what lets the team-scoped pickers (project, milestone, cycle, + * status, estimate) work exactly as they do on create. All fields are optional + * — a field left unset means "leave unchanged". + */ +export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { + intro: "Update an issue", + fields: [ + { + name: "title", + kind: "text", + message: "Title", + default: (draft) => draft.title, + }, + { + name: "description", + kind: "multiline", + message: "Description", + default: (draft) => draft.description, + }, + { + name: "assignee", + kind: "select", + message: "Assignee", + searchable: true, + choices: assigneeChoices, + }, + { + name: "priority", + kind: "select", + message: "Priority", + choices: async () => priorityChoices(), + }, + { + name: "project", + kind: "select", + message: "Project", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: optionalProjectChoices, + }, + { + name: "projectMilestone", + kind: "select", + message: "Milestone", + searchable: true, + when: (draft) => draft.project !== undefined, + choices: milestoneChoices, + }, + { + name: "cycle", + kind: "select", + message: "Cycle", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: cycleChoices, + }, + { + name: "status", + kind: "select", + message: "Status", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: statusChoices, + }, + { + name: "labels", + kind: "multiselect", + message: "Labels", + required: false, + searchable: true, + choices: labelChoices, + }, + { + name: "estimate", + kind: "select", + message: "Estimate", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: estimateChoices, + }, + { + name: "dueDate", + kind: "text", + message: "Due date (YYYY-MM-DD)", + validate: validateDueDate, + default: (draft) => draft.dueDate, + }, + ], +}; + +/** + * The `labels` multiselect yields a `string[]` of UUIDs, but the command body + * expects the CLI-shaped comma-separated `string`. Normalise it in place so the + * command body below the wizard call stays unchanged. + */ +function normalizeWizardLabels<O extends { labels?: unknown }>(filled: O): O { + const normalized = { ...filled }; + if (Array.isArray(normalized.labels)) { + const joined = normalized.labels.join(","); + if (joined.length > 0) { + (normalized as { labels?: string }).labels = joined; + } else { + delete (normalized as { labels?: string }).labels; + } + } + return normalized; +} + +/** + * Entity picker for an absent `<issue>` positional. Lists recent open issues + * and returns the selected issue's identifier (which the resolver accepts). + */ +async function issuePicker(ctx: CommandContext, io: PromptIO): Promise<string> { + const options = await issueChoices(ctx); + const answer = await io.select({ message: "Issue", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +/** + * Emoji picker for an absent `[emoji]` positional. Returns the emoji glyph, + * which flows into `resolveReactionEmojiInput` unchanged as the positional. + */ +async function emojiPicker( + _ctx: CommandContext, + io: PromptIO, +): Promise<string> { + const answer = await io.select({ + message: "Reaction", + options: emojiChoices(), + }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; + +/** + * Fill an absent `<issue>` positional via the issue picker when gating allows, + * else require it (preserving the old missing-argument error for + * agents/pipes). The command body downstream is unchanged. + */ +async function resolveIssuePositional( + ctx: CommandContext, + command: Command, + issue: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: issue === undefined, + positional: { name: "issue", value: issue, picker: issuePicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("issue", "is required"); + } + return filled.positional; +} + +/** + * Fill an absent `[emoji]` positional via the emoji picker when gating allows. + * Returns the (possibly still-undefined) emoji so the existing + * `resolveReactionEmojiInput` keeps ownership of the emoji-or-shortcode + * validation for the non-interactive path. + */ +async function resolveEmojiPositional( + ctx: CommandContext, + command: Command, + emoji: string | undefined, + shortcode: string | undefined, +): Promise<string | undefined> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: emoji === undefined && shortcode === undefined, + positional: { name: "emoji", value: emoji, picker: emojiPicker }, + }, + ); + return filled.positional; +} + interface ReadOptions { withAttachments?: boolean; withComments?: boolean; @@ -533,12 +872,13 @@ export function setupIssuesCommands(program: Command): void { relations.action(() => relations.help()); relations - .command("list <issue>") + .command("list [issue]") .description("list relations for an issue") .action( - commandAction<[string, unknown, Command]>( - async (issue, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (issueArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); const issueId = await resolveIssueId(ctx.gql, issue); const result = await listIssueRelations(ctx.gql, issueId); @@ -661,7 +1001,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("read <issue>") + .command("read [issue]") .description("get full issue details including description") .option("--with-attachments", "include issue attachments") .option("--with-comments", "include full issue comments") @@ -675,10 +1015,11 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - commandAction<[string, ReadOptions, Command]>( - async (issue, options, command) => { + commandAction<[string | undefined, ReadOptions, Command]>( + async (issueArg, options, command) => { validateReadOptions(options); const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); if (options.withAttachments) { if (isUuid(issue)) { @@ -770,8 +1111,14 @@ export function setupIssuesCommands(program: Command): void { ) .action( commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (issue, emoji, options, command) => { + async (issue, emojiArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const emoji = await resolveEmojiPositional( + ctx, + command, + emojiArg, + options.shortcode, + ); const issueId = await resolveIssueId(ctx.gql, issue); const result = await createReactionForIssue(ctx.gql, { issueId, @@ -793,8 +1140,14 @@ export function setupIssuesCommands(program: Command): void { ) .action( commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (issue, emoji, options, command) => { + async (issue, emojiArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const emoji = await resolveEmojiPositional( + ctx, + command, + emojiArg, + options.shortcode, + ); const issueId = await resolveIssueId(ctx.gql, issue); const result = await deleteOwnReactionByEmoji(ctx.gql, { kind: "issue", @@ -831,7 +1184,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("discuss <issue>") + .command("discuss [issue]") .description("start a discussion thread on an issue") .addHelpText( "after", @@ -839,14 +1192,15 @@ export function setupIssuesCommands(program: Command): void { ) .option("--body <text>", "discussion body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( - async (issue, options, command) => { + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( + async (issueArg, options, command) => { const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); } + const issue = await resolveIssuePositional(ctx, command, issueArg); const issueId = await resolveIssueId(ctx.gql, issue); const result = await startIssueDiscussion(ctx.gql, { issueId, @@ -893,7 +1247,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("discussions <issue>") + .command("discussions [issue]") .description("list root discussion threads on an issue") .addHelpText( "after", @@ -903,10 +1257,11 @@ export function setupIssuesCommands(program: Command): void { .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - commandAction<[string, DiscussionsOptions, Command]>( - async (issue, options, command) => { + commandAction<[string | undefined, DiscussionsOptions, Command]>( + async (issueArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); const issueId = await resolveIssueId(ctx.gql, issue); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "25"), @@ -1131,7 +1486,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("create <title>") + .command("create [title]") .description("create new issue") .option("--description <text>", "issue body") .option("--assignee <user>", "assign to user") @@ -1155,6 +1510,23 @@ export function setupIssuesCommands(program: Command): void { async (title, options, command) => { const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + CreateWizardOptions, + never + >(ctx, getRootOpts(command), { + spec: issueCreateSpec, + options: { + ...options, + ...(title !== undefined ? { title } : {}), + } as CreateWizardOptions, + missingRequired: title === undefined || options.team === undefined, + }); + title = filled.options.title ?? title; + if (title === undefined) { + throw invalidParameterError("title", "is required"); + } + options = normalizeWizardLabels(filled.options); + const relationActions = parseRelationFlags(options); const parsedPriority = @@ -1271,7 +1643,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("update <issue>") + .command("update [issue]") .description("update an existing issue") .addHelpText( "after", @@ -1303,8 +1675,41 @@ export function setupIssuesCommands(program: Command): void { .option("--similar-to <issue>", "add similar relation") .option("--remove-relation <issue>", "remove relation with <issue>") .action( - commandAction<[string, UpdateOptions, Command]>( - async (issue, options, command) => { + commandAction<[string | undefined, UpdateOptions, Command]>( + async (issueArg, options, command) => { + const rootOpts = getRootOpts(command); + const ctx = createContext(rootOpts); + + // Resolve the issue first (prompts via the picker when it is missing + // and interactive, otherwise uses the provided value or errors). + // Doing this before the field wizard lets us seed the issue's team + // so the team-scoped pickers match `issues create`. + const issue = await resolveIssuePositional(ctx, command, issueArg); + + // When the field wizard will run, look up the issue's team and seed + // it into the draft so project/milestone/cycle/status/estimate scope + // to it exactly like create. Reused below for estimate validation. + const seededEstimateContext = shouldPrompt(rootOpts, { + missingRequired: issueArg === undefined, + }) + ? await resolveIssueEstimateContext(ctx.gql, issue) + : undefined; + const wizardOptions = ( + seededEstimateContext + ? { ...options, team: seededEstimateContext.team.teamId } + : options + ) as UpdateWizardOptions; + + const filled = await maybeCollectInteractive< + UpdateWizardOptions, + never + >(ctx, rootOpts, { + spec: issueUpdateSpec, + options: wizardOptions, + missingRequired: issueArg === undefined, + }); + options = normalizeWizardLabels(filled.options); + if (options.parentTicket && options.clearParentTicket) { throw new Error( "Cannot use --parent-ticket and --clear-parent-ticket together", @@ -1358,11 +1763,10 @@ export function setupIssuesCommands(program: Command): void { const relationActions = parseRelationFlags(options); - const ctx = createContext(getRootOpts(command)); - const issueEstimateContext = parsedEstimate !== undefined - ? await resolveIssueEstimateContext(ctx.gql, issue) + ? (seededEstimateContext ?? + (await resolveIssueEstimateContext(ctx.gql, issue))) : undefined; const resolvedIssueId = issueEstimateContext @@ -1529,12 +1933,13 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("archive <issue>") + .command("archive [issue]") .description("archive an issue") .action( - commandAction<[string, unknown, Command]>( - async (issue, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (issueArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); const issueId = await resolveIssueId(ctx.gql, issue); const result = await archiveIssue(ctx.gql, issueId); outputSuccess(result); @@ -1543,12 +1948,13 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("unarchive <issue>") + .command("unarchive [issue]") .description("unarchive an issue") .action( - commandAction<[string, unknown, Command]>( - async (issue, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (issueArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); const issueId = await resolveIssueId(ctx.gql, issue); const result = await unarchiveIssue(ctx.gql, issueId); outputSuccess(result); @@ -1557,12 +1963,13 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("delete <issue>") + .command("delete [issue]") .description("delete an issue") .action( - commandAction<[string, unknown, Command]>( - async (issue, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (issueArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); const issueId = await resolveIssueId(ctx.gql, issue); const result = await deleteIssue(ctx.gql, issueId); outputSuccess(result); diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 62f8b24d..78b0ce5e 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -1,11 +1,22 @@ import type { Command } from "commander"; +import type { CommandContext } from "../common/context.js"; import { type CommandOptions, createContext, getRootOpts, } from "../common/context.js"; -import { invalidParameterError } from "../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; import type { UUID } from "../common/identifier.js"; +import { + labelChoices, + teamChoices, + withNoneChoice, +} from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { omitUndefined } from "../common/object.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -52,6 +63,111 @@ interface UpdateLabelOptions extends LabelLookupOptions { description?: string; } +/** Create-wizard shape: the create options plus the `name` positional. */ +interface CreateLabelWizardOptions extends Record<string, unknown> { + team?: string; + color?: string; + description?: string; + name?: string; +} + +/** Update-wizard shape: the update options with an index signature. */ +interface UpdateLabelWizardOptions extends Record<string, unknown> { + team?: string; + scope?: string; + name?: string; + color?: string; + description?: string; +} + +/** + * Interactive wizard for `labels create`. `name` is the required positional; + * `team` is optional (a workspace label when omitted). The team choice value is + * a UUID (see choices.ts), which the resolver passes through via `isUuid(...)`. + * Color is a free-text hex field validated the same way as `--color`. + */ +export const labelCreateSpec: PromptSpec<CreateLabelWizardOptions> = { + intro: "Create a new issue label", + fields: [ + { name: "name", kind: "text", message: "Name", required: true }, + { + name: "team", + kind: "select", + message: "Team", + choices: async (ctx) => + withNoneChoice(await teamChoices(ctx), "— none (workspace label) —"), + }, + { + name: "color", + kind: "text", + message: "Color (hex, e.g. #B45309)", + validate: (value) => + value === "" || /^#[0-9a-fA-F]{6}$/.test(value) + ? undefined + : "must be a hex color like #B45309", + }, + { name: "description", kind: "multiline", message: "Description" }, + ], +}; + +/** + * Interactive wizard for `labels update`. All fields optional; current option + * values seed each field so an explicit flag is never re-prompted. The label + * picker (run afterwards by the positional flow) resolves the `[label]`. + */ +export const labelUpdateSpec: PromptSpec<UpdateLabelWizardOptions> = { + intro: "Update an issue label", + fields: [ + { + name: "name", + kind: "text", + message: "Name", + default: (draft) => draft.name as string | undefined, + }, + { + name: "color", + kind: "text", + message: "Color (hex, e.g. #B45309)", + default: (draft) => draft.color as string | undefined, + validate: (value) => + value === "" || /^#[0-9a-fA-F]{6}$/.test(value) + ? undefined + : "must be a hex color like #B45309", + }, + { + name: "description", + kind: "multiline", + message: "Description", + default: (draft) => draft.description as string | undefined, + }, + ], +}; + +/** + * Entity picker for an absent `[label]` positional. Lists labels (scoped to the + * team from `--team` when supplied) and returns the selected label's UUID, which + * the resolver accepts via `isUuid(...)` passthrough. + */ +function makeLabelPicker( + teamHint: string | undefined, +): (ctx: CommandContext, io: PromptIO) => Promise<string> { + return async (ctx, io) => { + let teamId = teamHint; + if (teamId !== undefined) { + teamId = await resolveTeamId(ctx.gql, teamId); + } + const options = await labelChoices( + ctx, + teamId !== undefined ? { team: teamId } : {}, + ); + const answer = await io.select({ message: "Label", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + }; +} + function parseLabelType(value?: string): LabelType { if (value === undefined || value === "issue" || value === "project") { return value ?? "issue"; @@ -83,12 +199,43 @@ function parseLabelColor(value?: string): string | undefined { return value; } -async function resolveIssueLabelLookup( +const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; + +/** + * Fill an absent `[label]` positional via the label picker when gating allows, + * else require it (preserving the old missing-argument error for agents/pipes). + */ +async function resolveLabelPositional( + ctx: CommandContext, command: Command, + label: string | undefined, + teamHint: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: label === undefined, + positional: { + name: "label", + value: label, + picker: makeLabelPicker(teamHint), + }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("label", "is required"); + } + return filled.positional; +} + +async function resolveIssueLabelLookup( + ctx: CommandContext, label: string, options: LabelLookupOptions, -): Promise<{ ctx: ReturnType<typeof createContext>; labelId: UUID }> { - const ctx = createContext(getRootOpts(command)); +): Promise<{ labelId: UUID }> { const scope = parseLabelScope(options.scope); if (scope === "team" && !options.team) { @@ -114,7 +261,7 @@ async function resolveIssueLabelLookup( }), ); - return { ctx, labelId }; + return { labelId }; } function buildUpdateInput(options: UpdateLabelOptions): UpdateLabelInput { @@ -230,20 +377,37 @@ export function setupLabelsCommands(program: Command): void { ); labels - .command("create <name>") + .command("create [name]") .description("create an issue label") .option("--team <team>", "create a team-scoped label (key, name, or UUID)") .option("--color <hex>", "label color as a hex code (for example #B45309)") .option("--description <text>", "label description") .action( handleCommand(async (...args: unknown[]) => { - const [name, options, command] = args as [ - string, + const [nameArg, rawOptions, command] = args as [ + string | undefined, CreateLabelOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + CreateLabelWizardOptions, + never + >(ctx, getRootOpts(command), { + spec: labelCreateSpec, + options: { + ...rawOptions, + ...(nameArg !== undefined ? { name: nameArg } : {}), + } as CreateLabelWizardOptions, + missingRequired: nameArg === undefined, + }); + const options = filled.options as CreateLabelOptions; + const name = (filled.options.name as string | undefined) ?? nameArg; + if (name === undefined) { + throw invalidParameterError("name", "is required"); + } + const input: CreateLabelInput = { name }; const color = parseLabelColor(options.color); @@ -264,7 +428,7 @@ export function setupLabelsCommands(program: Command): void { ); labels - .command("read <label>") + .command("read [label]") .description("read an issue label") .option( "--team <team>", @@ -273,23 +437,26 @@ export function setupLabelsCommands(program: Command): void { .option("--scope <scope>", "resolve within workspace or team scope") .action( handleCommand(async (...args: unknown[]) => { - const [label, options, command] = args as [ - string, + const [labelArg, options, command] = args as [ + string | undefined, LabelLookupOptions, Command, ]; - const { ctx, labelId } = await resolveIssueLabelLookup( + const ctx = createContext(getRootOpts(command)); + const label = await resolveLabelPositional( + ctx, command, - label, - options, + labelArg, + options.team, ); + const { labelId } = await resolveIssueLabelLookup(ctx, label, options); outputSuccess(await getLabel(ctx.gql, labelId)); }), ); labels - .command("update <label>") + .command("update [label]") .description("update an issue label") .option( "--team <team>", @@ -301,24 +468,41 @@ export function setupLabelsCommands(program: Command): void { .option("--description <text>", "new label description") .action( handleCommand(async (...args: unknown[]) => { - const [label, options, command] = args as [ - string, + const [labelArg, rawOptions, command] = args as [ + string | undefined, UpdateLabelOptions, Command, ]; + const ctx = createContext(getRootOpts(command)); + + const filled = await maybeCollectInteractive< + UpdateLabelWizardOptions, + string + >(ctx, getRootOpts(command), { + spec: labelUpdateSpec, + options: { ...rawOptions } as UpdateLabelWizardOptions, + missingRequired: labelArg === undefined, + positional: { + name: "label", + value: labelArg, + picker: makeLabelPicker(rawOptions.team), + }, + }); + const options = filled.options as UpdateLabelOptions; + if (filled.positional === undefined) { + throw invalidParameterError("label", "is required"); + } + const label = filled.positional; + const input = buildUpdateInput(options); - const { ctx, labelId } = await resolveIssueLabelLookup( - command, - label, - options, - ); + const { labelId } = await resolveIssueLabelLookup(ctx, label, options); outputSuccess(await updateLabel(ctx.gql, labelId, input)); }), ); labels - .command("delete <label>") + .command("delete [label]") .description("delete an issue label") .option( "--team <team>", @@ -327,16 +511,19 @@ export function setupLabelsCommands(program: Command): void { .option("--scope <scope>", "resolve within workspace or team scope") .action( handleCommand(async (...args: unknown[]) => { - const [label, options, command] = args as [ - string, + const [labelArg, options, command] = args as [ + string | undefined, LabelLookupOptions, Command, ]; - const { ctx, labelId } = await resolveIssueLabelLookup( + const ctx = createContext(getRootOpts(command)); + const label = await resolveLabelPositional( + ctx, command, - label, - options, + labelArg, + options.team, ); + const { labelId } = await resolveIssueLabelLookup(ctx, label, options); outputSuccess(await deleteLabel(ctx.gql, labelId)); }), diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index 029b1f61..989a0ba8 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -1,5 +1,16 @@ import type { Command } from "commander"; +import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; +import { + milestoneChoices, + projectChoices, +} from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -39,6 +50,101 @@ interface MilestoneUpdateOptions { sortOrder?: string; } +/** Create-wizard shape: the create options plus the `name` positional. */ +interface MilestoneCreateWizardOptions extends Record<string, unknown> { + project?: string; + description?: string; + targetDate?: string; + name?: string; +} + +/** + * Interactive wizard for `milestones create`. Milestones are project-scoped, + * so `project` is required and precedes the milestone-specific fields. Entity + * choice values are UUIDs (see choices.ts); the resolvers pass those through + * unchanged via `isUuid(...)`. + */ +export const milestoneCreateSpec: PromptSpec<MilestoneCreateWizardOptions> = { + intro: "Create a new milestone", + fields: [ + { + name: "project", + kind: "select", + message: "Project", + required: true, + choices: projectChoices, + }, + { name: "name", kind: "text", message: "Name", required: true }, + { name: "description", kind: "multiline", message: "Description" }, + { name: "targetDate", kind: "text", message: "Target date (YYYY-MM-DD)" }, + ], +}; + +/** + * Interactive wizard for `milestones update`. All fields optional; the current + * option values seed each field. `project` is offered first so the milestone + * picker (run afterwards by the positional flow) can scope its lookup. + */ +export const milestoneUpdateSpec: PromptSpec<MilestoneCreateWizardOptions> = { + intro: "Update a milestone", + fields: [ + { + name: "name", + kind: "text", + message: "Name", + default: (draft) => draft.name as string | undefined, + }, + { + name: "description", + kind: "multiline", + message: "Description", + default: (draft) => draft.description as string | undefined, + }, + { + name: "targetDate", + kind: "text", + message: "Target date (YYYY-MM-DD)", + default: (draft) => draft.targetDate as string | undefined, + }, + ], +}; + +/** + * Entity picker for an absent `[milestone]` positional. Milestones are + * project-scoped, so this first prompts for a project (unless one was already + * supplied via `--project`), then loads that project's milestones. This is the + * cross-field-dependency case for the milestones domain: the milestone list is + * only fetched once the parent project is known. + * + * Returns the selected milestone UUID (which the resolver accepts). + */ +function makeMilestonePicker( + projectHint: string | undefined, +): (ctx: CommandContext, io: PromptIO) => Promise<string> { + return async (ctx, io) => { + let projectId = projectHint; + if (projectId === undefined) { + const projectAnswer = await io.select({ + message: "Project", + options: await projectChoices(ctx), + }); + if (io.isCancel(projectAnswer)) { + throw new InteractiveCancelledError(); + } + projectId = projectAnswer as string; + } else { + projectId = await resolveProjectId(ctx.gql, projectId); + } + + const options = await milestoneChoices(ctx, { project: projectId }); + const answer = await io.select({ message: "Milestone", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + }; +} + export const MILESTONES_META: DomainMeta = { name: "milestones", summary: "progress checkpoints within projects", @@ -93,19 +199,37 @@ export function setupMilestonesCommands(program: Command): void { // Get milestone details with issues milestones - .command("read <milestone>") + .command("read [milestone]") .description("get milestone details including issues") .option("--project <project>", "scope name lookup to project") .option("--limit <n>", "max issues to fetch", "50") .action( handleCommand(async (...args: unknown[]) => { - const [milestone, options, command] = args as [ - string, + const [milestoneArg, options, command] = args as [ + string | undefined, MilestoneReadOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + Record<string, never>, + string + >(ctx, getRootOpts(command), { + spec: { fields: [] }, + options: {}, + missingRequired: milestoneArg === undefined, + positional: { + name: "milestone", + value: milestoneArg, + picker: makeMilestonePicker(options.project), + }, + }); + if (filled.positional === undefined) { + throw invalidParameterError("milestone", "is required"); + } + const milestone = filled.positional; + const milestoneId = await resolveMilestoneId( ctx.gql, milestone, @@ -124,28 +248,52 @@ export function setupMilestonesCommands(program: Command): void { // Create a new milestone milestones - .command("create <name>") + .command("create [name]") .description("create a new milestone") - .requiredOption("--project <project>", "target project (required)") + .option("--project <project>", "target project (required)") .option("-d, --description <text>", "milestone description") .option("--target-date <date>", "target date in ISO format (YYYY-MM-DD)") .action( handleCommand(async (...args: unknown[]) => { - const [name, options, command] = args as [ - string, + const [nameArg, options, command] = args as [ + string | undefined, MilestoneCreateOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + MilestoneCreateWizardOptions, + never + >(ctx, getRootOpts(command), { + spec: milestoneCreateSpec, + options: { + ...options, + ...(nameArg !== undefined ? { name: nameArg } : {}), + } as MilestoneCreateWizardOptions, + missingRequired: + nameArg === undefined || options.project === undefined, + }); + const filledOptions = filled.options as MilestoneCreateOptions; + const name = (filled.options.name as string | undefined) ?? nameArg; + if (name === undefined) { + throw invalidParameterError("name", "is required"); + } + if (!filledOptions.project) { + throw invalidParameterError("--project", "is required"); + } + // Resolve project ID - const projectId = await resolveProjectId(ctx.gql, options.project); + const projectId = await resolveProjectId( + ctx.gql, + filledOptions.project, + ); const milestone = await createMilestone(ctx.gql, { projectId, name, - description: options.description, - targetDate: options.targetDate, + description: filledOptions.description, + targetDate: filledOptions.targetDate, }); outputSuccess(milestone); @@ -154,7 +302,7 @@ export function setupMilestonesCommands(program: Command): void { // Update an existing milestone milestones - .command("update <milestone>") + .command("update [milestone]") .description("update an existing milestone") .option("--project <project>", "scope name lookup to project") .option("-n, --name <name>", "new name") @@ -166,30 +314,51 @@ export function setupMilestonesCommands(program: Command): void { .option("--sort-order <n>", "display order") .action( handleCommand(async (...args: unknown[]) => { - const [milestone, options, command] = args as [ - string, + const [milestoneArg, options, command] = args as [ + string | undefined, MilestoneUpdateOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + MilestoneCreateWizardOptions, + string + >(ctx, getRootOpts(command), { + spec: milestoneUpdateSpec, + options: { ...options } as MilestoneCreateWizardOptions, + missingRequired: milestoneArg === undefined, + positional: { + name: "milestone", + value: milestoneArg, + picker: makeMilestonePicker(options.project), + }, + }); + const filledOptions = filled.options as MilestoneUpdateOptions; + if (filled.positional === undefined) { + throw invalidParameterError("milestone", "is required"); + } + const milestone = filled.positional; + const milestoneId = await resolveMilestoneId( ctx.gql, milestone, - options.project, + filledOptions.project, ); // Build update input (only include provided fields) const updateInput: UpdateMilestoneInput = {}; - if (options.name !== undefined) updateInput.name = options.name; - if (options.description !== undefined) { - updateInput.description = options.description; + if (filledOptions.name !== undefined) { + updateInput.name = filledOptions.name; + } + if (filledOptions.description !== undefined) { + updateInput.description = filledOptions.description; } - if (options.targetDate !== undefined) { - updateInput.targetDate = options.targetDate; + if (filledOptions.targetDate !== undefined) { + updateInput.targetDate = filledOptions.targetDate; } - if (options.sortOrder !== undefined) { - updateInput.sortOrder = parseFloat(options.sortOrder); + if (filledOptions.sortOrder !== undefined) { + updateInput.sortOrder = parseFloat(filledOptions.sortOrder); } const updated = await updateMilestone( diff --git a/src/commands/projects.ts b/src/commands/projects.ts index e9e9fc20..26b1f035 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,9 +1,22 @@ import type { Command } from "commander"; +import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; import { type Priority, parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; -import { invalidParameterError } from "../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; import { asUuid } from "../common/identifier.js"; +import { + labelChoices, + priorityChoices, + projectStatusChoices, + teamChoices, + userChoices, +} from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -173,6 +186,213 @@ interface UpdateOptions { clearLabels?: boolean; } +/** Create-wizard shape: the create options plus the `name` positional. */ +type CreateWizardOptions = CreateOptions & + Record<string, unknown> & { name?: string }; + +/** Update-wizard shape: the update options with an index signature. */ +type UpdateWizardOptions = UpdateOptions & Record<string, unknown>; + +/** + * Interactive wizard for `projects create`. Entity choice values are UUIDs + * (see choices.ts); the resolvers pass those through unchanged via + * `isUuid(...)`. `teams` is a multiselect whose UUID list is joined into the + * comma-separated `--teams` string the command body expects. + */ +export const projectCreateSpec: PromptSpec<CreateWizardOptions> = { + intro: "Create a new project", + fields: [ + { name: "name", kind: "text", message: "Name", required: true }, + { + name: "teams", + kind: "multiselect", + message: "Teams", + required: true, + choices: teamChoices, + }, + { name: "description", kind: "multiline", message: "Description" }, + { name: "content", kind: "multiline", message: "Content (markdown)" }, + { + name: "lead", + kind: "select", + message: "Lead", + choices: userChoices, + }, + { + name: "members", + kind: "multiselect", + message: "Members", + choices: userChoices, + }, + { + name: "priority", + kind: "select", + message: "Priority", + choices: async () => priorityChoices(), + }, + { + name: "status", + kind: "select", + message: "Status", + choices: projectStatusChoices, + }, + { + name: "labels", + kind: "multiselect", + message: "Labels", + required: false, + choices: labelChoices, + }, + { name: "startDate", kind: "text", message: "Start date (YYYY-MM-DD)" }, + { name: "targetDate", kind: "text", message: "Target date (YYYY-MM-DD)" }, + ], +}; + +/** + * Interactive wizard for `projects update`. All fields optional; current option + * values seed each field and prevent re-prompting for fields already provided + * by flags. + */ +export const projectUpdateSpec: PromptSpec<UpdateWizardOptions> = { + intro: "Update a project", + fields: [ + { + name: "name", + kind: "text", + message: "Name", + default: (draft) => draft.name, + }, + { + name: "description", + kind: "multiline", + message: "Description", + default: (draft) => draft.description, + }, + { + name: "content", + kind: "multiline", + message: "Content (markdown)", + default: (draft) => draft.content, + }, + { + name: "lead", + kind: "select", + message: "Lead", + choices: userChoices, + }, + { + name: "members", + kind: "multiselect", + message: "Members", + choices: userChoices, + }, + { + name: "priority", + kind: "select", + message: "Priority", + choices: async () => priorityChoices(), + }, + { + name: "status", + kind: "select", + message: "Status", + choices: projectStatusChoices, + }, + { + name: "labels", + kind: "multiselect", + message: "Labels", + required: false, + choices: labelChoices, + }, + { + name: "startDate", + kind: "text", + message: "Start date (YYYY-MM-DD)", + default: (draft) => draft.startDate, + }, + { + name: "targetDate", + kind: "text", + message: "Target date (YYYY-MM-DD)", + default: (draft) => draft.targetDate, + }, + ], +}; + +/** + * Multiselect fields yield a `string[]` of UUIDs, but the command body expects + * the CLI-shaped comma-separated `string`. Normalise the named keys in place so + * the command body below the wizard call stays unchanged. + */ +function normalizeWizardLists<O extends Record<string, unknown>>( + filled: O, + keys: readonly string[], +): O { + const normalized = { ...filled }; + for (const key of keys) { + const value = normalized[key]; + if (Array.isArray(value)) { + const joined = value.join(","); + if (joined.length > 0) { + (normalized as Record<string, unknown>)[key] = joined; + } else { + delete (normalized as Record<string, unknown>)[key]; + } + } + } + return normalized; +} + +/** + * Entity picker for an absent `[project]` positional. Lists recent projects and + * returns the selected project's UUID (which the resolver accepts). + */ +async function projectPicker( + ctx: CommandContext, + io: PromptIO, +): Promise<string> { + const { nodes } = await listProjects(ctx.gql); + const options = nodes.map((project) => ({ + value: project.id, + label: project.name, + hint: project.state, + })); + const answer = await io.select({ message: "Project", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; + +/** + * Fill an absent `[project]` positional via the project picker when gating + * allows, else require it (preserving the old missing-argument error for + * agents/pipes). The command body downstream is unchanged. + */ +async function resolveProjectPositional( + ctx: CommandContext, + command: Command, + project: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: project === undefined, + positional: { name: "project", value: project, picker: projectPicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("project", "is required"); + } + return filled.positional; +} + export const PROJECTS_META: DomainMeta = { name: "projects", summary: "groups of issues toward a goal", @@ -275,7 +495,7 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("read <project>") + .command("read [project]") .description("get full project details") .option( "--milestones-first <n>", @@ -288,9 +508,14 @@ export function setupProjectsCommands(program: Command): void { "50", ) .action( - commandAction<[string, ReadOptions, Command]>( - async (project, options, command) => { + commandAction<[string | undefined, ReadOptions, Command]>( + async (projectArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const project = await resolveProjectPositional( + ctx, + command, + projectArg, + ); const projectId = await resolveProjectId(ctx.gql, project); const result = await getProject(ctx.gql, projectId, { milestonesFirst: parseNonNegativeIntegerOption( @@ -308,18 +533,23 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("discuss <project>") + .command("discuss [project]") .description("start a discussion thread on a project") .option("--body <text>", "discussion body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( - async (project, options, command) => { + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( + async (projectArg, options, command) => { const ctx = createContext(getRootOpts(command)); if (!options.body) { throw invalidParameterError("--body", "is required"); } + const project = await resolveProjectPositional( + ctx, + command, + projectArg, + ); const projectId = await resolveProjectId(ctx.gql, project); const result = await startProjectDiscussion(ctx.gql, { projectId, @@ -332,16 +562,21 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("discussions <project>") + .command("discussions [project]") .description("list root discussion threads on a project") .option("-l, --limit <n>", "max results", "25") .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - commandAction<[string, DiscussionsOptions, Command]>( - async (project, options, command) => { + commandAction<[string | undefined, DiscussionsOptions, Command]>( + async (projectArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const project = await resolveProjectPositional( + ctx, + command, + projectArg, + ); const projectId = await resolveProjectId(ctx.gql, project); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "25"), @@ -566,7 +801,7 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("create <name>") + .command("create [name]") .description("create a new project") .option("--teams <teams>", "comma-separated team names or UUIDs") .option("--team <team>", "team name or UUID (alias for --teams)") @@ -582,10 +817,33 @@ export function setupProjectsCommands(program: Command): void { .option("--target-date <date>", "target date (YYYY-MM-DD)") .option("--labels <labels>", "comma-separated label names or UUIDs") .action( - commandAction<[string, CreateOptions, Command]>( - async (name, options, command) => { + commandAction<[string | undefined, CreateOptions, Command]>( + async (nameArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + CreateWizardOptions, + never + >(ctx, getRootOpts(command), { + spec: projectCreateSpec, + options: { + ...options, + ...(nameArg !== undefined ? { name: nameArg } : {}), + } as CreateWizardOptions, + missingRequired: + nameArg === undefined || + (options.team === undefined && options.teams === undefined), + }); + const name = filled.options.name ?? nameArg; + if (name === undefined) { + throw invalidParameterError("name", "is required"); + } + options = normalizeWizardLists(filled.options, [ + "teams", + "members", + "labels", + ]); + const teamNames = getCreateTeamNames(options); const teamIds = await Promise.all( teamNames.map((t) => resolveTeamId(ctx.gql, t)), @@ -660,7 +918,7 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("update <project>") + .command("update [project]") .description("update an existing project") .option("--name <name>", "new name") .option("--description <text>", "new description") @@ -682,10 +940,33 @@ export function setupProjectsCommands(program: Command): void { .option("--label-mode <mode>", "add | remove | overwrite") .option("--clear-labels", "remove all labels") .action( - commandAction<[string, UpdateOptions, Command]>( - async (project, options, command) => { + commandAction<[string | undefined, UpdateOptions, Command]>( + async (projectArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const filled = await maybeCollectInteractive< + UpdateWizardOptions, + string + >(ctx, getRootOpts(command), { + spec: projectUpdateSpec, + options: options as UpdateWizardOptions, + missingRequired: projectArg === undefined, + positional: { + name: "project", + value: projectArg, + picker: projectPicker, + }, + }); + options = normalizeWizardLists(filled.options, [ + "teams", + "members", + "labels", + ]); + if (filled.positional === undefined) { + throw invalidParameterError("project", "is required"); + } + const project = filled.positional; + if (options.lead && options.clearLead) { throw invalidParameterError( "--lead", @@ -845,12 +1126,17 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("archive <project>") + .command("archive [project]") .description("archive a project") .action( - commandAction<[string, unknown, Command]>( - async (project, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (projectArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const project = await resolveProjectPositional( + ctx, + command, + projectArg, + ); const projectId = await resolveProjectId(ctx.gql, project); const result = await archiveProject(ctx.gql, projectId); outputSuccess(result); @@ -859,12 +1145,17 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("unarchive <project>") + .command("unarchive [project]") .description("unarchive a project") .action( - commandAction<[string, unknown, Command]>( - async (project, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (projectArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const project = await resolveProjectPositional( + ctx, + command, + projectArg, + ); const projectId = await resolveProjectId(ctx.gql, project, { includeArchived: true, }); @@ -875,12 +1166,17 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("delete <project>") + .command("delete [project]") .description("delete a project") .action( - commandAction<[string, unknown, Command]>( - async (project, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (projectArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const project = await resolveProjectPositional( + ctx, + command, + projectArg, + ); const projectId = await resolveProjectId(ctx.gql, project, { includeArchived: true, }); diff --git a/src/commands/teams.ts b/src/commands/teams.ts index f88fdaec..91527a32 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -4,7 +4,13 @@ import { createContext, getRootOpts, } from "../common/context.js"; -import { invalidParameterError } from "../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; +import { teamChoices } from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -40,6 +46,21 @@ export const TEAMS_META: DomainMeta = { seeAlso: ["users list", "issues create --team", "cycles list --team"], }; +/** + * Entity picker for an absent `[team]` positional. Returns the selected team's + * UUID, which the resolver passes through via `isUuid(...)`. + */ +async function teamPicker(ctx: CommandContext, io: PromptIO): Promise<string> { + const options = await teamChoices(ctx); + const answer = await io.select({ message: "Team", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; + const ESTIMATION_TYPES = [ "notUsed", "exponential", @@ -281,14 +302,28 @@ export function setupTeamsCommands(program: Command): void { ); teams - .command("read <team>") + .command("read [team]") .description("get team details") .action( handleCommand(async (...args: unknown[]) => { - const team = args[0] as string; + const teamArg = args[0] as string | undefined; const command = args.at(-1) as Command; const ctx = createContext(getRootOpts(command)); - const teamId = await resolveTeamId(ctx.gql, team); + + const filled = await maybeCollectInteractive< + Record<string, never>, + string + >(ctx, getRootOpts(command), { + spec: EMPTY_SPEC, + options: {}, + missingRequired: teamArg === undefined, + positional: { name: "team", value: teamArg, picker: teamPicker }, + }); + if (filled.positional === undefined) { + throw invalidParameterError("team", "is required"); + } + + const teamId = await resolveTeamId(ctx.gql, filled.positional); const result = await getTeam(ctx.gql, { id: teamId }); outputSuccess(result); }), diff --git a/src/common/auth.ts b/src/common/auth.ts index 17cfb2b1..96f43822 100644 --- a/src/common/auth.ts +++ b/src/common/auth.ts @@ -7,6 +7,12 @@ export interface CommandOptions { apiToken?: string; compact?: boolean; fields?: string[]; + /** + * Root `-i/--interactive` / `--no-interactive` flag. Commander sets `true` + * for `-i`, `false` for `--no-interactive`, and leaves it `undefined` + * otherwise. + */ + interactive?: boolean; } export type TokenSource = "flag" | "env" | "stored" | "legacy"; diff --git a/src/common/errors.ts b/src/common/errors.ts index 0cd97433..e889099e 100644 --- a/src/common/errors.ts +++ b/src/common/errors.ts @@ -47,6 +47,13 @@ export class AuthenticationError extends Error { } } +export class InteractiveCancelledError extends Error { + constructor() { + super("Interactive input cancelled"); + this.name = "InteractiveCancelledError"; + } +} + const AUTH_ERROR_PATTERNS: ReadonlyArray<string> = [ "authentication required", "unauthorized", diff --git a/src/common/interactive/choices.ts b/src/common/interactive/choices.ts new file mode 100644 index 00000000..d2e64265 --- /dev/null +++ b/src/common/interactive/choices.ts @@ -0,0 +1,286 @@ +// ARCHITECTURAL EXCEPTION: the interactive estimate picker needs the selected +// team's estimation scale, which is exposed only through the team resolver's +// estimate-context helper (there is no lean list service for it). Reused here +// read-only to derive the allowed values; no ID resolution is performed. +import { resolveTeamEstimateContext } from "../../resolvers/team-resolver.js"; +import { listCycles } from "../../services/cycle-service.js"; +import { listDocuments } from "../../services/document-service.js"; +import { listInitiatives } from "../../services/initiative-service.js"; +import { listIssues } from "../../services/issue-service.js"; +import { listLabels } from "../../services/label-service.js"; +import { listMilestones } from "../../services/milestone-service.js"; +import { + listProjectStatuses, + listProjects, +} from "../../services/project-service.js"; +import { listTeams } from "../../services/team-service.js"; +import { listUsers } from "../../services/user-service.js"; +import { listWorkflowStates } from "../../services/workflow-state-service.js"; +import type { CommandContext } from "../context.js"; +import { getAllowedEstimates } from "../estimate-validation.js"; +import { asUuid, type UUID } from "../identifier.js"; +import { COMMON_REACTION_EMOJI } from "./emoji-choices.js"; +import type { Choice } from "./types.js"; + +/** + * Shared choice loaders for the interactive engine. Each reuses an EXISTING + * list service via `ctx.gql` (never a resolver). + * + * DESIGN: for entity fields (team, assignee, project, milestone, cycle, status, + * labels) the choice `value` is the entity's resolved UUID, with `label` = the + * human name and `hint` = extra context. This means: + * - cross-field child loaders read the parent UUID straight from `draft` + * (e.g. `cycleChoices`/`statusChoices` read the team UUID selected earlier); + * - the final options object carries UUIDs, which the issue resolvers already + * short-circuit on via `isUuid(...)` passthrough — so the downstream + * resolve → service → outputSuccess path is unchanged and layers hold. + * + * Non-entity fields (priority) keep their scalar value. + */ + +/** A draft carries prior answers keyed by option name; values are strings. */ +type Draft = Record<string, unknown>; + +/** Read a UUID a prior entity field wrote into the draft under `key`. */ +function draftUuid(draft: Draft, key: string): UUID | undefined { + const value = draft[key]; + return typeof value === "string" ? asUuid(value) : undefined; +} + +/** + * Prepend an empty-valued "none" sentinel to a choice list so a single-select + * field can be left unset. The interactive engine treats an empty selection as + * "leave unset" (see collectInteractive), so the field falls back to its + * absent-flag behaviour (e.g. a workspace label, or an all-teams listing). + */ +export function withNoneChoice(choices: Choice[], label: string): Choice[] { + return [{ value: "", label }, ...choices]; +} + +export async function teamChoices(ctx: CommandContext): Promise<Choice[]> { + const { nodes } = await listTeams(ctx.gql); + return nodes.map((team) => ({ + value: team.id, + label: team.name, + hint: team.key, + })); +} + +export async function userChoices(ctx: CommandContext): Promise<Choice[]> { + const { nodes } = await listUsers(ctx.gql, true); + return nodes.map((user) => ({ + value: user.id, + label: user.name, + hint: user.email, + })); +} + +/** + * Assignee picker: the user list with a leading "None" sentinel so the field + * can be left unassigned (the engine treats the empty value as "leave unset"). + */ +export async function assigneeChoices(ctx: CommandContext): Promise<Choice[]> { + return withNoneChoice(await userChoices(ctx), "None (unassigned)"); +} + +export async function projectChoices( + ctx: CommandContext, + draft: Draft = {}, +): Promise<Choice[]> { + const teamId = draftUuid(draft, "team"); + const { nodes } = await listProjects(ctx.gql); + // When a team was selected earlier in the wizard, only offer projects that + // team is involved in. With no team context (e.g. document/milestone + // wizards) the full list is returned unchanged. + const scoped = + teamId === undefined + ? nodes + : nodes.filter((project) => + project.teams.nodes.some((team) => team.id === teamId), + ); + return scoped.map((project) => ({ + value: project.id, + label: project.name, + hint: project.state, + })); +} + +/** + * Project picker with a leading "None" sentinel so an issue can be created or + * updated without a project (the engine treats the empty value as "leave + * unset"). Team-scoping from {@link projectChoices} is preserved. + */ +export async function optionalProjectChoices( + ctx: CommandContext, + draft: Draft, +): Promise<Choice[]> { + return withNoneChoice(await projectChoices(ctx, draft), "None (no project)"); +} + +/** + * Recent issues, valued by their human `identifier` (e.g. ABC-123) — the same + * string the issue resolver accepts. Unlike the entity choices above this is + * NOT UUID-valued because the content-domain positionals (`comments create + * <issue>`, `attachments list <issue>`) feed the identifier into + * `resolveIssueId`, which resolves identifiers directly. Shared by every + * content domain's issue picker so the loader is not duplicated. + */ +export async function issueChoices(ctx: CommandContext): Promise<Choice[]> { + const { nodes } = await listIssues(ctx.gql, { limit: 50 }, undefined); + return nodes.map((issue) => ({ + value: issue.identifier, + label: `${issue.identifier} ${issue.title}`, + hint: issue.state.name, + })); +} + +/** + * Recent documents, valued by UUID (which `asUuid` accepts unchanged in the + * documents domain read/update/delete positionals). Documents are standalone + * entities (optionally attached to a project and/or issue), so this picker is + * not parent-scoped. + */ +export async function documentChoices(ctx: CommandContext): Promise<Choice[]> { + const { nodes } = await listDocuments(ctx.gql, { limit: 50 }); + return nodes.map((document) => ({ + value: document.id, + label: document.title, + ...(document.icon ? { hint: document.icon } : {}), + })); +} + +export async function labelChoices( + ctx: CommandContext, + draft: Draft, +): Promise<Choice[]> { + const teamId = draftUuid(draft, "team"); + const { nodes } = await listLabels(ctx.gql, teamId); + return nodes.map((label) => ({ + value: label.id, + label: label.name, + ...(label.description !== undefined ? { hint: label.description } : {}), + })); +} + +export async function cycleChoices( + ctx: CommandContext, + draft: Draft, +): Promise<Choice[]> { + const teamId = draftUuid(draft, "team"); + const { nodes } = await listCycles(ctx.gql, teamId); + const now = Date.now(); + // Only current and future cycles are selectable for a new/updated issue; + // past cycles (already ended) are dropped. + const upcoming = nodes.filter( + (cycle) => new Date(cycle.endsAt).getTime() >= now, + ); + // Surface the active (current) cycle first so it is the default highlighted + // option; remaining future cycles follow in start-date order. + upcoming.sort((a, b) => { + if (a.isActive !== b.isActive) return a.isActive ? -1 : 1; + return new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(); + }); + return upcoming.map((cycle) => ({ + value: cycle.id, + label: cycle.name, + hint: cycle.isActive ? "current" : `${cycle.startsAt} → ${cycle.endsAt}`, + })); +} + +export async function milestoneChoices( + ctx: CommandContext, + draft: Draft, +): Promise<Choice[]> { + const projectId = draftUuid(draft, "project"); + if (projectId === undefined) return []; + const { nodes } = await listMilestones(ctx.gql, projectId); + return nodes.map((milestone) => ({ + value: milestone.id, + label: milestone.name, + })); +} + +export async function statusChoices( + ctx: CommandContext, + draft: Draft, +): Promise<Choice[]> { + const teamId = draftUuid(draft, "team"); + if (teamId === undefined) return []; + const states = await listWorkflowStates(ctx.gql, teamId); + return states.map((state) => ({ + value: state.id, + label: state.name, + hint: state.type, + })); +} + +export async function projectStatusChoices( + ctx: CommandContext, +): Promise<Choice[]> { + const nodes = await listProjectStatuses(ctx.gql); + return nodes.map((status) => ({ + value: status.id, + label: status.name, + })); +} + +export async function initiativeChoices( + ctx: CommandContext, +): Promise<Choice[]> { + const { nodes } = await listInitiatives(ctx.gql, { limit: 50 }); + return nodes.map((initiative) => ({ + value: initiative.id, + label: initiative.name, + ...(initiative.status !== null && initiative.status !== undefined + ? { hint: String(initiative.status) } + : {}), + })); +} + +/** + * Estimate picker scoped to the selected team's configured estimation scale. + * Reads the team estimate context and offers only the allowed point values. + * Returns an empty list when no team is selected yet or when the team has + * estimates disabled (`notUsed`), so the engine skips the field entirely. + */ +export async function estimateChoices( + ctx: CommandContext, + draft: Draft, +): Promise<Choice[]> { + const teamId = draftUuid(draft, "team"); + if (teamId === undefined) return []; + const config = await resolveTeamEstimateContext(ctx.gql, teamId); + return getAllowedEstimates(config).map((value) => ({ + value: String(value), + label: String(value), + })); +} + +/** + * Static Linear priority scale. The "None" sentinel uses an empty value so the + * engine leaves priority unset (the CLI's `--priority` accepts only 1-4; 0/no + * priority is expressed by omitting the flag). + */ +export function priorityChoices(): Choice[] { + return [ + { value: "", label: "None" }, + { value: "1", label: "Urgent", hint: "1" }, + { value: "2", label: "High", hint: "2" }, + { value: "3", label: "Medium", hint: "3" }, + { value: "4", label: "Low", hint: "4" }, + ]; +} + +/** + * A curated set of common reaction emoji. Each choice `value` is the emoji + * glyph, which flows into the existing `resolveReactionEmojiInput` unchanged as + * the positional `[emoji]` (it normalises glyphs verbatim). The shortcode is + * shown in the label/hint for recognition. + */ +export function emojiChoices(): Choice[] { + return COMMON_REACTION_EMOJI.map(({ shortcode, emoji }) => ({ + value: emoji, + label: `${emoji} :${shortcode}:`, + hint: shortcode, + })); +} diff --git a/src/common/interactive/clack-io.ts b/src/common/interactive/clack-io.ts new file mode 100644 index 00000000..337b9d9f --- /dev/null +++ b/src/common/interactive/clack-io.ts @@ -0,0 +1,153 @@ +import { + autocomplete as clackAutocomplete, + autocompleteMultiselect as clackAutocompleteMultiselect, + confirm as clackConfirm, + isCancel as clackIsCancel, + multiline as clackMultiline, + multiselect as clackMultiselect, + select as clackSelect, + text as clackText, +} from "@clack/prompts"; +import type { + ConfirmPromptOptions, + MultiLinePromptOptions, + MultiSelectPromptOptions, + PromptIO, + SelectPromptOptions, + TextPromptOptions, +} from "./types.js"; + +/** + * `@clack/prompts` adapter implementing {@link PromptIO}. Every primitive is + * routed to `process.stderr` via `{ output: process.stderr }` so that stdout + * stays reserved for the final JSON payload. This adapter never calls + * console.log. + */ +export const clackIO: PromptIO = { + text(options: TextPromptOptions): Promise<string | symbol> { + return clackText({ + message: options.message, + output: process.stderr, + ...(options.placeholder !== undefined + ? { placeholder: options.placeholder } + : {}), + ...(options.initialValue !== undefined + ? { initialValue: options.initialValue } + : {}), + ...(options.defaultValue !== undefined + ? { defaultValue: options.defaultValue } + : {}), + ...(options.validate !== undefined + ? { + validate: (value: string | undefined) => + options.validate?.(value ?? ""), + } + : {}), + }); + }, + + multiline(options: MultiLinePromptOptions): Promise<string | symbol> { + return clackMultiline({ + message: options.message, + output: process.stderr, + ...(options.placeholder !== undefined + ? { placeholder: options.placeholder } + : {}), + ...(options.initialValue !== undefined + ? { initialValue: options.initialValue } + : {}), + ...(options.defaultValue !== undefined + ? { defaultValue: options.defaultValue } + : {}), + ...(options.showSubmit !== undefined + ? { showSubmit: options.showSubmit } + : {}), + ...(options.validate !== undefined + ? { + validate: (value: string | undefined) => + options.validate?.(value ?? ""), + } + : {}), + }); + }, + + select(options: SelectPromptOptions): Promise<string | symbol> { + return clackSelect<string>({ + message: options.message, + output: process.stderr, + options: options.options.map((choice) => ({ + value: choice.value, + label: choice.label, + ...(choice.hint !== undefined ? { hint: choice.hint } : {}), + })), + ...(options.initialValue !== undefined + ? { initialValue: options.initialValue } + : {}), + }); + }, + + autocomplete(options: SelectPromptOptions): Promise<string | symbol> { + return clackAutocomplete<string>({ + message: options.message, + output: process.stderr, + placeholder: "Type to search…", + options: options.options.map((choice) => ({ + value: choice.value, + label: choice.label, + ...(choice.hint !== undefined ? { hint: choice.hint } : {}), + })), + ...(options.initialValue !== undefined + ? { initialValue: options.initialValue } + : {}), + }); + }, + + multiselect(options: MultiSelectPromptOptions): Promise<string[] | symbol> { + return clackMultiselect<string>({ + message: options.message, + output: process.stderr, + options: options.options.map((choice) => ({ + value: choice.value, + label: choice.label, + ...(choice.hint !== undefined ? { hint: choice.hint } : {}), + })), + ...(options.initialValues !== undefined + ? { initialValues: options.initialValues } + : {}), + ...(options.required !== undefined ? { required: options.required } : {}), + }); + }, + + autocompleteMultiselect( + options: MultiSelectPromptOptions, + ): Promise<string[] | symbol> { + return clackAutocompleteMultiselect<string>({ + message: options.message, + output: process.stderr, + placeholder: "Type to search…", + options: options.options.map((choice) => ({ + value: choice.value, + label: choice.label, + ...(choice.hint !== undefined ? { hint: choice.hint } : {}), + })), + ...(options.initialValues !== undefined + ? { initialValues: options.initialValues } + : {}), + ...(options.required !== undefined ? { required: options.required } : {}), + }); + }, + + confirm(options: ConfirmPromptOptions): Promise<boolean | symbol> { + return clackConfirm({ + message: options.message, + output: process.stderr, + ...(options.initialValue !== undefined + ? { initialValue: options.initialValue } + : {}), + }); + }, + + isCancel(value: unknown): boolean { + return clackIsCancel(value); + }, +}; diff --git a/src/common/interactive/emoji-choices.ts b/src/common/interactive/emoji-choices.ts new file mode 100644 index 00000000..b67a14a4 --- /dev/null +++ b/src/common/interactive/emoji-choices.ts @@ -0,0 +1,37 @@ +import { get } from "node-emoji"; + +/** + * A curated set of common reaction shortcodes. Each shortcode is resolved via + * node-emoji so the picker can show the glyph; only shortcodes node-emoji + * recognises are surfaced (they are the ones `resolveReactionEmojiInput` + * accepts). `thumbs_up` is aliased to `+1` (matching `src/common/emoji.ts`). + */ +const CANDIDATE_SHORTCODES: readonly string[] = [ + "+1", + "-1", + "heart", + "tada", + "rocket", + "eyes", + "fire", + "smile", + "laughing", + "thinking_face", + "raised_hands", + "clap", + "pray", + "100", + "white_check_mark", + "x", +]; + +export interface EmojiChoice { + shortcode: string; + emoji: string; +} + +export const COMMON_REACTION_EMOJI: readonly EmojiChoice[] = + CANDIDATE_SHORTCODES.flatMap((shortcode) => { + const emoji = get(shortcode); + return emoji ? [{ shortcode, emoji }] : []; + }); diff --git a/src/common/interactive/engine.ts b/src/common/interactive/engine.ts new file mode 100644 index 00000000..4fe45836 --- /dev/null +++ b/src/common/interactive/engine.ts @@ -0,0 +1,189 @@ +import type { CommandContext } from "../context.js"; +import { InteractiveCancelledError } from "../errors.js"; +import { clackIO } from "./clack-io.js"; +import { type InteractiveRootOptions, shouldPrompt } from "./gating.js"; +import type { FieldPrompt, PromptIO, PromptSpec } from "./types.js"; + +/** + * Walk a {@link PromptSpec} and collect answers for every field that still + * needs one, merging them onto a copy of `provided`. + * + * Behavior: + * - fields are processed in declared order; + * - a field is skipped when `when(draft) === false`; + * - a field is skipped when `skipIfProvided !== false` and the draft already + * has a defined value for it (so an explicit flag wins); + * - `choices(ctx, draft)` is invoked lazily, only when the field is reached, + * so cross-field ordering deps (team before cycle) hold; + * - the prompt's initial value is seeded from `default(draft)`; + * - an empty answer (a blank text prompt, or an empty-valued "none" choice) is + * treated as "leave unset" and not written to the draft, so update builders + * that test `!== undefined` do not clear the existing value; + * - on cancellation (`io.isCancel`) an {@link InteractiveCancelledError} is + * thrown. + */ +export async function collectInteractive<O extends Record<string, unknown>>( + ctx: CommandContext, + spec: PromptSpec<O>, + provided: O, + io: PromptIO = clackIO, +): Promise<O> { + const draft: Record<string, unknown> = { ...provided }; + + for (const field of spec.fields) { + const partial = draft as Partial<O>; + + if (field.when && !field.when(partial)) continue; + + const skipIfProvided = field.skipIfProvided !== false; + if (skipIfProvided && draft[field.name] !== undefined) continue; + + const initial = field.default?.(partial); + const answer = await promptField(ctx, field, partial, io, initial); + + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + + // An empty submission means "leave unset": a blank text prompt (clack + // returns "") or an explicit empty-valued "none" choice. Writing "" would + // make update builders that test `!== undefined` clear the existing value, + // so skip it and let the draft keep its prior (usually undefined) value. + if (answer === "") continue; + + draft[field.name] = answer; + } + + return draft as O; +} + +async function promptField<O>( + ctx: CommandContext, + field: FieldPrompt<O>, + draft: Partial<O>, + io: PromptIO, + initial: string | undefined, +): Promise<string | string[] | boolean | symbol> { + switch (field.kind) { + case "text": + return io.text({ + message: field.message, + ...(initial !== undefined ? { initialValue: initial } : {}), + ...(field.validate !== undefined ? { validate: field.validate } : {}), + }); + case "multiline": + return io.multiline({ + message: field.message, + // Enter inserts a newline; a visible, Tab-focusable [ submit ] button + // makes confirming discoverable (Enter on a blank line also submits). + showSubmit: true, + ...(initial !== undefined ? { initialValue: initial } : {}), + ...(field.validate !== undefined ? { validate: field.validate } : {}), + }); + case "select": { + const options = (await field.choices?.(ctx, draft)) ?? []; + // Nothing to choose from (e.g. team has estimates disabled, or no + // current/future cycles): treat as an empty submission so the field is + // left unset instead of rendering an unusable empty picker. + if (options.length === 0) return ""; + const args = { + message: field.message, + options, + ...(initial !== undefined ? { initialValue: initial } : {}), + }; + return field.searchable ? io.autocomplete(args) : io.select(args); + } + case "multiselect": { + const options = (await field.choices?.(ctx, draft)) ?? []; + if (options.length === 0) return ""; + const args = { + message: field.message, + options, + ...(field.required !== undefined ? { required: field.required } : {}), + ...(initial !== undefined ? { initialValues: [initial] } : {}), + }; + return field.searchable + ? io.autocompleteMultiselect(args) + : io.multiselect(args); + } + case "confirm": + return io.confirm({ + message: field.message, + ...(initial !== undefined ? { initialValue: initial === "true" } : {}), + }); + } +} + +/** + * Descriptor for a positional argument that can be filled by an entity picker + * when it is absent and gating passes. {@link maybeCollectInteractive} invokes + * `picker(ctx, io)` when `value` is undefined and gating allows a prompt. + */ +interface PositionalPicker<T> { + /** Positional argument name (for messaging). */ + name: string; + /** The current value parsed from the CLI (undefined when absent). */ + value: T | undefined; + /** + * Resolve a value interactively. Must respect the same cancellation contract + * as the field engine (throw {@link InteractiveCancelledError} on cancel). + */ + picker(ctx: CommandContext, io: PromptIO): Promise<T>; +} + +/** Result of {@link maybeCollectInteractive}: filled options + positional. */ +export interface MaybeCollectResult<O, T> { + options: O; + positional: T | undefined; +} + +export interface MaybeCollectArgs<O extends Record<string, unknown>, T> { + spec: PromptSpec<O>; + options: O; + /** True when a required input is missing (drives auto-launch gating). */ + missingRequired: boolean; + /** Optional positional picker descriptor. */ + positional?: PositionalPicker<T>; + io?: PromptIO; +} + +/** + * Call-site helper. Runs {@link shouldPrompt}; when it returns false the inputs + * are returned untouched (zero change for agents/pipes). When true it runs the + * options wizard and, if a positional picker was supplied and its value is + * absent, the picker. + */ +export async function maybeCollectInteractive< + O extends Record<string, unknown>, + T, +>( + ctx: CommandContext, + rootOpts: InteractiveRootOptions, + args: MaybeCollectArgs<O, T>, +): Promise<MaybeCollectResult<O, T>> { + const io = args.io ?? clackIO; + + if (!shouldPrompt(rootOpts, { missingRequired: args.missingRequired })) { + return { + options: args.options, + positional: args.positional?.value, + }; + } + + const filledOptions = await collectInteractive( + ctx, + args.spec, + args.options, + io, + ); + + let positional = args.positional?.value; + if (args.positional && positional === undefined) { + // Run the entity picker to fill an absent positional argument. Cancellation + // inside the picker must throw InteractiveCancelledError (same contract as + // the field engine) so it flows to outputError. + positional = await args.positional.picker(ctx, io); + } + + return { options: filledOptions, positional }; +} diff --git a/src/common/interactive/gating.ts b/src/common/interactive/gating.ts new file mode 100644 index 00000000..c893e0b4 --- /dev/null +++ b/src/common/interactive/gating.ts @@ -0,0 +1,35 @@ +import type { CommandOptions } from "../auth.js"; + +/** Root options that influence whether interactive prompts may fire. */ +export type InteractiveRootOptions = Pick< + CommandOptions, + "interactive" | "compact" | "fields" +>; + +/** + * Decide whether the interactive engine may prompt. Hard-gated so agents and + * pipes never trigger a prompt. + * + * Returns true only when ALL of the following hold: + * - both stdin and stdout are TTYs; + * - `--no-interactive` was not passed (`rootOpts.interactive !== false`); + * - neither `CI` nor `LINEARIS_NO_INTERACTIVE` is set in the environment; + * - `--compact` was not passed; + * - `--fields` is empty/undefined; + * AND either `-i` was explicit (`rootOpts.interactive === true`) or a required + * argument is missing (`opts.missingRequired === true`). + */ +export function shouldPrompt( + rootOpts: InteractiveRootOptions, + opts: { missingRequired: boolean }, +): boolean { + if (process.stdin.isTTY !== true) return false; + if (process.stdout.isTTY !== true) return false; + if (rootOpts.interactive === false) return false; + if (process.env["CI"]) return false; + if (process.env["LINEARIS_NO_INTERACTIVE"]) return false; + if (rootOpts.compact) return false; + if (rootOpts.fields && rootOpts.fields.length > 0) return false; + + return rootOpts.interactive === true || opts.missingRequired === true; +} diff --git a/src/common/interactive/types.ts b/src/common/interactive/types.ts new file mode 100644 index 00000000..2f4f6def --- /dev/null +++ b/src/common/interactive/types.ts @@ -0,0 +1,117 @@ +import type { CommandContext } from "../context.js"; + +/** Field prompt kinds supported by the interactive engine. */ +type PromptKind = "text" | "multiline" | "select" | "multiselect" | "confirm"; + +/** A single selectable option shown in a select/multiselect prompt. */ +export interface Choice { + /** The human-facing string a user would type on the CLI (team key, project name, ...). */ + value: string; + /** Display label shown in the picker. */ + label: string; + /** Optional extra context shown alongside the label. */ + hint?: string; +} + +/** + * Injectable primitive options. These are modelled closely on + * `@clack/prompts`' own option shapes so the {@link clackIO} adapter stays a + * thin passthrough. Only the fields the engine actually drives are surfaced. + */ +export interface TextPromptOptions { + message: string; + placeholder?: string; + initialValue?: string; + defaultValue?: string; + validate?: (value: string) => string | undefined; +} + +/** + * Options for the multi-line prompt. Modelled on clack's `MultiLineOptions` + * (a superset of the text options) for entering multi-line markdown bodies. + */ +export interface MultiLinePromptOptions extends TextPromptOptions { + /** + * When true, a `[ submit ]` button is shown that can be focused with tab; + * otherwise pressing Enter twice submits. + */ + showSubmit?: boolean; +} + +export interface SelectPromptOptions { + message: string; + options: Choice[]; + initialValue?: string; +} + +export interface MultiSelectPromptOptions { + message: string; + options: Choice[]; + initialValues?: string[]; + required?: boolean; +} + +export interface ConfirmPromptOptions { + message: string; + initialValue?: boolean; +} + +/** + * Injectable IO primitives. Each returns either a resolved value or a cancel + * `symbol` (mirroring clack's `symbol` cancellation contract). Tests supply a + * scripted fake so CI never blocks on a TTY. + */ +export interface PromptIO { + text(options: TextPromptOptions): Promise<string | symbol>; + multiline(options: MultiLinePromptOptions): Promise<string | symbol>; + select(options: SelectPromptOptions): Promise<string | symbol>; + /** Searchable single-select (combobox) — a select with a filter input. */ + autocomplete(options: SelectPromptOptions): Promise<string | symbol>; + multiselect(options: MultiSelectPromptOptions): Promise<string[] | symbol>; + /** Searchable multi-select (combobox) — a multiselect with a filter input. */ + autocompleteMultiselect( + options: MultiSelectPromptOptions, + ): Promise<string[] | symbol>; + confirm(options: ConfirmPromptOptions): Promise<boolean | symbol>; + isCancel(value: unknown): boolean; +} + +/** + * Declarative descriptor for one field the engine may prompt for. `O` is the + * command's parsed-options interface, so `name` is constrained to real keys. + */ +export interface FieldPrompt<O> { + /** Key on the options object this field fills. */ + name: keyof O & string; + kind: PromptKind; + /** Prompt message shown to the user. */ + message: string; + /** Whether the field must be answered (drives multiselect `required`). */ + required?: boolean; + /** Skip the field entirely when this returns false for the current draft. */ + when?(draft: Partial<O>): boolean; + /** Lazily load select/multiselect options from a list service. */ + choices?(ctx: CommandContext, draft: Partial<O>): Promise<Choice[]>; + /** + * For `select`/`multiselect` fields, render a searchable combobox + * (autocomplete) so large option lists can be filtered by typing. Ignored + * for other kinds. + */ + searchable?: boolean; + /** Return an error string to reject the value, or undefined to accept. */ + validate?(value: string): string | undefined; + /** Seed the initial value shown when the prompt first renders. */ + default?(draft: Partial<O>): string | undefined; + /** + * When true (the default), the field is skipped if the draft already has a + * defined value — so an explicit flag wins over prompting. + */ + skipIfProvided?: boolean; +} + +/** A full prompt specification for a command's options interface. */ +export interface PromptSpec<O> { + fields: FieldPrompt<O>[]; + /** Optional intro line rendered above the first prompt. */ + intro?: string; +} diff --git a/src/common/output.ts b/src/common/output.ts index 40790b28..6ac5de33 100644 --- a/src/common/output.ts +++ b/src/common/output.ts @@ -2,6 +2,7 @@ import type { CommandOptions } from "./auth.js"; import { AUTH_ERROR_CODE, AuthenticationError, + InteractiveCancelledError, invalidParameterError, } from "./errors.js"; import type { JsonSerializable } from "./json.js"; @@ -101,6 +102,20 @@ export function outputAuthError(error: AuthenticationError): void { process.exit(AUTH_ERROR_CODE); } +function outputInteractiveCancelled(error: InteractiveCancelledError): void { + console.error( + JSON.stringify( + { + error: "INTERACTIVE_CANCELLED", + message: error.message, + }, + null, + 2, + ), + ); + process.exit(1); +} + export function parseLimit(value: string): number { const limit = parseInt(value, 10); if (Number.isNaN(limit) || limit < 1) { @@ -120,6 +135,10 @@ export function handleCommand( outputAuthError(error); return; } + if (error instanceof InteractiveCancelledError) { + outputInteractiveCancelled(error); + return; + } outputError(error instanceof Error ? error : new Error(String(error))); } }; diff --git a/src/main.ts b/src/main.ts index 8cd5305c..0950d302 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,7 +47,9 @@ program "--fields <list>", "comma-separated dot-paths to include (e.g. identifier,title,state.name)", parseFieldsList, - ); + ) + .option("-i, --interactive", "prompt interactively for missing input") + .option("--no-interactive", "never prompt"); program.hook("preAction", async (_thisCommand, actionCommand) => { setOutputOptions(getRootOpts(actionCommand)); diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 6c8c8297..fc4fad92 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -13,6 +13,8 @@ import { DeleteProjectDocument, GetProjectDocument, type GetProjectQuery, + GetProjectStatusesDocument, + type GetProjectStatusesQuery, GetProjectsDocument, type GetProjectsQuery, type ProjectCreateInput, @@ -116,6 +118,21 @@ export async function listProjects( }; } +/** A project status option (workflow state for projects). */ +export type ProjectStatusItem = + GetProjectStatusesQuery["projectStatuses"]["nodes"][0]; + +/** + * Lists the organization's project statuses. Used by the interactive project + * status picker. The set is small and fixed, so there is no pagination. + */ +export async function listProjectStatuses( + client: GraphQLClient, +): Promise<ProjectStatusItem[]> { + const result = await client.request(GetProjectStatusesDocument); + return result.projectStatuses.nodes; +} + export async function getProject( client: GraphQLClient, id: UUID, diff --git a/src/services/workflow-state-service.ts b/src/services/workflow-state-service.ts new file mode 100644 index 00000000..4b67ef47 --- /dev/null +++ b/src/services/workflow-state-service.ts @@ -0,0 +1,32 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import type { UUID } from "../common/identifier.js"; +import { ListWorkflowStatesForTeamDocument } from "../gql/graphql.js"; + +/** A workflow state (status) as offered by a team, ordered by position. */ +export interface WorkflowState { + id: string; + name: string; + type: string; + position: number; +} + +/** + * Lists a team's workflow states (statuses), ordered by position. + * + * Accepts a pre-resolved team UUID (per layer contract, services take UUIDs). + * Used by the interactive status picker. + */ +export async function listWorkflowStates( + client: GraphQLClient, + teamId: UUID, + first: number = 50, +): Promise<WorkflowState[]> { + const result = await client.request(ListWorkflowStatesForTeamDocument, { + teamId, + first, + }); + + return [...result.workflowStates.nodes].sort( + (a, b) => a.position - b.position, + ); +} diff --git a/tests/unit/commands/comments.test.ts b/tests/unit/commands/comments.test.ts index 58c34ac7..284a8e58 100644 --- a/tests/unit/commands/comments.test.ts +++ b/tests/unit/commands/comments.test.ts @@ -126,7 +126,7 @@ describe("comments compatibility delegation", () => { expect(replyHelp).toMatch( /Nested-reply targets are not\s+supported in compatibility mode/i, ); - expect(replyHelp).toContain("reply [options] <thread>"); + expect(replyHelp).toContain("reply [options] [thread]"); }); it("comments list resolves issue and delegates to listDiscussionsForIssue", async () => { diff --git a/tests/unit/interactive/choices.test.ts b/tests/unit/interactive/choices.test.ts new file mode 100644 index 00000000..0a916f19 --- /dev/null +++ b/tests/unit/interactive/choices.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import type { CommandContext } from "../../../src/common/context.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + cycleChoices, + emojiChoices, + initiativeChoices, + labelChoices, + milestoneChoices, + projectStatusChoices, + statusChoices, + teamChoices, + withNoneChoice, +} from "../../../src/common/interactive/choices.js"; +import { listWorkflowStates } from "../../../src/services/workflow-state-service.js"; + +const TEAM_UUID = "550e8400-e29b-41d4-a716-446655440000"; + +function mockCtx(request: ReturnType<typeof vi.fn>): CommandContext { + return { gql: { request } as unknown as GraphQLClient }; +} + +describe("withNoneChoice", () => { + it("prepends an empty-valued sentinel with the given label", () => { + const result = withNoneChoice( + [{ value: "t1", label: "Team One" }], + "— all teams —", + ); + + expect(result).toEqual([ + { value: "", label: "— all teams —" }, + { value: "t1", label: "Team One" }, + ]); + }); +}); + +describe("listWorkflowStates", () => { + it("queries the team-scoped states and sorts by position", async () => { + const request = vi.fn().mockResolvedValue({ + workflowStates: { + nodes: [ + { id: "s2", name: "Done", type: "completed", position: 2 }, + { id: "s1", name: "Todo", type: "unstarted", position: 1 }, + ], + }, + }); + const client = { request } as unknown as GraphQLClient; + + const result = await listWorkflowStates(client, asUuid(TEAM_UUID)); + + expect(request).toHaveBeenCalledWith(expect.anything(), { + teamId: TEAM_UUID, + first: 50, + }); + expect(result.map((s) => s.id)).toEqual(["s1", "s2"]); + }); +}); + +describe("statusChoices", () => { + it("returns [] when no team UUID is in the draft", async () => { + const request = vi.fn(); + const result = await statusChoices(mockCtx(request), {}); + expect(result).toEqual([]); + expect(request).not.toHaveBeenCalled(); + }); + + it("maps team states to UUID-valued choices", async () => { + const request = vi.fn().mockResolvedValue({ + workflowStates: { + nodes: [{ id: "s1", name: "Todo", type: "unstarted", position: 1 }], + }, + }); + + const result = await statusChoices(mockCtx(request), { team: TEAM_UUID }); + + expect(request).toHaveBeenCalledWith(expect.anything(), { + teamId: TEAM_UUID, + first: 50, + }); + expect(result).toEqual([{ value: "s1", label: "Todo", hint: "unstarted" }]); + }); +}); + +const PROJECT_UUID = "660e8400-e29b-41d4-a716-446655440111"; + +describe("projectStatusChoices", () => { + it("maps project statuses to UUID-valued choices", async () => { + const request = vi.fn().mockResolvedValue({ + projectStatuses: { + nodes: [ + { id: "ps1", name: "Backlog" }, + { id: "ps2", name: "Started" }, + ], + }, + }); + + const result = await projectStatusChoices(mockCtx(request)); + + expect(result).toEqual([ + { value: "ps1", label: "Backlog" }, + { value: "ps2", label: "Started" }, + ]); + }); +}); + +describe("initiativeChoices", () => { + it("maps initiatives to UUID-valued choices with status hints", async () => { + const request = vi.fn().mockResolvedValue({ + initiatives: { + nodes: [ + { id: "i1", name: "Q1 Goals", status: "Active" }, + { id: "i2", name: "Q2 Goals", status: null }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await initiativeChoices(mockCtx(request)); + + expect(result).toEqual([ + { value: "i1", label: "Q1 Goals", hint: "Active" }, + { value: "i2", label: "Q2 Goals" }, + ]); + }); +}); + +describe("milestoneChoices", () => { + it("returns [] when no project UUID is in the draft", async () => { + const request = vi.fn(); + const result = await milestoneChoices(mockCtx(request), {}); + expect(result).toEqual([]); + expect(request).not.toHaveBeenCalled(); + }); + + it("loads milestones scoped to the draft project UUID", async () => { + const request = vi.fn().mockResolvedValue({ + project: { + projectMilestones: { + nodes: [{ id: "m1", name: "Phase 1" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + const result = await milestoneChoices(mockCtx(request), { + project: PROJECT_UUID, + }); + + expect(result).toEqual([{ value: "m1", label: "Phase 1" }]); + }); +}); + +describe("teamChoices", () => { + it("maps teams to UUID-valued choices with key hints", async () => { + const request = vi.fn().mockResolvedValue({ + teams: { + nodes: [{ id: "t1", name: "Engineering", key: "ENG" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await teamChoices(mockCtx(request)); + + expect(result).toEqual([ + { value: "t1", label: "Engineering", hint: "ENG" }, + ]); + }); +}); + +describe("labelChoices", () => { + it("scopes the label lookup to the draft team UUID", async () => { + const request = vi.fn().mockResolvedValue({ + issueLabels: { + nodes: [ + { id: "l1", name: "bug", color: "#f00", description: "defects" }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await labelChoices(mockCtx(request), { team: TEAM_UUID }); + + const [, variables] = request.mock.calls[0]; + expect(variables.filter).toEqual({ team: { id: { eq: TEAM_UUID } } }); + expect(result).toEqual([{ value: "l1", label: "bug", hint: "defects" }]); + }); + + it("omits the team filter when no team UUID is in the draft", async () => { + const request = vi.fn().mockResolvedValue({ + issueLabels: { + nodes: [{ id: "l1", name: "bug", color: "#f00" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + await labelChoices(mockCtx(request), {}); + + const [, variables] = request.mock.calls[0]; + expect(variables.filter).toBeUndefined(); + }); +}); + +describe("cycleChoices (cross-field: cycle needs team)", () => { + const day = 24 * 60 * 60 * 1000; + const iso = (offsetDays: number): string => + new Date(Date.now() + offsetDays * day).toISOString(); + + it("scopes the lookup to the team, drops past cycles, and puts the current cycle first", async () => { + const request = vi.fn().mockResolvedValue({ + cycles: { + nodes: [ + // past cycle: ended before now → dropped + { + id: "past", + number: 1, + name: "Past", + startsAt: iso(-28), + endsAt: iso(-14), + isActive: false, + isNext: false, + isPrevious: true, + }, + // future cycle + { + id: "future", + number: 3, + name: "Future", + startsAt: iso(14), + endsAt: iso(28), + isActive: false, + isNext: true, + isPrevious: false, + }, + // current cycle: active, ends in the future + { + id: "current", + number: 2, + name: "Current", + startsAt: iso(-3), + endsAt: iso(11), + isActive: true, + isNext: false, + isPrevious: false, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await cycleChoices(mockCtx(request), { team: TEAM_UUID }); + + const [, variables] = request.mock.calls[0]; + expect(variables.filter).toEqual({ team: { id: { eq: TEAM_UUID } } }); + // Past cycle excluded; current (active) first so it is the default. + expect(result.map((c) => c.value)).toEqual(["current", "future"]); + expect(result[0]?.hint).toBe("current"); + }); +}); + +describe("emojiChoices", () => { + it("maps common emoji to glyph-valued choices with shortcode hints", () => { + const choices = emojiChoices(); + expect(choices.length).toBeGreaterThan(0); + for (const choice of choices) { + expect(typeof choice.value).toBe("string"); + expect(choice.value.length).toBeGreaterThan(0); + expect(choice.hint).toBeDefined(); + expect(choice.label).toContain(`:${choice.hint}:`); + } + }); +}); diff --git a/tests/unit/interactive/content-specs.test.ts b/tests/unit/interactive/content-specs.test.ts new file mode 100644 index 00000000..f24af714 --- /dev/null +++ b/tests/unit/interactive/content-specs.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { attachmentCreateSpec } from "../../../src/commands/attachments.js"; +import { + commentCreateSpec, + commentEditSpec, + commentReplySpec, +} from "../../../src/commands/comments.js"; +import { + documentCreateSpec, + documentUpdateSpec, +} from "../../../src/commands/documents.js"; +import type { CommandContext } from "../../../src/common/context.js"; +import { + documentChoices, + issueChoices, +} from "../../../src/common/interactive/choices.js"; + +function mockCtx(request: ReturnType<typeof vi.fn>): CommandContext { + return { gql: { request } as unknown as GraphQLClient }; +} + +describe("commentCreateSpec / replySpec / editSpec", () => { + it("requires body on every comment wizard", () => { + for (const spec of [commentCreateSpec, commentReplySpec, commentEditSpec]) { + const body = spec.fields.find((f) => f.name === "body"); + expect(body?.required).toBe(true); + expect(body?.kind).toBe("multiline"); + } + }); +}); + +describe("documentCreateSpec", () => { + it("requires title and uses entity selects for project/team", () => { + const title = documentCreateSpec.fields.find((f) => f.name === "title"); + expect(title?.required).toBe(true); + const project = documentCreateSpec.fields.find((f) => f.name === "project"); + const team = documentCreateSpec.fields.find((f) => f.name === "team"); + expect(project?.kind).toBe("select"); + expect(project?.choices).toBeDefined(); + expect(team?.kind).toBe("select"); + expect(team?.choices).toBeDefined(); + }); +}); + +describe("documentUpdateSpec", () => { + it("has no required fields and seeds defaults from options", () => { + expect(documentUpdateSpec.fields.every((f) => !f.required)).toBe(true); + const title = documentUpdateSpec.fields.find((f) => f.name === "title"); + expect(title?.default?.({ title: "cur" })).toBe("cur"); + }); +}); + +describe("attachmentCreateSpec", () => { + it("requires title and url", () => { + const required = attachmentCreateSpec.fields + .filter((f) => f.required) + .map((f) => f.name); + expect(required).toContain("title"); + expect(required).toContain("url"); + }); +}); + +describe("issueChoices (shared content-domain issue picker loader)", () => { + it("maps issues to identifier-valued choices with state hints", async () => { + const request = vi.fn().mockResolvedValue({ + issues: { + nodes: [ + { + identifier: "ENG-1", + title: "Fix bug", + state: { name: "Todo" }, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await issueChoices(mockCtx(request)); + + expect(result).toEqual([ + { value: "ENG-1", label: "ENG-1 Fix bug", hint: "Todo" }, + ]); + }); +}); + +describe("documentChoices", () => { + it("maps documents to UUID-valued choices", async () => { + const request = vi.fn().mockResolvedValue({ + documents: { + nodes: [{ id: "d1", title: "Spec", icon: null }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await documentChoices(mockCtx(request)); + + expect(result).toEqual([{ value: "d1", label: "Spec" }]); + }); +}); diff --git a/tests/unit/interactive/coverage-sweep.test.ts b/tests/unit/interactive/coverage-sweep.test.ts new file mode 100644 index 00000000..d29381b0 --- /dev/null +++ b/tests/unit/interactive/coverage-sweep.test.ts @@ -0,0 +1,126 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Interactive coverage sweep. + * + * Guards two invariants so a new command cannot ship without interactive + * support: + * + * 1. Any create/update command's file must wire `maybeCollectInteractive` + * (i.e. a wizard spec is run for the command's options). + * 2. Any positional-id command whose single leading positional is an + * enumerable entity must optionalise it (`[arg]`, not `<arg>`) so the entity + * picker can fill it — UNLESS it is on the intentional-skip allowlist below. + * + * Skips are commands whose leading positional is a raw comment/thread/reaction + * UUID with no clean parent-scoped enumeration in that command, or a second + * required positional that cannot be picked (Commander forbids optional-before- + * required). These mirror the Phase 2–4 design: discussion subcommands keyed by + * a bare comment/thread UUID stay `<arg>`. + */ + +const COMMANDS_DIR = join(process.cwd(), "src/commands"); + +/** command signatures (verb + positionals) intentionally left with `<arg>`. */ +const SKIP_REQUIRED_POSITIONAL = new Set<string>([ + // raw comment/thread UUID discussion subcommands (no in-command parent list) + "replies", + "reply", + "edit", + "edit-reply", + "delete-comment", + "delete-reply", + "resolve", + "unresolve", + "react", + "unreact", + "unreact-id", + // relation ops: require relation flags / a raw relation UUID + "add", + "remove", + // full-text search takes a free-text query, not an entity id + "search", + // create's leading positional is a free-text name/title filled by the + // wizard's text field, not an entity picker (covered by the wizard invariant) + "create", +]); + +function listCommandFiles(): string[] { + const files: string[] = []; + for (const entry of readdirSync(COMMANDS_DIR, { withFileTypes: true })) { + if (entry.isDirectory()) { + for (const sub of readdirSync(join(COMMANDS_DIR, entry.name))) { + if (sub.endsWith(".ts")) files.push(join(entry.name, sub)); + } + } else if (entry.name.endsWith(".ts")) { + files.push(entry.name); + } + } + return files; +} + +interface CommandDef { + file: string; + verb: string; + raw: string; +} + +function extractCommands(content: string, file: string): CommandDef[] { + const defs: CommandDef[] = []; + for (const match of content.matchAll(/\.command\("([^"]+)"\)/g)) { + const raw = match[1]; + if (raw === undefined) continue; + const verb = raw.split(" ")[0]; + if (verb === undefined) continue; + defs.push({ file, verb, raw }); + } + return defs; +} + +describe("interactive coverage sweep", () => { + const files = listCommandFiles(); + const perFile = new Map<string, string>(); + for (const file of files) { + perFile.set(file, readFileSync(join(COMMANDS_DIR, file), "utf-8")); + } + + it("every create/update command wires a wizard via maybeCollectInteractive", () => { + const offenders: string[] = []; + for (const [file, content] of perFile) { + const hasCreateOrUpdate = extractCommands(content, file).some( + (c) => c.verb === "create" || c.verb === "update", + ); + if (!hasCreateOrUpdate) continue; + if (!content.includes("maybeCollectInteractive")) { + offenders.push(file); + } + } + expect(offenders).toEqual([]); + }); + + it("positional-id commands optionalise their leading entity positional", () => { + const offenders: string[] = []; + for (const [file, content] of perFile) { + for (const { verb, raw } of extractCommands(content, file)) { + // Only the leading positional matters for the picker. + const requiresLeadingPositional = /^\S+\s+<[^>]+>/.test(raw); + if (!requiresLeadingPositional) continue; + if (SKIP_REQUIRED_POSITIONAL.has(verb)) continue; + offenders.push(`${file}: ${raw}`); + } + } + expect(offenders).toEqual([]); + }); + + it("content domains export the expected wizard specs", async () => { + const comments = await import("../../../src/commands/comments.js"); + const documents = await import("../../../src/commands/documents.js"); + const attachments = await import("../../../src/commands/attachments.js"); + expect(comments.commentCreateSpec).toBeDefined(); + expect(documents.documentCreateSpec).toBeDefined(); + expect(documents.documentUpdateSpec).toBeDefined(); + expect(attachments.attachmentCreateSpec).toBeDefined(); + }); +}); diff --git a/tests/unit/interactive/cycle-specs.test.ts b/tests/unit/interactive/cycle-specs.test.ts new file mode 100644 index 00000000..fe19b447 --- /dev/null +++ b/tests/unit/interactive/cycle-specs.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { cycleListSpec } from "../../../src/commands/cycles.js"; + +describe("cycleListSpec", () => { + it("offers an optional team select (cycles are team-scoped)", () => { + expect(cycleListSpec.fields).toHaveLength(1); + const team = cycleListSpec.fields[0]; + expect(team?.name).toBe("team"); + expect(team?.kind).toBe("select"); + expect(team?.required).toBeUndefined(); + expect(team?.choices).toBeDefined(); + }); +}); diff --git a/tests/unit/interactive/engine.test.ts b/tests/unit/interactive/engine.test.ts new file mode 100644 index 00000000..932ecf31 --- /dev/null +++ b/tests/unit/interactive/engine.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../../../src/common/context.js"; +import { InteractiveCancelledError } from "../../../src/common/errors.js"; +import { + collectInteractive, + maybeCollectInteractive, +} from "../../../src/common/interactive/engine.js"; +import type { + PromptIO, + PromptSpec, +} from "../../../src/common/interactive/types.js"; + +const CANCEL = Symbol("cancel"); + +const ctx = {} as CommandContext; + +/** + * Build a fake PromptIO from scripted answers keyed by prompt message. Records + * the order in which primitives were invoked so ordering assertions are + * possible. + */ +function fakeIO( + answers: Record<string, string | string[] | boolean | symbol>, + calls: string[] = [], +): PromptIO { + return { + text: async (o) => { + calls.push(`text:${o.message}`); + return (answers[o.message] as string | symbol) ?? ""; + }, + multiline: async (o) => { + calls.push(`multiline:${o.message}`); + return (answers[o.message] as string | symbol) ?? ""; + }, + select: async (o) => { + calls.push(`select:${o.message}`); + return (answers[o.message] as string | symbol) ?? ""; + }, + autocomplete: async (o) => { + calls.push(`autocomplete:${o.message}`); + return (answers[o.message] as string | symbol) ?? ""; + }, + multiselect: async (o) => { + calls.push(`multiselect:${o.message}`); + return (answers[o.message] as string[] | symbol) ?? []; + }, + autocompleteMultiselect: async (o) => { + calls.push(`autocompleteMultiselect:${o.message}`); + return (answers[o.message] as string[] | symbol) ?? []; + }, + confirm: async (o) => { + calls.push(`confirm:${o.message}`); + return (answers[o.message] as boolean | symbol) ?? false; + }, + isCancel: (v) => v === CANCEL, + }; +} + +interface Opts extends Record<string, unknown> { + team?: string; + title?: string; + cycle?: string; + project?: string; + milestone?: string; +} + +describe("collectInteractive", () => { + it("skips fields whose when() returns false", async () => { + const calls: string[] = []; + const io = fakeIO({ Milestone: "M1" }, calls); + const spec: PromptSpec<Opts> = { + fields: [ + { + name: "milestone", + kind: "text", + message: "Milestone", + when: (d) => d.project !== undefined, + }, + ], + }; + + const result = await collectInteractive(ctx, spec, {}, io); + + expect(result.milestone).toBeUndefined(); + expect(calls).toEqual([]); + }); + + it("skips a field when the flag already provided it (skipIfProvided)", async () => { + const calls: string[] = []; + const io = fakeIO({ Title: "prompted" }, calls); + const spec: PromptSpec<Opts> = { + fields: [{ name: "title", kind: "text", message: "Title" }], + }; + + const result = await collectInteractive( + ctx, + spec, + { title: "from-flag" }, + io, + ); + + expect(result.title).toBe("from-flag"); + expect(calls).toEqual([]); + }); + + it("re-prompts (does not skip) when skipIfProvided is false", async () => { + const io = fakeIO({ Title: "prompted" }); + const spec: PromptSpec<Opts> = { + fields: [ + { + name: "title", + kind: "text", + message: "Title", + skipIfProvided: false, + }, + ], + }; + + const result = await collectInteractive( + ctx, + spec, + { title: "from-flag" }, + io, + ); + + expect(result.title).toBe("prompted"); + }); + + it("loads choices lazily so ordering deps hold (team before cycleChoices)", async () => { + const calls: string[] = []; + const io = fakeIO({ Team: "ENG", Cycle: "3" }, calls); + + const cycleChoices = vi.fn(async (_ctx, draft: Partial<Opts>) => { + // The team must already be in the draft by the time cycle choices load. + expect(draft.team).toBe("ENG"); + return [{ value: "3", label: "Cycle 3" }]; + }); + + const spec: PromptSpec<Opts> = { + fields: [ + { + name: "team", + kind: "select", + message: "Team", + choices: async () => [{ value: "ENG", label: "Engineering" }], + }, + { + name: "cycle", + kind: "select", + message: "Cycle", + choices: cycleChoices, + }, + ], + }; + + const result = await collectInteractive(ctx, spec, {}, io); + + expect(result.team).toBe("ENG"); + expect(result.cycle).toBe("3"); + expect(cycleChoices).toHaveBeenCalledTimes(1); + expect(calls).toEqual(["select:Team", "select:Cycle"]); + }); + + it("throws InteractiveCancelledError on cancel", async () => { + const io = fakeIO({ Title: CANCEL }); + const spec: PromptSpec<Opts> = { + fields: [{ name: "title", kind: "text", message: "Title" }], + }; + + await expect(collectInteractive(ctx, spec, {}, io)).rejects.toBeInstanceOf( + InteractiveCancelledError, + ); + }); + + it("passes the validate function through to the IO", async () => { + const validate = vi.fn((v: string) => + v.length < 2 ? "too short" : undefined, + ); + let seenValidate: ((v: string) => string | undefined) | undefined; + const io: PromptIO = { + ...fakeIO({}), + text: async (o) => { + seenValidate = o.validate; + return "ok"; + }, + }; + const spec: PromptSpec<Opts> = { + fields: [{ name: "title", kind: "text", message: "Title", validate }], + }; + + await collectInteractive(ctx, spec, {}, io); + + expect(seenValidate).toBe(validate); + expect(seenValidate?.("x")).toBe("too short"); + }); + + it("treats an empty answer as unset so it never overwrites a value", async () => { + // A blank text prompt (clack returns "") or an empty-valued "none" choice + // must leave the draft untouched, otherwise update builders that test + // `!== undefined` would clear the existing value. + const io = fakeIO({ Title: "", Team: "" }); + const spec: PromptSpec<Opts> = { + fields: [ + { name: "title", kind: "text", message: "Title" }, + { name: "team", kind: "select", message: "Team" }, + ], + }; + + const result = await collectInteractive(ctx, spec, {}, io); + + expect("title" in result).toBe(false); + expect("team" in result).toBe(false); + }); + + it("seeds the initial value from default(draft)", async () => { + let seenInitial: string | undefined; + const io: PromptIO = { + ...fakeIO({}), + text: async (o) => { + seenInitial = o.initialValue; + return o.initialValue ?? ""; + }, + }; + const spec: PromptSpec<Opts> = { + fields: [ + { + name: "title", + kind: "text", + message: "Title", + default: (d) => `re: ${d.team ?? "none"}`, + }, + ], + }; + + const result = await collectInteractive(ctx, spec, { team: "ENG" }, io); + + expect(seenInitial).toBe("re: ENG"); + expect(result.title).toBe("re: ENG"); + }); +}); + +describe("maybeCollectInteractive positional picker", () => { + const origStdin = process.stdin.isTTY; + const origStdout = process.stdout.isTTY; + const origCI = process.env["CI"]; + + function setTTY(on: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value: on, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: on, + configurable: true, + }); + } + + const emptySpec: PromptSpec<Record<string, never>> = { fields: [] }; + + it("runs the picker when the positional is absent and gating passes", async () => { + setTTY(true); + process.env["CI"] = ""; + const picker = vi.fn(async () => "ENG-42"); + + const result = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + { interactive: true }, + { + spec: emptySpec, + options: {}, + missingRequired: true, + positional: { name: "issue", value: undefined, picker }, + io: fakeIO({}), + }, + ); + + expect(picker).toHaveBeenCalledTimes(1); + expect(result.positional).toBe("ENG-42"); + + setTTY(!!origStdin && !!origStdout); + process.env["CI"] = origCI ?? ""; + }); + + it("does not run the picker when the positional is already provided", async () => { + setTTY(true); + process.env["CI"] = ""; + const picker = vi.fn(async () => "PICKED"); + + const result = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + { interactive: true }, + { + spec: emptySpec, + options: {}, + missingRequired: false, + positional: { name: "issue", value: "ENG-1", picker }, + io: fakeIO({}), + }, + ); + + expect(picker).not.toHaveBeenCalled(); + expect(result.positional).toBe("ENG-1"); + + setTTY(!!origStdin && !!origStdout); + process.env["CI"] = origCI ?? ""; + }); + + it("returns inputs untouched (no picker) when gating suppresses prompts", async () => { + setTTY(false); + const picker = vi.fn(async () => "PICKED"); + + const result = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + { interactive: true }, + { + spec: emptySpec, + options: {}, + missingRequired: true, + positional: { name: "issue", value: undefined, picker }, + io: fakeIO({}), + }, + ); + + expect(picker).not.toHaveBeenCalled(); + expect(result.positional).toBeUndefined(); + + setTTY(!!origStdin && !!origStdout); + }); +}); diff --git a/tests/unit/interactive/gating.test.ts b/tests/unit/interactive/gating.test.ts new file mode 100644 index 00000000..5282f0db --- /dev/null +++ b/tests/unit/interactive/gating.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { shouldPrompt } from "../../../src/common/interactive/gating.js"; + +const origStdin = process.stdin.isTTY; +const origStdout = process.stdout.isTTY; +const origCI = process.env["CI"]; +const origNoInteractive = process.env["LINEARIS_NO_INTERACTIVE"]; + +function setTTY(stdin: boolean, stdout: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + value: stdin, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: stdout, + configurable: true, + }); +} + +beforeEach(() => { + // Default: a clean interactive terminal with no suppress signals. + setTTY(true, true); + process.env["CI"] = ""; + process.env["LINEARIS_NO_INTERACTIVE"] = ""; +}); + +afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { + value: origStdin, + configurable: true, + }); + Object.defineProperty(process.stdout, "isTTY", { + value: origStdout, + configurable: true, + }); + if (origCI === undefined) delete process.env["CI"]; + else process.env["CI"] = origCI; + if (origNoInteractive === undefined) + delete process.env["LINEARIS_NO_INTERACTIVE"]; + else process.env["LINEARIS_NO_INTERACTIVE"] = origNoInteractive; +}); + +describe("shouldPrompt", () => { + it("prompts when a required arg is missing on a clean TTY", () => { + expect(shouldPrompt({}, { missingRequired: true })).toBe(true); + }); + + it("prompts when -i is explicit even with no missing required", () => { + expect( + shouldPrompt({ interactive: true }, { missingRequired: false }), + ).toBe(true); + }); + + it("does not prompt when nothing missing and -i not passed", () => { + expect(shouldPrompt({}, { missingRequired: false })).toBe(false); + }); + + it("does not prompt when stdin is not a TTY", () => { + setTTY(false, true); + expect(shouldPrompt({}, { missingRequired: true })).toBe(false); + }); + + it("does not prompt when stdout is not a TTY", () => { + setTTY(true, false); + expect(shouldPrompt({ interactive: true }, { missingRequired: true })).toBe( + false, + ); + }); + + it("does not prompt when --no-interactive passed", () => { + expect( + shouldPrompt({ interactive: false }, { missingRequired: true }), + ).toBe(false); + }); + + it("does not prompt when CI is set", () => { + process.env["CI"] = "true"; + expect(shouldPrompt({}, { missingRequired: true })).toBe(false); + }); + + it("does not prompt when LINEARIS_NO_INTERACTIVE is set", () => { + process.env["LINEARIS_NO_INTERACTIVE"] = "1"; + expect(shouldPrompt({}, { missingRequired: true })).toBe(false); + }); + + it("does not prompt when --compact passed", () => { + expect(shouldPrompt({ compact: true }, { missingRequired: true })).toBe( + false, + ); + }); + + it("does not prompt when --fields passed", () => { + expect( + shouldPrompt({ fields: ["identifier"] }, { missingRequired: true }), + ).toBe(false); + }); + + it("prompts when --fields is an empty array", () => { + expect(shouldPrompt({ fields: [] }, { missingRequired: true })).toBe(true); + }); +}); diff --git a/tests/unit/interactive/initiative-specs.test.ts b/tests/unit/interactive/initiative-specs.test.ts new file mode 100644 index 00000000..3747151c --- /dev/null +++ b/tests/unit/interactive/initiative-specs.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + initiativeCreateSpec, + initiativeUpdateSpec, +} from "../../../src/commands/initiatives/entity.js"; + +describe("initiativeCreateSpec", () => { + it("requires name", () => { + const required = initiativeCreateSpec.fields + .filter((f) => f.required) + .map((f) => f.name); + expect(required).toContain("name"); + }); + + it("prompts name first", () => { + expect(initiativeCreateSpec.fields[0]?.name).toBe("name"); + }); + + it("offers owner and status pickers", () => { + const names = initiativeCreateSpec.fields.map((f) => f.name); + expect(names).toEqual(expect.arrayContaining(["owner", "status"])); + const status = initiativeCreateSpec.fields.find((f) => f.name === "status"); + expect(status?.kind).toBe("select"); + }); +}); + +describe("initiativeUpdateSpec", () => { + it("has no required fields (all optional on update)", () => { + expect(initiativeUpdateSpec.fields.every((f) => !f.required)).toBe(true); + }); + + it("seeds defaults from current option values", () => { + const name = initiativeUpdateSpec.fields.find((f) => f.name === "name"); + expect(name?.default?.({ name: "cur" })).toBe("cur"); + }); +}); diff --git a/tests/unit/interactive/issue-specs.test.ts b/tests/unit/interactive/issue-specs.test.ts new file mode 100644 index 00000000..479ebd46 --- /dev/null +++ b/tests/unit/interactive/issue-specs.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + issueCreateSpec, + issueUpdateSpec, +} from "../../../src/commands/issues.js"; + +describe("issueCreateSpec", () => { + it("prompts team before its dependent fields (cycle/status)", () => { + const names = issueCreateSpec.fields.map((f) => f.name); + expect(names.indexOf("team")).toBeLessThan(names.indexOf("cycle")); + expect(names.indexOf("team")).toBeLessThan(names.indexOf("status")); + }); + + it("prompts project before milestone", () => { + const names = issueCreateSpec.fields.map((f) => f.name); + expect(names.indexOf("project")).toBeLessThan( + names.indexOf("projectMilestone"), + ); + }); + + it("requires team and title", () => { + const required = issueCreateSpec.fields + .filter((f) => f.required) + .map((f) => f.name); + expect(required).toContain("team"); + expect(required).toContain("title"); + }); + + it("gates cycle/status/milestone with when()", () => { + const cycle = issueCreateSpec.fields.find((f) => f.name === "cycle"); + const status = issueCreateSpec.fields.find((f) => f.name === "status"); + const milestone = issueCreateSpec.fields.find( + (f) => f.name === "projectMilestone", + ); + expect(cycle?.when?.({})).toBe(false); + expect(cycle?.when?.({ team: "t" })).toBe(true); + expect(status?.when?.({})).toBe(false); + expect(milestone?.when?.({})).toBe(false); + expect(milestone?.when?.({ project: "p" })).toBe(true); + }); +}); + +describe("issueUpdateSpec", () => { + it("has no required fields (all optional on update)", () => { + expect(issueUpdateSpec.fields.every((f) => !f.required)).toBe(true); + }); + + it("seeds defaults from current option values", () => { + const title = issueUpdateSpec.fields.find((f) => f.name === "title"); + expect(title?.default?.({ title: "cur" })).toBe("cur"); + }); +}); diff --git a/tests/unit/interactive/label-specs.test.ts b/tests/unit/interactive/label-specs.test.ts new file mode 100644 index 00000000..77e7bf94 --- /dev/null +++ b/tests/unit/interactive/label-specs.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + labelCreateSpec, + labelUpdateSpec, +} from "../../../src/commands/labels.js"; + +describe("labelCreateSpec", () => { + it("requires only name (team is optional -> workspace label)", () => { + const required = labelCreateSpec.fields + .filter((f) => f.required) + .map((f) => f.name); + expect(required).toEqual(["name"]); + }); + + it("prompts name before team", () => { + const names = labelCreateSpec.fields.map((f) => f.name); + expect(names.indexOf("name")).toBeLessThan(names.indexOf("team")); + }); + + it("uses a select for the team picker", () => { + const team = labelCreateSpec.fields.find((f) => f.name === "team"); + expect(team?.kind).toBe("select"); + expect(team?.choices).toBeDefined(); + }); + + it("validates color as a hex string (blank allowed)", () => { + const color = labelCreateSpec.fields.find((f) => f.name === "color"); + expect(color?.validate?.("")).toBeUndefined(); + expect(color?.validate?.("#B45309")).toBeUndefined(); + expect(color?.validate?.("blue")).toBeDefined(); + }); +}); + +describe("labelUpdateSpec", () => { + it("has no required fields (all optional on update)", () => { + expect(labelUpdateSpec.fields.every((f) => !f.required)).toBe(true); + }); + + it("seeds defaults from current option values", () => { + const name = labelUpdateSpec.fields.find((f) => f.name === "name"); + expect(name?.default?.({ name: "bug" })).toBe("bug"); + const color = labelUpdateSpec.fields.find((f) => f.name === "color"); + expect(color?.default?.({ color: "#111111" })).toBe("#111111"); + }); + + it("validates color the same way as create", () => { + const color = labelUpdateSpec.fields.find((f) => f.name === "color"); + expect(color?.validate?.("#000000")).toBeUndefined(); + expect(color?.validate?.("nope")).toBeDefined(); + }); +}); diff --git a/tests/unit/interactive/milestone-specs.test.ts b/tests/unit/interactive/milestone-specs.test.ts new file mode 100644 index 00000000..a85d3f75 --- /dev/null +++ b/tests/unit/interactive/milestone-specs.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + milestoneCreateSpec, + milestoneUpdateSpec, +} from "../../../src/commands/milestones.js"; + +describe("milestoneCreateSpec", () => { + it("requires project and name (project is the parent scope)", () => { + const required = milestoneCreateSpec.fields + .filter((f) => f.required) + .map((f) => f.name); + expect(required).toContain("project"); + expect(required).toContain("name"); + }); + + it("prompts project before name (project-scoped cross-field order)", () => { + const names = milestoneCreateSpec.fields.map((f) => f.name); + expect(names.indexOf("project")).toBeLessThan(names.indexOf("name")); + }); + + it("uses a select for the project picker", () => { + const project = milestoneCreateSpec.fields.find( + (f) => f.name === "project", + ); + expect(project?.kind).toBe("select"); + expect(project?.choices).toBeDefined(); + }); +}); + +describe("milestoneUpdateSpec", () => { + it("has no required fields (all optional on update)", () => { + expect(milestoneUpdateSpec.fields.every((f) => !f.required)).toBe(true); + }); + + it("seeds defaults from current option values", () => { + const name = milestoneUpdateSpec.fields.find((f) => f.name === "name"); + expect(name?.default?.({ name: "cur" })).toBe("cur"); + }); +}); diff --git a/tests/unit/interactive/project-specs.test.ts b/tests/unit/interactive/project-specs.test.ts new file mode 100644 index 00000000..c51c797d --- /dev/null +++ b/tests/unit/interactive/project-specs.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + projectCreateSpec, + projectUpdateSpec, +} from "../../../src/commands/projects.js"; + +describe("projectCreateSpec", () => { + it("requires name and teams", () => { + const required = projectCreateSpec.fields + .filter((f) => f.required) + .map((f) => f.name); + expect(required).toContain("name"); + expect(required).toContain("teams"); + }); + + it("prompts name before teams", () => { + const names = projectCreateSpec.fields.map((f) => f.name); + expect(names.indexOf("name")).toBeLessThan(names.indexOf("teams")); + }); + + it("offers status, lead, members, and labels pickers", () => { + const names = projectCreateSpec.fields.map((f) => f.name); + expect(names).toEqual( + expect.arrayContaining(["status", "lead", "members", "labels"]), + ); + }); + + it("uses multiselect for teams/members/labels", () => { + for (const name of ["teams", "members", "labels"]) { + const field = projectCreateSpec.fields.find((f) => f.name === name); + expect(field?.kind).toBe("multiselect"); + } + }); +}); + +describe("projectUpdateSpec", () => { + it("has no required fields (all optional on update)", () => { + expect(projectUpdateSpec.fields.every((f) => !f.required)).toBe(true); + }); + + it("seeds defaults from current option values", () => { + const name = projectUpdateSpec.fields.find((f) => f.name === "name"); + expect(name?.default?.({ name: "cur" })).toBe("cur"); + const target = projectUpdateSpec.fields.find( + (f) => f.name === "targetDate", + ); + expect(target?.default?.({ targetDate: "2026-01-01" })).toBe("2026-01-01"); + }); +}); From 454bc1c8a34f98623beaa57b5ffd4e6384c22f0c Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:08:30 +0200 Subject: [PATCH 03/13] feat(interactive): add date picker, lazy intro, and shared entity pickers Extend the descriptor-driven prompt engine and finish wiring the domains that had drifted ahead of it, keeping the non-interactive JSON contract byte-identical. Engine/adapter: - add a `date` field kind: a segmented picker gated behind a confirm for optional fields so "leave unset" stays reachable, with no min/max so the interactive path matches the CLI's unconstrained date handling. - render a spec's intro lazily, exactly once, immediately before the first field that actually prompts (never when everything is skipped/provided). - factor the duplicated flat single-select pickers into a shared `makeChoicePicker` helper (pickers.ts). Per-domain wiring: attachments, comments, issues, labels, milestones, projects, and initiatives entity gain their create/update wizards and positional entity pickers, with the matching spec tests updated. --- src/commands/attachments.ts | 10 +- src/commands/comments.ts | 24 +--- src/commands/initiatives/entity.ts | 10 +- src/commands/issues.ts | 128 +++++++++--------- src/commands/labels.ts | 3 - src/commands/milestones.ts | 9 +- src/commands/projects.ts | 17 +-- src/common/interactive/clack-io.ts | 18 +++ src/common/interactive/engine.ts | 89 +++++++++++- src/common/interactive/pickers.ts | 36 +++++ src/common/interactive/types.ts | 27 +++- tests/unit/interactive/emoji-choices.test.ts | 17 +++ tests/unit/interactive/engine.test.ts | 89 +++++++++++- .../unit/interactive/initiative-specs.test.ts | 7 +- tests/unit/interactive/issue-specs.test.ts | 7 +- tests/unit/interactive/label-specs.test.ts | 9 +- .../unit/interactive/milestone-specs.test.ts | 7 +- tests/unit/interactive/project-specs.test.ts | 11 +- 18 files changed, 373 insertions(+), 145 deletions(-) create mode 100644 src/common/interactive/pickers.ts create mode 100644 tests/unit/interactive/emoji-choices.test.ts diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index 3d3aa7a2..033fb36b 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -11,6 +11,7 @@ import { import { asUuid } from "../common/identifier.js"; import { issueChoices } from "../common/interactive/choices.js"; import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import { makeChoicePicker } from "../common/interactive/pickers.js"; import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -76,14 +77,7 @@ export const attachmentCreateSpec: PromptSpec<CreateWizardOptions> = { }; /** Entity picker for an absent `[issue]` positional (shared loader). */ -async function issuePicker(ctx: CommandContext, io: PromptIO): Promise<string> { - const options = await issueChoices(ctx); - const answer = await io.select({ message: "Issue", options }); - if (io.isCancel(answer)) { - throw new InteractiveCancelledError(); - } - return answer as string; -} +const issuePicker = makeChoicePicker("Issue", issueChoices); /** * Cross-field picker for an absent attachment `<id>`. First picks the parent diff --git a/src/commands/comments.ts b/src/commands/comments.ts index 623feb6a..d54ca227 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -13,6 +13,7 @@ import { import { asUuid } from "../common/identifier.js"; import { emojiChoices, issueChoices } from "../common/interactive/choices.js"; import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import { makeChoicePicker } from "../common/interactive/pickers.js"; import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; @@ -88,14 +89,7 @@ export const commentEditSpec: PromptSpec<BodyWizardOptions> = { * issue's identifier (which `resolveIssueId` accepts). Shared loader in * choices.ts keeps it in sync with the issues domain. */ -async function issuePicker(ctx: CommandContext, io: PromptIO): Promise<string> { - const options = await issueChoices(ctx); - const answer = await io.select({ message: "Issue", options }); - if (io.isCancel(answer)) { - throw new InteractiveCancelledError(); - } - return answer as string; -} +const issuePicker = makeChoicePicker("Issue", issueChoices); /** * Cross-field picker for an absent comment/thread positional. First picks the @@ -130,19 +124,7 @@ async function commentPicker( } /** Emoji picker for an absent `[emoji]` positional. */ -async function emojiPicker( - _ctx: CommandContext, - io: PromptIO, -): Promise<string> { - const answer = await io.select({ - message: "Reaction", - options: emojiChoices(), - }); - if (io.isCancel(answer)) { - throw new InteractiveCancelledError(); - } - return answer as string; -} +const emojiPicker = makeChoicePicker("Reaction", async () => emojiChoices()); const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index d2dfa93b..8c737359 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -247,7 +247,7 @@ export const initiativeCreateSpec: PromptSpec<InitiativeCreateWizardOptions> = { message: "Status", choices: async () => initiativeStatusChoices(), }, - { name: "targetDate", kind: "text", message: "Target date (YYYY-MM-DD)" }, + { name: "targetDate", kind: "date", message: "Target date" }, ], }; @@ -262,19 +262,16 @@ export const initiativeUpdateSpec: PromptSpec<InitiativeUpdateWizardOptions> = { name: "name", kind: "text", message: "Name", - default: (draft) => draft.name, }, { name: "description", kind: "multiline", message: "Description", - default: (draft) => draft.description, }, { name: "content", kind: "multiline", message: "Content (markdown)", - default: (draft) => draft.content, }, { name: "owner", @@ -290,9 +287,8 @@ export const initiativeUpdateSpec: PromptSpec<InitiativeUpdateWizardOptions> = { }, { name: "targetDate", - kind: "text", - message: "Target date (YYYY-MM-DD)", - default: (draft) => draft.targetDate, + kind: "date", + message: "Target date", }, ], }; diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 2284e357..2137b123 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -4,10 +4,7 @@ import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; import { parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; -import { - InteractiveCancelledError, - invalidParameterError, -} from "../common/errors.js"; +import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; import { asUuid, @@ -31,7 +28,8 @@ import { } from "../common/interactive/choices.js"; import { maybeCollectInteractive } from "../common/interactive/engine.js"; import { shouldPrompt } from "../common/interactive/gating.js"; -import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; +import { makeChoicePicker } from "../common/interactive/pickers.js"; +import type { PromptSpec } from "../common/interactive/types.js"; import type { RawFilterFlags } from "../common/issue-filter.js"; import { parseEstimateOption, @@ -185,16 +183,6 @@ type UpdateWizardOptions = UpdateOptions & { team?: string; } & Record<string, unknown>; -function validateDueDate(value: string): string | undefined { - if (!value) return undefined; - try { - parseDueDate(value); - return undefined; - } catch (error) { - return error instanceof Error ? error.message : "invalid date"; - } -} - /** * Interactive wizard for `issues create`. Fields are ordered so cross-field * deps resolve (team before cycle/status; project before milestone). Entity @@ -277,9 +265,8 @@ export const issueCreateSpec: PromptSpec<CreateWizardOptions> = { }, { name: "dueDate", - kind: "text", - message: "Due date (YYYY-MM-DD)", - validate: validateDueDate, + kind: "date", + message: "Due date", }, ], }; @@ -299,13 +286,11 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { name: "title", kind: "text", message: "Title", - default: (draft) => draft.title, }, { name: "description", kind: "multiline", message: "Description", - default: (draft) => draft.description, }, { name: "assignee", @@ -370,14 +355,54 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { }, { name: "dueDate", - kind: "text", - message: "Due date (YYYY-MM-DD)", - validate: validateDueDate, - default: (draft) => draft.dueDate, + kind: "date", + message: "Due date", }, ], }; +/** Wizard shape for the discussion body prompt: a single required `--body`. */ +type BodyWizardOptions = { body?: string } & Record<string, unknown>; + +/** + * Shared body wizard for the discussion subcommands (`discuss`, `reply`, + * `edit`, `edit-reply`). A single required multiline `body` field, mirroring the + * `comments` domain. When `--body` is absent and interactive gating passes the + * user is prompted; the non-interactive/agent path keeps the existing + * "--body is required" throw. + */ +const discussionBodySpec: PromptSpec<BodyWizardOptions> = { + intro: "Enter the comment body", + fields: [ + { name: "body", kind: "multiline", message: "Body", required: true }, + ], +}; + +/** + * Collect the discussion `--body` interactively when it is missing and gating + * allows, else preserve the "--body is required" error for agents/pipes. + */ +async function resolveDiscussionBody( + ctx: CommandContext, + command: Command, + options: DiscussionBodyOptions, +): Promise<string> { + const filled = await maybeCollectInteractive<BodyWizardOptions, never>( + ctx, + getRootOpts(command), + { + spec: discussionBodySpec, + options: options as BodyWizardOptions, + missingRequired: options.body === undefined, + }, + ); + const body = filled.options.body; + if (body === undefined) { + throw invalidParameterError("--body", "is required"); + } + return body; +} + /** * The `labels` multiselect yields a `string[]` of UUIDs, but the command body * expects the CLI-shaped comma-separated `string`. Normalise it in place so the @@ -400,32 +425,13 @@ function normalizeWizardLabels<O extends { labels?: unknown }>(filled: O): O { * Entity picker for an absent `<issue>` positional. Lists recent open issues * and returns the selected issue's identifier (which the resolver accepts). */ -async function issuePicker(ctx: CommandContext, io: PromptIO): Promise<string> { - const options = await issueChoices(ctx); - const answer = await io.select({ message: "Issue", options }); - if (io.isCancel(answer)) { - throw new InteractiveCancelledError(); - } - return answer as string; -} +const issuePicker = makeChoicePicker("Issue", issueChoices); /** * Emoji picker for an absent `[emoji]` positional. Returns the emoji glyph, * which flows into `resolveReactionEmojiInput` unchanged as the positional. */ -async function emojiPicker( - _ctx: CommandContext, - io: PromptIO, -): Promise<string> { - const answer = await io.select({ - message: "Reaction", - options: emojiChoices(), - }); - if (io.isCancel(answer)) { - throw new InteractiveCancelledError(); - } - return answer as string; -} +const emojiPicker = makeChoicePicker("Reaction", async () => emojiChoices()); const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; @@ -1196,15 +1202,12 @@ export function setupIssuesCommands(program: Command): void { async (issueArg, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - const issue = await resolveIssuePositional(ctx, command, issueArg); + const body = await resolveDiscussionBody(ctx, command, options); const issueId = await resolveIssueId(ctx.gql, issue); const result = await startIssueDiscussion(ctx.gql, { issueId, - body: options.body, + body, }); outputSuccess(result); @@ -1213,7 +1216,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("activity <issue>") + .command("activity [issue]") .description( "chronological activity timeline: comment threads plus history events", ) @@ -1226,10 +1229,11 @@ export function setupIssuesCommands(program: Command): void { .option("--comments-only", "exclude non-comment history events") .option("--with-reactions", "include normalized comment reactions") .action( - commandAction<[string, ActivityOptions, Command]>( - async (issue, options, command) => { + commandAction<[string | undefined, ActivityOptions, Command]>( + async (issueArg, options, command) => { const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); const issueId = await resolveIssueId(ctx.gql, issue); const paginationOptions = buildPaginationOptions( parseLimit(options.limit), @@ -1337,13 +1341,11 @@ export function setupIssuesCommands(program: Command): void { async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const body = await resolveDiscussionBody(ctx, command, options); const result = await replyToDiscussion(ctx.gql, { threadId: asUuid(thread), - body: options.body, + body, entityKind: "issue", }); @@ -1361,15 +1363,13 @@ export function setupIssuesCommands(program: Command): void { async (comment, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const body = await resolveDiscussionBody(ctx, command, options); const result = await editDiscussionComment( ctx.gql, asUuid(comment), { - body: options.body, + body, }, "issue", ); @@ -1388,15 +1388,13 @@ export function setupIssuesCommands(program: Command): void { async (reply, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const body = await resolveDiscussionBody(ctx, command, options); const result = await editDiscussionReply( ctx.gql, asUuid(reply), { - body: options.body, + body, }, "issue", ); diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 78b0ce5e..417ab83b 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -122,13 +122,11 @@ export const labelUpdateSpec: PromptSpec<UpdateLabelWizardOptions> = { name: "name", kind: "text", message: "Name", - default: (draft) => draft.name as string | undefined, }, { name: "color", kind: "text", message: "Color (hex, e.g. #B45309)", - default: (draft) => draft.color as string | undefined, validate: (value) => value === "" || /^#[0-9a-fA-F]{6}$/.test(value) ? undefined @@ -138,7 +136,6 @@ export const labelUpdateSpec: PromptSpec<UpdateLabelWizardOptions> = { name: "description", kind: "multiline", message: "Description", - default: (draft) => draft.description as string | undefined, }, ], }; diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index 989a0ba8..391200c7 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -76,7 +76,7 @@ export const milestoneCreateSpec: PromptSpec<MilestoneCreateWizardOptions> = { }, { name: "name", kind: "text", message: "Name", required: true }, { name: "description", kind: "multiline", message: "Description" }, - { name: "targetDate", kind: "text", message: "Target date (YYYY-MM-DD)" }, + { name: "targetDate", kind: "date", message: "Target date" }, ], }; @@ -92,19 +92,16 @@ export const milestoneUpdateSpec: PromptSpec<MilestoneCreateWizardOptions> = { name: "name", kind: "text", message: "Name", - default: (draft) => draft.name as string | undefined, }, { name: "description", kind: "multiline", message: "Description", - default: (draft) => draft.description as string | undefined, }, { name: "targetDate", - kind: "text", - message: "Target date (YYYY-MM-DD)", - default: (draft) => draft.targetDate as string | undefined, + kind: "date", + message: "Target date", }, ], }; diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 26b1f035..28a38027 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -243,8 +243,8 @@ export const projectCreateSpec: PromptSpec<CreateWizardOptions> = { required: false, choices: labelChoices, }, - { name: "startDate", kind: "text", message: "Start date (YYYY-MM-DD)" }, - { name: "targetDate", kind: "text", message: "Target date (YYYY-MM-DD)" }, + { name: "startDate", kind: "date", message: "Start date" }, + { name: "targetDate", kind: "date", message: "Target date" }, ], }; @@ -260,19 +260,16 @@ export const projectUpdateSpec: PromptSpec<UpdateWizardOptions> = { name: "name", kind: "text", message: "Name", - default: (draft) => draft.name, }, { name: "description", kind: "multiline", message: "Description", - default: (draft) => draft.description, }, { name: "content", kind: "multiline", message: "Content (markdown)", - default: (draft) => draft.content, }, { name: "lead", @@ -307,15 +304,13 @@ export const projectUpdateSpec: PromptSpec<UpdateWizardOptions> = { }, { name: "startDate", - kind: "text", - message: "Start date (YYYY-MM-DD)", - default: (draft) => draft.startDate, + kind: "date", + message: "Start date", }, { name: "targetDate", - kind: "text", - message: "Target date (YYYY-MM-DD)", - default: (draft) => draft.targetDate, + kind: "date", + message: "Target date", }, ], }; diff --git a/src/common/interactive/clack-io.ts b/src/common/interactive/clack-io.ts index 337b9d9f..0e83bf96 100644 --- a/src/common/interactive/clack-io.ts +++ b/src/common/interactive/clack-io.ts @@ -2,6 +2,7 @@ import { autocomplete as clackAutocomplete, autocompleteMultiselect as clackAutocompleteMultiselect, confirm as clackConfirm, + date as clackDate, isCancel as clackIsCancel, multiline as clackMultiline, multiselect as clackMultiselect, @@ -10,6 +11,7 @@ import { } from "@clack/prompts"; import type { ConfirmPromptOptions, + DatePromptOptions, MultiLinePromptOptions, MultiSelectPromptOptions, PromptIO, @@ -24,6 +26,12 @@ import type { * console.log. */ export const clackIO: PromptIO = { + intro(message: string): void { + // Routed to stderr like every other primitive so stdout stays reserved for + // the final JSON payload. + process.stderr.write(`${message}\n`); + }, + text(options: TextPromptOptions): Promise<string | symbol> { return clackText({ message: options.message, @@ -147,6 +155,16 @@ export const clackIO: PromptIO = { }); }, + date(options: DatePromptOptions): Promise<Date | symbol> { + return clackDate({ + message: options.message, + output: process.stderr, + ...(options.initialValue !== undefined + ? { initialValue: options.initialValue } + : {}), + }); + }, + isCancel(value: unknown): boolean { return clackIsCancel(value); }, diff --git a/src/common/interactive/engine.ts b/src/common/interactive/engine.ts index 4fe45836..cc2c64ca 100644 --- a/src/common/interactive/engine.ts +++ b/src/common/interactive/engine.ts @@ -29,6 +29,7 @@ export async function collectInteractive<O extends Record<string, unknown>>( io: PromptIO = clackIO, ): Promise<O> { const draft: Record<string, unknown> = { ...provided }; + let introRendered = false; for (const field of spec.fields) { const partial = draft as Partial<O>; @@ -38,8 +39,26 @@ export async function collectInteractive<O extends Record<string, unknown>>( const skipIfProvided = field.skipIfProvided !== false; if (skipIfProvided && draft[field.name] !== undefined) continue; + // Render the intro lazily, exactly once, immediately before the first field + // that actually prompts — never when every field is skipped/provided (and + // not for a select/multiselect whose choices resolve empty, which + // promptField treats as an empty submission rather than a real prompt). + const renderIntro = (): void => { + if (!introRendered && spec.intro !== undefined) { + io.intro?.(spec.intro); + introRendered = true; + } + }; + const initial = field.default?.(partial); - const answer = await promptField(ctx, field, partial, io, initial); + const answer = await promptField( + ctx, + field, + partial, + io, + initial, + renderIntro, + ); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); @@ -63,15 +82,18 @@ async function promptField<O>( draft: Partial<O>, io: PromptIO, initial: string | undefined, + onPrompt: () => void, ): Promise<string | string[] | boolean | symbol> { switch (field.kind) { case "text": + onPrompt(); return io.text({ message: field.message, ...(initial !== undefined ? { initialValue: initial } : {}), ...(field.validate !== undefined ? { validate: field.validate } : {}), }); case "multiline": + onPrompt(); return io.multiline({ message: field.message, // Enter inserts a newline; a visible, Tab-focusable [ submit ] button @@ -86,6 +108,7 @@ async function promptField<O>( // current/future cycles): treat as an empty submission so the field is // left unset instead of rendering an unusable empty picker. if (options.length === 0) return ""; + onPrompt(); const args = { message: field.message, options, @@ -96,6 +119,7 @@ async function promptField<O>( case "multiselect": { const options = (await field.choices?.(ctx, draft)) ?? []; if (options.length === 0) return ""; + onPrompt(); const args = { message: field.message, options, @@ -107,11 +131,74 @@ async function promptField<O>( : io.multiselect(args); } case "confirm": + onPrompt(); return io.confirm({ message: field.message, ...(initial !== undefined ? { initialValue: initial === "true" } : {}), }); + case "date": { + // No min/max is passed to the picker: the non-interactive CLI enforces no + // date range (it allows backdated due dates and does not require + // targetDate >= startDate), so constraining the interactive path would + // reject inputs the CLI otherwise accepts. This is a pure input-ergonomics + // swap — semantics stay identical. + onPrompt(); + + // A segmented date picker cannot produce an empty value (its only escape + // is Esc = cancel). For optional fields we gate the picker behind a + // confirm so "leave unset / leave unchanged" (return "") stays reachable. + if (field.required !== true) { + const proceed = await io.confirm({ + message: `Set a ${field.message.toLowerCase()}?`, + initialValue: false, + }); + if (io.isCancel(proceed)) return proceed; + if (!proceed) return ""; + } + + const seed = + initial !== undefined ? parseDatePromptInitial(initial) : undefined; + const answer = await io.date({ + message: field.message, + ...(seed !== undefined ? { initialValue: seed } : {}), + }); + if (io.isCancel(answer)) return answer as symbol; + return formatLocalDate(answer as Date); + } + } +} + +/** + * Parse a `YYYY-MM-DD` seed string into a local `Date` for the picker's initial + * value. Returns undefined when the string is not a parseable date so the + * picker simply opens on today. + */ +function parseDatePromptInitial(value: string): Date | undefined { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return undefined; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return undefined; } + return date; +} + +/** + * Format a `Date` as a local `YYYY-MM-DD` string. Uses local getters (not + * `toISOString`, which is UTC) so the day never shifts across timezones. + */ +function formatLocalDate(date: Date): string { + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; } /** diff --git a/src/common/interactive/pickers.ts b/src/common/interactive/pickers.ts new file mode 100644 index 00000000..63086904 --- /dev/null +++ b/src/common/interactive/pickers.ts @@ -0,0 +1,36 @@ +import type { CommandContext } from "../context.js"; +import { InteractiveCancelledError } from "../errors.js"; +import type { Choice, PromptIO } from "./types.js"; + +/** + * A single-select entity picker: prompts the user to choose one option and + * returns the selected value. + */ +export type ChoicePicker = ( + ctx: CommandContext, + io: PromptIO, +) => Promise<string>; + +/** + * Build a reusable flat single-select picker. Loads its options via `load`, + * shows a `select` prompt with `message`, throws {@link InteractiveCancelledError} + * on cancel, and returns the chosen value. + * + * Use for the truly identical flat pickers duplicated across the content + * domains (the issue picker, the emoji picker). Cross-field pickers that first + * select a parent (comment/thread, attachment, milestone, cycle) are NOT built + * with this factory. + */ +export function makeChoicePicker( + message: string, + load: (ctx: CommandContext) => Promise<Choice[]>, +): ChoicePicker { + return async (ctx, io) => { + const options = await load(ctx); + const answer = await io.select({ message, options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + }; +} diff --git a/src/common/interactive/types.ts b/src/common/interactive/types.ts index 2f4f6def..c4e4afb8 100644 --- a/src/common/interactive/types.ts +++ b/src/common/interactive/types.ts @@ -1,7 +1,13 @@ import type { CommandContext } from "../context.js"; /** Field prompt kinds supported by the interactive engine. */ -type PromptKind = "text" | "multiline" | "select" | "multiselect" | "confirm"; +type PromptKind = + | "text" + | "multiline" + | "select" + | "multiselect" + | "confirm" + | "date"; /** A single selectable option shown in a select/multiselect prompt. */ export interface Choice { @@ -56,12 +62,29 @@ export interface ConfirmPromptOptions { initialValue?: boolean; } +/** + * Options for the segmented date picker. Modelled on clack's `DateOptions`, + * but deliberately minimal: only `message` and an optional seed value. No + * min/max is exposed because the non-interactive CLI enforces no date range, + * and the interactive path must stay semantically identical (see the engine's + * `date` case). + */ +export interface DatePromptOptions { + message: string; + initialValue?: Date; +} + /** * Injectable IO primitives. Each returns either a resolved value or a cancel * `symbol` (mirroring clack's `symbol` cancellation contract). Tests supply a * scripted fake so CI never blocks on a TTY. */ export interface PromptIO { + /** + * Render an intro line above the first prompt. Optional so scripted test + * fakes need not implement it. + */ + intro?(message: string): void; text(options: TextPromptOptions): Promise<string | symbol>; multiline(options: MultiLinePromptOptions): Promise<string | symbol>; select(options: SelectPromptOptions): Promise<string | symbol>; @@ -73,6 +96,8 @@ export interface PromptIO { options: MultiSelectPromptOptions, ): Promise<string[] | symbol>; confirm(options: ConfirmPromptOptions): Promise<boolean | symbol>; + /** Segmented date picker returning a `Date` (or a cancel `symbol`). */ + date(options: DatePromptOptions): Promise<Date | symbol>; isCancel(value: unknown): boolean; } diff --git a/tests/unit/interactive/emoji-choices.test.ts b/tests/unit/interactive/emoji-choices.test.ts new file mode 100644 index 00000000..168e70aa --- /dev/null +++ b/tests/unit/interactive/emoji-choices.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { COMMON_REACTION_EMOJI } from "../../../src/common/interactive/emoji-choices.js"; + +describe("COMMON_REACTION_EMOJI", () => { + it("is non-empty", () => { + expect(COMMON_REACTION_EMOJI.length).toBeGreaterThan(0); + }); + + it("has a non-empty glyph and shortcode for every entry", () => { + for (const choice of COMMON_REACTION_EMOJI) { + expect(choice.emoji).toBeTruthy(); + expect(choice.emoji.length).toBeGreaterThan(0); + expect(choice.shortcode).toBeTruthy(); + expect(choice.shortcode.length).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/unit/interactive/engine.test.ts b/tests/unit/interactive/engine.test.ts index 932ecf31..46453a02 100644 --- a/tests/unit/interactive/engine.test.ts +++ b/tests/unit/interactive/engine.test.ts @@ -20,10 +20,12 @@ const ctx = {} as CommandContext; * possible. */ function fakeIO( - answers: Record<string, string | string[] | boolean | symbol>, + answers: Record<string, string | string[] | boolean | Date | symbol>, calls: string[] = [], + intro?: (message: string) => void, ): PromptIO { return { + ...(intro !== undefined ? { intro } : {}), text: async (o) => { calls.push(`text:${o.message}`); return (answers[o.message] as string | symbol) ?? ""; @@ -52,6 +54,10 @@ function fakeIO( calls.push(`confirm:${o.message}`); return (answers[o.message] as boolean | symbol) ?? false; }, + date: async (o) => { + calls.push(`date:${o.message}`); + return (answers[o.message] as Date | symbol) ?? ""; + }, isCancel: (v) => v === CANCEL, }; } @@ -212,6 +218,36 @@ describe("collectInteractive", () => { expect("team" in result).toBe(false); }); + it("renders spec.intro exactly once, before the first field that prompts", async () => { + const intro = vi.fn(); + const io = fakeIO({ Title: "hello" }, [], intro); + const spec: PromptSpec<Opts> = { + intro: "Create a new issue", + fields: [ + { name: "title", kind: "text", message: "Title" }, + { name: "project", kind: "text", message: "Project" }, + ], + }; + + await collectInteractive(ctx, spec, {}, io); + + expect(intro).toHaveBeenCalledTimes(1); + expect(intro).toHaveBeenCalledWith("Create a new issue"); + }); + + it("does not render spec.intro when every field is skipped/provided", async () => { + const intro = vi.fn(); + const io = fakeIO({}, [], intro); + const spec: PromptSpec<Opts> = { + intro: "Create a new issue", + fields: [{ name: "title", kind: "text", message: "Title" }], + }; + + await collectInteractive(ctx, spec, { title: "from-flag" }, io); + + expect(intro).not.toHaveBeenCalled(); + }); + it("seeds the initial value from default(draft)", async () => { let seenInitial: string | undefined; const io: PromptIO = { @@ -237,6 +273,57 @@ describe("collectInteractive", () => { expect(seenInitial).toBe("re: ENG"); expect(result.title).toBe("re: ENG"); }); + + it("optional date: confirm gate accepted → picker value formatted to local YYYY-MM-DD", async () => { + // March 5 2024, local time. Local getters must produce 2024-03-05 + // regardless of the runner's timezone (a naive toISOString could shift it). + const picked = new Date(2024, 2, 5, 12, 0, 0); + const calls: string[] = []; + const io = fakeIO({ "Set a due date?": true, "Due date": picked }, calls); + const spec: PromptSpec<Opts & { dueDate?: string }> = { + fields: [{ name: "dueDate", kind: "date", message: "Due date" }], + }; + + const result = await collectInteractive(ctx, spec, {}, io); + + expect(result.dueDate).toBe("2024-03-05"); + expect(calls).toEqual(["confirm:Set a due date?", "date:Due date"]); + }); + + it("optional date: confirm gate declined → field left unset, picker never shown", async () => { + const calls: string[] = []; + const io = fakeIO({ "Set a due date?": false }, calls); + const spec: PromptSpec<Opts & { dueDate?: string }> = { + fields: [{ name: "dueDate", kind: "date", message: "Due date" }], + }; + + const result = await collectInteractive(ctx, spec, {}, io); + + expect("dueDate" in result).toBe(false); + expect(calls).toEqual(["confirm:Set a due date?"]); + }); + + it("date: cancel in the picker throws InteractiveCancelledError", async () => { + const io = fakeIO({ "Set a due date?": true, "Due date": CANCEL }); + const spec: PromptSpec<Opts & { dueDate?: string }> = { + fields: [{ name: "dueDate", kind: "date", message: "Due date" }], + }; + + await expect(collectInteractive(ctx, spec, {}, io)).rejects.toBeInstanceOf( + InteractiveCancelledError, + ); + }); + + it("date: cancel in the confirm gate throws InteractiveCancelledError", async () => { + const io = fakeIO({ "Set a due date?": CANCEL }); + const spec: PromptSpec<Opts & { dueDate?: string }> = { + fields: [{ name: "dueDate", kind: "date", message: "Due date" }], + }; + + await expect(collectInteractive(ctx, spec, {}, io)).rejects.toBeInstanceOf( + InteractiveCancelledError, + ); + }); }); describe("maybeCollectInteractive positional picker", () => { diff --git a/tests/unit/interactive/initiative-specs.test.ts b/tests/unit/interactive/initiative-specs.test.ts index 3747151c..49854005 100644 --- a/tests/unit/interactive/initiative-specs.test.ts +++ b/tests/unit/interactive/initiative-specs.test.ts @@ -29,8 +29,9 @@ describe("initiativeUpdateSpec", () => { expect(initiativeUpdateSpec.fields.every((f) => !f.required)).toBe(true); }); - it("seeds defaults from current option values", () => { - const name = initiativeUpdateSpec.fields.find((f) => f.name === "name"); - expect(name?.default?.({ name: "cur" })).toBe("cur"); + it("carries no dead default accessors (fields fill from prompts only)", () => { + for (const field of initiativeUpdateSpec.fields) { + expect(field.default).toBeUndefined(); + } }); }); diff --git a/tests/unit/interactive/issue-specs.test.ts b/tests/unit/interactive/issue-specs.test.ts index 479ebd46..2c5381e0 100644 --- a/tests/unit/interactive/issue-specs.test.ts +++ b/tests/unit/interactive/issue-specs.test.ts @@ -45,8 +45,9 @@ describe("issueUpdateSpec", () => { expect(issueUpdateSpec.fields.every((f) => !f.required)).toBe(true); }); - it("seeds defaults from current option values", () => { - const title = issueUpdateSpec.fields.find((f) => f.name === "title"); - expect(title?.default?.({ title: "cur" })).toBe("cur"); + it("carries no dead default accessors (fields fill from prompts only)", () => { + for (const field of issueUpdateSpec.fields) { + expect(field.default).toBeUndefined(); + } }); }); diff --git a/tests/unit/interactive/label-specs.test.ts b/tests/unit/interactive/label-specs.test.ts index 77e7bf94..b65a7d02 100644 --- a/tests/unit/interactive/label-specs.test.ts +++ b/tests/unit/interactive/label-specs.test.ts @@ -36,11 +36,10 @@ describe("labelUpdateSpec", () => { expect(labelUpdateSpec.fields.every((f) => !f.required)).toBe(true); }); - it("seeds defaults from current option values", () => { - const name = labelUpdateSpec.fields.find((f) => f.name === "name"); - expect(name?.default?.({ name: "bug" })).toBe("bug"); - const color = labelUpdateSpec.fields.find((f) => f.name === "color"); - expect(color?.default?.({ color: "#111111" })).toBe("#111111"); + it("carries no dead default accessors (fields fill from prompts only)", () => { + for (const field of labelUpdateSpec.fields) { + expect(field.default).toBeUndefined(); + } }); it("validates color the same way as create", () => { diff --git a/tests/unit/interactive/milestone-specs.test.ts b/tests/unit/interactive/milestone-specs.test.ts index a85d3f75..5b12882b 100644 --- a/tests/unit/interactive/milestone-specs.test.ts +++ b/tests/unit/interactive/milestone-specs.test.ts @@ -32,8 +32,9 @@ describe("milestoneUpdateSpec", () => { expect(milestoneUpdateSpec.fields.every((f) => !f.required)).toBe(true); }); - it("seeds defaults from current option values", () => { - const name = milestoneUpdateSpec.fields.find((f) => f.name === "name"); - expect(name?.default?.({ name: "cur" })).toBe("cur"); + it("carries no dead default accessors (fields fill from prompts only)", () => { + for (const field of milestoneUpdateSpec.fields) { + expect(field.default).toBeUndefined(); + } }); }); diff --git a/tests/unit/interactive/project-specs.test.ts b/tests/unit/interactive/project-specs.test.ts index c51c797d..31564030 100644 --- a/tests/unit/interactive/project-specs.test.ts +++ b/tests/unit/interactive/project-specs.test.ts @@ -38,12 +38,9 @@ describe("projectUpdateSpec", () => { expect(projectUpdateSpec.fields.every((f) => !f.required)).toBe(true); }); - it("seeds defaults from current option values", () => { - const name = projectUpdateSpec.fields.find((f) => f.name === "name"); - expect(name?.default?.({ name: "cur" })).toBe("cur"); - const target = projectUpdateSpec.fields.find( - (f) => f.name === "targetDate", - ); - expect(target?.default?.({ targetDate: "2026-01-01" })).toBe("2026-01-01"); + it("carries no dead default accessors (fields fill from prompts only)", () => { + for (const field of projectUpdateSpec.fields) { + expect(field.default).toBeUndefined(); + } }); }); From f9aac02a38cdcf100bcb33e58c1f1963a7bf7e2d Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:08:45 +0200 Subject: [PATCH 04/13] feat(interactive): add field wizards for teams and initiative updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base branch gained a teams write surface (create/update/membership) and initiative-update create/update after the prompt engine landed, leaving two create/update commands without a field wizard: they prompted only their positional/parent via a picker, so -i never gathered the actual fields — inconsistent with every other domain. teams: - add teamCreateSpec/teamUpdateSpec (name/key/description). Advanced boolean settings stay flag-only: parseBooleanOption trims its input and throws on a real boolean, so a confirm field would crash buildTeamFields (documented at the spec). - make `create <name>` optional (`[name]`) and fill it from the wizard text field, mirroring `labels create` (not `issues create`, which also gates on --team). Run the update wizard before the "at least one field" guard so prompted input counts. - demote add-member/remove-member `--user` from requiredOption to option and fill it via a user picker when absent on a TTY. initiatives updates: add body/health wizards for create and update. coverage-sweep: require each create/update file to reference a verb- matched *CreateSpec/*UpdateSpec. The prior file-level string match passed despite these missing wizards because both files already contained maybeCollectInteractive for their pickers. tests: team + initiative-update spec tests, plus non-TTY guards asserting a missing name / missing --user errors as JSON instead of hanging. --- src/commands/initiatives/updates.ts | 137 +++++++++-- src/commands/teams.ts | 214 +++++++++++++++--- tests/unit/commands/teams.test.ts | 23 ++ tests/unit/interactive/coverage-sweep.test.ts | 37 ++- .../initiative-update-specs.test.ts | 55 +++++ tests/unit/interactive/team-specs.test.ts | 45 ++++ 6 files changed, 453 insertions(+), 58 deletions(-) create mode 100644 tests/unit/interactive/initiative-update-specs.test.ts create mode 100644 tests/unit/interactive/team-specs.test.ts diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 41ac99ce..93eca29a 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -6,9 +6,16 @@ import { invalidParameterError, } from "../../common/errors.js"; import { asUuid } from "../../common/identifier.js"; -import { initiativeChoices } from "../../common/interactive/choices.js"; +import { + initiativeChoices, + withNoneChoice, +} from "../../common/interactive/choices.js"; import { maybeCollectInteractive } from "../../common/interactive/engine.js"; -import type { PromptIO } from "../../common/interactive/types.js"; +import type { + Choice, + PromptIO, + PromptSpec, +} from "../../common/interactive/types.js"; import { handleCommand, outputSuccess, @@ -136,6 +143,74 @@ interface InitiativeUpdatesUpdateOptions { health?: string; } +/** Create-wizard shape: the initiative to post under, plus body/health. */ +interface InitiativeUpdateCreateWizardOptions extends Record<string, unknown> { + initiative?: string; + body?: string; + health?: string; +} + +/** Update-wizard shape: the editable fields (body/health). */ +interface InitiativeUpdateUpdateWizardOptions extends Record<string, unknown> { + body?: string; + health?: string; +} + +const HEALTH_VALUES = ["onTrack", "atRisk", "offTrack"] as const; + +/** + * Static health picker with a leading "none" sentinel so the field can be left + * unset (the engine treats the empty value as "leave unset", matching an absent + * `--health` flag). Values feed the existing {@link parseHealth} unchanged. + */ +function healthChoices(): Choice[] { + return withNoneChoice( + HEALTH_VALUES.map((value) => ({ value, label: value })), + "None (leave unset)", + ); +} + +/** + * Interactive wizard for `initiatives updates create`. Prompts the initiative + * (required; UUID value passed through by the resolver) then the update body and + * health, mirroring the body-centric `commentCreateSpec`. + */ +export const initiativeUpdateCreateSpec: PromptSpec<InitiativeUpdateCreateWizardOptions> = + { + intro: "Create an initiative update", + fields: [ + { + name: "initiative", + kind: "select", + message: "Initiative", + required: true, + choices: initiativeChoices, + }, + { name: "body", kind: "multiline", message: "Body (markdown)" }, + { + name: "health", + kind: "select", + message: "Health", + choices: async () => healthChoices(), + }, + ], + }; + +/** Interactive wizard for `initiatives updates update`. All fields optional. */ +export const initiativeUpdateUpdateSpec: PromptSpec<InitiativeUpdateUpdateWizardOptions> = + { + intro: "Update an initiative update", + fields: [ + { name: "body", kind: "multiline", message: "Body (markdown)" }, + { + name: "health", + kind: "select", + message: "Health", + choices: async () => healthChoices(), + }, + ], + }; + export function setupInitiativeUpdateCommands(initiatives: Command): void { const updates = initiatives .command("updates") @@ -209,11 +284,22 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const initiative = await resolveInitiativeOption( - ctx, - command, - options.initiative, - ); + const filled = await maybeCollectInteractive< + InitiativeUpdateCreateWizardOptions, + never + >(ctx, getRootOpts(command), { + spec: initiativeUpdateCreateSpec, + options: { + ...(options.initiative !== undefined + ? { initiative: options.initiative } + : {}), + ...(options.body !== undefined ? { body: options.body } : {}), + ...(options.health !== undefined ? { health: options.health } : {}), + }, + missingRequired: options.initiative === undefined, + }); + + const initiative = filled.options.initiative; if (initiative === undefined) { throw invalidParameterError("--initiative", "is required"); } @@ -221,11 +307,11 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { const input: CreateInitiativeUpdateInput = { initiativeId }; - if (options.body !== undefined) { - input.body = options.body; + if (filled.options.body !== undefined) { + input.body = filled.options.body; } - const health = parseHealth(options.health); + const health = parseHealth(filled.options.health); if (health) { input.health = health; } @@ -248,15 +334,38 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { Command, ]; const ctx = createContext(getRootOpts(command)); - const updateId = await resolveUpdatePositional(ctx, command, updateArg); + + // Wizard first: it picks the `[update]` positional AND fills body/health, + // so the "at least one option" guard below sees prompted input rather + // than firing before the user is asked. + const filled = await maybeCollectInteractive< + InitiativeUpdateUpdateWizardOptions, + string + >(ctx, getRootOpts(command), { + spec: initiativeUpdateUpdateSpec, + options: { + ...(options.body !== undefined ? { body: options.body } : {}), + ...(options.health !== undefined ? { health: options.health } : {}), + }, + missingRequired: updateArg === undefined, + positional: { + name: "update", + value: updateArg, + picker: updatePicker, + }, + }); + if (filled.positional === undefined) { + throw invalidParameterError("update", "is required"); + } + const updateId = filled.positional; const input: UpdateInitiativeUpdateInput = {}; - if (options.body !== undefined) { - input.body = options.body; + if (filled.options.body !== undefined) { + input.body = filled.options.body; } - const health = parseHealth(options.health); + const health = parseHealth(filled.options.health); if (health) { input.health = health; } diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 91527a32..8b703269 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -8,7 +8,7 @@ import { InteractiveCancelledError, invalidParameterError, } from "../common/errors.js"; -import { teamChoices } from "../common/interactive/choices.js"; +import { teamChoices, userChoices } from "../common/interactive/choices.js"; import { maybeCollectInteractive } from "../common/interactive/engine.js"; import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; @@ -36,7 +36,9 @@ export const TEAMS_META: DomainMeta = { "labels. teams are identified by a short key (e.g. ENG), name, or UUID.", "teams can be created and updated, and their membership managed with", "add-member/remove-member. boolean settings take an explicit true|false", - "value so scripts can set or unset them unambiguously.", + "value so scripts can set or unset them unambiguously. run create/update/", + "add-member/remove-member with -i (or omit a required value on a TTY) to", + "fill missing input interactively; piped/--no-interactive usage stays JSON.", ].join("\n"), arguments: { team: "team identifier (key, name, or UUID)", @@ -132,6 +134,47 @@ interface TeamFieldOptions { autoArchivePeriod?: string; } +/** + * Wizard shape for `teams create`/`update`: the promptable core fields only. + * `name` is the create positional / update `--name`; `key` and `description` + * are shared. The index signature carries the untouched advanced flag settings + * through the engine so they reach {@link buildTeamFields} unchanged. + */ +interface TeamWizardOptions extends Record<string, unknown> { + name?: string; + key?: string; + description?: string; +} + +// IMPORTANT: keep these specs to string-valued `text` fields only. The ~20 +// advanced settings (private, cyclesEnabled, triage, estimation…) are parsed +// from strings via parseBooleanOption, which `.trim()`s its input and therefore +// throws on a real boolean. The engine's `confirm` kind returns a boolean, so +// adding a boolean setting here as a `confirm` field would crash +// buildTeamFields. They stay flag-only unless buildTeamFields is first taught to +// accept booleans. +export const teamCreateSpec: PromptSpec<TeamWizardOptions> = { + intro: "Create a new team", + fields: [ + { name: "name", kind: "text", message: "Name", required: true }, + { + name: "key", + kind: "text", + message: "Key (uppercase; auto-derived from name if blank)", + }, + { name: "description", kind: "multiline", message: "Description" }, + ], +}; + +export const teamUpdateSpec: PromptSpec<TeamWizardOptions> = { + intro: "Update a team", + fields: [ + { name: "name", kind: "text", message: "Name" }, + { name: "key", kind: "text", message: "Key" }, + { name: "description", kind: "multiline", message: "Description" }, + ], +}; + // Build the shared mutable field set once, resolving the parent team to a // UUID. Only fields the user provided are included, so `update` never // overwrites untouched settings. @@ -276,6 +319,70 @@ function addTeamSettingFlags(command: Command): Command { .option("--auto-archive-period <months>", "auto-archive period in months"); } +/** + * Fill an absent `[team]` positional via the team picker when gating allows, + * otherwise error. Returns the team identifier (or picked UUID) for the + * resolver. + */ +async function resolveTeamPositional( + ctx: CommandContext, + command: Command, + team: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: team === undefined, + positional: { name: "team", value: team, picker: teamPicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("team", "is required"); + } + return filled.positional; +} + +/** + * Entity picker for an absent `--user` on the membership commands. Returns the + * selected user's UUID, which `resolveUserId` passes through via `isUuid(...)`. + */ +async function userPicker(ctx: CommandContext, io: PromptIO): Promise<string> { + const options = await userChoices(ctx); + const answer = await io.select({ message: "User", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; +} + +/** + * Fill an absent `--user` value via the user picker when gating allows, + * otherwise error. Returns the user identifier (or picked UUID) for the resolver. + */ +async function resolveUserOption( + ctx: CommandContext, + command: Command, + user: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: user === undefined, + positional: { name: "user", value: user, picker: userPicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("--user", "is required"); + } + return filled.positional; +} + export function setupTeamsCommands(program: Command): void { const teams = program.command("teams").description("Team operations"); @@ -310,20 +417,8 @@ export function setupTeamsCommands(program: Command): void { const command = args.at(-1) as Command; const ctx = createContext(getRootOpts(command)); - const filled = await maybeCollectInteractive< - Record<string, never>, - string - >(ctx, getRootOpts(command), { - spec: EMPTY_SPEC, - options: {}, - missingRequired: teamArg === undefined, - positional: { name: "team", value: teamArg, picker: teamPicker }, - }); - if (filled.positional === undefined) { - throw invalidParameterError("team", "is required"); - } - - const teamId = await resolveTeamId(ctx.gql, filled.positional); + const team = await resolveTeamPositional(ctx, command, teamArg); + const teamId = await resolveTeamId(ctx.gql, team); const result = await getTeam(ctx.gql, { id: teamId }); outputSuccess(result); }), @@ -331,7 +426,7 @@ export function setupTeamsCommands(program: Command): void { addTeamSettingFlags( teams - .command("create <name>") + .command("create [name]") .description("create a new team") .option( "--key <key>", @@ -339,12 +434,31 @@ export function setupTeamsCommands(program: Command): void { ), ).action( handleCommand(async (...args: unknown[]) => { - const [name, options, command] = args as [ - string, + const [nameArg, rawOptions, command] = args as [ + string | undefined, TeamFieldOptions, Command, ]; const ctx = createContext(getRootOpts(command)); + + const filled = await maybeCollectInteractive<TeamWizardOptions, never>( + ctx, + getRootOpts(command), + { + spec: teamCreateSpec, + options: { + ...rawOptions, + ...(nameArg !== undefined ? { name: nameArg } : {}), + } as TeamWizardOptions, + missingRequired: nameArg === undefined, + }, + ); + const options = filled.options as unknown as TeamFieldOptions; + const name = (filled.options.name as string | undefined) ?? nameArg; + if (name === undefined) { + throw invalidParameterError("name", "is required"); + } + const fields = await buildTeamFields(ctx, options); const input: CreateTeamInput = { ...fields, name }; const result = await createTeam(ctx.gql, input); @@ -354,18 +468,39 @@ export function setupTeamsCommands(program: Command): void { addTeamSettingFlags( teams - .command("update <team>") + .command("update [team]") .description("update an existing team") .option("--name <name>", "new team name") .option("--key <key>", "new team key"), ).action( handleCommand(async (...args: unknown[]) => { - const [team, options, command] = args as [ - string, + const [teamArg, rawOptions, command] = args as [ + string | undefined, TeamFieldOptions & { name?: string }, Command, ]; const ctx = createContext(getRootOpts(command)); + + // Wizard first: it picks the `[team]` positional AND fills name/key/ + // description, so the "at least one field" guard below sees prompted input + // rather than firing before the user is asked. + const filled = await maybeCollectInteractive<TeamWizardOptions, string>( + ctx, + getRootOpts(command), + { + spec: teamUpdateSpec, + options: { ...rawOptions } as TeamWizardOptions, + missingRequired: teamArg === undefined, + positional: { name: "team", value: teamArg, picker: teamPicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("team", "is required"); + } + const options = filled.options as unknown as TeamFieldOptions & { + name?: string; + }; + const input = await buildTeamFields(ctx, options); if (options.name !== undefined) input.name = options.name; @@ -376,20 +511,21 @@ export function setupTeamsCommands(program: Command): void { ); } - const teamId = await resolveTeamId(ctx.gql, team); + const teamId = await resolveTeamId(ctx.gql, filled.positional); const result = await updateTeam(ctx.gql, teamId, input); outputSuccess(result); }), ); teams - .command("members <team>") + .command("members [team]") .description("list a team's members") .action( handleCommand(async (...args: unknown[]) => { - const team = args[0] as string; + const teamArg = args[0] as string | undefined; const command = args.at(-1) as Command; const ctx = createContext(getRootOpts(command)); + const team = await resolveTeamPositional(ctx, command, teamArg); const teamId = await resolveTeamId(ctx.gql, team); const result = await listTeamMembers(ctx.gql, { id: teamId }); outputSuccess(result); @@ -397,21 +533,23 @@ export function setupTeamsCommands(program: Command): void { ); teams - .command("add-member <team>") + .command("add-member [team]") .description("add a user to a team") - .requiredOption("--user <user>", "user display name, email, or UUID") + .option("--user <user>", "user display name, email, or UUID") .option("--owner <true|false>", "grant team-admin (owner) rights") .action( handleCommand(async (...args: unknown[]) => { - const [team, options, command] = args as [ - string, - { user: string; owner?: string }, + const [teamArg, options, command] = args as [ + string | undefined, + { user?: string; owner?: string }, Command, ]; const ctx = createContext(getRootOpts(command)); + const team = await resolveTeamPositional(ctx, command, teamArg); + const user = await resolveUserOption(ctx, command, options.user); const [teamId, userId] = await Promise.all([ resolveTeamId(ctx.gql, team), - resolveUserId(ctx.gql, options.user), + resolveUserId(ctx.gql, user), ]); const result = await addTeamMember(ctx.gql, { teamId, @@ -425,20 +563,22 @@ export function setupTeamsCommands(program: Command): void { ); teams - .command("remove-member <team>") + .command("remove-member [team]") .description("remove a user from a team") - .requiredOption("--user <user>", "user display name, email, or UUID") + .option("--user <user>", "user display name, email, or UUID") .action( handleCommand(async (...args: unknown[]) => { - const [team, options, command] = args as [ - string, - { user: string }, + const [teamArg, options, command] = args as [ + string | undefined, + { user?: string }, Command, ]; const ctx = createContext(getRootOpts(command)); + const team = await resolveTeamPositional(ctx, command, teamArg); + const user = await resolveUserOption(ctx, command, options.user); const [teamId, userId] = await Promise.all([ resolveTeamId(ctx.gql, team), - resolveUserId(ctx.gql, options.user), + resolveUserId(ctx.gql, user), ]); const result = await removeTeamMember(ctx.gql, { teamId, userId }); outputSuccess(result); diff --git a/tests/unit/commands/teams.test.ts b/tests/unit/commands/teams.test.ts index a23af4f7..2ffbe886 100644 --- a/tests/unit/commands/teams.test.ts +++ b/tests/unit/commands/teams.test.ts @@ -180,6 +180,18 @@ describe("teams create", () => { expect(createTeam).not.toHaveBeenCalled(); expect(process.exit).toHaveBeenCalledWith(1); }); + + it("errors (does not hang) when name is missing and not on a TTY", async () => { + // `create [name]` dropped Commander's required-positional guard; without a + // TTY the wizard never runs (gating suppresses it), so the action itself + // must reject the missing name as JSON rather than block on a prompt. + const program = createProgram(); + + await program.parseAsync(["node", "test", "teams", "create"]); + + expect(createTeam).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); }); describe("teams update", () => { @@ -258,6 +270,17 @@ describe("teams add-member", () => { owner: true, }); }); + + it("errors (does not hang) when --user is missing and not on a TTY", async () => { + // --user was demoted from requiredOption to option so the picker can fill it + // interactively; without a TTY the action must still reject a missing user. + const program = createProgram(); + + await program.parseAsync(["node", "test", "teams", "add-member", "ENG"]); + + expect(addTeamMember).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); + }); }); describe("teams remove-member", () => { diff --git a/tests/unit/interactive/coverage-sweep.test.ts b/tests/unit/interactive/coverage-sweep.test.ts index d29381b0..f4b885c5 100644 --- a/tests/unit/interactive/coverage-sweep.test.ts +++ b/tests/unit/interactive/coverage-sweep.test.ts @@ -86,15 +86,27 @@ describe("interactive coverage sweep", () => { perFile.set(file, readFileSync(join(COMMANDS_DIR, file), "utf-8")); } - it("every create/update command wires a wizard via maybeCollectInteractive", () => { + it("every create/update command references a matching field wizard spec", () => { + // A bare `maybeCollectInteractive` string is insufficient — a file can wire + // it for an entity/positional picker over an EMPTY_SPEC while leaving the + // create/update fields un-prompted (this is exactly how the teams and + // initiative-updates drift gaps hid). Require the file to reference a + // verb-matched `*CreateSpec` / `*UpdateSpec`, which only exists when a real + // field wizard was declared for that command. const offenders: string[] = []; for (const [file, content] of perFile) { - const hasCreateOrUpdate = extractCommands(content, file).some( - (c) => c.verb === "create" || c.verb === "update", - ); - if (!hasCreateOrUpdate) continue; - if (!content.includes("maybeCollectInteractive")) { - offenders.push(file); + const cmds = extractCommands(content, file); + if ( + cmds.some((c) => c.verb === "create") && + !/spec:\s*\w*CreateSpec\b/.test(content) + ) { + offenders.push(`${file} (create)`); + } + if ( + cmds.some((c) => c.verb === "update") && + !/spec:\s*\w*UpdateSpec\b/.test(content) + ) { + offenders.push(`${file} (update)`); } } expect(offenders).toEqual([]); @@ -123,4 +135,15 @@ describe("interactive coverage sweep", () => { expect(documents.documentUpdateSpec).toBeDefined(); expect(attachments.attachmentCreateSpec).toBeDefined(); }); + + it("drift-added write domains export their wizard specs", async () => { + const teams = await import("../../../src/commands/teams.js"); + const initiativeUpdates = await import( + "../../../src/commands/initiatives/updates.js" + ); + expect(teams.teamCreateSpec).toBeDefined(); + expect(teams.teamUpdateSpec).toBeDefined(); + expect(initiativeUpdates.initiativeUpdateCreateSpec).toBeDefined(); + expect(initiativeUpdates.initiativeUpdateUpdateSpec).toBeDefined(); + }); }); diff --git a/tests/unit/interactive/initiative-update-specs.test.ts b/tests/unit/interactive/initiative-update-specs.test.ts new file mode 100644 index 00000000..f2ad6108 --- /dev/null +++ b/tests/unit/interactive/initiative-update-specs.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + initiativeUpdateCreateSpec, + initiativeUpdateUpdateSpec, +} from "../../../src/commands/initiatives/updates.js"; + +describe("initiativeUpdateCreateSpec", () => { + it("requires only the initiative", () => { + const required = initiativeUpdateCreateSpec.fields + .filter((f) => f.required) + .map((f) => f.name); + expect(required).toEqual(["initiative"]); + }); + + it("prompts initiative before body and health", () => { + const names = initiativeUpdateCreateSpec.fields.map((f) => f.name); + expect(names.indexOf("initiative")).toBeLessThan(names.indexOf("body")); + expect(names.indexOf("body")).toBeLessThan(names.indexOf("health")); + }); + + it("uses a select for initiative and health, multiline for body", () => { + const byName = new Map( + initiativeUpdateCreateSpec.fields.map((f) => [f.name, f]), + ); + expect(byName.get("initiative")?.kind).toBe("select"); + expect(byName.get("body")?.kind).toBe("multiline"); + expect(byName.get("health")?.kind).toBe("select"); + }); + + it("offers a leave-unset choice for health", async () => { + const health = initiativeUpdateCreateSpec.fields.find( + (f) => f.name === "health", + ); + // choices are static (ctx/draft unused); a leading empty-valued sentinel + // lets the optional field be skipped. + const choices = await health?.choices?.(undefined as never, {} as never); + expect(choices?.some((c) => c.value === "")).toBe(true); + expect(choices?.map((c) => c.value)).toContain("onTrack"); + }); +}); + +describe("initiativeUpdateUpdateSpec", () => { + it("has no required fields (all optional on update)", () => { + expect(initiativeUpdateUpdateSpec.fields.every((f) => !f.required)).toBe( + true, + ); + }); + + it("prompts only body and health", () => { + expect(initiativeUpdateUpdateSpec.fields.map((f) => f.name)).toEqual([ + "body", + "health", + ]); + }); +}); diff --git a/tests/unit/interactive/team-specs.test.ts b/tests/unit/interactive/team-specs.test.ts new file mode 100644 index 00000000..f17ffd6d --- /dev/null +++ b/tests/unit/interactive/team-specs.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { teamCreateSpec, teamUpdateSpec } from "../../../src/commands/teams.js"; + +describe("teamCreateSpec", () => { + it("requires only name", () => { + const required = teamCreateSpec.fields + .filter((f) => f.required) + .map((f) => f.name); + expect(required).toEqual(["name"]); + }); + + it("prompts name first, then key and description", () => { + expect(teamCreateSpec.fields.map((f) => f.name)).toEqual([ + "name", + "key", + "description", + ]); + }); + + it("uses only string-valued text fields (no confirm/select)", () => { + // Boolean settings must stay flag-only: parseBooleanOption throws on a real + // boolean, so a `confirm` field would crash buildTeamFields. + for (const field of teamCreateSpec.fields) { + expect(["text", "multiline"]).toContain(field.kind); + } + }); +}); + +describe("teamUpdateSpec", () => { + it("has no required fields (all optional on update)", () => { + expect(teamUpdateSpec.fields.every((f) => !f.required)).toBe(true); + }); + + it("carries no default accessors (fields fill from prompts only)", () => { + for (const field of teamUpdateSpec.fields) { + expect(field.default).toBeUndefined(); + } + }); + + it("uses only string-valued text fields", () => { + for (const field of teamUpdateSpec.fields) { + expect(["text", "multiline"]).toContain(field.kind); + } + }); +}); From 2ea663a668f5fbebdbbcbed67bd489c334b4869b Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:28:42 +0200 Subject: [PATCH 05/13] feat(interactive): cover discussion, membership, and relation domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The descriptor-driven interactive rollout reached the primary create/ update/read paths but never covered a subsystem built out in parallel: the discussion thread/reply commands duplicated across issues, projects, and initiatives, plus relation and team-membership operations. In a terminal those commands still forced users to paste raw UUIDs — the exact friction the interactive feature exists to remove. Add a shared makeDiscussionPickers builder, colocated in commands/ (not common/interactive/, to preserve that layer's resolver-free invariant), that produces three positional pickers per content domain: - rootThreadPicker for reply/resolve/unresolve and thread reactions; - commentOrReplyPicker for edit/delete-comment, which the CLI accepts for a root OR a reply, so a root-only picker would silently drop reply targets; - replyPicker for edit-reply/delete-reply and reply reactions. Pickers are searchable, label threads by author/resolved state, and re-prompt on an empty entity instead of aborting mid-command. Shared resolvePickedPositional / resolveEmojiPositional / resolveDiscussionBody back every wired action; the required <thread>/<comment>/<reply> positionals become optional [..] so a picker can fire when the arg is omitted, while non-TTY/CI/piped runs keep the existing missing-argument error (they exit 1 with JSON, never hang or prompt). Also adds: issues relations add/remove pickers (relation labels narrow the forward/inverse union to both endpoints); teams remove-member offers the team's current members rather than all users; and field-wizard polish for attachments (comment/icon-url), projects (icon/hex color), documents (optional issue attach), plus a milestones-list project wizard replacing the hard --project requiredOption. unreact-id stays flag-only because its reaction id cannot be sourced, and the coverage-sweep allowlist is tightened to the genuinely-required positionals. --- src/commands/attachments.ts | 2 + src/commands/cycles.ts | 11 +- src/commands/discussion-pickers.ts | 296 ++++++++++++ src/commands/documents.ts | 9 + src/commands/initiatives/entity.ts | 251 +++++++--- src/commands/issues.ts | 433 +++++++++++------- src/commands/milestones.ts | 58 ++- src/commands/projects.ts | 268 ++++++++--- src/commands/teams.ts | 65 ++- src/common/interactive/choices.ts | 46 ++ src/common/interactive/engine.ts | 32 +- tests/unit/interactive/choices.test.ts | 81 ++++ tests/unit/interactive/content-specs.test.ts | 18 + tests/unit/interactive/coverage-sweep.test.ts | 16 +- .../interactive/discussion-pickers.test.ts | 217 +++++++++ tests/unit/interactive/engine.test.ts | 51 +++ .../unit/interactive/milestone-specs.test.ts | 11 + tests/unit/interactive/project-specs.test.ts | 9 + 18 files changed, 1514 insertions(+), 360 deletions(-) create mode 100644 src/commands/discussion-pickers.ts create mode 100644 tests/unit/interactive/discussion-pickers.test.ts diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index 033fb36b..6562a207 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -73,6 +73,8 @@ export const attachmentCreateSpec: PromptSpec<CreateWizardOptions> = { { name: "title", kind: "text", message: "Title", required: true }, { name: "url", kind: "text", message: "URL", required: true }, { name: "subtitle", kind: "text", message: "Subtitle" }, + { name: "iconUrl", kind: "text", message: "Icon URL" }, + { name: "comment", kind: "multiline", message: "Comment" }, ], }; diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index 9a6cfad6..d48c494a 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -12,7 +12,7 @@ import { requiresParameterError, } from "../common/errors.js"; import { - cycleChoices, + allCycleChoices, teamChoices, withNoneChoice, } from "../common/interactive/choices.js"; @@ -64,9 +64,10 @@ export const cycleListSpec: PromptSpec<CycleListWizardOptions> = { /** * Entity picker for an absent `[cycle]` positional. Cycles are team-scoped, so * this first resolves/prompts the parent team (via `--team` or a team select), - * then loads that team's cycles via `cycleChoices({ team })`. This is the - * cross-field-dependency case for the cycles domain: the cycle list is only - * fetched once the parent team UUID is known. + * then loads that team's cycles via `allCycleChoices({ team })` — the unfiltered + * loader, since reading a cycle is retrospective and must reach ended cycles too. + * This is the cross-field-dependency case for the cycles domain: the cycle list + * is only fetched once the parent team UUID is known. * * Returns the selected cycle UUID (which the resolver accepts). */ @@ -88,7 +89,7 @@ function makeCyclePicker( teamId = await resolveTeamId(ctx.gql, teamId); } - const options = await cycleChoices(ctx, { team: teamId }); + const options = await allCycleChoices(ctx, { team: teamId }); const answer = await io.select({ message: "Cycle", options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/commands/discussion-pickers.ts b/src/commands/discussion-pickers.ts new file mode 100644 index 00000000..3a44a7ff --- /dev/null +++ b/src/commands/discussion-pickers.ts @@ -0,0 +1,296 @@ +import type { Command } from "commander"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import type { CommandContext } from "../common/context.js"; +import { getRootOpts } from "../common/context.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; +import { asUuid, type UUID } from "../common/identifier.js"; +import { emojiChoices } from "../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import { + type ChoicePicker, + makeChoicePicker, +} from "../common/interactive/pickers.js"; +import type { + Choice, + PromptIO, + PromptSpec, +} from "../common/interactive/types.js"; +import type { PaginatedResult, PaginationOptions } from "../common/types.js"; +import { + type DiscussionEntityKind, + type DiscussionThread, + listDiscussionReplies, +} from "../services/discussion-service.js"; + +const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; + +/** + * Fill an absent required positional via `picker` when interactive gating + * allows, else require it (preserving the missing-argument error for + * agents/pipes). Shared by every discussion command that takes a + * `[thread]`/`[comment]`/`[reply]` across the issue/project/initiative domains. + */ +export async function resolvePickedPositional( + ctx: CommandContext, + command: Command, + name: string, + value: string | undefined, + picker: ChoicePicker, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: value === undefined, + positional: { name, value, picker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError(name, "is required"); + } + return filled.positional; +} + +/** Options shape for the shared discussion-body wizard. */ +interface BodyWizardOptions extends Record<string, unknown> { + body?: string; +} + +const discussionBodySpec: PromptSpec<BodyWizardOptions> = { + intro: "Enter the comment body", + fields: [ + { name: "body", kind: "multiline", message: "Body", required: true }, + ], +}; + +/** + * Collect the discussion `--body` interactively when it is missing and gating + * allows, else preserve the "--body is required" error for agents/pipes. Shared + * by the reply/edit/discuss commands across the issue/project/initiative + * domains so the body is prompted after the positional picker rather than + * dead-ending on a missing flag. + */ +export async function resolveDiscussionBody( + ctx: CommandContext, + command: Command, + options: { body?: string }, +): Promise<string> { + const filled = await maybeCollectInteractive<BodyWizardOptions, never>( + ctx, + getRootOpts(command), + { + spec: discussionBodySpec, + options: options as BodyWizardOptions, + missingRequired: options.body === undefined, + }, + ); + const body = filled.options.body; + if (body === undefined) { + throw invalidParameterError("--body", "is required"); + } + return body; +} + +/** Emoji picker for an absent `[emoji]` reaction positional. */ +const emojiPicker = makeChoicePicker("Reaction", async () => emojiChoices()); + +/** + * Fill an absent `[emoji]` positional via the emoji picker when gating allows. + * Returns the (possibly still-undefined) emoji so the caller's existing + * `resolveReactionEmojiInput` keeps ownership of the emoji-or-shortcode + * validation for the non-interactive path. + */ +export async function resolveEmojiPositional( + ctx: CommandContext, + command: Command, + emoji: string | undefined, + shortcode: string | undefined, +): Promise<string | undefined> { + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: emoji === undefined && shortcode === undefined, + positional: { name: "emoji", value: emoji, picker: emojiPicker }, + }, + ); + return filled.positional; +} + +/** + * How many root threads / replies to offer in a picker. The selects are + * {@link PromptIO.autocomplete searchable}, so this is a soft cap on breadth + * rather than a hard limit a user must scroll — typing filters the list. + */ +const PICKER_LIMIT = 50; + +/** + * Configuration for {@link makeDiscussionPickers}. The three content domains + * (issue/project/initiative) expose an identical discussion subsystem + * parameterized only by `entityKind` plus the domain's entity picker, resolver, + * and root-thread list service — so one builder produces the pickers for all + * three. + * + * This builder lives in `commands/` (not `common/interactive/`) on purpose: it + * resolves entity ids, and the `common/interactive/` layer is deliberately kept + * resolver-free (see the invariant documented in `choices.ts`). It mirrors where + * the pre-existing `commentPicker` (comments.ts) already lives. + */ +export interface DiscussionPickerConfig { + entityKind: DiscussionEntityKind; + /** Domain entity picker (issue/project/initiative); returns a human id or UUID. */ + entityPicker: ChoicePicker; + /** Normalize the entity picker's return value to a UUID. */ + resolveEntityId(ctx: CommandContext, human: string): Promise<UUID>; + /** The domain's `listDiscussionsFor<Entity>` root-thread service. */ + listThreads( + client: GraphQLClient, + entityId: UUID, + options: PaginationOptions, + ): Promise<PaginatedResult<DiscussionThread>>; +} + +/** + * Build the three discussion positional pickers for one content domain: + * + * - `rootThreadPicker` — pick a **root thread** (for `reply`, `resolve`, + * `unresolve`, and thread-level reactions). + * - `commentOrReplyPicker` — pick a root thread **or one of its replies** (for + * `edit` / `delete-comment`, which the non-interactive CLI accepts for either; + * a root-only picker would silently drop reply targets). + * - `replyPicker` — pick a **reply within a chosen thread** (for `edit-reply`, + * `delete-reply`, and reply-level reactions). + * + * Every picker gates through the caller's `maybeCollectInteractive` wrapper, so + * none of the loads here run in non-TTY/CI/piped contexts. + */ +export function makeDiscussionPickers(cfg: DiscussionPickerConfig): { + rootThreadPicker: ChoicePicker; + commentOrReplyPicker: ChoicePicker; + replyPicker: ChoicePicker; +} { + /** + * Pick a root thread node. Loops the entity selection: an entity with no + * threads shows a non-fatal notice and re-prompts rather than aborting the + * whole command. Cancelling (at the entity or thread step) throws + * {@link InteractiveCancelledError}. + */ + async function pickThreadNode( + ctx: CommandContext, + io: PromptIO, + ): Promise<DiscussionThread> { + for (;;) { + const human = await cfg.entityPicker(ctx, io); + const entityId = await cfg.resolveEntityId(ctx, human); + const { nodes } = await cfg.listThreads(ctx.gql, entityId, { + limit: PICKER_LIMIT, + }); + if (nodes.length === 0) { + io.intro?.( + `That ${cfg.entityKind} has no discussion threads — choose another.`, + ); + continue; + } + return selectNode(io, "Thread", nodes, (node) => threadChoice(node)); + } + } + + async function fetchReplies( + ctx: CommandContext, + threadId: string, + ): Promise<DiscussionThread[]> { + const { nodes } = await listDiscussionReplies( + ctx.gql, + asUuid(threadId), + { limit: PICKER_LIMIT }, + cfg.entityKind, + ); + return nodes; + } + + const rootThreadPicker: ChoicePicker = async (ctx, io) => + (await pickThreadNode(ctx, io)).id; + + const commentOrReplyPicker: ChoicePicker = async (ctx, io) => { + const thread = await pickThreadNode(ctx, io); + const replies = await fetchReplies(ctx, thread.id); + const chosen = await selectNode( + io, + "Comment", + [thread, ...replies], + (node) => threadChoice(node, node.parentId ? "reply" : "root"), + ); + return chosen.id; + }; + + const replyPicker: ChoicePicker = async (ctx, io) => { + for (;;) { + const thread = await pickThreadNode(ctx, io); + const replies = await fetchReplies(ctx, thread.id); + if (replies.length === 0) { + io.intro?.("That thread has no replies — choose another."); + continue; + } + const chosen = await selectNode(io, "Reply", replies, (node) => + threadChoice(node), + ); + return chosen.id; + } + }; + + return { rootThreadPicker, commentOrReplyPicker, replyPicker }; +} + +/** + * Render a searchable single-select over `nodes` and return the chosen node. + * Throws {@link InteractiveCancelledError} on cancel. The returned value is + * always one of `nodes` (the autocomplete only yields a provided option value). + */ +async function selectNode<T extends { id: string }>( + io: PromptIO, + message: string, + nodes: T[], + toChoice: (node: T) => Choice, +): Promise<T> { + const answer = await io.autocomplete({ + message, + options: nodes.map(toChoice), + }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + const chosen = nodes.find((node) => node.id === answer); + if (chosen === undefined) { + throw new InteractiveCancelledError(); + } + return chosen; +} + +/** + * Map a discussion comment (root thread or reply) to a picker choice. The label + * is the first line of the body; the hint carries the author and, for resolved + * threads, a resolved marker (plus an optional `role` prefix so a combined + * root+reply list stays legible). + */ +function threadChoice( + comment: DiscussionThread, + role?: "root" | "reply", +): Choice { + const firstLine = comment.body.split("\n")[0]?.slice(0, 72) || comment.id; + const hintParts: string[] = []; + if (role) hintParts.push(role); + if (comment.user?.displayName) hintParts.push(comment.user.displayName); + if (comment.resolvedAt) hintParts.push("resolved"); + return { + value: comment.id, + label: firstLine, + ...(hintParts.length > 0 ? { hint: hintParts.join(" · ") } : {}), + }; +} diff --git a/src/commands/documents.ts b/src/commands/documents.ts index e11e3e84..c8e766d9 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -11,6 +11,8 @@ import { import { asUuid, type UUID } from "../common/identifier.js"; import { documentChoices, + issueChoices, + optionalChoices, projectChoices, teamChoices, } from "../common/interactive/choices.js"; @@ -117,6 +119,13 @@ export const documentCreateSpec: PromptSpec<DocumentCreateWizardOptions> = { { name: "team", kind: "select", message: "Team", choices: teamChoices }, { name: "icon", kind: "text", message: "Icon" }, { name: "color", kind: "text", message: "Icon color" }, + { + name: "issue", + kind: "select", + message: "Attach to issue", + searchable: true, + choices: optionalChoices(issueChoices, "None (standalone document)"), + }, ], }; diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index 8c737359..a4f150b2 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -10,9 +10,12 @@ import { import { asUuid } from "../../common/identifier.js"; import { initiativeChoices, + optionalChoices, userChoices, + withNoneChoice, } from "../../common/interactive/choices.js"; import { maybeCollectInteractive } from "../../common/interactive/engine.js"; +import type { ChoicePicker } from "../../common/interactive/pickers.js"; import type { Choice, PromptIO, @@ -62,6 +65,12 @@ import { unarchiveInitiative, updateInitiative, } from "../../services/initiative-service.js"; +import { + makeDiscussionPickers, + resolveDiscussionBody, + resolveEmojiPositional, + resolvePickedPositional, +} from "../discussion-pickers.js"; interface InitiativeExpandOptions { withProjects?: boolean; @@ -125,46 +134,75 @@ interface ReactionOptions { function addCommentReactionCommands( parent: ReturnType<Command["command"]>, noun: "thread" | "reply", + picker: ChoicePicker, ): void { parent - .command(`react <${noun}> [emoji]`) + .command(`react [${noun}] [emoji]`) .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (commentId, emoji, options, command) => { - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId: asUuid(commentId), - target: noun, - expectedEntityKind: "initiative", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }, - ), + commandAction< + [string | undefined, string | undefined, ReactionOptions, Command] + >(async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const resolvedComment = await resolvePickedPositional( + ctx, + command, + noun, + commentId, + picker, + ); + const resolvedEmoji = await resolveEmojiPositional( + ctx, + command, + emoji, + options.shortcode, + ); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId: asUuid(resolvedComment), + target: noun, + expectedEntityKind: "initiative", + emoji: resolveReactionEmojiInput(resolvedEmoji, options.shortcode), + }); + outputSuccess(result); + }), ); parent - .command(`unreact <${noun}> [emoji]`) + .command(`unreact [${noun}] [emoji]`) .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (commentId, emoji, options, command) => { - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId: asUuid(commentId), - target: noun, - expectedEntityKind: "initiative", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }, - ), + commandAction< + [string | undefined, string | undefined, ReactionOptions, Command] + >(async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const resolvedComment = await resolvePickedPositional( + ctx, + command, + noun, + commentId, + picker, + ); + const resolvedEmoji = await resolveEmojiPositional( + ctx, + command, + emoji, + options.shortcode, + ); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId: asUuid(resolvedComment), + target: noun, + expectedEntityKind: "initiative", + emoji: resolveReactionEmojiInput(resolvedEmoji, options.shortcode), + }); + outputSuccess(result); + }), ); parent + // `unreact-id` stays fully non-interactive: its <reactionId> cannot be + // sourced from any list service (flag-only by-ID escape hatch for agents). .command(`unreact-id <${noun}> <reactionId>`) .description( `remove your reaction from a discussion ${noun} by reaction ID`, @@ -239,13 +277,14 @@ export const initiativeCreateSpec: PromptSpec<InitiativeCreateWizardOptions> = { name: "owner", kind: "select", message: "Owner", - choices: userChoices, + choices: optionalChoices(userChoices, "None (no owner)"), }, { name: "status", kind: "select", message: "Status", - choices: async () => initiativeStatusChoices(), + choices: async () => + withNoneChoice(initiativeStatusChoices(), "None (default status)"), }, { name: "targetDate", kind: "date", message: "Target date" }, ], @@ -277,13 +316,14 @@ export const initiativeUpdateSpec: PromptSpec<InitiativeUpdateWizardOptions> = { name: "owner", kind: "select", message: "Owner", - choices: userChoices, + choices: optionalChoices(userChoices, "Keep current"), }, { name: "status", kind: "select", message: "Status", - choices: async () => initiativeStatusChoices(), + choices: async () => + withNoneChoice(initiativeStatusChoices(), "Keep current"), }, { name: "targetDate", @@ -339,6 +379,20 @@ async function resolveInitiativePositional( return filled.positional; } +/** + * Discussion positional pickers for the initiative domain (see + * {@link makeDiscussionPickers}). `rootThreadPicker` fills a `[thread]`, + * `commentOrReplyPicker` fills a `[comment]` (root or reply), and `replyPicker` + * fills a `[reply]`. + */ +const { rootThreadPicker, commentOrReplyPicker, replyPicker } = + makeDiscussionPickers({ + entityKind: "initiative", + entityPicker: initiativePicker, + resolveEntityId: (ctx, human) => resolveInitiativeId(ctx.gql, human), + listThreads: listDiscussionsForInitiative, + }); + function parseSortOrder(value?: string): "asc" | "desc" | undefined { if (!value) return undefined; const normalized = value.toLowerCase(); @@ -584,19 +638,16 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { async (initiativeArg, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - const initiative = await resolveInitiativePositional( ctx, command, initiativeArg, ); + const body = await resolveDiscussionBody(ctx, command, options); const initiativeId = await resolveInitiativeId(ctx.gql, initiative); const result = await startInitiativeDiscussion(ctx.gql, { initiativeId, - body: options.body, + body, }); outputSuccess(result); @@ -645,19 +696,26 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const initiativeThreads = initiatives .command("threads") .description("discussion thread reaction operations"); - addCommentReactionCommands(initiativeThreads, "thread"); + addCommentReactionCommands(initiativeThreads, "thread", rootThreadPicker); const initiativeReplies = initiatives - .command("replies <thread>") + .command("replies [thread]") .description("list replies in a root discussion thread") .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - commandAction<[string, DiscussionsOptions, Command]>( + commandAction<[string | undefined, DiscussionsOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "50"), options.after, @@ -665,13 +723,13 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, - asUuid(thread), + asUuid(threadId), paginationOptions, "initiative", ) : await listDiscussionReplies( ctx.gql, - asUuid(thread), + asUuid(threadId), paginationOptions, "initiative", ); @@ -680,28 +738,33 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { }, ), ); - addCommentReactionCommands(initiativeReplies, "reply"); + addCommentReactionCommands(initiativeReplies, "reply", replyPicker); initiatives - .command("reply <thread>") + .command("reply [thread]") .description("reply to a root discussion thread") .addHelpText( "after", - "\nImportant: `<thread>` must be a root discussion thread ID.", + "\nImportant: `[thread]` must be a root discussion thread ID.", ) .option("--body <text>", "reply body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); + const body = await resolveDiscussionBody(ctx, command, options); const result = await replyToDiscussion(ctx.gql, { - threadId: asUuid(thread), - body: options.body, + threadId: asUuid(threadId), + body, entityKind: "initiative", }); @@ -711,23 +774,28 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("edit <comment>") + .command("edit [comment]") .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (comment, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const commentId = await resolvePickedPositional( + ctx, + command, + "comment", + comment, + commentOrReplyPicker, + ); + const body = await resolveDiscussionBody(ctx, command, options); const result = await editDiscussionComment( ctx.gql, - asUuid(comment), + asUuid(commentId), { - body: options.body, + body, }, "initiative", ); @@ -738,23 +806,28 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("edit-reply <reply>") + .command("edit-reply [reply]") .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (reply, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const replyId = await resolvePickedPositional( + ctx, + command, + "reply", + reply, + replyPicker, + ); + const body = await resolveDiscussionBody(ctx, command, options); const result = await editDiscussionReply( ctx.gql, - asUuid(reply), + asUuid(replyId), { - body: options.body, + body, }, "initiative", ); @@ -765,16 +838,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("delete-comment <comment>") + .command("delete-comment [comment]") .description("delete a root discussion or reply comment") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (comment, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const commentId = await resolvePickedPositional( + ctx, + command, + "comment", + comment, + commentOrReplyPicker, + ); const result = await deleteDiscussionComment( ctx.gql, - asUuid(comment), + asUuid(commentId), "initiative", ); @@ -784,16 +864,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("delete-reply <reply>") + .command("delete-reply [reply]") .description("delete a discussion reply") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (reply, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const replyId = await resolvePickedPositional( + ctx, + command, + "reply", + reply, + replyPicker, + ); const result = await deleteDiscussionReply( ctx.gql, - asUuid(reply), + asUuid(replyId), "initiative", ); @@ -803,16 +890,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("resolve <thread>") + .command("resolve [thread]") .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - commandAction<[string, ResolveDiscussionOptions, Command]>( + commandAction<[string | undefined, ResolveDiscussionOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const result = await resolveDiscussion(ctx.gql, { - threadId: asUuid(thread), + threadId: asUuid(threadId), ...(options.withComment !== undefined ? { resolvingCommentId: asUuid(options.withComment) } : {}), @@ -825,16 +919,23 @@ export function setupInitiativeEntityCommands(initiatives: Command): void { ); initiatives - .command("unresolve <thread>") + .command("unresolve [thread]") .description("unresolve a discussion thread") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (thread, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const result = await unresolveDiscussion( ctx.gql, - asUuid(thread), + asUuid(threadId), "initiative", ); diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 2137b123..72762249 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -4,7 +4,10 @@ import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; import { parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; -import { invalidParameterError } from "../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; import { asUuid, @@ -16,11 +19,11 @@ import { import { assigneeChoices, cycleChoices, - emojiChoices, estimateChoices, issueChoices, labelChoices, milestoneChoices, + optionalChoices, optionalProjectChoices, priorityChoices, statusChoices, @@ -28,8 +31,11 @@ import { } from "../common/interactive/choices.js"; import { maybeCollectInteractive } from "../common/interactive/engine.js"; import { shouldPrompt } from "../common/interactive/gating.js"; -import { makeChoicePicker } from "../common/interactive/pickers.js"; -import type { PromptSpec } from "../common/interactive/types.js"; +import { + type ChoicePicker, + makeChoicePicker, +} from "../common/interactive/pickers.js"; +import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import type { RawFilterFlags } from "../common/issue-filter.js"; import { parseEstimateOption, @@ -103,6 +109,12 @@ import { deleteOwnReactionByEmoji, deleteOwnReactionById, } from "../services/reaction-service.js"; +import { + makeDiscussionPickers, + resolveDiscussionBody, + resolveEmojiPositional, + resolvePickedPositional, +} from "./discussion-pickers.js"; interface FilterOptions extends RawFilterFlags { limit: string; @@ -229,7 +241,7 @@ export const issueCreateSpec: PromptSpec<CreateWizardOptions> = { message: "Milestone", searchable: true, when: (draft) => draft.project !== undefined, - choices: milestoneChoices, + choices: optionalChoices(milestoneChoices, "None (no milestone)"), }, { name: "cycle", @@ -237,7 +249,7 @@ export const issueCreateSpec: PromptSpec<CreateWizardOptions> = { message: "Cycle", searchable: true, when: (draft) => draft.team !== undefined, - choices: cycleChoices, + choices: optionalChoices(cycleChoices, "None (no cycle)"), }, { name: "status", @@ -245,7 +257,7 @@ export const issueCreateSpec: PromptSpec<CreateWizardOptions> = { message: "Status", searchable: true, when: (draft) => draft.team !== undefined, - choices: statusChoices, + choices: optionalChoices(statusChoices, "None (team default)"), }, { name: "labels", @@ -261,7 +273,7 @@ export const issueCreateSpec: PromptSpec<CreateWizardOptions> = { message: "Estimate", searchable: true, when: (draft) => draft.team !== undefined, - choices: estimateChoices, + choices: optionalChoices(estimateChoices, "None (no estimate)"), }, { name: "dueDate", @@ -319,7 +331,7 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { message: "Milestone", searchable: true, when: (draft) => draft.project !== undefined, - choices: milestoneChoices, + choices: optionalChoices(milestoneChoices, "Keep current"), }, { name: "cycle", @@ -327,7 +339,7 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { message: "Cycle", searchable: true, when: (draft) => draft.team !== undefined, - choices: cycleChoices, + choices: optionalChoices(cycleChoices, "Keep current"), }, { name: "status", @@ -335,7 +347,7 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { message: "Status", searchable: true, when: (draft) => draft.team !== undefined, - choices: statusChoices, + choices: optionalChoices(statusChoices, "Keep current"), }, { name: "labels", @@ -351,7 +363,7 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { message: "Estimate", searchable: true, when: (draft) => draft.team !== undefined, - choices: estimateChoices, + choices: optionalChoices(estimateChoices, "Keep current"), }, { name: "dueDate", @@ -361,48 +373,6 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { ], }; -/** Wizard shape for the discussion body prompt: a single required `--body`. */ -type BodyWizardOptions = { body?: string } & Record<string, unknown>; - -/** - * Shared body wizard for the discussion subcommands (`discuss`, `reply`, - * `edit`, `edit-reply`). A single required multiline `body` field, mirroring the - * `comments` domain. When `--body` is absent and interactive gating passes the - * user is prompted; the non-interactive/agent path keeps the existing - * "--body is required" throw. - */ -const discussionBodySpec: PromptSpec<BodyWizardOptions> = { - intro: "Enter the comment body", - fields: [ - { name: "body", kind: "multiline", message: "Body", required: true }, - ], -}; - -/** - * Collect the discussion `--body` interactively when it is missing and gating - * allows, else preserve the "--body is required" error for agents/pipes. - */ -async function resolveDiscussionBody( - ctx: CommandContext, - command: Command, - options: DiscussionBodyOptions, -): Promise<string> { - const filled = await maybeCollectInteractive<BodyWizardOptions, never>( - ctx, - getRootOpts(command), - { - spec: discussionBodySpec, - options: options as BodyWizardOptions, - missingRequired: options.body === undefined, - }, - ); - const body = filled.options.body; - if (body === undefined) { - throw invalidParameterError("--body", "is required"); - } - return body; -} - /** * The `labels` multiselect yields a `string[]` of UUIDs, but the command body * expects the CLI-shaped comma-separated `string`. Normalise it in place so the @@ -428,10 +398,37 @@ function normalizeWizardLabels<O extends { labels?: unknown }>(filled: O): O { const issuePicker = makeChoicePicker("Issue", issueChoices); /** - * Emoji picker for an absent `[emoji]` positional. Returns the emoji glyph, - * which flows into `resolveReactionEmojiInput` unchanged as the positional. + * Cross-field picker for an absent `[relation]` positional. Picks the parent + * issue, lists its relations, and returns the selected relation's UUID (which + * `deleteIssueRelation` accepts). An issue with no relations shows a non-fatal + * notice and re-prompts rather than aborting the command. */ -const emojiPicker = makeChoicePicker("Reaction", async () => emojiChoices()); +async function relationPicker( + ctx: CommandContext, + io: PromptIO, +): Promise<string> { + for (;;) { + const issueIdentifier = await issuePicker(ctx, io); + const issueId = await resolveIssueId(ctx.gql, issueIdentifier); + const { relations } = await listIssueRelations(ctx.gql, issueId); + if (relations.length === 0) { + io.intro?.("That issue has no relations — choose another."); + continue; + } + const options = relations.map((relation) => ({ + value: relation.id, + label: `${relation.type}: ${relation.issue.identifier} → ${relation.relatedIssue.identifier}`, + ...(relation.relatedIssue.title + ? { hint: relation.relatedIssue.title } + : {}), + })); + const answer = await io.autocomplete({ message: "Relation", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + } +} const EMPTY_SPEC: PromptSpec<Record<string, never>> = { fields: [] }; @@ -462,29 +459,17 @@ async function resolveIssuePositional( } /** - * Fill an absent `[emoji]` positional via the emoji picker when gating allows. - * Returns the (possibly still-undefined) emoji so the existing - * `resolveReactionEmojiInput` keeps ownership of the emoji-or-shortcode - * validation for the non-interactive path. + * Discussion positional pickers for the issue domain. `rootThreadPicker` fills a + * `[thread]`, `commentOrReplyPicker` fills a `[comment]` (root or reply, matching + * what `edit`/`delete-comment` accept), and `replyPicker` fills a `[reply]`. */ -async function resolveEmojiPositional( - ctx: CommandContext, - command: Command, - emoji: string | undefined, - shortcode: string | undefined, -): Promise<string | undefined> { - const filled = await maybeCollectInteractive<Record<string, never>, string>( - ctx, - getRootOpts(command), - { - spec: EMPTY_SPEC, - options: {}, - missingRequired: emoji === undefined && shortcode === undefined, - positional: { name: "emoji", value: emoji, picker: emojiPicker }, - }, - ); - return filled.positional; -} +const { rootThreadPicker, commentOrReplyPicker, replyPicker } = + makeDiscussionPickers({ + entityKind: "issue", + entityPicker: issuePicker, + resolveEntityId: (ctx, human) => resolveIssueId(ctx.gql, human), + listThreads: listDiscussionsForIssue, + }); interface ReadOptions { withAttachments?: boolean; @@ -535,48 +520,78 @@ interface ResolveDiscussionOptions { function addCommentReactionCommands( parent: ReturnType<Command["command"]>, noun: "thread" | "reply", + picker: ChoicePicker, ): void { parent - .command(`react <${noun}> [emoji]`) + .command(`react [${noun}] [emoji]`) .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (commentId, emoji, options, command) => { - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId: asUuid(commentId), - target: noun, - expectedEntityKind: "issue", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction< + [string | undefined, string | undefined, ReactionOptions, Command] + >(async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const resolvedComment = await resolvePickedPositional( + ctx, + command, + noun, + commentId, + picker, + ); + const resolvedEmoji = await resolveEmojiPositional( + ctx, + command, + emoji, + options.shortcode, + ); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId: asUuid(resolvedComment), + target: noun, + expectedEntityKind: "issue", + emoji: resolveReactionEmojiInput(resolvedEmoji, options.shortcode), + }); - outputSuccess(result); - }, - ), + outputSuccess(result); + }), ); parent - .command(`unreact <${noun}> [emoji]`) + .command(`unreact [${noun}] [emoji]`) .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (commentId, emoji, options, command) => { - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId: asUuid(commentId), - target: noun, - expectedEntityKind: "issue", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction< + [string | undefined, string | undefined, ReactionOptions, Command] + >(async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const resolvedComment = await resolvePickedPositional( + ctx, + command, + noun, + commentId, + picker, + ); + const resolvedEmoji = await resolveEmojiPositional( + ctx, + command, + emoji, + options.shortcode, + ); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId: asUuid(resolvedComment), + target: noun, + expectedEntityKind: "issue", + emoji: resolveReactionEmojiInput(resolvedEmoji, options.shortcode), + }); - outputSuccess(result); - }, - ), + outputSuccess(result); + }), ); parent + // `unreact-id` stays fully non-interactive: its <reactionId> cannot be + // sourced from any list service, so a picker would be a half-interactive + // trap. It remains the flag-only by-ID escape hatch for agents. .command(`unreact-id <${noun}> <reactionId>`) .description( `remove your reaction from a discussion ${noun} by reaction ID`, @@ -894,7 +909,7 @@ export function setupIssuesCommands(program: Command): void { ); relations - .command("add <issue>") + .command("add [issue]") .description("add relation(s) to an issue") .option("--blocks <issues>", "issues this issue blocks (comma-separated)") .option("--related <issues>", "related issues (comma-separated)") @@ -904,10 +919,11 @@ export function setupIssuesCommands(program: Command): void { ) .option("--similar <issues>", "similar issues (comma-separated)") .action( - commandAction<[string, RelationAddOptions, Command]>( - async (issue, options, command) => { + commandAction<[string | undefined, RelationAddOptions, Command]>( + async (issueArg, options, command) => { const relation = parseRelationAddOptions(options); const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); const sourceIssueId = await resolveIssueId(ctx.gql, issue); const targetIds = await Promise.all( relation.targets.map((target) => resolveIssueId(ctx.gql, target)), @@ -929,12 +945,19 @@ export function setupIssuesCommands(program: Command): void { ); relations - .command("remove <relation>") + .command("remove [relation]") .description("remove a relation by UUID") .action( - commandAction<[string, unknown, Command]>( - async (relation, _unused1, command) => { + commandAction<[string | undefined, unknown, Command]>( + async (relationArg, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const relation = await resolvePickedPositional( + ctx, + command, + "relation", + relationArg, + relationPicker, + ); const result = await deleteIssueRelation(ctx.gql, asUuid(relation)); outputSuccess(result); @@ -1108,7 +1131,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("react <issue> [emoji]") + .command("react [issue] [emoji]") .description("add a root reaction to an issue") .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .addHelpText( @@ -1116,28 +1139,29 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (issue, emojiArg, options, command) => { - const ctx = createContext(getRootOpts(command)); - const emoji = await resolveEmojiPositional( - ctx, - command, - emojiArg, - options.shortcode, - ); - const issueId = await resolveIssueId(ctx.gql, issue); - const result = await createReactionForIssue(ctx.gql, { - issueId, - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction< + [string | undefined, string | undefined, ReactionOptions, Command] + >(async (issueArg, emojiArg, options, command) => { + const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); + const emoji = await resolveEmojiPositional( + ctx, + command, + emojiArg, + options.shortcode, + ); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await createReactionForIssue(ctx.gql, { + issueId, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }, - ), + outputSuccess(result); + }), ); issues - .command("unreact <issue> [emoji]") + .command("unreact [issue] [emoji]") .description("remove your root reaction from an issue by emoji") .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .addHelpText( @@ -1145,25 +1169,26 @@ export function setupIssuesCommands(program: Command): void { `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, ) .action( - commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (issue, emojiArg, options, command) => { - const ctx = createContext(getRootOpts(command)); - const emoji = await resolveEmojiPositional( - ctx, - command, - emojiArg, - options.shortcode, - ); - const issueId = await resolveIssueId(ctx.gql, issue); - const result = await deleteOwnReactionByEmoji(ctx.gql, { - kind: "issue", - id: issueId, - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); + commandAction< + [string | undefined, string | undefined, ReactionOptions, Command] + >(async (issueArg, emojiArg, options, command) => { + const ctx = createContext(getRootOpts(command)); + const issue = await resolveIssuePositional(ctx, command, issueArg); + const emoji = await resolveEmojiPositional( + ctx, + command, + emojiArg, + options.shortcode, + ); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await deleteOwnReactionByEmoji(ctx.gql, { + kind: "issue", + id: issueId, + emoji: resolveReactionEmojiInput(emoji, options.shortcode), + }); - outputSuccess(result); - }, - ), + outputSuccess(result); + }), ); issues @@ -1291,19 +1316,26 @@ export function setupIssuesCommands(program: Command): void { const issueThreads = issues .command("threads") .description("discussion thread reaction operations"); - addCommentReactionCommands(issueThreads, "thread"); + addCommentReactionCommands(issueThreads, "thread", rootThreadPicker); const issueReplies = issues - .command("replies <thread>") + .command("replies [thread]") .description("list replies in a root discussion thread") .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - commandAction<[string, DiscussionsOptions, Command]>( + commandAction<[string | undefined, DiscussionsOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "50"), options.after, @@ -1311,13 +1343,13 @@ export function setupIssuesCommands(program: Command): void { const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, - asUuid(thread), + asUuid(threadId), paginationOptions, "issue", ) : await listDiscussionReplies( ctx.gql, - asUuid(thread), + asUuid(threadId), paginationOptions, "issue", ); @@ -1326,25 +1358,32 @@ export function setupIssuesCommands(program: Command): void { }, ), ); - addCommentReactionCommands(issueReplies, "reply"); + addCommentReactionCommands(issueReplies, "reply", replyPicker); issues - .command("reply <thread>") + .command("reply [thread]") .description("reply to a root discussion thread") .addHelpText( "after", - "\nImportant: `<thread>` must be a root discussion thread ID.", + "\nImportant: `[thread]` must be a root discussion thread ID.", ) .option("--body <text>", "reply body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const body = await resolveDiscussionBody(ctx, command, options); const result = await replyToDiscussion(ctx.gql, { - threadId: asUuid(thread), + threadId: asUuid(threadId), body, entityKind: "issue", }); @@ -1355,19 +1394,26 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("edit <comment>") + .command("edit [comment]") .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (comment, options, command) => { const ctx = createContext(getRootOpts(command)); + const commentId = await resolvePickedPositional( + ctx, + command, + "comment", + comment, + commentOrReplyPicker, + ); const body = await resolveDiscussionBody(ctx, command, options); const result = await editDiscussionComment( ctx.gql, - asUuid(comment), + asUuid(commentId), { body, }, @@ -1380,19 +1426,26 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("edit-reply <reply>") + .command("edit-reply [reply]") .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (reply, options, command) => { const ctx = createContext(getRootOpts(command)); + const replyId = await resolvePickedPositional( + ctx, + command, + "reply", + reply, + replyPicker, + ); const body = await resolveDiscussionBody(ctx, command, options); const result = await editDiscussionReply( ctx.gql, - asUuid(reply), + asUuid(replyId), { body, }, @@ -1405,16 +1458,23 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("delete-comment <comment>") + .command("delete-comment [comment]") .description("delete a root discussion or reply comment") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (comment, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const commentId = await resolvePickedPositional( + ctx, + command, + "comment", + comment, + commentOrReplyPicker, + ); const result = await deleteDiscussionComment( ctx.gql, - asUuid(comment), + asUuid(commentId), "issue", ); @@ -1424,16 +1484,23 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("delete-reply <reply>") + .command("delete-reply [reply]") .description("delete a discussion reply") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (reply, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const replyId = await resolvePickedPositional( + ctx, + command, + "reply", + reply, + replyPicker, + ); const result = await deleteDiscussionReply( ctx.gql, - asUuid(reply), + asUuid(replyId), "issue", ); @@ -1443,16 +1510,23 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("resolve <thread>") + .command("resolve [thread]") .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - commandAction<[string, ResolveDiscussionOptions, Command]>( + commandAction<[string | undefined, ResolveDiscussionOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const result = await resolveDiscussion(ctx.gql, { - threadId: asUuid(thread), + threadId: asUuid(threadId), ...(options.withComment !== undefined ? { resolvingCommentId: asUuid(options.withComment) } : {}), @@ -1465,16 +1539,23 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("unresolve <thread>") + .command("unresolve [thread]") .description("unresolve a discussion thread") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (thread, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const result = await unresolveDiscussion( ctx.gql, - asUuid(thread), + asUuid(threadId), "issue", ); diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index 391200c7..14872ece 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -106,6 +106,33 @@ export const milestoneUpdateSpec: PromptSpec<MilestoneCreateWizardOptions> = { ], }; +/** Wizard shape for `milestones list`: a required project select. */ +interface MilestoneListWizardOptions extends Record<string, unknown> { + project?: string; + limit?: string; + after?: string; +} + +/** + * Interactive wizard for `milestones list`. Milestones are project-scoped and + * `--project` is required, so — unlike the other list commands — a TTY user with + * no `--project` is prompted for one instead of hitting the missing-required + * error. Agents/pipes keep the old "--project is required" throw. + */ +export const milestoneListSpec: PromptSpec<MilestoneListWizardOptions> = { + intro: "List milestones in a project", + fields: [ + { + name: "project", + kind: "select", + message: "Project", + required: true, + searchable: true, + choices: projectChoices, + }, + ], +}; + /** * Entity picker for an absent `[milestone]` positional. Milestones are * project-scoped, so this first prompts for a project (unless one was already @@ -170,23 +197,42 @@ export function setupMilestonesCommands(program: Command): void { milestones .command("list") .description("list milestones in a project") - .requiredOption("--project <project>", "target project (required)") + .option("--project <project>", "target project (required)") .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page") + .addHelpText( + "after", + "\n--project is required. Pass it, or run in a terminal (or with -i) to pick one interactively.", + ) .action( handleCommand(async (...args: unknown[]) => { - const [options, command] = args as [MilestoneListOptions, Command]; + const [options, command] = args as [ + Partial<MilestoneListOptions>, + Command, + ]; const ctx = createContext(getRootOpts(command)); - // Resolve project ID - const projectId = await resolveProjectId(ctx.gql, options.project); + const filled = await maybeCollectInteractive< + MilestoneListWizardOptions, + never + >(ctx, getRootOpts(command), { + spec: milestoneListSpec, + options: options as MilestoneListWizardOptions, + missingRequired: options.project === undefined, + }); + const project = filled.options.project; + if (project === undefined) { + throw invalidParameterError("--project", "is required"); + } + + const projectId = await resolveProjectId(ctx.gql, project); const milestones = await listMilestones( ctx.gql, projectId, buildPaginationOptions( - parseLimit(options.limit || "50"), - options.after, + parseLimit(filled.options.limit || "50"), + filled.options.after, ), ); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 28a38027..cb2c4007 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -10,12 +10,14 @@ import { import { asUuid } from "../common/identifier.js"; import { labelChoices, + optionalChoices, priorityChoices, projectStatusChoices, teamChoices, userChoices, } from "../common/interactive/choices.js"; import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { ChoicePicker } from "../common/interactive/pickers.js"; import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; @@ -55,6 +57,12 @@ import { unarchiveProject, updateProject, } from "../services/project-service.js"; +import { + makeDiscussionPickers, + resolveDiscussionBody, + resolveEmojiPositional, + resolvePickedPositional, +} from "./discussion-pickers.js"; interface ListOptions { limit: string; @@ -88,46 +96,75 @@ interface ReactionOptions { function addCommentReactionCommands( parent: ReturnType<Command["command"]>, noun: "thread" | "reply", + picker: ChoicePicker, ): void { parent - .command(`react <${noun}> [emoji]`) + .command(`react [${noun}] [emoji]`) .description(`add a reaction to a discussion ${noun}`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (commentId, emoji, options, command) => { - const ctx = createContext(getRootOpts(command)); - const result = await createDiscussionCommentReaction(ctx.gql, { - commentId: asUuid(commentId), - target: noun, - expectedEntityKind: "project", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }, - ), + commandAction< + [string | undefined, string | undefined, ReactionOptions, Command] + >(async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const resolvedComment = await resolvePickedPositional( + ctx, + command, + noun, + commentId, + picker, + ); + const resolvedEmoji = await resolveEmojiPositional( + ctx, + command, + emoji, + options.shortcode, + ); + const result = await createDiscussionCommentReaction(ctx.gql, { + commentId: asUuid(resolvedComment), + target: noun, + expectedEntityKind: "project", + emoji: resolveReactionEmojiInput(resolvedEmoji, options.shortcode), + }); + outputSuccess(result); + }), ); parent - .command(`unreact <${noun}> [emoji]`) + .command(`unreact [${noun}] [emoji]`) .description(`remove your reaction from a discussion ${noun} by emoji`) .option("--shortcode <name>", "emoji shortcode (e.g. thumbs_up)") .action( - commandAction<[string, string | undefined, ReactionOptions, Command]>( - async (commentId, emoji, options, command) => { - const ctx = createContext(getRootOpts(command)); - const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { - commentId: asUuid(commentId), - target: noun, - expectedEntityKind: "project", - emoji: resolveReactionEmojiInput(emoji, options.shortcode), - }); - outputSuccess(result); - }, - ), + commandAction< + [string | undefined, string | undefined, ReactionOptions, Command] + >(async (commentId, emoji, options, command) => { + const ctx = createContext(getRootOpts(command)); + const resolvedComment = await resolvePickedPositional( + ctx, + command, + noun, + commentId, + picker, + ); + const resolvedEmoji = await resolveEmojiPositional( + ctx, + command, + emoji, + options.shortcode, + ); + const result = await deleteDiscussionCommentReactionByEmoji(ctx.gql, { + commentId: asUuid(resolvedComment), + target: noun, + expectedEntityKind: "project", + emoji: resolveReactionEmojiInput(resolvedEmoji, options.shortcode), + }); + outputSuccess(result); + }), ); parent + // `unreact-id` stays fully non-interactive: its <reactionId> cannot be + // sourced from any list service (flag-only by-ID escape hatch for agents). .command(`unreact-id <${noun}> <reactionId>`) .description( `remove your reaction from a discussion ${noun} by reaction ID`, @@ -212,11 +249,21 @@ export const projectCreateSpec: PromptSpec<CreateWizardOptions> = { }, { name: "description", kind: "multiline", message: "Description" }, { name: "content", kind: "multiline", message: "Content (markdown)" }, + { name: "icon", kind: "text", message: "Icon (emoji or icon name)" }, + { + name: "color", + kind: "text", + message: "Color (hex, e.g. #B45309)", + validate: (value) => + value === "" || /^#[0-9a-fA-F]{6}$/.test(value) + ? undefined + : "must be a hex color like #B45309", + }, { name: "lead", kind: "select", message: "Lead", - choices: userChoices, + choices: optionalChoices(userChoices, "None (no lead)"), }, { name: "members", @@ -234,7 +281,7 @@ export const projectCreateSpec: PromptSpec<CreateWizardOptions> = { name: "status", kind: "select", message: "Status", - choices: projectStatusChoices, + choices: optionalChoices(projectStatusChoices, "None (no status)"), }, { name: "labels", @@ -271,11 +318,21 @@ export const projectUpdateSpec: PromptSpec<UpdateWizardOptions> = { kind: "multiline", message: "Content (markdown)", }, + { name: "icon", kind: "text", message: "Icon (emoji or icon name)" }, + { + name: "color", + kind: "text", + message: "Color (hex, e.g. #B45309)", + validate: (value) => + value === "" || /^#[0-9a-fA-F]{6}$/.test(value) + ? undefined + : "must be a hex color like #B45309", + }, { name: "lead", kind: "select", message: "Lead", - choices: userChoices, + choices: optionalChoices(userChoices, "Keep current"), }, { name: "members", @@ -293,7 +350,7 @@ export const projectUpdateSpec: PromptSpec<UpdateWizardOptions> = { name: "status", kind: "select", message: "Status", - choices: projectStatusChoices, + choices: optionalChoices(projectStatusChoices, "Keep current"), }, { name: "labels", @@ -388,6 +445,20 @@ async function resolveProjectPositional( return filled.positional; } +/** + * Discussion positional pickers for the project domain (see + * {@link makeDiscussionPickers}). `rootThreadPicker` fills a `[thread]`, + * `commentOrReplyPicker` fills a `[comment]` (root or reply), and `replyPicker` + * fills a `[reply]`. + */ +const { rootThreadPicker, commentOrReplyPicker, replyPicker } = + makeDiscussionPickers({ + entityKind: "project", + entityPicker: projectPicker, + resolveEntityId: (ctx, human) => resolveProjectId(ctx.gql, human), + listThreads: listDiscussionsForProject, + }); + export const PROJECTS_META: DomainMeta = { name: "projects", summary: "groups of issues toward a goal", @@ -536,19 +607,16 @@ export function setupProjectsCommands(program: Command): void { async (projectArg, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } - const project = await resolveProjectPositional( ctx, command, projectArg, ); + const body = await resolveDiscussionBody(ctx, command, options); const projectId = await resolveProjectId(ctx.gql, project); const result = await startProjectDiscussion(ctx.gql, { projectId, - body: options.body, + body, }); outputSuccess(result); @@ -597,19 +665,26 @@ export function setupProjectsCommands(program: Command): void { const projectThreads = projects .command("threads") .description("discussion thread reaction operations"); - addCommentReactionCommands(projectThreads, "thread"); + addCommentReactionCommands(projectThreads, "thread", rootThreadPicker); const projectReplies = projects - .command("replies <thread>") + .command("replies [thread]") .description("list replies in a root discussion thread") .option("-l, --limit <n>", "max results", "50") .option("--after <cursor>", "cursor for next page") .option("--with-reactions", "include normalized discussion reactions") .action( - commandAction<[string, DiscussionsOptions, Command]>( + commandAction<[string | undefined, DiscussionsOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const paginationOptions = buildPaginationOptions( parseLimit(options.limit || "50"), options.after, @@ -617,13 +692,13 @@ export function setupProjectsCommands(program: Command): void { const result = options.withReactions ? await listDiscussionRepliesWithReactions( ctx.gql, - asUuid(thread), + asUuid(threadId), paginationOptions, "project", ) : await listDiscussionReplies( ctx.gql, - asUuid(thread), + asUuid(threadId), paginationOptions, "project", ); @@ -632,28 +707,33 @@ export function setupProjectsCommands(program: Command): void { }, ), ); - addCommentReactionCommands(projectReplies, "reply"); + addCommentReactionCommands(projectReplies, "reply", replyPicker); projects - .command("reply <thread>") + .command("reply [thread]") .description("reply to a root discussion thread") .addHelpText( "after", - "\nImportant: `<thread>` must be a root discussion thread ID.", + "\nImportant: `[thread]` must be a root discussion thread ID.", ) .option("--body <text>", "reply body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); + const body = await resolveDiscussionBody(ctx, command, options); const result = await replyToDiscussion(ctx.gql, { - threadId: asUuid(thread), - body: options.body, + threadId: asUuid(threadId), + body, entityKind: "project", }); @@ -663,23 +743,28 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("edit <comment>") + .command("edit [comment]") .description("edit a root discussion or reply comment") .option("--body <text>", "new comment body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (comment, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const commentId = await resolvePickedPositional( + ctx, + command, + "comment", + comment, + commentOrReplyPicker, + ); + const body = await resolveDiscussionBody(ctx, command, options); const result = await editDiscussionComment( ctx.gql, - asUuid(comment), + asUuid(commentId), { - body: options.body, + body, }, "project", ); @@ -690,23 +775,28 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("edit-reply <reply>") + .command("edit-reply [reply]") .description("edit a discussion reply") .option("--body <text>", "new reply body (required, markdown supported)") .action( - commandAction<[string, DiscussionBodyOptions, Command]>( + commandAction<[string | undefined, DiscussionBodyOptions, Command]>( async (reply, options, command) => { const ctx = createContext(getRootOpts(command)); - if (!options.body) { - throw invalidParameterError("--body", "is required"); - } + const replyId = await resolvePickedPositional( + ctx, + command, + "reply", + reply, + replyPicker, + ); + const body = await resolveDiscussionBody(ctx, command, options); const result = await editDiscussionReply( ctx.gql, - asUuid(reply), + asUuid(replyId), { - body: options.body, + body, }, "project", ); @@ -717,16 +807,23 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("delete-comment <comment>") + .command("delete-comment [comment]") .description("delete a root discussion or reply comment") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (comment, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const commentId = await resolvePickedPositional( + ctx, + command, + "comment", + comment, + commentOrReplyPicker, + ); const result = await deleteDiscussionComment( ctx.gql, - asUuid(comment), + asUuid(commentId), "project", ); @@ -736,16 +833,23 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("delete-reply <reply>") + .command("delete-reply [reply]") .description("delete a discussion reply") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (reply, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const replyId = await resolvePickedPositional( + ctx, + command, + "reply", + reply, + replyPicker, + ); const result = await deleteDiscussionReply( ctx.gql, - asUuid(reply), + asUuid(replyId), "project", ); @@ -755,16 +859,23 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("resolve <thread>") + .command("resolve [thread]") .description("resolve a discussion thread") .option("--with-comment <comment>", "comment to mark as resolving comment") .action( - commandAction<[string, ResolveDiscussionOptions, Command]>( + commandAction<[string | undefined, ResolveDiscussionOptions, Command]>( async (thread, options, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const result = await resolveDiscussion(ctx.gql, { - threadId: asUuid(thread), + threadId: asUuid(threadId), ...(options.withComment !== undefined ? { resolvingCommentId: asUuid(options.withComment) } : {}), @@ -777,16 +888,23 @@ export function setupProjectsCommands(program: Command): void { ); projects - .command("unresolve <thread>") + .command("unresolve [thread]") .description("unresolve a discussion thread") .action( - commandAction<[string, unknown, Command]>( + commandAction<[string | undefined, unknown, Command]>( async (thread, _unused1, command) => { const ctx = createContext(getRootOpts(command)); + const threadId = await resolvePickedPositional( + ctx, + command, + "thread", + thread, + rootThreadPicker, + ); const result = await unresolveDiscussion( ctx.gql, - asUuid(thread), + asUuid(threadId), "project", ); diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 8b703269..aa380dd8 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -8,8 +8,10 @@ import { InteractiveCancelledError, invalidParameterError, } from "../common/errors.js"; +import type { UUID } from "../common/identifier.js"; import { teamChoices, userChoices } from "../common/interactive/choices.js"; import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import type { ChoicePicker } from "../common/interactive/pickers.js"; import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { buildPaginationOptions } from "../common/types.js"; @@ -383,6 +385,56 @@ async function resolveUserOption( return filled.positional; } +/** + * Fill an absent `--user` on `remove-member` via a picker scoped to the team's + * CURRENT members (unlike {@link resolveUserOption}, which offers all users) so + * a non-member — which the API would reject — cannot be selected. Returns the + * selected user's UUID for the resolver. + */ +async function resolveTeamMemberOption( + ctx: CommandContext, + command: Command, + teamId: UUID, + user: string | undefined, +): Promise<string> { + const memberPicker: ChoicePicker = async (pickerCtx, io) => { + const { nodes } = await listTeamMembers(pickerCtx.gql, { id: teamId }); + const options = nodes.flatMap((member) => + member.user + ? [ + { + value: member.user.id, + label: member.user.displayName, + hint: member.user.email, + }, + ] + : [], + ); + if (options.length === 0) { + throw invalidParameterError("--user", "the selected team has no members"); + } + const answer = await io.select({ message: "User", options }); + if (io.isCancel(answer)) { + throw new InteractiveCancelledError(); + } + return answer as string; + }; + const filled = await maybeCollectInteractive<Record<string, never>, string>( + ctx, + getRootOpts(command), + { + spec: EMPTY_SPEC, + options: {}, + missingRequired: user === undefined, + positional: { name: "user", value: user, picker: memberPicker }, + }, + ); + if (filled.positional === undefined) { + throw invalidParameterError("--user", "is required"); + } + return filled.positional; +} + export function setupTeamsCommands(program: Command): void { const teams = program.command("teams").description("Team operations"); @@ -575,11 +627,14 @@ export function setupTeamsCommands(program: Command): void { ]; const ctx = createContext(getRootOpts(command)); const team = await resolveTeamPositional(ctx, command, teamArg); - const user = await resolveUserOption(ctx, command, options.user); - const [teamId, userId] = await Promise.all([ - resolveTeamId(ctx.gql, team), - resolveUserId(ctx.gql, user), - ]); + const teamId = await resolveTeamId(ctx.gql, team); + const user = await resolveTeamMemberOption( + ctx, + command, + teamId, + options.user, + ); + const userId = await resolveUserId(ctx.gql, user); const result = await removeTeamMember(ctx.gql, { teamId, userId }); outputSuccess(result); }), diff --git a/src/common/interactive/choices.ts b/src/common/interactive/choices.ts index d2e64265..68dd1f28 100644 --- a/src/common/interactive/choices.ts +++ b/src/common/interactive/choices.ts @@ -57,6 +57,28 @@ export function withNoneChoice(choices: Choice[], label: string): Choice[] { return [{ value: "", label }, ...choices]; } +/** + * Wrap a choice loader so it prepends an empty-valued sentinel (via + * {@link withNoneChoice}) whenever it returns at least one real option. This + * makes an otherwise-mandatory single-select escapable: + * - on create, picking the sentinel leaves the field unset (CLI default); + * - on update, it leaves the field unchanged. + * + * When the underlying loader returns no options (e.g. a team with estimates + * disabled, or no upcoming cycles) the empty list is passed through unchanged, + * so the engine skips the field entirely instead of rendering a select whose + * only entry is the sentinel. + */ +export function optionalChoices( + load: (ctx: CommandContext, draft: Draft) => Promise<Choice[]>, + label: string, +): (ctx: CommandContext, draft: Draft) => Promise<Choice[]> { + return async (ctx, draft) => { + const base = await load(ctx, draft); + return base.length === 0 ? [] : withNoneChoice(base, label); + }; +} + export async function teamChoices(ctx: CommandContext): Promise<Choice[]> { const { nodes } = await listTeams(ctx.gql); return nodes.map((team) => ({ @@ -187,6 +209,30 @@ export async function cycleChoices( })); } +/** + * Every cycle for the draft's team, including ended ones. Unlike + * {@link cycleChoices} (which drops past cycles because you cannot schedule work + * into a finished cycle), reading a cycle is a retrospective operation, so the + * `cycles read` picker must be able to reach historical cycles too. The active + * cycle is surfaced first; the rest follow most-recent-first by start date. + */ +export async function allCycleChoices( + ctx: CommandContext, + draft: Draft, +): Promise<Choice[]> { + const teamId = draftUuid(draft, "team"); + const { nodes } = await listCycles(ctx.gql, teamId); + const sorted = [...nodes].sort((a, b) => { + if (a.isActive !== b.isActive) return a.isActive ? -1 : 1; + return new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime(); + }); + return sorted.map((cycle) => ({ + value: cycle.id, + label: cycle.name, + hint: cycle.isActive ? "current" : `${cycle.startsAt} → ${cycle.endsAt}`, + })); +} + export async function milestoneChoices( ctx: CommandContext, draft: Draft, diff --git a/src/common/interactive/engine.ts b/src/common/interactive/engine.ts index cc2c64ca..72c830b0 100644 --- a/src/common/interactive/engine.ts +++ b/src/common/interactive/engine.ts @@ -85,23 +85,27 @@ async function promptField<O>( onPrompt: () => void, ): Promise<string | string[] | boolean | symbol> { switch (field.kind) { - case "text": + case "text": { onPrompt(); + const validate = buildTextValidate(field); return io.text({ message: field.message, ...(initial !== undefined ? { initialValue: initial } : {}), - ...(field.validate !== undefined ? { validate: field.validate } : {}), + ...(validate !== undefined ? { validate } : {}), }); - case "multiline": + } + case "multiline": { onPrompt(); + const validate = buildTextValidate(field); return io.multiline({ message: field.message, // Enter inserts a newline; a visible, Tab-focusable [ submit ] button // makes confirming discoverable (Enter on a blank line also submits). showSubmit: true, ...(initial !== undefined ? { initialValue: initial } : {}), - ...(field.validate !== undefined ? { validate: field.validate } : {}), + ...(validate !== undefined ? { validate } : {}), }); + } case "select": { const options = (await field.choices?.(ctx, draft)) ?? []; // Nothing to choose from (e.g. team has estimates disabled, or no @@ -168,6 +172,26 @@ async function promptField<O>( } } +/** + * Build the validate callback for a text/multiline field. When the field is + * `required`, a non-blank guard is composed in front of any caller-supplied + * validator: clack returns "" for an empty submission, and collectInteractive + * would treat that as "leave unset", so without this a required title/name/body + * could be blown past with Enter and only fail at the downstream required-field + * throw after the whole wizard was filled in. Returning an error string here + * re-prompts in place instead. + */ +function buildTextValidate<O>( + field: FieldPrompt<O>, +): ((value: string) => string | undefined) | undefined { + const base = field.validate; + if (field.required !== true) return base; + return (value: string) => { + if (value.trim() === "") return `${field.message} is required`; + return base?.(value); + }; +} + /** * Parse a `YYYY-MM-DD` seed string into a local `Date` for the picker's initial * value. Returns undefined when the string is not a parseable date so the diff --git a/tests/unit/interactive/choices.test.ts b/tests/unit/interactive/choices.test.ts index 0a916f19..dcf13479 100644 --- a/tests/unit/interactive/choices.test.ts +++ b/tests/unit/interactive/choices.test.ts @@ -3,11 +3,13 @@ import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import type { CommandContext } from "../../../src/common/context.js"; import { asUuid } from "../../../src/common/identifier.js"; import { + allCycleChoices, cycleChoices, emojiChoices, initiativeChoices, labelChoices, milestoneChoices, + optionalChoices, projectStatusChoices, statusChoices, teamChoices, @@ -35,6 +37,33 @@ describe("withNoneChoice", () => { }); }); +describe("optionalChoices", () => { + it("prepends the leave-unchanged sentinel when the loader has options", async () => { + const load = vi.fn().mockResolvedValue([{ value: "u1", label: "Ada" }]); + + const result = await optionalChoices(load, "Keep current")( + mockCtx(vi.fn()), + {}, + ); + + expect(result).toEqual([ + { value: "", label: "Keep current" }, + { value: "u1", label: "Ada" }, + ]); + }); + + it("passes an empty list through so the engine skips the field", async () => { + const load = vi.fn().mockResolvedValue([]); + + const result = await optionalChoices(load, "Keep current")( + mockCtx(vi.fn()), + {}, + ); + + expect(result).toEqual([]); + }); +}); + describe("listWorkflowStates", () => { it("queries the team-scoped states and sorts by position", async () => { const request = vi.fn().mockResolvedValue({ @@ -258,6 +287,58 @@ describe("cycleChoices (cross-field: cycle needs team)", () => { }); }); +describe("allCycleChoices (read picker: keeps ended cycles)", () => { + const day = 24 * 60 * 60 * 1000; + const iso = (offsetDays: number): string => + new Date(Date.now() + offsetDays * day).toISOString(); + + it("keeps past cycles and surfaces the active cycle first", async () => { + const request = vi.fn().mockResolvedValue({ + cycles: { + nodes: [ + { + id: "past", + number: 1, + name: "Past", + startsAt: iso(-28), + endsAt: iso(-14), + isActive: false, + isNext: false, + isPrevious: true, + }, + { + id: "future", + number: 3, + name: "Future", + startsAt: iso(14), + endsAt: iso(28), + isActive: false, + isNext: true, + isPrevious: false, + }, + { + id: "current", + number: 2, + name: "Current", + startsAt: iso(-3), + endsAt: iso(11), + isActive: true, + isNext: false, + isPrevious: false, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + const result = await allCycleChoices(mockCtx(request), { team: TEAM_UUID }); + + // Unlike cycleChoices, the past cycle is retained; active is first, then + // remaining cycles most-recent-first by start date. + expect(result.map((c) => c.value)).toEqual(["current", "future", "past"]); + }); +}); + describe("emojiChoices", () => { it("maps common emoji to glyph-valued choices with shortcode hints", () => { const choices = emojiChoices(); diff --git a/tests/unit/interactive/content-specs.test.ts b/tests/unit/interactive/content-specs.test.ts index f24af714..9b0ef6ec 100644 --- a/tests/unit/interactive/content-specs.test.ts +++ b/tests/unit/interactive/content-specs.test.ts @@ -41,6 +41,14 @@ describe("documentCreateSpec", () => { expect(team?.kind).toBe("select"); expect(team?.choices).toBeDefined(); }); + + it("offers an optional issue attachment via a searchable select", () => { + const issue = documentCreateSpec.fields.find((f) => f.name === "issue"); + expect(issue?.kind).toBe("select"); + expect(issue?.required).not.toBe(true); + expect(issue?.searchable).toBe(true); + expect(issue?.choices).toBeDefined(); + }); }); describe("documentUpdateSpec", () => { @@ -59,6 +67,16 @@ describe("attachmentCreateSpec", () => { expect(required).toContain("title"); expect(required).toContain("url"); }); + + it("covers the optional comment and icon-url flags", () => { + const names = attachmentCreateSpec.fields.map((f) => f.name); + expect(names).toContain("comment"); + expect(names).toContain("iconUrl"); + const comment = attachmentCreateSpec.fields.find( + (f) => f.name === "comment", + ); + expect(comment?.kind).toBe("multiline"); + }); }); describe("issueChoices (shared content-domain issue picker loader)", () => { diff --git a/tests/unit/interactive/coverage-sweep.test.ts b/tests/unit/interactive/coverage-sweep.test.ts index f4b885c5..94e91578 100644 --- a/tests/unit/interactive/coverage-sweep.test.ts +++ b/tests/unit/interactive/coverage-sweep.test.ts @@ -25,21 +25,9 @@ const COMMANDS_DIR = join(process.cwd(), "src/commands"); /** command signatures (verb + positionals) intentionally left with `<arg>`. */ const SKIP_REQUIRED_POSITIONAL = new Set<string>([ - // raw comment/thread UUID discussion subcommands (no in-command parent list) - "replies", - "reply", - "edit", - "edit-reply", - "delete-comment", - "delete-reply", - "resolve", - "unresolve", - "react", - "unreact", + // `unreact-id` targets a reaction by raw UUID; no per-comment reaction list + // service exists to source a picker, so it stays a flag-only escape hatch. "unreact-id", - // relation ops: require relation flags / a raw relation UUID - "add", - "remove", // full-text search takes a free-text query, not an entity id "search", // create's leading positional is a free-text name/title filled by the diff --git a/tests/unit/interactive/discussion-pickers.test.ts b/tests/unit/interactive/discussion-pickers.test.ts new file mode 100644 index 00000000..7a2f90ba --- /dev/null +++ b/tests/unit/interactive/discussion-pickers.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../../../src/common/context.js"; +import { InteractiveCancelledError } from "../../../src/common/errors.js"; +import { asUuid, type UUID } from "../../../src/common/identifier.js"; +import type { ChoicePicker } from "../../../src/common/interactive/pickers.js"; +import type { PromptIO } from "../../../src/common/interactive/types.js"; +import type { PaginatedResult } from "../../../src/common/types.js"; +import type { + DiscussionEntityKind, + DiscussionThread, +} from "../../../src/services/discussion-service.js"; + +// The builder calls the real `listDiscussionReplies` service; stub it so the +// picker traversal can be exercised without a GraphQL client. +const { listDiscussionReplies } = vi.hoisted(() => ({ + listDiscussionReplies: vi.fn(), +})); +vi.mock("../../../src/services/discussion-service.js", async (orig) => ({ + ...(await orig< + typeof import("../../../src/services/discussion-service.js") + >()), + listDiscussionReplies, +})); + +const { makeDiscussionPickers } = await import( + "../../../src/commands/discussion-pickers.js" +); + +const CANCEL = Symbol("cancel"); +const ctx = { gql: {} } as unknown as CommandContext; + +function thread(id: string, parentId: string | null = null): DiscussionThread { + return { + id, + body: `body of ${id}`, + createdAt: "", + editedAt: null, + parentId, + resolvedAt: null, + resolvingComment: null, + resolvingUser: null, + user: { id: "u1", displayName: "Alice" }, + } as unknown as DiscussionThread; +} + +function page(nodes: DiscussionThread[]): PaginatedResult<DiscussionThread> { + return { + nodes, + pageInfo: {}, + } as unknown as PaginatedResult<DiscussionThread>; +} + +/** Fake PromptIO whose `autocomplete` returns a scripted answer per message. */ +function fakeIO( + answers: Record<string, string | symbol>, + notices: string[] = [], +): PromptIO { + const unimplemented = async () => ""; + return { + intro: (m) => notices.push(m), + text: unimplemented, + multiline: unimplemented, + select: unimplemented, + autocomplete: async (o) => answers[o.message] ?? "", + multiselect: async () => [], + autocompleteMultiselect: async () => [], + confirm: async () => false, + date: async () => new Date(), + isCancel: (v) => v === CANCEL, + }; +} + +interface Cfg { + entityKind: DiscussionEntityKind; + entityPicker: ChoicePicker; + resolveEntityId: (ctx: CommandContext, human: string) => Promise<UUID>; + listThreads: () => Promise<PaginatedResult<DiscussionThread>>; +} + +function buildCfg(overrides: Partial<Cfg> = {}) { + const entityPicker = vi.fn<ChoicePicker>(async () => "E1"); + const resolveEntityId = vi.fn(async () => asUuid("entity-uuid")); + const listThreads = vi.fn(async () => page([thread("t1"), thread("t2")])); + const cfg = { + entityKind: "issue" as DiscussionEntityKind, + entityPicker, + resolveEntityId, + listThreads, + ...overrides, + }; + return { + pickers: makeDiscussionPickers(cfg), + entityPicker, + resolveEntityId, + listThreads, + }; +} + +describe("makeDiscussionPickers", () => { + it("rootThreadPicker resolves entity then returns the chosen thread id", async () => { + const { pickers, entityPicker, resolveEntityId } = buildCfg(); + const io = fakeIO({ Thread: "t2" }); + + const result = await pickers.rootThreadPicker(ctx, io); + + expect(result).toBe("t2"); + expect(entityPicker).toHaveBeenCalledTimes(1); + expect(resolveEntityId).toHaveBeenCalledWith(ctx, "E1"); + }); + + it("re-prompts (does not abort) when the chosen entity has no threads", async () => { + const listThreads = vi + .fn<Cfg["listThreads"]>() + .mockResolvedValueOnce(page([])) + .mockResolvedValueOnce(page([thread("t9")])); + const { pickers, entityPicker } = buildCfg({ listThreads }); + const notices: string[] = []; + const io = fakeIO({ Thread: "t9" }, notices); + + const result = await pickers.rootThreadPicker(ctx, io); + + expect(result).toBe("t9"); + expect(entityPicker).toHaveBeenCalledTimes(2); + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("no discussion threads"); + }); + + it("rootThreadPicker throws InteractiveCancelledError on cancel", async () => { + const { pickers } = buildCfg(); + const io = fakeIO({ Thread: CANCEL }); + + await expect(pickers.rootThreadPicker(ctx, io)).rejects.toBeInstanceOf( + InteractiveCancelledError, + ); + }); + + it("commentOrReplyPicker offers the root thread AND its replies", async () => { + listDiscussionReplies.mockResolvedValue(page([thread("r1", "t1")])); + const { pickers } = buildCfg({ + listThreads: async () => page([thread("t1")]), + }); + + // Selecting the reply returns the reply id (root would return "t1"). + const io = fakeIO({ Thread: "t1", Comment: "r1" }); + const result = await pickers.commentOrReplyPicker(ctx, io); + + expect(result).toBe("r1"); + expect(listDiscussionReplies).toHaveBeenCalledWith( + ctx.gql, + asUuid("t1"), + { limit: 50 }, + "issue", + ); + }); + + it("commentOrReplyPicker can return the root thread itself", async () => { + listDiscussionReplies.mockResolvedValue(page([thread("r1", "t1")])); + const { pickers } = buildCfg({ + listThreads: async () => page([thread("t1")]), + }); + const io = fakeIO({ Thread: "t1", Comment: "t1" }); + + expect(await pickers.commentOrReplyPicker(ctx, io)).toBe("t1"); + }); + + it("replyPicker re-prompts when the chosen thread has no replies", async () => { + listDiscussionReplies + .mockResolvedValueOnce(page([])) + .mockResolvedValueOnce(page([thread("r5", "t1")])); + const { pickers, entityPicker } = buildCfg({ + listThreads: async () => page([thread("t1")]), + }); + const notices: string[] = []; + const io = fakeIO({ Thread: "t1", Reply: "r5" }, notices); + + const result = await pickers.replyPicker(ctx, io); + + expect(result).toBe("r5"); + expect(entityPicker).toHaveBeenCalledTimes(2); + expect(notices.some((n) => n.includes("no replies"))).toBe(true); + }); + + it.each([ + "issue", + "project", + "initiative", + ] as const)("threads listing works for entityKind %s and forwards the kind to replies", async (entityKind) => { + listDiscussionReplies.mockResolvedValue(page([thread("r1", "t1")])); + const { pickers } = buildCfg({ + entityKind, + listThreads: async () => page([thread("t1")]), + }); + const io = fakeIO({ Thread: "t1", Reply: "r1" }); + + const result = await pickers.replyPicker(ctx, io); + + expect(result).toBe("r1"); + expect(listDiscussionReplies).toHaveBeenLastCalledWith( + ctx.gql, + asUuid("t1"), + { limit: 50 }, + entityKind, + ); + }); + + it("propagates cancellation thrown by the entity picker", async () => { + const entityPicker = vi.fn<ChoicePicker>(async () => { + throw new InteractiveCancelledError(); + }); + const { pickers } = buildCfg({ entityPicker }); + const io = fakeIO({}); + + await expect(pickers.rootThreadPicker(ctx, io)).rejects.toBeInstanceOf( + InteractiveCancelledError, + ); + }); +}); diff --git a/tests/unit/interactive/engine.test.ts b/tests/unit/interactive/engine.test.ts index 46453a02..4ad51cc9 100644 --- a/tests/unit/interactive/engine.test.ts +++ b/tests/unit/interactive/engine.test.ts @@ -178,6 +178,57 @@ describe("collectInteractive", () => { ); }); + it("gives a required text field a non-blank validator (composed with any base)", async () => { + let received: ((value: string) => string | undefined) | undefined; + const io: PromptIO = { + ...fakeIO({}), + text: async (o) => { + received = o.validate; + return "Acme"; + }, + }; + const spec: PromptSpec<Opts> = { + fields: [ + { + name: "title", + kind: "text", + message: "Title", + required: true, + validate: (v) => (v === "bad" ? "no bad" : undefined), + }, + ], + }; + + await collectInteractive(ctx, spec, {}, io); + + expect(received).toBeDefined(); + // Blank is rejected in place instead of being accepted as "leave unset". + expect(received?.("")).toBe("Title is required"); + expect(received?.(" ")).toBe("Title is required"); + // A non-blank value still runs the caller-supplied validator. + expect(received?.("bad")).toBe("no bad"); + expect(received?.("Acme")).toBeUndefined(); + }); + + it("does not add a required validator to an optional text field", async () => { + let received: ((value: string) => string | undefined) | undefined = () => + "sentinel"; + const io: PromptIO = { + ...fakeIO({}), + text: async (o) => { + received = o.validate; + return ""; + }, + }; + const spec: PromptSpec<Opts> = { + fields: [{ name: "title", kind: "text", message: "Title" }], + }; + + await collectInteractive(ctx, spec, {}, io); + + expect(received).toBeUndefined(); + }); + it("passes the validate function through to the IO", async () => { const validate = vi.fn((v: string) => v.length < 2 ? "too short" : undefined, diff --git a/tests/unit/interactive/milestone-specs.test.ts b/tests/unit/interactive/milestone-specs.test.ts index 5b12882b..3cb0eb26 100644 --- a/tests/unit/interactive/milestone-specs.test.ts +++ b/tests/unit/interactive/milestone-specs.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { milestoneCreateSpec, + milestoneListSpec, milestoneUpdateSpec, } from "../../../src/commands/milestones.js"; @@ -27,6 +28,16 @@ describe("milestoneCreateSpec", () => { }); }); +describe("milestoneListSpec", () => { + it("requires a project (list is project-scoped) via a searchable select", () => { + const project = milestoneListSpec.fields.find((f) => f.name === "project"); + expect(project?.required).toBe(true); + expect(project?.kind).toBe("select"); + expect(project?.searchable).toBe(true); + expect(project?.choices).toBeDefined(); + }); +}); + describe("milestoneUpdateSpec", () => { it("has no required fields (all optional on update)", () => { expect(milestoneUpdateSpec.fields.every((f) => !f.required)).toBe(true); diff --git a/tests/unit/interactive/project-specs.test.ts b/tests/unit/interactive/project-specs.test.ts index 31564030..24373e71 100644 --- a/tests/unit/interactive/project-specs.test.ts +++ b/tests/unit/interactive/project-specs.test.ts @@ -31,6 +31,15 @@ describe("projectCreateSpec", () => { expect(field?.kind).toBe("multiselect"); } }); + + it("covers icon and hex-validated color fields", () => { + const names = projectCreateSpec.fields.map((f) => f.name); + expect(names).toEqual(expect.arrayContaining(["icon", "color"])); + const color = projectCreateSpec.fields.find((f) => f.name === "color"); + expect(color?.validate?.("not-hex")).toBeDefined(); + expect(color?.validate?.("#B45309")).toBeUndefined(); + expect(color?.validate?.("")).toBeUndefined(); + }); }); describe("projectUpdateSpec", () => { From 462cab239aae1401ccad1ed00ff6a08c4b02bbed Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:47:47 +0200 Subject: [PATCH 06/13] fix(interactive): correct collectInteractive generic and test cast The `typecheck:test` CI job failed with six type errors in the new interactive test suites. Five came from `collectInteractive<O>`'s `provided: O` parameter. When a test called it with a `{}` literal and a `PromptSpec<Opts>`, TypeScript unified `O` down to `{}` (the empty-object candidate from the argument won over the spec's `Opts`), so the returned draft had no known properties and `result.team`/`result.title`/etc. were TS2339 errors. `provided` is genuinely a partial set of already-supplied options, so typing it `Partial<O>` both reflects reality and lets `O` be inferred from the spec. The sole production caller passes a full `O`, which is assignable to `Partial<O>`. The sixth was a `noUncheckedIndexedAccess` violation: destructuring `request.mock.calls[0]` (typed `any[] | undefined`) is not iterable. Cast the tuple as the repo's other mock-call tests do, using an explicit `{ filter?: unknown }` shape rather than `Record<string, unknown>` to stay clear of `noPropertyAccessFromIndexSignature`. --- src/common/interactive/engine.ts | 2 +- tests/unit/interactive/choices.test.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/common/interactive/engine.ts b/src/common/interactive/engine.ts index 72c830b0..76215f92 100644 --- a/src/common/interactive/engine.ts +++ b/src/common/interactive/engine.ts @@ -25,7 +25,7 @@ import type { FieldPrompt, PromptIO, PromptSpec } from "./types.js"; export async function collectInteractive<O extends Record<string, unknown>>( ctx: CommandContext, spec: PromptSpec<O>, - provided: O, + provided: Partial<O>, io: PromptIO = clackIO, ): Promise<O> { const draft: Record<string, unknown> = { ...provided }; diff --git a/tests/unit/interactive/choices.test.ts b/tests/unit/interactive/choices.test.ts index dcf13479..b08213ce 100644 --- a/tests/unit/interactive/choices.test.ts +++ b/tests/unit/interactive/choices.test.ts @@ -210,7 +210,10 @@ describe("labelChoices", () => { const result = await labelChoices(mockCtx(request), { team: TEAM_UUID }); - const [, variables] = request.mock.calls[0]; + const [, variables] = request.mock.calls[0] as [ + unknown, + { filter?: unknown }, + ]; expect(variables.filter).toEqual({ team: { id: { eq: TEAM_UUID } } }); expect(result).toEqual([{ value: "l1", label: "bug", hint: "defects" }]); }); @@ -225,7 +228,10 @@ describe("labelChoices", () => { await labelChoices(mockCtx(request), {}); - const [, variables] = request.mock.calls[0]; + const [, variables] = request.mock.calls[0] as [ + unknown, + { filter?: unknown }, + ]; expect(variables.filter).toBeUndefined(); }); }); @@ -279,7 +285,10 @@ describe("cycleChoices (cross-field: cycle needs team)", () => { const result = await cycleChoices(mockCtx(request), { team: TEAM_UUID }); - const [, variables] = request.mock.calls[0]; + const [, variables] = request.mock.calls[0] as [ + unknown, + { filter?: unknown }, + ]; expect(variables.filter).toEqual({ team: { id: { eq: TEAM_UUID } } }); // Past cycle excluded; current (active) first so it is the default. expect(result.map((c) => c.value)).toEqual(["current", "future"]); From 931fc23afb3f60e03182b434edc144a1c505c6e9 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:55:12 +0200 Subject: [PATCH 07/13] docs(interactive): document when and how to add interactive prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The descriptor-driven interactive-prompt engine now spans src/common/ interactive/, wizard specs, and entity pickers across every write and read-by-id domain, guarded by a coverage-sweep test. AGENTS.md said nothing about it, so an agent adding a new command had no guidance on whether interactive support is required, what scope it takes (field wizard vs. entity picker), which shared helpers to reuse, or — most importantly — when NOT to prompt so the JSON/agent contract stays intact. The gap let commands ship without support (failing coverage-sweep) or invite hand-rolled prompts that break the machine contract. Add a concise, decision-oriented "Interactive Prompts" section covering: the input-only contract (stderr-only UI, byte-identical stdout JSON), a when-to-add decision list (create/update spec vs. optional [id] + picker vs. deliberate skip), the ~3-line call site, the shared choices/picker helpers to reuse, and the invariants plus the coverage-sweep enforcement. Cross-reference it from the Decision Tree and File Map. Docs-only. Verified the coverage-sweep claims by running that test, and had the section reviewed by accuracy and clarity subagents (findings folded in: the estimateChoices resolver exception, the "free-text positional" wording, and the Invariants→Rules heading to avoid colliding with the P0 Invariants section). --- AGENTS.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4605c8b7..76c88d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,8 @@ Need a CLI command? → Use createContext() → resolve IDs → call service → outputSuccess() → Register in src/main.ts (setupXCommands + META in allMetas[]) → Add DomainMeta export + usage subcommand + → Add interactive support (see Interactive Prompts): *CreateSpec/*UpdateSpec + for create/update; optional [id] positional + entity picker for id commands Need tests? → Add tests/unit/{resolvers,services,common}/*.test.ts @@ -192,6 +194,72 @@ Registration checklist: 3. Add meta to `allMetas[]` in `src/main.ts`. 4. Run `npm run generate:usage` to update `USAGE.md`. +## Interactive Prompts + +Human users get an optional wizard; agents and pipes get untouched JSON. The +descriptor-driven engine in `src/common/interactive/` **gathers input only** — it +sits above the Resolver→Service→`outputSuccess` pipeline and never changes it. +Choice `value`s are the human strings a user would type (team key, project name) or +a UUID passthrough, so **resolvers still run** and layer separation holds. All prompt +UI is on **stderr**; stdout stays byte-identical JSON. **Never compromise the +machine/agent contract to add interactivity.** + +### When to add support + +- **New `create`/`update` command** → declare an `export const *CreateSpec` / + `*UpdateSpec` (`PromptSpec<O>`) colocated at the top of the command file, next to + the options interface it mirrors. Field order encodes cross-field deps. +- **New command with a leading entity-id positional** (`read`/`update`/`delete`/ + `archive`/`react`) → make the positional **optional** (`[id]`, not `<id>`) and pass + an entity `picker` so the engine can fill it when absent. +- **Otherwise** (`list`/`search`, a free-text positional whose value is user-typed + rather than chosen from an enumerable source, or a required 2nd positional) → + **skip**, and if it keeps a required leading `<arg>` add its verb to + `SKIP_REQUIRED_POSITIONAL` in the coverage test with a one-line reason. + +### Call site (one insertion per action; body below is unchanged) + +```typescript +const filled = await maybeCollectInteractive<CreateWizardOptions, never>( + ctx, getRootOpts(command), { + spec: issueCreateSpec, + options: { ...options, ...(title !== undefined ? { title } : {}) } as CreateWizardOptions, + missingRequired: title === undefined || options.team === undefined, + }); +// For id positionals: use EMPTY_SPEC + `positional: { name, value, picker }`. +``` + +`maybeCollectInteractive` returns inputs **untouched** when `shouldPrompt` +(`gating.ts`) says no — that gate fires only on a real TTY with `-i` explicit or a +required arg missing, and is suppressed by non-TTY, `CI`/`LINEARIS_NO_INTERACTIVE`, +`--no-interactive`, `--compact`, or `--fields`. + +### Reuse, don't reinvent + +- Choices come from `src/common/interactive/choices.ts` — loaders import **list + services** via `ctx.gql`, not resolvers (`teamChoices`, `assigneeChoices`, + `projectChoices`, `milestoneChoices`, `statusChoices`, `priorityChoices`, …). The + one exception is `estimateChoices`, which reads a team's estimate scale via a + resolver under a documented `ARCHITECTURAL EXCEPTION` — follow that pattern only + when no list service exposes the data. +- Wrap with `withNoneChoice`/`optionalChoices` for escapable/optional fields; gate + cross-field fields with `when(draft)` + lazy `choices(ctx, draft)` (team before + cycle/status; project before milestone). +- Pickers: `makeChoicePicker` for a flat select; `makeDiscussionPickers` + (`discussion-pickers.ts`) for thread/reply selection. Entity-picker commands seed + the resolved parent (e.g. the issue's team) into the draft before the wizard. + +### Rules + +- Never `console.log` or prompt on stdout; never bypass `shouldPrompt` or weaken its + gates. Choice loaders read list services, not resolvers (see the `estimateChoices` + exception above). +- Cancellation is handled centrally (`InteractiveCancelledError` → + `{"error":"INTERACTIVE_CANCELLED"}` on stderr, exit 1) — don't catch it in commands. +- `tests/unit/interactive/coverage-sweep.test.ts` fails CI if a create/update ships + without a matching spec or an entity-id positional is left required. Run `npm test` + after adding a command. + ## File Map ``` @@ -202,6 +270,7 @@ src/ services/ # business logic (GraphQL CRUD) commands/ # CLI definitions (Commander.js) common/ # context, output, errors, types, auth, usage + interactive/ # descriptor-driven prompt engine (input only) gql/ # GENERATED — do not edit graphql/ queries/ # .graphql query definitions From 7ff58010d908e6a33191881c364afdb70b4055e8 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:14:04 +0200 Subject: [PATCH 08/13] refactor(interactive): sharpen field-wizard input gathering Several small correctness fixes to the interactive prompt layer, plus a shared helper to remove duplication: - Hoist the multiselect->CSV normaliser into the engine as `normalizeWizardLists`. It previously existed twice (a `labels`-only copy in issues.ts and a keyed copy in projects.ts); both command files now import the single engine version. A `multiselect` yields a `string[]`, but the command bodies expect the comma-separated string a flag would give, so the join/delete has to happen in exactly one place. - Seed the issue-create wizard with resolved team/project UUIDs before running it, mirroring the update path. Without this, `-i --team ENG` filtered the cycle/status/label/milestone loaders on the raw key and silently offered no options. Resolution only runs when the wizard will actually fire (guarded by shouldPrompt). - Skip the emoji picker when `--shortcode` is given (comments and discussion reactions). The shortcode already determines the emoji, so prompting would force a glyph choice that then collides with the shortcode in resolveReactionEmojiInput ("cannot provide both"). - Make document create/update's project (and create's team) optional in the wizard via optionalChoices, so a document need not be tied to one. --- src/commands/comments.ts | 9 ++++- src/commands/discussion-pickers.ts | 9 ++++- src/commands/documents.ts | 11 ++++-- src/commands/issues.ts | 62 ++++++++++++++++++------------ src/commands/projects.ts | 29 ++------------ src/common/interactive/engine.ts | 27 +++++++++++++ 6 files changed, 92 insertions(+), 55 deletions(-) diff --git a/src/commands/comments.ts b/src/commands/comments.ts index d54ca227..73b2ce2b 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -166,13 +166,20 @@ async function resolveEmojiPositional( emoji: string | undefined, shortcode: string | undefined, ): Promise<string | undefined> { + // A --shortcode already fully determines the emoji, so never offer the + // picker in that case: it would force the user to pick a glyph and then + // collide with the shortcode in resolveReactionEmojiInput ("cannot provide + // both"). Only prompt for a genuinely absent emoji. + if (shortcode !== undefined) { + return emoji; + } const filled = await maybeCollectInteractive<Record<string, never>, string>( ctx, getRootOpts(command), { spec: EMPTY_SPEC, options: {}, - missingRequired: emoji === undefined && shortcode === undefined, + missingRequired: emoji === undefined, positional: { name: "emoji", value: emoji, picker: emojiPicker }, }, ); diff --git a/src/commands/discussion-pickers.ts b/src/commands/discussion-pickers.ts index 3a44a7ff..88fcf6f1 100644 --- a/src/commands/discussion-pickers.ts +++ b/src/commands/discussion-pickers.ts @@ -111,13 +111,20 @@ export async function resolveEmojiPositional( emoji: string | undefined, shortcode: string | undefined, ): Promise<string | undefined> { + // A --shortcode already fully determines the emoji, so never offer the + // picker in that case: it would force the user to pick a glyph and then + // collide with the shortcode in resolveReactionEmojiInput ("cannot provide + // both"). Only prompt for a genuinely absent emoji. + if (shortcode !== undefined) { + return emoji; + } const filled = await maybeCollectInteractive<Record<string, never>, string>( ctx, getRootOpts(command), { spec: EMPTY_SPEC, options: {}, - missingRequired: emoji === undefined && shortcode === undefined, + missingRequired: emoji === undefined, positional: { name: "emoji", value: emoji, picker: emojiPicker }, }, ); diff --git a/src/commands/documents.ts b/src/commands/documents.ts index c8e766d9..f5cbd9f1 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -114,9 +114,14 @@ export const documentCreateSpec: PromptSpec<DocumentCreateWizardOptions> = { name: "project", kind: "select", message: "Project", - choices: projectChoices, + choices: optionalChoices(projectChoices, "None (no project)"), + }, + { + name: "team", + kind: "select", + message: "Team", + choices: optionalChoices(teamChoices, "None (no team)"), }, - { name: "team", kind: "select", message: "Team", choices: teamChoices }, { name: "icon", kind: "text", message: "Icon" }, { name: "color", kind: "text", message: "Icon color" }, { @@ -147,7 +152,7 @@ export const documentUpdateSpec: PromptSpec<DocumentUpdateWizardOptions> = { name: "project", kind: "select", message: "Project", - choices: projectChoices, + choices: optionalChoices(projectChoices, "Keep current"), }, { name: "icon", kind: "text", message: "Icon", default: (d) => d.icon }, { diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 72762249..41f0f11f 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -29,7 +29,10 @@ import { statusChoices, teamChoices, } from "../common/interactive/choices.js"; -import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import { + maybeCollectInteractive, + normalizeWizardLists, +} from "../common/interactive/engine.js"; import { shouldPrompt } from "../common/interactive/gating.js"; import { type ChoicePicker, @@ -58,6 +61,8 @@ import { resolveIssueEstimateContext, resolveIssueId, } from "../resolvers/issue-resolver.js"; +import { resolveProjectId } from "../resolvers/project-resolver.js"; +import { resolveTeamId } from "../resolvers/team-resolver.js"; import { getIssueActivity } from "../services/activity-service.js"; import { createDiscussionCommentReaction, @@ -373,24 +378,6 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { ], }; -/** - * The `labels` multiselect yields a `string[]` of UUIDs, but the command body - * expects the CLI-shaped comma-separated `string`. Normalise it in place so the - * command body below the wizard call stays unchanged. - */ -function normalizeWizardLabels<O extends { labels?: unknown }>(filled: O): O { - const normalized = { ...filled }; - if (Array.isArray(normalized.labels)) { - const joined = normalized.labels.join(","); - if (joined.length > 0) { - (normalized as { labels?: string }).labels = joined; - } else { - delete (normalized as { labels?: string }).labels; - } - } - return normalized; -} - /** * Entity picker for an absent `<issue>` positional. Lists recent open issues * and returns the selected issue's identifier (which the resolver accepts). @@ -1587,24 +1574,49 @@ export function setupIssuesCommands(program: Command): void { .action( commandAction<[string, CreateOptions, Command]>( async (title, options, command) => { - const ctx = createContext(getRootOpts(command)); + const rootOpts = getRootOpts(command); + const ctx = createContext(rootOpts); + + const missingRequired = + title === undefined || options.team === undefined; + + // When the field wizard will run, resolve any human-readable + // --team/--project flags to UUIDs up front so the team/project-scoped + // choice loaders (cycle, status, labels, milestone) filter correctly. + // Mirrors the update path, which seeds the resolved team UUID: without + // this, `-i --team ENG` would filter those pickers on the raw key and + // silently offer no options. + let seededOptions = options; + if (shouldPrompt(rootOpts, { missingRequired })) { + const [teamId, projectId] = await Promise.all([ + options.team ? resolveTeamId(ctx.gql, options.team) : undefined, + options.project + ? resolveProjectId(ctx.gql, options.project) + : undefined, + ]); + seededOptions = { + ...options, + ...(teamId !== undefined ? { team: teamId } : {}), + ...(projectId !== undefined ? { project: projectId } : {}), + }; + } const filled = await maybeCollectInteractive< CreateWizardOptions, never - >(ctx, getRootOpts(command), { + >(ctx, rootOpts, { spec: issueCreateSpec, options: { - ...options, + ...seededOptions, ...(title !== undefined ? { title } : {}), } as CreateWizardOptions, - missingRequired: title === undefined || options.team === undefined, + missingRequired, }); title = filled.options.title ?? title; if (title === undefined) { throw invalidParameterError("title", "is required"); } - options = normalizeWizardLabels(filled.options); + options = normalizeWizardLists(filled.options, ["labels"]); const relationActions = parseRelationFlags(options); @@ -1787,7 +1799,7 @@ export function setupIssuesCommands(program: Command): void { options: wizardOptions, missingRequired: issueArg === undefined, }); - options = normalizeWizardLabels(filled.options); + options = normalizeWizardLists(filled.options, ["labels"]); if (options.parentTicket && options.clearParentTicket) { throw new Error( diff --git a/src/commands/projects.ts b/src/commands/projects.ts index cb2c4007..8e183b00 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -16,7 +16,10 @@ import { teamChoices, userChoices, } from "../common/interactive/choices.js"; -import { maybeCollectInteractive } from "../common/interactive/engine.js"; +import { + maybeCollectInteractive, + normalizeWizardLists, +} from "../common/interactive/engine.js"; import type { ChoicePicker } from "../common/interactive/pickers.js"; import type { PromptIO, PromptSpec } from "../common/interactive/types.js"; import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; @@ -372,30 +375,6 @@ export const projectUpdateSpec: PromptSpec<UpdateWizardOptions> = { ], }; -/** - * Multiselect fields yield a `string[]` of UUIDs, but the command body expects - * the CLI-shaped comma-separated `string`. Normalise the named keys in place so - * the command body below the wizard call stays unchanged. - */ -function normalizeWizardLists<O extends Record<string, unknown>>( - filled: O, - keys: readonly string[], -): O { - const normalized = { ...filled }; - for (const key of keys) { - const value = normalized[key]; - if (Array.isArray(value)) { - const joined = value.join(","); - if (joined.length > 0) { - (normalized as Record<string, unknown>)[key] = joined; - } else { - delete (normalized as Record<string, unknown>)[key]; - } - } - } - return normalized; -} - /** * Entity picker for an absent `[project]` positional. Lists recent projects and * returns the selected project's UUID (which the resolver accepts). diff --git a/src/common/interactive/engine.ts b/src/common/interactive/engine.ts index 76215f92..aaa1301b 100644 --- a/src/common/interactive/engine.ts +++ b/src/common/interactive/engine.ts @@ -298,3 +298,30 @@ export async function maybeCollectInteractive< return { options: filledOptions, positional }; } + +/** + * Normalise wizard-filled multiselect fields back to the CLI-shaped + * comma-separated `string` the command bodies expect. A `multiselect` prompt + * yields a `string[]` of values (usually UUIDs), whereas the same option passed + * as a flag (e.g. `--labels a,b`) is a comma-separated string — so for each + * named key this joins a present array, or deletes the key when the array is + * empty so it reads as "unset" downstream. Non-array values are left untouched. + */ +export function normalizeWizardLists<O extends Record<string, unknown>>( + filled: O, + keys: readonly string[], +): O { + const normalized = { ...filled }; + for (const key of keys) { + const value = normalized[key]; + if (Array.isArray(value)) { + const joined = value.join(","); + if (joined.length > 0) { + (normalized as Record<string, unknown>)[key] = joined; + } else { + delete (normalized as Record<string, unknown>)[key]; + } + } + } + return normalized; +} From 0450ef0d1b90aa2db4f38d62afc12cfb1a38b81c Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:14:17 +0200 Subject: [PATCH 09/13] docs: add interactive/agent demo recordings and refresh README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rendered SVG demos of the two ways Linearis is driven — a human stepping through the interactive issue-create wizard, and Claude Code creating an issue via the skill — and rewrite the README opening to lead with that human-vs-agent framing. Supporting tooling and config: - scripts/{rec-demo-interactive,gen-demo-agent,anonymize-demo}-cast.mjs regenerate the casts; only the rendered SVGs are committed. The intermediate .cast/.jsonl captures are regenerable, so .gitignore drops them and knip ignores the one-off scripts (no importers by design). - biome ignores docs/assets/*.svg (generated, not ours to format). - SKILL.md notes the `--no-interactive` flag so an agent gets a JSON error for a missing required argument instead of blocking on stdin. --- .gitignore | 5 + README.md | 172 +++++++---------- biome.json | 8 +- docs/assets/issue-create-agent.svg | 1 + docs/assets/issue-create-interactive.svg | 1 + knip.json | 7 +- scripts/anonymize-demo-cast.mjs | 194 +++++++++++++++++++ scripts/gen-demo-agent-cast.mjs | 129 +++++++++++++ scripts/rec-demo-interactive-cast.mjs | 233 +++++++++++++++++++++++ skills/linearis/SKILL.md | 2 + 10 files changed, 647 insertions(+), 105 deletions(-) create mode 100644 docs/assets/issue-create-agent.svg create mode 100644 docs/assets/issue-create-interactive.svg create mode 100644 scripts/anonymize-demo-cast.mjs create mode 100644 scripts/gen-demo-agent-cast.mjs create mode 100644 scripts/rec-demo-interactive-cast.mjs diff --git a/.gitignore b/.gitignore index 8760a204..433a8c83 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ /src/gql/ USAGE.md +# Demo recordings: intermediate casts and raw session captures are regenerable +# (see scripts/*-demo-*-cast.mjs); only the rendered SVGs in docs/assets/ are committed. +docs/assets/*.cast +docs/assets/*.jsonl + # clean-publish staging dir (release publish; see .releaserc.cjs) /.clean-pkg/ diff --git a/README.md b/README.md index 87f26905..0859f505 100644 --- a/README.md +++ b/README.md @@ -12,117 +12,95 @@ </div> -Linearis is a command-line interface for Linear that speaks **JSON only**. It resolves human-friendly IDs (like `ENG-42` or a team name) to UUIDs for you, and exposes a two-tier `usage` system so an agent can discover exactly the commands it needs without loading the whole API surface into context. +Linearis is a command-line interface for Linear that speaks **JSON only**. It resolves human-friendly IDs (`ENG-42`, a team name) to UUIDs for you, prompts interactively when a human is at the keyboard, and stays out of the way — pure JSON on stdout — when a script or an agent is driving. + +<div align="center"> + +<em>The same task — creating an issue — from the two audiences Linearis serves.</em> + +**A human, interactively** — searchable pickers, multiselect, and a date picker fill the gaps: + +![Interactive issues create wizard](docs/assets/issue-create-interactive.svg) + +**An agent (Claude Code)** — the `linearis` skill drives discover-then-act, then creates the issue: + +![Claude Code creating an issue via the linearis skill](docs/assets/issue-create-agent.svg) + +</div> + +## Quick start ```bash -npm install -g linearis -linearis auth login +npm install -g linearis # requires Node.js >= 22 +linearis auth login # opens Linear, stores an encrypted token linearis issues list --limit 10 ``` -## Why Linearis? +The `linearis` command is canonical; `linear` is a fully supported alias. -The official Linear MCP works well, but it costs ~13k tokens just by being connected — before an agent does anything. Linearis takes a different approach: agents discover capabilities on demand through a two-tier usage system. +## Why Linearis -- `linearis usage` — a compact overview of every domain (~200 tokens). -- `linearis <domain> usage` — the full reference for one domain (~300–500 tokens). +The official Linear MCP works well, but it costs ~13k tokens just by being connected — before an agent does anything. Linearis flips that: agents discover capabilities on demand through a two-tier `usage` system, and a typical interaction costs **~500–700 tokens** instead of ~13k. -A typical agent interaction costs **~500–700 tokens** of context instead of ~13k. The agent pays only for what it uses, one domain at a time. +| | Linearis | Linear MCP | +|---|---|---| +| Context cost | ~500–700 tokens per interaction | ~13k tokens on connect | +| Coverage | Common operations (issues, discussions, cycles, docs, files) | Full Linear API | +| Output | JSON via stdout | Tool-call responses | +| Setup | `npm install -g linearis` + Bash tool | MCP server connection | > [!NOTE] -> The trade-off is coverage. Linearis focuses on the operations that matter for day-to-day work — issues, discussions, cycles, projects, documents, and files. For custom workflows, integrations, or workspace settings, the MCP is the better choice. +> The trade-off is coverage. Linearis focuses on day-to-day work — issues, discussions, cycles, projects, documents, and files. For custom workflows, integrations, or workspace settings, the MCP is the better choice. ## Features - **JSON-only output** — pipe into `jq`, no parsing of tables or prose. - **Smart ID resolution** — pass `ENG-42`, a team name, or a UUID interchangeably. - **Two-tier discovery** — self-documenting `usage` commands keep agent context small. +- **Interactive when it helps** — pickers and field wizards fill missing input in a TTY; hard-gated off for pipes, CI, and agents. - **Discussion threads** — first-class root/reply modeling on issues. - **File attachments** — upload and download with signed URLs. - **Broad domain coverage** — issues, projects, cycles, milestones, initiatives, documents, labels, teams, users, and more. -## Installation - -```bash -npm install -g linearis -``` - -Requires **Node.js ≥ 22**. The `linearis` command is canonical; `linear` is a fully supported alias that runs the same CLI. - -## Authentication - -The interactive flow opens Linear in your browser, walks you through creating an API key, and stores it encrypted in `~/.linearis/token`: - -```bash -linearis auth login -``` - -Or provide a token directly: - -```bash -linearis --api-token <token> issues list # via flag -LINEAR_API_TOKEN=<token> linearis issues list # via environment variable -``` - -Token resolution order: `--api-token` flag → `LINEAR_API_TOKEN` env → `~/.linearis/token` → `~/.linear_api_token` (deprecated). - ## Usage -All output is JSON. Start with discovery, then act. +Every command returns JSON. Agents follow a **discover-then-act** loop; humans can jump straight to commands. ```bash -# Discover what's available (~200 tokens) +# 1. Discover — a compact overview of every domain (~200 tokens) linearis usage -# Drill into one domain for its full command reference +# 2. Drill down — the full reference for one domain (~300–500 tokens) linearis issues usage -# List and search +# 3. Act linearis issues list --limit 10 linearis issues search "authentication bug" - -# Create an issue linearis issues create "Fix login flow" --team Platform --priority 2 - -# Read an issue (includes embeds with signed download URLs) -linearis issues read ENG-42 +linearis issues read ENG-42 # includes embeds with signed download URLs ``` -For the complete reference of every command and flag, run `linearis <domain> usage`. - ### Interactive prompts -In a real terminal, Linearis can prompt for missing input instead of erroring — pickers for entities (issue, project, document, ...) and field wizards for create/update. The final stdout is always the same JSON; prompts are drawn on stderr. +In a real terminal, Linearis prompts for missing input instead of erroring (see the demo above). The final stdout is always the same JSON; prompts are drawn on stderr. ```bash -# Auto-launches a wizard when a required arg is missing (TTY only) -linearis issues create - -# Force interactive; only the gaps are prompted, flags win -linearis issues create "Fix login" -i - -# Opt out entirely (also the default for pipes, CI, and non-TTY) -linearis issues create "Fix login" --team ENG --no-interactive +linearis issues create # auto-launches a wizard (TTY only) +linearis issues create "Fix login" -i # force interactive; flags still win +linearis issues create "Fix login" --team ENG --no-interactive # opt out ``` -Prompts are hard-gated off whenever stdin/stdout is not a TTY, `CI` or `LINEARIS_NO_INTERACTIVE` is set, `--no-interactive` is passed, or `--compact`/`--fields` is used — so agents and pipes never hang and stdout stays pure JSON. +Prompts are hard-gated off whenever stdin/stdout is not a TTY, `CI` or `LINEARIS_NO_INTERACTIVE` is set, or `--no-interactive` / `--compact` / `--fields` is used — so pipes and agents never hang and stdout stays pure JSON. ### Discussions -Discussions are modeled as root threads with replies, rather than a flat comment list: +Discussions are modeled as root threads with replies, not a flat comment list: ```bash -# Start a discussion thread on an issue -linearis issues discuss ENG-42 --body "Investigating this now" - -# List root discussion threads for an issue -linearis issues discussions ENG-42 - -# List replies in one root thread -linearis issues replies <root-thread-id> - -# Reply to a thread (use a root discussion thread ID, not a reply ID) -linearis issues reply <root-thread-id> --body "I found the root cause" +linearis issues discuss ENG-42 --body "Investigating this now" # start a thread +linearis issues discussions ENG-42 # list root threads +linearis issues replies <root-thread-id> # list replies +linearis issues reply <root-thread-id> --body "Found the cause" # reply to a thread ``` ### Domains @@ -142,55 +120,45 @@ linearis issues reply <root-thread-id> --body "I found the root cause" | `users` | Workspace members and assignees | | `auth` | Authenticate with the Linear API | -## AI agent integration - -Linearis is structured around a **discover-then-act** pattern that matches how agents work: - -1. **Discover** — `linearis usage` returns a compact overview of all domains. The agent reads it once. -2. **Drill down** — `linearis <domain> usage` gives the full reference for a single domain. The agent loads only what it needs. -3. **Execute** — every command returns structured JSON. No table or prose parsing. - -The agent never loads the full API surface into context — it pays for what it uses, one domain at a time. +Run `linearis <domain> usage` for the complete command and flag reference. -### Linearis vs. Linear MCP +## Authentication -| | Linearis | Linear MCP | -|---|---|---| -| Context cost | ~500–700 tokens per interaction | ~13k tokens on connect | -| Coverage | Common operations (issues, discussions, cycles, docs, files) | Full Linear API | -| Output | JSON via stdout | Tool-call responses | -| Setup | `npm install -g linearis` + Bash tool | MCP server connection | +`linearis auth login` is the easy path. To supply a token directly: -Use Linearis when token efficiency matters and you work primarily with issues and related data. Use the MCP when you need full API coverage or tight tool-call integration. +```bash +linearis --api-token <token> issues list # via flag +LINEAR_API_TOKEN=<token> linearis issues list # via environment variable +``` -### Agent skill +Resolution order: `--api-token` → `LINEAR_API_TOKEN` → `~/.linearis/token` → `~/.linear_api_token` (deprecated). -Linearis ships an agent skill (following the [agentskills.io](https://agentskills.io) standard) so your agent knows how to use it — no prompt to paste. The skill preflights the install, advisory-checks for updates, then follows the discover-then-act protocol above. +## For AI agents -**Any harness (recommended)** — Vercel's skills CLI installs into the right place for 70+ agents and lists it on [skills.sh](https://skills.sh): +Linearis ships an agent skill (following the [agentskills.io](https://agentskills.io) standard) so your agent knows the discover-then-act protocol with no prompt to paste. It preflights the install and advisory-checks for updates. ```bash -npx skills add linearis-oss/linearis +npx skills add linearis-oss/linearis # any harness — installs for 70+ agents ``` -**Claude Code** — native plugin: +<details> +<summary>Other harnesses</summary> -``` -/plugin marketplace add linearis-oss/linearis -/plugin install linearis@linearis -``` +- **Claude Code** — native plugin: + ``` + /plugin marketplace add linearis-oss/linearis + /plugin install linearis@linearis + ``` +- **OpenAI Codex** — `npx skills add linearis-oss/linearis` installs to `~/.agents/skills/`; invoke with `/skills` or `$`. +- **pi** — `npx skills add linearis-oss/linearis` (or drop `skills/linearis/` into `.pi/skills/`); invoke `/skill:linearis`. +- **Google Antigravity** — `npx skills add linearis-oss/linearis` installs to `.agents/skills/`; auto-discovered. -**OpenAI Codex** — `npx skills add linearis-oss/linearis` installs to `~/.agents/skills/`; invoke with `/skills` or `$`. - -**pi** — `npx skills add linearis-oss/linearis` (or drop `skills/linearis/` into `.pi/skills/`); invoke `/skill:linearis`. - -**Google Antigravity** — `npx skills add linearis-oss/linearis` installs to `.agents/skills/`; auto-discovered from the skill list. +</details> ## Documentation -- [MIGRATION_2026.4.9.md](MIGRATION_2026.4.9.md) — migrating from the deprecated `comments` domain to discussions (v2026.4.9). - [`docs/`](docs/) — architecture, development, testing, and build-system references. -- [`docs/ci-run-model.md`](docs/ci-run-model.md) — the authoritative CI/release trigger matrix. +- [MIGRATION_2026.4.9.md](MIGRATION_2026.4.9.md) — migrating from the deprecated `comments` domain to discussions. - [CONTRIBUTING.md](CONTRIBUTING.md) — contributor guidelines. - [SECURITY.md](SECURITY.md) — how to report security issues. @@ -204,6 +172,4 @@ Made with [contrib.rocks](https://contrib.rocks). ## License -[MIT](LICENSE.md) - -This project is neither affiliated with nor endorsed by Linear. +[MIT](LICENSE.md) — this project is neither affiliated with nor endorsed by Linear. diff --git a/biome.json b/biome.json index 236ec8c7..1d123c35 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,13 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!!**/dist", "!!**/src/gql", "!!**/coverage"] + "includes": [ + "**", + "!!**/dist", + "!!**/src/gql", + "!!**/coverage", + "!!docs/assets/*.svg" + ] }, "formatter": { "indentStyle": "space", diff --git a/docs/assets/issue-create-agent.svg b/docs/assets/issue-create-agent.svg new file mode 100644 index 00000000..18e0b60b --- /dev/null +++ b/docs/assets/issue-create-agent.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="988" height="804.43"><rect width="988" height="804.43" rx="5" ry="5" class="a"/><svg y="0%" x="0%"><circle cx="20" cy="20" r="6" fill="#ff5f58"/><circle cx="40" cy="20" r="6" fill="#ffbd2e"/><circle cx="60" cy="20" r="6" fill="#18c132"/></svg><svg height="716.43" viewBox="0 0 92 71.643" width="920" x="29" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" y="64"><style>@keyframes m{0%{transform:translateX(0)}3.2%{transform:translateX(-92px)}10.3%{transform:translateX(-184px)}12.5%{transform:translateX(-276px)}19.6%{transform:translateX(-368px)}26.7%{transform:translateX(-460px)}28.9%{transform:translateX(-552px)}31.1%{transform:translateX(-644px)}38.2%{transform:translateX(-736px)}40.4%{transform:translateX(-828px)}42.7%{transform:translateX(-920px)}49.8%{transform:translateX(-1012px)}52%{transform:translateX(-1104px)}54.2%{transform:translateX(-1196px)}61.3%{transform:translateX(-1288px)}63.5%{transform:translateX(-1380px)}65.7%{transform:translateX(-1472px)}67.9%{transform:translateX(-1564px)}70.1%{transform:translateX(-1656px)}72.4%{transform:translateX(-1748px)}79.5%{transform:translateX(-1840px)}to{transform:translateX(-1932px)}}.a{fill:#282d35}.g{fill:#b9c0cb;white-space:pre}.h{fill:#87d787}.h,.i,.j{white-space:pre}.i{fill:#b9c0cb;font-weight:700}.j{fill:#8a8a8a}</style><g font-family="Monaco,Consolas,Menlo,'Bitstream Vera Sans Mono','Powerline Symbols',monospace" font-size="1.67"><defs><symbol id="1"><text y="1.67" style="white-space:pre" fill="#87afd7">></text><text x="2.004" y="1.67" class="g">Using</text><text x="8.016" y="1.67" class="g">linearis,</text><text x="18.036" y="1.67" class="g">create</text><text x="25.05" y="1.67" class="g">a</text><text x="27.054" y="1.67" class="g">Linear</text><text x="34.068" y="1.67" class="g">issue</text><text x="40.08" y="1.67" class="g">in</text><text x="43.086" y="1.67" class="g">the</text><text x="47.094" y="1.67" class="g">data</text><text x="52.104" y="1.67" class="g">team</text><text x="57.114" y="1.67" class="g">titled</text></symbol><symbol id="2"><text x="2.004" y="1.67" class="g">"Backfill</text><text x="12.024" y="1.67" class="g">fct_orders</text><text x="23.046" y="1.67" class="g">after</text><text x="29.058" y="1.67" class="g">Airflow</text><text x="37.074" y="1.67" class="g">DAG</text><text x="41.082" y="1.67" class="g">failure</text><text x="49.098" y="1.67" class="g">(2026-07-04)",</text><text x="64.128" y="1.67" class="g">priority</text></symbol><symbol id="3"><text x="2.004" y="1.67" class="g">high,</text><text x="8.016" y="1.67" class="g">with</text><text x="13.026" y="1.67" class="g">label</text><text x="19.038" y="1.67" class="g">technical-debt.</text><text x="35.07" y="1.67" class="g">Report</text><text x="42.084" y="1.67" class="g">the</text><text x="46.092" y="1.67" class="g">created</text><text x="54.108" y="1.67" class="g">issue</text><text x="60.12" y="1.67" class="g">identifier.</text></symbol><symbol id="4"><text y="1.67" class="h">⏺</text><text x="2.004" y="1.67" class="i">linearis</text><text x="11.022" y="1.67" class="j">(Skill)</text></symbol><symbol id="5"><text x="2.004" y="1.67" class="j">⎿</text><text x="5.01" y="1.67" class="j">Launching</text><text x="15.03" y="1.67" class="j">skill:</text><text x="22.044" y="1.67" class="j">linearis</text></symbol><symbol id="6"><text y="1.67" class="h">⏺</text><text x="2.004" y="1.67" class="g">I'll</text><text x="7.014" y="1.67" class="g">discover</text><text x="16.032" y="1.67" class="g">the</text><text x="20.04" y="1.67" class="g">command</text><text x="28.056" y="1.67" class="g">surface,</text><text x="37.074" y="1.67" class="g">then</text><text x="42.084" y="1.67" class="g">create</text><text x="49.098" y="1.67" class="g">the</text><text x="53.106" y="1.67" class="g">issue.</text></symbol><symbol id="7"><text y="1.67" class="h">⏺</text><text x="2.004" y="1.67" class="i">Bash</text><text x="6.012" y="1.67" class="g">(linearis</text><text x="16.032" y="1.67" class="g">usage)</text></symbol><symbol id="8"><text x="2.004" y="1.67" class="j">⎿</text><text x="5.01" y="1.67" class="j">linearis</text><text x="14.028" y="1.67" class="j">v2026.4.8</text><text x="24.048" y="1.67" class="j">—</text><text x="26.052" y="1.67" class="j">CLI</text><text x="30.06" y="1.67" class="j">for</text><text x="34.068" y="1.67" class="j">Linear.app</text><text x="45.09" y="1.67" class="j">(JSON</text><text x="51.102" y="1.67" class="j">output)</text></symbol><symbol id="9"><text x="5.01" y="1.67" class="j">domains:</text><text x="14.028" y="1.67" class="j">auth</text><text x="20.04" y="1.67" class="j">issues</text><text x="28.056" y="1.67" class="j">projects</text><text x="38.076" y="1.67" class="j">cycles</text><text x="46.092" y="1.67" class="j">teams</text><text x="53.106" y="1.67" class="j">…</text><text x="64.128" y="1.67" class="j">(+14</text><text x="69.138" y="1.67" class="j">lines)</text></symbol><symbol id="10"><text y="1.67" class="h">⏺</text><text x="2.004" y="1.67" class="i">Bash</text><text x="6.012" y="1.67" class="g">(linearis</text><text x="16.032" y="1.67" class="g">issues</text><text x="23.046" y="1.67" class="g">usage)</text></symbol><symbol id="11"><text x="2.004" y="1.67" class="j">⎿</text><text x="5.01" y="1.67" class="j">linearis</text><text x="14.028" y="1.67" class="j">issues</text><text x="21.042" y="1.67" class="j">—</text><text x="23.046" y="1.67" class="j">work</text><text x="28.056" y="1.67" class="j">items</text><text x="34.068" y="1.67" class="j">with</text><text x="39.078" y="1.67" class="j">status,</text><text x="47.094" y="1.67" class="j">priority,</text><text x="57.114" y="1.67" class="j">labels</text></symbol><symbol id="12"><text x="5.01" y="1.67" class="j">create</text><text x="12.024" y="1.67" class="j"><title></text><text x="20.04" y="1.67" class="j">--team</text><text x="27.054" y="1.67" class="j"><team></text><text x="34.068" y="1.67" class="j">[--priority</text><text x="46.092" y="1.67" class="j">1-4]</text><text x="51.102" y="1.67" class="j">[--labels</text><text x="61.122" y="1.67" class="j">…]</text><text x="66.132" y="1.67" class="j">(+20</text><text x="71.142" y="1.67" class="j">lines)</text></symbol><symbol id="13"><text y="1.67" class="h">⏺</text><text x="2.004" y="1.67" class="i">Bash</text><text x="6.012" y="1.67" class="g">(linearis</text><text x="16.032" y="1.67" class="g">teams</text><text x="22.044" y="1.67" class="g">list)</text></symbol><symbol id="14"><text x="2.004" y="1.67" class="j">⎿</text><text x="5.01" y="1.67" class="j">{</text><text x="7.014" y="1.67" class="j">"nodes":</text><text x="16.032" y="1.67" class="j">[</text><text x="18.036" y="1.67" class="j">{</text><text x="20.04" y="1.67" class="j">"key":</text><text x="27.054" y="1.67" class="j">"DAT",</text><text x="34.068" y="1.67" class="j">"name":</text><text x="42.084" y="1.67" class="j">"data"</text><text x="49.098" y="1.67" class="j">},</text></symbol><symbol id="15"><text x="18.036" y="1.67" class="j">{</text><text x="20.04" y="1.67" class="j">"key":</text><text x="27.054" y="1.67" class="j">"ENE",</text><text x="34.068" y="1.67" class="j">"name":</text><text x="42.084" y="1.67" class="j">"energy"</text><text x="51.102" y="1.67" class="j">},</text><text x="54.108" y="1.67" class="j">…</text><text x="56.112" y="1.67" class="j">]</text><text x="58.116" y="1.67" class="j">}</text></symbol><symbol id="16"><text y="1.67" class="h">⏺</text><text x="2.004" y="1.67" class="i">Bash</text><text x="6.012" y="1.67" class="g">(linearis</text><text x="16.032" y="1.67" class="g">issues</text><text x="23.046" y="1.67" class="g">create</text><text x="30.06" y="1.67" class="g">"Backfill</text><text x="40.08" y="1.67" class="g">fct_orders</text><text x="51.102" y="1.67" class="g">after</text><text x="57.114" y="1.67" class="g">Airflow</text><text x="65.13" y="1.67" class="g">DAG</text><text x="69.138" y="1.67" class="g">failure</text></symbol><symbol id="17"><text x="9.018" y="1.67" class="g">(2026-07-04)"</text><text x="23.046" y="1.67" class="g">--team</text><text x="30.06" y="1.67" class="g">DAT</text><text x="34.068" y="1.67" class="g">--priority</text><text x="45.09" y="1.67" class="g">2</text><text x="47.094" y="1.67" class="g">--labels</text><text x="56.112" y="1.67" class="g">technical-debt)</text></symbol><symbol id="18"><text x="2.004" y="1.67" class="j">⎿</text><text x="5.01" y="1.67" class="j">{</text></symbol><symbol id="19"><text x="7.014" y="1.67" class="j">"identifier":</text><text x="21.042" y="1.67" class="j">"DAT-1219",</text><text x="33.066" y="1.67" class="j">"priority":</text><text x="45.09" y="1.67" class="j">2,</text><text x="48.096" y="1.67" class="j">"state":</text><text x="57.114" y="1.67" class="j">{</text><text x="59.118" y="1.67" class="j">"name":</text><text x="67.134" y="1.67" class="j">"Triage"</text><text x="76.152" y="1.67" class="j">},</text></symbol><symbol id="20"><text x="7.014" y="1.67" class="j">"team":</text><text x="15.03" y="1.67" class="j">{</text><text x="17.034" y="1.67" class="j">"key":</text><text x="24.048" y="1.67" class="j">"DAT",</text><text x="31.062" y="1.67" class="j">"name":</text><text x="39.078" y="1.67" class="j">"data"</text><text x="46.092" y="1.67" class="j">},</text></symbol><symbol id="21"><text x="7.014" y="1.67" class="j">"labels":</text><text x="17.034" y="1.67" class="j">{</text><text x="19.038" y="1.67" class="j">"nodes":</text><text x="28.056" y="1.67" class="j">[</text><text x="30.06" y="1.67" class="j">{</text><text x="32.064" y="1.67" class="j">"name":</text><text x="40.08" y="1.67" class="j">"technical-debt"</text><text x="57.114" y="1.67" class="j">}</text><text x="59.118" y="1.67" class="j">]</text><text x="61.122" y="1.67" class="j">}</text></symbol><symbol id="22"><text x="5.01" y="1.67" class="j">}</text></symbol><symbol id="23"><text y="1.67" class="h">⏺</text><text x="2.004" y="1.67" class="g">Created</text><text x="10.02" y="1.67" class="i">DAT-1219</text><text x="19.038" y="1.67" class="g">—</text><text x="21.042" y="1.67" class="g">"Backfill</text><text x="31.062" y="1.67" class="g">fct_orders</text><text x="42.084" y="1.67" class="g">after</text><text x="48.096" y="1.67" class="g">Airflow</text><text x="56.112" y="1.67" class="g">DAG</text><text x="60.12" y="1.67" class="g">failure</text></symbol><symbol id="24"><text x="2.004" y="1.67" class="g">(2026-07-04)"</text><text x="16.032" y="1.67" class="g">in</text><text x="19.038" y="1.67" class="g">the</text><text x="23.046" y="1.67" class="g">data</text><text x="28.056" y="1.67" class="g">team,</text><text x="34.068" y="1.67" class="g">priority</text><text x="43.086" y="1.67" class="g">High,</text><text x="49.098" y="1.67" class="g">labeled</text><text x="57.114" y="1.67" class="g">technical-debt.</text></symbol><symbol id="a"><path fill="transparent" d="M0 0h92v34H0z"/></symbol><symbol id="b"><path fill="#6f7683" d="M0 0h1.102v2.171H0z"/></symbol></defs><path class="a" d="M0 0h92v71.643H0z"/><g style="animation-duration:12.66s;animation-iteration-count:infinite;animation-name:m;animation-timing-function:steps(1,end)"><svg width="2024"><svg><use xlink:href="#a"/><use xlink:href="#b" x="-.004"/></svg><svg x="92"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="6.488"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/></svg><svg x="184"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="10.83"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/></svg><svg x="276"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="13.001"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/></svg><svg x="368"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="17.343"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/></svg><svg x="460"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="21.685"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/></svg><svg x="552"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="23.856"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/></svg><svg x="644"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="26.027"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/></svg><svg x="736"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="30.369"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/></svg><svg x="828"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="32.54"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/></svg><svg x="920"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="34.711"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/></svg><svg x="1012"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="39.053"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/></svg><svg x="1104"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="41.224"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/></svg><svg x="1196"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="43.395"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/></svg><svg x="1288"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="49.908"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/><use xlink:href="#16" y="45.591"/><use xlink:href="#17" y="47.762"/></svg><svg x="1380"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="52.079"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/><use xlink:href="#16" y="45.591"/><use xlink:href="#17" y="47.762"/><use xlink:href="#18" y="49.933"/></svg><svg x="1472"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="54.25"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/><use xlink:href="#16" y="45.591"/><use xlink:href="#17" y="47.762"/><use xlink:href="#18" y="49.933"/><use xlink:href="#19" y="52.104"/></svg><svg x="1564"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="56.421"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/><use xlink:href="#16" y="45.591"/><use xlink:href="#17" y="47.762"/><use xlink:href="#18" y="49.933"/><use xlink:href="#19" y="52.104"/><use xlink:href="#20" y="54.275"/></svg><svg x="1656"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="58.592"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/><use xlink:href="#16" y="45.591"/><use xlink:href="#17" y="47.762"/><use xlink:href="#18" y="49.933"/><use xlink:href="#19" y="52.104"/><use xlink:href="#20" y="54.275"/><use xlink:href="#21" y="56.446"/></svg><svg x="1748"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="60.763"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/><use xlink:href="#16" y="45.591"/><use xlink:href="#17" y="47.762"/><use xlink:href="#18" y="49.933"/><use xlink:href="#19" y="52.104"/><use xlink:href="#20" y="54.275"/><use xlink:href="#21" y="56.446"/><use xlink:href="#22" y="58.617"/></svg><svg x="1840"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="67.276"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/><use xlink:href="#16" y="45.591"/><use xlink:href="#17" y="47.762"/><use xlink:href="#18" y="49.933"/><use xlink:href="#19" y="52.104"/><use xlink:href="#20" y="54.275"/><use xlink:href="#21" y="56.446"/><use xlink:href="#22" y="58.617"/><use xlink:href="#23" y="62.959"/><use xlink:href="#24" y="65.13"/></svg><svg x="1932"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="67.276"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="8.684"/><use xlink:href="#5" y="10.855"/><use xlink:href="#6" y="15.197"/><use xlink:href="#7" y="19.539"/><use xlink:href="#8" y="21.71"/><use xlink:href="#9" y="23.881"/><use xlink:href="#10" y="28.223"/><use xlink:href="#11" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="36.907"/><use xlink:href="#14" y="39.078"/><use xlink:href="#15" y="41.249"/><use xlink:href="#16" y="45.591"/><use xlink:href="#17" y="47.762"/><use xlink:href="#18" y="49.933"/><use xlink:href="#19" y="52.104"/><use xlink:href="#20" y="54.275"/><use xlink:href="#21" y="56.446"/><use xlink:href="#22" y="58.617"/><use xlink:href="#23" y="62.959"/><use xlink:href="#24" y="65.13"/></svg></svg></g></g></svg></svg> \ No newline at end of file diff --git a/docs/assets/issue-create-interactive.svg b/docs/assets/issue-create-interactive.svg new file mode 100644 index 00000000..e47c322d --- /dev/null +++ b/docs/assets/issue-create-interactive.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="988" height="739.3"><rect width="988" height="739.3" rx="5" ry="5" class="a"/><svg y="0%" x="0%"><circle cx="20" cy="20" r="6" fill="#ff5f58"/><circle cx="40" cy="20" r="6" fill="#ffbd2e"/><circle cx="60" cy="20" r="6" fill="#18c132"/></svg><svg height="651.3" viewBox="0 0 92 65.13" width="920" x="29" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" y="64"><style>@keyframes n{0%{transform:translateX(0)}2.24%{transform:translateX(-92px)}4.51%{transform:translateX(-184px)}4.8%{transform:translateX(-276px)}5%{transform:translateX(-368px)}5.27%{transform:translateX(-460px)}7.5%{transform:translateX(-552px)}9.57%{transform:translateX(-644px)}9.8%{transform:translateX(-736px)}10.11%{transform:translateX(-828px)}10.37%{transform:translateX(-920px)}10.59%{transform:translateX(-1012px)}10.86%{transform:translateX(-1104px)}11.14%{transform:translateX(-1196px)}11.32%{transform:translateX(-1288px)}11.6%{transform:translateX(-1380px)}11.82%{transform:translateX(-1472px)}12.06%{transform:translateX(-1564px)}12.25%{transform:translateX(-1656px)}12.44%{transform:translateX(-1748px)}12.64%{transform:translateX(-1840px)}12.9%{transform:translateX(-1932px)}13.08%{transform:translateX(-2024px)}13.3%{transform:translateX(-2116px)}13.53%{transform:translateX(-2208px)}13.71%{transform:translateX(-2300px)}13.89%{transform:translateX(-2392px)}14.13%{transform:translateX(-2484px)}14.31%{transform:translateX(-2576px)}14.5%{transform:translateX(-2668px)}14.79%{transform:translateX(-2760px)}15.09%{transform:translateX(-2852px)}15.37%{transform:translateX(-2944px)}15.64%{transform:translateX(-3036px)}15.9%{transform:translateX(-3128px)}16.19%{transform:translateX(-3220px)}16.39%{transform:translateX(-3312px)}16.61%{transform:translateX(-3404px)}16.88%{transform:translateX(-3496px)}17.11%{transform:translateX(-3588px)}17.28%{transform:translateX(-3680px)}17.46%{transform:translateX(-3772px)}17.71%{transform:translateX(-3864px)}18.02%{transform:translateX(-3956px)}18.19%{transform:translateX(-4048px)}18.41%{transform:translateX(-4140px)}18.69%{transform:translateX(-4232px)}19.01%{transform:translateX(-4324px)}19.2%{transform:translateX(-4416px)}19.44%{transform:translateX(-4508px)}19.68%{transform:translateX(-4600px)}19.96%{transform:translateX(-4692px)}20.16%{transform:translateX(-4784px)}20.36%{transform:translateX(-4876px)}20.57%{transform:translateX(-4968px)}20.85%{transform:translateX(-5060px)}21.11%{transform:translateX(-5152px)}21.43%{transform:translateX(-5244px)}21.61%{transform:translateX(-5336px)}21.88%{transform:translateX(-5428px)}22.06%{transform:translateX(-5520px)}22.26%{transform:translateX(-5612px)}22.5%{transform:translateX(-5704px)}22.75%{transform:translateX(-5796px)}23.01%{transform:translateX(-5888px)}24.95%{transform:translateX(-5980px)}27.01%{transform:translateX(-6072px)}27.25%{transform:translateX(-6164px)}27.51%{transform:translateX(-6256px)}27.79%{transform:translateX(-6348px)}28.09%{transform:translateX(-6440px)}28.36%{transform:translateX(-6532px)}28.65%{transform:translateX(-6624px)}28.87%{transform:translateX(-6716px)}29.18%{transform:translateX(-6808px)}29.42%{transform:translateX(-6900px)}29.73%{transform:translateX(-6992px)}29.91%{transform:translateX(-7084px)}30.08%{transform:translateX(-7176px)}30.28%{transform:translateX(-7268px)}30.53%{transform:translateX(-7360px)}30.72%{transform:translateX(-7452px)}30.99%{transform:translateX(-7544px)}31.21%{transform:translateX(-7636px)}31.43%{transform:translateX(-7728px)}31.71%{transform:translateX(-7820px)}31.88%{transform:translateX(-7912px)}32.19%{transform:translateX(-8004px)}32.37%{transform:translateX(-8096px)}32.59%{transform:translateX(-8188px)}32.8%{transform:translateX(-8280px)}33.05%{transform:translateX(-8372px)}33.33%{transform:translateX(-8464px)}33.61%{transform:translateX(-8556px)}33.84%{transform:translateX(-8648px)}34.03%{transform:translateX(-8740px)}34.23%{transform:translateX(-8832px)}34.54%{transform:translateX(-8924px)}34.86%{transform:translateX(-9016px)}35.07%{transform:translateX(-9108px)}35.26%{transform:translateX(-9200px)}35.48%{transform:translateX(-9292px)}35.66%{transform:translateX(-9384px)}35.95%{transform:translateX(-9476px)}36.14%{transform:translateX(-9568px)}36.36%{transform:translateX(-9660px)}36.62%{transform:translateX(-9752px)}36.91%{transform:translateX(-9844px)}37.14%{transform:translateX(-9936px)}37.45%{transform:translateX(-10028px)}37.76%{transform:translateX(-10120px)}37.94%{transform:translateX(-10212px)}38.13%{transform:translateX(-10304px)}38.43%{transform:translateX(-10396px)}38.62%{transform:translateX(-10488px)}38.92%{transform:translateX(-10580px)}39.11%{transform:translateX(-10672px)}39.36%{transform:translateX(-10764px)}39.59%{transform:translateX(-10856px)}39.8%{transform:translateX(-10948px)}40.12%{transform:translateX(-11040px)}40.44%{transform:translateX(-11132px)}40.69%{transform:translateX(-11224px)}40.89%{transform:translateX(-11316px)}41.12%{transform:translateX(-11408px)}41.42%{transform:translateX(-11500px)}41.71%{transform:translateX(-11592px)}41.9%{transform:translateX(-11684px)}42.21%{transform:translateX(-11776px)}42.44%{transform:translateX(-11868px)}42.61%{transform:translateX(-11960px)}42.88%{transform:translateX(-12052px)}43.18%{transform:translateX(-12144px)}43.45%{transform:translateX(-12236px)}43.71%{transform:translateX(-12328px)}43.94%{transform:translateX(-12420px)}44.19%{transform:translateX(-12512px)}44.47%{transform:translateX(-12604px)}44.69%{transform:translateX(-12696px)}44.93%{transform:translateX(-12788px)}45.11%{transform:translateX(-12880px)}45.42%{transform:translateX(-12972px)}45.68%{transform:translateX(-13064px)}45.95%{transform:translateX(-13156px)}46.13%{transform:translateX(-13248px)}46.41%{transform:translateX(-13340px)}46.72%{transform:translateX(-13432px)}46.91%{transform:translateX(-13524px)}47.12%{transform:translateX(-13616px)}47.34%{transform:translateX(-13708px)}47.53%{transform:translateX(-13800px)}47.75%{transform:translateX(-13892px)}48.06%{transform:translateX(-13984px)}48.29%{transform:translateX(-14076px)}48.6%{transform:translateX(-14168px)}48.86%{transform:translateX(-14260px)}49.09%{transform:translateX(-14352px)}49.4%{transform:translateX(-14444px)}49.58%{transform:translateX(-14536px)}51.52%{transform:translateX(-14628px)}52.45%{transform:translateX(-14720px)}53.96%{transform:translateX(-14812px)}54.71%{transform:translateX(-14904px)}55.84%{transform:translateX(-14996px)}57.71%{transform:translateX(-15088px)}57.91%{transform:translateX(-15180px)}58.22%{transform:translateX(-15272px)}58.48%{transform:translateX(-15364px)}58.76%{transform:translateX(-15456px)}61.1%{transform:translateX(-15548px)}63.54%{transform:translateX(-15640px)}64.66%{transform:translateX(-15732px)}65.97%{transform:translateX(-15824px)}67.04%{transform:translateX(-15916px)}69.16%{transform:translateX(-16008px)}70%{transform:translateX(-16100px)}72.34%{transform:translateX(-16192px)}73.12%{transform:translateX(-16284px)}75.53%{transform:translateX(-16376px)}76.32%{transform:translateX(-16468px)}78.52%{transform:translateX(-16560px)}78.82%{transform:translateX(-16652px)}79.08%{transform:translateX(-16744px)}79.32%{transform:translateX(-16836px)}79.63%{transform:translateX(-16928px)}79.93%{transform:translateX(-17020px)}80.21%{transform:translateX(-17112px)}80.51%{transform:translateX(-17204px)}80.79%{transform:translateX(-17296px)}83.04%{transform:translateX(-17388px)}84.72%{transform:translateX(-17480px)}85.4%{transform:translateX(-17572px)}87.15%{transform:translateX(-17664px)}89.77%{transform:translateX(-17756px)}91.08%{transform:translateX(-17848px)}93.34%{transform:translateX(-17940px)}93.59%{transform:translateX(-18032px)}93.9%{transform:translateX(-18124px)}94.18%{transform:translateX(-18216px)}94.45%{transform:translateX(-18308px)}94.76%{transform:translateX(-18400px)}95.08%{transform:translateX(-18492px)}95.26%{transform:translateX(-18584px)}97.78%{transform:translateX(-18676px)}to{transform:translateX(-18768px)}}.a{fill:#282d35}.f,.g,.h,.i{fill:#b9c0cb;white-space:pre}.g,.h,.i{fill:#6f7783}.h,.i{fill:#66c2cd}.i{fill:#a8cc8c}.j{fill:#b9c0cb}.k{fill:#282d35;white-space:pre}</style><g font-family="Monaco,Consolas,Menlo,'Bitstream Vera Sans Mono','Powerline Symbols',monospace" font-size="1.67"><defs><symbol id="1"><text y="1.67" class="f">Create</text><text x="7.014" y="1.67" class="f">a</text><text x="9.018" y="1.67" class="f">new</text><text x="13.026" y="1.67" class="f">issue</text></symbol><symbol id="2"><text y="1.67" class="g">│</text></symbol><symbol id="3"><text y="1.67" class="h">◆</text><text x="3.006" y="1.67" class="f">Team</text></symbol><symbol id="4"><text y="1.67" class="h">│</text></symbol><symbol id="5"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">Search:</text><text x="11.022" y="1.67" class="f">Type</text><text x="16.032" y="1.67" class="f">to</text><text x="19.038" y="1.67" class="f">search…</text></symbol><symbol id="6"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="i">●</text><text x="5.01" y="1.67" class="f">data</text><text x="10.02" y="1.67" class="f">(DAT)</text></symbol><symbol id="7"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">insights</text></symbol><symbol id="8"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">billing</text></symbol><symbol id="9"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">growth</text></symbol><symbol id="10"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">platform</text></symbol><symbol id="11"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">mobile</text></symbol><symbol id="12"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">↑/↓</text><text x="7.014" y="1.67" class="f">to</text><text x="10.02" y="1.67" class="f">select</text><text x="17.034" y="1.67" class="f">•</text><text x="19.038" y="1.67" class="f">Enter:</text><text x="26.052" y="1.67" class="f">confirm</text><text x="34.068" y="1.67" class="f">•</text><text x="36.072" y="1.67" class="f">Type:</text><text x="42.084" y="1.67" class="f">to</text><text x="45.09" y="1.67" class="f">search</text></symbol><symbol id="13"><text y="1.67" class="h">└</text></symbol><symbol id="14"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Team</text></symbol><symbol id="15"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">data</text></symbol><symbol id="16"><text y="1.67" class="h">◆</text><text x="3.006" y="1.67" class="f">Title</text></symbol><symbol id="17"><text y="1.67" class="h">│</text><path class="j" d="M3.006 0h1v2.171h-1z"/><text x="3.006" y="1.67" class="k">_</text></symbol><symbol id="18"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Title</text></symbol><symbol id="19"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">Backfill</text><text x="12.024" y="1.67" class="f">fct_orders</text><text x="23.046" y="1.67" class="f">after</text><text x="29.058" y="1.67" class="f">Airflow</text><text x="37.074" y="1.67" class="f">DAG</text><text x="41.082" y="1.67" class="f">failure</text><text x="49.098" y="1.67" class="f">(2026-07-04)</text></symbol><symbol id="20"><text y="1.67" class="h">◆</text><text x="3.006" y="1.67" class="f">Description</text></symbol><symbol id="21"><text x="2.004" y="1.67" class="f">[</text><text x="4.008" y="1.67" class="f">submit</text><text x="11.022" y="1.67" class="f">]</text></symbol><symbol id="22"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">dbt_daily</text><text x="13.026" y="1.67" class="f">DAG</text><text x="17.034" y="1.67" class="f">failed</text><text x="24.048" y="1.67" class="f">03:12</text><text x="30.06" y="1.67" class="f">UTC;</text><text x="35.07" y="1.67" class="f">fct_orders</text><text x="46.092" y="1.67" class="f">missing</text><text x="54.108" y="1.67" class="f">2</text><text x="56.112" y="1.67" class="f">days.</text><text x="62.124" y="1.67" class="f">Re-run</text><text x="69.138" y="1.67" class="f">models</text><text x="76.152" y="1.67" class="f">+</text></symbol><symbol id="23"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">verify</text><text x="10.02" y="1.67" class="f">row</text><text x="14.028" y="1.67" class="f">counts.</text></symbol><symbol id="24"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">█</text></symbol><symbol id="25"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Description</text></symbol><symbol id="26"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">dbt_daily</text><text x="13.026" y="1.67" class="f">DAG</text><text x="17.034" y="1.67" class="f">failed</text><text x="24.048" y="1.67" class="f">03:12</text><text x="30.06" y="1.67" class="f">UTC;</text><text x="35.07" y="1.67" class="f">fct_orders</text><text x="46.092" y="1.67" class="f">missing</text><text x="54.108" y="1.67" class="f">2</text><text x="56.112" y="1.67" class="f">days.</text><text x="62.124" y="1.67" class="f">Re-run</text><text x="69.138" y="1.67" class="f">models</text><text x="76.152" y="1.67" class="f">+</text></symbol><symbol id="27"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">verify</text><text x="10.02" y="1.67" class="f">row</text><text x="14.028" y="1.67" class="f">counts.</text></symbol><symbol id="28"><text y="1.67" class="h">◆</text><text x="3.006" y="1.67" class="f">Assignee</text></symbol><symbol id="29"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">Fabian</text><text x="12.024" y="1.67" class="f">Jocks</text></symbol><symbol id="30"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">Jamie</text><text x="11.022" y="1.67" class="f">Cole</text></symbol><symbol id="31"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">Drew</text><text x="10.02" y="1.67" class="f">Ellis</text></symbol><symbol id="32"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">Robin</text><text x="11.022" y="1.67" class="f">Shaw</text></symbol><symbol id="33"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">Avery</text><text x="11.022" y="1.67" class="f">Hart</text></symbol><symbol id="34"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="i">●</text><text x="5.01" y="1.67" class="f">Fabian</text><text x="12.024" y="1.67" class="f">Jocks</text><text x="18.036" y="1.67" class="f">(jocks@example.com)</text></symbol><symbol id="35"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Assignee</text></symbol><symbol id="36"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">Fabian</text><text x="10.02" y="1.67" class="f">Jocks</text></symbol><symbol id="37"><text y="1.67" class="h">◆</text><text x="3.006" y="1.67" class="f">Priority</text></symbol><symbol id="38"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">Urgent</text></symbol><symbol id="39"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">High</text></symbol><symbol id="40"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">Medium</text></symbol><symbol id="41"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">Low</text></symbol><symbol id="42"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">↑/↓</text><text x="7.014" y="1.67" class="f">to</text><text x="10.02" y="1.67" class="f">navigate</text><text x="19.038" y="1.67" class="f">•</text><text x="21.042" y="1.67" class="f">Enter:</text><text x="28.056" y="1.67" class="f">confirm</text></symbol><symbol id="43"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">○</text><text x="5.01" y="1.67" class="f">None</text></symbol><symbol id="44"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">High</text></symbol><symbol id="45"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Project</text></symbol><symbol id="46"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">None</text><text x="8.016" y="1.67" class="f">(no</text><text x="12.024" y="1.67" class="f">project)</text></symbol><symbol id="47"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Cycle</text></symbol><symbol id="48"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">None</text><text x="8.016" y="1.67" class="f">(no</text><text x="12.024" y="1.67" class="f">cycle)</text></symbol><symbol id="49"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Status</text></symbol><symbol id="50"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">None</text><text x="8.016" y="1.67" class="f">(team</text><text x="14.028" y="1.67" class="f">default)</text></symbol><symbol id="51"><text y="1.67" class="h">◆</text><text x="3.006" y="1.67" class="f">Labels</text></symbol><symbol id="52"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">◻</text><text x="5.01" y="1.67" class="f">technical-debt</text></symbol><symbol id="53"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">◻</text><text x="5.01" y="1.67" class="f">refinement</text></symbol><symbol id="54"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">◻</text><text x="5.01" y="1.67" class="f">Incomplete</text></symbol><symbol id="55"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">◻</text><text x="5.01" y="1.67" class="f">retro-done</text></symbol><symbol id="56"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">◻</text><text x="5.01" y="1.67" class="f">retro</text></symbol><symbol id="57"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">◻</text><text x="5.01" y="1.67" class="f">under-estimated</text></symbol><symbol id="58"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">◻</text><text x="5.01" y="1.67" class="f">over-estimated</text></symbol><symbol id="59"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">↑/↓</text><text x="7.014" y="1.67" class="f">to</text><text x="10.02" y="1.67" class="f">navigate</text><text x="19.038" y="1.67" class="f">•</text><text x="21.042" y="1.67" class="f">Tab:</text><text x="26.052" y="1.67" class="f">select</text><text x="33.066" y="1.67" class="f">•</text><text x="35.07" y="1.67" class="f">Enter:</text><text x="42.084" y="1.67" class="f">confirm</text><text x="50.1" y="1.67" class="f">•</text><text x="52.104" y="1.67" class="f">Type:</text><text x="58.116" y="1.67" class="f">to</text><text x="61.122" y="1.67" class="f">search</text></symbol><symbol id="60"><text y="1.67" class="h">│</text><text x="3.006" y="1.67" class="f">Search:</text><text x="11.022" y="1.67" class="f">technical█</text><text x="22.044" y="1.67" class="f">(1</text><text x="25.05" y="1.67" class="f">match)</text></symbol><symbol id="61"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Labels</text></symbol><symbol id="62"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">1</text><text x="5.01" y="1.67" class="f">items</text><text x="11.022" y="1.67" class="f">selected</text></symbol><symbol id="63"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Estimate</text></symbol><symbol id="64"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">None</text><text x="8.016" y="1.67" class="f">(no</text><text x="12.024" y="1.67" class="f">estimate)</text></symbol><symbol id="65"><text y="1.67" class="h">◆</text><text x="3.006" y="1.67" class="f">Set</text><text x="7.014" y="1.67" class="f">a</text><text x="9.018" y="1.67" class="f">due</text><text x="13.026" y="1.67" class="f">date?</text></symbol><symbol id="66"><text y="1.67" class="i">◇</text><text x="3.006" y="1.67" class="f">Set</text><text x="7.014" y="1.67" class="f">a</text><text x="9.018" y="1.67" class="f">due</text><text x="13.026" y="1.67" class="f">date?</text></symbol><symbol id="67"><text y="1.67" class="g">│</text><text x="3.006" y="1.67" class="f">Yes</text></symbol><symbol id="68"><text y="1.67" class="h">◆</text><text x="3.006" y="1.67" class="f">Due</text><text x="7.014" y="1.67" class="f">date</text></symbol><symbol id="69"><text x="2.004" y="1.67" class="f">},</text></symbol><symbol id="70"><text x="4.008" y="1.67" class="f">"nodes":</text><text x="13.026" y="1.67" class="f">[]</text></symbol><symbol id="a"><path fill="transparent" d="M0 0h92v31H0z"/></symbol><symbol id="b"><path fill="#6f7683" d="M0 0h1.102v2.171H0z"/></symbol></defs><path class="a" d="M0 0h92v65.13H0z"/><g style="animation-duration:26.85s;animation-iteration-count:infinite;animation-name:n;animation-timing-function:steps(1,end)"><svg width="18860"><svg><use xlink:href="#a"/><use xlink:href="#b" x="-.004"/></svg><svg x="92"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="6.513"/><use xlink:href="#5" y="8.684"/><use xlink:href="#6" y="10.855"/><use xlink:href="#7" y="13.026"/><use xlink:href="#8" y="15.197"/><use xlink:href="#9" y="17.368"/><use xlink:href="#10" y="19.539"/><use xlink:href="#11" y="21.71"/><use xlink:href="#12" y="23.881"/><use xlink:href="#13" y="26.052"/></svg><svg x="184"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="6.513"/><text y="10.354" class="h">│</text><text x="3.006" y="10.354" class="f">Search:</text><text x="11.022" y="10.354" class="f">d█</text><use xlink:href="#6" y="10.855"/><use xlink:href="#7" y="13.026"/><use xlink:href="#8" y="15.197"/><use xlink:href="#9" y="17.368"/><use xlink:href="#10" y="19.539"/><use xlink:href="#11" y="21.71"/><use xlink:href="#12" y="23.881"/><use xlink:href="#13" y="26.052"/></svg><svg x="276"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="6.513"/><text y="10.354" class="h">│</text><text x="3.006" y="10.354" class="f">Search:</text><text x="11.022" y="10.354" class="f">da█</text><text x="15.03" y="10.354" class="f">(1</text><text x="18.036" y="10.354" class="f">match)</text><use xlink:href="#6" y="10.855"/><use xlink:href="#12" y="13.026"/><use xlink:href="#13" y="15.197"/></svg><svg x="368"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="6.513"/><text y="10.354" class="h">│</text><text x="3.006" y="10.354" class="f">Search:</text><text x="11.022" y="10.354" class="f">dat█</text><text x="16.032" y="10.354" class="f">(1</text><text x="19.038" y="10.354" class="f">match)</text><use xlink:href="#6" y="10.855"/><use xlink:href="#12" y="13.026"/><use xlink:href="#13" y="15.197"/></svg><svg x="460"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#3" y="4.342"/><use xlink:href="#4" y="6.513"/><text y="10.354" class="h">│</text><text x="3.006" y="10.354" class="f">Search:</text><text x="11.022" y="10.354" class="f">data█</text><text x="17.034" y="10.354" class="f">(1</text><text x="20.04" y="10.354" class="f">match)</text><use xlink:href="#6" y="10.855"/><use xlink:href="#12" y="13.026"/><use xlink:href="#13" y="15.197"/></svg><svg x="552"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><use xlink:href="#17" y="13.026"/><use xlink:href="#13" y="15.197"/></svg><svg x="644"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">B█</text><use xlink:href="#13" y="15.197"/></svg><svg x="736"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Ba█</text><use xlink:href="#13" y="15.197"/></svg><svg x="828"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Bac█</text><use xlink:href="#13" y="15.197"/></svg><svg x="920"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Back█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1012"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backf█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1104"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfi█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1196"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfil█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1288"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1380"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1472"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">f█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1564"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fc█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1656"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1748"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1840"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_o█</text><use xlink:href="#13" y="15.197"/></svg><svg x="1932"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_or█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2024"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_ord█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2116"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orde█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2208"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_order█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2300"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2392"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2484"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">a█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2576"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">af█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2668"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">aft█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2760"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">afte█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2852"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after█</text><use xlink:href="#13" y="15.197"/></svg><svg x="2944"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3036"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">A█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3128"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Ai█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3220"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Air█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3312"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airf█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3404"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airfl█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3496"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflo█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3588"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3680"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3772"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">D█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3864"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DA█</text><use xlink:href="#13" y="15.197"/></svg><svg x="3956"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4048"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4140"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">f█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4232"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">fa█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4324"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">fai█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4416"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">fail█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4508"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failu█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4600"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failur█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4692"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4784"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4876"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(█</text><use xlink:href="#13" y="15.197"/></svg><svg x="4968"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5060"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(20█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5152"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(202█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5244"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2026█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5336"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2026-█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5428"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2026-0█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5520"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2026-07█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5612"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2026-07-█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5704"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2026-07-0█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5796"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2026-07-04█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5888"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#16" y="10.855"/><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">Backfill</text><text x="12.024" y="14.696" class="f">fct_orders</text><text x="23.046" y="14.696" class="f">after</text><text x="29.058" y="14.696" class="f">Airflow</text><text x="37.074" y="14.696" class="f">DAG</text><text x="41.082" y="14.696" class="f">failure</text><text x="49.098" y="14.696" class="f">(2026-07-04)█</text><use xlink:href="#13" y="15.197"/></svg><svg x="5980"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#17" y="19.539"/><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6072"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">d█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6164"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">db█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6256"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6348"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6440"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_d█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6532"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_da█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6624"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_dai█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6716"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_dail█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6808"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6900"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="6992"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">D█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7084"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DA█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7176"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7268"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7360"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">f█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7452"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">fa█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7544"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">fai█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7636"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">fail█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7728"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">faile█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7820"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="7912"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8004"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">0█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8096"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8188"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8280"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:1█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8372"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8464"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8556"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">U█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8648"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UT█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8740"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8832"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="8924"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9016"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">f█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9108"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fc█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9200"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9292"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9384"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_o█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9476"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_or█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9568"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_ord█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9660"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orde█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9752"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_order█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9844"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="9936"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10028"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">m█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10120"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">mi█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10212"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">mis█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10304"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">miss█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10396"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missi█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10488"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missin█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10580"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10672"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10764"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10856"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="10948"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">d█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11040"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">da█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11132"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">day█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11224"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11316"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11408"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11500"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">R█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11592"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11684"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11776"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-r█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11868"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-ru█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="11960"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12052"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12144"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">m█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12236"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">mo█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12328"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">mod█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12420"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">mode█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12512"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">model█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12604"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">models█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12696"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">models</text><text x="76.152" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12788"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">models</text><text x="76.152" y="21.209" class="f">+█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12880"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">models</text><text x="76.152" y="21.209" class="f">+</text><text x="78.156" y="21.209" class="f">█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="12972"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">models</text><text x="76.152" y="21.209" class="f">+</text><text x="78.156" y="21.209" class="f">v█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="13064"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">models</text><text x="76.152" y="21.209" class="f">+</text><text x="78.156" y="21.209" class="f">ve█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="13156"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">dbt_daily</text><text x="13.026" y="21.209" class="f">DAG</text><text x="17.034" y="21.209" class="f">failed</text><text x="24.048" y="21.209" class="f">03:12</text><text x="30.06" y="21.209" class="f">UTC;</text><text x="35.07" y="21.209" class="f">fct_orders</text><text x="46.092" y="21.209" class="f">missing</text><text x="54.108" y="21.209" class="f">2</text><text x="56.112" y="21.209" class="f">days.</text><text x="62.124" y="21.209" class="f">Re-run</text><text x="69.138" y="21.209" class="f">models</text><text x="76.152" y="21.209" class="f">+</text><text x="78.156" y="21.209" class="f">ver█</text><use xlink:href="#13" y="21.71"/><use xlink:href="#21" y="23.881"/></svg><svg x="13248"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">veri█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="13340"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verif█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="13432"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="13524"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="13616"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">r█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="13708"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">ro█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="13800"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="13892"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row</text><text x="14.028" y="23.38" class="f">█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="13984"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row</text><text x="14.028" y="23.38" class="f">c█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="14076"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row</text><text x="14.028" y="23.38" class="f">co█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="14168"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row</text><text x="14.028" y="23.38" class="f">cou█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="14260"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row</text><text x="14.028" y="23.38" class="f">coun█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="14352"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row</text><text x="14.028" y="23.38" class="f">count█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="14444"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row</text><text x="14.028" y="23.38" class="f">counts█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="14536"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">verify</text><text x="10.02" y="23.38" class="f">row</text><text x="14.028" y="23.38" class="f">counts.█</text><use xlink:href="#13" y="23.881"/><use xlink:href="#21" y="26.052"/></svg><svg x="14628"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><use xlink:href="#23" y="21.71"/><use xlink:href="#24" y="23.881"/><use xlink:href="#13" y="26.052"/><use xlink:href="#21" y="28.223"/></svg><svg x="14720"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><use xlink:href="#23" y="21.71"/><use xlink:href="#4" y="23.881"/><use xlink:href="#24" y="26.052"/><use xlink:href="#13" y="28.223"/><use xlink:href="#21" y="30.394"/></svg><svg x="14812"><use xlink:href="#a"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#20" y="17.368"/><use xlink:href="#22" y="19.539"/><use xlink:href="#23" y="21.71"/><use xlink:href="#4" y="23.881"/><use xlink:href="#24" y="26.052"/><use xlink:href="#13" y="28.223"/><text x="2.004" y="32.064" class="h">[</text><text x="4.008" y="32.064" class="h">submit</text><text x="11.022" y="32.064" class="h">]</text></svg><svg x="14904"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="28.198"/><use xlink:href="#1"/><use xlink:href="#2" y="2.171"/><use xlink:href="#14" y="4.342"/><use xlink:href="#15" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#18" y="10.855"/><use xlink:href="#19" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#25" y="17.368"/><use xlink:href="#26" y="19.539"/><use xlink:href="#27" y="21.71"/><use xlink:href="#2" y="23.881"/><use xlink:href="#2" y="26.052"/></svg><svg x="14996"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#28" y="13.026"/><use xlink:href="#4" y="15.197"/><use xlink:href="#5" y="17.368"/><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="i">●</text><text x="5.01" y="21.209" class="f">None</text><text x="10.02" y="21.209" class="f">(unassigned)</text><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">○</text><text x="5.01" y="23.38" class="f">Alex</text><text x="10.02" y="23.38" class="f">Carter</text><text y="25.551" class="h">│</text><text x="3.006" y="25.551" class="f">○</text><text x="5.01" y="25.551" class="f">Jordan</text><text x="12.024" y="25.551" class="f">Lee</text><text y="27.722" class="h">│</text><text x="3.006" y="27.722" class="f">○</text><text x="5.01" y="27.722" class="f">Sam</text><text x="9.018" y="27.722" class="f">Rivera</text><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">○</text><text x="5.01" y="29.893" class="f">Morgan</text><text x="12.024" y="29.893" class="f">Reed</text><text y="32.064" class="h">│</text><text x="3.006" y="32.064" class="f">○</text><text x="5.01" y="32.064" class="f">Riley</text><text x="11.022" y="32.064" class="f">Quinn</text><text y="34.235" class="h">│</text><text x="3.006" y="34.235" class="f">○</text><text x="5.01" y="34.235" class="f">Casey</text><text x="11.022" y="34.235" class="f">Brooks</text><text y="36.406" class="h">│</text><text x="3.006" y="36.406" class="f">○</text><text x="5.01" y="36.406" class="f">Codex</text><text y="38.577" class="h">│</text><text x="3.006" y="38.577" class="f">○</text><text x="5.01" y="38.577" class="f">Cursor</text><use xlink:href="#29" y="39.078"/><text y="42.919" class="h">│</text><text x="3.006" y="42.919" class="f">○</text><text x="5.01" y="42.919" class="f">Taylor</text><text x="12.024" y="42.919" class="f">Fox</text><text y="45.09" class="h">│</text><text x="3.006" y="45.09" class="f">○</text><text x="5.01" y="45.09" class="f">GitHub</text><text x="12.024" y="45.09" class="f">Copilot</text><use xlink:href="#30" y="45.591"/><use xlink:href="#31" y="47.762"/><use xlink:href="#32" y="49.933"/><use xlink:href="#33" y="52.104"/><text y="55.945" class="h">│</text><text x="3.006" y="55.945" class="f">○</text><text x="5.01" y="55.945" class="f">Linear</text><text y="58.116" class="h">│</text><text x="3.006" y="58.116" class="f">○</text><text x="5.01" y="58.116" class="f">Quinn</text><text x="11.022" y="58.116" class="f">Diaz</text><text y="60.287" class="h">│</text><text x="3.006" y="60.287" class="f">○</text><text x="5.01" y="60.287" class="f">Reese</text><text x="11.022" y="60.287" class="f">Park</text><text y="62.458" class="h">│</text><text x="3.006" y="62.458" class="f">○</text><text x="5.01" y="62.458" class="f">acme</text><use xlink:href="#12" y="62.959"/><use xlink:href="#13" y="65.13"/></svg><svg x="15088"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#28" y="13.026"/><use xlink:href="#4" y="15.197"/><text y="19.038" class="h">│</text><text x="3.006" y="19.038" class="f">Search:</text><text x="11.022" y="19.038" class="f">j█</text><text x="14.028" y="19.038" class="f">(6</text><text x="17.034" y="19.038" class="f">matches)</text><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="i">●</text><text x="5.01" y="21.209" class="f">Morgan</text><text x="12.024" y="21.209" class="f">Reed</text><text x="17.034" y="21.209" class="f">(reed@example.com)</text><use xlink:href="#29" y="21.71"/><use xlink:href="#30" y="23.881"/><use xlink:href="#31" y="26.052"/><use xlink:href="#32" y="28.223"/><use xlink:href="#33" y="30.394"/><use xlink:href="#12" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="15180"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#28" y="13.026"/><use xlink:href="#4" y="15.197"/><text y="19.038" class="h">│</text><text x="3.006" y="19.038" class="f">Search:</text><text x="11.022" y="19.038" class="f">jo█</text><text x="15.03" y="19.038" class="f">(3</text><text x="18.036" y="19.038" class="f">matches)</text><use xlink:href="#34" y="19.539"/><use xlink:href="#32" y="21.71"/><use xlink:href="#33" y="23.881"/><use xlink:href="#12" y="26.052"/><use xlink:href="#13" y="28.223"/></svg><svg x="15272"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#28" y="13.026"/><use xlink:href="#4" y="15.197"/><text y="19.038" class="h">│</text><text x="3.006" y="19.038" class="f">Search:</text><text x="11.022" y="19.038" class="f">joc█</text><text x="16.032" y="19.038" class="f">(1</text><text x="19.038" y="19.038" class="f">match)</text><use xlink:href="#34" y="19.539"/><use xlink:href="#12" y="21.71"/><use xlink:href="#13" y="23.881"/></svg><svg x="15364"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#28" y="13.026"/><use xlink:href="#4" y="15.197"/><text y="19.038" class="h">│</text><text x="3.006" y="19.038" class="f">Search:</text><text x="11.022" y="19.038" class="f">jock█</text><text x="17.034" y="19.038" class="f">(1</text><text x="20.04" y="19.038" class="f">match)</text><use xlink:href="#34" y="19.539"/><use xlink:href="#12" y="21.71"/><use xlink:href="#13" y="23.881"/></svg><svg x="15456"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#28" y="13.026"/><use xlink:href="#4" y="15.197"/><text y="19.038" class="h">│</text><text x="3.006" y="19.038" class="f">Search:</text><text x="11.022" y="19.038" class="f">jocks█</text><text x="18.036" y="19.038" class="f">(1</text><text x="21.042" y="19.038" class="f">match)</text><use xlink:href="#34" y="19.539"/><use xlink:href="#12" y="21.71"/><use xlink:href="#13" y="23.881"/></svg><svg x="15548"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#35" y="13.026"/><use xlink:href="#36" y="15.197"/><use xlink:href="#2" y="17.368"/><use xlink:href="#37" y="19.539"/><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="i">●</text><text x="5.01" y="23.38" class="f">None</text><use xlink:href="#38" y="23.881"/><use xlink:href="#39" y="26.052"/><use xlink:href="#40" y="28.223"/><use xlink:href="#41" y="30.394"/><use xlink:href="#42" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="15640"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#35" y="13.026"/><use xlink:href="#36" y="15.197"/><use xlink:href="#2" y="17.368"/><use xlink:href="#37" y="19.539"/><use xlink:href="#43" y="21.71"/><text y="25.551" class="h">│</text><text x="3.006" y="25.551" class="i">●</text><text x="5.01" y="25.551" class="f">Urgent</text><text x="12.024" y="25.551" class="f">(1)</text><use xlink:href="#39" y="26.052"/><use xlink:href="#40" y="28.223"/><use xlink:href="#41" y="30.394"/><use xlink:href="#42" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="15732"><use xlink:href="#a"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#35" y="13.026"/><use xlink:href="#36" y="15.197"/><use xlink:href="#2" y="17.368"/><use xlink:href="#37" y="19.539"/><use xlink:href="#43" y="21.71"/><use xlink:href="#38" y="23.881"/><text y="27.722" class="h">│</text><text x="3.006" y="27.722" class="i">●</text><text x="5.01" y="27.722" class="f">High</text><text x="10.02" y="27.722" class="f">(2)</text><use xlink:href="#40" y="28.223"/><use xlink:href="#41" y="30.394"/><use xlink:href="#42" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="15824"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="23.856"/><use xlink:href="#25"/><use xlink:href="#26" y="2.171"/><use xlink:href="#27" y="4.342"/><use xlink:href="#2" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#2" y="10.855"/><use xlink:href="#35" y="13.026"/><use xlink:href="#36" y="15.197"/><use xlink:href="#2" y="17.368"/><text y="21.209" class="i">◇</text><text x="3.006" y="21.209" class="f">Priority</text><use xlink:href="#44" y="21.71"/></svg><svg x="15916"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><text y="6.012" class="h">◆</text><text x="3.006" y="6.012" class="f">Project</text><use xlink:href="#4" y="6.513"/><use xlink:href="#5" y="8.684"/><text y="12.525" class="h">│</text><text x="3.006" y="12.525" class="i">●</text><text x="5.01" y="12.525" class="f">None</text><text x="10.02" y="12.525" class="f">(no</text><text x="14.028" y="12.525" class="f">project)</text><text y="14.696" class="h">│</text><text x="3.006" y="14.696" class="f">○</text><text x="5.01" y="14.696" class="f">BORIS</text><text x="11.022" y="14.696" class="f">NRW</text><text x="15.03" y="14.696" class="f">Integration</text><text y="16.867" class="h">│</text><text x="3.006" y="16.867" class="f">○</text><text x="5.01" y="16.867" class="f">Building</text><text x="14.028" y="16.867" class="f">Part</text><text x="19.038" y="16.867" class="f">Geometries</text><text x="30.06" y="16.867" class="f">in</text><text x="33.066" y="16.867" class="f">Connect-API</text><text y="19.038" class="h">│</text><text x="3.006" y="19.038" class="f">○</text><text x="5.01" y="19.038" class="f">Resilient</text><text x="15.03" y="19.038" class="f">Field</text><text x="21.042" y="19.038" class="f">Transformation</text><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">○</text><text x="5.01" y="21.209" class="f">Connect-API</text><text x="17.034" y="21.209" class="f">Performance</text><text x="29.058" y="21.209" class="f">&</text><text x="31.062" y="21.209" class="f">Reliability</text><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">○</text><text x="5.01" y="23.38" class="f">Connect-API</text><text x="17.034" y="23.38" class="f">Security</text><text x="26.052" y="23.38" class="f">&</text><text x="28.056" y="23.38" class="f">Architecture</text><text y="25.551" class="h">│</text><text x="3.006" y="25.551" class="f">○</text><text x="5.01" y="25.551" class="f">Precompute</text><text x="16.032" y="25.551" class="f">`building_group_id`</text><text y="27.722" class="h">│</text><text x="3.006" y="27.722" class="f">○</text><text x="5.01" y="27.722" class="f">Self-Managed</text><text x="18.036" y="27.722" class="f">API</text><text x="22.044" y="27.722" class="f">Keys</text><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">○</text><text x="5.01" y="29.893" class="f">Multi-Source</text><text x="18.036" y="29.893" class="f">Index</text><text x="24.048" y="29.893" class="f">Reconciliation</text><text y="32.064" class="h">│</text><text x="3.006" y="32.064" class="f">○</text><text x="5.01" y="32.064" class="f">CityJSON</text><text x="14.028" y="32.064" class="f">3D</text><text x="17.034" y="32.064" class="f">Geometry</text><text x="26.052" y="32.064" class="f">Delivery</text><text y="34.235" class="h">│</text><text x="3.006" y="34.235" class="f">○</text><text x="5.01" y="34.235" class="f">Address</text><text x="13.026" y="34.235" class="f">Search</text><text x="20.04" y="34.235" class="f">API</text><text y="36.406" class="h">│</text><text x="3.006" y="36.406" class="f">○</text><text x="5.01" y="36.406" class="f">Enhanced</text><text x="14.028" y="36.406" class="f">Address</text><text x="22.044" y="36.406" class="f">Matching</text><text y="38.577" class="h">│</text><text x="3.006" y="38.577" class="f">○</text><text x="5.01" y="38.577" class="f">Building</text><text x="14.028" y="38.577" class="f">Data</text><text x="19.038" y="38.577" class="f">Plausibility</text><text x="32.064" y="38.577" class="f">Index</text><text y="40.748" class="h">│</text><text x="3.006" y="40.748" class="f">○</text><text x="5.01" y="40.748" class="f">Dual</text><text x="10.02" y="40.748" class="f">Geometry</text><text x="19.038" y="40.748" class="f">Grouping</text><text x="28.056" y="40.748" class="f">Strategies</text><text x="39.078" y="40.748" class="f">Rollout</text><text y="42.919" class="h">│</text><text x="3.006" y="42.919" class="f">○</text><text x="5.01" y="42.919" class="f">Introduce</text><text x="15.03" y="42.919" class="f">`cellar_leap`</text><text y="45.09" class="h">│</text><text x="3.006" y="45.09" class="f">○</text><text x="5.01" y="45.09" class="f">Update</text><text x="12.024" y="45.09" class="f">LoD2</text><text x="17.034" y="45.09" class="f">2022</text><text x="22.044" y="45.09" class="f">-></text><text x="25.05" y="45.09" class="f">2025</text><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">○</text><text x="5.01" y="47.261" class="f">Update</text><text x="12.024" y="47.261" class="f">Solar</text><text x="18.036" y="47.261" class="f">Thermal</text><text x="26.052" y="47.261" class="f">Data</text><text y="49.432" class="h">│</text><text x="3.006" y="49.432" class="f">○</text><text x="5.01" y="49.432" class="f">Introduce</text><text x="15.03" y="49.432" class="f">`building_volume_ratio`</text><text x="39.078" y="49.432" class="f">to</text><text x="42.084" y="49.432" class="f">`parcels`</text><text y="51.603" class="h">│</text><text x="3.006" y="51.603" class="f">○</text><text x="5.01" y="51.603" class="f">Enhanced</text><text x="14.028" y="51.603" class="f">Building</text><text x="23.046" y="51.603" class="f">Address</text><text x="31.062" y="51.603" class="f">Discovery</text><text y="53.774" class="h">│</text><text x="3.006" y="53.774" class="f">○</text><text x="5.01" y="53.774" class="f">Housing</text><text x="13.026" y="53.774" class="f">Market</text><text x="20.04" y="53.774" class="f">Indices</text><text x="28.056" y="53.774" class="f">Integration</text><text y="55.945" class="h">│</text><text x="3.006" y="55.945" class="f">○</text><text x="5.01" y="55.945" class="f">Introduce</text><text x="15.03" y="55.945" class="f">`federal_state`</text><text y="58.116" class="h">│</text><text x="3.006" y="58.116" class="f">○</text><text x="5.01" y="58.116" class="f">Introduce</text><text x="15.03" y="58.116" class="f">`postcode`</text><text x="26.052" y="58.116" class="f">geometries</text><text y="60.287" class="h">│</text><text x="3.006" y="60.287" class="f">○</text><text x="5.01" y="60.287" class="f">Introducing</text><text x="17.034" y="60.287" class="f">`storey_height`</text><text x="33.066" y="60.287" class="f">Consistency</text><text y="62.458" class="h">│</text><text x="3.006" y="62.458" class="f">...</text><use xlink:href="#12" y="62.959"/><use xlink:href="#13" y="65.13"/></svg><svg x="16008"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="8.659"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/></svg><svg x="16100"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><text y="12.525" class="h">◆</text><text x="3.006" y="12.525" class="f">Cycle</text><use xlink:href="#4" y="13.026"/><use xlink:href="#5" y="15.197"/><text y="19.038" class="h">│</text><text x="3.006" y="19.038" class="i">●</text><text x="5.01" y="19.038" class="f">None</text><text x="10.02" y="19.038" class="f">(no</text><text x="14.028" y="19.038" class="f">cycle)</text><text y="21.209" class="h">│</text><text x="3.006" y="21.209" class="f">○</text><text x="5.01" y="21.209" class="f">Cycle</text><text x="11.022" y="21.209" class="f">54</text><text y="23.38" class="h">│</text><text x="3.006" y="23.38" class="f">○</text><text x="5.01" y="23.38" class="f">Cycle</text><text x="11.022" y="23.38" class="f">55</text><text y="25.551" class="h">│</text><text x="3.006" y="25.551" class="f">○</text><text x="5.01" y="25.551" class="f">Cycle</text><text x="11.022" y="25.551" class="f">56</text><text y="27.722" class="h">│</text><text x="3.006" y="27.722" class="f">○</text><text x="5.01" y="27.722" class="f">Cycle</text><text x="11.022" y="27.722" class="f">57</text><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">○</text><text x="5.01" y="29.893" class="f">Cycle</text><text x="11.022" y="29.893" class="f">58</text><use xlink:href="#12" y="30.394"/><use xlink:href="#13" y="32.565"/></svg><svg x="16192"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="15.172"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/></svg><svg x="16284"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><text y="19.038" class="h">◆</text><text x="3.006" y="19.038" class="f">Status</text><use xlink:href="#4" y="19.539"/><use xlink:href="#5" y="21.71"/><text y="25.551" class="h">│</text><text x="3.006" y="25.551" class="i">●</text><text x="5.01" y="25.551" class="f">None</text><text x="10.02" y="25.551" class="f">(team</text><text x="16.032" y="25.551" class="f">default)</text><text y="27.722" class="h">│</text><text x="3.006" y="27.722" class="f">○</text><text x="5.01" y="27.722" class="f">Triage</text><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">○</text><text x="5.01" y="29.893" class="f">Backlog</text><text y="32.064" class="h">│</text><text x="3.006" y="32.064" class="f">○</text><text x="5.01" y="32.064" class="f">Todo</text><text y="34.235" class="h">│</text><text x="3.006" y="34.235" class="f">○</text><text x="5.01" y="34.235" class="f">In</text><text x="8.016" y="34.235" class="f">Progress</text><text y="36.406" class="h">│</text><text x="3.006" y="36.406" class="f">○</text><text x="5.01" y="36.406" class="f">On</text><text x="8.016" y="36.406" class="f">Hold</text><text y="38.577" class="h">│</text><text x="3.006" y="38.577" class="f">○</text><text x="5.01" y="38.577" class="f">In</text><text x="8.016" y="38.577" class="f">Review</text><text y="40.748" class="h">│</text><text x="3.006" y="40.748" class="f">○</text><text x="5.01" y="40.748" class="f">Done</text><text y="42.919" class="h">│</text><text x="3.006" y="42.919" class="f">○</text><text x="5.01" y="42.919" class="f">Canceled</text><text y="45.09" class="h">│</text><text x="3.006" y="45.09" class="f">○</text><text x="5.01" y="45.09" class="f">Duplicate</text><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">○</text><text x="5.01" y="47.261" class="f">Stale</text><text y="49.432" class="h">│</text><text x="3.006" y="49.432" class="f">○</text><text x="5.01" y="49.432" class="f">Outdated</text><use xlink:href="#12" y="49.933"/><use xlink:href="#13" y="52.104"/></svg><svg x="16376"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="21.685"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/></svg><svg x="16468"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><use xlink:href="#5" y="28.223"/><text y="32.064" class="h">│</text><text x="3.006" y="32.064" class="f">◻</text><text x="5.01" y="32.064" class="f">perf</text><use xlink:href="#52" y="32.565"/><use xlink:href="#53" y="34.736"/><text y="38.577" class="h">│</text><text x="3.006" y="38.577" class="f">◻</text><text x="5.01" y="38.577" class="f">good-issue</text><use xlink:href="#54" y="39.078"/><use xlink:href="#55" y="41.249"/><use xlink:href="#56" y="43.42"/><use xlink:href="#57" y="45.591"/><use xlink:href="#58" y="47.762"/><use xlink:href="#59" y="49.933"/><use xlink:href="#13" y="52.104"/></svg><svg x="16560"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">Search:</text><text x="11.022" y="29.893" class="f">t█</text><text x="14.028" y="29.893" class="f">(7</text><text x="17.034" y="29.893" class="f">matches)</text><use xlink:href="#52" y="30.394"/><use xlink:href="#53" y="32.565"/><use xlink:href="#54" y="34.736"/><use xlink:href="#55" y="36.907"/><use xlink:href="#56" y="39.078"/><use xlink:href="#57" y="41.249"/><use xlink:href="#58" y="43.42"/><use xlink:href="#59" y="45.591"/><use xlink:href="#13" y="47.762"/></svg><svg x="16652"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">Search:</text><text x="11.022" y="29.893" class="f">te█</text><text x="15.03" y="29.893" class="f">(4</text><text x="18.036" y="29.893" class="f">matches)</text><use xlink:href="#52" y="30.394"/><use xlink:href="#54" y="32.565"/><use xlink:href="#57" y="34.736"/><use xlink:href="#58" y="36.907"/><use xlink:href="#59" y="39.078"/><use xlink:href="#13" y="41.249"/></svg><svg x="16744"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">Search:</text><text x="11.022" y="29.893" class="f">tec█</text><text x="16.032" y="29.893" class="f">(1</text><text x="19.038" y="29.893" class="f">match)</text><use xlink:href="#52" y="30.394"/><use xlink:href="#59" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="16836"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">Search:</text><text x="11.022" y="29.893" class="f">tech█</text><text x="17.034" y="29.893" class="f">(1</text><text x="20.04" y="29.893" class="f">match)</text><use xlink:href="#52" y="30.394"/><use xlink:href="#59" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="16928"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">Search:</text><text x="11.022" y="29.893" class="f">techn█</text><text x="18.036" y="29.893" class="f">(1</text><text x="21.042" y="29.893" class="f">match)</text><use xlink:href="#52" y="30.394"/><use xlink:href="#59" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="17020"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">Search:</text><text x="11.022" y="29.893" class="f">techni█</text><text x="19.038" y="29.893" class="f">(1</text><text x="22.044" y="29.893" class="f">match)</text><use xlink:href="#52" y="30.394"/><use xlink:href="#59" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="17112"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">Search:</text><text x="11.022" y="29.893" class="f">technic█</text><text x="20.04" y="29.893" class="f">(1</text><text x="23.046" y="29.893" class="f">match)</text><use xlink:href="#52" y="30.394"/><use xlink:href="#59" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="17204"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><text y="29.893" class="h">│</text><text x="3.006" y="29.893" class="f">Search:</text><text x="11.022" y="29.893" class="f">technica█</text><text x="21.042" y="29.893" class="f">(1</text><text x="24.048" y="29.893" class="f">match)</text><use xlink:href="#52" y="30.394"/><use xlink:href="#59" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="17296"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><use xlink:href="#60" y="28.223"/><use xlink:href="#52" y="30.394"/><use xlink:href="#59" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="17388"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#51" y="23.881"/><use xlink:href="#4" y="26.052"/><use xlink:href="#60" y="28.223"/><text y="32.064" class="h">│</text><text x="3.006" y="32.064" class="i">◼</text><text x="5.01" y="32.064" class="f">technical-debt</text><use xlink:href="#59" y="32.565"/><use xlink:href="#13" y="34.736"/></svg><svg x="17480"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="28.198"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/></svg><svg x="17572"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><text y="32.064" class="h">◆</text><text x="3.006" y="32.064" class="f">Estimate</text><use xlink:href="#4" y="32.565"/><use xlink:href="#5" y="34.736"/><text y="38.577" class="h">│</text><text x="3.006" y="38.577" class="i">●</text><text x="5.01" y="38.577" class="f">None</text><text x="10.02" y="38.577" class="f">(no</text><text x="14.028" y="38.577" class="f">estimate)</text><text y="40.748" class="h">│</text><text x="3.006" y="40.748" class="f">○</text><text x="5.01" y="40.748" class="f">0</text><text y="42.919" class="h">│</text><text x="3.006" y="42.919" class="f">○</text><text x="5.01" y="42.919" class="f">1</text><text y="45.09" class="h">│</text><text x="3.006" y="45.09" class="f">○</text><text x="5.01" y="45.09" class="f">2</text><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">○</text><text x="5.01" y="47.261" class="f">3</text><text y="49.432" class="h">│</text><text x="3.006" y="49.432" class="f">○</text><text x="5.01" y="49.432" class="f">5</text><text y="51.603" class="h">│</text><text x="3.006" y="51.603" class="f">○</text><text x="5.01" y="51.603" class="f">8</text><use xlink:href="#12" y="52.104"/><use xlink:href="#13" y="54.275"/></svg><svg x="17664"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#65" y="36.907"/><text y="40.748" class="h">│</text><text x="3.006" y="40.748" class="f">○</text><text x="5.01" y="40.748" class="f">Yes</text><text x="9.018" y="40.748" class="f">/</text><text x="11.022" y="40.748" class="i">●</text><text x="13.026" y="40.748" class="f">No</text><use xlink:href="#13" y="41.249"/></svg><svg x="17756"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#65" y="36.907"/><text y="40.748" class="h">│</text><text x="3.006" y="40.748" class="i">●</text><text x="5.01" y="40.748" class="f">Yes</text><text x="9.018" y="40.748" class="f">/</text><text x="11.022" y="40.748" class="f">○</text><text x="13.026" y="40.748" class="f">No</text><use xlink:href="#13" y="41.249"/></svg><svg x="17848"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><path class="j" d="M3.006 45.591h2v2.171h-2z"/><text x="3.006" y="47.261" class="k">mm</text><text x="5.01" y="47.261" class="g">/</text><text x="6.012" y="47.261" class="f">dd</text><text x="8.016" y="47.261" class="g">/</text><text x="9.018" y="47.261" class="f">yyyy</text><use xlink:href="#13" y="47.762"/></svg><svg x="17940"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><path class="j" d="M3.006 45.591h2v2.171h-2z"/><text x="3.006" y="47.261" class="k">00</text><text x="5.01" y="47.261" class="g">/</text><text x="6.012" y="47.261" class="f">dd</text><text x="8.016" y="47.261" class="g">/</text><text x="9.018" y="47.261" class="f">yyyy</text><use xlink:href="#13" y="47.762"/></svg><svg x="18032"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">07</text><text x="5.01" y="47.261" class="g">/</text><path class="j" d="M6.012 45.591h2v2.171h-2z"/><text x="6.012" y="47.261" class="k">dd</text><text x="8.016" y="47.261" class="g">/</text><text x="9.018" y="47.261" class="f">yyyy</text><use xlink:href="#13" y="47.762"/></svg><svg x="18124"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">07</text><text x="5.01" y="47.261" class="g">/</text><path class="j" d="M6.012 45.591h2v2.171h-2z"/><text x="6.012" y="47.261" class="k">00</text><text x="8.016" y="47.261" class="g">/</text><text x="9.018" y="47.261" class="f">yyyy</text><use xlink:href="#13" y="47.762"/></svg><svg x="18216"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">07</text><text x="5.01" y="47.261" class="g">/</text><text x="6.012" y="47.261" class="f">07</text><text x="8.016" y="47.261" class="g">/</text><path class="j" d="M9.018 45.591h4v2.171h-4z"/><text x="9.018" y="47.261" class="k">yyyy</text><use xlink:href="#13" y="47.762"/></svg><svg x="18308"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">07</text><text x="5.01" y="47.261" class="g">/</text><text x="6.012" y="47.261" class="f">07</text><text x="8.016" y="47.261" class="g">/</text><path class="j" d="M9.018 45.591h4v2.171h-4z"/><text x="9.018" y="47.261" class="k">2</text><use xlink:href="#13" y="47.762"/></svg><svg x="18400"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">07</text><text x="5.01" y="47.261" class="g">/</text><text x="6.012" y="47.261" class="f">07</text><text x="8.016" y="47.261" class="g">/</text><path class="j" d="M9.018 45.591h4v2.171h-4z"/><text x="9.018" y="47.261" class="k">20</text><use xlink:href="#13" y="47.762"/></svg><svg x="18492"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">07</text><text x="5.01" y="47.261" class="g">/</text><text x="6.012" y="47.261" class="f">07</text><text x="8.016" y="47.261" class="g">/</text><path class="j" d="M9.018 45.591h4v2.171h-4z"/><text x="9.018" y="47.261" class="k">202</text><use xlink:href="#13" y="47.762"/></svg><svg x="18584"><use xlink:href="#a"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><use xlink:href="#68" y="43.42"/><text y="47.261" class="h">│</text><text x="3.006" y="47.261" class="f">07</text><text x="5.01" y="47.261" class="g">/</text><text x="6.012" y="47.261" class="f">07</text><text x="8.016" y="47.261" class="g">/</text><path class="j" d="M9.018 45.591h4v2.171h-4z"/><text x="9.018" y="47.261" class="k">2026</text><use xlink:href="#13" y="47.762"/></svg><svg x="18676"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="47.737"/><use xlink:href="#44"/><use xlink:href="#2" y="2.171"/><use xlink:href="#45" y="4.342"/><use xlink:href="#46" y="6.513"/><use xlink:href="#2" y="8.684"/><use xlink:href="#47" y="10.855"/><use xlink:href="#48" y="13.026"/><use xlink:href="#2" y="15.197"/><use xlink:href="#49" y="17.368"/><use xlink:href="#50" y="19.539"/><use xlink:href="#2" y="21.71"/><use xlink:href="#61" y="23.881"/><use xlink:href="#62" y="26.052"/><use xlink:href="#2" y="28.223"/><use xlink:href="#63" y="30.394"/><use xlink:href="#64" y="32.565"/><use xlink:href="#2" y="34.736"/><use xlink:href="#66" y="36.907"/><use xlink:href="#67" y="39.078"/><use xlink:href="#2" y="41.249"/><text y="45.09" class="i">◇</text><text x="3.006" y="45.09" class="f">Due</text><text x="7.014" y="45.09" class="f">date</text><text y="47.261" class="g">│</text><text x="3.006" y="47.261" class="f">07/07/2026</text></svg><svg x="18768"><use xlink:href="#a"/><use xlink:href="#b" x="-.004" y="65.105"/><text x="2.004" y="1.67" class="f">"team":</text><text x="10.02" y="1.67" class="f">{</text><text x="4.008" y="3.841" class="f">"id":</text><text x="10.02" y="3.841" class="f">"23321c69-fe7a-44d4-8c8d-12240cb41b10",</text><text x="4.008" y="6.012" class="f">"key":</text><text x="11.022" y="6.012" class="f">"DAT",</text><text x="4.008" y="8.183" class="f">"name":</text><text x="12.024" y="8.183" class="f">"data"</text><use xlink:href="#69" y="8.684"/><text x="2.004" y="12.525" class="f">"project":</text><text x="13.026" y="12.525" class="f">null,</text><text x="2.004" y="14.696" class="f">"labels":</text><text x="12.024" y="14.696" class="f">{</text><text x="4.008" y="16.867" class="f">"nodes":</text><text x="13.026" y="16.867" class="f">[</text><text x="6.012" y="19.038" class="f">{</text><text x="8.016" y="21.209" class="f">"id":</text><text x="14.028" y="21.209" class="f">"6da32660-4426-47a1-a820-a61217d8edca",</text><text x="8.016" y="23.38" class="f">"name":</text><text x="16.032" y="23.38" class="f">"technical-debt"</text><text x="6.012" y="25.551" class="f">}</text><text x="4.008" y="27.722" class="f">]</text><use xlink:href="#69" y="28.223"/><text x="2.004" y="32.064" class="f">"cycle":</text><text x="11.022" y="32.064" class="f">null,</text><text x="2.004" y="34.235" class="f">"projectMilestone":</text><text x="22.044" y="34.235" class="f">null,</text><text x="2.004" y="36.406" class="f">"parent":</text><text x="12.024" y="36.406" class="f">null,</text><text x="2.004" y="38.577" class="f">"children":</text><text x="14.028" y="38.577" class="f">{</text><use xlink:href="#70" y="39.078"/><use xlink:href="#69" y="41.249"/><text x="2.004" y="45.09" class="f">"relations":</text><text x="15.03" y="45.09" class="f">{</text><use xlink:href="#70" y="45.591"/><use xlink:href="#69" y="47.762"/><text x="2.004" y="51.603" class="f">"inverseRelations":</text><text x="22.044" y="51.603" class="f">{</text><use xlink:href="#70" y="52.104"/><use xlink:href="#69" y="54.275"/><text x="2.004" y="58.116" class="f">"comments":</text><text x="14.028" y="58.116" class="f">{</text><use xlink:href="#70" y="58.617"/><text x="2.004" y="62.458" class="f">}</text><text y="64.629" class="f">}</text></svg></svg></g></g></svg></svg> \ No newline at end of file diff --git a/knip.json b/knip.json index 39a1645f..c204a2ab 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,12 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"], - "ignore": ["src/gql/**"], + "ignore": [ + "src/gql/**", + "scripts/gen-demo-agent-cast.mjs", + "scripts/rec-demo-interactive-cast.mjs", + "scripts/anonymize-demo-cast.mjs" + ], "ignoreDependencies": [ "@semantic-release/github", "@semantic-release/npm", diff --git a/scripts/anonymize-demo-cast.mjs b/scripts/anonymize-demo-cast.mjs new file mode 100644 index 00000000..b9676a45 --- /dev/null +++ b/scripts/anonymize-demo-cast.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// +// anonymize-demo-cast.mjs — scrub PII from a recorded demo cast. +// +// The interactive wizard demo (rec-demo-interactive-cast.mjs) is a real pty +// recording, so its Team and Assignee pickers capture real workspace member +// names, the company email domain, and internal team names. This filter rewrites +// those to stable pseudonyms so no personal data lands in the committed SVG. +// +// It contains NO real names itself: it fetches the current workspace users and +// teams from the live API (`linearis users/teams list`) at run time, builds a +// deterministic real→pseudonym map, and applies it to every output chunk of the +// cast. One identity is preserved so the demo still shows a real assignee — by +// default "Fabian Jocks" (override with DEMO_KEEP_NAME), whose company email +// domain is still scrubbed. One team name is preserved as the demo subject — by +// default "data" (override with DEMO_KEEP_TEAM). +// +// Integration/bot members (Codex, Cursor, GitHub Copilot, Linear) are product +// names, not PII, and are left as-is. +// +// USAGE (needs Linear credentials, same as recording): +// node scripts/rec-demo-interactive-cast.mjs \ +// | node scripts/anonymize-demo-cast.mjs > docs/assets/issue-create-interactive.cast +// # or filter an existing cast in place: +// node scripts/anonymize-demo-cast.mjs < raw.cast > clean.cast + +import { execFileSync } from "node:child_process"; + +const KEEP_NAME = process.env.DEMO_KEEP_NAME ?? "Fabian Jocks"; +const KEEP_TEAM = process.env.DEMO_KEEP_TEAM ?? "data"; + +// Deterministic pseudonym pools (gender-neutral names; generic team names). +const FAKE_NAMES = [ + "Alex Carter", + "Jordan Lee", + "Sam Rivera", + "Morgan Reed", + "Riley Quinn", + "Casey Brooks", + "Taylor Fox", + "Jamie Cole", + "Drew Ellis", + "Robin Shaw", + "Avery Hart", + "Quinn Diaz", + "Reese Park", + "Skyler Nash", + "Emerson Wells", + "Harper Vance", + "Rowan Frost", + "Sage Bello", + "Micah Lund", + "Noa Behr", +]; +const FAKE_TEAMS = [ + ["platform", "PLT"], + ["growth", "GRW"], + ["mobile", "MOB"], + ["billing", "BIL"], + ["insights", "INS"], + ["support", "SUP"], +]; + +function fetchJson(domain) { + const out = execFileSync("linearis", [domain, "list", "--limit", "200"], { + encoding: "utf8", + }); + return JSON.parse(out).nodes; +} + +// A member is a real person (vs. an integration bot) when their email is not on +// a Linear integration domain. +const isBot = (email = "") => + /@oauthapp\.linear\.app$|@linear\.linear\.app$/i.test(email); + +const users = fetchJson("users"); +const teams = fetchJson("teams"); + +// Replacement rules, applied longest-source-first. Full strings (names, emails, +// teams) match literally; individual name tokens match on word boundaries so a +// name split across two output chunks (redraw) is still caught. +const rules = []; +const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const add = (find, replace, { token = false } = {}) => { + if (!find || replace === undefined || find === replace) return; + const body = esc(find); + rules.push({ + len: find.length, + re: new RegExp(token ? `\\b${body}\\b` : body, "g"), + to: replace, + }); +}; +// Replace each whitespace-separated token of a full name with the matching +// token of its pseudonym (catches chunk-split renders of the name). +const addTokens = (realName, fakeName) => { + const rp = realName.split(/\s+/); + const fp = fakeName.split(/\s+/); + rp.forEach((tok, i) => { + if (tok.length >= 3) add(tok, fp[i] ?? fp[fp.length - 1], { token: true }); + }); +}; + +// Company email domains (from real members) → example.com, and the domain's +// bare label (e.g. "viadukt") → "acme" to catch bot usernames / stragglers. +const domains = new Set(); +for (const u of users) { + if (u.email && !isBot(u.email)) domains.add(u.email.split("@")[1]); +} +for (const d of domains) { + add(d, "example.com"); + const label = d.split(".")[0]; + if (label.length > 3) add(label, "acme"); +} + +// Humans → pseudonyms (stable: sorted by name), keeping KEEP_NAME. +const humans = users + .filter((u) => !isBot(u.email) && u.name !== KEEP_NAME) + .sort((a, b) => a.name.localeCompare(b.name)); +humans.forEach((u, i) => { + const fake = FAKE_NAMES[i % FAKE_NAMES.length]; + add(u.email, `${fake.split(" ")[1].toLowerCase()}@example.com`); + add(u.name, fake); + addTokens(u.name, fake); +}); +// Preserve KEEP_NAME but still scrub its email. +const me = users.find((u) => u.name === KEEP_NAME); +if (me?.email) + add(me.email, `${KEEP_NAME.split(" ")[1].toLowerCase()}@example.com`); + +// Teams → generic names + keys (in the "name (KEY)" form), keeping KEEP_TEAM. +teams + .filter((t) => t.name !== KEEP_TEAM) + .sort((a, b) => a.name.localeCompare(b.name)) + .forEach((t, i) => { + const [name, key] = FAKE_TEAMS[i % FAKE_TEAMS.length]; + add(`(${t.key})`, `(${key})`); + add(t.name, name); + }); + +rules.sort((a, b) => b.len - a.len); + +function scrub(text) { + let out = text; + for (const { re, to } of rules) out = out.replace(re, to); + return out; +} + +// Stream the cast. The pty delivers a single screen redraw as several chunks +// microseconds apart, which can split a name across events and defeat scrubbing. +// So first coalesce consecutive "o" events that arrive within one frame +// (GAP seconds) into a single event — this reunites split names while leaving +// the deliberate typing/pauses (tens to hundreds of ms) untouched — then scrub. +const GAP = 0.04; +const input = await readStdin(); +const raw = []; +for (const line of input.split("\n")) { + if (!line) continue; + try { + raw.push(JSON.parse(line)); + } catch { + process.stdout.write(`${line}\n`); // header (object, not array) + } +} + +let pending = null; // { t, data } accumulator for a coalesced frame +const flush = () => { + if (!pending) return; + process.stdout.write( + `${JSON.stringify([pending.t, "o", scrub(pending.data)])}\n`, + ); + pending = null; +}; +for (const ev of raw) { + if (ev[1] === "o" && pending && ev[0] - pending.last <= GAP) { + pending.data += ev[2]; + pending.last = ev[0]; + } else if (ev[1] === "o") { + flush(); + pending = { t: ev[0], last: ev[0], data: ev[2] }; + } else { + flush(); + process.stdout.write(`${JSON.stringify(ev)}\n`); + } +} +flush(); + +function readStdin() { + return new Promise((resolve) => { + let d = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (d += c)); + process.stdin.on("end", () => resolve(d)); + }); +} diff --git a/scripts/gen-demo-agent-cast.mjs b/scripts/gen-demo-agent-cast.mjs new file mode 100644 index 00000000..659e7f32 --- /dev/null +++ b/scripts/gen-demo-agent-cast.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// +// gen-demo-agent-cast.mjs — build the "agent" README demo. +// +// Renders a faithful Claude Code transcript into an asciinema v2 cast: an AI +// agent invoking the `linearis` skill and running discover-then-act to create +// an issue. It is the counterpart to the "human" wizard demo produced by +// rec-demo-interactive-cast.mjs; both appear at the top of README.md. +// +// WHY A RENDERER (not a raw recording): Claude Code's TUI redraws heavily and +// its output is non-deterministic, which records poorly. Instead we captured a +// real headless session and re-render its content in the recognisable Claude +// Code style (⏺ bullets, ⎿ result boxes). Every command, output snippet and the +// closing line in `steps` below is VERBATIM from that run — see PROVENANCE. +// The transcript is lightly trimmed for length (a `teams usage` probe and a +// `--fields` retry are omitted). This script is deterministic: no TTY, no +// network, no credentials. +// +// PROVENANCE — the source session was captured (from a neutral directory, with +// the `linearis` skill installed and Linear credentials available) via: +// +// claude -p 'Using linearis, create a Linear issue in the data team titled \ +// "Backfill fct_orders after Airflow DAG failure (2026-07-04)", priority \ +// high, with label technical-debt. Report the created issue identifier.' \ +// --output-format stream-json --verbose --dangerously-skip-permissions \ +// > docs/assets/issue-create-agent.jsonl +// +// The `.jsonl` capture is git-ignored (a raw artifact); re-run the command +// above to refresh it, delete the demo issue it creates, then update `steps`. +// +// USAGE — regenerate the committed SVG (run from the repo root): +// +// node scripts/gen-demo-agent-cast.mjs > docs/assets/issue-create-agent.cast +// npx svg-term-cli --in docs/assets/issue-create-agent.cast \ +// --out docs/assets/issue-create-agent.svg --window --width 92 --height 33 --padding 14 +// +// The `.cast` is git-ignored (regenerable); only the `.svg` is committed. + +const WIDTH = 92; // terminal columns (must match the svg-term --width above) +const HEIGHT = 33; // terminal rows (must match --height; tall enough to show it all) + +// ANSI palette chosen to echo Claude Code's own colours. +const DIM = "\x1b[38;5;245m"; // muted gray — command output / hints +const R = "\x1b[0m"; // reset +const DOT = "\x1b[38;5;114m⏺\x1b[0m"; // green bullet that prefixes each agent step +const CORNER = `${DIM}⎿${R}`; // result connector under a tool call +const BOLD = "\x1b[1m"; +const USER = "\x1b[38;5;110m"; // soft blue — the user's prompt marker + +const CREATE_CMD = + 'linearis issues create "Backfill fct_orders after Airflow DAG failure\r\n (2026-07-04)" --team DAT --priority 2 --labels technical-debt'; + +// [dot line, [result lines...]] — result lines are dimmed and indented under ⎿. +const steps = [ + [ + `${DOT} ${BOLD}linearis${R} ${DIM}(Skill)${R}`, + [`${DIM}Launching skill: linearis${R}`], + ], + [`${DOT} I'll discover the command surface, then create the issue.`, []], + [ + `${DOT} ${BOLD}Bash${R}(linearis usage)`, + [ + `${DIM}linearis v2026.4.8 — CLI for Linear.app (JSON output)${R}`, + `${DIM}domains: auth issues projects cycles teams … (+14 lines)${R}`, + ], + ], + [ + `${DOT} ${BOLD}Bash${R}(linearis issues usage)`, + [ + `${DIM}linearis issues — work items with status, priority, labels${R}`, + `${DIM}create <title> --team <team> [--priority 1-4] [--labels …] (+20 lines)${R}`, + ], + ], + [ + `${DOT} ${BOLD}Bash${R}(linearis teams list)`, + [ + `${DIM}{ "nodes": [ { "key": "DAT", "name": "data" },${R}`, + `${DIM} { "key": "ENE", "name": "energy" }, … ] }${R}`, + ], + ], + [ + `${DOT} ${BOLD}Bash${R}(${CREATE_CMD})`, + [ + `${DIM}{${R}`, + `${DIM} "identifier": "DAT-1219", "priority": 2, "state": { "name": "Triage" },${R}`, + `${DIM} "team": { "key": "DAT", "name": "data" },${R}`, + `${DIM} "labels": { "nodes": [ { "name": "technical-debt" } ] }${R}`, + `${DIM}}${R}`, + ], + ], + [ + `${DOT} Created ${BOLD}DAT-1219${R} — "Backfill fct_orders after Airflow DAG failure\r\n (2026-07-04)" in the data team, priority High, labeled technical-debt.`, + [], + ], +]; + +const events = []; +let t = 0; +const push = (dt, data) => { + t += dt; + events.push([Number(t.toFixed(3)), "o", data]); +}; + +// The user's prompt, shown the way Claude Code echoes it. +const promptLines = [ + `${USER}>${R} Using linearis, create a Linear issue in the data team titled`, + ` "Backfill fct_orders after Airflow DAG failure (2026-07-04)", priority`, + ` high, with label technical-debt. Report the created issue identifier.`, +]; +push(0.4, `${promptLines.join("\r\n")}\r\n`); + +for (const [line, results] of steps) { + push(0.9, `\r\n${line}\r\n`); + for (let i = 0; i < results.length; i++) { + const prefix = i === 0 ? ` ${CORNER} ` : " "; + push(0.28, `${prefix}${results[i]}\r\n`); + } +} +push(2.6, ""); // hold on the final frame + +const header = { + version: 2, + width: WIDTH, + height: HEIGHT, + timestamp: 0, + env: { SHELL: "/bin/zsh", TERM: "xterm-256color" }, +}; +process.stdout.write(`${JSON.stringify(header)}\n`); +for (const e of events) process.stdout.write(`${JSON.stringify(e)}\n`); diff --git a/scripts/rec-demo-interactive-cast.mjs b/scripts/rec-demo-interactive-cast.mjs new file mode 100644 index 00000000..16a2371a --- /dev/null +++ b/scripts/rec-demo-interactive-cast.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +// +// rec-demo-interactive-cast.mjs — build the "human" README demo. +// +// Records the real interactive `linearis issues create` wizard into an +// asciinema v2 cast. It is the counterpart to the "agent" demo produced by +// gen-demo-agent-cast.mjs; both appear at the top of README.md. +// +// HOW IT WORKS: `@clack/prompts` renders a raw-mode TUI that only runs on a real +// terminal, so we spawn the CLI inside a pseudo-terminal via node-pty and drive +// it with an event-driven keystroke script. Each turn it reads the screen, +// finds the active clack prompt by its `◆ <Label>` marker (see `activeLabel`), +// and runs that label's handler from the `handlers` map. Because it reacts to +// whichever prompt is on screen rather than following a fixed sequence, it stays +// correct when optional fields appear or are skipped depending on team config +// (project / cycle / estimate). Every keystroke and its timing are captured, so +// the resulting SVG is a genuine recording, not a re-render. +// +// REQUIREMENTS: +// - a built CLI: `npm run build` (this drives ./dist/main.js) +// - Linear credentials: LINEAR_API_TOKEN or ~/.linearis/token (the wizard's +// pickers load teams/labels/users from the live API) +// - node-pty, which is NOT a project dependency: `npm i node-pty` +// (if it fails to spawn with "posix_spawnp failed", the prebuilt helper is +// missing its exec bit: +// `chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper`) +// - `CI` unset (interactive prompts are hard-gated off under CI) +// +// SIDE EFFECT: a successful run creates ONE real issue in the target team. +// Delete it afterwards: `linearis issues delete <identifier>`. +// +// PII: the pickers capture real workspace names, so the raw cast is piped +// through anonymize-demo-cast.mjs (pseudonymises everyone but the assignee) +// before rendering. Never render/commit an SVG from the un-anonymised cast. +// +// USAGE — regenerate the committed SVG (run from the repo root): +// +// node scripts/rec-demo-interactive-cast.mjs \ +// | node scripts/anonymize-demo-cast.mjs > docs/assets/issue-create-interactive.cast +// npx svg-term-cli --in docs/assets/issue-create-interactive.cast \ +// --out docs/assets/issue-create-interactive.svg --window --width 92 --height 30 --padding 14 +// linearis issues delete <identifier> # remove the demo issue this created +// +// The `.cast` is git-ignored (regenerable); only the `.svg` is committed. +// +// TUNING: edit TITLE/DESC and the per-prompt `handlers` below to change what +// the demo fills in. COLS/ROWS must match the svg-term --width/--height above. +import pty from "node-pty"; + +const COLS = 92; // pty columns (must match svg-term --width) +const ROWS = 30; // pty rows (must match svg-term --height) +const TITLE = "Backfill fct_orders after Airflow DAG failure (2026-07-04)"; +const DESC = + "dbt_daily DAG failed 03:12 UTC; fct_orders missing 2 days. Re-run models + verify row counts."; + +const env = { ...process.env, TERM: "xterm-256color" }; +delete env.CI; // gating.ts refuses to prompt when CI is set +delete env.LINEARIS_NO_INTERACTIVE; + +const term = pty.spawn("node", ["dist/main.js", "issues", "create"], { + name: "xterm-256color", + cols: COLS, + rows: ROWS, + cwd: process.cwd(), + env, +}); + +// ---- cast recording ---- +const start = Date.now(); +const events = []; +let raw = ""; +term.onData((d) => { + events.push([(Date.now() - start) / 1000, "o", d]); + raw += d; +}); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +// biome-ignore-start lint/suspicious/noControlCharactersInRegex: stripping raw ANSI/VT escapes requires matching the ESC (\x1b) and BEL (\x07) control chars. +const strip = (s) => + s + .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "") + .replace(/\x1b[()][AB012]/g, "") + .replace(/\x1b[=>]/g, "") + .replace(/\x1b\].*?\x07/g, ""); +// biome-ignore-end lint/suspicious/noControlCharactersInRegex: end ANSI-strip suppression. + +// Type visibly, char by char. +async function type(text) { + for (const ch of text) { + term.write(ch); + await sleep(45 + Math.random() * 40); + } +} +// Press a control key after a readable pause. +async function press(seq, pause = 320) { + await sleep(pause); + term.write(seq); +} +const KEY = { + enter: "\r", + down: "\x1b[B", + up: "\x1b[A", + left: "\x1b[D", + right: "\x1b[C", + tab: "\t", + space: " ", +}; + +// Per-prompt handlers, keyed by the clack message label. +const handlers = { + Team: async () => { + await sleep(400); + await type("data"); + await press(KEY.enter, 550); + }, + Title: async () => { + await sleep(350); + await type(TITLE); + await press(KEY.enter, 450); + }, + Description: async () => { + await sleep(350); + await type(DESC); + await press(KEY.enter, 450); // newline + await press(KEY.enter, 250); // blank line submits + }, + Assignee: async () => { + await sleep(400); + await type("jocks"); + await press(KEY.enter, 550); + }, + Priority: async () => { + await press(KEY.down, 450); + await press(KEY.down, 300); + await press(KEY.enter, 350); + }, + Project: async () => { + await press(KEY.enter, 450); + }, // None (no project) + Milestone: async () => { + await press(KEY.enter, 450); + }, // safety + Cycle: async () => { + await press(KEY.enter, 450); + }, // None (no cycle) + Status: async () => { + await press(KEY.enter, 450); + }, // None (team default) + Labels: async () => { + await sleep(400); + await type("technical"); + await press(KEY.tab, 550); + await press(KEY.enter, 450); + }, // Tab toggles, Enter confirms + Estimate: async () => { + await press(KEY.enter, 450); + }, // None (no estimate) + "Set a due date?": async () => { + await press(KEY.left, 500); + await press(KEY.enter, 350); + }, // Yes + "Due date": async () => { + await sleep(400); + await type("07072026"); + await press(KEY.enter, 600); + }, // mm/dd/yyyy +}; + +// Find the currently active prompt label (last `◆ <Label>` in the screen). +function activeLabel() { + const lines = strip(raw).split("\n"); + let label = null; + for (const line of lines) { + const m = line.match(/◆\s+(.+?)\s*$/); + if (m) label = m[1].trim(); + } + return label; +} + +let finished = false; +term.onExit(() => { + finished = true; +}); + +(async () => { + const handled = new Set(); + let last = null; + let lastChangeAt = Date.now(); + const deadline = Date.now() + 90_000; + + while (!finished && Date.now() < deadline) { + await sleep(200); + // Final JSON on stdout means the wizard completed. + if (/\{"id":"[0-9a-f-]{36}"/.test(strip(raw))) { + await sleep(600); + break; + } + const label = activeLabel(); + if (!label) continue; + if (label !== last) { + last = label; + lastChangeAt = Date.now(); + } + const h = handlers[label]; + if (h && !handled.has(label)) { + handled.add(label); + await h(); + } else if (handled.has(label) && Date.now() - lastChangeAt > 5000) { + // Stuck on an already-handled prompt (e.g. multiline didn't submit): + // escalate with Tab -> Enter (submit button) once. + lastChangeAt = Date.now(); + await press(KEY.tab, 200); + await press(KEY.enter, 200); + } + } + await sleep(400); + term.kill(); + + // Emit the cast. + const header = { + version: 2, + width: COLS, + height: ROWS, + timestamp: 0, + env: { SHELL: "/bin/zsh", TERM: "xterm-256color" }, + }; + process.stdout.write(`${JSON.stringify(header)}\n`); + for (const e of events) + process.stdout.write( + `${JSON.stringify([Number(e[0].toFixed(3)), e[1], e[2]])}\n`, + ); + process.exit(0); +})(); diff --git a/skills/linearis/SKILL.md b/skills/linearis/SKILL.md index f9ef4010..8cd2f0c6 100644 --- a/skills/linearis/SKILL.md +++ b/skills/linearis/SKILL.md @@ -38,6 +38,8 @@ Drive [Linear.app](https://linear.app) from the shell via the `linearis` CLI (JS Every command prints JSON on stdout. Shape it at the source with the global `--fields identifier,title,state.name` and `--compact` — no external binary, works on Windows and fresh containers. Reach for `jq` only for complex reshaping, and fall back to raw JSON if `jq` is absent. +The CLI can prompt interactively for missing input, but only on a real TTY. As an agent, pass the global `--no-interactive` flag so a missing required argument returns a JSON error you can act on instead of blocking on stdin. + ## Invariants worth knowing (everything else lives in `usage`) - IDs are forgiving: pass a UUID, team key (`ENG`), issue identifier (`ABC-123`), or name interchangeably. Reference tickets by identifier. From 892c7984452b4d92cfd4ef10c7165f4ba5f84894 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:25:02 +0200 Subject: [PATCH 10/13] chore(skill): bump skill/plugin version to 1.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent skill gained new guidance on this branch (documenting the `--no-interactive` flag for agents) but shipped without a version bump. Consumers pull skill and plugin updates by this SemVer version, which is independent of the date-based npm package version, so the content change needs to be reflected. Bump all four version fields in lockstep — SKILL.md metadata, plugin.json, and both fields in marketplace.json — as a patch, since the change is wording/guidance to existing content rather than new capability. --- .claude-plugin/marketplace.json | 4 ++-- .claude-plugin/plugin.json | 2 +- skills/linearis/SKILL.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d6279fc1..a0fc57d3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Marketplace for the linearis Claude Code plugin.", - "version": "1.0.0" + "version": "1.0.1" }, "plugins": [ { "name": "linearis", "source": "./", "description": "Agent skill teaching agents to use the linearis Linear.app CLI.", - "version": "1.0.0", + "version": "1.0.1", "author": { "name": "linearis-oss" }, "homepage": "https://github.com/linearis-oss/linearis", "license": "MIT" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6f33d71f..d9917df2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "linearis", "description": "Agent skill for the linearis Linear.app CLI: preflight, discover-then-act usage protocol, JSON output, ID resolution, discussions, files.", - "version": "1.0.0", + "version": "1.0.1", "author": { "name": "linearis-oss", "url": "https://github.com/linearis-oss/linearis" diff --git a/skills/linearis/SKILL.md b/skills/linearis/SKILL.md index 8cd2f0c6..4c87f879 100644 --- a/skills/linearis/SKILL.md +++ b/skills/linearis/SKILL.md @@ -13,7 +13,7 @@ compatibility: Requires the linearis CLI (npm i -g linearis), Node >=22, and a L allowed-tools: Bash(linearis:*), Bash(linear:*), Bash(jq:*) metadata: author: linearis-oss - version: "1.0.0" + version: "1.0.1" --- # linearis From 1a38301a1a1c388f8707725f8cfa8a3cf23a4156 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:25:04 +0200 Subject: [PATCH 11/13] docs(agents): require version bumps when editing skill/plugin files Future coding agents had no instruction to bump the skill/plugin version when changing shipped content, so edits landed without one (this branch's --no-interactive guidance among them). Add a "Skill & Plugin Versioning" section explaining the SemVer version is independent of the date-based npm version, that all four version fields must move in lockstep, and how to pick patch/minor/major. Also list skills/ and .claude-plugin/ in the File Map so the files are discoverable. --- AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 76c88d62..311f2a6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,8 +278,30 @@ graphql/ tests/ unit/ # mirrors src/ structure integration/ # CLI integration tests (need API token) +skills/ + linearis/SKILL.md # agent skill teaching the CLI (has its own version) +.claude-plugin/ # Claude Code plugin + marketplace manifests (versioned) ``` +## Skill & Plugin Versioning + +The agent skill and Claude Code plugin (`skills/linearis/SKILL.md`, +`.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`) carry a +**SemVer** version that is **independent of the date-based npm package version** +(`package.json`). Consumers pull skill/plugin updates by this version, so it must +move whenever the shipped content changes. + +**When you edit any skill or plugin file, bump the version in the same change:** + +- Keep all four version fields in lockstep — `metadata.version` in `SKILL.md`, + `version` in `plugin.json`, and both `metadata.version` and `plugins[0].version` + in `marketplace.json`. +- **patch** (`1.0.0` → `1.0.1`) for wording, guidance, or fixes to existing + content; **minor** for new capability or materially new instructions; **major** + for breaking changes to what agents rely on. +- Commit the bump as its own `chore(skill):` commit (or fold it into the content + commit), separate from the date-based release flow — never touch `CHANGELOG.md`. + ## Verification Checklist Before claiming work is complete, run: From 3b2baac7a5df2991615bbbc83f87f72fc166bbcb Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:51:53 +0200 Subject: [PATCH 12/13] fix(interactive): correct issue update sentinels and create title type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `issues update` wizard reused the create-oriented choice loaders for the assignee and project fields, so their escapable sentinel rendered as "None (unassigned)" / "None (no project)". Selecting an empty-valued sentinel means "leave unset", which on update leaves the field unchanged rather than clearing it — so the labels implied an outcome the engine never performs, and there is no clear path from the wizard. The sibling update fields (milestone/cycle/status/estimate) already relabel their sentinel "Keep current" for exactly this reason; assignee and project were missed. Switch both to `optionalChoices(..., "Keep current")`, preserving the project loader's team-scoping. Also widen the `issues create` action generic from `[string, ...]` to `[string | undefined, ...]` to match the now-optional `[title]` positional (and the other create commands). Previously `title` was typed as `string`, making the runtime `title === undefined` guard dead per the type system. --- src/commands/issues.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 41f0f11f..47d98761 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -26,8 +26,10 @@ import { optionalChoices, optionalProjectChoices, priorityChoices, + projectChoices, statusChoices, teamChoices, + userChoices, } from "../common/interactive/choices.js"; import { maybeCollectInteractive, @@ -314,7 +316,10 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { kind: "select", message: "Assignee", searchable: true, - choices: assigneeChoices, + // "Keep current" (not the create-only "None (unassigned)"): an empty + // selection leaves the assignee unchanged on update, so the sentinel must + // not imply it unassigns. + choices: optionalChoices(userChoices, "Keep current"), }, { name: "priority", @@ -328,7 +333,9 @@ export const issueUpdateSpec: PromptSpec<UpdateWizardOptions> = { message: "Project", searchable: true, when: (draft) => draft.team !== undefined, - choices: optionalProjectChoices, + // "Keep current" (not the create-only "None (no project)"): an empty + // selection leaves the project unchanged on update. + choices: optionalChoices(projectChoices, "Keep current"), }, { name: "projectMilestone", @@ -1572,7 +1579,7 @@ export function setupIssuesCommands(program: Command): void { .option("--duplicate-of <issue>", "this issue duplicates <issue>") .option("--similar-to <issue>", "this issue is similar to <issue>") .action( - commandAction<[string, CreateOptions, Command]>( + commandAction<[string | undefined, CreateOptions, Command]>( async (title, options, command) => { const rootOpts = getRootOpts(command); const ctx = createContext(rootOpts); From cbd0b1c1552b1b922398e2836c4c184c1dc5fd95 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:29:28 +0200 Subject: [PATCH 13/13] fix(interactive): guard empty pickers, order positional first, refine update-spec docs Follow-up fixes to the interactive-prompts feature raised in review. Crash fix: clack's single-select indexes options[cursor] in its constructor, so calling io.select on an empty option list throws a raw TypeError instead of the JSON error contract. Add the same empty guard the comments/attachments/discussion pickers already use to every remaining picker that can face an empty list (makeChoicePicker plus the milestone, cycle, label, initiative-update, project, document, and initiative entity/relation pickers). Team/user pickers stay unguarded because a workspace always has at least one team and the current user. Ordering: resolve the positional picker before running the field wizard in maybeCollectInteractive, so an interactive user chooses which entity to act on before being prompted for its fields. Fixing it in the shared helper covers every update command (and the create commands with a positional picker) at once instead of per command. Docs: drop the inert default callbacks from documentUpdateSpec and correct the "current option values seed each field" comments across the update specs. The wizard skips a flag-supplied field and prompts the rest fresh; it does not pre-load an entity's current values. Tests: add makeChoicePicker empty-guard coverage, a positional-before- fields ordering test, and update the documentUpdateSpec test that asserted the removed default. --- src/commands/cycles.ts | 3 ++ src/commands/documents.ts | 14 +++--- src/commands/initiatives/entity.ts | 8 ++- src/commands/initiatives/relations.ts | 8 ++- src/commands/initiatives/updates.ts | 12 ++++- src/commands/labels.ts | 10 ++-- src/commands/milestones.ts | 19 +++++-- src/commands/projects.ts | 9 ++-- src/common/interactive/engine.ts | 24 +++++---- src/common/interactive/pickers.ts | 11 +++- tests/unit/interactive/content-specs.test.ts | 4 +- tests/unit/interactive/engine.test.ts | 36 +++++++++++++ tests/unit/interactive/pickers.test.ts | 53 ++++++++++++++++++++ 13 files changed, 177 insertions(+), 34 deletions(-) create mode 100644 tests/unit/interactive/pickers.test.ts diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index d48c494a..e7ab7d91 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -90,6 +90,9 @@ function makeCyclePicker( } const options = await allCycleChoices(ctx, { team: teamId }); + if (options.length === 0) { + throw invalidParameterError("cycle", "the selected team has no cycles"); + } const answer = await io.select({ message: "Cycle", options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/commands/documents.ts b/src/commands/documents.ts index f5cbd9f1..adba39ac 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -135,18 +135,18 @@ export const documentCreateSpec: PromptSpec<DocumentCreateWizardOptions> = { }; /** - * Interactive wizard for `documents update`. All fields optional; current - * option values seed each field and prevent re-prompting for provided flags. + * Interactive wizard for `documents update`. All fields optional; a field + * already supplied by a flag is skipped, the rest are prompted fresh (the + * wizard does not pre-load the document's current values). */ export const documentUpdateSpec: PromptSpec<DocumentUpdateWizardOptions> = { intro: "Update a document", fields: [ - { name: "title", kind: "text", message: "Title", default: (d) => d.title }, + { name: "title", kind: "text", message: "Title" }, { name: "content", kind: "multiline", message: "Content (markdown)", - default: (d) => d.content, }, { name: "project", @@ -154,12 +154,11 @@ export const documentUpdateSpec: PromptSpec<DocumentUpdateWizardOptions> = { message: "Project", choices: optionalChoices(projectChoices, "Keep current"), }, - { name: "icon", kind: "text", message: "Icon", default: (d) => d.icon }, + { name: "icon", kind: "text", message: "Icon" }, { name: "color", kind: "text", message: "Icon color", - default: (d) => d.color, }, ], }; @@ -173,6 +172,9 @@ async function documentPicker( io: PromptIO, ): Promise<string> { const options = await documentChoices(ctx); + if (options.length === 0) { + throw invalidParameterError("document", "no documents are available"); + } const answer = await io.select({ message: "Document", options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/commands/initiatives/entity.ts b/src/commands/initiatives/entity.ts index a4f150b2..5b165da5 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -291,8 +291,9 @@ export const initiativeCreateSpec: PromptSpec<InitiativeCreateWizardOptions> = { }; /** - * Interactive wizard for `initiatives update`. All fields optional; the current - * option values seed each field. + * Interactive wizard for `initiatives update`. All fields optional; a field + * already supplied by a flag is skipped, the rest are prompted fresh (the + * wizard does not pre-load the initiative's current values). */ export const initiativeUpdateSpec: PromptSpec<InitiativeUpdateWizardOptions> = { intro: "Update an initiative", @@ -343,6 +344,9 @@ async function initiativePicker( io: PromptIO, ): Promise<string> { const options = await initiativeChoices(ctx); + if (options.length === 0) { + throw invalidParameterError("initiative", "no initiatives are available"); + } const answer = await io.select({ message: "Initiative", options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/commands/initiatives/relations.ts b/src/commands/initiatives/relations.ts index dbc39c68..2fcd1d58 100644 --- a/src/commands/initiatives/relations.ts +++ b/src/commands/initiatives/relations.ts @@ -1,7 +1,10 @@ import type { Command } from "commander"; import type { CommandContext } from "../../common/context.js"; import { createContext, getRootOpts } from "../../common/context.js"; -import { InteractiveCancelledError } from "../../common/errors.js"; +import { + InteractiveCancelledError, + invalidParameterError, +} from "../../common/errors.js"; import { initiativeChoices } from "../../common/interactive/choices.js"; import { maybeCollectInteractive } from "../../common/interactive/engine.js"; import type { PromptIO } from "../../common/interactive/types.js"; @@ -21,6 +24,9 @@ function makeInitiativePicker( ): (ctx: CommandContext, io: PromptIO) => Promise<string> { return async (ctx, io) => { const options = await initiativeChoices(ctx); + if (options.length === 0) { + throw invalidParameterError("initiative", "no initiatives are available"); + } const answer = await io.select({ message: label, options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 93eca29a..acbe66e8 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -75,9 +75,13 @@ async function updatePicker( ctx: CommandContext, io: PromptIO, ): Promise<string> { + const initiativeOptions = await initiativeChoices(ctx); + if (initiativeOptions.length === 0) { + throw invalidParameterError("initiative", "no initiatives are available"); + } const initiativeAnswer = await io.select({ message: "Initiative", - options: await initiativeChoices(ctx), + options: initiativeOptions, }); if (io.isCancel(initiativeAnswer)) { throw new InteractiveCancelledError(); @@ -93,6 +97,12 @@ async function updatePicker( label: (update.body ?? "").slice(0, 60) || update.id, ...(update.health ? { hint: String(update.health) } : {}), })); + if (options.length === 0) { + throw invalidParameterError( + "update", + "the selected initiative has no updates", + ); + } const answer = await io.select({ message: "Update", options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/commands/labels.ts b/src/commands/labels.ts index 417ab83b..0c53c818 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -111,9 +111,10 @@ export const labelCreateSpec: PromptSpec<CreateLabelWizardOptions> = { }; /** - * Interactive wizard for `labels update`. All fields optional; current option - * values seed each field so an explicit flag is never re-prompted. The label - * picker (run afterwards by the positional flow) resolves the `[label]`. + * Interactive wizard for `labels update`. All fields optional; a field already + * supplied by a flag is skipped, the rest are prompted fresh (the wizard does + * not pre-load the label's current values). The `[label]` positional is + * resolved first by the picker before these fields are prompted. */ export const labelUpdateSpec: PromptSpec<UpdateLabelWizardOptions> = { intro: "Update an issue label", @@ -157,6 +158,9 @@ function makeLabelPicker( ctx, teamId !== undefined ? { team: teamId } : {}, ); + if (options.length === 0) { + throw invalidParameterError("label", "no labels are available"); + } const answer = await io.select({ message: "Label", options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/commands/milestones.ts b/src/commands/milestones.ts index 14872ece..00c17b90 100644 --- a/src/commands/milestones.ts +++ b/src/commands/milestones.ts @@ -81,9 +81,10 @@ export const milestoneCreateSpec: PromptSpec<MilestoneCreateWizardOptions> = { }; /** - * Interactive wizard for `milestones update`. All fields optional; the current - * option values seed each field. `project` is offered first so the milestone - * picker (run afterwards by the positional flow) can scope its lookup. + * Interactive wizard for `milestones update`. All fields optional; a field + * already supplied by a flag is skipped, the rest are prompted fresh (the + * wizard does not pre-load the milestone's current values). The `[milestone]` + * positional is resolved first by the picker before these fields are prompted. */ export const milestoneUpdateSpec: PromptSpec<MilestoneCreateWizardOptions> = { intro: "Update a milestone", @@ -148,9 +149,13 @@ function makeMilestonePicker( return async (ctx, io) => { let projectId = projectHint; if (projectId === undefined) { + const projectOptions = await projectChoices(ctx); + if (projectOptions.length === 0) { + throw invalidParameterError("project", "no projects are available"); + } const projectAnswer = await io.select({ message: "Project", - options: await projectChoices(ctx), + options: projectOptions, }); if (io.isCancel(projectAnswer)) { throw new InteractiveCancelledError(); @@ -161,6 +166,12 @@ function makeMilestonePicker( } const options = await milestoneChoices(ctx, { project: projectId }); + if (options.length === 0) { + throw invalidParameterError( + "milestone", + "the selected project has no milestones", + ); + } const answer = await io.select({ message: "Milestone", options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 8e183b00..0c09b35d 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -299,9 +299,9 @@ export const projectCreateSpec: PromptSpec<CreateWizardOptions> = { }; /** - * Interactive wizard for `projects update`. All fields optional; current option - * values seed each field and prevent re-prompting for fields already provided - * by flags. + * Interactive wizard for `projects update`. All fields optional; a field + * already supplied by a flag is skipped, the rest are prompted fresh (the + * wizard does not pre-load the project's current values). */ export const projectUpdateSpec: PromptSpec<UpdateWizardOptions> = { intro: "Update a project", @@ -389,6 +389,9 @@ async function projectPicker( label: project.name, hint: project.state, })); + if (options.length === 0) { + throw invalidParameterError("project", "no projects are available"); + } const answer = await io.select({ message: "Project", options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/src/common/interactive/engine.ts b/src/common/interactive/engine.ts index aaa1301b..6ba73dfa 100644 --- a/src/common/interactive/engine.ts +++ b/src/common/interactive/engine.ts @@ -260,9 +260,10 @@ export interface MaybeCollectArgs<O extends Record<string, unknown>, T> { /** * Call-site helper. Runs {@link shouldPrompt}; when it returns false the inputs - * are returned untouched (zero change for agents/pipes). When true it runs the - * options wizard and, if a positional picker was supplied and its value is - * absent, the picker. + * are returned untouched (zero change for agents/pipes). When true it first runs + * the positional picker (if one was supplied and its value is absent) and then + * the options wizard, so an interactive user chooses *which* entity to act on + * before being prompted for its fields. */ export async function maybeCollectInteractive< O extends Record<string, unknown>, @@ -281,6 +282,15 @@ export async function maybeCollectInteractive< }; } + // Resolve the positional first so the user picks which entity to act on + // before the field wizard prompts for its values. Cancellation inside the + // picker must throw InteractiveCancelledError (same contract as the field + // engine) so it flows to outputError. + let positional = args.positional?.value; + if (args.positional && positional === undefined) { + positional = await args.positional.picker(ctx, io); + } + const filledOptions = await collectInteractive( ctx, args.spec, @@ -288,14 +298,6 @@ export async function maybeCollectInteractive< io, ); - let positional = args.positional?.value; - if (args.positional && positional === undefined) { - // Run the entity picker to fill an absent positional argument. Cancellation - // inside the picker must throw InteractiveCancelledError (same contract as - // the field engine) so it flows to outputError. - positional = await args.positional.picker(ctx, io); - } - return { options: filledOptions, positional }; } diff --git a/src/common/interactive/pickers.ts b/src/common/interactive/pickers.ts index 63086904..add6b759 100644 --- a/src/common/interactive/pickers.ts +++ b/src/common/interactive/pickers.ts @@ -1,5 +1,5 @@ import type { CommandContext } from "../context.js"; -import { InteractiveCancelledError } from "../errors.js"; +import { InteractiveCancelledError, invalidParameterError } from "../errors.js"; import type { Choice, PromptIO } from "./types.js"; /** @@ -20,6 +20,9 @@ export type ChoicePicker = ( * domains (the issue picker, the emoji picker). Cross-field pickers that first * select a parent (comment/thread, attachment, milestone, cycle) are NOT built * with this factory. + * + * Throws a clean {@link invalidParameterError} when `load` yields no options, + * since clack's `select` crashes on an empty option list. */ export function makeChoicePicker( message: string, @@ -27,6 +30,12 @@ export function makeChoicePicker( ): ChoicePicker { return async (ctx, io) => { const options = await load(ctx); + if (options.length === 0) { + throw invalidParameterError( + message.toLowerCase(), + "none are available to choose from", + ); + } const answer = await io.select({ message, options }); if (io.isCancel(answer)) { throw new InteractiveCancelledError(); diff --git a/tests/unit/interactive/content-specs.test.ts b/tests/unit/interactive/content-specs.test.ts index 9b0ef6ec..3c6ef8ae 100644 --- a/tests/unit/interactive/content-specs.test.ts +++ b/tests/unit/interactive/content-specs.test.ts @@ -52,10 +52,10 @@ describe("documentCreateSpec", () => { }); describe("documentUpdateSpec", () => { - it("has no required fields and seeds defaults from options", () => { + it("has no required fields (a flag-supplied field is skipped, the rest prompted)", () => { expect(documentUpdateSpec.fields.every((f) => !f.required)).toBe(true); const title = documentUpdateSpec.fields.find((f) => f.name === "title"); - expect(title?.default?.({ title: "cur" })).toBe("cur"); + expect(title?.kind).toBe("text"); }); }); diff --git a/tests/unit/interactive/engine.test.ts b/tests/unit/interactive/engine.test.ts index 4ad51cc9..860d7864 100644 --- a/tests/unit/interactive/engine.test.ts +++ b/tests/unit/interactive/engine.test.ts @@ -464,4 +464,40 @@ describe("maybeCollectInteractive positional picker", () => { setTTY(!!origStdin && !!origStdout); }); + + it("runs the positional picker before the field wizard", async () => { + setTTY(true); + process.env["CI"] = ""; + const calls: string[] = []; + const picker = vi.fn(async () => { + calls.push("picker"); + return "ENG-42"; + }); + const spec: PromptSpec<{ title?: string } & Record<string, unknown>> = { + fields: [{ name: "title", kind: "text", message: "Title" }], + }; + + const result = await maybeCollectInteractive< + { title?: string } & Record<string, unknown>, + string + >( + ctx, + { interactive: true }, + { + spec, + options: {}, + missingRequired: true, + positional: { name: "issue", value: undefined, picker }, + io: fakeIO({ Title: "hello" }, calls), + }, + ); + + // The user picks which entity to act on before being prompted for fields. + expect(calls).toEqual(["picker", "text:Title"]); + expect(result.positional).toBe("ENG-42"); + expect(result.options.title).toBe("hello"); + + setTTY(!!origStdin && !!origStdout); + process.env["CI"] = origCI ?? ""; + }); }); diff --git a/tests/unit/interactive/pickers.test.ts b/tests/unit/interactive/pickers.test.ts new file mode 100644 index 00000000..a7be8dd8 --- /dev/null +++ b/tests/unit/interactive/pickers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../../../src/common/context.js"; +import { InteractiveCancelledError } from "../../../src/common/errors.js"; +import { makeChoicePicker } from "../../../src/common/interactive/pickers.js"; +import type { + Choice, + PromptIO, +} from "../../../src/common/interactive/types.js"; + +const CANCEL = Symbol("cancel"); +const ctx = {} as CommandContext; + +/** Fake PromptIO whose `select` returns a scripted answer per message. */ +function fakeIO(answers: Record<string, string | symbol>): PromptIO { + const unimplemented = async () => ""; + return { + text: unimplemented, + multiline: unimplemented, + select: async (o) => answers[o.message] ?? "", + autocomplete: unimplemented, + multiselect: async () => [], + autocompleteMultiselect: async () => [], + confirm: async () => false, + date: async () => new Date(), + isCancel: (v) => v === CANCEL, + }; +} + +describe("makeChoicePicker", () => { + const choices: Choice[] = [{ value: "ENG-1", label: "ENG-1 Fix bug" }]; + + it("returns the chosen value", async () => { + const picker = makeChoicePicker("Issue", async () => choices); + const result = await picker(ctx, fakeIO({ Issue: "ENG-1" })); + expect(result).toBe("ENG-1"); + }); + + it("throws InteractiveCancelledError on cancel", async () => { + const picker = makeChoicePicker("Issue", async () => choices); + await expect(picker(ctx, fakeIO({ Issue: CANCEL }))).rejects.toBeInstanceOf( + InteractiveCancelledError, + ); + }); + + it("throws a clean error instead of rendering an empty select", async () => { + // clack's select crashes on an empty option list, so the picker must guard. + const load = vi.fn(async () => [] as Choice[]); + const picker = makeChoicePicker("Issue", load); + await expect(picker(ctx, fakeIO({}))).rejects.toThrow( + "Invalid issue: none are available to choose from", + ); + }); +});