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/.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/AGENTS.md b/AGENTS.md index 4605c8b7..311f2a6a 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`) 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 ``) 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 `` 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( + 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 @@ -209,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: diff --git a/README.md b/README.md index dd2c1027..0859f505 100644 --- a/README.md +++ b/README.md @@ -12,100 +12,95 @@ -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. + +
+ +The same task — creating an issue — from the two audiences Linearis serves. + +**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) + +
+ +## 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 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 issues list # via flag -LINEAR_API_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 usage`. - -### Discussions +### Interactive prompts -Discussions are modeled as root threads with replies, rather than a flat comment list: +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 -# Start a discussion thread on an issue -linearis issues discuss ENG-42 --body "Investigating this now" +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, or `--no-interactive` / `--compact` / `--fields` is used — so pipes and agents never hang and stdout stays pure JSON. -# List root discussion threads for an issue -linearis issues discussions ENG-42 +### Discussions -# List replies in one root thread -linearis issues replies +Discussions are modeled as root threads with replies, not a flat comment list: -# Reply to a thread (use a root discussion thread ID, not a reply ID) -linearis issues reply --body "I found the root cause" +```bash +linearis issues discuss ENG-42 --body "Investigating this now" # start a thread +linearis issues discussions ENG-42 # list root threads +linearis issues replies # list replies +linearis issues reply --body "Found the cause" # reply to a thread ``` ### Domains @@ -125,55 +120,45 @@ linearis issues reply --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 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 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 issues list # via flag +LINEAR_API_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: +
+Other harnesses -``` -/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. +
## 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. @@ -187,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 @@ +>Usinglinearis,createaLinearissueinthedatateamtitled"Backfillfct_ordersafterAirflowDAGfailure(2026-07-04)",priorityhigh,withlabeltechnical-debt.Reportthecreatedissueidentifier.linearis(Skill)Launchingskill:linearisI'lldiscoverthecommandsurface,thencreatetheissue.Bash(linearisusage)linearisv2026.4.8CLIforLinear.app(JSONoutput)domains:authissuesprojectscyclesteams(+14lines)Bash(linearisissuesusage)linearisissuesworkitemswithstatus,priority,labelscreate<title>--team<team>[--priority1-4][--labels…](+20lines)Bash(linearisteamslist){"nodes":[{"key":"DAT","name":"data"},{"key":"ENE","name":"energy"},]}Bash(linearisissuescreate"Backfillfct_ordersafterAirflowDAGfailure(2026-07-04)"--teamDAT--priority2--labelstechnical-debt){"identifier":"DAT-1219","priority":2,"state":{"name":"Triage"},"team":{"key":"DAT","name":"data"},"labels":{"nodes":[{"name":"technical-debt"}]}}CreatedDAT-1219"Backfillfct_ordersafterAirflowDAGfailure(2026-07-04)"inthedatateam,priorityHigh,labeledtechnical-debt. \ 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 @@ +CreateanewissueTeamSearch:Typetosearch…data(DAT)insightsbillinggrowthplatformmobile↑/↓toselectEnter:confirmType:tosearchTeamdataTitle_TitleBackfillfct_ordersafterAirflowDAGfailure(2026-07-04)Description[submit]dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodels+verifyrowcounts.Descriptiondbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodels+verifyrowcounts.AssigneeFabianJocksJamieColeDrewEllisRobinShawAveryHartFabianJocks(jocks@example.com)AssigneeFabianJocksPriorityUrgentHighMediumLow↑/↓tonavigateEnter:confirmNoneHighProjectNone(noproject)CycleNone(nocycle)StatusNone(teamdefault)Labelstechnical-debtrefinementIncompleteretro-doneretrounder-estimatedover-estimated↑/↓tonavigateTab:selectEnter:confirmType:tosearchSearch:technical█(1match)Labels1itemsselectedEstimateNone(noestimate)Setaduedate?Setaduedate?YesDuedate},"nodes":[]Search:d█Search:da█(1match)Search:dat█(1match)Search:data█(1match)B█Ba█Bac█Back█Backf█Backfi█Backfil█Backfill█BackfillBackfillf█Backfillfc█Backfillfct█Backfillfct_█Backfillfct_o█Backfillfct_or█Backfillfct_ord█Backfillfct_orde█Backfillfct_order█Backfillfct_orders█Backfillfct_ordersBackfillfct_ordersa█Backfillfct_ordersaf█Backfillfct_ordersaft█Backfillfct_ordersafte█Backfillfct_ordersafter█Backfillfct_ordersafterBackfillfct_ordersafterA█Backfillfct_ordersafterAi█Backfillfct_ordersafterAir█Backfillfct_ordersafterAirf█Backfillfct_ordersafterAirfl█Backfillfct_ordersafterAirflo█Backfillfct_ordersafterAirflow█Backfillfct_ordersafterAirflowBackfillfct_ordersafterAirflowD█Backfillfct_ordersafterAirflowDA█Backfillfct_ordersafterAirflowDAG█Backfillfct_ordersafterAirflowDAGBackfillfct_ordersafterAirflowDAGf█Backfillfct_ordersafterAirflowDAGfa█Backfillfct_ordersafterAirflowDAGfai█Backfillfct_ordersafterAirflowDAGfail█Backfillfct_ordersafterAirflowDAGfailu█Backfillfct_ordersafterAirflowDAGfailur█Backfillfct_ordersafterAirflowDAGfailure█Backfillfct_ordersafterAirflowDAGfailureBackfillfct_ordersafterAirflowDAGfailure(█Backfillfct_ordersafterAirflowDAGfailure(2█Backfillfct_ordersafterAirflowDAGfailure(20█Backfillfct_ordersafterAirflowDAGfailure(202█Backfillfct_ordersafterAirflowDAGfailure(2026█Backfillfct_ordersafterAirflowDAGfailure(2026-█Backfillfct_ordersafterAirflowDAGfailure(2026-0█Backfillfct_ordersafterAirflowDAGfailure(2026-07█Backfillfct_ordersafterAirflowDAGfailure(2026-07-█Backfillfct_ordersafterAirflowDAGfailure(2026-07-0█Backfillfct_ordersafterAirflowDAGfailure(2026-07-04█Backfillfct_ordersafterAirflowDAGfailure(2026-07-04)█d█db█dbt█dbt_█dbt_d█dbt_da█dbt_dai█dbt_dail█dbt_daily█dbt_dailydbt_dailyD█dbt_dailyDA█dbt_dailyDAG█dbt_dailyDAGdbt_dailyDAGf█dbt_dailyDAGfa█dbt_dailyDAGfai█dbt_dailyDAGfail█dbt_dailyDAGfaile█dbt_dailyDAGfailed█dbt_dailyDAGfaileddbt_dailyDAGfailed0█dbt_dailyDAGfailed03█dbt_dailyDAGfailed03:█dbt_dailyDAGfailed03:1█dbt_dailyDAGfailed03:12█dbt_dailyDAGfailed03:12dbt_dailyDAGfailed03:12U█dbt_dailyDAGfailed03:12UT█dbt_dailyDAGfailed03:12UTC█dbt_dailyDAGfailed03:12UTC;█dbt_dailyDAGfailed03:12UTC;dbt_dailyDAGfailed03:12UTC;f█dbt_dailyDAGfailed03:12UTC;fc█dbt_dailyDAGfailed03:12UTC;fct█dbt_dailyDAGfailed03:12UTC;fct_█dbt_dailyDAGfailed03:12UTC;fct_o█dbt_dailyDAGfailed03:12UTC;fct_or█dbt_dailyDAGfailed03:12UTC;fct_ord█dbt_dailyDAGfailed03:12UTC;fct_orde█dbt_dailyDAGfailed03:12UTC;fct_order█dbt_dailyDAGfailed03:12UTC;fct_orders█dbt_dailyDAGfailed03:12UTC;fct_ordersdbt_dailyDAGfailed03:12UTC;fct_ordersm█dbt_dailyDAGfailed03:12UTC;fct_ordersmi█dbt_dailyDAGfailed03:12UTC;fct_ordersmis█dbt_dailyDAGfailed03:12UTC;fct_ordersmiss█dbt_dailyDAGfailed03:12UTC;fct_ordersmissi█dbt_dailyDAGfailed03:12UTC;fct_ordersmissin█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing█dbt_dailyDAGfailed03:12UTC;fct_ordersmissingdbt_dailyDAGfailed03:12UTC;fct_ordersmissing2█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2d█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2da█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2day█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.R█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-r█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-ru█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-run█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-rundbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runm█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmo█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmod█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmode█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodel█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodels█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodelsdbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodels+█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodels+dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodels+v█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodels+ve█dbt_dailyDAGfailed03:12UTC;fct_ordersmissing2days.Re-runmodels+ver█veri█verif█verify█verifyverifyr█verifyro█verifyrow█verifyrowverifyrowc█verifyrowco█verifyrowcou█verifyrowcoun█verifyrowcount█verifyrowcounts█verifyrowcounts.█[submit]None(unassigned)AlexCarterJordanLeeSamRiveraMorganReedRileyQuinnCaseyBrooksCodexCursorTaylorFoxGitHubCopilotLinearQuinnDiazReeseParkacmeSearch:j█(6matches)MorganReed(reed@example.com)Search:jo█(3matches)Search:joc█(1match)Search:jock█(1match)Search:jocks█(1match)NoneUrgent(1)High(2)PriorityProjectNone(noproject)BORISNRWIntegrationBuildingPartGeometriesinConnect-APIResilientFieldTransformationConnect-APIPerformance&ReliabilityConnect-APISecurity&ArchitecturePrecompute`building_group_id`Self-ManagedAPIKeysMulti-SourceIndexReconciliationCityJSON3DGeometryDeliveryAddressSearchAPIEnhancedAddressMatchingBuildingDataPlausibilityIndexDualGeometryGroupingStrategiesRolloutIntroduce`cellar_leap`UpdateLoD22022->2025UpdateSolarThermalDataIntroduce`building_volume_ratio`to`parcels`EnhancedBuildingAddressDiscoveryHousingMarketIndicesIntegrationIntroduce`federal_state`Introduce`postcode`geometriesIntroducing`storey_height`Consistency...CycleNone(nocycle)Cycle54Cycle55Cycle56Cycle57Cycle58StatusNone(teamdefault)TriageBacklogTodoInProgressOnHoldInReviewDoneCanceledDuplicateStaleOutdatedperfgood-issueSearch:t█(7matches)Search:te█(4matches)Search:tec█(1match)Search:tech█(1match)Search:techn█(1match)Search:techni█(1match)Search:technic█(1match)Search:technica█(1match)technical-debtEstimateNone(noestimate)012358Yes/NoYes/Nomm/dd/yyyy00/dd/yyyy07/dd/yyyy07/00/yyyy07/07/yyyy07/07/207/07/2007/07/20207/07/2026Duedate07/07/2026"team":{"id":"23321c69-fe7a-44d4-8c8d-12240cb41b10","key":"DAT","name":"data""project":null,"labels":{"nodes":[{"id":"6da32660-4426-47a1-a820-a61217d8edca","name":"technical-debt"}]"cycle":null,"projectMilestone":null,"parent":null,"children":{"relations":{"inverseRelations":{"comments":{}} \ No newline at end of file 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/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/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/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 --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..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 @@ -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. diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index 90003f6d..6562a207 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -1,7 +1,18 @@ 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 { 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"; import { resolveIssueId } from "../resolvers/issue-resolver.js"; @@ -22,6 +33,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 +60,100 @@ interface CreateOptions { iconUrl?: string; } +/** Create-wizard shape: create options with an index signature. */ +type CreateWizardOptions = Partial<CreateOptions> & Record<string, unknown>; + +/** + * 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<CreateWizardOptions> = { + 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" }, + { name: "iconUrl", kind: "text", message: "Icon URL" }, + { name: "comment", kind: "multiline", message: "Comment" }, + ], +}; + +/** Entity picker for an absent `[issue]` positional (shared loader). */ +const issuePicker = makeChoicePicker("Issue", issueChoices); + +/** + * Cross-field picker for an absent attachment `<id>`. 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<string> { + 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<Record<string, never>> = { fields: [] }; + +/** Fill an absent `[issue]` positional via the issue picker when gating allows. */ +async function resolveIssuePositional( + ctx: CommandContext, + command: Command, + issue: string | undefined, +): Promise<string | undefined> { + 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 }, + }, + ); + return filled.positional; +} + +/** + * Fill an absent attachment `<id>` via {@link attachmentPicker} when gating + * allows, else preserve the old missing-argument error. + */ +async function resolveAttachmentPositional( + ctx: CommandContext, + command: Command, + id: string | undefined, +): Promise<string> { + const filled = await maybeCollectInteractive<Record<string, never>, 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 +193,17 @@ export function setupAttachmentsCommands(program: Command): void { .option("--created-before <date>", "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 +215,54 @@ export function setupAttachmentsCommands(program: Command): void { .command("create [issue]") .description("create an attachment on an issue") .option("--issue <issue>", "issue identifier (alias for positional issue)") - .requiredOption("--title <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 +278,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..73b2ce2b 100644 --- a/src/commands/comments.ts +++ b/src/commands/comments.ts @@ -1,12 +1,20 @@ 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 { 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"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -43,12 +51,147 @@ 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. + */ +const issuePicker = makeChoicePicker("Issue", issueChoices); + +/** + * 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. */ +const emojiPicker = makeChoicePicker("Reaction", async () => emojiChoices()); + +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> { + // 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, + 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 +220,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 +233,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 +254,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 +266,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 +303,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 +319,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 +362,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 +370,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 +411,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 +438,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 +449,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 +479,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 +490,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 +557,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..e7ab7d91 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 { + allCycleChoices, + 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,69 @@ 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 `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). + */ +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 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(); + } + return answer as string; + }; +} + export const CYCLES_META: DomainMeta = { name: "cycles", summary: "time-boxed iterations (sprints) per team", @@ -70,9 +142,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 +204,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/discussion-pickers.ts b/src/commands/discussion-pickers.ts new file mode 100644 index 00000000..88fcf6f1 --- /dev/null +++ b/src/commands/discussion-pickers.ts @@ -0,0 +1,303 @@ +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> { + // 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, + 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 e9392eba..adba39ac 100644 --- a/src/commands/documents.ts +++ b/src/commands/documents.ts @@ -1,7 +1,23 @@ 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, + issueChoices, + optionalChoices, + 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 +92,131 @@ 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: optionalChoices(projectChoices, "None (no project)"), + }, + { + name: "team", + kind: "select", + message: "Team", + choices: optionalChoices(teamChoices, "None (no team)"), + }, + { 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)"), + }, + ], +}; + +/** + * 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" }, + { + name: "content", + kind: "multiline", + message: "Content (markdown)", + }, + { + name: "project", + kind: "select", + message: "Project", + choices: optionalChoices(projectChoices, "Keep current"), + }, + { name: "icon", kind: "text", message: "Icon" }, + { + name: "color", + kind: "text", + message: "Icon 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); + 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(); + } + 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 +294,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 +319,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 +329,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 +358,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 +384,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 +393,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 +439,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..5b165da5 100644 --- a/src/commands/initiatives/entity.ts +++ b/src/commands/initiatives/entity.ts @@ -1,9 +1,26 @@ 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, + 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, + PromptSpec, +} from "../../common/interactive/types.js"; import { omitUndefined } from "../../common/object.js"; import { commandAction, @@ -48,6 +65,12 @@ import { unarchiveInitiative, updateInitiative, } from "../../services/initiative-service.js"; +import { + makeDiscussionPickers, + resolveDiscussionBody, + resolveEmojiPositional, + resolvePickedPositional, +} from "../discussion-pickers.js"; interface InitiativeExpandOptions { withProjects?: boolean; @@ -111,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`, @@ -190,6 +242,161 @@ 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: optionalChoices(userChoices, "None (no owner)"), + }, + { + name: "status", + kind: "select", + message: "Status", + choices: async () => + withNoneChoice(initiativeStatusChoices(), "None (default status)"), + }, + { name: "targetDate", kind: "date", message: "Target date" }, + ], +}; + +/** + * 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", + fields: [ + { + name: "name", + kind: "text", + message: "Name", + }, + { + name: "description", + kind: "multiline", + message: "Description", + }, + { + name: "content", + kind: "multiline", + message: "Content (markdown)", + }, + { + name: "owner", + kind: "select", + message: "Owner", + choices: optionalChoices(userChoices, "Keep current"), + }, + { + name: "status", + kind: "select", + message: "Status", + choices: async () => + withNoneChoice(initiativeStatusChoices(), "Keep current"), + }, + { + name: "targetDate", + kind: "date", + message: "Target date", + }, + ], +}; + +/** + * 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); + 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(); + } + 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; +} + +/** + * 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(); @@ -390,7 +597,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 +613,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,22 +634,24 @@ 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 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); @@ -446,16 +660,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"), @@ -481,19 +700,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, @@ -501,13 +727,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", ); @@ -516,28 +742,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", }); @@ -547,23 +778,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", ); @@ -574,23 +810,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", ); @@ -601,16 +842,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", ); @@ -620,16 +868,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", ); @@ -639,16 +894,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) } : {}), @@ -661,16 +923,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", ); @@ -680,7 +949,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 +958,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 +1014,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 +1024,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 +1094,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 +1113,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 +1132,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..2fcd1d58 100644 --- a/src/commands/initiatives/relations.ts +++ b/src/commands/initiatives/relations.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, + 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"; import { handleCommand, outputSuccess } from "../../common/output.js"; import { resolveInitiativeId, @@ -10,20 +18,81 @@ 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); + 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(); + } + 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 +106,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..acbe66e8 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -1,7 +1,21 @@ 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, + withNoneChoice, +} from "../../common/interactive/choices.js"; +import { maybeCollectInteractive } from "../../common/interactive/engine.js"; +import type { + Choice, + PromptIO, + PromptSpec, +} from "../../common/interactive/types.js"; import { handleCommand, outputSuccess, @@ -21,15 +35,115 @@ 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 initiativeOptions = await initiativeChoices(ctx); + if (initiativeOptions.length === 0) { + throw invalidParameterError("initiative", "no initiatives are available"); + } + const initiativeAnswer = await io.select({ + message: "Initiative", + options: initiativeOptions, + }); + 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) } : {}), + })); + 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(); + } + 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; } @@ -39,6 +153,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") @@ -49,7 +231,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 +243,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 +264,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 +283,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,18 +294,34 @@ export function setupInitiativeUpdateCommands(initiatives: Command): void { ]; const ctx = createContext(getRootOpts(command)); - const initiativeId = await resolveInitiativeId( - ctx.gql, - 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"); + } + const initiativeId = await resolveInitiativeId(ctx.gql, initiative); 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; } @@ -124,26 +332,50 @@ 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)); + // 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; } @@ -165,24 +397,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..47d98761 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,31 @@ import { parseIssueIdentifier, type UUID, } from "../common/identifier.js"; +import { + assigneeChoices, + cycleChoices, + estimateChoices, + issueChoices, + labelChoices, + milestoneChoices, + optionalChoices, + optionalProjectChoices, + priorityChoices, + projectChoices, + statusChoices, + teamChoices, + userChoices, +} from "../common/interactive/choices.js"; +import { + maybeCollectInteractive, + normalizeWizardLists, +} from "../common/interactive/engine.js"; +import { shouldPrompt } from "../common/interactive/gating.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, @@ -35,6 +63,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, @@ -86,6 +116,12 @@ import { deleteOwnReactionByEmoji, deleteOwnReactionById, } from "../services/reaction-service.js"; +import { + makeDiscussionPickers, + resolveDiscussionBody, + resolveEmojiPositional, + resolvePickedPositional, +} from "./discussion-pickers.js"; interface FilterOptions extends RawFilterFlags { limit: string; @@ -141,6 +177,294 @@ 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>; + +/** + * 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: optionalChoices(milestoneChoices, "None (no milestone)"), + }, + { + name: "cycle", + kind: "select", + message: "Cycle", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: optionalChoices(cycleChoices, "None (no cycle)"), + }, + { + name: "status", + kind: "select", + message: "Status", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: optionalChoices(statusChoices, "None (team default)"), + }, + { + 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: optionalChoices(estimateChoices, "None (no estimate)"), + }, + { + name: "dueDate", + kind: "date", + message: "Due date", + }, + ], +}; + +/** + * 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", + }, + { + name: "description", + kind: "multiline", + message: "Description", + }, + { + name: "assignee", + kind: "select", + message: "Assignee", + searchable: true, + // "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", + kind: "select", + message: "Priority", + choices: async () => priorityChoices(), + }, + { + name: "project", + kind: "select", + message: "Project", + searchable: true, + when: (draft) => draft.team !== undefined, + // "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", + kind: "select", + message: "Milestone", + searchable: true, + when: (draft) => draft.project !== undefined, + choices: optionalChoices(milestoneChoices, "Keep current"), + }, + { + name: "cycle", + kind: "select", + message: "Cycle", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: optionalChoices(cycleChoices, "Keep current"), + }, + { + name: "status", + kind: "select", + message: "Status", + searchable: true, + when: (draft) => draft.team !== undefined, + choices: optionalChoices(statusChoices, "Keep current"), + }, + { + 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: optionalChoices(estimateChoices, "Keep current"), + }, + { + name: "dueDate", + kind: "date", + message: "Due date", + }, + ], +}; + +/** + * Entity picker for an absent `<issue>` positional. Lists recent open issues + * and returns the selected issue's identifier (which the resolver accepts). + */ +const issuePicker = makeChoicePicker("Issue", issueChoices); + +/** + * 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. + */ +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: [] }; + +/** + * 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; +} + +/** + * 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]`. + */ +const { rootThreadPicker, commentOrReplyPicker, replyPicker } = + makeDiscussionPickers({ + entityKind: "issue", + entityPicker: issuePicker, + resolveEntityId: (ctx, human) => resolveIssueId(ctx.gql, human), + listThreads: listDiscussionsForIssue, + }); + interface ReadOptions { withAttachments?: boolean; withComments?: boolean; @@ -190,48 +514,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`, @@ -533,12 +887,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); @@ -548,7 +903,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)") @@ -558,10 +913,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)), @@ -583,12 +939,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); @@ -661,7 +1024,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 +1038,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)) { @@ -761,7 +1125,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( @@ -769,22 +1133,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, emoji, options, command) => { - const ctx = createContext(getRootOpts(command)); - 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( @@ -792,19 +1163,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, emoji, options, command) => { - const ctx = createContext(getRootOpts(command)); - 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 @@ -831,7 +1209,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,18 +1217,16 @@ 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 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); @@ -859,7 +1235,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("activity <issue>") + .command("activity [issue]") .description( "chronological activity timeline: comment threads plus history events", ) @@ -872,10 +1248,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), @@ -893,7 +1270,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 +1280,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"), @@ -932,19 +1310,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, @@ -952,13 +1337,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", ); @@ -967,28 +1352,33 @@ 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)); - 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: "issue", }); @@ -998,23 +1388,28 @@ 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)); - 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, }, "issue", ); @@ -1025,23 +1420,28 @@ 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)); - 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, }, "issue", ); @@ -1052,16 +1452,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", ); @@ -1071,16 +1478,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", ); @@ -1090,16 +1504,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) } : {}), @@ -1112,16 +1533,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", ); @@ -1131,7 +1559,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") @@ -1151,9 +1579,51 @@ 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 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, rootOpts, { + spec: issueCreateSpec, + options: { + ...seededOptions, + ...(title !== undefined ? { title } : {}), + } as CreateWizardOptions, + missingRequired, + }); + title = filled.options.title ?? title; + if (title === undefined) { + throw invalidParameterError("title", "is required"); + } + options = normalizeWizardLists(filled.options, ["labels"]); const relationActions = parseRelationFlags(options); @@ -1271,7 +1741,7 @@ export function setupIssuesCommands(program: Command): void { ); issues - .command("update <issue>") + .command("update [issue]") .description("update an existing issue") .addHelpText( "after", @@ -1303,8 +1773,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 = normalizeWizardLists(filled.options, ["labels"]); + if (options.parentTicket && options.clearParentTicket) { throw new Error( "Cannot use --parent-ticket and --clear-parent-ticket together", @@ -1358,11 +1861,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 +2031,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 +2046,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 +2061,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..0c53c818 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,112 @@ 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; 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", + fields: [ + { + name: "name", + kind: "text", + message: "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: "description", + kind: "multiline", + message: "Description", + }, + ], +}; + +/** + * 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 } : {}, + ); + 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(); + } + return answer as string; + }; +} + function parseLabelType(value?: string): LabelType { if (value === undefined || value === "issue" || value === "project") { return value ?? "issue"; @@ -83,12 +200,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 +262,7 @@ async function resolveIssueLabelLookup( }), ); - return { ctx, labelId }; + return { labelId }; } function buildUpdateInput(options: UpdateLabelOptions): UpdateLabelInput { @@ -230,20 +378,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 +429,7 @@ export function setupLabelsCommands(program: Command): void { ); labels - .command("read <label>") + .command("read [label]") .description("read an issue label") .option( "--team <team>", @@ -273,23 +438,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 +469,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 +512,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..00c17b90 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,136 @@ 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: "date", message: "Target date" }, + ], +}; + +/** + * 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", + fields: [ + { + name: "name", + kind: "text", + message: "Name", + }, + { + name: "description", + kind: "multiline", + message: "Description", + }, + { + name: "targetDate", + kind: "date", + message: "Target date", + }, + ], +}; + +/** 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 + * 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 projectOptions = await projectChoices(ctx); + if (projectOptions.length === 0) { + throw invalidParameterError("project", "no projects are available"); + } + const projectAnswer = await io.select({ + message: "Project", + options: projectOptions, + }); + 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 }); + 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(); + } + return answer as string; + }; +} + export const MILESTONES_META: DomainMeta = { name: "milestones", summary: "progress checkpoints within projects", @@ -67,23 +208,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, ), ); @@ -93,19 +253,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 +302,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 +356,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 +368,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..0c09b35d 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,9 +1,27 @@ 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, + optionalChoices, + priorityChoices, + projectStatusChoices, + teamChoices, + userChoices, +} from "../common/interactive/choices.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"; import { buildPaginationOptions } from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -42,6 +60,12 @@ import { unarchiveProject, updateProject, } from "../services/project-service.js"; +import { + makeDiscussionPickers, + resolveDiscussionBody, + resolveEmojiPositional, + resolvePickedPositional, +} from "./discussion-pickers.js"; interface ListOptions { limit: string; @@ -75,46 +99,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`, @@ -173,6 +226,221 @@ 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: "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: optionalChoices(userChoices, "None (no lead)"), + }, + { + name: "members", + kind: "multiselect", + message: "Members", + choices: userChoices, + }, + { + name: "priority", + kind: "select", + message: "Priority", + choices: async () => priorityChoices(), + }, + { + name: "status", + kind: "select", + message: "Status", + choices: optionalChoices(projectStatusChoices, "None (no status)"), + }, + { + name: "labels", + kind: "multiselect", + message: "Labels", + required: false, + choices: labelChoices, + }, + { name: "startDate", kind: "date", message: "Start date" }, + { name: "targetDate", kind: "date", message: "Target date" }, + ], +}; + +/** + * 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", + fields: [ + { + name: "name", + kind: "text", + message: "Name", + }, + { + 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: optionalChoices(userChoices, "Keep current"), + }, + { + name: "members", + kind: "multiselect", + message: "Members", + choices: userChoices, + }, + { + name: "priority", + kind: "select", + message: "Priority", + choices: async () => priorityChoices(), + }, + { + name: "status", + kind: "select", + message: "Status", + choices: optionalChoices(projectStatusChoices, "Keep current"), + }, + { + name: "labels", + kind: "multiselect", + message: "Labels", + required: false, + choices: labelChoices, + }, + { + name: "startDate", + kind: "date", + message: "Start date", + }, + { + name: "targetDate", + kind: "date", + message: "Target date", + }, + ], +}; + +/** + * 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, + })); + 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(); + } + 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; +} + +/** + * 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", @@ -275,7 +543,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 +556,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,22 +581,24 @@ 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 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); @@ -332,16 +607,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"), @@ -367,19 +647,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, @@ -387,13 +674,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", ); @@ -402,28 +689,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", }); @@ -433,23 +725,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", ); @@ -460,23 +757,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", ); @@ -487,16 +789,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", ); @@ -506,16 +815,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", ); @@ -525,16 +841,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) } : {}), @@ -547,16 +870,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", ); @@ -566,7 +896,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 +912,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 +1013,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 +1035,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 +1221,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 +1240,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 +1261,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..aa380dd8 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -4,7 +4,15 @@ import { 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 { 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"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -30,7 +38,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)", @@ -40,6 +50,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", @@ -111,6 +136,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. @@ -255,6 +321,120 @@ 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; +} + +/** + * 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"); @@ -281,13 +461,15 @@ 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 team = await resolveTeamPositional(ctx, command, teamArg); const teamId = await resolveTeamId(ctx.gql, team); const result = await getTeam(ctx.gql, { id: teamId }); outputSuccess(result); @@ -296,7 +478,7 @@ export function setupTeamsCommands(program: Command): void { addTeamSettingFlags( teams - .command("create <name>") + .command("create [name]") .description("create a new team") .option( "--key <key>", @@ -304,12 +486,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); @@ -319,18 +520,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; @@ -341,20 +563,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); @@ -362,21 +585,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, @@ -390,21 +615,26 @@ 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 [teamId, userId] = await Promise.all([ - resolveTeamId(ctx.gql, team), - resolveUserId(ctx.gql, options.user), - ]); + const team = await resolveTeamPositional(ctx, command, teamArg); + 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/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..68dd1f28 --- /dev/null +++ b/src/common/interactive/choices.ts @@ -0,0 +1,332 @@ +// 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]; +} + +/** + * 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) => ({ + 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}`, + })); +} + +/** + * 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, +): 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..0e83bf96 --- /dev/null +++ b/src/common/interactive/clack-io.ts @@ -0,0 +1,171 @@ +import { + autocomplete as clackAutocomplete, + autocompleteMultiselect as clackAutocompleteMultiselect, + confirm as clackConfirm, + date as clackDate, + isCancel as clackIsCancel, + multiline as clackMultiline, + multiselect as clackMultiselect, + select as clackSelect, + text as clackText, +} from "@clack/prompts"; +import type { + ConfirmPromptOptions, + DatePromptOptions, + 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 = { + 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, + 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 } + : {}), + }); + }, + + 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/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..6ba73dfa --- /dev/null +++ b/src/common/interactive/engine.ts @@ -0,0 +1,329 @@ +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: Partial<O>, + 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>; + + if (field.when && !field.when(partial)) continue; + + 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, + renderIntro, + ); + + 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, + onPrompt: () => void, +): Promise<string | string[] | boolean | symbol> { + switch (field.kind) { + case "text": { + onPrompt(); + const validate = buildTextValidate(field); + return io.text({ + message: field.message, + ...(initial !== undefined ? { initialValue: initial } : {}), + ...(validate !== undefined ? { validate } : {}), + }); + } + 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 } : {}), + ...(validate !== undefined ? { 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 ""; + onPrompt(); + 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 ""; + onPrompt(); + 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": + 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); + } + } +} + +/** + * 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 + * 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}`; +} + +/** + * 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 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>, + 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, + }; + } + + // 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, + args.options, + io, + ); + + 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; +} 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/pickers.ts b/src/common/interactive/pickers.ts new file mode 100644 index 00000000..add6b759 --- /dev/null +++ b/src/common/interactive/pickers.ts @@ -0,0 +1,45 @@ +import type { CommandContext } from "../context.js"; +import { InteractiveCancelledError, invalidParameterError } 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. + * + * 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, + load: (ctx: CommandContext) => Promise<Choice[]>, +): 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(); + } + return answer as string; + }; +} diff --git a/src/common/interactive/types.ts b/src/common/interactive/types.ts new file mode 100644 index 00000000..c4e4afb8 --- /dev/null +++ b/src/common/interactive/types.ts @@ -0,0 +1,142 @@ +import type { CommandContext } from "../context.js"; + +/** Field prompt kinds supported by the interactive engine. */ +type PromptKind = + | "text" + | "multiline" + | "select" + | "multiselect" + | "confirm" + | "date"; + +/** 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; +} + +/** + * 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>; + /** 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>; + /** Segmented date picker returning a `Date` (or a cancel `symbol`). */ + date(options: DatePromptOptions): Promise<Date | 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/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) { 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/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/choices.test.ts b/tests/unit/interactive/choices.test.ts new file mode 100644 index 00000000..b08213ce --- /dev/null +++ b/tests/unit/interactive/choices.test.ts @@ -0,0 +1,362 @@ +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 { + allCycleChoices, + cycleChoices, + emojiChoices, + initiativeChoices, + labelChoices, + milestoneChoices, + optionalChoices, + 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("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({ + 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] as [ + unknown, + { filter?: unknown }, + ]; + 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] as [ + unknown, + { filter?: unknown }, + ]; + 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] 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"]); + expect(result[0]?.hint).toBe("current"); + }); +}); + +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(); + 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..3c6ef8ae --- /dev/null +++ b/tests/unit/interactive/content-specs.test.ts @@ -0,0 +1,118 @@ +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(); + }); + + 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", () => { + 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?.kind).toBe("text"); + }); +}); + +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"); + }); + + 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)", () => { + 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..94e91578 --- /dev/null +++ b/tests/unit/interactive/coverage-sweep.test.ts @@ -0,0 +1,137 @@ +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>([ + // `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", + // 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 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 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([]); + }); + + 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(); + }); + + 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/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/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/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 new file mode 100644 index 00000000..860d7864 --- /dev/null +++ b/tests/unit/interactive/engine.test.ts @@ -0,0 +1,503 @@ +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 | 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) ?? ""; + }, + 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; + }, + date: async (o) => { + calls.push(`date:${o.message}`); + return (answers[o.message] as Date | symbol) ?? ""; + }, + 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("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, + ); + 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("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 = { + ...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"); + }); + + 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", () => { + 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); + }); + + 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/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..49854005 --- /dev/null +++ b/tests/unit/interactive/initiative-specs.test.ts @@ -0,0 +1,37 @@ +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("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/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/issue-specs.test.ts b/tests/unit/interactive/issue-specs.test.ts new file mode 100644 index 00000000..2c5381e0 --- /dev/null +++ b/tests/unit/interactive/issue-specs.test.ts @@ -0,0 +1,53 @@ +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("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 new file mode 100644 index 00000000..b65a7d02 --- /dev/null +++ b/tests/unit/interactive/label-specs.test.ts @@ -0,0 +1,50 @@ +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("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", () => { + 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..3cb0eb26 --- /dev/null +++ b/tests/unit/interactive/milestone-specs.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + milestoneCreateSpec, + milestoneListSpec, + 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("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); + }); + + 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/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", + ); + }); +}); diff --git a/tests/unit/interactive/project-specs.test.ts b/tests/unit/interactive/project-specs.test.ts new file mode 100644 index 00000000..24373e71 --- /dev/null +++ b/tests/unit/interactive/project-specs.test.ts @@ -0,0 +1,55 @@ +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"); + } + }); + + 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", () => { + it("has no required fields (all optional on update)", () => { + expect(projectUpdateSpec.fields.every((f) => !f.required)).toBe(true); + }); + + it("carries no dead default accessors (fields fill from prompts only)", () => { + for (const field of projectUpdateSpec.fields) { + expect(field.default).toBeUndefined(); + } + }); +}); 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); + } + }); +});