diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a8efd..e731060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,27 @@ This project aims to follow [Keep a Changelog](https://keepachangelog.com/en/1.1 Earlier project history may predate this file. -## [Unreleased] +## 1.2.0 + +### Added + +- **Project Stats & Analytics.** `plane stats` aggregates issues by state group, priority, assignment, and period counts. Supports `--since`/`--until` date windows, `--cycle`/`--module` scoping, `--assignee` filtering, and workspace-wide aggregation via `plane stats workspace`. Outputs human-readable summaries or structured data via `--json`/`--xml`. All aggregation is client-side with paginated issue fetches, and workspace mode skips inaccessible projects while reporting which ones were skipped. +- **Issue Data Visibility.** `plane issue get` and `plane issues list --json` now include `start_date`, `target_date`, `completed_at`, `created_at`, `updated_at`, `estimate_point`, and full label objects (with `id`, `name`, `color`). The API expand was broadened from `state` to `state,labels`. +- **Issue Attribute Writing.** `plane issue create` and `plane issue update` support new flags: `--start-date`, `--target-date` (alias `--due-date`), `--estimate`, `--cycle` (name or UUID), and `--module` (name or UUID). `--label` can now be passed multiple times for multi-label assignment. +- **Advanced Issue Filtering.** `plane issues list` supports `--no-assignee`, `--stale ` (issues not updated in N+ days), and `--cycle ` filters. +- **Cycle Lifecycle Management.** `plane cycles create`, `plane cycles update`, and `plane cycles delete` commands with date validation and name-based resolution. `plane cycles list` now shows issue stats (`total_issues`, `completed_issues`, `cancelled_issues`) and a computed cycle status (draft, upcoming, current, completed). +- **Smart Resolution.** `resolveCycle` joins `resolveModule` for name-to-UUID resolution so automation scripts stay readable. + +### Changed + +- **Archived project defaults.** Project-listing contexts now exclude archived projects by default, including interactive init selection and workspace stats aggregation. Use `--include-archived` to opt back in when needed. +- Extracted issue link, comments, and worklogs sub-commands into `src/commands/issue-sub.ts` to keep `issue.ts` under the 700-line file-size limit. + +### Validated + +- Full release gate passed via `bun run check:all`: TypeScript typecheck, Biome check, file-size gate, and coverage check all succeeded for the 1.2.0 release train. +- Full Bun test suite passed: **304 tests across 22 files** with **598 expectations**, plus coverage at **98.33% functions** and **96.57% lines**. +- 1.2.0 command surfaces now covered by tests include issue field visibility and write-path expansions, advanced issue list filters, cycle lifecycle commands, workspace/project stats aggregation, and archived-project exclusion by default with `--include-archived` opt-in for init/project-listing contexts. ## 1.1.0 @@ -46,4 +66,4 @@ Earlier project history may predate this file. - Full live test sweep completed against a real Plane instance. All core CLI workflows exercised: init (global, local, alias), project resolution, issue CRUD with rich options, comments, links, activity, cycles, modules, intake mutations, states, labels, members, and structured output. - Confirmed the CLI's project-page endpoint routes are correct; both page API surfaces (project pages and workspace wiki pages) return 404 on some deployments regardless of feature flags. - Confirmed worklogs are a Pro-plan-gated feature; the CLI returns explicit compatibility errors on non-Pro deployments. -- Added and validated first-class CLI cleanup commands for label delete and module delete. \ No newline at end of file +- Added and validated first-class CLI cleanup commands for label delete and module delete. diff --git a/README.md b/README.md index 0c9cef6..febe506 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ For path-local overrides in the current project directory: ```bash plane init --local +plane init --local --include-archived plane . init ``` @@ -58,6 +59,7 @@ environment variables > nearest .plane/config.json > ~/.config/plane/config.json The local config is discovered from the current working directory upward, so a config written at the repo root applies inside nested folders unless a deeper `.plane/config.json` overrides it. When you run `plane init --local`, the CLI also reads the project's feature flags from Plane and reports which project-scoped features are actually enabled. Cycles, modules, pages, and intake commands return explicit feature-disabled errors when the project has them turned off. +Project lists and project-selection prompts exclude archived projects by default. Add `--include-archived` to `plane init`, `plane . init`, `plane projects list`, or `plane stats ... workspace` when you intentionally want archived projects included. It also writes `.plane/project-context.json`, a machine-readable helper snapshot of the project's existing states, labels, and estimate points so agents can reuse what already exists instead of inventing duplicates. If `AGENTS.md` already exists in that directory, `plane init --local` appends a managed Plane project context section at the bottom without removing the existing content. If it does not exist, the CLI creates it. The managed section points agents at `.plane/project-context.json`, tells them to prefer the repo-local `plane` CLI for Plane work, and includes a small command pattern for clearing inherited `PLANE_*` overrides before using the local config. @@ -74,6 +76,7 @@ To persist a current project after setup: ```bash plane projects list +plane projects list --include-archived plane projects use PROJ plane projects use PROJ --local plane projects use PROJ --global @@ -94,6 +97,7 @@ Project-scoped feature availability still depends on the target Plane project. O ```bash # Projects plane projects list +plane projects list --include-archived plane projects use PROJ plane projects use PROJ --local plane projects current @@ -102,10 +106,20 @@ plane projects current plane issues list plane issues list PROJ plane issues list PROJ --state started +plane issues list PROJ --no-assignee +plane issues list PROJ --stale 7 +plane issues list PROJ --cycle "Week 14" plane issue get PROJ-29 plane issue create --title "Title" plane issue create --title "Title" PROJ +plane issue create --start-date 2025-04-01 --target-date 2025-04-14 --title "Sprint task" PROJ +plane issue create --label bug --label urgent --title "Regression" PROJ +plane issue create --cycle "Week 14" --title "Scoped task" PROJ plane issue update --state completed --priority high PROJ-29 +plane issue update --start-date 2025-04-01 --target-date 2025-04-14 PROJ-29 +plane issue update --estimate PROJ-29 +plane issue update --cycle "Week 14" PROJ-29 +plane issue update --module "Sprint 3" PROJ-29 plane issue delete PROJ-29 # Comments @@ -129,6 +143,9 @@ plane issue worklogs add --description "standup" PROJ-29 30 # Cycles plane cycles list PROJ +plane cycles create --name "Week 14" --start-date 2025-04-01 --end-date 2025-04-07 PROJ +plane cycles update --end-date 2025-04-08 PROJ "Week 14" +plane cycles delete PROJ "Week 14" plane cycles issues list PROJ CYCLE_ID plane cycles issues add PROJ CYCLE_ID PROJ-29 @@ -154,8 +171,21 @@ plane states list PROJ plane labels list PROJ plane labels delete PROJ bug plane members list + +# Stats +plane stats +plane stats PROJ +plane stats --since 2025-01-01 --until 2025-02-01 PROJ +plane stats --cycle "Sprint 1" PROJ +plane stats --module "Sprint 3" PROJ +plane stats --assignee Alice PROJ +plane stats workspace +plane stats --include-archived workspace +plane stats --since 2025-01-01 workspace --json ``` +For `plane stats`, command-specific options such as `--since`, `--until`, `--cycle`, `--module`, and `--assignee` must come before the `PROJECT` argument or the special `workspace` keyword because of `@effect/cli` parsing rules. `--json` and `--xml` still work as global output flags. Workspace aggregation skips projects that return `403` for issue listing and reports them in the output. + Project identifiers: short strings like `PROJ`, `WEB`. Issue refs: `PROJ-29`, `WEB-5`. State groups: `backlog` | `unstarted` | `started` | `completed` | `cancelled` @@ -178,6 +208,9 @@ plane cycles list PROJ --json - `plane issue update` expects flags before the issue ref, for example `plane issue update --state completed PROJ-29`. - `--description` for issue and page create or update commands is sent through to Plane as HTML in `description_html`. +- `--target-date` has an alias `--due-date` for convenience. +- `--label` can be passed multiple times to assign several labels at once. +- `--cycle` and `--module` accept either a UUID or the exact name shown by `plane cycles list` / `plane modules list`. - `plane issue link add` accepts an optional link title via `--title`. - `plane labels delete` accepts either the label UUID or the exact label name returned by `plane labels list`. - `plane modules create --lead` accepts a member display name, email, or UUID from `plane members list`. diff --git a/SKILL.md b/SKILL.md index d8374cc..ba10ce0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -3,9 +3,9 @@ name: plane-cli description: > Use when working with Plane project management via the `plane` CLI. Covers listing/creating/updating/deleting issues, managing cycles, modules, pages, - intake, comments, worklogs, links, states, labels, and members. Works with - any Plane instance (cloud or self-hosted). Supports structured --xml/--json - output for AI agents. + intake, comments, worklogs, links, states, labels, members, and project + stats/analytics. Works with any Plane instance (cloud or self-hosted). + Supports structured --xml/--json output for AI agents. --- # Plane CLI Skill Guide @@ -31,6 +31,7 @@ For path-local overrides in the current directory: ```bash plane init --local +plane init --local --include-archived plane . init ``` @@ -43,6 +44,7 @@ PLANE_* environment variables > nearest .plane/config.json > ~/.config/plane/con `plane init --local` also fetches the project's feature flags from Plane and reports which project-scoped features are actually enabled. Cycles, modules, pages, and intake commands fail with explicit feature-disabled errors when the project has them turned off. It also writes `.plane/project-context.json`, a machine-readable helper snapshot of the project's existing states, labels, and estimate points so agents can reuse current project conventions instead of creating duplicates. It also creates or updates `AGENTS.md` in that directory with a managed Plane context section at the bottom so AI agents know to read `.plane/project-context.json`, prefer the repo-local `plane` CLI, and clear inherited `PLANE_*` overrides before relying on local project config. +Project lists and project-selection prompts exclude archived projects by default. Add `--include-archived` to `plane init`, `plane . init`, `plane projects list`, or `plane stats ... workspace` when you intentionally need archived projects included. Or set environment variables (override saved config): @@ -57,6 +59,7 @@ You can also save a current project explicitly: ```bash plane projects list +plane projects list --include-archived plane projects use PROJ plane projects use PROJ --local plane projects use PROJ --global @@ -91,6 +94,7 @@ All list commands support `--xml` and `--json` flags. plane projects list --xml plane issues list PROJ --xml plane issues list PROJ --state started --xml +plane stats --json PROJ plane states list PROJ --xml plane labels list PROJ --xml plane members list --xml @@ -111,6 +115,7 @@ plane modules list PROJ --xml ```bash plane projects list +plane projects list --include-archived plane projects use PROJ plane projects use PROJ --local plane projects current @@ -130,6 +135,9 @@ plane issues list PROJ --state started plane issues list PROJ --state backlog plane issues list PROJ --assignee "Jane Doe" plane issues list PROJ --priority high +plane issues list PROJ --no-assignee +plane issues list PROJ --stale 7 +plane issues list PROJ --cycle "Week 14" plane issues list PROJ --xml ``` @@ -149,7 +157,11 @@ plane issue create --title "Issue title" PROJ plane issue create --priority high --state started --title "Fix lint pipeline" plane issue create --description '

Detailed context

' --title "Add dark mode" PROJ plane issue create --assignee "Jane Doe" --title "Onboarding bug" PROJ -plane issue create --label "bug" --title "Regression in login flow" PROJ +plane issue create --label "bug" --label "urgent" --title "Regression in login flow" PROJ +plane issue create --start-date 2025-04-01 --target-date 2025-04-14 --title "Sprint task" PROJ +plane issue create --estimate --title "Sized work" PROJ +plane issue create --cycle "Week 14" --title "Scoped to cycle" PROJ +plane issue create --module "Sprint 3" --title "Scoped to module" PROJ ``` ### Update @@ -166,6 +178,11 @@ plane issue update --description '

Updated context

' PROJ-29 plane issue update --assignee "Jane Doe" PROJ-29 plane issue update --no-assignee PROJ-29 plane issue update --label "enhancement" PROJ-29 +plane issue update --label "bug" --label "critical" PROJ-29 +plane issue update --start-date 2025-04-01 --target-date 2025-04-14 PROJ-29 +plane issue update --estimate PROJ-29 +plane issue update --cycle "Week 14" PROJ-29 +plane issue update --module "Sprint 3" PROJ-29 ``` ### Delete @@ -251,17 +268,43 @@ Members are workspace-scoped. This command does not take a project argument. --- +## Stats (analytics) + +```bash +plane stats +plane stats PROJ +plane stats --since 2025-01-01 --until 2025-02-01 PROJ +plane stats --cycle "Sprint 1" PROJ +plane stats --module "Sprint 3" PROJ +plane stats --assignee Alice PROJ +plane stats workspace +plane stats --include-archived workspace +plane stats --since 2025-01-01 workspace --json +plane stats workspace --xml +``` + +Aggregates issues client-side by state group, priority, assignment, and period counts. Supports `--since`/`--until` date filtering, cycle/module scoping, assignee filtering, and the special `workspace` target for cross-project totals. Workspace stats exclude archived projects by default; add `--include-archived` to opt in. `--json` returns a structured object with `total_issues`, `by_state_group`, `by_priority`, `created_in_range`, `completed_in_range`, `assigned`, and `unassigned` counts. + +For `plane stats`, command-specific options must come before `PROJ` or `workspace` because of `@effect/cli` parsing rules. Workspace aggregation skips projects that return `403` for issue listing and reports them in the output. + +--- + ## Cycles (sprints) ```bash plane cycles list plane cycles list PROJ plane cycles list PROJ --xml +plane cycles create --name "Week 14" --start-date 2025-04-01 --end-date 2025-04-07 PROJ +plane cycles update --end-date 2025-04-08 PROJ "Week 14" +plane cycles delete PROJ "Week 14" plane cycles issues list PROJ plane cycles issues add PROJ PROJ-29 ``` Cycle IDs are UUIDs. Fetch them from `plane cycles list PROJ`. +Cycle create/update/delete accept cycle names for convenience — the CLI resolves names to UUIDs internally. +`plane cycles list --json` includes `total_issues`, `completed_issues`, and `cancelled_issues` counts plus a computed status (draft, upcoming, current, completed). --- @@ -328,8 +371,14 @@ Some deployments do not expose page endpoints even when the project advertises p | `state_detail` | Always null — ignore | | `priority` | `urgent`, `high`, `medium`, `low`, `none` | | `assignees` | Array of user UUIDs | +| `labels` | Array of label objects (`id`, `name`, `color`) | | `label_ids` | Array of label UUIDs | -| `due_date` | null or ISO date string | +| `start_date` | null or ISO date string | +| `target_date` | null or ISO date string | +| `completed_at` | null or ISO timestamp (when issue moved to completed state group) | +| `created_at` | ISO timestamp | +| `updated_at` | ISO timestamp | +| `estimate_point` | null or estimate value | --- @@ -338,6 +387,9 @@ Some deployments do not expose page endpoints even when the project advertises p - No server-side text search — fetch all issues and filter locally. - No epics — use labels or modules to group related issues. - `description` in issue or page create and update flows is passed through to `description_html`; send HTML such as `

Details

` when you want formatted output. +- `--target-date` has an alias `--due-date` for convenience. +- `--label` can be specified multiple times for multi-label assignment. +- `--cycle` and `--module` accept either a UUID or the exact name listed by `plane cycles list` / `plane modules list`. The CLI resolves names internally. - `plane modules create --lead` accepts a member display name, email, or UUID from `plane members list`. - `plane modules create --status in_progress` is normalized to Plane's `in-progress` API value. - Always fetch state/label/member IDs live — never hardcode UUIDs across workspaces. diff --git a/package.json b/package.json index 24db4e6..5fbd05f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.1.0", + "version": "1.2.0", "description": "CLI for the Plane project management API", "author": "Gabriel Reynold and Contributors", "license": "MIT", diff --git a/src/api.ts b/src/api.ts index 7212f50..5bff0ec 100644 --- a/src/api.ts +++ b/src/api.ts @@ -19,9 +19,11 @@ function request( ); let url = `${host}/api/v1/workspaces/${workspace}/${path}`; - // Always expand state on issue list/get calls (not intake-issues/ or cycle-issues/) - if (method === "GET" && /(?:^|\/)(issues\/)/.test(path)) { - url += url.includes("?") ? "&expand=state" : "?expand=state"; + // Always expand state and labels on issue list/get calls (not intake-issues/ or cycle-issues/) + if (method === "GET" && /(?:^|\/)(?:issues\/)/.test(path)) { + url += url.includes("?") + ? "&expand=state,labels" + : "?expand=state,labels"; } const headers: Record = { diff --git a/src/app.ts b/src/app.ts index 1fae5dc..77ee3b3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import { modules } from "./commands/modules.js"; import { pages } from "./commands/pages.js"; import { projects } from "./commands/projects.js"; import { states } from "./commands/states.js"; +import { stats } from "./commands/stats.js"; const plane = Command.make("plane").pipe( Command.withDescription( @@ -52,14 +53,19 @@ ALL SUBCOMMANDS init Set up global or local config interactively . local init projects list | current | use - issues list List issues (supports --state, --assignee, --priority) + issues list List issues (supports --state, --assignee, --priority, + --no-assignee, --stale, --cycle) issue get | create | update | delete | comment | activity | link | comments | worklogs - cycles list | issues (list, add) + create/update support --start-date, --target-date, + --estimate, --cycle, --module, --label (repeatable) + cycles list | create | update | delete | issues (list, add) modules list | create | delete | issues (list, add, remove) intake list | accept | reject pages list | get | create | update | delete | archive | unarchive | lock | unlock | duplicate states list List workflow states for a project + stats Aggregated issue statistics with period counts; use + 'workspace' for cross-project totals labels list | create | delete members list List workspace members @@ -72,6 +78,7 @@ FOR AI AGENTS / BOTS - Use PLANE_WORKSPACE to select the workspace - Use PLANE_PROJECT or 'plane projects use PROJ' to persist a current project - Local config lives in '.plane/config.json' and is resolved from the current directory upward + - Project-listing contexts exclude archived projects by default; add '--include-archived' where supported to include them - 'plane init --local' also writes '.plane/project-context.json' with existing states, labels, and estimate points for the selected project - 'plane init --local' also creates or updates 'AGENTS.md' so local AI agents reuse '.plane/project-context.json' for project-specific context - Full Plane REST API reference (180+ endpoints): @@ -84,6 +91,7 @@ FOR AI AGENTS / BOTS issues, issue, states, + stats, labels, members, cycles, @@ -95,5 +103,5 @@ FOR AI AGENTS / BOTS export const cli = Command.run(plane, { name: "plane", - version: "1.1.0", + version: "1.2.0", }); diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index e1ec34a..e80ad34 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -1,12 +1,17 @@ -import { Args, Command } from "@effect/cli"; -import { Console, Effect } from "effect"; +import { Args, Command, Options } from "@effect/cli"; +import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; -import { CycleIssuesResponseSchema, CyclesResponseSchema } from "../config.js"; +import { + CycleIssuesResponseSchema, + CycleSchema, + CyclesResponseSchema, +} from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; import { findIssueBySeq, parseIssueRef, requireProjectFeature, + resolveCycle, resolveProject, } from "../resolve.js"; @@ -22,6 +27,72 @@ const cycleIdArg = Args.text({ name: "cycle-id" }).pipe( Args.withDescription("Cycle UUID (from 'plane cycles list PROJECT')"), ); +// --- shared options --- + +const cycleNameOption = Options.text("name").pipe( + Options.withDescription("Cycle name"), +); + +const cycleStartDateOption = Options.optional(Options.text("start-date")).pipe( + Options.withDescription("Cycle start date in YYYY-MM-DD format"), +); + +const cycleEndDateOption = Options.optional(Options.text("end-date")).pipe( + Options.withDescription("Cycle end date in YYYY-MM-DD format"), +); + +const cycleArg = Args.text({ name: "cycle" }).pipe( + Args.withDescription( + "Cycle UUID or exact name (from 'plane cycles list PROJECT')", + ), +); + +interface CyclePayload { + name?: string; + start_date?: string; + end_date?: string; + project_id?: string; +} + +function validateCycleDateInput( + value: string, + flagName: string, +): Effect.Effect { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return Effect.fail( + new Error(`${flagName} must be a valid date in YYYY-MM-DD format`), + ); + } + const [year, month, day] = value.split("-").map(Number); + const parsed = new Date(Date.UTC(year, month - 1, day)); + const isValid = + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day; + if (!isValid) { + return Effect.fail( + new Error(`${flagName} must be a valid date in YYYY-MM-DD format`), + ); + } + return Effect.succeed(value); +} + +function computeCycleStatus( + startDate: string | null | undefined, + endDate: string | null | undefined, +): string { + if (!startDate || !endDate) return "draft"; + const now = new Date(); + const today = new Date( + Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()), + ); + const start = new Date(`${startDate}T00:00:00Z`); + const end = new Date(`${endDate}T00:00:00Z`); + if (today < start) return "upcoming"; + if (today > end) return "completed"; + return "current"; +} + // --- cycles list --- export function cyclesListHandler({ project }: { project: string }) { @@ -45,8 +116,13 @@ export function cyclesListHandler({ project }: { project: string }) { const lines = results.map((c) => { const start = c.start_date ?? "—"; const end = c.end_date ?? "—"; - const status = (c.status ?? "?").padEnd(10); - return `${c.id} ${status} ${start} → ${end} ${c.name}`; + const status = ( + c.status ?? computeCycleStatus(c.start_date, c.end_date) + ).padEnd(10); + const total = c.total_issues ?? 0; + const done = c.completed_issues ?? 0; + const stats = `[${done}/${total}]`.padEnd(8); + return `${c.id} ${status} ${stats} ${start} → ${end} ${c.name}`; }); yield* Console.log(lines.join("\n")); }); @@ -58,7 +134,147 @@ export const cyclesList = Command.make( cyclesListHandler, ).pipe( Command.withDescription( - "List cycles for a project. Shows cycle UUID, status, date range, and name. Omit PROJECT to use the saved current project.\n\nExample:\n plane cycles list PROJ", + "List cycles for a project. Shows cycle UUID, status, progress, date range, and name. Omit PROJECT to use the saved current project.\n\nExample:\n plane cycles list PROJ", + ), +); + +// --- cycles create --- + +export function cyclesCreateHandler({ + project, + name, + startDate, + endDate, +}: { + project: string; + name: string; + startDate: Option.Option; + endDate: Option.Option; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "cycle_view"); + const body: CyclePayload = { name, project_id: id }; + if (Option.isSome(startDate)) { + body.start_date = yield* validateCycleDateInput( + startDate.value, + "--start-date", + ); + } + if (Option.isSome(endDate)) { + body.end_date = yield* validateCycleDateInput( + endDate.value, + "--end-date", + ); + } + const raw = yield* api.post(`projects/${id}/cycles/`, body); + const cycle = yield* decodeOrFail(CycleSchema, raw); + yield* Console.log(`Created cycle: ${cycle.name} (${cycle.id})`); + }); +} + +export const cyclesCreate = Command.make( + "create", + { + name: cycleNameOption, + startDate: cycleStartDateOption, + endDate: cycleEndDateOption, + project: listProjectArg, + }, + cyclesCreateHandler, +).pipe( + Command.withDescription( + 'Create a new cycle in a project. Omit PROJECT to use the saved current project.\n\nExamples:\n plane cycles create --name "Sprint 5"\n plane cycles create --name "Sprint 5" --start-date 2025-04-01 --end-date 2025-04-14 PROJ', + ), +); + +// --- cycles update --- + +const cycleUpdateNameOption = Options.optional(Options.text("name")).pipe( + Options.withDescription("New cycle name"), +); + +export function cyclesUpdateHandler({ + project, + cycle, + name, + startDate, + endDate, +}: { + project: string; + cycle: string; + name: Option.Option; + startDate: Option.Option; + endDate: Option.Option; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "cycle_view"); + const resolved = yield* resolveCycle(id, cycle); + const body: CyclePayload = {}; + if (Option.isSome(name)) body.name = name.value; + if (Option.isSome(startDate)) { + body.start_date = yield* validateCycleDateInput( + startDate.value, + "--start-date", + ); + } + if (Option.isSome(endDate)) { + body.end_date = yield* validateCycleDateInput( + endDate.value, + "--end-date", + ); + } + if (Object.keys(body).length === 0) { + yield* Console.log("Nothing to update"); + return; + } + yield* api.patch(`projects/${id}/cycles/${resolved.id}/`, body); + yield* Console.log(`Updated cycle: ${resolved.name} (${resolved.id})`); + }); +} + +export const cyclesUpdate = Command.make( + "update", + { + name: cycleUpdateNameOption, + startDate: cycleStartDateOption, + endDate: cycleEndDateOption, + project: projectArg, + cycle: cycleArg, + }, + cyclesUpdateHandler, +).pipe( + Command.withDescription( + 'Update a cycle by UUID or exact name.\n\nExamples:\n plane cycles update --name "Sprint 5b" PROJ "Sprint 5"\n plane cycles update --end-date 2025-04-15 PROJ ', + ), +); + +// --- cycles delete --- + +export function cyclesDeleteHandler({ + project, + cycle, +}: { + project: string; + cycle: string; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "cycle_view"); + const resolved = yield* resolveCycle(id, cycle); + yield* api.delete(`projects/${id}/cycles/${resolved.id}/`); + yield* Console.log(`Deleted cycle: ${resolved.name} (${resolved.id})`); + }); +} + +export const cyclesDelete = Command.make( + "delete", + { project: projectArg, cycle: cycleArg }, + cyclesDeleteHandler, +).pipe( + Command.withDescription( + "Delete a cycle by UUID or exact name.\n\nExample:\n plane cycles delete PROJ ", ), ); @@ -165,7 +381,13 @@ export const cycleIssues = Command.make("issues").pipe( export const cycles = Command.make("cycles").pipe( Command.withDescription( - "Manage cycles (sprints). Subcommands: list, issues\n\nExamples:\n plane cycles list PROJ\n plane cycles issues list PROJ \n plane cycles issues add PROJ PROJ-29", + 'Manage cycles (sprints). Subcommands: list, create, update, delete, issues\n\nExamples:\n plane cycles list PROJ\n plane cycles create --name "Sprint 5" --start-date 2025-04-01 --end-date 2025-04-14\n plane cycles update --name "Sprint 5b" PROJ "Sprint 5"\n plane cycles delete PROJ \n plane cycles issues list PROJ \n plane cycles issues add PROJ PROJ-29', ), - Command.withSubcommands([cyclesList, cycleIssues]), + Command.withSubcommands([ + cyclesList, + cyclesCreate, + cyclesUpdate, + cyclesDelete, + cycleIssues, + ]), ); diff --git a/src/commands/init.ts b/src/commands/init.ts index 8356818..7572019 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -5,6 +5,7 @@ import { decodeOrFail } from "../api.js"; import { EstimatePointsResponseSchema, EstimateSchema, + isProjectArchived, isProjectIntakeEnabled, LabelsResponseSchema, ProjectDetailSchema, @@ -178,16 +179,36 @@ const localOption = Options.boolean("local").pipe( Options.withDefault(false), ); -function fetchProjectsForConfig(config: { - host: string; - workspace: string; - token: string; -}) { +const includeArchivedOption = Options.boolean("include-archived").pipe( + Options.withDescription( + "Include archived projects in interactive project lists", + ), + Options.withDefault(false), +); + +function fetchProjectsForConfig( + config: { + host: string; + workspace: string; + token: string; + }, + { + includeArchived = false, + }: { + includeArchived?: boolean; + } = {}, +) { return fetchDecodedFromConfig( ProjectsResponseSchema, config, "projects/", - ).pipe(Effect.map(({ results }) => results)); + ).pipe( + Effect.map(({ results }) => + includeArchived + ? results + : results.filter((project) => !isProjectArchived(project)), + ), + ); } function requestJsonFromConfig( @@ -263,19 +284,28 @@ function fetchLocalProjectHelperForConfig( `projects/${project.id}/labels/`, ); - let estimate = null; + let estimate: import("../config.js").Estimate | null = null; let estimatePoints: readonly import("../config.js").EstimatePoint[] = []; if (detail.estimate) { - estimate = yield* fetchDecodedFromConfig( - EstimateSchema, - config, - `projects/${project.id}/estimates/`, - ); - estimatePoints = yield* fetchDecodedFromConfig( - EstimatePointsResponseSchema, - config, - `projects/${project.id}/estimates/${estimate.id}/estimate-points/`, + const estimateResult = yield* Effect.either( + Effect.gen(function* () { + const est = yield* fetchDecodedFromConfig( + EstimateSchema, + config, + `projects/${project.id}/estimates/`, + ); + const pts = yield* fetchDecodedFromConfig( + EstimatePointsResponseSchema, + config, + `projects/${project.id}/estimates/${est.id}/estimate-points/`, + ); + return { est, pts }; + }), ); + if (estimateResult._tag === "Right") { + estimate = estimateResult.right.est; + estimatePoints = estimateResult.right.pts; + } } return { @@ -310,7 +340,11 @@ function summarizeProjectFeatures(project: { } export function initHandler( - { global, local }: { global: boolean; local: boolean }, + { + global, + local, + includeArchived, + }: { global: boolean; local: boolean; includeArchived?: boolean }, defaultScope: ConfigScope = "global", ) { return Effect.gen(function* () { @@ -336,7 +370,12 @@ export function initHandler( let mergedWorkspace = ""; let mergedToken = ""; let projectsResult: import("effect").Either.Either< - ReadonlyArray<{ id: string; identifier: string; name: string }>, + ReadonlyArray<{ + id: string; + identifier: string; + name: string; + archived_at?: string | null; + }>, Error >; @@ -399,11 +438,14 @@ export function initHandler( } projectsResult = yield* Effect.either( - fetchProjectsForConfig({ - host: normalizedHost, - workspace: mergedWorkspace, - token: mergedToken, - }), + fetchProjectsForConfig( + { + host: normalizedHost, + workspace: mergedWorkspace, + token: mergedToken, + }, + { includeArchived: includeArchived ?? false }, + ), ); if (projectsResult._tag === "Right" && projectsResult.right.length > 0) { yield* Console.log("\nAvailable projects:"); @@ -411,7 +453,7 @@ export function initHandler( projectsResult.right .map( (project, index) => - `${index + 1}. ${project.identifier} ${project.name}`, + `${index + 1}. ${project.identifier} ${project.name}${isProjectArchived(project) ? " (archived)" : ""}`, ) .join("\n"), ); @@ -588,18 +630,24 @@ export function initHandler( export const init = Command.make( "init", - { global: globalOption, local: localOption }, + { + global: globalOption, + local: localOption, + includeArchived: includeArchivedOption, + }, (options) => initHandler(options, "global"), ).pipe( Command.withDescription( - "Interactive setup. Defaults to global config, supports --global/-g and --local/-l, and can save an optional current-project override.", + "Interactive setup. Defaults to global config, supports --global/-g and --local/-l, and can save an optional current-project override. Project selection excludes archived projects by default; add --include-archived to include them.", ), ); -export const localInit = Command.make("init", {}, () => - initHandler({ global: false, local: true }, "local"), +export const localInit = Command.make( + "init", + { includeArchived: includeArchivedOption }, + (options) => initHandler({ global: false, local: true, ...options }, "local"), ).pipe( Command.withDescription( - "Interactive local setup. Saves overrides to ./.plane/config.json in the current directory, reports project feature flags, writes a local project helper snapshot for states, labels, and estimate points, updates AGENTS.md with project-context guidance for AI agents, and optionally imports the SKILL.md CLI usage guide into AGENTS.md.", + "Interactive local setup. Saves overrides to ./.plane/config.json in the current directory, reports project feature flags, writes a local project helper snapshot for states, labels, and estimate points, updates AGENTS.md with project-context guidance for AI agents, and optionally imports the SKILL.md CLI usage guide into AGENTS.md. Project selection excludes archived projects by default; add --include-archived to include them.", ), ); diff --git a/src/commands/issue-sub.ts b/src/commands/issue-sub.ts new file mode 100644 index 0000000..058cea8 --- /dev/null +++ b/src/commands/issue-sub.ts @@ -0,0 +1,351 @@ +import { Args, Command, Options } from "@effect/cli"; +import { Console, Effect, Option } from "effect"; +import { api, decodeOrFail } from "../api.js"; +import { + CommentsResponseSchema, + IssueLinkSchema, + IssueLinksResponseSchema, + WorklogSchema, + WorklogsResponseSchema, +} from "../config.js"; +import { escapeHtmlText } from "../format.js"; +import { + issueLinkPaths, + issueWorklogPaths, + requestWithFallback, + type WorklogPayload, +} from "../issue-support.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { findIssueBySeq, parseIssueRef } from "../resolve.js"; + +const refArg = Args.text({ name: "ref" }).pipe( + Args.withDescription("Issue reference, e.g. PROJ-29"), +); +const textArg = Args.text({ name: "text" }).pipe( + Args.withDescription("Comment text to add"), +); + +// --- issue link list --- +export function issueLinkListHandler({ ref }: { ref: string }) { + return Effect.gen(function* () { + const { projectId, seq } = yield* parseIssueRef(ref); + const issue = yield* findIssueBySeq(projectId, seq); + const raw = yield* requestWithFallback( + issueLinkPaths(projectId, issue.id), + (path) => api.get(path), + `Issue links are not available for ${ref} on this Plane instance or API version.`, + ); + const { results } = yield* decodeOrFail(IssueLinksResponseSchema, raw); + if (jsonMode) { + yield* Console.log(JSON.stringify(results, null, 2)); + return; + } + if (xmlMode) { + yield* Console.log(toXml(results)); + return; + } + if (results.length === 0) { + yield* Console.log("No links"); + return; + } + const lines = results.map( + (l) => `${l.id} ${l.title ?? "(no title)"} ${l.url}`, + ); + yield* Console.log(lines.join("\n")); + }); +} + +export const issueLinkList = Command.make( + "list", + { ref: refArg }, + issueLinkListHandler, +).pipe(Command.withDescription("List URL links attached to an issue.")); + +// --- issue link add --- +const urlArg = Args.text({ name: "url" }).pipe( + Args.withDescription("URL to link"), +); +const linkTitleOption = Options.optional(Options.text("title")).pipe( + Options.withDescription("Human-readable title for the link"), +); + +export function issueLinkAddHandler({ + ref, + url, + title, +}: { + ref: string; + url: string; + title: Option.Option; +}) { + return Effect.gen(function* () { + const { projectId, seq } = yield* parseIssueRef(ref); + const issue = yield* findIssueBySeq(projectId, seq); + const body: Record = { url }; + if (Option.isSome(title)) body.title = title.value; + const raw = yield* requestWithFallback( + issueLinkPaths(projectId, issue.id), + (path) => api.post(path, body), + `Issue links are not available for ${ref} on this Plane instance or API version.`, + ); + const link = yield* decodeOrFail(IssueLinkSchema, raw); + yield* Console.log(`Link added: ${link.id} ${link.url}`); + }); +} + +export const issueLinkAdd = Command.make( + "add", + { title: linkTitleOption, ref: refArg, url: urlArg }, + issueLinkAddHandler, +).pipe( + Command.withDescription( + 'Attach a URL link to an issue.\n\nExamples:\n plane issue link add PROJ-29 https://github.com/org/repo/pull/42\n plane issue link add --title "Design doc" PROJ-29 https://docs.example.com', + ), +); + +// --- issue link remove --- +const linkIdArg = Args.text({ name: "link-id" }).pipe( + Args.withDescription("Link ID (from 'plane issue link list')"), +); + +export function issueLinkRemoveHandler({ + ref, + linkId, +}: { + ref: string; + linkId: string; +}) { + return Effect.gen(function* () { + const { projectId, seq } = yield* parseIssueRef(ref); + const issue = yield* findIssueBySeq(projectId, seq); + const basePath = yield* requestWithFallback( + issueLinkPaths(projectId, issue.id), + (path) => api.get(path).pipe(Effect.as(path)), + `Issue links are not available for ${ref} on this Plane instance or API version.`, + ); + yield* api.delete(`${basePath}${linkId}/`); + yield* Console.log(`Link ${linkId} removed from ${ref}`); + }); +} + +export const issueLinkRemove = Command.make( + "remove", + { ref: refArg, linkId: linkIdArg }, + issueLinkRemoveHandler, +).pipe(Command.withDescription("Remove a URL link from an issue by link ID.")); + +// --- issue link (parent) --- +export const issueLink = Command.make("link").pipe( + Command.withDescription( + "Manage URL links on an issue. Subcommands: list, add, remove", + ), + Command.withSubcommands([issueLinkList, issueLinkAdd, issueLinkRemove]), +); + +// --- issue comments list --- +export function issueCommentsListHandler({ ref }: { ref: string }) { + return Effect.gen(function* () { + const { projectId, seq } = yield* parseIssueRef(ref); + const issue = yield* findIssueBySeq(projectId, seq); + const raw = yield* api.get( + `projects/${projectId}/issues/${issue.id}/comments/`, + ); + const { results } = yield* decodeOrFail(CommentsResponseSchema, raw); + if (jsonMode) { + yield* Console.log(JSON.stringify(results, null, 2)); + return; + } + if (xmlMode) { + yield* Console.log(toXml(results)); + return; + } + if (results.length === 0) { + yield* Console.log("No comments"); + return; + } + const lines = results.map((c) => { + const who = c.actor_detail?.display_name ?? "?"; + const when = c.created_at.slice(0, 16).replace("T", " "); + const text = (c.comment_html ?? "").replace(/<[^>]+>/g, "").trim(); + return `${c.id} ${when} ${who}: ${text}`; + }); + yield* Console.log(lines.join("\n")); + }); +} + +export const issueCommentsList = Command.make( + "list", + { ref: refArg }, + issueCommentsListHandler, +).pipe( + Command.withDescription( + "List comments on an issue. Shows comment ID, timestamp, author, and plain text.\n\nExample:\n plane issue comments list PROJ-29", + ), +); + +// --- issue comment update --- +const commentIdArg = Args.text({ name: "comment-id" }).pipe( + Args.withDescription("Comment ID (from 'plane issue comments list')"), +); + +export function issueCommentUpdateHandler({ + ref, + commentId, + text, +}: { + ref: string; + commentId: string; + text: string; +}) { + return Effect.gen(function* () { + const { projectId, seq } = yield* parseIssueRef(ref); + const issue = yield* findIssueBySeq(projectId, seq); + const escaped = escapeHtmlText(text); + yield* api.patch( + `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`, + { comment_html: `

${escaped}

` }, + ); + yield* Console.log(`Comment ${commentId} updated`); + }); +} + +export const issueCommentUpdate = Command.make( + "update", + { ref: refArg, commentId: commentIdArg, text: textArg }, + issueCommentUpdateHandler, +).pipe( + Command.withDescription( + 'Edit an existing comment.\n\nExample:\n plane issue comments update PROJ-29 "Updated text"', + ), +); + +// --- issue comment delete --- +export function issueCommentDeleteHandler({ + ref, + commentId, +}: { + ref: string; + commentId: string; +}) { + return Effect.gen(function* () { + const { projectId, seq } = yield* parseIssueRef(ref); + const issue = yield* findIssueBySeq(projectId, seq); + yield* api.delete( + `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`, + ); + yield* Console.log(`Comment ${commentId} deleted`); + }); +} + +export const issueCommentDelete = Command.make( + "delete", + { ref: refArg, commentId: commentIdArg }, + issueCommentDeleteHandler, +).pipe(Command.withDescription("Delete a comment from an issue.")); + +// --- issue comments (parent) --- +export const issueComments = Command.make("comments").pipe( + Command.withDescription( + "Manage comments on an issue. Subcommands: list, update, delete\n\nNote: use 'plane issue comment REF TEXT' to add a new comment.", + ), + Command.withSubcommands([ + issueCommentsList, + issueCommentUpdate, + issueCommentDelete, + ]), +); + +// --- issue worklogs list --- +export function issueWorklogsListHandler({ ref }: { ref: string }) { + return Effect.gen(function* () { + const { projectId, seq } = yield* parseIssueRef(ref); + const issue = yield* findIssueBySeq(projectId, seq); + const raw = yield* requestWithFallback( + issueWorklogPaths(projectId, issue.id), + (path) => api.get(path), + `Issue worklogs are not available for ${ref} on this Plane instance or API version.`, + ); + const { results } = yield* decodeOrFail(WorklogsResponseSchema, raw); + if (jsonMode) { + yield* Console.log(JSON.stringify(results, null, 2)); + return; + } + if (xmlMode) { + yield* Console.log(toXml(results)); + return; + } + if (results.length === 0) { + yield* Console.log("No worklogs"); + return; + } + const lines = results.map((w) => { + const who = w.logged_by_detail?.display_name ?? "?"; + const when = w.created_at.slice(0, 10); + const hrs = (w.duration / 60).toFixed(1); + const desc = w.description ?? ""; + return `${w.id} ${when} ${who} ${hrs}h ${desc}`; + }); + yield* Console.log(lines.join("\n")); + }); +} + +export const issueWorklogsList = Command.make( + "list", + { ref: refArg }, + issueWorklogsListHandler, +).pipe( + Command.withDescription( + "List time log entries for an issue. Duration shown in hours.\n\nExample:\n plane issue worklogs list PROJ-29", + ), +); + +// --- issue worklogs add --- +const durationArg = Args.integer({ name: "minutes" }).pipe( + Args.withDescription("Time spent in minutes"), +); +const worklogDescOption = Options.optional(Options.text("description")).pipe( + Options.withDescription("Optional description of work done"), +); + +export function issueWorklogsAddHandler({ + ref, + duration, + description, +}: { + ref: string; + duration: number; + description: Option.Option; +}) { + return Effect.gen(function* () { + const { projectId, seq } = yield* parseIssueRef(ref); + const issue = yield* findIssueBySeq(projectId, seq); + const body: WorklogPayload = { duration }; + if (Option.isSome(description)) body.description = description.value; + const raw = yield* requestWithFallback( + issueWorklogPaths(projectId, issue.id), + (path) => api.post(path, body), + `Issue worklogs are not available for ${ref} on this Plane instance or API version.`, + ); + const log = yield* decodeOrFail(WorklogSchema, raw); + const hrs = (log.duration / 60).toFixed(1); + yield* Console.log(`Logged ${hrs}h on ${ref} (${log.id})`); + }); +} + +export const issueWorklogsAdd = Command.make( + "add", + { description: worklogDescOption, ref: refArg, duration: durationArg }, + issueWorklogsAddHandler, +).pipe( + Command.withDescription( + 'Log time spent on an issue (duration in minutes).\n\nExamples:\n plane issue worklogs add PROJ-29 90\n plane issue worklogs add --description "code review" PROJ-29 30', + ), +); + +// --- issue worklogs (parent) --- +export const issueWorklogs = Command.make("worklogs").pipe( + Command.withDescription( + "Manage time logs for an issue. Subcommands: list, add", + ), + Command.withSubcommands([issueWorklogsList, issueWorklogsAdd]), +); diff --git a/src/commands/issue.ts b/src/commands/issue.ts index eb1c52c..46cd315 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,23 +1,11 @@ import { Args, Command, Options } from "@effect/cli"; import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; -import { - ActivitiesResponseSchema, - CommentsResponseSchema, - IssueLinkSchema, - IssueLinksResponseSchema, - IssueSchema, - WorklogSchema, - WorklogsResponseSchema, -} from "../config.js"; +import { ActivitiesResponseSchema, IssueSchema } from "../config.js"; import { escapeHtmlText } from "../format.js"; -import { - type IssueCreatePayload, - type IssueUpdatePayload, - issueLinkPaths, - issueWorklogPaths, - requestWithFallback, - type WorklogPayload, +import type { + IssueCreatePayload, + IssueUpdatePayload, } from "../issue-support.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; import { @@ -26,8 +14,26 @@ import { getMemberId, getStateId, parseIssueRef, + requireProjectFeature, + resolveCycle, + resolveModule, resolveProject, } from "../resolve.js"; +import { issueComments, issueLink, issueWorklogs } from "./issue-sub.js"; + +export { + issueCommentDeleteHandler, + issueComments, + issueCommentsListHandler, + issueCommentUpdateHandler, + issueLink, + issueLinkAddHandler, + issueLinkListHandler, + issueLinkRemoveHandler, + issueWorklogs, + issueWorklogsAddHandler, + issueWorklogsListHandler, +} from "./issue-sub.js"; const refArg = Args.text({ name: "ref" }).pipe( Args.withDescription("Issue reference, e.g. PROJ-29"), @@ -71,8 +77,8 @@ const assigneeOption = Options.optional(Options.text("assignee")).pipe( Options.withDescription("Assign to a member (display name, email, or UUID)"), ); -const labelOption = Options.optional(Options.text("label")).pipe( - Options.withDescription("Set issue label by name"), +const labelOption = Options.repeated(Options.text("label")).pipe( + Options.withDescription("Set issue label(s) by name (repeatable)"), ); const noAssigneeOption = Options.boolean("no-assignee").pipe( @@ -80,6 +86,28 @@ const noAssigneeOption = Options.boolean("no-assignee").pipe( Options.withDefault(false), ); +const startDateOption = Options.optional(Options.text("start-date")).pipe( + Options.withDescription("Start date (YYYY-MM-DD)"), +); + +const targetDateOption = Options.optional( + Options.text("target-date").pipe(Options.withAlias("due-date")), +).pipe(Options.withDescription("Target/due date (YYYY-MM-DD)")); + +const estimateOption = Options.optional(Options.text("estimate")).pipe( + Options.withDescription( + "Estimate point UUID (from project estimate settings)", + ), +); + +const cycleOption = Options.optional(Options.text("cycle")).pipe( + Options.withDescription("Assign to a cycle (name or UUID)"), +); + +const moduleOption = Options.optional(Options.text("module")).pipe( + Options.withDescription("Assign to a module (name or UUID)"), +); + export function issueUpdateHandler({ ref, state, @@ -89,6 +117,11 @@ export function issueUpdateHandler({ assignee, label, noAssignee, + startDate, + targetDate, + estimate, + cycle, + module: mod, }: { ref: string; state: Option.Option; @@ -96,8 +129,13 @@ export function issueUpdateHandler({ title: Option.Option; description: Option.Option; assignee: Option.Option; - label: Option.Option; + label: Array; noAssignee: boolean; + startDate: Option.Option; + targetDate: Option.Option; + estimate: Option.Option; + cycle: Option.Option; + module: Option.Option; }) { return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); @@ -123,24 +161,60 @@ export function issueUpdateHandler({ const memberId = yield* getMemberId(assignee.value); body.assignees = [memberId]; } - if (Option.isSome(label)) { - const labelId = yield* getLabelId(projectId, label.value); - body.label_ids = [labelId]; + if (label.length > 0) { + const labelIds: string[] = []; + for (const l of label) { + labelIds.push(yield* getLabelId(projectId, l)); + } + body.labels = labelIds; + } + if (Option.isSome(startDate)) { + body.start_date = startDate.value; + } + if (Option.isSome(targetDate)) { + body.target_date = targetDate.value; + } + if (Option.isSome(estimate)) { + body.estimate_point = estimate.value; } - if (Object.keys(body).length === 0) { + const hasCycle = Option.isSome(cycle); + const hasModule = Option.isSome(mod); + + if (Object.keys(body).length === 0 && !hasCycle && !hasModule) { yield* Effect.fail( new Error( - "Nothing to update. Specify --state, --priority, --title, --description, --assignee, --label, or --no-assignee", + "Nothing to update. Specify --state, --priority, --title, --description, --assignee, --label, --no-assignee, --start-date, --target-date, --estimate, --cycle, or --module", ), ); } - const raw = yield* api.patch( - `projects/${projectId}/issues/${issue.id}/`, - body, - ); - yield* decodeOrFail(IssueSchema, raw); + if (Object.keys(body).length > 0) { + const raw = yield* api.patch( + `projects/${projectId}/issues/${issue.id}/`, + body, + ); + yield* decodeOrFail(IssueSchema, raw); + } + + if (hasCycle) { + yield* requireProjectFeature(projectId, "cycle_view"); + const resolved = yield* resolveCycle(projectId, cycle.value); + yield* api.post( + `projects/${projectId}/cycles/${resolved.id}/cycle-issues/`, + { issues: [issue.id] }, + ); + } + + if (hasModule) { + yield* requireProjectFeature(projectId, "module_view"); + const resolved = yield* resolveModule(projectId, mod.value); + yield* api.post( + `projects/${projectId}/modules/${resolved.id}/module-issues/`, + { issues: [issue.id] }, + ); + } + const refreshedRaw = yield* api.get( `projects/${projectId}/issues/${issue.id}/`, ); @@ -163,12 +237,17 @@ export const issueUpdate = Command.make( assignee: assigneeOption, label: labelOption, noAssignee: noAssigneeOption, + startDate: startDateOption, + targetDate: targetDateOption, + estimate: estimateOption, + cycle: cycleOption, + module: moduleOption, ref: refArg, }, issueUpdateHandler, ).pipe( Command.withDescription( - 'Update an issue\'s state, priority, title, description, or assignee. Options must come before the REF argument.\n\nExamples:\n plane issue update --state completed PROJ-29\n plane issue update --priority high WEB-5\n plane issue update --title "New issue title" PROJ-29\n plane issue update --assignee "Jane Doe" PROJ-29\n plane issue update --no-assignee PROJ-29\n plane issue update --description "New description" PROJ-29', + 'Update an issue\'s state, priority, title, description, assignee, labels, dates, estimate, cycle, or module.\n\nExamples:\n plane issue update --state completed PROJ-29\n plane issue update --priority high WEB-5\n plane issue update --start-date 2026-04-01 --target-date 2026-04-15 PROJ-29\n plane issue update --label bug --label urgent PROJ-29\n plane issue update --cycle "Sprint 3" PROJ-29\n plane issue update --module "Backend" PROJ-29\n plane issue update --estimate PROJ-29', ), ); // --- issue comment --- @@ -232,8 +311,30 @@ const createAssigneeOption = Options.optional(Options.text("assignee")).pipe( Options.withDescription("Assign to a member (display name, email, or UUID)"), ); -const createLabelOption = Options.optional(Options.text("label")).pipe( - Options.withDescription("Set issue label by name"), +const createLabelOption = Options.repeated(Options.text("label")).pipe( + Options.withDescription("Set issue label(s) by name (repeatable)"), +); + +const createStartDateOption = Options.optional(Options.text("start-date")).pipe( + Options.withDescription("Start date (YYYY-MM-DD)"), +); + +const createTargetDateOption = Options.optional( + Options.text("target-date").pipe(Options.withAlias("due-date")), +).pipe(Options.withDescription("Target/due date (YYYY-MM-DD)")); + +const createEstimateOption = Options.optional(Options.text("estimate")).pipe( + Options.withDescription( + "Estimate point UUID (from project estimate settings)", + ), +); + +const createCycleOption = Options.optional(Options.text("cycle")).pipe( + Options.withDescription("Assign to a cycle (name or UUID)"), +); + +const createModuleOption = Options.optional(Options.text("module")).pipe( + Options.withDescription("Assign to a module (name or UUID)"), ); export function issueCreateHandler({ @@ -244,6 +345,11 @@ export function issueCreateHandler({ description, assignee, label, + startDate, + targetDate, + estimate, + cycle, + module: mod, }: { project: string; title: string; @@ -251,7 +357,12 @@ export function issueCreateHandler({ state: Option.Option; description: Option.Option; assignee: Option.Option; - label: Option.Option; + label: Array; + startDate: Option.Option; + targetDate: Option.Option; + estimate: Option.Option; + cycle: Option.Option; + module: Option.Option; }) { return Effect.gen(function* () { const { key, id: projectId } = yield* resolveProject(project); @@ -266,12 +377,43 @@ export function issueCreateHandler({ const memberId = yield* getMemberId(assignee.value); body.assignees = [memberId]; } - if (Option.isSome(label)) { - const labelId = yield* getLabelId(projectId, label.value); - body.label_ids = [labelId]; + if (label.length > 0) { + const labelIds: string[] = []; + for (const l of label) { + labelIds.push(yield* getLabelId(projectId, l)); + } + body.labels = labelIds; + } + if (Option.isSome(startDate)) { + body.start_date = startDate.value; + } + if (Option.isSome(targetDate)) { + body.target_date = targetDate.value; + } + if (Option.isSome(estimate)) { + body.estimate_point = estimate.value; } const raw = yield* api.post(`projects/${projectId}/issues/`, body); const created = yield* decodeOrFail(IssueSchema, raw); + + if (Option.isSome(cycle)) { + yield* requireProjectFeature(projectId, "cycle_view"); + const resolved = yield* resolveCycle(projectId, cycle.value); + yield* api.post( + `projects/${projectId}/cycles/${resolved.id}/cycle-issues/`, + { issues: [created.id] }, + ); + } + + if (Option.isSome(mod)) { + yield* requireProjectFeature(projectId, "module_view"); + const resolved = yield* resolveModule(projectId, mod.value); + yield* api.post( + `projects/${projectId}/modules/${resolved.id}/module-issues/`, + { issues: [created.id] }, + ); + } + yield* Console.log( `Created ${key}-${created.sequence_id}: ${created.name}`, ); @@ -286,13 +428,18 @@ export const issueCreate = Command.make( description: createDescriptionOption, assignee: createAssigneeOption, label: createLabelOption, + startDate: createStartDateOption, + targetDate: createTargetDateOption, + estimate: createEstimateOption, + cycle: createCycleOption, + module: createModuleOption, title: createTitleOption, project: createProjectArg, }, issueCreateHandler, ).pipe( Command.withDescription( - 'Create a new issue in a project. Omit PROJECT to use the saved current project.\n\nExamples:\n plane issue create --title "Migrate Button component"\n plane issue create --title "Migrate Button component" PROJ\n plane issue create --priority high --state started --title "Fix lint pipeline"\n plane issue create --description "Detailed context here" --title "Add dark mode" PROJ\n plane issue create --assignee "Jane Doe" --title "Onboarding bug" PROJ', + 'Create a new issue in a project.\n\nExamples:\n plane issue create --title "Migrate Button component"\n plane issue create --title "Fix pipeline" --start-date 2026-04-01 --target-date 2026-04-15\n plane issue create --title "Bug fix" --label bug --label urgent\n plane issue create --title "Sprint task" --cycle "Sprint 3"\n plane issue create --title "Backend task" --module "Backend"', ), ); // --- issue activity --- @@ -339,322 +486,6 @@ export const issueActivity = Command.make( "Show audit trail for an issue — who changed what and when.\n\nExample:\n plane issue activity PROJ-29", ), ); -// --- issue link list --- -export function issueLinkListHandler({ ref }: { ref: string }) { - return Effect.gen(function* () { - const { projectId, seq } = yield* parseIssueRef(ref); - const issue = yield* findIssueBySeq(projectId, seq); - const raw = yield* requestWithFallback( - issueLinkPaths(projectId, issue.id), - (path) => api.get(path), - `Issue links are not available for ${ref} on this Plane instance or API version.`, - ); - const { results } = yield* decodeOrFail(IssueLinksResponseSchema, raw); - if (jsonMode) { - yield* Console.log(JSON.stringify(results, null, 2)); - return; - } - if (xmlMode) { - yield* Console.log(toXml(results)); - return; - } - if (results.length === 0) { - yield* Console.log("No links"); - return; - } - const lines = results.map( - (l) => `${l.id} ${l.title ?? "(no title)"} ${l.url}`, - ); - yield* Console.log(lines.join("\n")); - }); -} - -export const issueLinkList = Command.make( - "list", - { ref: refArg }, - issueLinkListHandler, -).pipe(Command.withDescription("List URL links attached to an issue.")); -// --- issue link add --- -const urlArg = Args.text({ name: "url" }).pipe( - Args.withDescription("URL to link"), -); -const linkTitleOption = Options.optional(Options.text("title")).pipe( - Options.withDescription("Human-readable title for the link"), -); - -export function issueLinkAddHandler({ - ref, - url, - title, -}: { - ref: string; - url: string; - title: Option.Option; -}) { - return Effect.gen(function* () { - const { projectId, seq } = yield* parseIssueRef(ref); - const issue = yield* findIssueBySeq(projectId, seq); - const body: Record = { url }; - if (Option.isSome(title)) body.title = title.value; - const raw = yield* requestWithFallback( - issueLinkPaths(projectId, issue.id), - (path) => api.post(path, body), - `Issue links are not available for ${ref} on this Plane instance or API version.`, - ); - const link = yield* decodeOrFail(IssueLinkSchema, raw); - yield* Console.log(`Link added: ${link.id} ${link.url}`); - }); -} - -export const issueLinkAdd = Command.make( - "add", - { title: linkTitleOption, ref: refArg, url: urlArg }, - issueLinkAddHandler, -).pipe( - Command.withDescription( - 'Attach a URL link to an issue.\n\nExamples:\n plane issue link add PROJ-29 https://github.com/org/repo/pull/42\n plane issue link add --title "Design doc" PROJ-29 https://docs.example.com', - ), -); -// --- issue link remove --- -const linkIdArg = Args.text({ name: "link-id" }).pipe( - Args.withDescription("Link ID (from 'plane issue link list')"), -); - -export function issueLinkRemoveHandler({ - ref, - linkId, -}: { - ref: string; - linkId: string; -}) { - return Effect.gen(function* () { - const { projectId, seq } = yield* parseIssueRef(ref); - const issue = yield* findIssueBySeq(projectId, seq); - // Resolve the correct base path for issue links via a safe probe, - // then delete once so a missing link ID results in a proper not-found error. - const basePath = yield* requestWithFallback( - issueLinkPaths(projectId, issue.id), - (path) => api.get(path).pipe(Effect.as(path)), - `Issue links are not available for ${ref} on this Plane instance or API version.`, - ); - yield* api.delete(`${basePath}${linkId}/`); - yield* Console.log(`Link ${linkId} removed from ${ref}`); - }); -} - -export const issueLinkRemove = Command.make( - "remove", - { ref: refArg, linkId: linkIdArg }, - issueLinkRemoveHandler, -).pipe(Command.withDescription("Remove a URL link from an issue by link ID.")); -// --- issue link (parent) --- -export const issueLink = Command.make("link").pipe( - Command.withDescription( - "Manage URL links on an issue. Subcommands: list, add, remove", - ), - Command.withSubcommands([issueLinkList, issueLinkAdd, issueLinkRemove]), -); -// --- issue comments list --- -export function issueCommentsListHandler({ ref }: { ref: string }) { - return Effect.gen(function* () { - const { projectId, seq } = yield* parseIssueRef(ref); - const issue = yield* findIssueBySeq(projectId, seq); - const raw = yield* api.get( - `projects/${projectId}/issues/${issue.id}/comments/`, - ); - const { results } = yield* decodeOrFail(CommentsResponseSchema, raw); - if (jsonMode) { - yield* Console.log(JSON.stringify(results, null, 2)); - return; - } - if (xmlMode) { - yield* Console.log(toXml(results)); - return; - } - if (results.length === 0) { - yield* Console.log("No comments"); - return; - } - const lines = results.map((c) => { - const who = c.actor_detail?.display_name ?? "?"; - const when = c.created_at.slice(0, 16).replace("T", " "); - const text = (c.comment_html ?? "").replace(/<[^>]+>/g, "").trim(); - return `${c.id} ${when} ${who}: ${text}`; - }); - yield* Console.log(lines.join("\n")); - }); -} - -export const issueCommentsList = Command.make( - "list", - { ref: refArg }, - issueCommentsListHandler, -).pipe( - Command.withDescription( - "List comments on an issue. Shows comment ID, timestamp, author, and plain text.\n\nExample:\n plane issue comments list PROJ-29", - ), -); -// --- issue comment update --- -const commentIdArg = Args.text({ name: "comment-id" }).pipe( - Args.withDescription("Comment ID (from 'plane issue comments list')"), -); - -export function issueCommentUpdateHandler({ - ref, - commentId, - text, -}: { - ref: string; - commentId: string; - text: string; -}) { - return Effect.gen(function* () { - const { projectId, seq } = yield* parseIssueRef(ref); - const issue = yield* findIssueBySeq(projectId, seq); - const escaped = escapeHtmlText(text); - yield* api.patch( - `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`, - { comment_html: `

${escaped}

` }, - ); - yield* Console.log(`Comment ${commentId} updated`); - }); -} - -export const issueCommentUpdate = Command.make( - "update", - { ref: refArg, commentId: commentIdArg, text: textArg }, - issueCommentUpdateHandler, -).pipe( - Command.withDescription( - 'Edit an existing comment.\n\nExample:\n plane issue comments update PROJ-29 "Updated text"', - ), -); -// --- issue comment delete --- -export function issueCommentDeleteHandler({ - ref, - commentId, -}: { - ref: string; - commentId: string; -}) { - return Effect.gen(function* () { - const { projectId, seq } = yield* parseIssueRef(ref); - const issue = yield* findIssueBySeq(projectId, seq); - yield* api.delete( - `projects/${projectId}/issues/${issue.id}/comments/${commentId}/`, - ); - yield* Console.log(`Comment ${commentId} deleted`); - }); -} - -export const issueCommentDelete = Command.make( - "delete", - { ref: refArg, commentId: commentIdArg }, - issueCommentDeleteHandler, -).pipe(Command.withDescription("Delete a comment from an issue.")); -// --- issue comments (parent) --- -export const issueComments = Command.make("comments").pipe( - Command.withDescription( - "Manage comments on an issue. Subcommands: list, update, delete\n\nNote: use 'plane issue comment REF TEXT' to add a new comment.", - ), - Command.withSubcommands([ - issueCommentsList, - issueCommentUpdate, - issueCommentDelete, - ]), -); -// --- issue worklogs list --- -export function issueWorklogsListHandler({ ref }: { ref: string }) { - return Effect.gen(function* () { - const { projectId, seq } = yield* parseIssueRef(ref); - const issue = yield* findIssueBySeq(projectId, seq); - const raw = yield* requestWithFallback( - issueWorklogPaths(projectId, issue.id), - (path) => api.get(path), - `Issue worklogs are not available for ${ref} on this Plane instance or API version.`, - ); - const { results } = yield* decodeOrFail(WorklogsResponseSchema, raw); - if (jsonMode) { - yield* Console.log(JSON.stringify(results, null, 2)); - return; - } - if (xmlMode) { - yield* Console.log(toXml(results)); - return; - } - if (results.length === 0) { - yield* Console.log("No worklogs"); - return; - } - const lines = results.map((w) => { - const who = w.logged_by_detail?.display_name ?? "?"; - const when = w.created_at.slice(0, 10); - const hrs = (w.duration / 60).toFixed(1); - const desc = w.description ?? ""; - return `${w.id} ${when} ${who} ${hrs}h ${desc}`; - }); - yield* Console.log(lines.join("\n")); - }); -} - -export const issueWorklogsList = Command.make( - "list", - { ref: refArg }, - issueWorklogsListHandler, -).pipe( - Command.withDescription( - "List time log entries for an issue. Duration shown in hours.\n\nExample:\n plane issue worklogs list PROJ-29", - ), -); -// --- issue worklogs add --- -const durationArg = Args.integer({ name: "minutes" }).pipe( - Args.withDescription("Time spent in minutes"), -); -const worklogDescOption = Options.optional(Options.text("description")).pipe( - Options.withDescription("Optional description of work done"), -); - -export function issueWorklogsAddHandler({ - ref, - duration, - description, -}: { - ref: string; - duration: number; - description: Option.Option; -}) { - return Effect.gen(function* () { - const { projectId, seq } = yield* parseIssueRef(ref); - const issue = yield* findIssueBySeq(projectId, seq); - const body: WorklogPayload = { duration }; - if (Option.isSome(description)) body.description = description.value; - const raw = yield* requestWithFallback( - issueWorklogPaths(projectId, issue.id), - (path) => api.post(path, body), - `Issue worklogs are not available for ${ref} on this Plane instance or API version.`, - ); - const log = yield* decodeOrFail(WorklogSchema, raw); - const hrs = (log.duration / 60).toFixed(1); - yield* Console.log(`Logged ${hrs}h on ${ref} (${log.id})`); - }); -} - -export const issueWorklogsAdd = Command.make( - "add", - { description: worklogDescOption, ref: refArg, duration: durationArg }, - issueWorklogsAddHandler, -).pipe( - Command.withDescription( - 'Log time spent on an issue (duration in minutes).\n\nExamples:\n plane issue worklogs add PROJ-29 90\n plane issue worklogs add --description "code review" PROJ-29 30', - ), -); -// --- issue worklogs (parent) --- -export const issueWorklogs = Command.make("worklogs").pipe( - Command.withDescription( - "Manage time logs for an issue. Subcommands: list, add", - ), - Command.withSubcommands([issueWorklogsList, issueWorklogsAdd]), -); // --- issue delete --- export function issueDeleteHandler({ ref }: { ref: string }) { return Effect.gen(function* () { diff --git a/src/commands/issues.ts b/src/commands/issues.ts index e3b7c15..46ed804 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -5,7 +5,12 @@ import type { State } from "../config.js"; import { IssuesResponseSchema } from "../config.js"; import { formatIssue } from "../format.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; -import { getMemberId, resolveProject } from "../resolve.js"; +import { + getMemberId, + requireProjectFeature, + resolveCycle, + resolveProject, +} from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( Args.withDescription( @@ -31,16 +36,35 @@ const priorityOption = Options.optional( Options.choice("priority", ["urgent", "high", "medium", "low", "none"]), ).pipe(Options.withDescription("Filter by priority")); +const noAssigneeOption = Options.boolean("no-assignee").pipe( + Options.withDescription("Filter for unassigned issues"), + Options.withDefault(false), +); + +const staleOption = Options.optional(Options.integer("stale")).pipe( + Options.withDescription("Filter issues not updated in more than N days"), +); + +const cycleOption = Options.optional(Options.text("cycle")).pipe( + Options.withDescription("Filter by cycle (name or UUID)"), +); + export function issuesListHandler({ project, state, assignee, priority, + noAssignee, + stale, + cycle, }: { project: string; state: Option.Option; assignee: Option.Option; priority: Option.Option; + noAssignee: boolean; + stale: Option.Option; + cycle: Option.Option; }) { return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); @@ -75,6 +99,32 @@ export function issuesListHandler({ filtered = filtered.filter((i) => i.priority === priority.value); } + if (noAssignee) { + filtered = filtered.filter( + (i) => !Array.isArray(i.assignees) || i.assignees.length === 0, + ); + } + + if (stale._tag === "Some") { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - stale.value); + filtered = filtered.filter((i) => { + if (!i.updated_at) return false; + return new Date(i.updated_at) < cutoff; + }); + } + + if (cycle._tag === "Some") { + yield* requireProjectFeature(id, "cycle_view"); + const resolved = yield* resolveCycle(id, cycle.value); + const cycleRaw = yield* api.get( + `projects/${id}/cycles/${resolved.id}/cycle-issues/`, + ); + const cycleData = yield* decodeOrFail(IssuesResponseSchema, cycleRaw); + const cycleIssueIds = new Set(cycleData.results.map((i) => i.id)); + filtered = filtered.filter((i) => cycleIssueIds.has(i.id)); + } + if (jsonMode) { yield* Console.log(JSON.stringify(filtered, null, 2)); return; @@ -93,12 +143,15 @@ export const issuesList = Command.make( state: stateOption, assignee: assigneeOption, priority: priorityOption, + noAssignee: noAssigneeOption, + stale: staleOption, + cycle: cycleOption, project: listProjectArg, }, issuesListHandler, ).pipe( Command.withDescription( - "List issues for a project ordered by sequence ID. Each line shows: REF [state-group] state-name title. Omit PROJECT to use the saved current project.", + "List issues for a project ordered by sequence ID.\n\nFilters:\n --state State group or name\n --assignee Member name/email/UUID\n --priority Priority level\n --no-assignee Unassigned issues only\n --stale N Issues not updated in N+ days\n --cycle Issues in a specific cycle", ), ); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 0e9cb3c..cad2485 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,9 +1,8 @@ import { Args, Command, Options } from "@effect/cli"; import { Console, Effect } from "effect"; -import { api, decodeOrFail } from "../api.js"; -import { ProjectsResponseSchema } from "../config.js"; +import { isProjectArchived } from "../config.js"; import { jsonMode, toXml, xmlMode } from "../output.js"; -import { resolveProject } from "../resolve.js"; +import { listProjects, resolveProject } from "../resolve.js"; import { type ConfigScope, getConfigDetails, @@ -30,6 +29,11 @@ const localOption = Options.boolean("local").pipe( Options.withDefault(false), ); +const includeArchivedOption = Options.boolean("include-archived").pipe( + Options.withDescription("Include archived projects in the results"), + Options.withDefault(false), +); + function resolveWriteScope({ global, local, @@ -64,10 +68,13 @@ function describeProjectSource(source: string): string { } } -export function projectsListHandler() { +export function projectsListHandler({ + includeArchived = false, +}: { + includeArchived?: boolean; +} = {}) { return Effect.gen(function* () { - const raw = yield* api.get("projects/"); - const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw); + const results = yield* listProjects({ includeArchived }); const currentProject = getConfigDetails().defaultProject.toUpperCase(); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); @@ -80,15 +87,20 @@ export function projectsListHandler() { const lines = results.map((project) => { const marker = currentProject === project.identifier.toUpperCase() ? "*" : " "; - return `${marker} ${project.identifier.padEnd(6)} ${project.id} ${project.name}`; + const archivedSuffix = isProjectArchived(project) ? " (archived)" : ""; + return `${marker} ${project.identifier.padEnd(6)} ${project.id} ${project.name}${archivedSuffix}`; }); yield* Console.log(lines.join("\n")); }); } -export const projectsList = Command.make("list", {}, projectsListHandler).pipe( +export const projectsList = Command.make( + "list", + { includeArchived: includeArchivedOption }, + projectsListHandler, +).pipe( Command.withDescription( - "List all projects in the workspace. The IDENTIFIER column is what you pass to other commands. A leading '*' marks the saved current project.", + "List workspace projects, excluding archived ones by default. The IDENTIFIER column is what you pass to other commands. A leading '*' marks the saved current project. Add --include-archived to include archived projects.", ), ); @@ -105,8 +117,7 @@ export function projectsCurrentHandler() { } const source = describeProjectSource(config.sources.defaultProject); const { key, id } = yield* resolveProject("@current"); - const raw = yield* api.get("projects/"); - const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw); + const results = yield* listProjects({ includeArchived: true }); const project = results.find((candidate) => candidate.id === id); if (!project) { yield* Console.log(`${key} (${source})`); diff --git a/src/commands/stats.ts b/src/commands/stats.ts new file mode 100644 index 0000000..69ea292 --- /dev/null +++ b/src/commands/stats.ts @@ -0,0 +1,427 @@ +import { Args, Command, Options } from "@effect/cli"; +import { Console, Effect, Option } from "effect"; +import { api, decodeOrFail } from "../api.js"; +import type { + Issue, + State, + StatsPeriod, + StatsResult, + WorkspaceStatsResult, +} from "../config.js"; +import { PaginatedIssuesResponseSchema } from "../config.js"; +import { formatStats } from "../format.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { + getMemberId, + listProjects, + requireProjectFeature, + resolveCycle, + resolveModule, + resolveProject, +} from "../resolve.js"; +import { getConfig } from "../user-config.js"; + +const projectArg = Args.text({ name: "project" }).pipe( + Args.withDescription( + "Project identifier — see 'plane projects list' for available identifiers. Use '@current' for the saved default project.", + ), + Args.withDefault(""), +); + +const sinceOption = Options.optional(Options.text("since")).pipe( + Options.withDescription( + "Only count issues created on or after this date (YYYY-MM-DD)", + ), +); + +const untilOption = Options.optional(Options.text("until")).pipe( + Options.withDescription( + "Only count issues created before this date (YYYY-MM-DD)", + ), +); + +const cycleOption = Options.optional(Options.text("cycle")).pipe( + Options.withDescription("Scope stats to a cycle (name or UUID)"), +); + +const moduleOption = Options.optional(Options.text("module")).pipe( + Options.withDescription("Scope stats to a module (name or UUID)"), +); + +const assigneeOption = Options.optional(Options.text("assignee")).pipe( + Options.withDescription( + "Scope stats to an assignee (display name, email, or member UUID)", + ), +); + +const includeArchivedOption = Options.boolean("include-archived").pipe( + Options.withDescription( + "Include archived projects when PROJECT is 'workspace'", + ), + Options.withDefault(false), +); + +const EMPTY_STATE_COUNTS: Record = { + backlog: 0, + unstarted: 0, + started: 0, + completed: 0, + cancelled: 0, +}; + +const EMPTY_PRIORITY_COUNTS: Record = { + urgent: 0, + high: 0, + medium: 0, + low: 0, + none: 0, +}; + +function getPeriod( + since: Option.Option, + until: Option.Option, +): Effect.Effect { + return Effect.sync(() => { + const period: StatsPeriod = {}; + if (since._tag === "Some") { + if (!/^\d{4}-\d{2}-\d{2}$/.test(since.value)) { + throw new Error("Invalid --since date. Expected YYYY-MM-DD."); + } + period.since = since.value; + } + if (until._tag === "Some") { + if (!/^\d{4}-\d{2}-\d{2}$/.test(until.value)) { + throw new Error("Invalid --until date. Expected YYYY-MM-DD."); + } + period.until = until.value; + } + if (!period.since && !period.until) { + return undefined; + } + if ( + period.since && + period.until && + new Date(period.since) >= new Date(period.until) + ) { + throw new Error("--since must be earlier than --until."); + } + return period; + }); +} + +function isDateInRange( + value: string | null | undefined, + period?: StatsPeriod, +): boolean { + if (!value) { + return false; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return false; + } + if (period?.since && date < new Date(period.since)) { + return false; + } + if (period?.until && date >= new Date(period.until)) { + return false; + } + return true; +} + +function aggregateStats( + issues: readonly Issue[], + projKey: string, + period?: StatsPeriod, +): StatsResult { + const byStateGroup: Record = { ...EMPTY_STATE_COUNTS }; + const byPriority: Record = { ...EMPTY_PRIORITY_COUNTS }; + let assigned = 0; + let unassigned = 0; + let createdInRange = 0; + let completedInRange = 0; + + for (const issue of issues) { + const state = issue.state as State | string; + const group = typeof state === "object" ? state.group : "unknown"; + byStateGroup[group] = (byStateGroup[group] ?? 0) + 1; + byPriority[issue.priority] = (byPriority[issue.priority] ?? 0) + 1; + + if (Array.isArray(issue.assignees) && issue.assignees.length > 0) { + assigned++; + } else { + unassigned++; + } + + if (isDateInRange(issue.created_at, period)) { + createdInRange++; + } + + if (isDateInRange(issue.completed_at, period)) { + completedInRange++; + } + } + + return { + project: projKey, + ...(period ? { period } : {}), + total_issues: issues.length, + by_state_group: byStateGroup, + by_priority: byPriority, + created_in_range: period ? createdInRange : issues.length, + completed_in_range: period + ? completedInRange + : issues.filter((issue) => issue.completed_at).length, + assigned, + unassigned, + }; +} + +function combineStats( + label: string, + projects: ReadonlyArray, + period?: StatsPeriod, + skippedProjects?: ReadonlyArray, +): WorkspaceStatsResult { + const result: WorkspaceStatsResult = { + workspace: label, + ...(period ? { period } : {}), + total_issues: 0, + by_state_group: { ...EMPTY_STATE_COUNTS }, + by_priority: { ...EMPTY_PRIORITY_COUNTS }, + created_in_range: 0, + completed_in_range: 0, + assigned: 0, + unassigned: 0, + projects: [...projects], + ...(skippedProjects && skippedProjects.length > 0 + ? { skipped_projects: [...skippedProjects] } + : {}), + }; + + for (const project of projects) { + result.total_issues += project.total_issues; + result.created_in_range += project.created_in_range; + result.completed_in_range += project.completed_in_range; + result.assigned += project.assigned; + result.unassigned += project.unassigned; + + for (const [group, count] of Object.entries(project.by_state_group)) { + result.by_state_group[group] = + (result.by_state_group[group] ?? 0) + count; + } + + for (const [priority, count] of Object.entries(project.by_priority)) { + result.by_priority[priority] = + (result.by_priority[priority] ?? 0) + count; + } + } + + return result; +} + +function fetchIssueCollection(path: string): Effect.Effect { + return Effect.gen(function* () { + const issues: Issue[] = []; + let cursor: string | undefined; + + while (true) { + const separator = path.includes("?") ? "&" : "?"; + const cursorPart = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""; + const raw = yield* api.get( + `${path}${separator}per_page=100${cursorPart}`, + ); + const page = yield* decodeOrFail(PaginatedIssuesResponseSchema, raw); + issues.push(...page.results); + if (!page.next_page_results || !page.next_cursor) { + break; + } + cursor = page.next_cursor; + } + + return issues; + }); +} + +function filterByCycleOrModule( + projectId: string, + issues: readonly Issue[], + cycle: Option.Option, + module: Option.Option, +) { + return Effect.gen(function* () { + let filtered = [...issues]; + + if (cycle._tag === "Some") { + yield* requireProjectFeature(projectId, "cycle_view"); + const resolved = yield* resolveCycle(projectId, cycle.value); + const cycleIssues = yield* fetchIssueCollection( + `projects/${projectId}/cycles/${resolved.id}/cycle-issues/`, + ); + const cycleIssueIds = new Set(cycleIssues.map((i) => i.id)); + filtered = filtered.filter((i) => cycleIssueIds.has(i.id)); + } + + if (module._tag === "Some") { + yield* requireProjectFeature(projectId, "module_view"); + const resolved = yield* resolveModule(projectId, module.value); + const moduleIssues = yield* fetchIssueCollection( + `projects/${projectId}/modules/${resolved.id}/module-issues/`, + ); + const moduleIssueIds = new Set(moduleIssues.map((i) => i.id)); + filtered = filtered.filter((i) => moduleIssueIds.has(i.id)); + } + + return filtered; + }); +} + +function outputStats(result: StatsResult | WorkspaceStatsResult) { + return Effect.gen(function* () { + if (jsonMode) { + yield* Console.log(JSON.stringify(result, null, 2)); + return; + } + if (xmlMode) { + yield* Console.log(toXml([result])); + return; + } + yield* Console.log(formatStats(result)); + }); +} + +function getScopedProjectIssues( + projectId: string, + assignee: Option.Option, + cycle: Option.Option, + module: Option.Option, +): Effect.Effect { + return Effect.gen(function* () { + let filtered = yield* fetchIssueCollection( + `projects/${projectId}/issues/?order_by=sequence_id`, + ); + + if (assignee._tag === "Some") { + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + assignee.value, + ); + const memberId = isUuid + ? assignee.value + : yield* getMemberId(assignee.value); + filtered = filtered.filter( + (issue) => + Array.isArray(issue.assignees) && issue.assignees.includes(memberId), + ); + } + + return yield* filterByCycleOrModule(projectId, filtered, cycle, module); + }); +} + +function workspaceStatsHandler({ + period, + includeArchived, +}: { + period?: StatsPeriod; + includeArchived: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + const results = yield* listProjects({ includeArchived }); + const projectStats: StatsResult[] = []; + const skippedProjects: string[] = []; + + for (const project of results) { + const issues = yield* getScopedProjectIssues( + project.id, + Option.none(), + Option.none(), + Option.none(), + ).pipe( + Effect.catchAll((error) => { + if (/^HTTP 403:/.test(error.message)) { + skippedProjects.push(project.identifier); + return Effect.succeed([]); + } + return Effect.fail(error); + }), + ); + if (issues.length === 0 && skippedProjects.includes(project.identifier)) { + continue; + } + projectStats.push(aggregateStats(issues, project.identifier, period)); + } + + const result = combineStats( + getConfig().workspace, + projectStats, + period, + skippedProjects, + ); + yield* outputStats(result); + }); +} + +export function statsHandler({ + project, + since, + until, + cycle, + module, + assignee, + includeArchived, +}: { + project: string; + since: Option.Option; + until: Option.Option; + cycle: Option.Option; + module: Option.Option; + assignee: Option.Option; + includeArchived?: boolean; +}) { + return Effect.gen(function* () { + const period = yield* getPeriod(since, until); + if (project.trim().toLowerCase() === "workspace") { + if ( + cycle._tag === "Some" || + module._tag === "Some" || + assignee._tag === "Some" + ) { + return yield* Effect.fail( + new Error( + "Workspace stats currently support only --since and --until.", + ), + ); + } + return yield* workspaceStatsHandler({ + period, + includeArchived: includeArchived ?? false, + }); + } + + const { key, id } = yield* resolveProject(project); + const issues = yield* getScopedProjectIssues(id, assignee, cycle, module); + const result = aggregateStats(issues, key, period); + yield* outputStats(result); + }); +} + +export const statsList = Command.make( + "stats", + { + project: projectArg, + since: sinceOption, + until: untilOption, + cycle: cycleOption, + module: moduleOption, + assignee: assigneeOption, + includeArchived: includeArchivedOption, + }, + statsHandler, +).pipe( + Command.withDescription( + "Show aggregated issue statistics for a project or for the whole workspace using PROJECT='workspace'.\n\nBreaks down issues by state group, priority, assignment, and period counts.\nAll aggregation is client-side — no server analytics endpoints required. Workspace aggregation excludes archived projects by default; add --include-archived to include them.\n\nFilters:\n --since DATE Count created/completed issues on or after DATE (YYYY-MM-DD)\n --until DATE Count created/completed issues before DATE (YYYY-MM-DD)\n --cycle NAME Scope to a specific cycle (project stats only)\n --module NAME Scope to a specific module (project stats only)\n --assignee WHO Scope to issues assigned to a member (project stats only)\n --include-archived Include archived projects in workspace aggregation\n\nNote: @effect/cli requires command options before PROJECT, so use 'plane stats --since 2026-04-01 PROJ'.", + ), +); + +export const stats = statsList; diff --git a/src/config.ts b/src/config.ts index ebe6e13..bfee531 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,13 @@ export const StateSchema = Schema.Struct({ }); export type State = typeof StateSchema.Type; +export const IssueLabelSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), +}); +export type IssueLabel = typeof IssueLabelSchema.Type; + export const IssueSchema = Schema.Struct({ id: Schema.String, sequence_id: Schema.Number, @@ -19,6 +26,14 @@ export const IssueSchema = Schema.Struct({ assignees: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), description_html: Schema.optional(Schema.NullOr(Schema.String)), estimate_point: Schema.optional(Schema.NullOr(Schema.String)), + start_date: Schema.optional(Schema.NullOr(Schema.String)), + target_date: Schema.optional(Schema.NullOr(Schema.String)), + completed_at: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.optional(Schema.NullOr(Schema.String)), + updated_at: Schema.optional(Schema.NullOr(Schema.String)), + labels: Schema.optional( + Schema.NullOr(Schema.Array(Schema.Union(Schema.String, IssueLabelSchema))), + ), }); export type Issue = typeof IssueSchema.Type; @@ -30,6 +45,43 @@ export const IssuesResponseSchema = Schema.Struct({ results: Schema.Array(IssueSchema), }); +export const PaginatedIssuesResponseSchema = Schema.Struct({ + results: Schema.Array(IssueSchema), + next_cursor: Schema.optional(Schema.NullOr(Schema.String)), + next_page_results: Schema.optional(Schema.Boolean), +}); + +export interface StatsPeriod { + since?: string; + until?: string; +} + +export interface StatsResult { + project: string; + period?: StatsPeriod; + total_issues: number; + by_state_group: Record; + by_priority: Record; + created_in_range: number; + completed_in_range: number; + assigned: number; + unassigned: number; +} + +export interface WorkspaceStatsResult { + workspace: string; + period?: StatsPeriod; + total_issues: number; + by_state_group: Record; + by_priority: Record; + created_in_range: number; + completed_in_range: number; + assigned: number; + unassigned: number; + projects: StatsResult[]; + skipped_projects?: string[]; +} + export const LabelSchema = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -55,9 +107,15 @@ export const MembersResponseSchema = Schema.Array(MemberSchema); export const CycleSchema = Schema.Struct({ id: Schema.String, name: Schema.String, - status: Schema.optional(Schema.String), + status: Schema.optional(Schema.NullOr(Schema.String)), start_date: Schema.optional(Schema.NullOr(Schema.String)), end_date: Schema.optional(Schema.NullOr(Schema.String)), + total_issues: Schema.optional(Schema.Number), + completed_issues: Schema.optional(Schema.Number), + cancelled_issues: Schema.optional(Schema.Number), + started_issues: Schema.optional(Schema.Number), + unstarted_issues: Schema.optional(Schema.Number), + backlog_issues: Schema.optional(Schema.Number), }); export type Cycle = typeof CycleSchema.Type; @@ -70,6 +128,7 @@ export const ProjectSchema = Schema.Struct({ identifier: Schema.String, name: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)), + archived_at: Schema.optional(Schema.NullOr(Schema.String)), }); export type Project = typeof ProjectSchema.Type; @@ -93,6 +152,12 @@ export function isProjectIntakeEnabled( return (project.inbox_view || project.intake_view) ?? false; } +export function isProjectArchived( + project: Pick, +): boolean { + return project.archived_at != null; +} + export const EstimateSchema = Schema.Struct({ id: Schema.String, name: Schema.String, diff --git a/src/format.ts b/src/format.ts index c72bb37..7d8e7af 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,4 +1,10 @@ -import type { Issue, State } from "./config.js"; +import type { + Issue, + State, + StatsPeriod, + StatsResult, + WorkspaceStatsResult, +} from "./config.js"; export function escapeHtmlText(text: string): string { return text @@ -16,3 +22,73 @@ export function formatIssue(issue: Issue, projKey: string): string { const namePad = stateName.padEnd(12, " "); return `${projKey}-${seqPad} [${groupPad}] ${namePad} ${issue.name}`; } + +function formatPeriod(period?: StatsPeriod): string { + if (!period || (!period.since && !period.until)) { + return ""; + } + return ` (${period.since ?? "..."} to ${period.until ?? "..."})`; +} + +function formatInlineCounts(counts: Record): string { + return Object.entries(counts) + .filter(([, count]) => count > 0) + .map(([label, count]) => `${label}=${count}`) + .join(", "); +} + +function formatProjectStats(data: StatsResult): string { + const lines: string[] = []; + lines.push(`${data.project} Stats${formatPeriod(data.period)}`); + lines.push(` Total issues: ${data.total_issues}`); + lines.push(` By state group: ${formatInlineCounts(data.by_state_group)}`); + lines.push(` By priority: ${formatInlineCounts(data.by_priority)}`); + lines.push( + ` Created: ${data.created_in_range}${data.period ? " (in range)" : ""}`, + ); + lines.push( + ` Completed: ${data.completed_in_range}${data.period ? " (in range)" : ""}`, + ); + lines.push( + ` Assignee spread: ${data.assigned} assigned, ${data.unassigned} unassigned`, + ); + return lines.join("\n"); +} + +function formatWorkspaceStats(data: WorkspaceStatsResult): string { + const lines: string[] = []; + lines.push(`Workspace ${data.workspace} Stats${formatPeriod(data.period)}`); + lines.push(` Total issues: ${data.total_issues}`); + lines.push(` By state group: ${formatInlineCounts(data.by_state_group)}`); + lines.push(` By priority: ${formatInlineCounts(data.by_priority)}`); + lines.push( + ` Created: ${data.created_in_range}${data.period ? " (in range)" : ""}`, + ); + lines.push( + ` Completed: ${data.completed_in_range}${data.period ? " (in range)" : ""}`, + ); + lines.push( + ` Assignee spread: ${data.assigned} assigned, ${data.unassigned} unassigned`, + ); + if (data.projects.length > 0) { + lines.push(""); + lines.push("Projects:"); + for (const project of data.projects) { + lines.push( + ` ${project.project}: total=${project.total_issues}, created=${project.created_in_range}, completed=${project.completed_in_range}`, + ); + } + } + if (data.skipped_projects && data.skipped_projects.length > 0) { + lines.push(""); + lines.push(`Skipped projects: ${data.skipped_projects.join(", ")}`); + } + return lines.join("\n"); +} + +export function formatStats(data: StatsResult | WorkspaceStatsResult): string { + if ("workspace" in data) { + return formatWorkspaceStats(data); + } + return formatProjectStats(data); +} diff --git a/src/issue-support.ts b/src/issue-support.ts index 2c44e47..cff9f47 100644 --- a/src/issue-support.ts +++ b/src/issue-support.ts @@ -6,7 +6,10 @@ export interface IssueUpdatePayload { name?: string; description_html?: string; assignees?: string[]; - label_ids?: string[]; + labels?: string[]; + start_date?: string | null; + target_date?: string | null; + estimate_point?: string | null; } export interface IssueCreatePayload { @@ -15,7 +18,10 @@ export interface IssueCreatePayload { state?: string; description_html?: string; assignees?: string[]; - label_ids?: string[]; + labels?: string[]; + start_date?: string | null; + target_date?: string | null; + estimate_point?: string | null; } export interface WorklogPayload { diff --git a/src/resolve.ts b/src/resolve.ts index 11192d4..1d3172a 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,8 +1,10 @@ import { Effect } from "effect"; import { api, decodeOrFail } from "./api.js"; -import type { Issue, ProjectDetail } from "./config.js"; +import type { Issue, Project, ProjectDetail } from "./config.js"; import { + CyclesResponseSchema, IssuesResponseSchema, + isProjectArchived, isProjectIntakeEnabled, LabelsResponseSchema, MembersResponseSchema, @@ -14,7 +16,14 @@ import { import { getConfig } from "./user-config.js"; // Cache project list within a process invocation -let _projectCache: Record | null = null; +let _projectListCache: ReadonlyArray | null = null; +let _projectCache: { + active: Record | null; + all: Record | null; +} = { + active: null, + all: null, +}; let _projectDetailCache: Record | null = null; type ProjectFeatureKey = @@ -68,22 +77,65 @@ function getConfiguredProject(identifier: string): string { /** Clear the project cache — for use in tests only */ export function _clearProjectCache(): void { - _projectCache = null; + _projectListCache = null; + _projectCache = { active: null, all: null }; _projectDetailCache = null; } -function getProjectMap(): Effect.Effect, Error> { - if (_projectCache) return Effect.succeed(_projectCache); +function getProjects({ + includeArchived = false, +}: { + includeArchived?: boolean; +} = {}): Effect.Effect, Error> { + if (_projectListCache) { + return Effect.succeed( + includeArchived + ? _projectListCache + : _projectListCache.filter((project) => !isProjectArchived(project)), + ); + } return Effect.gen(function* () { const raw = yield* api.get("projects/"); const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw); - _projectCache = Object.fromEntries( - results.map((p) => [p.identifier.toUpperCase(), p.id]), - ); - return _projectCache; + _projectListCache = results; + return includeArchived + ? results + : results.filter((project) => !isProjectArchived(project)); }); } +function getProjectMap({ + includeArchived = true, +}: { + includeArchived?: boolean; +} = {}): Effect.Effect, Error> { + const cacheKey = includeArchived ? "all" : "active"; + const cached = _projectCache[cacheKey]; + if (cached) { + return Effect.succeed(cached); + } + return getProjects({ includeArchived }).pipe( + Effect.map((projects) => { + const map = Object.fromEntries( + projects.map((project) => [ + project.identifier.toUpperCase(), + project.id, + ]), + ); + _projectCache[cacheKey] = map; + return map; + }), + ); +} + +export function listProjects({ + includeArchived = false, +}: { + includeArchived?: boolean; +} = {}): Effect.Effect, Error> { + return getProjects({ includeArchived }); +} + function getProjectDetail( projectId: string, ): Effect.Effect { @@ -136,9 +188,12 @@ export function requireProjectFeature( export function resolveProject( identifier: string, + options?: { includeArchived?: boolean }, ): Effect.Effect<{ key: string; id: string }, Error> { const key = getConfiguredProject(identifier).toUpperCase(); - return getProjectMap().pipe( + return getProjectMap({ + includeArchived: options?.includeArchived ?? true, + }).pipe( Effect.flatMap((map) => { const id = map[key]; if (!id) { @@ -265,3 +320,20 @@ export function resolveModule( return { id: module.id, name: module.name }; }); } + +export function resolveCycle( + projectId: string, + nameOrId: string, +): Effect.Effect<{ id: string; name: string }, Error> { + return Effect.gen(function* () { + const raw = yield* api.get(`projects/${projectId}/cycles/`); + const { results } = yield* decodeOrFail(CyclesResponseSchema, raw); + const lower = nameOrId.toLowerCase(); + const cycle = results.find( + (c) => c.id === nameOrId || c.name.toLowerCase() === lower, + ); + if (!cycle) + return yield* Effect.fail(new Error(`Cycle not found: ${nameOrId}`)); + return { id: cycle.id, name: cycle.name }; + }); +} diff --git a/tests/cycles-extended.test.ts b/tests/cycles-extended.test.ts index 755e587..970b501 100644 --- a/tests/cycles-extended.test.ts +++ b/tests/cycles-extended.test.ts @@ -7,7 +7,7 @@ import { expect, it, } from "bun:test"; -import { Effect } from "effect"; +import { Effect, Option } from "effect"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -44,6 +44,12 @@ const CYCLES = [ status: "started", start_date: "2025-01-01", end_date: "2025-01-14", + total_issues: 10, + completed_issues: 3, + cancelled_issues: 0, + started_issues: 4, + unstarted_issues: 3, + backlog_issues: 0, }, { id: "cyc2", name: "Sprint 2", status: "backlog" }, ]; @@ -271,3 +277,256 @@ describe("cycleIssuesAdd", () => { expect(logs.join("\n")).toContain("cyc1"); }); }); + +describe("cyclesCreate", () => { + it("creates a cycle with name only", async () => { + let postedBody: unknown; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, + async ({ request }) => { + postedBody = await request.json(); + return HttpResponse.json( + { id: "cyc-new", name: "Sprint 3", status: "draft" }, + { status: 201 }, + ); + }, + ), + ); + const { cyclesCreateHandler } = await import("@/commands/cycles"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + cyclesCreateHandler({ + project: "ACME", + name: "Sprint 3", + startDate: Option.none(), + endDate: Option.none(), + }), + ); + } finally { + console.log = orig; + } + expect((postedBody as { name: string }).name).toBe("Sprint 3"); + expect(logs.join("\n")).toContain("Created cycle"); + expect(logs.join("\n")).toContain("cyc-new"); + }); + + it("creates a cycle with dates", async () => { + let postedBody: unknown; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, + async ({ request }) => { + postedBody = await request.json(); + return HttpResponse.json( + { + id: "cyc-dated", + name: "Sprint 4", + start_date: "2025-06-01", + end_date: "2025-06-14", + }, + { status: 201 }, + ); + }, + ), + ); + const { cyclesCreateHandler } = await import("@/commands/cycles"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + cyclesCreateHandler({ + project: "ACME", + name: "Sprint 4", + startDate: Option.some("2025-06-01"), + endDate: Option.some("2025-06-14"), + }), + ); + } finally { + console.log = orig; + } + const body = postedBody as { + start_date?: string; + end_date?: string; + }; + expect(body.start_date).toBe("2025-06-01"); + expect(body.end_date).toBe("2025-06-14"); + }); + + it("rejects invalid date format", async () => { + const { cyclesCreateHandler } = await import("@/commands/cycles"); + const result = await Effect.runPromise( + Effect.either( + cyclesCreateHandler({ + project: "ACME", + name: "Bad", + startDate: Option.some("not-a-date"), + endDate: Option.none(), + }), + ), + ); + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect((result.left as Error).message).toContain("YYYY-MM-DD"); + } + }); + + it("rejects invalid calendar date", async () => { + const { cyclesCreateHandler } = await import("@/commands/cycles"); + const result = await Effect.runPromise( + Effect.either( + cyclesCreateHandler({ + project: "ACME", + name: "Bad", + startDate: Option.some("2025-02-30"), + endDate: Option.none(), + }), + ), + ); + expect(result._tag).toBe("Left"); + }); +}); + +describe("cyclesUpdate", () => { + it("updates a cycle by name", async () => { + let patchedBody: unknown; + server.use( + http.patch( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/`, + async ({ request }) => { + patchedBody = await request.json(); + return HttpResponse.json({ + id: "cyc1", + name: "Sprint 1b", + status: "started", + }); + }, + ), + ); + const { cyclesUpdateHandler } = await import("@/commands/cycles"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + cyclesUpdateHandler({ + project: "ACME", + cycle: "Sprint 1", + name: Option.some("Sprint 1b"), + startDate: Option.none(), + endDate: Option.none(), + }), + ); + } finally { + console.log = orig; + } + expect((patchedBody as { name: string }).name).toBe("Sprint 1b"); + expect(logs.join("\n")).toContain("Updated cycle"); + }); + + it("prints nothing-to-update when no options given", async () => { + const { cyclesUpdateHandler } = await import("@/commands/cycles"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + cyclesUpdateHandler({ + project: "ACME", + cycle: "Sprint 1", + name: Option.none(), + startDate: Option.none(), + endDate: Option.none(), + }), + ); + } finally { + console.log = orig; + } + expect(logs.join("\n")).toContain("Nothing to update"); + }); +}); + +describe("cyclesDelete", () => { + it("deletes a cycle by name", async () => { + let deleteCalled = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc2/`, + () => { + deleteCalled = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + const { cyclesDeleteHandler } = await import("@/commands/cycles"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + cyclesDeleteHandler({ project: "ACME", cycle: "Sprint 2" }), + ); + } finally { + console.log = orig; + } + expect(deleteCalled).toBe(true); + expect(logs.join("\n")).toContain("Deleted cycle"); + expect(logs.join("\n")).toContain("Sprint 2"); + }); +}); + +describe("cyclesList display", () => { + it("shows stats and computed status", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, + () => + HttpResponse.json({ + results: [ + { + id: "cyc-future", + name: "Future Sprint", + start_date: "2099-01-01", + end_date: "2099-01-14", + total_issues: 5, + completed_issues: 0, + }, + { + id: "cyc-past", + name: "Past Sprint", + start_date: "2020-01-01", + end_date: "2020-01-14", + total_issues: 8, + completed_issues: 8, + }, + { + id: "cyc-draft", + name: "Draft Sprint", + total_issues: 0, + completed_issues: 0, + }, + ], + }), + ), + ); + const { cyclesListHandler } = await import("@/commands/cycles"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise(cyclesListHandler({ project: "ACME" })); + } finally { + console.log = orig; + } + const output = logs.join("\n"); + expect(output).toContain("upcoming"); + expect(output).toContain("completed"); + expect(output).toContain("draft"); + expect(output).toContain("[0/5]"); + expect(output).toContain("[8/8]"); + }); +}); diff --git a/tests/issue-commands.test.ts b/tests/issue-commands.test.ts index b6b6a44..9c1b48a 100644 --- a/tests/issue-commands.test.ts +++ b/tests/issue-commands.test.ts @@ -77,6 +77,18 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/members/`, () => HttpResponse.json(MEMBERS), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json({ + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + cycle_view: true, + module_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, + }), + ), ); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); @@ -158,6 +170,9 @@ describe("issuesList", () => { state: Option.some("completed"), assignee: Option.none(), priority: Option.none(), + noAssignee: false, + stale: Option.none(), + cycle: Option.none(), }), ); } finally { @@ -209,6 +224,9 @@ describe("issuesList", () => { state: Option.none(), assignee: Option.some("alice@example.com"), priority: Option.none(), + noAssignee: false, + stale: Option.none(), + cycle: Option.none(), }), ); } finally { @@ -260,6 +278,9 @@ describe("issuesList", () => { state: Option.none(), assignee: Option.none(), priority: Option.some("urgent"), + noAssignee: false, + stale: Option.none(), + cycle: Option.none(), }), ); } finally { @@ -285,6 +306,9 @@ describe("issuesList", () => { state: Option.none(), assignee: Option.none(), priority: Option.none(), + noAssignee: false, + stale: Option.none(), + cycle: Option.none(), }), ); } finally { @@ -295,6 +319,183 @@ describe("issuesList", () => { expect(output).toContain("ACME-"); expect(output).toContain("Migrate Button"); }); + + it("filters by --no-assignee", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, + () => + HttpResponse.json({ + results: [ + { + id: "i-assigned", + sequence_id: 1, + name: "Assigned issue", + priority: "high", + state: "s1", + assignees: ["m-alice"], + }, + { + id: "i-unassigned", + sequence_id: 2, + name: "Unassigned issue", + priority: "low", + state: "s1", + assignees: [], + }, + ], + }), + ), + ); + const { issuesListHandler } = await import("@/commands/issues"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + issuesListHandler({ + project: "ACME", + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), + noAssignee: true, + stale: Option.none(), + cycle: Option.none(), + }), + ); + } finally { + console.log = orig; + } + const output = logs.join("\n"); + expect(output).toContain("Unassigned issue"); + expect(output).not.toContain("Assigned issue"); + }); + + it("filters by --stale", async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 60); + const recentDate = new Date(); + recentDate.setDate(recentDate.getDate() - 1); + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, + () => + HttpResponse.json({ + results: [ + { + id: "i-stale", + sequence_id: 1, + name: "Stale issue", + priority: "high", + state: "s1", + updated_at: oldDate.toISOString(), + }, + { + id: "i-recent", + sequence_id: 2, + name: "Recent issue", + priority: "low", + state: "s1", + updated_at: recentDate.toISOString(), + }, + ], + }), + ), + ); + const { issuesListHandler } = await import("@/commands/issues"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + issuesListHandler({ + project: "ACME", + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), + noAssignee: false, + stale: Option.some(30), + cycle: Option.none(), + }), + ); + } finally { + console.log = orig; + } + const output = logs.join("\n"); + expect(output).toContain("Stale issue"); + expect(output).not.toContain("Recent issue"); + }); + + it("filters by --cycle", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, + () => + HttpResponse.json({ + results: [ + { + id: "i-in-cycle", + sequence_id: 1, + name: "In cycle issue", + priority: "high", + state: "s1", + }, + { + id: "i-not-in-cycle", + sequence_id: 2, + name: "Not in cycle", + priority: "low", + state: "s1", + }, + ], + }), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, + () => + HttpResponse.json({ + results: [{ id: "cyc-1", name: "Sprint 1", status: "started" }], + }), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc-1/cycle-issues/`, + () => + HttpResponse.json({ + results: [ + { + id: "i-in-cycle", + sequence_id: 1, + name: "In cycle issue", + priority: "high", + state: "s1", + }, + ], + }), + ), + ); + const { issuesListHandler } = await import("@/commands/issues"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + issuesListHandler({ + project: "ACME", + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), + noAssignee: false, + stale: Option.none(), + cycle: Option.some("Sprint 1"), + }), + ); + } finally { + console.log = orig; + } + const output = logs.join("\n"); + expect(output).toContain("In cycle issue"); + expect(output).not.toContain("Not in cycle"); + }); }); describe("issueUpdate", () => { @@ -329,8 +530,13 @@ describe("issueUpdate", () => { title: Option.none(), description: Option.none(), assignee: Option.none(), - label: Option.none(), + label: [], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); } finally { @@ -382,8 +588,13 @@ describe("issueUpdate", () => { title: Option.none(), description: Option.none(), assignee: Option.none(), - label: Option.none(), + label: [], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); } finally { @@ -404,8 +615,13 @@ describe("issueUpdate", () => { title: Option.none(), description: Option.none(), assignee: Option.none(), - label: Option.none(), + label: [], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ), ); @@ -442,8 +658,13 @@ describe("issueUpdate", () => { title: Option.some("New title"), description: Option.none(), assignee: Option.none(), - label: Option.none(), + label: [], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -550,7 +771,12 @@ describe("issueCreate", () => { state: Option.none(), description: Option.none(), assignee: Option.none(), - label: Option.none(), + label: [], + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); } finally { @@ -596,7 +822,12 @@ describe("issueCreate", () => { state: Option.some("completed"), description: Option.none(), assignee: Option.none(), - label: Option.none(), + label: [], + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); } finally { @@ -635,7 +866,12 @@ describe("issueCreate description", () => { state: Option.none(), description: Option.some("Some context here"), assignee: Option.none(), - label: Option.none(), + label: [], + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -671,7 +907,12 @@ describe("issueCreate description", () => { state: Option.none(), description: Option.some("

Raw HTML

"), assignee: Option.none(), - label: Option.none(), + label: [], + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -709,8 +950,13 @@ describe("issueUpdate description", () => { title: Option.none(), description: Option.some("Updated description"), assignee: Option.none(), - label: Option.none(), + label: [], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -746,8 +992,13 @@ describe("issueUpdate description", () => { title: Option.none(), description: Option.some("bold"), assignee: Option.none(), - label: Option.none(), + label: [], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -785,8 +1036,13 @@ describe("issueUpdate assignee", () => { title: Option.none(), description: Option.none(), assignee: Option.some("Alice"), - label: Option.none(), + label: [], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -822,8 +1078,13 @@ describe("issueUpdate assignee", () => { title: Option.none(), description: Option.none(), assignee: Option.none(), - label: Option.none(), + label: [], noAssignee: true, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -857,8 +1118,13 @@ describe("issueUpdate assignee", () => { title: Option.none(), description: Option.none(), assignee: Option.some("bob@example.com"), - label: Option.none(), + label: [], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -896,7 +1162,12 @@ describe("issueCreate assignee", () => { state: Option.none(), description: Option.none(), assignee: Option.some("Alice"), - label: Option.none(), + label: [], + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); @@ -934,14 +1205,17 @@ describe("issueUpdate label", () => { title: Option.none(), description: Option.none(), assignee: Option.none(), - label: Option.some("bug"), + label: ["bug"], noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); - expect((patchedBody as { label_ids?: string[] }).label_ids).toEqual([ - "l-bug", - ]); + expect((patchedBody as { labels?: string[] }).labels).toEqual(["l-bug"]); }); }); @@ -973,13 +1247,16 @@ describe("issueCreate label", () => { state: Option.none(), description: Option.none(), assignee: Option.none(), - label: Option.some("Bug"), + label: ["Bug"], + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), }), ); - expect((postedBody as { label_ids?: string[] }).label_ids).toEqual([ - "l-bug", - ]); + expect((postedBody as { labels?: string[] }).labels).toEqual(["l-bug"]); }); }); @@ -1130,3 +1407,322 @@ describe("--description argv parsing", () => { ).toBe("New desc"); }); }); + +describe("issueUpdate new fields", () => { + it("sets startDate and targetDate", async () => { + let patchedBody: unknown; + server.use( + http.patch( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`, + async ({ request }) => { + patchedBody = await request.json(); + return HttpResponse.json({ + id: "i1", + sequence_id: 29, + name: "Migrate Button", + priority: "high", + state: "s1", + }); + }, + ), + ); + const { issueUpdateHandler } = await import("@/commands/issue"); + await Effect.runPromise( + issueUpdateHandler({ + ref: "ACME-29", + state: Option.none(), + priority: Option.none(), + title: Option.none(), + description: Option.none(), + assignee: Option.none(), + label: [], + noAssignee: false, + startDate: Option.some("2025-07-01"), + targetDate: Option.some("2025-07-15"), + estimate: Option.none(), + cycle: Option.none(), + module: Option.none(), + }), + ); + const body = patchedBody as { + start_date?: string; + target_date?: string; + }; + expect(body.start_date).toBe("2025-07-01"); + expect(body.target_date).toBe("2025-07-15"); + }); + + it("sets estimate", async () => { + let patchedBody: unknown; + server.use( + http.patch( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`, + async ({ request }) => { + patchedBody = await request.json(); + return HttpResponse.json({ + id: "i1", + sequence_id: 29, + name: "Migrate Button", + priority: "high", + state: "s1", + }); + }, + ), + ); + const { issueUpdateHandler } = await import("@/commands/issue"); + await Effect.runPromise( + issueUpdateHandler({ + ref: "ACME-29", + state: Option.none(), + priority: Option.none(), + title: Option.none(), + description: Option.none(), + assignee: Option.none(), + label: [], + noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.some("5"), + cycle: Option.none(), + module: Option.none(), + }), + ); + expect((patchedBody as { estimate_point?: string }).estimate_point).toBe( + "5", + ); + }); + + it("adds issue to cycle on update", async () => { + let cyclePOSTcalled = false; + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, + () => + HttpResponse.json({ + results: [{ id: "cyc-x", name: "Sprint X", status: "started" }], + }), + ), + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc-x/cycle-issues/`, + () => { + cyclePOSTcalled = true; + return HttpResponse.json({ issues: ["i1"] }, { status: 201 }); + }, + ), + ); + const { issueUpdateHandler } = await import("@/commands/issue"); + await Effect.runPromise( + issueUpdateHandler({ + ref: "ACME-29", + state: Option.none(), + priority: Option.none(), + title: Option.none(), + description: Option.none(), + assignee: Option.none(), + label: [], + noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.some("Sprint X"), + module: Option.none(), + }), + ); + expect(cyclePOSTcalled).toBe(true); + }); + + it("adds issue to module on update", async () => { + let modulePOSTcalled = false; + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, + () => + HttpResponse.json({ + results: [{ id: "mod-y", name: "Module Y", status: "active" }], + }), + ), + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod-y/module-issues/`, + () => { + modulePOSTcalled = true; + return HttpResponse.json({ issues: ["i1"] }, { status: 201 }); + }, + ), + ); + const { issueUpdateHandler } = await import("@/commands/issue"); + await Effect.runPromise( + issueUpdateHandler({ + ref: "ACME-29", + state: Option.none(), + priority: Option.none(), + title: Option.none(), + description: Option.none(), + assignee: Option.none(), + label: [], + noAssignee: false, + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.some("Module Y"), + }), + ); + expect(modulePOSTcalled).toBe(true); + }); +}); + +describe("issueCreate new fields", () => { + it("sets dates and estimate on create", async () => { + let postedBody: unknown; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, + async ({ request }) => { + postedBody = await request.json(); + return HttpResponse.json({ + id: "new-dates", + sequence_id: 400, + name: "Dated issue", + priority: "none", + state: "s1", + }); + }, + ), + ); + const { issueCreateHandler } = await import("@/commands/issue"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + issueCreateHandler({ + project: "ACME", + title: "Dated issue", + priority: Option.none(), + state: Option.none(), + description: Option.none(), + assignee: Option.none(), + label: [], + startDate: Option.some("2025-08-01"), + targetDate: Option.some("2025-08-15"), + estimate: Option.some("3"), + cycle: Option.none(), + module: Option.none(), + }), + ); + } finally { + console.log = orig; + } + const body = postedBody as { + start_date?: string; + target_date?: string; + estimate_point?: string; + }; + expect(body.start_date).toBe("2025-08-01"); + expect(body.target_date).toBe("2025-08-15"); + expect(body.estimate_point).toBe("3"); + expect(logs.join("\n")).toContain("Created ACME-400"); + }); + + it("adds created issue to cycle", async () => { + let cyclePOSTcalled = false; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, + async ({ request }) => { + const b = await request.json(); + return HttpResponse.json({ + id: "new-cyc", + sequence_id: 401, + name: (b as { name: string }).name, + priority: "none", + state: "s1", + }); + }, + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, + () => + HttpResponse.json({ + results: [{ id: "cyc-a", name: "Sprint A", status: "started" }], + }), + ), + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc-a/cycle-issues/`, + () => { + cyclePOSTcalled = true; + return HttpResponse.json({ issues: ["new-cyc"] }, { status: 201 }); + }, + ), + ); + const { issueCreateHandler } = await import("@/commands/issue"); + await Effect.runPromise( + issueCreateHandler({ + project: "ACME", + title: "Cycle-bound issue", + priority: Option.none(), + state: Option.none(), + description: Option.none(), + assignee: Option.none(), + label: [], + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.some("Sprint A"), + module: Option.none(), + }), + ); + expect(cyclePOSTcalled).toBe(true); + }); + + it("adds created issue to module", async () => { + let modulePOSTcalled = false; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, + async ({ request }) => { + const b = await request.json(); + return HttpResponse.json({ + id: "new-mod", + sequence_id: 402, + name: (b as { name: string }).name, + priority: "none", + state: "s1", + }); + }, + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, + () => + HttpResponse.json({ + results: [{ id: "mod-b", name: "Module B", status: "active" }], + }), + ), + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod-b/module-issues/`, + () => { + modulePOSTcalled = true; + return HttpResponse.json({ issues: ["new-mod"] }, { status: 201 }); + }, + ), + ); + const { issueCreateHandler } = await import("@/commands/issue"); + await Effect.runPromise( + issueCreateHandler({ + project: "ACME", + title: "Module-bound issue", + priority: Option.none(), + state: Option.none(), + description: Option.none(), + assignee: Option.none(), + label: [], + startDate: Option.none(), + targetDate: Option.none(), + estimate: Option.none(), + cycle: Option.none(), + module: Option.some("Module B"), + }), + ); + expect(modulePOSTcalled).toBe(true); + }); +}); diff --git a/tests/json-output.test.ts b/tests/json-output.test.ts index bc08630..372fad0 100644 --- a/tests/json-output.test.ts +++ b/tests/json-output.test.ts @@ -361,6 +361,9 @@ describe("issuesList --json", () => { state: Option.none(), assignee: Option.none(), priority: Option.none(), + noAssignee: false, + stale: Option.none(), + cycle: Option.none(), }), ), ); diff --git a/tests/project-features.test.ts b/tests/project-features.test.ts index 72db6b1..dae77d4 100644 --- a/tests/project-features.test.ts +++ b/tests/project-features.test.ts @@ -23,6 +23,12 @@ const ORIGINAL_CWD = process.cwd(); const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, + { + id: "proj-old", + identifier: "OLD", + name: "Old Project", + archived_at: "2025-01-01T00:00:00Z", + }, ]; const PROJECT_DETAIL = { @@ -185,6 +191,7 @@ describe("feature gates", () => { } const output = logs.join("\n"); + expect(output).not.toContain("OLD Old Project"); expect(output).toContain("Project feature flags:"); expect(output).toContain("Cycles: disabled"); expect(output).toContain("Modules: enabled"); @@ -232,6 +239,32 @@ describe("feature gates", () => { expect(helper.helpers.estimate.pointsByValue["1"].id).toBe("ep1"); }); + it("includes archived projects in init selection when requested", async () => { + const { initHandler } = await import("@/commands/init"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + process.chdir(repoDir); + promptResponses = ["", "", "", "1"]; + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + initHandler( + { global: false, local: true, includeArchived: true }, + "local", + ), + ); + } finally { + console.log = orig; + } + + const output = logs.join("\n"); + expect(output).toContain("1. ACME Acme Project"); + expect(output).toContain("2. OLD Old Project (archived)"); + }); + it("preserves user AGENTS content and refreshes the managed project section", async () => { const { initHandler } = await import("@/commands/init"); const { getLocalAgentsFilePath } = await import("@/project-agents"); @@ -278,6 +311,45 @@ describe("feature gates", () => { ); expect(agentsContent).toContain("plane projects current"); }); + + it("succeeds with estimates disabled when estimates endpoint returns 404", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/estimates/`, + () => HttpResponse.json({ error: "Page not found." }, { status: 404 }), + ), + ); + const { initHandler } = await import("@/commands/init"); + const { getLocalProjectContextFilePath } = await import( + "@/project-context" + ); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + process.chdir(repoDir); + promptResponses = ["", "", "", "1"]; + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + } finally { + console.log = orig; + } + const output = logs.join("\n"); + expect(output).toContain("Project helper saved to"); + expect(output).toContain("States: 2"); + expect(output).toContain("Labels: 2"); + expect(output).toContain("Estimate: disabled"); + expect(output).not.toContain("could not load project helper data"); + const helperPath = getLocalProjectContextFilePath(repoDir); + expect(fs.existsSync(helperPath)).toBe(true); + const helper = JSON.parse(fs.readFileSync(helperPath, "utf8")) as { + helpers: { estimate: { enabled: boolean } }; + }; + expect(helper.helpers.estimate.enabled).toBe(false); + }); }); describe("SKILL.md import into AGENTS.md", () => { diff --git a/tests/projects.test.ts b/tests/projects.test.ts index 00ccf4d..1050822 100644 --- a/tests/projects.test.ts +++ b/tests/projects.test.ts @@ -25,6 +25,12 @@ const ORIGINAL_CWD = process.cwd(); const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, { id: "proj-web", identifier: "WEB", name: "Web Project" }, + { + id: "proj-old", + identifier: "OLD", + name: "Old Project", + archived_at: "2025-01-01T00:00:00Z", + }, ]; const server = setupServer( @@ -231,5 +237,22 @@ describe("projectsList", () => { } expect(logs.join("\n")).toContain("* WEB"); + expect(logs.join("\n")).not.toContain("OLD"); + }); + + it("includes archived projects when requested", async () => { + const { projectsListHandler } = await import("@/commands/projects"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(projectsListHandler({ includeArchived: true })); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("OLD"); + expect(logs.join("\n")).toContain("(archived)"); }); }); diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index 1f3175a..940f466 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -16,6 +16,7 @@ import { getMemberId, getStateId, parseIssueRef, + resolveCycle, resolveProject, } from "@/resolve"; @@ -68,6 +69,14 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/members/`, () => HttpResponse.json(MEMBERS), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, () => + HttpResponse.json({ + results: [ + { id: "cyc-1", name: "Sprint 1", status: "started" }, + { id: "cyc-2", name: "Sprint 2", status: "backlog" }, + ], + }), + ), ); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); @@ -258,3 +267,30 @@ describe("getMemberId", () => { } }); }); + +describe("resolveCycle", () => { + it("finds cycle by id", async () => { + const result = await Effect.runPromise(resolveCycle("proj-acme", "cyc-1")); + expect(result.id).toBe("cyc-1"); + expect(result.name).toBe("Sprint 1"); + }); + + it("finds cycle by name (case-insensitive)", async () => { + const result = await Effect.runPromise( + resolveCycle("proj-acme", "sprint 2"), + ); + expect(result.id).toBe("cyc-2"); + }); + + it("fails when cycle not found", async () => { + const result = await Effect.runPromise( + Effect.either(resolveCycle("proj-acme", "nonexistent")), + ); + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect((result.left as Error).message).toContain( + "Cycle not found: nonexistent", + ); + } + }); +}); diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index 5bb481a..bdcc76d 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -148,6 +148,16 @@ describe("ProjectSchema", () => { }); expect(p.description).toBe("desc"); }); + + it("accepts archived_at metadata", async () => { + const p = await decode(ProjectSchema, { + id: "p1", + identifier: "ACME", + name: "Acme Project", + archived_at: "2025-01-01T00:00:00Z", + }); + expect(p.archived_at).toBe("2025-01-01T00:00:00Z"); + }); }); describe("ProjectsResponseSchema", () => { diff --git a/tests/stats.test.ts b/tests/stats.test.ts new file mode 100644 index 0000000..5faf6b6 --- /dev/null +++ b/tests/stats.test.ts @@ -0,0 +1,708 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test"; +import { Effect, Option } from "effect"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { toXml } from "@/output"; +import { _clearProjectCache } from "@/resolve"; + +const BASE = "http://stats-test.local"; +const WS = "testws"; + +const PROJECTS = [ + { id: "proj-acme", identifier: "ACME", name: "Acme" }, + { id: "proj-web", identifier: "WEB", name: "Website" }, + { + id: "proj-old", + identifier: "OLD", + name: "Old Project", + archived_at: "2025-01-01T00:00:00Z", + }, +]; +const PROJECT_DETAILS = { + "proj-acme": { + id: "proj-acme", + identifier: "ACME", + name: "Acme", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, + }, + "proj-web": { + id: "proj-web", + identifier: "WEB", + name: "Website", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, + }, +} as const; + +const ACME_ISSUES = [ + { + id: "i1", + sequence_id: 1, + name: "Issue One", + priority: "high", + state: { id: "s1", name: "In Progress", group: "started" }, + assignees: ["m-alice"], + created_at: "2025-01-10T10:00:00Z", + completed_at: null, + }, + { + id: "i2", + sequence_id: 2, + name: "Issue Two", + priority: "low", + state: { id: "s2", name: "Done", group: "completed" }, + assignees: ["m-bob"], + created_at: "2025-01-12T10:00:00Z", + completed_at: "2025-01-14T10:00:00Z", + }, + { + id: "i3", + sequence_id: 3, + name: "Issue Three", + priority: "none", + state: { id: "s3", name: "Backlog", group: "backlog" }, + assignees: [], + created_at: "2025-02-01T10:00:00Z", + completed_at: null, + }, + { + id: "i4", + sequence_id: 4, + name: "Issue Four", + priority: "medium", + state: { id: "s4", name: "Cancelled", group: "cancelled" }, + assignees: ["m-alice"], + created_at: "2025-01-20T10:00:00Z", + completed_at: null, + }, +]; + +const WEB_ISSUES = [ + { + id: "w1", + sequence_id: 1, + name: "Landing refresh", + priority: "urgent", + state: { id: "s5", name: "In Progress", group: "started" }, + assignees: [], + created_at: "2025-01-11T10:00:00Z", + completed_at: null, + }, + { + id: "w2", + sequence_id: 2, + name: "Pricing cleanup", + priority: "medium", + state: { id: "s6", name: "Done", group: "completed" }, + assignees: ["m-bob"], + created_at: "2025-02-10T10:00:00Z", + completed_at: "2025-02-12T10:00:00Z", + }, +]; + +const OLD_ISSUES = [ + { + id: "o1", + sequence_id: 1, + name: "Archived cleanup", + priority: "low", + state: { id: "s7", name: "Done", group: "completed" }, + assignees: [], + created_at: "2024-12-10T10:00:00Z", + completed_at: "2024-12-12T10:00:00Z", + }, +]; + +const MEMBERS = [ + { id: "m-alice", display_name: "Alice", email: "alice@example.com" }, + { id: "m-bob", display_name: "Bob", email: "bob@example.com" }, +]; + +const CYCLES = [{ id: "cyc1", name: "Sprint 1", status: "started" }]; +const CYCLE_ISSUES = [ + { + id: "i1", + sequence_id: 1, + name: "Issue One", + priority: "high", + state: { id: "s1", name: "In Progress", group: "started" }, + }, + { + id: "i2", + sequence_id: 2, + name: "Issue Two", + priority: "low", + state: { id: "s2", name: "Done", group: "completed" }, + }, +]; + +const MODULES = [{ id: "mod1", name: "Module Alpha", status: "in-progress" }]; +const MODULE_ISSUES = [ + { + id: "i3", + sequence_id: 3, + name: "Issue Three", + priority: "none", + state: { id: "s3", name: "Backlog", group: "backlog" }, + }, +]; + +function paginatedIssuesResponse( + issues: typeof ACME_ISSUES, + cursor: string | null, +) { + if (cursor === "2:1:0") { + return HttpResponse.json({ + results: issues.slice(2), + next_cursor: null, + next_page_results: false, + }); + } + + if (issues.length > 2) { + return HttpResponse.json({ + results: issues.slice(0, 2), + next_cursor: "2:1:0", + next_page_results: true, + }); + } + + return HttpResponse.json({ + results: issues, + next_cursor: null, + next_page_results: false, + }); +} + +const server = setupServer( + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => + HttpResponse.json({ results: PROJECTS }), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/:projectId/`, + ({ params }) => + HttpResponse.json( + PROJECT_DETAILS[params.projectId as "proj-acme" | "proj-web"], + ), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, + ({ request }) => + paginatedIssuesResponse( + ACME_ISSUES, + new URL(request.url).searchParams.get("cursor"), + ), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-web/issues/`, + ({ request }) => + paginatedIssuesResponse( + WEB_ISSUES, + new URL(request.url).searchParams.get("cursor"), + ), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-old/issues/`, + ({ request }) => + paginatedIssuesResponse( + OLD_ISSUES, + new URL(request.url).searchParams.get("cursor"), + ), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/members/`, () => + HttpResponse.json(MEMBERS), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/`, () => + HttpResponse.json({ results: CYCLES }), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, + () => HttpResponse.json({ results: CYCLE_ISSUES }), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/`, () => + HttpResponse.json({ results: MODULES }), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/`, + () => HttpResponse.json({ results: MODULE_ISSUES }), + ), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +beforeEach(() => { + _clearProjectCache(); + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; +}); + +afterEach(() => { + server.resetHandlers(); + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; +}); + +async function captureLogs(fn: () => Promise): Promise { + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await fn(); + } finally { + console.log = orig; + } + return logs.join("\n"); +} + +describe("stats command", () => { + it("aggregates all issues with correct counts", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain("ACME Stats"); + expect(output).toContain("Total issues: 4"); + expect(output).toContain("backlog=1"); + expect(output).toContain("started=1"); + expect(output).toContain("completed=1"); + expect(output).toContain("cancelled=1"); + expect(output).toContain("Created: 4"); + expect(output).toContain("Completed: 1"); + expect(output).toContain("Assignee spread: 3 assigned, 1 unassigned"); + }); + + it("counts created and completed in a --since period without shrinking total issues", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.some("2025-01-15"), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain("ACME Stats (2025-01-15 to ...)"); + expect(output).toContain("Total issues: 4"); + expect(output).toContain("Created: 2 (in range)"); + expect(output).toContain("Completed: 0 (in range)"); + }); + + it("counts created and completed in a --until period", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.none(), + until: Option.some("2025-01-15"), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain("ACME Stats (... to 2025-01-15)"); + expect(output).toContain("Total issues: 4"); + expect(output).toContain("Created: 2 (in range)"); + expect(output).toContain("Completed: 1 (in range)"); + }); + + it("counts created and completed in a bounded date range", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.some("2025-01-11"), + until: Option.some("2025-02-01"), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain("ACME Stats (2025-01-11 to 2025-02-01)"); + expect(output).toContain("Total issues: 4"); + expect(output).toContain("Created: 2 (in range)"); + expect(output).toContain("Completed: 1 (in range)"); + }); + + it("filters by --assignee", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.some("Alice"), + }), + ), + ); + + expect(output).toContain("Total issues: 2"); + expect(output).toContain("Assignee spread: 2 assigned, 0 unassigned"); + }); + + it("scopes to a cycle", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.none(), + until: Option.none(), + cycle: Option.some("Sprint 1"), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain("Total issues: 2"); + }); + + it("scopes to a module", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.some("Module Alpha"), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain("Total issues: 1"); + }); + + it("aggregates across all projects when project=workspace", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "workspace", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain("Workspace testws Stats"); + expect(output).toContain("Total issues: 6"); + expect(output).toContain("started=2"); + expect(output).toContain("completed=2"); + expect(output).toContain("Created: 6"); + expect(output).toContain("Completed: 2"); + expect(output).toContain("Projects:"); + expect(output).toContain("ACME: total=4, created=4, completed=1"); + expect(output).toContain("WEB: total=2, created=2, completed=1"); + expect(output).not.toContain("OLD:"); + }); + + it("includes archived projects in workspace aggregation when requested", async () => { + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "workspace", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + includeArchived: true, + }), + ), + ); + + expect(output).toContain("Workspace testws Stats"); + expect(output).toContain("Total issues: 7"); + expect(output).toContain("OLD: total=1, created=1, completed=1"); + }); + + it("rejects project-only filters for workspace aggregation", async () => { + const { statsHandler } = await import("@/commands/stats"); + await expect( + Effect.runPromise( + statsHandler({ + project: "workspace", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.some("Alice"), + }), + ), + ).rejects.toThrow( + "Workspace stats currently support only --since and --until.", + ); + }); + + it("skips inaccessible projects in workspace aggregation", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-web/issues/`, + () => + new HttpResponse( + '{"detail":"You do not have permission to perform this action."}', + { status: 403 }, + ), + ), + ); + + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "workspace", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain("Workspace testws Stats"); + expect(output).toContain("Total issues: 4"); + expect(output).toContain("Skipped projects: WEB"); + }); +}); + +describe("stats --json", () => { + it("outputs structured JSON stats", async () => { + mock.module("@/output", () => ({ + jsonMode: true, + xmlMode: false, + toXml, + })); + + // Re-import to pick up mocked output module + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + const parsed = JSON.parse(output); + expect(parsed.project).toBe("ACME"); + expect(parsed.total_issues).toBe(4); + expect(parsed.by_state_group.started).toBe(1); + expect(parsed.by_state_group.completed).toBe(1); + expect(parsed.by_state_group.backlog).toBe(1); + expect(parsed.by_state_group.cancelled).toBe(1); + expect(parsed.by_priority.high).toBe(1); + expect(parsed.by_priority.low).toBe(1); + expect(parsed.by_priority.none).toBe(1); + expect(parsed.by_priority.medium).toBe(1); + expect(parsed.created_in_range).toBe(4); + expect(parsed.completed_in_range).toBe(1); + expect(parsed.assigned).toBe(3); + expect(parsed.unassigned).toBe(1); + + // Restore + mock.module("@/output", () => ({ + jsonMode: false, + xmlMode: false, + toXml, + })); + }); +}); + +describe("stats --xml", () => { + it("outputs XML stats", async () => { + mock.module("@/output", () => ({ + jsonMode: false, + xmlMode: true, + toXml, + })); + + const { statsHandler } = await import("@/commands/stats"); + const output = await captureLogs(() => + Effect.runPromise( + statsHandler({ + project: "ACME", + since: Option.none(), + until: Option.none(), + cycle: Option.none(), + module: Option.none(), + assignee: Option.none(), + }), + ), + ); + + expect(output).toContain(""); + expect(output).toContain('project="ACME"'); + expect(output).toContain('total_issues="4"'); + + // Restore + mock.module("@/output", () => ({ + jsonMode: false, + xmlMode: false, + toXml, + })); + }); +}); + +describe("formatStats", () => { + it("formats stats without date range", () => { + const { formatStats } = require("@/format"); + const result = formatStats({ + project: "ACME", + total_issues: 10, + by_state_group: { backlog: 3, started: 4, completed: 3 }, + by_priority: { high: 5, low: 5 }, + created_in_range: 10, + completed_in_range: 3, + assigned: 7, + unassigned: 3, + }); + + expect(result).toContain("ACME Stats"); + expect(result).toContain("Total issues: 10"); + expect(result).not.toContain("(in range)"); + expect(result).toContain("backlog=3"); + expect(result).toContain("Completed: 3"); + expect(result).toContain("Assignee spread: 7 assigned, 3 unassigned"); + }); + + it("formats stats with date range", () => { + const { formatStats } = require("@/format"); + const result = formatStats({ + project: "TEST", + period: { since: "2025-01-01", until: "2025-02-01" }, + total_issues: 5, + by_state_group: { started: 5 }, + by_priority: { medium: 5 }, + created_in_range: 5, + completed_in_range: 0, + assigned: 5, + unassigned: 0, + }); + + expect(result).toContain("TEST Stats (2025-01-01 to 2025-02-01)"); + expect(result).toContain("Created: 5 (in range)"); + }); + + it("omits zero-count groups and priorities", () => { + const { formatStats } = require("@/format"); + const result = formatStats({ + project: "X", + total_issues: 2, + by_state_group: { backlog: 0, started: 2, completed: 0 }, + by_priority: { high: 0, medium: 2, low: 0, none: 0 }, + created_in_range: 2, + completed_in_range: 0, + assigned: 2, + unassigned: 0, + }); + + expect(result).toContain("started"); + expect(result).not.toContain("backlog"); + expect(result).toContain("medium"); + expect(result).not.toContain("high"); + }); + + it("formats workspace stats with project breakdown", () => { + const { formatStats } = require("@/format"); + const result = formatStats({ + workspace: "testws", + total_issues: 6, + by_state_group: { started: 2, completed: 2, backlog: 1, cancelled: 1 }, + by_priority: { urgent: 1, high: 1, medium: 2, low: 1, none: 1 }, + created_in_range: 6, + completed_in_range: 2, + assigned: 4, + unassigned: 2, + projects: [ + { + project: "ACME", + total_issues: 4, + by_state_group: { started: 1 }, + by_priority: { high: 1 }, + created_in_range: 4, + completed_in_range: 1, + assigned: 3, + unassigned: 1, + }, + ], + }); + + expect(result).toContain("Workspace testws Stats"); + expect(result).toContain("Projects:"); + expect(result).toContain("ACME: total=4, created=4, completed=1"); + }); + + it("formats skipped workspace projects", () => { + const { formatStats } = require("@/format"); + const result = formatStats({ + workspace: "testws", + total_issues: 4, + by_state_group: { started: 1 }, + by_priority: { high: 1 }, + created_in_range: 4, + completed_in_range: 1, + assigned: 3, + unassigned: 1, + projects: [], + skipped_projects: ["WEB"], + }); + + expect(result).toContain("Skipped projects: WEB"); + }); +}); diff --git a/tests/xml-output.test.ts b/tests/xml-output.test.ts index bc6f452..6c7a73f 100644 --- a/tests/xml-output.test.ts +++ b/tests/xml-output.test.ts @@ -351,6 +351,9 @@ describe("issuesList --xml", () => { state: Option.none(), assignee: Option.none(), priority: Option.none(), + noAssignee: false, + stale: Option.none(), + cycle: Option.none(), }), ), );