diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 113d334..5214e15 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,11 @@ blank_issues_enabled: false contact_links: - name: Security policy - url: https://github.com/backslash-ux/plane-cli/blob/main/SECURITY.md + url: https://github.com/backslash-ux/plane-cli-cli/blob/main/SECURITY.md about: Report suspected vulnerabilities privately instead of opening a public issue. - name: Contributing guide - url: https://github.com/backslash-ux/plane-cli/blob/main/CONTRIBUTING.md + url: https://github.com/backslash-ux/plane-cli-cli/blob/main/CONTRIBUTING.md about: Review contribution expectations, quality gates, and documentation requirements first. - name: Existing issues - url: https://github.com/backslash-ux/plane-cli/issues + url: https://github.com/backslash-ux/plane-cli-cli/issues about: Check open issues and feature requests before filing a new one. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 40557fa..3d3a8e1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,12 @@ node_modules/ dist/ .env *.env +.plane/ # Local AI/editor customization files .github/copilot-instructions.md .github/instructions/ .github/prompts/ .github/agents/ -.vscode/docs/ +.vscode/ diff --git a/.husky/pre-commit b/.husky/pre-commit index dc2222b..ccf6944 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,16 @@ +# Locate bun - git hooks run with a stripped PATH that often omits ~/.bun/bin +BUN=$(command -v bun 2>/dev/null \ + || command -v "$HOME/.bun/bin/bun" 2>/dev/null \ + || echo "") +if [ -z "$BUN" ]; then + echo "husky: bun not found in PATH or ~/.bun/bin – please install bun or add it to your PATH" >&2 + exit 1 +fi + # Check file sizes echo "Checking file sizes..." -bun scripts/check-file-size.ts +"$BUN" scripts/check-file-size.ts # Check test coverage echo "Checking test coverage..." -bun run test:coverage:check +"$BUN" scripts/check-coverage.ts diff --git a/AGENTS.md b/AGENTS.md index 5a13b76..06f9d4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,4 +51,39 @@ Before making non-trivial changes: - Keep public documentation presentable and accurate. - Use [CHANGELOG.md](./CHANGELOG.md) for notable user-facing changes. - Follow [docs/RELEASING.md](./docs/RELEASING.md) for release workflow expectations. -- Respect [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) and [SECURITY.md](./SECURITY.md). \ No newline at end of file +- Respect [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) and [SECURITY.md](./SECURITY.md). + +## Known Deployment Compatibility + +The CLI has been validated against a real Plane instance. Be aware of these deployment-dependent behaviors when developing or testing: + +- **Pages**: The CLI targets the project-page API surface. Plane also has a separate workspace wiki page surface that the CLI does not cover. Both may return 404 on some deployments even when the project feature flag is enabled. +- **Worklogs**: Time tracking is a Pro-plan feature. Non-Pro deployments will not expose worklog API endpoints. +- **Feature gating**: The CLI returns explicit compatibility errors (not raw 404s) when a project feature is flagged on but the backing API route is absent. + + +## Plane Project Context +This directory is scoped to Plane project PLANECLI (Plane CLI). + +When working as an AI agent in this directory: +- Read `./.plane/project-context.json` before planning or applying Plane project changes. +- Reuse the existing states, labels, and estimate points in that snapshot instead of creating duplicates. +- Respect the feature flags in that snapshot before using cycles, modules, pages, intake, or estimates. +- Prefer the `plane` CLI from this repository root for Plane project work instead of direct API calls. +- Use `@current` as the default project selector once local init has been run. +- If the shell may contain inherited `PLANE_*` variables, clear them before relying on `./.plane/config.json`. + +Common agent commands: + +```sh +unset PLANE_HOST PLANE_WORKSPACE PLANE_API_TOKEN PLANE_PROJECT +plane projects current +plane issues list @current +plane issue get PLANECLI-12 +plane issue update --state started PLANECLI-12 +``` + +- Rerun `plane init --local` from this directory whenever the Plane project configuration changes so this context stays current. + +This section is managed by `plane-cli` and is updated by `plane init --local`. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 1227c64..87b5185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,5 +13,18 @@ Earlier project history may predate this file. - Public open-source repository baseline with contributor, governance, security, architecture, and release documentation. - GitHub issue and pull request templates plus issue intake routing. - Stricter repository quality gates covering formatting, file-size limits, and coverage thresholds. -- Fork-specific package identity and public metadata alignment under `@backslash-ux/plane`. -- A versioned root `AGENTS.md` file that provides baseline context for AI coding agents contributing to the repository. \ No newline at end of file +- Fork-specific package identity and public metadata alignment under `@backslash-ux/plane-cli`. +- A versioned root `AGENTS.md` file that provides baseline context for AI coding agents contributing to the repository. +- `plane labels delete` and `plane modules delete` cleanup commands. + +### Changed + +- Tightened published CLI documentation so README and SKILL examples match the validated command grammar, identifier semantics, and deployment-compatibility behavior. +- Updated compatibility notes in README and SKILL to document confirmed page and worklog deployment dependencies. + +### Validated + +- 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 diff --git a/README.md b/README.md index 5894658..22a4057 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # plane -[![CI](https://github.com/backslash-ux/plane-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/backslash-ux/plane-cli/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![CI](https://github.com/backslash-ux/plane-cli-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/backslash-ux/plane-cli-cli/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) CLI for the [Plane](https://plane.so) project management API. @@ -31,16 +31,35 @@ This repository is a fork of [aaronshaf/plane-cli](https://github.com/aaronshaf/ ```bash curl -fsSL https://bun.sh/install | bash -bun install -g @backslash-ux/plane +bun install -g @backslash-ux/plane-cli ``` ## Setup ```bash -plane init +plane init -g ``` -Prompts for your Plane host, workspace slug, and API token. Saves to `~/.config/plane/config.json` (mode 0600). Safe to re-run. +Prompts for your Plane host, workspace, and API token. Global setup saves to `~/.config/plane/config.json` (mode 0600). Safe to re-run. +It also offers an optional current-project selection so repeated project-scoped commands can reuse the same context. + +For path-local overrides in the current project directory: + +```bash +plane init --local +plane . init +``` + +Local setup writes `./.plane/config.json`. When the CLI runs, it resolves config with this precedence: + +```text +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. +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. You can also use environment variables (override saved config): @@ -48,31 +67,56 @@ You can also use environment variables (override saved config): PLANE_API_TOKEN=... PLANE_HOST=https://plane.so PLANE_WORKSPACE=myworkspace +PLANE_PROJECT=PROJ # optional saved-project override ``` +To persist a current project after setup: + +```bash +plane projects list +plane projects use PROJ +plane projects use PROJ --local +plane projects use PROJ --global +plane projects current +``` + +When a local config is active in the current path, `plane projects use PROJ` writes there by default; otherwise it writes to global config. Once a current project is saved, list-style commands such as `plane issues list`, `plane cycles list`, `plane modules list`, `plane pages list`, `plane states list`, `plane labels list`, and `plane intake list` can omit the project argument. Other project-scoped commands can use `@current` instead of repeating the identifier. + +Project-scoped feature availability still depends on the target Plane project. On deployments where a feature is flagged on but the backing API is unavailable, the CLI returns an explicit compatibility error instead of a raw backend `404`. + +**Known deployment dependencies:** + +- **Pages**: The CLI targets the project-page API surface (`/projects/{id}/pages/`). Some Plane deployments do not expose page endpoints even when the project feature flag is present. Additionally, Plane has a separate workspace wiki page surface that the CLI does not cover. +- **Worklogs**: Time tracking is a Pro-plan feature. Non-Pro deployments will return compatibility errors for worklog commands. + ## Common Commands ```bash # Projects plane projects list +plane projects use PROJ +plane projects use PROJ --local +plane projects current # Issues +plane issues list plane issues list PROJ plane issues list PROJ --state started plane issue get PROJ-29 plane issue create PROJ "Title" -plane issue update PROJ-29 --state done --priority high +plane issue create @current "Title" +plane issue update --state completed --priority high PROJ-29 plane issue delete PROJ-29 # Comments plane issue comments list PROJ-29 -plane issue comments add PROJ-29 "text" +plane issue comment PROJ-29 "text" plane issue comments update PROJ-29 COMMENT_ID "new text" plane issue comments delete PROJ-29 COMMENT_ID # Links plane issue link list PROJ-29 -plane issue link add PROJ-29 https://example.com "title" +plane issue link add --title "title" PROJ-29 https://example.com plane issue link remove PROJ-29 LINK_ID # Activity @@ -80,8 +124,8 @@ plane issue activity PROJ-29 # Worklogs plane issue worklogs list PROJ-29 -plane issue worklogs add PROJ-29 --duration 90 -plane issue worklogs add PROJ-29 --duration 30 --description "standup" +plane issue worklogs add PROJ-29 90 +plane issue worklogs add --description "standup" PROJ-29 30 # Cycles plane cycles list PROJ @@ -90,9 +134,10 @@ plane cycles issues add PROJ CYCLE_ID PROJ-29 # Modules plane modules list PROJ +plane modules delete PROJ MODULE_ID plane modules issues list PROJ MODULE_ID plane modules issues add PROJ MODULE_ID PROJ-29 -plane modules issues remove PROJ MODULE_ID PROJ-29 +plane modules issues remove PROJ MODULE_ID MODULE_ISSUE_ID # Intake plane intake list PROJ @@ -106,7 +151,8 @@ plane pages get PROJ PAGE_ID # States, labels, members plane states list PROJ plane labels list PROJ -plane members list PROJ +plane labels delete PROJ bug +plane members list ``` Project identifiers: short strings like `PROJ`, `WEB`. Issue refs: `PROJ-29`, `WEB-5`. @@ -127,16 +173,31 @@ plane issues list PROJ --xml plane cycles list PROJ --json ``` +## Command Notes + +- `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`. +- `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 delete` accepts either the module UUID or the exact module name returned by `plane modules list`. +- `plane modules issues remove` expects the module-issue identifier returned by `plane modules issues list`, not an issue ref. +- `plane members list` is workspace-scoped and does not take a project argument. + +## Compatibility Notes + +- `plane init --local` reports which project-scoped features are enabled for the selected project. +- Pages and worklogs can be deployment-dependent even when a feature flag is present. The CLI now returns explicit compatibility errors for unsupported endpoints. + ## Upgrade ```bash -bun update -g @backslash-ux/plane +bun update -g @backslash-ux/plane-cli ``` ## Development ```bash -git clone https://github.com/backslash-ux/plane-cli +git clone https://github.com/backslash-ux/plane-cli-cli cd plane-cli bun install diff --git a/SKILL.md b/SKILL.md index 65730de..d37a5d8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,7 +14,7 @@ The `plane` CLI wraps the Plane REST API. It is designed for both human and AI agent use. Install it globally with bun: ```bash -bun install -g @backslash-ux/plane +bun install -g @backslash-ux/plane-cli ``` ## Configuration @@ -22,19 +22,49 @@ bun install -g @backslash-ux/plane Run once to save credentials interactively: ```bash -plane init +plane init -g ``` -Saves to `~/.config/plane/config.json` (mode 0600). Safe to re-run. +Saves to `~/.config/plane/config.json` (mode 0600). Safe to re-run. The interactive flow can also save a current project for repeated project-scoped commands. + +For path-local overrides in the current directory: + +```bash +plane init --local +plane . init +``` + +Local setup writes `./.plane/config.json`. Effective config resolution is: + +```text +PLANE_* environment variables > nearest .plane/config.json > ~/.config/plane/config.json +``` + +`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. Or set environment variables (override saved config): ```bash export PLANE_API_TOKEN=your-token -export PLANE_HOST=https://plane.so # or your self-hosted URL -export PLANE_WORKSPACE=your-workspace-slug +export PLANE_HOST=https://plane.so +export PLANE_WORKSPACE=your-workspace +export PLANE_PROJECT=PROJ # optional current-project override ``` +You can also save a current project explicitly: + +```bash +plane projects list +plane projects use PROJ +plane projects use PROJ --local +plane projects use PROJ --global +plane projects current +``` + +If a local config is active in the current path, `plane projects use PROJ` writes there by default. + --- ## Concepts @@ -42,6 +72,7 @@ export PLANE_WORKSPACE=your-workspace-slug | Term | Meaning | |---|---| | **Project identifier** | Short uppercase string, e.g. `ACME`, `WEB`. Shown by `plane projects list`. | +| **Current project** | Optional saved project identifier used when a list-style command omits the project argument or when a command uses `@current`. | | **Issue ref** | `PROJ-29` — identifier + sequence number. | | **State group** | `backlog` \| `unstarted` \| `started` \| `completed` \| `cancelled` | | **Priority** | `urgent` \| `high` \| `medium` \| `low` \| `none` | @@ -67,12 +98,22 @@ plane cycles list PROJ --xml plane modules list PROJ --xml ``` +## Compatibility Notes + +- Project-scoped feature availability depends on the target project's Plane feature flags. +- Some Plane deployments expose pages or worklogs in project settings but still do not provide the backing API routes. In those cases, the CLI returns an explicit compatibility error instead of a raw backend `404`. +- **Pages**: The CLI targets the project-page API surface. Plane also has a separate workspace wiki page surface that the CLI does not cover. Both may be absent on some deployments even when feature flags are present. +- **Worklogs**: Time tracking (worklogs) is a Pro-plan feature in Plane. Non-Pro deployments will not expose worklog endpoints. + --- ## Projects ```bash plane projects list +plane projects use PROJ +plane projects use PROJ --local +plane projects current plane projects list --xml ``` @@ -83,6 +124,7 @@ plane projects list --xml ### List ```bash +plane issues list plane issues list PROJ plane issues list PROJ --state started plane issues list PROJ --state backlog @@ -103,8 +145,9 @@ plane issue get PROJ-29 ```bash plane issue create PROJ "Issue title" +plane issue create @current "Issue title" plane issue create --priority high --state started PROJ "Fix lint pipeline" -plane issue create --description "Detailed context" PROJ "Add dark mode" +plane issue create --description '

Detailed context

' PROJ "Add dark mode" plane issue create --assignee "Jane Doe" PROJ "Onboarding bug" plane issue create --label "bug" PROJ "Regression in login flow" ``` @@ -119,7 +162,7 @@ plane issue create --label "bug" PROJ "Regression in login flow" plane issue update --state completed PROJ-29 plane issue update --priority high WEB-5 plane issue update --title "New title" PROJ-29 -plane issue update --description "Updated context" PROJ-29 +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 @@ -168,11 +211,14 @@ plane issue worklogs add PROJ-29 90 # 90 minutes plane issue worklogs add --description "code review" PROJ-29 30 ``` +Some deployments do not expose worklog endpoints even when time tracking appears enabled. Expect an explicit compatibility error in that case. + --- ## States ```bash +plane states list plane states list PROJ plane states list PROJ --xml ``` @@ -184,10 +230,12 @@ State IDs are UUIDs unique per project. Always fetch live — never hardcode. ## Labels ```bash +plane labels list plane labels list PROJ plane labels list PROJ --xml plane labels create PROJ "bug" plane labels create --color "#ff0000" PROJ "critical" +plane labels delete PROJ bug ``` --- @@ -199,11 +247,14 @@ plane members list plane members list --xml ``` +Members are workspace-scoped. This command does not take a project argument. + --- ## Cycles (sprints) ```bash +plane cycles list plane cycles list PROJ plane cycles list PROJ --xml plane cycles issues list PROJ @@ -217,11 +268,13 @@ Cycle IDs are UUIDs. Fetch them from `plane cycles list PROJ`. ## Modules ```bash +plane modules list plane modules list PROJ plane modules list PROJ --xml +plane modules delete PROJ plane modules issues list PROJ plane modules issues add PROJ PROJ-29 -plane modules issues remove PROJ # use join ID, not issue ref +plane modules issues remove PROJ # use the identifier returned by `plane modules issues list` ``` --- @@ -229,6 +282,7 @@ plane modules issues remove PROJ # use join ID, n ## Intake (triage) ```bash +plane intake list plane intake list PROJ plane intake accept PROJ plane intake reject PROJ @@ -241,13 +295,14 @@ Statuses: `pending`, `accepted`, `rejected`, `snoozed`, `duplicate`. ## Pages (documentation) ```bash +plane pages list plane pages list PROJ plane pages list PROJ --xml plane pages get PROJ # full JSON including description_html plane pages create --name "My Page" PROJ -plane pages create --name "My Page" --description "Content here" PROJ +plane pages create --name "My Page" --description '

Content here

' PROJ plane pages update --name "New Title" PROJ -plane pages update --description "New content" PROJ +plane pages update --description '

New content

' PROJ plane pages delete PROJ plane pages archive PROJ plane pages unarchive PROJ @@ -256,6 +311,8 @@ plane pages unlock PROJ plane pages duplicate PROJ ``` +Some deployments do not expose page endpoints even when the project advertises page support. Expect an explicit compatibility error in that case. + --- ## Issue Fields Reference @@ -279,7 +336,7 @@ plane pages duplicate PROJ - No server-side text search — fetch all issues and filter locally. - No epics — use labels or modules to group related issues. -- `description` in `issue create`/`update` is plain text; the CLI wraps it in `

` tags automatically. +- `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. - Always fetch state/label/member IDs live — never hardcode UUIDs across workspaces. - `plane issue get PROJ-N` is the fastest way to inspect all fields on a single issue. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1647e02..1c027f0 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -4,6 +4,30 @@ This repository publishes from Git tags that match `v*` through [`.github/workflows/publish.yml`](../.github/workflows/publish.yml). +## One-Time Maintainer Setup + +Before the first public release, make sure the publication path itself is ready: + +1. Verify the npm package name is available and that the publishing account or npm organization has access to `@backslash-ux/plane-cli`. +2. Enable npm account 2FA for maintainers. For CI publishing, either: + - keep using the current `NPM_CONFIG_TOKEN` secret with a token that is allowed to publish this package, or + - migrate the workflow to npm trusted publishing so long-lived tokens are no longer required. +3. Add the `NPM_CONFIG_TOKEN` repository secret in GitHub if the token-based workflow remains in use. +4. Confirm GitHub Actions is enabled for the repository and that the publish workflow can create releases. The current workflow already requests `contents: write`. +5. Verify the default branch is healthy before tagging: CI should pass on `main` and the version in `package.json` should match the intended release. +6. Confirm the repository URLs in `package.json` and the install instructions in `README.md` and `SKILL.md` point at the maintained fork. + +## Recommended Preflight Checks + +Run these checks before cutting a release: + +```bash +bun run check:all +bun publish --dry-run +``` + +The dry run confirms the package contents and publish metadata without pushing a release to npm. + ## Before Releasing 1. Update [CHANGELOG.md](../CHANGELOG.md) with user-facing changes. @@ -32,6 +56,18 @@ git push origin vX.Y.Z - publish the package to npm - create a GitHub release with generated notes +## After Releasing + +1. Confirm the GitHub release was created from the pushed tag. +2. Verify the package is visible on npm and that the published version matches `package.json`. +3. Smoke-test installation from the public registry: + +```bash +bunx @backslash-ux/plane-cli --help +``` + +4. If the release changes agent workflows, confirm `README.md`, `SKILL.md`, and `AGENTS.md` guidance still matches the shipped package. + ## Required Repository Secrets - `NPM_CONFIG_TOKEN` for package publication. @@ -39,4 +75,5 @@ git push origin vX.Y.Z ## Notes - If the release changes command behavior, keep related GitHub issues, release notes, and docs aligned as part of the same change. -- If a release uncovers a workflow gap, document it here instead of relying on maintainer memory. \ No newline at end of file +- If a release uncovers a workflow gap, document it here instead of relying on maintainer memory. +- npm currently recommends trusted publishing for GitHub Actions when possible. This repository still uses `NPM_CONFIG_TOKEN`, so moving to trusted publishing plus provenance is a useful follow-up when maintainers are ready. \ No newline at end of file diff --git a/package.json b/package.json index 6e37257..e0318f9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@backslash-ux/plane", + "name": "@backslash-ux/plane-cli", "publishConfig": { "access": "public" }, @@ -33,12 +33,12 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/backslash-ux/plane-cli.git" + "url": "git+https://github.com/backslash-ux/plane-cli-cli.git" }, "bugs": { - "url": "https://github.com/backslash-ux/plane-cli/issues" + "url": "https://github.com/backslash-ux/plane-cli-cli/issues" }, - "homepage": "https://github.com/backslash-ux/plane-cli#readme", + "homepage": "https://github.com/backslash-ux/plane-cli-cli#readme", "engines": { "bun": ">=1.0.0" }, diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index 9d270ff..bbc3102 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -10,7 +10,7 @@ const THRESHOLDS = { console.log("Running tests with coverage...\n") try { - const output = execSync("bun test --coverage 2>&1", { encoding: "utf8" }) + const output = execSync(`"${process.execPath}" test --coverage 2>&1`, { encoding: "utf8" }) console.log(output) const coverageMatch = output.match(/All files\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|/) diff --git a/src/api.ts b/src/api.ts index d396582..7212f50 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,34 +1,5 @@ import { Effect, Schema } from "effect"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; - -const CONFIG_FILE = path.join(os.homedir(), ".config", "plane", "config.json"); - -function readConfigFile(): Partial<{ - token: string; - host: string; - workspace: string; -}> { - try { - return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); - } catch { - return {}; - } -} - -function getConfig() { - const file = readConfigFile(); - return { - token: process.env["PLANE_API_TOKEN"] ?? file.token ?? "", - host: ( - process.env["PLANE_HOST"] ?? - file.host ?? - "https://plane.so" - ).replace(/\/$/, ""), - workspace: process.env["PLANE_WORKSPACE"] ?? file.workspace ?? "", - }; -} +import { getConfig } from "./user-config.js"; function request( method: string, @@ -38,8 +9,14 @@ function request( return Effect.tryPromise({ try: async () => { const { token, host, workspace } = getConfig(); - if (!token) throw new Error("No API token configured. Run 'plane init' or set PLANE_API_TOKEN."); - if (!workspace) throw new Error("No workspace configured. Run 'plane init' or set PLANE_WORKSPACE."); + if (!token) + throw new Error( + "No API token configured. Run 'plane init', 'plane init --local', 'plane . init', or set PLANE_API_TOKEN.", + ); + if (!workspace) + throw new Error( + "No workspace configured. Run 'plane init', 'plane init --local', 'plane . init', or set PLANE_WORKSPACE.", + ); let url = `${host}/api/v1/workspaces/${workspace}/${path}`; // Always expand state on issue list/get calls (not intake-issues/ or cycle-issues/) @@ -75,10 +52,13 @@ function request( return JSON.parse(text); } catch { // Escape bare control characters inside JSON string values and retry. - const sanitized = text.replace( - /"(?:[^"\\]|\\.)*"/g, - (match) => match.replace(/[\x00-\x1F]/g, (c) => { - const hex = c.charCodeAt(0).toString(16).padStart(4, "0"); + const sanitized = text.replace(/"(?:[^"\\]|\\.)*"/g, (match) => + match.replace(/./gsu, (c) => { + const code = c.charCodeAt(0); + if (code > 0x1f) { + return c; + } + const hex = code.toString(16).padStart(4, "0"); return `\\u${hex}`; }), ); diff --git a/src/app.ts b/src/app.ts index 23fabed..34a7c53 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,31 +1,43 @@ import { Command } from "@effect/cli"; +import { cycles } from "./commands/cycles.js"; +import { init } from "./commands/init.js"; +import { intake } from "./commands/intake.js"; import { issue } from "./commands/issue.js"; import { issues } from "./commands/issues.js"; -import { states } from "./commands/states.js"; import { labels } from "./commands/labels.js"; +import { local } from "./commands/local.js"; import { members } from "./commands/members.js"; -import { cycles } from "./commands/cycles.js"; import { modules } from "./commands/modules.js"; -import { intake } from "./commands/intake.js"; import { pages } from "./commands/pages.js"; import { projects } from "./commands/projects.js"; -import { init } from "./commands/init.js"; +import { states } from "./commands/states.js"; const plane = Command.make("plane").pipe( Command.withDescription( `CLI for the Plane project management API. Useful for humans and AI agents/bots. CONFIGURATION - Config file: ~/.config/plane/config.json (written by 'plane init') - Env vars: PLANE_API_TOKEN, PLANE_HOST, PLANE_WORKSPACE - Env vars take priority over the config file. + Global config: ~/.config/plane/config.json + Local config: nearest .plane/config.json from the current directory upward + Env vars: PLANE_API_TOKEN + PLANE_HOST + PLANE_WORKSPACE + PLANE_PROJECT for a default project identifier + Precedence: env vars > local config > global config QUICK START - plane init Interactive setup — saves host/workspace/token + plane init -g Interactive global setup + plane init --local Interactive local setup in the current directory + plane . init Local setup alias for the current directory plane projects list List projects and their identifiers + plane projects use PROJ Save a current project in the active config scope + plane projects use PROJ --global Force the saved current project into global config + plane projects use PROJ --local Force the saved current project into local config + plane issues list List issues for the saved current project plane issues list PROJ List issues for a project plane issue get PROJ-29 Get full JSON for an issue plane issue create PROJ "title" Create an issue + plane issue create @current "title" Create an issue in the saved current project plane issue update --state done PROJ-29 plane issue comment PROJ-29 "text" Add a comment @@ -36,28 +48,36 @@ CONCEPTS Priorities urgent | high | medium | low | none ALL SUBCOMMANDS - init Set up config interactively - projects list List all projects + init Set up global or local config interactively + . local init + projects list | current | use issues list List issues (supports --state, --assignee, --priority) issue get | create | update | delete | comment | activity | link | comments | worklogs cycles list | issues (list, add) - modules list | issues (list, add, remove) + modules list | 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 - labels list List labels for a project - members list List members of a project + labels list | create | delete + members list List workspace members FOR AI AGENTS / BOTS - Add --json to any list command for JSON output (array of objects) - Add --xml to any list command for XML output - 'plane issue get PROJ-N' always outputs full JSON - - Use PLANE_API_TOKEN / PLANE_HOST / PLANE_WORKSPACE env vars to avoid 'plane init' + - Use PLANE_API_TOKEN to avoid 'plane init' + - Use PLANE_HOST for self-hosted Plane instances + - 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 + - '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): https://developers.plane.so/api-reference/introduction`, ), Command.withSubcommands([ + local, init, projects, issues, @@ -74,5 +94,5 @@ FOR AI AGENTS / BOTS export const cli = Command.run(plane, { name: "plane", - version: "0.1.11", + version: "1.0.0", }); diff --git a/src/commands/cycles.ts b/src/commands/cycles.ts index 0700711..e1ec34a 100644 --- a/src/commands/cycles.ts +++ b/src/commands/cycles.ts @@ -1,14 +1,23 @@ -import { Command, Args } from "@effect/cli"; +import { Args, Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; -import { CyclesResponseSchema, CycleIssuesResponseSchema } from "../config.js"; -import { resolveProject, parseIssueRef, findIssueBySeq } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { CycleIssuesResponseSchema, CyclesResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { + findIssueBySeq, + parseIssueRef, + requireProjectFeature, + resolveProject, +} from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + const cycleIdArg = Args.text({ name: "cycle-id" }).pipe( Args.withDescription("Cycle UUID (from 'plane cycles list PROJECT')"), ); @@ -18,6 +27,7 @@ const cycleIdArg = Args.text({ name: "cycle-id" }).pipe( export function cyclesListHandler({ project }: { project: string }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "cycle_view"); const raw = yield* api.get(`projects/${id}/cycles/`); const { results } = yield* decodeOrFail(CyclesResponseSchema, raw); if (jsonMode) { @@ -44,11 +54,11 @@ export function cyclesListHandler({ project }: { project: string }) { export const cyclesList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, cyclesListHandler, ).pipe( Command.withDescription( - "List cycles for a project. Shows cycle UUID, status, date range, and name.\n\nExample:\n plane cycles list PROJ", + "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", ), ); @@ -63,6 +73,7 @@ export function cycleIssuesListHandler({ }) { return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "cycle_view"); const raw = yield* api.get( `projects/${id}/cycles/${cycleId}/cycle-issues/`, ); @@ -80,11 +91,15 @@ export function cycleIssuesListHandler({ return; } const lines = results.map((ci) => { + if ("sequence_id" in ci) { + const seq = String(ci.sequence_id).padStart(3, " "); + return `${key}-${seq} ${ci.name}`; + } if (ci.issue_detail) { const seq = String(ci.issue_detail.sequence_id).padStart(3, " "); - return `${key}-${seq} ${ci.issue_detail.name} (${ci.id})`; + return `${key}-${seq} ${ci.issue_detail.name}`; } - return `${ci.issue} (cycle-issue: ${ci.id})`; + return `${ci.issue}`; }); yield* Console.log(lines.join("\n")); }); @@ -117,6 +132,7 @@ export function cycleIssuesAddHandler({ }) { return Effect.gen(function* () { const { id: projectId } = yield* resolveProject(project); + yield* requireProjectFeature(projectId, "cycle_view"); const { seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); yield* api.post(`projects/${projectId}/cycles/${cycleId}/cycle-issues/`, { diff --git a/src/commands/init.ts b/src/commands/init.ts index 1ff097c..98b5e4a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,74 +1,574 @@ -import { Command } from "@effect/cli"; -import { Console, Effect } from "effect"; import * as readline from "node:readline"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as os from "node:os"; +import { Command, Options } from "@effect/cli"; +import { Console, Effect, type Schema } from "effect"; +import { decodeOrFail } from "../api.js"; +import { + EstimatePointsResponseSchema, + EstimateSchema, + isProjectIntakeEnabled, + LabelsResponseSchema, + ProjectDetailSchema, + ProjectsResponseSchema, + StatesResponseSchema, +} from "../config.js"; +import { + getLocalAgentsFilePath, + writeLocalProjectAgentsFile, +} from "../project-agents.js"; +import { + buildProjectContextSnapshot, + getLocalProjectContextFilePath, + writeLocalProjectContextSnapshot, +} from "../project-context.js"; +import { + type ConfigScope, + getConfigDetails, + getGlobalConfigFilePath, + getLocalConfigFilePath, + normalizeHost, + readGlobalStoredConfig, + readLocalStoredConfigAtPath, + writeGlobalStoredConfig, + writeLocalStoredConfig, +} from "../user-config.js"; -export const CONFIG_DIR = path.join(os.homedir(), ".config", "plane"); -export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); - -export interface PlaneConfig { - token: string; - host: string; - workspace: string; +interface ProjectFeatureSummary { + label: string; + enabled: boolean; } function prompt(rl: readline.Interface, question: string): Promise { return new Promise((resolve) => rl.question(question, resolve)); } -export const init = Command.make("init", {}, () => - Effect.gen(function* () { - let existing: Partial = {}; - try { - existing = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")); - } catch { - // no existing config +function resolveProjectSelection( + input: string, + projects: ReadonlyArray<{ identifier: string; name: string }>, + existingDefaultProject: string | undefined, + scope: ConfigScope, +): string | undefined { + const trimmed = input.trim(); + if (!trimmed) { + return existingDefaultProject; + } + if (trimmed === "-") { + return scope === "global" ? "" : undefined; + } + const byNumber = Number.parseInt(trimmed, 10); + if ( + Number.isInteger(byNumber) && + byNumber >= 1 && + byNumber <= projects.length + ) { + return projects[byNumber - 1].identifier; + } + const byIdentifier = projects.find( + (project) => project.identifier.toUpperCase() === trimmed.toUpperCase(), + ); + if (byIdentifier) { + return byIdentifier.identifier; + } + throw new Error( + `Unknown project selection: ${trimmed}. Enter a number, identifier, or '-' to clear.`, + ); +} + +function resolveScope( + { global, local }: { global: boolean; local: boolean }, + defaultScope: ConfigScope, +): Effect.Effect { + if (global && local) { + return Effect.fail( + new Error("Choose either --global or --local, not both."), + ); + } + if (local) { + return Effect.succeed("local"); + } + if (global) { + return Effect.succeed("global"); + } + return Effect.succeed(defaultScope); +} + +function promptLabel( + label: string, + scope: ConfigScope, + existingValue: string | undefined, + effectiveValue: string, + options?: { hidden?: boolean; clearHint?: boolean }, +): string { + const displayValue = (value: string) => + options?.hidden && value ? "***" : value; + + if (scope === "local") { + const currentValue = existingValue?.trim(); + const inheritedValue = effectiveValue.trim(); + const shownValue = currentValue + ? displayValue(currentValue) + : inheritedValue + ? `inherit: ${displayValue(inheritedValue)}` + : "inherit"; + const clearHint = options?.clearHint + ? " ('-' to clear)" + : " ('-' to inherit)"; + return `${label} [${shownValue}]${clearHint}: `; + } + + return `${label} [${displayValue(existingValue?.trim() ?? "")}]: `; +} + +function resolveLocalValue( + input: string, + existingValue: string | undefined, +): string | undefined { + const trimmed = input.trim(); + if (!trimmed) { + return existingValue?.trim() || undefined; + } + if (trimmed === "-") { + return undefined; + } + return trimmed; +} + +function resolveGlobalValue( + input: string, + existingValue: string | undefined, +): string { + const trimmed = input.trim(); + return trimmed || existingValue?.trim() || ""; +} + +function describeValue( + scope: ConfigScope, + localValue: string | undefined, + effectiveValue: string, + options?: { hidden?: boolean }, +): string { + const displayValue = (value: string) => + options?.hidden && value ? "***" : value; + + if (scope === "local") { + if (localValue?.trim()) { + return displayValue(localValue.trim()); } + return effectiveValue + ? `inherit (${displayValue(effectiveValue)})` + : "inherit"; + } - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, + return displayValue(effectiveValue); +} + +const globalOption = Options.boolean("global").pipe( + Options.withAlias("g"), + Options.withDescription("Save to ~/.config/plane/config.json"), + Options.withDefault(false), +); + +const localOption = Options.boolean("local").pipe( + Options.withAlias("l"), + Options.withDescription( + "Save to ./.plane/config.json in the current directory", + ), + Options.withDefault(false), +); + +function fetchProjectsForConfig(config: { + host: string; + workspace: string; + token: string; +}) { + return fetchDecodedFromConfig( + ProjectsResponseSchema, + config, + "projects/", + ).pipe(Effect.map(({ results }) => results)); +} + +function requestJsonFromConfig( + config: { + host: string; + workspace: string; + token: string; + }, + path: string, +) { + return Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: () => + fetch(`${config.host}/api/v1/workspaces/${config.workspace}/${path}`, { + headers: { "X-Api-Key": config.token }, + }), + catch: (error) => + error instanceof Error ? error : new Error(String(error)), + }); + if (!response.ok) { + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: (error) => + error instanceof Error ? error : new Error(String(error)), + }); + return yield* Effect.fail(new Error(`HTTP ${response.status}: ${text}`)); + } + const raw = yield* Effect.tryPromise({ + try: () => response.json(), + catch: (error) => + error instanceof Error ? error : new Error(String(error)), }); + return raw; + }); +} + +function fetchDecodedFromConfig( + schema: Schema.Schema, + config: { + host: string; + workspace: string; + token: string; + }, + path: string, +) { + return requestJsonFromConfig(config, path).pipe( + Effect.flatMap((raw) => decodeOrFail(schema, raw)), + ); +} - const host = yield* Effect.promise(() => - prompt(rl, `Plane host [${existing.host ?? "https://plane.so"}]: `), +function fetchLocalProjectHelperForConfig( + config: { + host: string; + workspace: string; + token: string; + }, + project: { id: string; identifier: string; name: string }, +) { + return Effect.gen(function* () { + const detail = yield* fetchDecodedFromConfig( + ProjectDetailSchema, + config, + `projects/${project.id}/`, ); - const workspace = yield* Effect.promise(() => - prompt(rl, `Workspace slug [${existing.workspace ?? ""}]: `), + const { results: states } = yield* fetchDecodedFromConfig( + StatesResponseSchema, + config, + `projects/${project.id}/states/`, ); - const token = yield* Effect.promise(() => - prompt(rl, `API token [${existing.token ? "***" : ""}]: `), + const { results: labels } = yield* fetchDecodedFromConfig( + LabelsResponseSchema, + config, + `projects/${project.id}/labels/`, ); - rl.close(); + let estimate = 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 config: PlaneConfig = { - host: host.trim() || existing.host || "https://plane.so", - workspace: workspace.trim() || existing.workspace || "", - token: token.trim() || existing.token || "", + return { + detail, + snapshot: buildProjectContextSnapshot({ + project, + detail, + states, + labels, + estimate, + estimatePoints, + }), }; + }); +} + +function summarizeProjectFeatures(project: { + cycle_view: boolean; + module_view: boolean; + issue_views_view: boolean; + page_view: boolean; + inbox_view?: boolean; + intake_view?: boolean; +}): ProjectFeatureSummary[] { + return [ + { label: "Cycles", enabled: project.cycle_view }, + { label: "Modules", enabled: project.module_view }, + { label: "Views", enabled: project.issue_views_view }, + { label: "Pages", enabled: project.page_view }, + { label: "Intake", enabled: isProjectIntakeEnabled(project) }, + ]; +} + +export function initHandler( + { global, local }: { global: boolean; local: boolean }, + defaultScope: ConfigScope = "global", +) { + return Effect.gen(function* () { + const scope = yield* resolveScope({ global, local }, defaultScope); + const effective = getConfigDetails(); + const existing = + scope === "global" + ? readGlobalStoredConfig() + : readLocalStoredConfigAtPath(); + const savePath = + scope === "global" ? getGlobalConfigFilePath() : getLocalConfigFilePath(); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + let savedHost: string | undefined; + let savedWorkspace: string | undefined; + let savedToken: string | undefined; + let savedDefaultProject = existing.defaultProject; + let normalizedHost = ""; + let mergedWorkspace = ""; + let mergedToken = ""; + let projectsResult: import("effect").Either.Either< + ReadonlyArray<{ id: string; identifier: string; name: string }>, + Error + >; + + try { + const host = yield* Effect.promise(() => + prompt( + rl, + promptLabel("Plane host URL", scope, existing.host, effective.host), + ), + ); + const workspace = yield* Effect.promise(() => + prompt( + rl, + promptLabel( + "Workspace", + scope, + existing.workspace, + effective.workspace, + ), + ), + ); + const token = yield* Effect.promise(() => + prompt( + rl, + promptLabel("API token", scope, existing.token, effective.token, { + hidden: true, + }), + ), + ); + + savedHost = + scope === "global" + ? resolveGlobalValue( + host, + existing.host || effective.host || "https://plane.so", + ) + : resolveLocalValue(host, existing.host); + savedWorkspace = + scope === "global" + ? resolveGlobalValue( + workspace, + existing.workspace || effective.workspace, + ) + : resolveLocalValue(workspace, existing.workspace); + savedToken = + scope === "global" + ? resolveGlobalValue(token, existing.token || effective.token) + : resolveLocalValue(token, existing.token); + + const mergedHost = savedHost ?? effective.host ?? "https://plane.so"; + normalizedHost = normalizeHost(mergedHost); + mergedWorkspace = savedWorkspace ?? effective.workspace; + mergedToken = savedToken ?? effective.token; - if (!config.token) { - yield* Effect.fail(new Error("API token is required")); + if (!mergedToken) { + yield* Effect.fail(new Error("API token is required")); + } + if (!mergedWorkspace) { + yield* Effect.fail(new Error("Workspace is required")); + } + + projectsResult = yield* Effect.either( + fetchProjectsForConfig({ + host: normalizedHost, + workspace: mergedWorkspace, + token: mergedToken, + }), + ); + if (projectsResult._tag === "Right" && projectsResult.right.length > 0) { + yield* Console.log("\nAvailable projects:"); + yield* Console.log( + projectsResult.right + .map( + (project, index) => + `${index + 1}. ${project.identifier} ${project.name}`, + ) + .join("\n"), + ); + const selectedProject = yield* Effect.promise(() => + prompt( + rl, + promptLabel( + "Default project number or identifier", + scope, + existing.defaultProject, + effective.defaultProject, + { clearHint: true }, + ), + ), + ); + savedDefaultProject = resolveProjectSelection( + selectedProject, + projectsResult.right, + existing.defaultProject, + scope, + ); + } else if (projectsResult._tag === "Left") { + yield* Console.log( + `\nWarning: could not load projects for selection (${projectsResult.left.message}). Continuing without changing the current-project override.`, + ); + } + } finally { + rl.close(); } - if (!config.workspace) { - yield* Effect.fail(new Error("Workspace slug is required")); + + if (scope === "global") { + writeGlobalStoredConfig({ + host: normalizedHost, + workspace: mergedWorkspace, + token: mergedToken, + defaultProject: savedDefaultProject, + }); + } else { + writeLocalStoredConfig( + { + host: savedHost, + workspace: savedWorkspace, + token: savedToken, + defaultProject: savedDefaultProject, + }, + { target: "cwd" }, + ); } - fs.mkdirSync(CONFIG_DIR, { recursive: true }); - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { - mode: 0o600, - }); + const hostForDisplay = + scope === "global" + ? normalizedHost + : savedHost + ? normalizeHost(savedHost) + : normalizedHost; - yield* Console.log(`\nConfig saved to ${CONFIG_FILE}`); - yield* Console.log(` Host: ${config.host}`); - yield* Console.log(` Workspace: ${config.workspace}`); + yield* Console.log( + `\n${scope === "global" ? "Global" : "Local"} config saved to ${savePath}`, + ); + yield* Console.log( + ` Host: ${describeValue(scope, savedHost, hostForDisplay)}`, + ); + yield* Console.log( + ` Workspace: ${describeValue(scope, savedWorkspace, mergedWorkspace)}`, + ); yield* Console.log(` Token: ***`); - }), + if ((savedDefaultProject ?? effective.defaultProject).trim()) { + yield* Console.log( + ` Project: ${describeValue( + scope, + savedDefaultProject, + savedDefaultProject ?? effective.defaultProject, + )}`, + ); + } + + const activeDefaultProject = + savedDefaultProject ?? effective.defaultProject; + if (scope === "local" && activeDefaultProject) { + const selectedProject = + projectsResult?._tag === "Right" + ? projectsResult?.right.find( + (project) => + project.identifier.toUpperCase() === + activeDefaultProject.toUpperCase(), + ) + : undefined; + if (selectedProject) { + const projectHelper = yield* Effect.either( + fetchLocalProjectHelperForConfig( + { + host: normalizedHost, + workspace: mergedWorkspace, + token: mergedToken, + }, + selectedProject, + ), + ); + if (projectHelper._tag === "Right") { + yield* Console.log("\nProject feature flags:"); + const featureSummary = summarizeProjectFeatures( + projectHelper.right.detail, + ); + for (const feature of featureSummary) { + yield* Console.log( + ` ${feature.label}: ${feature.enabled ? "enabled" : "disabled"}`, + ); + } + const disabled = featureSummary + .filter((feature) => !feature.enabled) + .map((feature) => feature.label); + if (disabled.length > 0) { + yield* Console.log( + ` Disabled features will fail with explicit errors until Plane enables them: ${disabled.join(", ")}`, + ); + } + + writeLocalProjectContextSnapshot(projectHelper.right.snapshot); + const helperPath = getLocalProjectContextFilePath(); + writeLocalProjectAgentsFile(projectHelper.right.snapshot); + const agentsPath = getLocalAgentsFilePath(); + yield* Console.log(`\nProject helper saved to ${helperPath}`); + yield* Console.log( + ` States: ${projectHelper.right.snapshot.helpers.states.total}`, + ); + yield* Console.log( + ` Labels: ${projectHelper.right.snapshot.helpers.labels.total}`, + ); + if (projectHelper.right.snapshot.helpers.estimate.enabled) { + yield* Console.log( + ` Estimate: ${projectHelper.right.snapshot.helpers.estimate.name} (${projectHelper.right.snapshot.helpers.estimate.points.length} points)`, + ); + } else { + yield* Console.log(" Estimate: disabled"); + } + yield* Console.log(`Local AGENTS.md updated at ${agentsPath}`); + } else { + yield* Console.log( + `\nWarning: could not load project helper data for ${selectedProject.identifier}: ${projectHelper.left.message}`, + ); + } + } + } + }); +} + +export const init = Command.make( + "init", + { global: globalOption, local: localOption }, + (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.", + ), +); + +export const localInit = Command.make("init", {}, () => + initHandler({ global: false, local: true }, "local"), ).pipe( Command.withDescription( - "Interactive setup. Prompts for host, workspace slug, and API token, then saves to ~/.config/plane/config.json (mode 0600). Safe to re-run — existing values shown as defaults.", + "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, and updates AGENTS.md with project-context guidance for AI agents.", ), ); diff --git a/src/commands/intake.ts b/src/commands/intake.ts index b382029..bb859f5 100644 --- a/src/commands/intake.ts +++ b/src/commands/intake.ts @@ -1,28 +1,50 @@ -import { Command, Options, Args } from "@effect/cli"; +import { Args, Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { IntakeIssuesResponseSchema } from "../config.js"; -import { resolveProject } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { requireProjectFeature, resolveProject } from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); -// Intake status codes: -2=rejected, -1=snoozed, 0=pending, 1=accepted, 2=duplicate +const listProjectArg = projectArg.pipe(Args.withDefault("")); + +// Intake status codes: -2=pending, -1=rejected, 0=snoozed, 1=accepted, 2=duplicate const STATUS_LABELS: Record = { - [-2]: "rejected", - [-1]: "snoozed", - [0]: "pending", - [1]: "accepted", - [2]: "duplicate", + [-2]: "pending", + [-1]: "rejected", + 0: "snoozed", + 1: "accepted", + 2: "duplicate", }; +function resolveIntakeMutationId(projectId: string, intakeId: string) { + return Effect.gen(function* () { + const raw = yield* api.get(`projects/${projectId}/intake-issues/`); + const { results } = yield* decodeOrFail(IntakeIssuesResponseSchema, raw); + const match = results.find( + (item) => + item.id === intakeId || + item.issue === intakeId || + item.issue_detail?.id === intakeId, + ); + if (!match) { + return yield* Effect.fail(new Error(`Unknown intake issue: ${intakeId}`)); + } + return match.issue ?? match.issue_detail?.id ?? match.id; + }); +} + // --- intake list --- export function intakeListHandler({ project }: { project: string }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "intake_view"); const raw = yield* api.get(`projects/${id}/intake-issues/`); const { results } = yield* decodeOrFail(IntakeIssuesResponseSchema, raw); if (jsonMode) { @@ -38,7 +60,10 @@ export function intakeListHandler({ project }: { project: string }) { return; } const lines = results.map((i) => { - const status = STATUS_LABELS[i.status ?? 0] ?? String(i.status ?? "?"); + const status = + i.status != null + ? (STATUS_LABELS[i.status] ?? String(i.status)) + : "unknown"; const statusPad = status.padEnd(10); if (i.issue_detail) { return `${i.id} [${statusPad}] ${i.issue_detail.name}`; @@ -51,11 +76,11 @@ export function intakeListHandler({ project }: { project: string }) { export const intakeList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, intakeListHandler, ).pipe( Command.withDescription( - "List intake (triage) issues for a project. Shows status: pending, accepted, rejected, snoozed, duplicate.\n\nExample:\n plane intake list PROJ", + "List intake (triage) issues for a project. Shows status: pending, accepted, rejected, snoozed, duplicate. Omit PROJECT to use the saved current project.\n\nExample:\n plane intake list PROJ", ), ); @@ -74,7 +99,9 @@ export function intakeAcceptHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); - yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, { + yield* requireProjectFeature(id, "intake_view"); + const mutationId = yield* resolveIntakeMutationId(id, intakeId); + yield* api.patch(`projects/${id}/intake-issues/${mutationId}/`, { status: 1, }); yield* Console.log(`Intake issue ${intakeId} accepted`); @@ -102,8 +129,10 @@ export function intakeRejectHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); - yield* api.patch(`projects/${id}/intake-issues/${intakeId}/`, { - status: -2, + yield* requireProjectFeature(id, "intake_view"); + const mutationId = yield* resolveIntakeMutationId(id, intakeId); + yield* api.patch(`projects/${id}/intake-issues/${mutationId}/`, { + status: -1, }); yield* Console.log(`Intake issue ${intakeId} rejected`); }); diff --git a/src/commands/issue.ts b/src/commands/issue.ts index c474514..8f3efef 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -1,15 +1,25 @@ -import { Command, Options, Args } from "@effect/cli"; +import { Args, Command, Options } from "@effect/cli"; import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; import { - IssueSchema, ActivitiesResponseSchema, - IssueLinksResponseSchema, - IssueLinkSchema, CommentsResponseSchema, - WorklogsResponseSchema, + IssueLinkSchema, + IssueLinksResponseSchema, + IssueSchema, WorklogSchema, + WorklogsResponseSchema, } from "../config.js"; +import { escapeHtmlText } from "../format.js"; +import { + type IssueCreatePayload, + type IssueUpdatePayload, + issueLinkPaths, + issueWorklogPaths, + requestWithFallback, + type WorklogPayload, +} from "../issue-support.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; import { findIssueBySeq, getLabelId, @@ -18,35 +28,10 @@ import { parseIssueRef, resolveProject, } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; -import { escapeHtmlText } from "../format.js"; const refArg = Args.text({ name: "ref" }).pipe( Args.withDescription("Issue reference, e.g. PROJ-29"), ); -// --- Typed payload interfaces --- -interface IssueUpdatePayload { - state?: string; - priority?: string; - name?: string; - description_html?: string; - assignees?: string[]; - label_ids?: string[]; -} - -interface IssueCreatePayload { - name: string; - priority?: string; - state?: string; - description_html?: string; - assignees?: string[]; - label_ids?: string[]; -} - -interface WorklogPayload { - duration: number; - description?: string; -} // --- issue get --- export function issueGetHandler({ ref }: { ref: string }) { return Effect.gen(function* () { @@ -155,7 +140,11 @@ export function issueUpdateHandler({ `projects/${projectId}/issues/${issue.id}/`, body, ); - const updated = yield* decodeOrFail(IssueSchema, raw); + yield* decodeOrFail(IssueSchema, raw); + const refreshedRaw = yield* api.get( + `projects/${projectId}/issues/${issue.id}/`, + ); + const updated = yield* decodeOrFail(IssueSchema, refreshedRaw); const stateName = typeof updated.state === "object" ? updated.state.name : updated.state; yield* Console.log( @@ -219,7 +208,9 @@ const titleArg = Args.text({ name: "title" }).pipe( Args.withDescription("Issue title"), ); const projectRefArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ)"), + Args.withDescription( + "Project identifier (e.g. PROJ). Use '@current' for the saved default project.", + ), ); const createPriorityOption = Options.optional( @@ -300,7 +291,7 @@ export const issueCreate = Command.make( issueCreateHandler, ).pipe( Command.withDescription( - 'Create a new issue in a project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"\n plane issue create --assignee "Jane Doe" PROJ "Onboarding bug"', + 'Create a new issue in a project. Use @current to target the saved default project.\n\nExamples:\n plane issue create PROJ "Migrate Button component"\n plane issue create @current "Migrate Button component"\n plane issue create --priority high --state started PROJ "Fix lint pipeline"\n plane issue create --description "Detailed context here" PROJ "Add dark mode"\n plane issue create --assignee "Jane Doe" PROJ "Onboarding bug"', ), ); // --- issue activity --- @@ -352,8 +343,10 @@ 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* api.get( - `projects/${projectId}/issues/${issue.id}/issue-links/`, + 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) { @@ -401,10 +394,11 @@ export function issueLinkAddHandler({ 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* api.post( - `projects/${projectId}/issues/${issue.id}/issue-links/`, - body, + 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}`); @@ -435,9 +429,14 @@ export function issueLinkRemoveHandler({ return Effect.gen(function* () { const { projectId, seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); - yield* api.delete( - `projects/${projectId}/issues/${issue.id}/issue-links/${linkId}/`, + // 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}`); }); } @@ -568,8 +567,10 @@ 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* api.get( - `projects/${projectId}/issues/${issue.id}/worklogs/`, + 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) { @@ -626,9 +627,10 @@ export function issueWorklogsAddHandler({ const issue = yield* findIssueBySeq(projectId, seq); const body: WorklogPayload = { duration }; if (Option.isSome(description)) body.description = description.value; - const raw = yield* api.post( - `projects/${projectId}/issues/${issue.id}/worklogs/`, - body, + 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); diff --git a/src/commands/issues.ts b/src/commands/issues.ts index fb35ba5..e3b7c15 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -1,18 +1,20 @@ -import { Command, Options, Args } from "@effect/cli"; -import { Console, Effect, Option } from "effect"; +import { Args, Command, Options } from "@effect/cli"; +import { Console, Effect, type Option } from "effect"; import { api, decodeOrFail } from "../api.js"; +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 type { State } from "../config.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; const projectArg = Args.text({ name: "project" }).pipe( Args.withDescription( - "Project identifier — see 'plane projects list' for available identifiers", + "Project identifier — see 'plane projects list' for available identifiers. Use '@current' for the saved default project.", ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + const stateOption = Options.optional(Options.text("state")).pipe( Options.withDescription( "Filter by state group (backlog | unstarted | started | completed | cancelled) or exact state name", @@ -91,12 +93,12 @@ export const issuesList = Command.make( state: stateOption, assignee: assigneeOption, priority: priorityOption, - project: projectArg, + project: listProjectArg, }, issuesListHandler, ).pipe( Command.withDescription( - "List issues for a project ordered by sequence ID. Each line shows: REF [state-group] state-name title", + "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.", ), ); diff --git a/src/commands/labels.ts b/src/commands/labels.ts index e23aff3..55397d9 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -1,41 +1,48 @@ -import { Command, Options, Args } from "@effect/cli"; +import { Args, Command, Options } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; -import { LabelsResponseSchema, LabelSchema } from "../config.js"; -import { resolveProject } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { LabelSchema, LabelsResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { resolveLabel, resolveProject } from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + // --- labels list --- +export function labelsListHandler({ project }: { project: string }) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + const raw = yield* api.get(`projects/${id}/labels/`); + const { results } = yield* decodeOrFail(LabelsResponseSchema, 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 labels found"); + return; + } + const lines = results.map( + (l) => `${l.id} ${(l.color ?? "").padEnd(8)} ${l.name}`, + ); + yield* Console.log(lines.join("\n")); + }); +} + export const labelsList = Command.make( "list", - { project: projectArg }, - ({ project }) => - Effect.gen(function* () { - const { id } = yield* resolveProject(project); - const raw = yield* api.get(`projects/${id}/labels/`); - const { results } = yield* decodeOrFail(LabelsResponseSchema, 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 labels found"); - return; - } - const lines = results.map( - (l) => `${l.id} ${(l.color ?? "").padEnd(8)} ${l.name}`, - ); - yield* Console.log(lines.join("\n")); - }), + { project: listProjectArg }, + labelsListHandler, ); // --- labels create --- @@ -46,25 +53,71 @@ const nameArg = Args.text({ name: "name" }).pipe( const colorOption = Options.optional(Options.text("color")).pipe( Options.withDescription("Hex color e.g. #ff0000"), ); +const labelArg = Args.text({ name: "label" }).pipe( + Args.withDescription( + "Label UUID or exact name (from 'plane labels list PROJECT')", + ), +); export const labelsCreate = Command.make( "create", { color: colorOption, project: projectArg, name: nameArg }, - ({ project, name, color }) => - Effect.gen(function* () { - const { id } = yield* resolveProject(project); - interface LabelPayload { - name: string; - color?: string; - } - const body: LabelPayload = { name }; - if (color._tag === "Some") body.color = color.value; - const raw = yield* api.post(`projects/${id}/labels/`, body); - const label = yield* decodeOrFail(LabelSchema, raw); - yield* Console.log(`Created label: ${label.name} (${label.id})`); - }), + labelsCreateHandler, +); + +export function labelsCreateHandler({ + project, + name, + color, +}: { + project: string; + name: string; + color: { _tag: "Some"; value: string } | { _tag: "None" }; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + interface LabelPayload { + name: string; + color?: string; + } + const body: LabelPayload = { name }; + if (color._tag === "Some") body.color = color.value; + const raw = yield* api.post(`projects/${id}/labels/`, body); + const label = yield* decodeOrFail(LabelSchema, raw); + yield* Console.log(`Created label: ${label.name} (${label.id})`); + }); +} + +export function labelsDeleteHandler({ + project, + label, +}: { + project: string; + label: string; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + const resolvedLabel = yield* resolveLabel(id, label); + yield* api.delete(`projects/${id}/labels/${resolvedLabel.id}/`); + yield* Console.log( + `Deleted label: ${resolvedLabel.name} (${resolvedLabel.id})`, + ); + }); +} + +export const labelsDelete = Command.make( + "delete", + { project: projectArg, label: labelArg }, + labelsDeleteHandler, +).pipe( + Command.withDescription( + `Delete a label by UUID or exact name. + +Example: + plane labels delete PROJ bug`, + ), ); export const labels = Command.make("labels").pipe( - Command.withSubcommands([labelsList, labelsCreate]), + Command.withSubcommands([labelsList, labelsCreate, labelsDelete]), ); diff --git a/src/commands/local.ts b/src/commands/local.ts new file mode 100644 index 0000000..24805cf --- /dev/null +++ b/src/commands/local.ts @@ -0,0 +1,9 @@ +import { Command } from "@effect/cli"; +import { localInit } from "./init.js"; + +export const local = Command.make(".").pipe( + Command.withDescription( + "Manage path-local Plane config for the current directory.", + ), + Command.withSubcommands([localInit]), +); diff --git a/src/commands/members.ts b/src/commands/members.ts index 0dce79d..788e327 100644 --- a/src/commands/members.ts +++ b/src/commands/members.ts @@ -2,7 +2,7 @@ import { Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { MembersResponseSchema } from "../config.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; export const membersList = Command.make("list", {}, () => Effect.gen(function* () { diff --git a/src/commands/modules.ts b/src/commands/modules.ts index 03d6b21..9d9baa7 100644 --- a/src/commands/modules.ts +++ b/src/commands/modules.ts @@ -1,26 +1,42 @@ -import { Command, Args } from "@effect/cli"; +import { Args, Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { - ModulesResponseSchema, ModuleIssuesResponseSchema, + ModulesResponseSchema, } from "../config.js"; -import { resolveProject, parseIssueRef, findIssueBySeq } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { + findIssueBySeq, + parseIssueRef, + requireProjectFeature, + resolveModule, + resolveProject, +} from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + const moduleIdArg = Args.text({ name: "module-id" }).pipe( Args.withDescription("Module UUID (from 'plane modules list PROJECT')"), ); +const moduleArg = Args.text({ name: "module" }).pipe( + Args.withDescription( + "Module UUID or exact name (from 'plane modules list PROJECT')", + ), +); // --- modules list --- export function modulesListHandler({ project }: { project: string }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); const raw = yield* api.get(`projects/${id}/modules/`); const { results } = yield* decodeOrFail(ModulesResponseSchema, raw); if (jsonMode) { @@ -45,11 +61,44 @@ export function modulesListHandler({ project }: { project: string }) { export const modulesList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, modulesListHandler, ).pipe( Command.withDescription( - "List modules for a project. Shows module UUID, status, and name.\n\nExample:\n plane modules list PROJ", + "List modules for a project. Shows module UUID, status, and name. Omit PROJECT to use the saved current project.\n\nExample:\n plane modules list PROJ", + ), +); + +// --- modules delete --- + +export function modulesDeleteHandler({ + project, + module, +}: { + project: string; + module: string; +}) { + return Effect.gen(function* () { + const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); + const resolvedModule = yield* resolveModule(id, module); + yield* api.delete(`projects/${id}/modules/${resolvedModule.id}/`); + yield* Console.log( + `Deleted module: ${resolvedModule.name} (${resolvedModule.id})`, + ); + }); +} + +export const modulesDelete = Command.make( + "delete", + { project: projectArg, module: moduleArg }, + modulesDeleteHandler, +).pipe( + Command.withDescription( + `Delete a module by UUID or exact name. + +Example: + plane modules delete PROJ `, ), ); @@ -64,6 +113,7 @@ export function moduleIssuesListHandler({ }) { return Effect.gen(function* () { const { key, id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); const raw = yield* api.get( `projects/${id}/modules/${moduleId}/module-issues/`, ); @@ -81,10 +131,14 @@ export function moduleIssuesListHandler({ return; } const lines = results.map((mi) => { - if (mi.issue_detail) { + if ("issue_detail" in mi && mi.issue_detail) { const seq = String(mi.issue_detail.sequence_id).padStart(3, " "); return `${key}-${seq} ${mi.issue_detail.name} (${mi.id})`; } + if ("sequence_id" in mi) { + const seq = String(mi.sequence_id).padStart(3, " "); + return `${key}-${seq} ${mi.name} (${mi.id})`; + } return `${mi.issue} (module-issue: ${mi.id})`; }); yield* Console.log(lines.join("\n")); @@ -118,6 +172,7 @@ export function moduleIssuesAddHandler({ }) { return Effect.gen(function* () { const { id: projectId } = yield* resolveProject(project); + yield* requireProjectFeature(projectId, "module_view"); const { seq } = yield* parseIssueRef(ref); const issue = yield* findIssueBySeq(projectId, seq); yield* api.post( @@ -144,7 +199,7 @@ export const moduleIssuesAdd = Command.make( const moduleIssueIdArg = Args.text({ name: "module-issue-id" }).pipe( Args.withDescription( - "Module-issue join ID (from 'plane modules issues list')", + "Module issue identifier from 'plane modules issues list' (legacy join ID or live raw issue ID)", ), ); @@ -159,6 +214,7 @@ export function moduleIssuesRemoveHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "module_view"); yield* api.delete( `projects/${id}/modules/${moduleId}/module-issues/${moduleIssueId}/`, ); @@ -178,7 +234,7 @@ export const moduleIssuesRemove = Command.make( moduleIssuesRemoveHandler, ).pipe( Command.withDescription( - "Remove an issue from a module using the module-issue join ID.\n\nExample:\n plane modules issues remove PROJ ", + "Remove an issue from a module using the identifier returned by 'plane modules issues list'.\n\nExample:\n plane modules issues remove PROJ ", ), ); @@ -199,7 +255,7 @@ export const moduleIssues = Command.make("issues").pipe( export const modules = Command.make("modules").pipe( Command.withDescription( - "Manage modules (groups of related issues). Subcommands: list, issues\n\nExamples:\n plane modules list PROJ\n plane modules issues list PROJ \n plane modules issues add PROJ PROJ-29", + "Manage modules (groups of related issues). Subcommands: list, delete, issues\n\nExamples:\n plane modules list PROJ\n plane modules delete PROJ \n plane modules issues list PROJ \n plane modules issues add PROJ PROJ-29", ), - Command.withSubcommands([modulesList, moduleIssues]), + Command.withSubcommands([modulesList, modulesDelete, moduleIssues]), ); diff --git a/src/commands/pages.ts b/src/commands/pages.ts index 68d63dc..21815ab 100644 --- a/src/commands/pages.ts +++ b/src/commands/pages.ts @@ -1,14 +1,18 @@ -import { Command, Args, Options } from "@effect/cli"; +import { Args, Command, Options } from "@effect/cli"; import { Console, Effect, Option } from "effect"; import { api, decodeOrFail } from "../api.js"; -import { PagesResponseSchema, PageSchema } from "../config.js"; -import { resolveProject } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { PageSchema, PagesResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { requireProjectFeature, resolveProject } from "../resolve.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + const pageIdArg = Args.text({ name: "page-id" }).pipe( Args.withDescription("Page UUID (from 'plane pages list')"), ); @@ -25,15 +29,43 @@ const descriptionOption = Options.optional(Options.text("description")).pipe( Options.withDescription("Page description as HTML (e.g. '

Hello

')"), ); -interface PageCreatePayload { name: string; description_html?: string; } -interface PageUpdatePayload { name?: string; description_html?: string; } +interface PageCreatePayload { + name: string; + description_html?: string; +} +interface PageUpdatePayload { + name?: string; + description_html?: string; +} + +function isNotFoundError(error: Error): boolean { + return /^HTTP 404:/.test(error.message); +} + +function mapPageAvailabilityError( + effect: Effect.Effect, + message: string, +): Effect.Effect { + return effect.pipe( + Effect.catchAll((error) => { + if (isNotFoundError(error)) { + return Effect.fail(new Error(message)); + } + return Effect.fail(error); + }), + ); +} // --- pages list --- export function pagesListHandler({ project }: { project: string }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); - const raw = yield* api.get(`projects/${id}/pages/`); + const { key, id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); + const raw = yield* mapPageAvailabilityError( + api.get(`projects/${id}/pages/`), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); const { results } = yield* decodeOrFail(PagesResponseSchema, raw); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); @@ -57,11 +89,11 @@ export function pagesListHandler({ project }: { project: string }) { export const pagesList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, pagesListHandler, ).pipe( Command.withDescription( - "List pages for a project. Shows page UUID, last updated date, and title.\n\nExample:\n plane pages list PROJ", + "List pages for a project. Shows page UUID, last updated date, and title. Omit PROJECT to use the saved current project.\n\nExample:\n plane pages list PROJ", ), ); @@ -76,6 +108,7 @@ export function pagesGetHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); const raw = yield* api.get(`projects/${id}/pages/${pageId}/`); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(JSON.stringify(page, null, 2)); @@ -104,12 +137,16 @@ export function pagesCreateHandler({ description: Option.Option; }) { return Effect.gen(function* () { - const { id } = yield* resolveProject(project); + const { key, id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); const body: PageCreatePayload = { name }; if (Option.isSome(description)) { body.description_html = description.value; } - const raw = yield* api.post(`projects/${id}/pages/`, body); + const raw = yield* mapPageAvailabilityError( + api.post(`projects/${id}/pages/`, body), + `Project pages are not available for ${key} on this Plane instance or API version.`, + ); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Created page ${page.id}: ${page.name}`); }); @@ -121,7 +158,7 @@ export const pagesCreate = Command.make( pagesCreateHandler, ).pipe( Command.withDescription( - "Create a new page.\n\nExample:\n plane pages create --name \"My Page\" PROJ", + 'Create a new page.\n\nExample:\n plane pages create --name "My Page" PROJ', ), ); @@ -143,6 +180,7 @@ export function pagesUpdateHandler({ yield* Effect.fail(new Error("provide at least --name or --description")); } const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); const body: PageUpdatePayload = {}; if (Option.isSome(name)) body.name = name.value; if (Option.isSome(description)) body.description_html = description.value; @@ -154,11 +192,16 @@ export function pagesUpdateHandler({ export const pagesUpdate = Command.make( "update", - { project: projectArg, pageId: pageIdArg, name: nameOptionalOption, description: descriptionOption }, + { + project: projectArg, + pageId: pageIdArg, + name: nameOptionalOption, + description: descriptionOption, + }, pagesUpdateHandler, ).pipe( Command.withDescription( - "Update a page's name or description.\n\nExample:\n plane pages update --name \"New Title\" PROJ ", + 'Update a page\'s name or description.\n\nExample:\n plane pages update --name "New Title" PROJ ', ), ); @@ -173,6 +216,7 @@ export function pagesDeleteHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/`); yield* Console.log(`Deleted page ${pageId}`); }); @@ -199,6 +243,7 @@ export function pagesArchiveHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.post(`projects/${id}/pages/${pageId}/archive/`, {}); yield* Console.log(`Archived page ${pageId}`); }); @@ -225,6 +270,7 @@ export function pagesUnarchiveHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/archive/`); yield* Console.log(`Unarchived page ${pageId}`); }); @@ -251,6 +297,7 @@ export function pagesLockHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.post(`projects/${id}/pages/${pageId}/lock/`, {}); yield* Console.log(`Locked page ${pageId}`); }); @@ -277,6 +324,7 @@ export function pagesUnlockHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); + yield* requireProjectFeature(id, "page_view"); yield* api.delete(`projects/${id}/pages/${pageId}/lock/`); yield* Console.log(`Unlocked page ${pageId}`); }); @@ -303,7 +351,11 @@ export function pagesDuplicateHandler({ }) { return Effect.gen(function* () { const { id } = yield* resolveProject(project); - const raw = yield* api.post(`projects/${id}/pages/${pageId}/duplicate/`, {}); + yield* requireProjectFeature(id, "page_view"); + const raw = yield* api.post( + `projects/${id}/pages/${pageId}/duplicate/`, + {}, + ); const page = yield* decodeOrFail(PageSchema, raw); yield* Console.log(`Duplicated page ${page.id}: ${page.name}`); }); @@ -326,7 +378,15 @@ export const pages = Command.make("pages").pipe( "Manage project pages (documentation). Subcommands: list, get, create, update, delete, archive, unarchive, lock, unlock, duplicate\n\nExamples:\n plane pages list PROJ\n plane pages get PROJ ", ), Command.withSubcommands([ - pagesList, pagesGet, pagesCreate, pagesUpdate, pagesDelete, - pagesArchive, pagesUnarchive, pagesLock, pagesUnlock, pagesDuplicate, + pagesList, + pagesGet, + pagesCreate, + pagesUpdate, + pagesDelete, + pagesArchive, + pagesUnarchive, + pagesLock, + pagesUnlock, + pagesDuplicate, ]), ); diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 2860455..0e9cb3c 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -1,13 +1,74 @@ -import { Command } from "@effect/cli"; +import { Args, Command, Options } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { ProjectsResponseSchema } from "../config.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; +import { resolveProject } from "../resolve.js"; +import { + type ConfigScope, + getConfigDetails, + getDefaultConfigWriteScope, + readLocalStoredConfig, + readStoredConfig, + writeLocalStoredConfig, + writeStoredConfig, +} from "../user-config.js"; -export const projectsList = Command.make("list", {}, () => - Effect.gen(function* () { +const projectArg = Args.text({ name: "project" }).pipe( + Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), +); + +const globalOption = Options.boolean("global").pipe( + Options.withAlias("g"), + Options.withDescription("Write the current project to global config"), + Options.withDefault(false), +); + +const localOption = Options.boolean("local").pipe( + Options.withAlias("l"), + Options.withDescription("Write the current project to local config"), + Options.withDefault(false), +); + +function resolveWriteScope({ + global, + local, +}: { + global: boolean; + local: boolean; +}): Effect.Effect { + if (global && local) { + return Effect.fail( + new Error("Choose either --global or --local, not both."), + ); + } + if (local) { + return Effect.succeed("local"); + } + if (global) { + return Effect.succeed("global"); + } + return Effect.succeed(getDefaultConfigWriteScope()); +} + +function describeProjectSource(source: string): string { + switch (source) { + case "env": + return "env"; + case "local": + return "local"; + case "global": + return "global"; + default: + return "config"; + } +} + +export function projectsListHandler() { + return Effect.gen(function* () { const raw = yield* api.get("projects/"); const { results } = yield* decodeOrFail(ProjectsResponseSchema, raw); + const currentProject = getConfigDetails().defaultProject.toUpperCase(); if (jsonMode) { yield* Console.log(JSON.stringify(results, null, 2)); return; @@ -16,18 +77,100 @@ export const projectsList = Command.make("list", {}, () => yield* Console.log(toXml(results)); return; } - const lines = results.map( - (p) => `${p.identifier.padEnd(6)} ${p.id} ${p.name}`, - ); + const lines = results.map((project) => { + const marker = + currentProject === project.identifier.toUpperCase() ? "*" : " "; + return `${marker} ${project.identifier.padEnd(6)} ${project.id} ${project.name}`; + }); yield* Console.log(lines.join("\n")); - }), + }); +} + +export const projectsList = Command.make("list", {}, 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.", + ), +); + +export function projectsCurrentHandler() { + return Effect.gen(function* () { + const config = getConfigDetails(); + const configuredProject = config.defaultProject; + if (!configuredProject) { + return yield* Effect.fail( + new Error( + "No default project configured. Run 'plane init', 'plane init --local', 'plane . init', or 'plane projects use PROJ'.", + ), + ); + } + 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 project = results.find((candidate) => candidate.id === id); + if (!project) { + yield* Console.log(`${key} (${source})`); + return; + } + yield* Console.log( + `${project.identifier} ${project.id} ${project.name} (${source})`, + ); + }); +} + +export const projectsCurrent = Command.make( + "current", + {}, + projectsCurrentHandler, +).pipe( + Command.withDescription( + "Show the effective current project and whether it came from env, local config, or global config.", + ), +); + +export function projectsUseHandler({ + project, + global, + local, +}: { + project: string; + global: boolean; + local: boolean; +}) { + return Effect.gen(function* () { + const scope = yield* resolveWriteScope({ global, local }); + const { key } = yield* resolveProject(project); + if (scope === "local") { + const existing = readLocalStoredConfig(); + writeLocalStoredConfig( + { + ...existing, + defaultProject: key, + }, + { target: "active" }, + ); + } else { + const existing = readStoredConfig(); + writeStoredConfig({ + ...existing, + defaultProject: key, + }); + } + yield* Console.log(`Current project set to ${key} (${scope})`); + }); +} + +export const projectsUse = Command.make( + "use", + { project: projectArg, global: globalOption, local: localOption }, + projectsUseHandler, ).pipe( Command.withDescription( - "List all projects in the workspace. The IDENTIFIER column is what you pass to other commands (e.g. 'plane issues list PROJ').", + "Persist a current project. Defaults to local scope when a local config is active in the current path; use --global or --local to force the target scope.", ), ); export const projects = Command.make("projects").pipe( Command.withDescription("Manage projects."), - Command.withSubcommands([projectsList]), + Command.withSubcommands([projectsList, projectsCurrent, projectsUse]), ); diff --git a/src/commands/states.ts b/src/commands/states.ts index 092a747..b3a67d6 100644 --- a/src/commands/states.ts +++ b/src/commands/states.ts @@ -1,17 +1,21 @@ -import { Command, Options, Args } from "@effect/cli"; +import { Args, Command } from "@effect/cli"; import { Console, Effect } from "effect"; import { api, decodeOrFail } from "../api.js"; import { StatesResponseSchema } from "../config.js"; +import { jsonMode, toXml, xmlMode } from "../output.js"; import { resolveProject } from "../resolve.js"; -import { jsonMode, xmlMode, toXml } from "../output.js"; const projectArg = Args.text({ name: "project" }).pipe( - Args.withDescription("Project identifier (e.g. PROJ, WEB, OPS)"), + Args.withDescription( + "Project identifier (e.g. PROJ, WEB, OPS). Use '@current' for the saved default project.", + ), ); +const listProjectArg = projectArg.pipe(Args.withDefault("")); + export const statesList = Command.make( "list", - { project: projectArg }, + { project: listProjectArg }, ({ project }) => Effect.gen(function* () { const { id } = yield* resolveProject(project); diff --git a/src/config.ts b/src/config.ts index 0ff9bf9..400c54b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -73,6 +73,50 @@ export const ProjectSchema = Schema.Struct({ }); export type Project = typeof ProjectSchema.Type; +export const ProjectDetailSchema = Schema.Struct({ + id: Schema.String, + identifier: Schema.String, + name: Schema.String, + module_view: Schema.Boolean, + cycle_view: Schema.Boolean, + issue_views_view: Schema.Boolean, + page_view: Schema.Boolean, + inbox_view: Schema.optional(Schema.Boolean), + intake_view: Schema.optional(Schema.Boolean), + estimate: Schema.optional(Schema.NullOr(Schema.String)), +}); +export type ProjectDetail = typeof ProjectDetailSchema.Type; + +export function isProjectIntakeEnabled( + project: Pick, +): boolean { + return (project.inbox_view || project.intake_view) ?? false; +} + +export const EstimateSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + type: Schema.String, + last_used: Schema.optional(Schema.Boolean), + project: Schema.String, + workspace: Schema.String, +}); +export type Estimate = typeof EstimateSchema.Type; + +export const EstimatePointSchema = Schema.Struct({ + id: Schema.String, + estimate: Schema.String, + key: Schema.optional(Schema.Number), + value: Schema.String, + description: Schema.optional(Schema.NullOr(Schema.String)), + project: Schema.String, + workspace: Schema.String, +}); +export type EstimatePoint = typeof EstimatePointSchema.Type; + +export const EstimatePointsResponseSchema = Schema.Array(EstimatePointSchema); + export const ProjectsResponseSchema = Schema.Struct({ results: Schema.Array(ProjectSchema), }); @@ -122,7 +166,7 @@ export const ModulesResponseSchema = Schema.Struct({ results: Schema.Array(ModuleSchema), }); -export const ModuleIssueSchema = Schema.Struct({ +export const ModuleIssueRelationSchema = Schema.Struct({ id: Schema.String, issue: Schema.String, issue_detail: Schema.optional( @@ -133,6 +177,17 @@ export const ModuleIssueSchema = Schema.Struct({ }), ), }); + +export const ModuleIssueRawSchema = Schema.Struct({ + id: Schema.String, + sequence_id: Schema.Number, + name: Schema.String, +}); + +export const ModuleIssueSchema = Schema.Union( + ModuleIssueRelationSchema, + ModuleIssueRawSchema, +); export type ModuleIssue = typeof ModuleIssueSchema.Type; export const ModuleIssuesResponseSchema = Schema.Struct({ @@ -199,7 +254,7 @@ export const CommentsResponseSchema = Schema.Struct({ results: Schema.Array(CommentSchema), }); -export const CycleIssueSchema = Schema.Struct({ +export const CycleIssueRelationSchema = Schema.Struct({ id: Schema.String, issue: Schema.String, issue_detail: Schema.optional( @@ -210,6 +265,17 @@ export const CycleIssueSchema = Schema.Struct({ }), ), }); + +export const CycleIssueRawSchema = Schema.Struct({ + id: Schema.String, + sequence_id: Schema.Number, + name: Schema.String, +}); + +export const CycleIssueSchema = Schema.Union( + CycleIssueRelationSchema, + CycleIssueRawSchema, +); export type CycleIssue = typeof CycleIssueSchema.Type; export const CycleIssuesResponseSchema = Schema.Struct({ diff --git a/src/issue-support.ts b/src/issue-support.ts new file mode 100644 index 0000000..2c44e47 --- /dev/null +++ b/src/issue-support.ts @@ -0,0 +1,72 @@ +import { Effect } from "effect"; + +export interface IssueUpdatePayload { + state?: string; + priority?: string; + name?: string; + description_html?: string; + assignees?: string[]; + label_ids?: string[]; +} + +export interface IssueCreatePayload { + name: string; + priority?: string; + state?: string; + description_html?: string; + assignees?: string[]; + label_ids?: string[]; +} + +export interface WorklogPayload { + duration: number; + description?: string; +} + +function isNotFoundError(error: Error): boolean { + return /^HTTP 404:/.test(error.message); +} + +export function requestWithFallback( + paths: ReadonlyArray, + request: (path: string) => Effect.Effect, + notFoundMessage: string, +): Effect.Effect { + const [current, ...rest] = paths; + if (!current) { + return Effect.fail(new Error(notFoundMessage)); + } + return request(current).pipe( + Effect.catchAll((error) => { + if (!isNotFoundError(error)) { + return Effect.fail(error); + } + if (rest.length === 0) { + return Effect.fail(new Error(notFoundMessage)); + } + return requestWithFallback(rest, request, notFoundMessage); + }), + ); +} + +export function issueLinkPaths( + projectId: string, + issueId: string, +): ReadonlyArray { + return [ + `projects/${projectId}/work-items/${issueId}/links/`, + `projects/${projectId}/issues/${issueId}/links/`, + `projects/${projectId}/issues/${issueId}/issue-links/`, + ]; +} + +export function issueWorklogPaths( + projectId: string, + issueId: string, +): ReadonlyArray { + return [ + `projects/${projectId}/work-items/${issueId}/worklogs/`, + `projects/${projectId}/issues/${issueId}/worklogs/`, + `projects/${projectId}/issues/${issueId}/issue-worklogs/`, + ]; +} diff --git a/src/output.ts b/src/output.ts index f911b04..8f6af21 100644 --- a/src/output.ts +++ b/src/output.ts @@ -34,9 +34,9 @@ function toXmlItem(obj: Record, tag = "item"): string { : toXmlItem(v as Record, k), ) .join(""); - return `<${tag}${attrs ? " " + attrs : ""}>${children}`; + return `<${tag}${attrs ? ` ${attrs}` : ""}>${children}`; } export function toXml(results: readonly unknown[]): string { - return `\n${results.map((r) => " " + toXmlItem(r as Record)).join("\n")}\n`; + return `\n${results.map((r) => ` ${toXmlItem(r as Record)}`).join("\n")}\n`; } diff --git a/src/project-agents.ts b/src/project-agents.ts new file mode 100644 index 0000000..1bdbc89 --- /dev/null +++ b/src/project-agents.ts @@ -0,0 +1,83 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { ProjectContextSnapshot } from "./project-context.js"; +import { getLocalConfigDir } from "./user-config.js"; + +const MANAGED_SECTION_START = ""; +const MANAGED_SECTION_END = ""; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildManagedSection(snapshot: ProjectContextSnapshot): string { + return [ + MANAGED_SECTION_START, + "## Plane Project Context", + `This directory is scoped to Plane project ${snapshot.project.identifier} (${snapshot.project.name}).`, + "", + "When working as an AI agent in this directory:", + "- Read `./.plane/project-context.json` before planning or applying Plane project changes.", + "- Reuse the existing states, labels, and estimate points in that snapshot instead of creating duplicates.", + "- Respect the feature flags in that snapshot before using cycles, modules, pages, intake, or estimates.", + "- Prefer the `plane` CLI from this repository root for Plane project work instead of direct API calls.", + "- Use `@current` as the default project selector once local init has been run.", + "- If the shell may contain inherited `PLANE_*` variables, clear them before relying on `./.plane/config.json`.", + "", + "Common agent commands:", + "", + "```sh", + "unset PLANE_HOST PLANE_WORKSPACE PLANE_API_TOKEN PLANE_PROJECT", + "plane projects current", + "plane issues list @current", + `plane issue get ${snapshot.project.identifier}-12`, + `plane issue update --state started ${snapshot.project.identifier}-12`, + "```", + "", + "- Rerun `plane init --local` from this directory whenever the Plane project configuration changes so this context stays current.", + "", + "This section is managed by `plane-cli` and is updated by `plane init --local`.", + MANAGED_SECTION_END, + "", + ].join("\n"); +} + +function upsertManagedSection( + existingContent: string, + managedSection: string, +): string { + const managedPattern = new RegExp( + `${escapeRegExp(MANAGED_SECTION_START)}[\\s\\S]*?${escapeRegExp(MANAGED_SECTION_END)}\\n?`, + "m", + ); + + if (managedPattern.test(existingContent)) { + return existingContent.replace(managedPattern, managedSection); + } + + const trimmed = existingContent.trimEnd(); + if (!trimmed) { + return managedSection; + } + + return `${trimmed}\n\n${managedSection}`; +} + +export function getLocalAgentsFilePath(cwd = process.cwd()): string { + return path.join(path.dirname(getLocalConfigDir(cwd)), "AGENTS.md"); +} + +export function writeLocalProjectAgentsFile( + snapshot: ProjectContextSnapshot, + cwd = process.cwd(), +): void { + const filePath = getLocalAgentsFilePath(cwd); + const existingContent = fs.existsSync(filePath) + ? fs.readFileSync(filePath, "utf8") + : ""; + const nextContent = upsertManagedSection( + existingContent, + buildManagedSection(snapshot), + ); + fs.writeFileSync(filePath, nextContent, "utf8"); +} diff --git a/src/project-context.ts b/src/project-context.ts new file mode 100644 index 0000000..6b8df64 --- /dev/null +++ b/src/project-context.ts @@ -0,0 +1,176 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { + Estimate, + EstimatePoint, + Label, + ProjectDetail, + State, +} from "./config.js"; +import { isProjectIntakeEnabled } from "./config.js"; +import { getLocalConfigDir } from "./user-config.js"; + +interface ProjectSummary { + id: string; + identifier: string; + name: string; +} + +interface ProjectFeaturesSummary { + cycles: boolean; + modules: boolean; + views: boolean; + pages: boolean; + intake: boolean; + estimates: boolean; +} + +interface ProjectStateHelperEntry { + id: string; + name: string; + group: string; + color?: string; +} + +interface ProjectLabelHelperEntry { + id: string; + name: string; + color?: string | null; + parent?: string | null; +} + +interface ProjectEstimatePointHelperEntry { + id: string; + key?: number; + value: string; + description?: string | null; +} + +export interface ProjectContextSnapshot { + generatedAt: string; + project: ProjectSummary; + features: ProjectFeaturesSummary; + helpers: { + states: { + total: number; + byName: Record; + byGroup: Record; + }; + labels: { + total: number; + byName: Record; + }; + estimate: { + enabled: boolean; + id?: string; + name?: string; + type?: string; + points: ProjectEstimatePointHelperEntry[]; + pointsByValue: Record; + }; + }; +} + +function normalizeLookupKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function buildProjectContextSnapshot({ + project, + detail, + states, + labels, + estimate, + estimatePoints, +}: { + project: ProjectSummary; + detail: ProjectDetail; + states: readonly State[]; + labels: readonly Label[]; + estimate: Estimate | null; + estimatePoints: readonly EstimatePoint[]; +}): ProjectContextSnapshot { + const statesByName: Record = {}; + const statesByGroup: Record = {}; + for (const state of states) { + const entry: ProjectStateHelperEntry = { + id: state.id, + name: state.name, + group: state.group, + color: state.color, + }; + statesByName[normalizeLookupKey(state.name)] = entry; + statesByGroup[state.group] ??= []; + statesByGroup[state.group].push(entry); + } + + const labelsByName: Record = {}; + for (const label of labels) { + labelsByName[normalizeLookupKey(label.name)] = { + id: label.id, + name: label.name, + color: label.color, + parent: label.parent, + }; + } + + const points = estimatePoints + .map((point) => ({ + id: point.id, + key: point.key, + value: point.value, + description: point.description, + })) + .sort((left, right) => (left.key ?? 0) - (right.key ?? 0)); + const pointsByValue = Object.fromEntries( + points.map((point) => [normalizeLookupKey(point.value), point]), + ); + + return { + generatedAt: new Date().toISOString(), + project, + features: { + cycles: detail.cycle_view, + modules: detail.module_view, + views: detail.issue_views_view, + pages: detail.page_view, + intake: isProjectIntakeEnabled(detail), + estimates: estimate !== null, + }, + helpers: { + states: { + total: states.length, + byName: statesByName, + byGroup: statesByGroup, + }, + labels: { + total: labels.length, + byName: labelsByName, + }, + estimate: { + enabled: estimate !== null, + id: estimate?.id, + name: estimate?.name, + type: estimate?.type, + points, + pointsByValue, + }, + }, + }; +} + +export function getLocalProjectContextFilePath(cwd = process.cwd()): string { + return path.join(getLocalConfigDir(cwd), "project-context.json"); +} + +export function writeLocalProjectContextSnapshot( + snapshot: ProjectContextSnapshot, + cwd = process.cwd(), +): void { + const filePath = getLocalProjectContextFilePath(cwd); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(snapshot, null, 2)}\n`, { + mode: 0o600, + }); + fs.chmodSync(filePath, 0o600); +} diff --git a/src/resolve.ts b/src/resolve.ts index 7f77c91..11192d4 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -1,20 +1,75 @@ import { Effect } from "effect"; import { api, decodeOrFail } from "./api.js"; +import type { Issue, ProjectDetail } from "./config.js"; import { IssuesResponseSchema, + isProjectIntakeEnabled, LabelsResponseSchema, MembersResponseSchema, + ModulesResponseSchema, + ProjectDetailSchema, ProjectsResponseSchema, StatesResponseSchema, } from "./config.js"; -import type { Issue } from "./config.js"; +import { getConfig } from "./user-config.js"; // Cache project list within a process invocation let _projectCache: Record | null = null; +let _projectDetailCache: Record | null = null; + +type ProjectFeatureKey = + | "cycle_view" + | "module_view" + | "page_view" + | "intake_view"; + +const FEATURE_LABELS: Record = { + cycle_view: "Cycles", + module_view: "Modules", + page_view: "Pages", + intake_view: "Intake", +}; + +const FEATURE_HINTS: Record = { + cycle_view: "Enable Cycles in the Plane project settings.", + module_view: "Enable Modules in the Plane project settings.", + page_view: "Enable Pages in the Plane project settings.", + intake_view: "Enable Intake in the Plane project settings.", +}; + +function isProjectFeatureEnabled( + project: ProjectDetail, + feature: ProjectFeatureKey, +): boolean { + if (feature === "intake_view") { + return isProjectIntakeEnabled(project); + } + return project[feature]; +} + +function getConfiguredProject(identifier: string): string { + const trimmed = identifier.trim(); + if ( + trimmed && + trimmed !== "." && + trimmed.toLowerCase() !== "@current" && + trimmed.toLowerCase() !== "@default" + ) { + return trimmed; + } + const defaultProject = getConfig().defaultProject.trim(); + if (defaultProject) { + return defaultProject; + } + throw new Error( + "No default project configured. Run 'plane init', 'plane init --local', 'plane . init', 'plane projects use PROJ', or set PLANE_PROJECT.", + ); +} /** Clear the project cache — for use in tests only */ export function _clearProjectCache(): void { _projectCache = null; + _projectDetailCache = null; } function getProjectMap(): Effect.Effect, Error> { @@ -29,10 +84,60 @@ function getProjectMap(): Effect.Effect, Error> { }); } +function getProjectDetail( + projectId: string, +): Effect.Effect { + if (_projectDetailCache?.[projectId]) { + return Effect.succeed(_projectDetailCache[projectId]); + } + return Effect.gen(function* () { + const raw = yield* api.get(`projects/${projectId}/`); + const project = yield* decodeOrFail(ProjectDetailSchema, raw); + _projectDetailCache ??= {}; + _projectDetailCache[projectId] = project; + return project; + }); +} + +export function getProjectFeatureDetails(projectId: string) { + return getProjectDetail(projectId).pipe( + Effect.map((project) => ({ + project, + features: { + Cycles: project.cycle_view, + Modules: project.module_view, + Views: project.issue_views_view, + Pages: project.page_view, + Intake: isProjectIntakeEnabled(project), + }, + })), + ); +} + +export function requireProjectFeature( + projectId: string, + feature: ProjectFeatureKey, +): Effect.Effect { + return getProjectDetail(projectId).pipe( + Effect.flatMap((project) => { + if (isProjectFeatureEnabled(project, feature)) { + return Effect.succeed(void 0); + } + const featureLabel = FEATURE_LABELS[feature]; + const featureFlag = feature === "intake_view" ? "intake_view" : feature; + return Effect.fail( + new Error( + `Project ${project.identifier} has ${featureLabel} disabled (${featureFlag}=false). ${FEATURE_HINTS[feature]}`, + ), + ); + }), + ); +} + export function resolveProject( identifier: string, ): Effect.Effect<{ key: string; id: string }, Error> { - const key = identifier.toUpperCase(); + const key = getConfiguredProject(identifier).toUpperCase(); return getProjectMap().pipe( Effect.flatMap((map) => { const id = map[key]; @@ -124,13 +229,39 @@ export function getLabelId( projectId: string, name: string, ): Effect.Effect { + return resolveLabel(projectId, name).pipe(Effect.map((label) => label.id)); +} + +export function resolveLabel( + projectId: string, + nameOrId: string, +): Effect.Effect<{ id: string; name: string }, Error> { return Effect.gen(function* () { const raw = yield* api.get(`projects/${projectId}/labels/`); const { results } = yield* decodeOrFail(LabelsResponseSchema, raw); - const lower = name.toLowerCase(); - const label = results.find((l) => l.name.toLowerCase() === lower); + const lower = nameOrId.toLowerCase(); + const label = results.find( + (l) => l.id === nameOrId || l.name.toLowerCase() === lower, + ); if (!label) - return yield* Effect.fail(new Error(`Label not found: ${name}`)); - return label.id; + return yield* Effect.fail(new Error(`Label not found: ${nameOrId}`)); + return { id: label.id, name: label.name }; + }); +} + +export function resolveModule( + projectId: string, + nameOrId: string, +): Effect.Effect<{ id: string; name: string }, Error> { + return Effect.gen(function* () { + const raw = yield* api.get(`projects/${projectId}/modules/`); + const { results } = yield* decodeOrFail(ModulesResponseSchema, raw); + const lower = nameOrId.toLowerCase(); + const module = results.find( + (m) => m.id === nameOrId || m.name.toLowerCase() === lower, + ); + if (!module) + return yield* Effect.fail(new Error(`Module not found: ${nameOrId}`)); + return { id: module.id, name: module.name }; }); } diff --git a/src/user-config.ts b/src/user-config.ts new file mode 100644 index 0000000..c34d6a7 --- /dev/null +++ b/src/user-config.ts @@ -0,0 +1,270 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export type ConfigScope = "global" | "local"; + +type ConfigSource = "env" | "local" | "global" | "default" | "none"; + +export interface StoredPlaneConfig { + token?: string; + host?: string; + workspace?: string; + defaultProject?: string; +} + +export interface PlaneConfig { + token: string; + host: string; + workspace: string; + defaultProject: string; +} + +export interface PlaneConfigDetails extends PlaneConfig { + sources: { + token: ConfigSource; + host: ConfigSource; + workspace: ConfigSource; + defaultProject: ConfigSource; + }; + paths: { + globalConfigFile: string; + localConfigFile: string | null; + localConfigTargetFile: string; + }; +} + +const DEFAULT_HOST = "https://plane.so"; + +export function normalizeHost(host: string): string { + const trimmed = host.trim().replace(/\/$/, ""); + if (!trimmed) { + return trimmed; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + return trimmed; + } + return `https://${trimmed}`; +} + +function cleanConfig(config: StoredPlaneConfig): StoredPlaneConfig { + const cleaned: StoredPlaneConfig = {}; + + if (config.token?.trim()) { + cleaned.token = config.token.trim(); + } + if (config.host?.trim()) { + cleaned.host = normalizeHost(config.host.trim()); + } + if (config.workspace?.trim()) { + cleaned.workspace = config.workspace.trim(); + } + if (config.defaultProject?.trim()) { + cleaned.defaultProject = config.defaultProject.trim(); + } + + return cleaned; +} + +function readConfigFile(filePath: string): StoredPlaneConfig { + try { + return cleanConfig(JSON.parse(fs.readFileSync(filePath, "utf8"))); + } catch { + return {}; + } +} + +function writeConfigFile(filePath: string, config: StoredPlaneConfig): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + `${JSON.stringify(cleanConfig(config), null, 2)}\n`, + { + mode: 0o600, + }, + ); + fs.chmodSync(filePath, 0o600); +} + +export function getGlobalConfigDir(): string { + return path.join(os.homedir(), ".config", "plane"); +} + +export function getConfigDir(): string { + return getGlobalConfigDir(); +} + +export function getGlobalConfigFilePath(): string { + return path.join(getGlobalConfigDir(), "config.json"); +} + +export function getConfigFilePath(): string { + return getGlobalConfigFilePath(); +} + +export function getLocalConfigDir(cwd = process.cwd()): string { + return path.join(path.resolve(cwd), ".plane"); +} + +export function getLocalConfigFilePath(cwd = process.cwd()): string { + return path.join(getLocalConfigDir(cwd), "config.json"); +} + +export function findNearestLocalConfigFilePath( + cwd = process.cwd(), +): string | null { + let currentDir = path.resolve(cwd); + + for (;;) { + const candidate = getLocalConfigFilePath(currentDir); + if (fs.existsSync(candidate)) { + return candidate; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function getDefaultConfigWriteScope(cwd = process.cwd()): ConfigScope { + return findNearestLocalConfigFilePath(cwd) ? "local" : "global"; +} + +export function getLocalConfigTargetFilePath( + cwd = process.cwd(), + target: "active" | "cwd" = "active", +): string { + if (target === "cwd") { + return getLocalConfigFilePath(cwd); + } + + return findNearestLocalConfigFilePath(cwd) ?? getLocalConfigFilePath(cwd); +} + +export function readGlobalStoredConfig(): StoredPlaneConfig { + return readConfigFile(getGlobalConfigFilePath()); +} + +export function readStoredConfig(): StoredPlaneConfig { + return readGlobalStoredConfig(); +} + +export function readLocalStoredConfig(cwd = process.cwd()): StoredPlaneConfig { + const filePath = findNearestLocalConfigFilePath(cwd); + return filePath ? readConfigFile(filePath) : {}; +} + +export function readLocalStoredConfigAtPath( + cwd = process.cwd(), +): StoredPlaneConfig { + return readConfigFile(getLocalConfigFilePath(cwd)); +} + +export function writeGlobalStoredConfig(config: StoredPlaneConfig): void { + writeConfigFile(getGlobalConfigFilePath(), config); +} + +export function writeStoredConfig(config: StoredPlaneConfig): void { + writeGlobalStoredConfig(config); +} + +export function writeLocalStoredConfig( + config: StoredPlaneConfig, + options?: { + cwd?: string; + target?: "active" | "cwd"; + }, +): void { + writeConfigFile( + getLocalConfigTargetFilePath(options?.cwd, options?.target), + config, + ); +} + +export function getConfigDetails(cwd = process.cwd()): PlaneConfigDetails { + const globalConfig = readGlobalStoredConfig(); + const localConfigFile = findNearestLocalConfigFilePath(cwd); + const localConfig = localConfigFile ? readConfigFile(localConfigFile) : {}; + + const envToken = process.env.PLANE_API_TOKEN?.trim() || undefined; + const envHost = process.env.PLANE_HOST?.trim() || undefined; + const envWorkspace = process.env.PLANE_WORKSPACE?.trim() || undefined; + const envProject = process.env.PLANE_PROJECT?.trim() || undefined; + + const hostSource: ConfigSource = envHost + ? "env" + : localConfig.host + ? "local" + : globalConfig.host + ? "global" + : "default"; + const tokenSource: ConfigSource = envToken + ? "env" + : localConfig.token + ? "local" + : globalConfig.token + ? "global" + : "none"; + + // Security: if the host comes from local config but the token comes from + // global config, an untrusted repo could redirect a real token to an + // attacker-controlled host. In that case, fall back to the global host. + const safeHostSource = + hostSource === "local" && tokenSource === "global" ? "global" : hostSource; + + const token = envToken ?? localConfig.token ?? globalConfig.token ?? ""; + const host = normalizeHost( + safeHostSource === "local" + ? (localConfig.host ?? globalConfig.host ?? DEFAULT_HOST) + : safeHostSource === "env" + ? (envHost ?? DEFAULT_HOST) + : safeHostSource === "global" + ? (globalConfig.host ?? DEFAULT_HOST) + : DEFAULT_HOST, + ); + const workspace = + envWorkspace ?? localConfig.workspace ?? globalConfig.workspace ?? ""; + const defaultProject = + envProject ?? + localConfig.defaultProject ?? + globalConfig.defaultProject ?? + ""; + + return { + token, + host, + workspace, + defaultProject, + sources: { + token: tokenSource, + host: safeHostSource, + workspace: envWorkspace + ? "env" + : localConfig.workspace + ? "local" + : globalConfig.workspace + ? "global" + : "none", + defaultProject: envProject + ? "env" + : localConfig.defaultProject + ? "local" + : globalConfig.defaultProject + ? "global" + : "none", + }, + paths: { + globalConfigFile: getGlobalConfigFilePath(), + localConfigFile, + localConfigTargetFile: getLocalConfigTargetFilePath(cwd), + }, + }; +} + +export function getConfig(): PlaneConfig { + const { sources: _sources, paths: _paths, ...config } = getConfigDetails(); + return config; +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 18101c1..1c8e1b4 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -7,11 +7,10 @@ import { expect, it, } from "bun:test"; -import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { Effect, Schema } from "effect"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { api, decodeOrFail } from "@/api"; -import { Schema } from "effect"; const BASE = "http://api-test.local"; const WS = "testws"; @@ -22,16 +21,16 @@ beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterAll(() => server.close()); beforeEach(() => { - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("api.get", () => { @@ -50,7 +49,7 @@ describe("api.get", () => { }); it("strips trailing slash from PLANE_HOST", async () => { - process.env["PLANE_HOST"] = `${BASE}/`; + process.env.PLANE_HOST = `${BASE}/`; server.use( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: [] }), diff --git a/tests/cycles-extended.test.ts b/tests/cycles-extended.test.ts index c1ddee6..755e587 100644 --- a/tests/cycles-extended.test.ts +++ b/tests/cycles-extended.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -18,6 +18,16 @@ const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const ISSUES = [ { id: "i1", @@ -39,9 +49,9 @@ const CYCLES = [ ]; const CYCLE_ISSUES = [ { - id: "ci1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" }, + id: "i1", + sequence_id: 29, + name: "Migrate Button", }, ]; @@ -49,6 +59,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), @@ -66,16 +79,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("cyclesList", () => { @@ -148,6 +161,40 @@ describe("cycleIssuesList", () => { expect(output).toContain("Migrate Button"); }); + it("accepts legacy cycle-issue join payloads", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/cycles/cyc1/cycle-issues/`, + () => + HttpResponse.json({ + results: [ + { + id: "ci1", + issue: "i1", + issue_detail: { + id: "i1", + sequence_id: 29, + name: "Migrate Button", + }, + }, + ], + }), + ), + ); + const { cycleIssuesListHandler } = await import("@/commands/cycles"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await Effect.runPromise( + cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }), + ); + } finally { + console.log = orig; + } + expect(logs.join("\n")).toContain("Migrate Button"); + }); + it("falls back to issue UUID without detail", async () => { server.use( http.get( diff --git a/tests/format.test.ts b/tests/format.test.ts index 999d480..3d22fdc 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { escapeHtmlText, formatIssue } from "@/format"; import type { Issue } from "@/config"; +import { escapeHtmlText, formatIssue } from "@/format"; const stateObj = { id: "s1", name: "In Progress", group: "started" }; diff --git a/tests/helpers/mock-api.ts b/tests/helpers/mock-api.ts index 3046f76..43922dc 100644 --- a/tests/helpers/mock-api.ts +++ b/tests/helpers/mock-api.ts @@ -1,4 +1,4 @@ -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; export const BASE = "http://localhost:3737"; diff --git a/tests/intake.test.ts b/tests/intake.test.ts index 639d1e0..d4bf21e 100644 --- a/tests/intake.test.ts +++ b/tests/intake.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -18,20 +18,32 @@ const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + intake_view: true, +}; const INTAKE_ISSUES = [ { id: "int1", + issue: "i1", issue_detail: { id: "i1", sequence_id: 5, name: "Bug report", priority: "high", }, - status: 0, + status: -2, created_at: "2025-01-15T10:00:00Z", }, { id: "int2", + issue: "i2", issue_detail: { id: "i2", sequence_id: 6, @@ -43,6 +55,7 @@ const INTAKE_ISSUES = [ }, { id: "int3", + issue: "i3", created_at: "2025-01-13T10:00:00Z", }, ]; @@ -51,6 +64,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/`, () => HttpResponse.json({ results: INTAKE_ISSUES }), @@ -62,16 +78,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("intakeList", () => { @@ -131,7 +147,7 @@ describe("intakeAccept", () => { let patchedBody: unknown; server.use( http.patch( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/i1/`, async ({ request }) => { patchedBody = await request.json(); return HttpResponse.json({ @@ -163,12 +179,12 @@ describe("intakeReject", () => { let patchedBody: unknown; server.use( http.patch( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/int1/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/intake-issues/i1/`, async ({ request }) => { patchedBody = await request.json(); return HttpResponse.json({ id: "int1", - status: -2, + status: -1, created_at: "2025-01-15T10:00:00Z", }); }, @@ -185,7 +201,7 @@ describe("intakeReject", () => { } finally { console.log = orig; } - expect((patchedBody as { status?: number }).status).toBe(-2); + expect((patchedBody as { status?: number }).status).toBe(-1); expect(logs.join("\n")).toContain("rejected"); }); }); diff --git a/tests/issue-activity.test.ts b/tests/issue-activity.test.ts index fe7dc63..2abd76d 100644 --- a/tests/issue-activity.test.ts +++ b/tests/issue-activity.test.ts @@ -6,10 +6,9 @@ import { describe, expect, it, - mock, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -67,16 +66,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("issueActivity command handler", () => { diff --git a/tests/issue-commands.test.ts b/tests/issue-commands.test.ts index 58548f2..29e7e9c 100644 --- a/tests/issue-commands.test.ts +++ b/tests/issue-commands.test.ts @@ -10,7 +10,7 @@ import { import { Command } from "@effect/cli"; import { NodeContext } from "@effect/platform-node"; import { Effect, Layer, Option } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -57,6 +57,14 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/:issueId/`, + ({ params }) => { + const issue = ISSUES.find((i) => i.id === params.issueId); + if (issue) return HttpResponse.json(issue); + return new HttpResponse(null, { status: 404 }); + }, + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () => HttpResponse.json({ results: STATES }), ), @@ -76,16 +84,17 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; }); describe("issueGet", () => { @@ -261,6 +270,31 @@ describe("issuesList", () => { expect(output).toContain("Urgent fix"); expect(output).not.toContain("Low cleanup"); }); + + it("uses the saved current project when the project input is blank", async () => { + process.env.PLANE_PROJECT = "ACME"; + 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: "", + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), + }), + ); + } finally { + console.log = orig; + } + + const output = logs.join("\n"); + expect(output).toContain("ACME-"); + expect(output).toContain("Migrate Button"); + }); }); describe("issueUpdate", () => { @@ -321,6 +355,17 @@ describe("issueUpdate", () => { }); }, ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`, + () => + HttpResponse.json({ + id: "i1", + sequence_id: 29, + name: "Migrate Button", + priority: "urgent", + state: "s1", + }), + ), ); const { issueUpdateHandler } = await import("@/commands/issue"); @@ -630,9 +675,9 @@ describe("issueCreate description", () => { }), ); - expect( - (postedBody as { description_html?: string }).description_html, - ).toBe("

Raw HTML

"); + expect((postedBody as { description_html?: string }).description_html).toBe( + "

Raw HTML

", + ); }); }); diff --git a/tests/issue-comments-worklogs.test.ts b/tests/issue-comments-worklogs.test.ts index d86d77a..e81028f 100644 --- a/tests/issue-comments-worklogs.test.ts +++ b/tests/issue-comments-worklogs.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect, Option } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -62,6 +62,10 @@ const server = setupServer( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () => HttpResponse.json({ results: COMMENTS }), ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => HttpResponse.json({ results: WORKLOGS }), @@ -73,16 +77,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("issueCommentsList", () => { @@ -241,6 +245,10 @@ describe("issueWorklogsList", () => { it("shows 'No worklogs' when empty", async () => { server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => HttpResponse.json({ results: [] }), @@ -262,6 +270,10 @@ describe("issueWorklogsList", () => { describe("issueWorklogsAdd", () => { it("logs time without description", async () => { server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.post( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, async ({ request }) => { @@ -295,6 +307,10 @@ describe("issueWorklogsAdd", () => { it("logs time with description", async () => { server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.post( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, async ({ request }) => { @@ -331,6 +347,10 @@ describe("issueWorklogsAdd", () => { it("handles missing logged_by_detail in worklogs list", async () => { server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => diff --git a/tests/issue-links.test.ts b/tests/issue-links.test.ts index 0a81e00..8e37fb4 100644 --- a/tests/issue-links.test.ts +++ b/tests/issue-links.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect, Option } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -50,7 +50,7 @@ const server = setupServer( HttpResponse.json({ results: ISSUES }), ), http.get( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, () => HttpResponse.json({ results: LINKS }), ), ); @@ -60,16 +60,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("issueLinkList", () => { @@ -109,7 +109,7 @@ describe("issueLinkList", () => { it("shows 'No links' when empty", async () => { server.use( http.get( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, () => HttpResponse.json({ results: [] }), ), ); @@ -133,7 +133,7 @@ describe("issueLinkAdd", () => { it("adds a link without title", async () => { server.use( http.post( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, async ({ request }) => { const body = (await request.json()) as { url?: string }; return HttpResponse.json({ @@ -170,7 +170,7 @@ describe("issueLinkAdd", () => { it("adds a link with title", async () => { server.use( http.post( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, async ({ request }) => { const body = (await request.json()) as { url?: string; @@ -212,7 +212,7 @@ describe("issueLinkRemove", () => { let deleted = false; server.use( http.delete( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/lnk1/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/lnk1/`, () => { deleted = true; return new HttpResponse(null, { status: 204 }); diff --git a/tests/json-output.test.ts b/tests/json-output.test.ts index 6c30604..bc08630 100644 --- a/tests/json-output.test.ts +++ b/tests/json-output.test.ts @@ -10,13 +10,13 @@ import { describe, expect, it, + mock, } from "bun:test"; -import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { Effect, Option } from "effect"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; -import { mock } from "bun:test"; -import { _clearProjectCache } from "@/resolve"; import { toXml } from "@/output"; +import { _clearProjectCache } from "@/resolve"; // Set jsonMode=true for this entire test file before command modules load mock.module("@/output", () => ({ @@ -29,6 +29,16 @@ const BASE = "http://json-output-test.local"; const WS = "testws"; const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme" }]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const ISSUES = [ { id: "i1", @@ -49,17 +59,17 @@ const CYCLES = [ ]; const CYCLE_ISSUES = [ { - id: "ci1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" }, + id: "i1", + sequence_id: 1, + name: "Issue One", }, ]; const MODULES = [{ id: "mod1", name: "Module Alpha", status: "in-progress" }]; const MODULE_ISSUES = [ { - id: "mi1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" }, + id: "i1", + sequence_id: 1, + name: "Issue One", }, ]; const INTAKE_ISSUES = [ @@ -124,6 +134,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), @@ -153,13 +166,17 @@ const server = setupServer( () => HttpResponse.json({ results: ACTIVITIES }), ), http.get( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, () => HttpResponse.json({ results: LINKS }), ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () => HttpResponse.json({ results: COMMENTS }), ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => HttpResponse.json({ results: WORKLOGS }), @@ -182,16 +199,16 @@ afterAll(() => { beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); async function captureLogs(fn: () => Promise): Promise { @@ -208,9 +225,9 @@ async function captureLogs(fn: () => Promise): Promise { describe("cyclesList --json", () => { it("outputs JSON array of cycles", async () => { - const { cyclesList } = await import("@/commands/cycles"); + const { cyclesListHandler } = await import("@/commands/cycles"); const output = await captureLogs(() => - Effect.runPromise((cyclesList as any).handler({ project: "ACME" })), + Effect.runPromise(cyclesListHandler({ project: "ACME" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -220,23 +237,23 @@ describe("cyclesList --json", () => { describe("cycleIssuesList --json", () => { it("outputs JSON array of cycle issues", async () => { - const { cycleIssuesList } = await import("@/commands/cycles"); + const { cycleIssuesListHandler } = await import("@/commands/cycles"); const output = await captureLogs(() => Effect.runPromise( - (cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }), + cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }), ), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].id).toBe("ci1"); + expect(parsed[0].id).toBe("i1"); }); }); describe("modulesList --json", () => { it("outputs JSON array of modules", async () => { - const { modulesList } = await import("@/commands/modules"); + const { modulesListHandler } = await import("@/commands/modules"); const output = await captureLogs(() => - Effect.runPromise((modulesList as any).handler({ project: "ACME" })), + Effect.runPromise(modulesListHandler({ project: "ACME" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -246,10 +263,10 @@ describe("modulesList --json", () => { describe("moduleIssuesList --json", () => { it("outputs JSON array of module issues", async () => { - const { moduleIssuesList } = await import("@/commands/modules"); + const { moduleIssuesListHandler } = await import("@/commands/modules"); const output = await captureLogs(() => Effect.runPromise( - (moduleIssuesList as any).handler({ + moduleIssuesListHandler({ project: "ACME", moduleId: "mod1", }), @@ -257,15 +274,16 @@ describe("moduleIssuesList --json", () => { ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); - expect(parsed[0].id).toBe("mi1"); + expect(parsed[0].id).toBe("i1"); + expect(parsed[0].sequence_id).toBe(1); }); }); describe("intakeList --json", () => { it("outputs JSON array of intake issues", async () => { - const { intakeList } = await import("@/commands/intake"); + const { intakeListHandler } = await import("@/commands/intake"); const output = await captureLogs(() => - Effect.runPromise((intakeList as any).handler({ project: "ACME" })), + Effect.runPromise(intakeListHandler({ project: "ACME" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -275,9 +293,9 @@ describe("intakeList --json", () => { describe("pagesList --json", () => { it("outputs JSON array of pages", async () => { - const { pagesList } = await import("@/commands/pages"); + const { pagesListHandler } = await import("@/commands/pages"); const output = await captureLogs(() => - Effect.runPromise((pagesList as any).handler({ project: "ACME" })), + Effect.runPromise(pagesListHandler({ project: "ACME" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -287,9 +305,9 @@ describe("pagesList --json", () => { describe("issueActivity --json", () => { it("outputs JSON array of activities", async () => { - const { issueActivity } = await import("@/commands/issue"); + const { issueActivityHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueActivity as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueActivityHandler({ ref: "ACME-1" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -299,9 +317,9 @@ describe("issueActivity --json", () => { describe("issueLinkList --json", () => { it("outputs JSON array of links", async () => { - const { issueLinkList } = await import("@/commands/issue"); + const { issueLinkListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueLinkList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueLinkListHandler({ ref: "ACME-1" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -311,9 +329,9 @@ describe("issueLinkList --json", () => { describe("issueCommentsList --json", () => { it("outputs JSON array of comments", async () => { - const { issueCommentsList } = await import("@/commands/issue"); + const { issueCommentsListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueCommentsListHandler({ ref: "ACME-1" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -323,9 +341,9 @@ describe("issueCommentsList --json", () => { describe("issueWorklogsList --json", () => { it("outputs JSON array of worklogs", async () => { - const { issueWorklogsList } = await import("@/commands/issue"); + const { issueWorklogsListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueWorklogsListHandler({ ref: "ACME-1" })), ); const parsed = JSON.parse(output); expect(Array.isArray(parsed)).toBe(true); @@ -335,14 +353,14 @@ describe("issueWorklogsList --json", () => { describe("issuesList --json", () => { it("outputs JSON array of issues", async () => { - const { issuesList } = await import("@/commands/issues"); + const { issuesListHandler } = await import("@/commands/issues"); const output = await captureLogs(() => Effect.runPromise( - (issuesList as any).handler({ + issuesListHandler({ project: "ACME", - state: { _tag: "None" }, - assignee: { _tag: "None" }, - priority: { _tag: "None" }, + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), }), ), ); diff --git a/tests/labels.test.ts b/tests/labels.test.ts new file mode 100644 index 0000000..9ce6b1a --- /dev/null +++ b/tests/labels.test.ts @@ -0,0 +1,196 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import { Effect, Option } from "effect"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { _clearProjectCache } from "@/resolve"; + +const BASE = "http://labels-test.local"; +const WS = "testws"; + +const PROJECTS = [ + { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, +]; +const LABELS = [ + { id: "l-bug", name: "bug", color: "#ff0000" }, + { id: "l-ready", name: "ready", color: "#00ff00" }, +]; + +const server = setupServer( + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => + HttpResponse.json({ results: PROJECTS }), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, () => + HttpResponse.json({ results: LABELS }), + ), +); + +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; + delete process.env.PLANE_PROJECT; +}); + +describe("labelsDelete", () => { + it("deletes a label by exact name", async () => { + let deleted = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/l-bug/`, + () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const { labelsDeleteHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + labelsDeleteHandler({ project: "ACME", label: "bug" }), + ); + } finally { + console.log = orig; + } + + expect(deleted).toBe(true); + expect(logs.join("\n")).toContain("Deleted label: bug (l-bug)"); + }); + + it("deletes a label by UUID", async () => { + let deleted = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/l-ready/`, + () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const { labelsDeleteHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + labelsDeleteHandler({ project: "ACME", label: "l-ready" }), + ); + } finally { + console.log = orig; + } + + expect(deleted).toBe(true); + expect(logs.join("\n")).toContain("Deleted label: ready (l-ready)"); + }); +}); + +describe("labelsList", () => { + it("lists labels for a project", async () => { + const { labelsListHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(labelsListHandler({ project: "ACME" })); + } finally { + console.log = orig; + } + + const output = logs.join("\n"); + expect(output).toContain("l-bug"); + expect(output).toContain("bug"); + expect(output).toContain("l-ready"); + }); + + it("shows 'No labels found' when empty", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, + () => HttpResponse.json({ results: [] }), + ), + ); + + const { labelsListHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(labelsListHandler({ project: "ACME" })); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toBe("No labels found"); + }); +}); + +describe("labelsCreate", () => { + it("creates a label with color", async () => { + let postedBody: unknown; + server.use( + http.post( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, + async ({ request }) => { + postedBody = await request.json(); + return HttpResponse.json( + { id: "l-new", name: "critical", color: "#ff0000" }, + { status: 201 }, + ); + }, + ), + ); + + const { labelsCreateHandler } = await import("@/commands/labels"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + labelsCreateHandler({ + project: "ACME", + name: "critical", + color: Option.some("#ff0000"), + }), + ); + } finally { + console.log = orig; + } + + expect((postedBody as { name?: string; color?: string }).name).toBe( + "critical", + ); + expect((postedBody as { name?: string; color?: string }).color).toBe( + "#ff0000", + ); + expect(logs.join("\n")).toContain("Created label: critical (l-new)"); + }); +}); diff --git a/tests/modules.test.ts b/tests/modules.test.ts index 0094fc6..5170c5a 100644 --- a/tests/modules.test.ts +++ b/tests/modules.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -18,6 +18,16 @@ const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const ISSUES = [ { id: "i1", @@ -33,9 +43,9 @@ const MODULES = [ ]; const MODULE_ISSUES = [ { - id: "mi1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 29, name: "Migrate Button" }, + id: "i1", + sequence_id: 29, + name: "Migrate Button", }, ]; @@ -43,6 +53,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), @@ -60,16 +73,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("modulesList", () => { @@ -141,6 +154,72 @@ describe("modulesList", () => { }); }); +describe("modulesDelete", () => { + it("deletes a module by UUID", async () => { + let deleted = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/`, + () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const { modulesDeleteHandler } = await import("@/commands/modules"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + modulesDeleteHandler({ + project: "ACME", + module: "mod1", + }), + ); + } finally { + console.log = orig; + } + + expect(deleted).toBe(true); + expect(logs.join("\n")).toContain("Deleted module: Sprint 1 (mod1)"); + }); + + it("deletes a module by exact name", async () => { + let deleted = false; + server.use( + http.delete( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod2/`, + () => { + deleted = true; + return new HttpResponse(null, { status: 204 }); + }, + ), + ); + + const { modulesDeleteHandler } = await import("@/commands/modules"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + modulesDeleteHandler({ + project: "ACME", + module: "Sprint 2", + }), + ); + } finally { + console.log = orig; + } + + expect(deleted).toBe(true); + expect(logs.join("\n")).toContain("Deleted module: Sprint 2 (mod2)"); + }); +}); + describe("moduleIssuesList", () => { it("lists issues in a module with detail", async () => { const { moduleIssuesListHandler } = await import("@/commands/modules"); @@ -165,6 +244,36 @@ describe("moduleIssuesList", () => { expect(output).toContain("Migrate Button"); }); + it("lists raw issue payloads returned by newer Plane APIs", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/modules/mod1/module-issues/`, + () => + HttpResponse.json({ + results: [{ id: "i1", sequence_id: 29, name: "Migrate Button" }], + }), + ), + ); + + const { moduleIssuesListHandler } = await import("@/commands/modules"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + moduleIssuesListHandler({ + project: "ACME", + moduleId: "mod1", + }), + ); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("ACME- 29 Migrate Button"); + }); + it("falls back to issue UUID when no issue_detail", async () => { server.use( http.get( diff --git a/tests/new-schemas.test.ts b/tests/new-schemas.test.ts index 7e51f4f..b6d42df 100644 --- a/tests/new-schemas.test.ts +++ b/tests/new-schemas.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "bun:test"; import { Effect, Schema } from "effect"; import { - ActivitySchema, ActivitiesResponseSchema, + ActivitySchema, IssueLinkSchema, IssueLinksResponseSchema, - ModuleSchema, - ModulesResponseSchema, ModuleIssueSchema, ModuleIssuesResponseSchema, + ModuleSchema, + ModulesResponseSchema, } from "@/config"; async function decode( @@ -194,7 +194,19 @@ describe("ModuleIssueSchema", () => { }, }); expect(mi.id).toBe("mi1"); - expect(mi.issue_detail?.sequence_id).toBe(29); + expect( + "issue_detail" in mi ? mi.issue_detail?.sequence_id : undefined, + ).toBe(29); + }); + + it("decodes a raw issue payload", async () => { + const mi = await decode(ModuleIssueSchema, { + id: "issue-uuid", + sequence_id: 29, + name: "Migrate Button", + }); + expect(mi.id).toBe("issue-uuid"); + expect("sequence_id" in mi ? mi.sequence_id : undefined).toBe(29); }); it("decodes without issue_detail", async () => { @@ -202,11 +214,11 @@ describe("ModuleIssueSchema", () => { id: "mi1", issue: "issue-uuid", }); - expect(mi.issue).toBe("issue-uuid"); - expect(mi.issue_detail).toBeUndefined(); + expect("issue" in mi ? mi.issue : undefined).toBe("issue-uuid"); + expect("issue_detail" in mi ? mi.issue_detail : undefined).toBeUndefined(); }); - it("rejects missing issue", async () => { + it("rejects payloads missing both issue relation and raw issue fields", async () => { await expect(decode(ModuleIssueSchema, { id: "mi1" })).rejects.toThrow(); }); }); @@ -214,7 +226,7 @@ describe("ModuleIssueSchema", () => { describe("ModuleIssuesResponseSchema", () => { it("decodes results", async () => { const resp = await decode(ModuleIssuesResponseSchema, { - results: [{ id: "mi1", issue: "uuid1" }], + results: [{ id: "issue-uuid", sequence_id: 29, name: "Migrate Button" }], }); expect(resp.results).toHaveLength(1); }); diff --git a/tests/new-schemas2.test.ts b/tests/new-schemas2.test.ts index 3ad4d1c..1c9d280 100644 --- a/tests/new-schemas2.test.ts +++ b/tests/new-schemas2.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "bun:test"; import { Effect, Schema } from "effect"; import { - WorklogSchema, - WorklogsResponseSchema, - IntakeIssueSchema, - IntakeIssuesResponseSchema, - PageSchema, - PagesResponseSchema, CommentSchema, CommentsResponseSchema, CycleIssueSchema, CycleIssuesResponseSchema, + IntakeIssueSchema, + IntakeIssuesResponseSchema, + PageSchema, + PagesResponseSchema, + WorklogSchema, + WorklogsResponseSchema, } from "@/config"; async function decode( @@ -194,14 +194,33 @@ describe("CycleIssueSchema", () => { issue: "i1", issue_detail: { id: "i1", sequence_id: 5, name: "Fix bug" }, }); + if (!("issue" in ci)) { + throw new Error("Expected legacy cycle-issue payload"); + } expect(ci.issue_detail?.sequence_id).toBe(5); }); it("decodes without detail", async () => { const ci = await decode(CycleIssueSchema, { id: "ci2", issue: "i2" }); + if (!("issue" in ci)) { + throw new Error("Expected legacy cycle-issue payload"); + } expect(ci.issue).toBe("i2"); }); + it("decodes raw issue payloads", async () => { + const ci = await decode(CycleIssueSchema, { + id: "i3", + sequence_id: 9, + name: "Ship campaign", + }); + if (!("sequence_id" in ci)) { + throw new Error("Expected raw issue payload"); + } + expect(ci.sequence_id).toBe(9); + expect(ci.name).toBe("Ship campaign"); + }); + it("rejects missing issue", async () => { await expect(decode(CycleIssueSchema, { id: "ci3" })).rejects.toThrow(); }); diff --git a/tests/output.test.ts b/tests/output.test.ts index d7906be..ff61cb9 100644 --- a/tests/output.test.ts +++ b/tests/output.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { describe, expect, it } from "bun:test"; import { toXml } from "@/output"; describe("toXml", () => { diff --git a/tests/pages.test.ts b/tests/pages.test.ts index 898422d..99a0622 100644 --- a/tests/pages.test.ts +++ b/tests/pages.test.ts @@ -8,7 +8,7 @@ import { it, } from "bun:test"; import { Effect, Option } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { _clearProjectCache } from "@/resolve"; @@ -18,6 +18,16 @@ const WS = "testws"; const PROJECTS = [ { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, ]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const PAGES = [ { id: "pg1", @@ -46,6 +56,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`, () => HttpResponse.json({ results: PAGES }), ), @@ -60,16 +73,16 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); describe("pagesList", () => { @@ -123,6 +136,21 @@ describe("pagesList", () => { } expect(logs.join("\n")).toBe("No pages"); }); + + it("returns a definitive error when the page API is unavailable", async () => { + server.use( + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), + ); + const { pagesListHandler } = await import("@/commands/pages"); + await expect( + Effect.runPromise(pagesListHandler({ project: "ACME" })), + ).rejects.toThrow( + "Project pages are not available for ACME on this Plane instance or API version.", + ); + }); }); describe("pagesGet", () => { @@ -364,7 +392,12 @@ describe("pagesDuplicate", () => { server.use( http.post( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/pages/pg1/duplicate/`, - () => HttpResponse.json({ ...NEW_PAGE, id: "pg-dup", name: "New Page (copy)" }), + () => + HttpResponse.json({ + ...NEW_PAGE, + id: "pg-dup", + name: "New Page (copy)", + }), ), ); const { pagesDuplicateHandler } = await import("@/commands/pages"); diff --git a/tests/project-features.test.ts b/tests/project-features.test.ts new file mode 100644 index 0000000..c11c1fc --- /dev/null +++ b/tests/project-features.test.ts @@ -0,0 +1,281 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + mock, +} from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Effect } from "effect"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { _clearProjectCache } from "@/resolve"; + +const BASE = "http://feature-gates-test.local"; +const WS = "testws"; +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_CWD = process.cwd(); + +const PROJECTS = [ + { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, +]; + +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme Project", + module_view: true, + cycle_view: false, + issue_views_view: true, + page_view: true, + intake_view: true, + estimate: "est1", +}; + +const STATES = [ + { id: "st-backlog", name: "Backlog", group: "backlog", color: "#888888" }, + { + id: "st-progress", + name: "In Progress", + group: "started", + color: "#ffaa00", + }, +]; + +const LABELS = [ + { id: "lbl-ready", name: "Ready to Deploy", color: "#00aa88", parent: null }, + { id: "lbl-backend", name: "Backend", color: "#00bb66", parent: null }, +]; + +const ESTIMATE = { + id: "est1", + name: "Story Points", + description: "Default scale", + type: "points", + last_used: true, + project: "proj-acme", + workspace: "ws1", +}; + +const ESTIMATE_POINTS = [ + { + id: "ep1", + estimate: "est1", + key: 1, + value: "1", + description: "Tiny", + project: "proj-acme", + workspace: "ws1", + }, + { + id: "ep2", + estimate: "est1", + key: 2, + value: "2", + description: "Small", + project: "proj-acme", + workspace: "ws1", + }, +]; + +const server = setupServer( + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => + HttpResponse.json({ results: PROJECTS }), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () => + HttpResponse.json({ results: STATES }), + ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, () => + HttpResponse.json({ results: LABELS }), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/estimates/`, + () => HttpResponse.json(ESTIMATE), + ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/estimates/est1/estimate-points/`, + () => HttpResponse.json(ESTIMATE_POINTS), + ), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +let tempHome = ""; +let promptResponses: string[] = []; + +mock.module("node:readline", () => ({ + createInterface: () => ({ + question: (_question: string, callback: (answer: string) => void) => { + callback(promptResponses.shift() ?? ""); + }, + close: () => undefined, + }), +})); + +beforeEach(() => { + _clearProjectCache(); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-features-")); + process.env.HOME = tempHome; + process.chdir(tempHome); + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; + delete process.env.PLANE_PROJECT; + promptResponses = []; +}); + +afterEach(() => { + server.resetHandlers(); + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + process.chdir(ORIGINAL_CWD); + fs.rmSync(tempHome, { force: true, recursive: true }); +}); + +describe("feature gates", () => { + it("fails with a definitive error when cycles are disabled", async () => { + const { cyclesListHandler } = await import("@/commands/cycles"); + const result = await Effect.runPromise( + Effect.either(cyclesListHandler({ project: "ACME" })), + ); + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect(result.left.message).toContain("Project ACME has Cycles disabled"); + expect(result.left.message).toContain("cycle_view=false"); + expect(result.left.message).toContain("Enable Cycles"); + } + }); + + it("reports project feature flags during local init", async () => { + const { initHandler } = await import("@/commands/init"); + const { getLocalAgentsFilePath } = await import("@/project-agents"); + 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 feature flags:"); + expect(output).toContain("Cycles: disabled"); + expect(output).toContain("Modules: enabled"); + expect(output).toContain("Project helper saved to"); + expect(output).toContain("States: 2"); + expect(output).toContain("Labels: 2"); + expect(output).toContain("Estimate: Story Points (2 points)"); + expect(output).toContain("Local AGENTS.md updated at"); + expect(output).toContain( + "Disabled features will fail with explicit errors until Plane enables them", + ); + expect(fs.existsSync(path.join(repoDir, ".plane", "config.json"))).toBe( + true, + ); + const helperPath = getLocalProjectContextFilePath(repoDir); + expect(fs.existsSync(helperPath)).toBe(true); + const agentsPath = getLocalAgentsFilePath(repoDir); + expect(fs.existsSync(agentsPath)).toBe(true); + const agentsContent = fs.readFileSync(agentsPath, "utf8"); + expect(agentsContent).toContain("## Plane Project Context"); + expect(agentsContent).toContain("Plane project ACME (Acme Project)"); + expect(agentsContent).toContain("./.plane/project-context.json"); + expect(agentsContent).toContain( + "Prefer the `plane` CLI from this repository root for Plane project work instead of direct API calls.", + ); + expect(agentsContent).toContain("plane issues list @current"); + expect(agentsContent).toContain("plane issue get ACME-12"); + const helper = JSON.parse(fs.readFileSync(helperPath, "utf8")) as { + features: { estimates: boolean }; + helpers: { + states: { byName: Record }; + labels: { byName: Record }; + estimate: { + enabled: boolean; + pointsByValue: Record; + }; + }; + }; + expect(helper.features.estimates).toBe(true); + expect(helper.helpers.states.byName.backlog.id).toBe("st-backlog"); + expect(helper.helpers.labels.byName["ready to deploy"].id).toBe( + "lbl-ready", + ); + expect(helper.helpers.estimate.enabled).toBe(true); + expect(helper.helpers.estimate.pointsByValue["1"].id).toBe("ep1"); + }); + + it("preserves user AGENTS content and refreshes the managed project section", async () => { + const { initHandler } = await import("@/commands/init"); + const { getLocalAgentsFilePath } = await import("@/project-agents"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + process.chdir(repoDir); + const existingAgents = [ + "# Team Instructions", + "", + "Keep release notes short.", + ].join("\n"); + fs.writeFileSync( + path.join(repoDir, "AGENTS.md"), + `${existingAgents}\n`, + "utf8", + ); + + promptResponses = ["", "", "", "1"]; + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + + promptResponses = ["", "", "", ""]; + await Effect.runPromise( + initHandler({ global: false, local: true }, "local"), + ); + + const agentsPath = getLocalAgentsFilePath(repoDir); + const agentsContent = fs.readFileSync(agentsPath, "utf8"); + expect(agentsContent).toContain(existingAgents); + expect( + agentsContent + .trimEnd() + .endsWith(""), + ).toBe(true); + expect( + agentsContent.match(/plane-cli local project context start/g)?.length, + ).toBe(1); + expect(agentsContent).toContain( + "Read `./.plane/project-context.json` before planning or applying Plane project changes.", + ); + expect(agentsContent).toContain( + "If the shell may contain inherited `PLANE_*` variables, clear them before relying on `./.plane/config.json`.", + ); + expect(agentsContent).toContain("plane projects current"); + }); +}); diff --git a/tests/projects.test.ts b/tests/projects.test.ts new file mode 100644 index 0000000..00ccf4d --- /dev/null +++ b/tests/projects.test.ts @@ -0,0 +1,235 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Command } from "@effect/cli"; +import { NodeContext } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { _clearProjectCache } from "@/resolve"; + +const BASE = "http://projects-test.local"; +const WS = "testws"; +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_CWD = process.cwd(); + +const PROJECTS = [ + { id: "proj-acme", identifier: "ACME", name: "Acme Project" }, + { id: "proj-web", identifier: "WEB", name: "Web Project" }, +]; + +const server = setupServer( + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => + HttpResponse.json({ results: PROJECTS }), + ), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); + +let tempHome = ""; + +beforeEach(() => { + _clearProjectCache(); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-projects-")); + process.env.HOME = tempHome; + process.chdir(tempHome); + process.env.PLANE_HOST = BASE; + process.env.PLANE_WORKSPACE = WS; + process.env.PLANE_API_TOKEN = "test-token"; + delete process.env.PLANE_PROJECT; +}); + +afterEach(() => { + server.resetHandlers(); + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + process.chdir(ORIGINAL_CWD); + fs.rmSync(tempHome, { force: true, recursive: true }); +}); + +describe("projectsUse", () => { + it("routes through the root CLI for local project persistence", async () => { + const { projects } = await import("@/commands/projects"); + const { getLocalConfigFilePath } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + process.chdir(repoDir); + const root = Command.make("plane").pipe( + Command.withSubcommands([projects]), + ); + const cli = Command.run(root, { name: "plane", version: "0.0.0" }); + + await Effect.runPromise( + cli(["_", "_", "projects", "use", "--local", "WEB"]).pipe( + Effect.provide(Layer.mergeAll(NodeContext.layer)), + ), + ); + + const saved = JSON.parse( + fs.readFileSync(getLocalConfigFilePath(repoDir), "utf8"), + ) as { + defaultProject?: string; + }; + expect(saved.defaultProject).toBe("WEB"); + }); + + it("persists the current project in global config by default", async () => { + const { projectsUseHandler } = await import("@/commands/projects"); + const { getConfigFilePath } = await import("@/user-config"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise( + projectsUseHandler({ project: "ACME", global: false, local: false }), + ); + } finally { + console.log = orig; + } + + const saved = JSON.parse(fs.readFileSync(getConfigFilePath(), "utf8")) as { + defaultProject?: string; + }; + expect(saved.defaultProject).toBe("ACME"); + expect(logs.join("\n")).toContain("Current project set to ACME (global)"); + }); + + it("persists the current project in local config when a local config is active", async () => { + const { projectsUseHandler } = await import("@/commands/projects"); + const { getLocalConfigFilePath, writeLocalStoredConfig } = await import( + "@/user-config" + ); + const repoDir = path.join(tempHome, "repo"); + const nestedDir = path.join(repoDir, "packages", "web"); + fs.mkdirSync(nestedDir, { recursive: true }); + writeLocalStoredConfig( + { workspace: WS, token: "test-token", host: BASE }, + { cwd: repoDir, target: "cwd" }, + ); + process.chdir(nestedDir); + + await Effect.runPromise( + projectsUseHandler({ project: "WEB", global: false, local: false }), + ); + + const saved = JSON.parse( + fs.readFileSync(getLocalConfigFilePath(repoDir), "utf8"), + ) as { + defaultProject?: string; + }; + expect(saved.defaultProject).toBe("WEB"); + }); + + it("allows forcing a global current project even when local config is active", async () => { + const { projectsUseHandler } = await import("@/commands/projects"); + const { getConfigFilePath, writeLocalStoredConfig } = await import( + "@/user-config" + ); + const repoDir = path.join(tempHome, "repo"); + const nestedDir = path.join(repoDir, "apps", "cli"); + fs.mkdirSync(nestedDir, { recursive: true }); + writeLocalStoredConfig( + { workspace: WS, token: "test-token", host: BASE }, + { cwd: repoDir, target: "cwd" }, + ); + process.chdir(nestedDir); + + await Effect.runPromise( + projectsUseHandler({ project: "ACME", global: true, local: false }), + ); + + const saved = JSON.parse(fs.readFileSync(getConfigFilePath(), "utf8")) as { + defaultProject?: string; + }; + expect(saved.defaultProject).toBe("ACME"); + }); +}); + +describe("projectsCurrent", () => { + it("prints the saved current project", async () => { + const { writeStoredConfig } = await import("@/user-config"); + writeStoredConfig({ defaultProject: "WEB" }); + const { projectsCurrentHandler } = await import("@/commands/projects"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(projectsCurrentHandler()); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("WEB"); + expect(logs.join("\n")).toContain("proj-web"); + expect(logs.join("\n")).toContain("Web Project"); + expect(logs.join("\n")).toContain("(global)"); + }); + + it("reports when the effective current project comes from local config", async () => { + const { writeLocalStoredConfig } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + const nestedDir = path.join(repoDir, "services", "api"); + fs.mkdirSync(nestedDir, { recursive: true }); + writeLocalStoredConfig( + { + workspace: WS, + token: "test-token", + host: BASE, + defaultProject: "ACME", + }, + { cwd: repoDir, target: "cwd" }, + ); + process.chdir(nestedDir); + const { projectsCurrentHandler } = await import("@/commands/projects"); + const logs: string[] = []; + const orig = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + + try { + await Effect.runPromise(projectsCurrentHandler()); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("ACME"); + expect(logs.join("\n")).toContain("(local)"); + }); +}); + +describe("projectsList", () => { + it("marks the saved current project", async () => { + const { writeStoredConfig } = await import("@/user-config"); + writeStoredConfig({ defaultProject: "WEB" }); + 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()); + } finally { + console.log = orig; + } + + expect(logs.join("\n")).toContain("* WEB"); + }); +}); diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index b1d2293..1f3175a 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -8,15 +8,15 @@ import { it, } from "bun:test"; import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { - resolveProject, - parseIssueRef, + _clearProjectCache, findIssueBySeq, - getStateId, getMemberId, - _clearProjectCache, + getStateId, + parseIssueRef, + resolveProject, } from "@/resolve"; const BASE = "http://test.local"; @@ -75,16 +75,17 @@ afterAll(() => server.close()); beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_PROJECT; }); describe("resolveProject", () => { @@ -126,6 +127,20 @@ describe("resolveProject", () => { const result = await Effect.runPromise(resolveProject("WEB")); expect(result.id).toBe("proj-web"); // still from cache }); + + it("resolves @current from PLANE_PROJECT", async () => { + process.env.PLANE_PROJECT = "WEB"; + const result = await Effect.runPromise(resolveProject("@current")); + expect(result.key).toBe("WEB"); + expect(result.id).toBe("proj-web"); + }); + + it("resolves blank project from PLANE_PROJECT", async () => { + process.env.PLANE_PROJECT = "ACME"; + const result = await Effect.runPromise(resolveProject("")); + expect(result.key).toBe("ACME"); + expect(result.id).toBe("proj-acme"); + }); }); describe("parseIssueRef", () => { diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index 4e279f1..5bb481a 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "bun:test"; import { Effect, Schema } from "effect"; import { - StateSchema, + CycleSchema, + CyclesResponseSchema, IssueSchema, IssuesResponseSchema, - StatesResponseSchema, - ProjectSchema, - ProjectsResponseSchema, LabelSchema, LabelsResponseSchema, MemberSchema, MembersResponseSchema, - CycleSchema, - CyclesResponseSchema, + ProjectSchema, + ProjectsResponseSchema, + StateSchema, + StatesResponseSchema, } from "@/config"; async function decode( diff --git a/tests/user-config.test.ts b/tests/user-config.test.ts new file mode 100644 index 0000000..5d04aac --- /dev/null +++ b/tests/user-config.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const ORIGINAL_HOME = process.env.HOME; +const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_PLANE_API_TOKEN = process.env.PLANE_API_TOKEN; +const ORIGINAL_PLANE_HOST = process.env.PLANE_HOST; +const ORIGINAL_PLANE_WORKSPACE = process.env.PLANE_WORKSPACE; +const ORIGINAL_PLANE_PROJECT = process.env.PLANE_PROJECT; + +let tempHome = ""; + +beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "plane-cli-config-")); + process.env.HOME = tempHome; + delete process.env.PLANE_API_TOKEN; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_PROJECT; + process.chdir(tempHome); +}); + +afterEach(() => { + if (ORIGINAL_PLANE_API_TOKEN === undefined) { + delete process.env.PLANE_API_TOKEN; + } else { + process.env.PLANE_API_TOKEN = ORIGINAL_PLANE_API_TOKEN; + } + if (ORIGINAL_PLANE_HOST === undefined) { + delete process.env.PLANE_HOST; + } else { + process.env.PLANE_HOST = ORIGINAL_PLANE_HOST; + } + if (ORIGINAL_PLANE_WORKSPACE === undefined) { + delete process.env.PLANE_WORKSPACE; + } else { + process.env.PLANE_WORKSPACE = ORIGINAL_PLANE_WORKSPACE; + } + if (ORIGINAL_PLANE_PROJECT === undefined) { + delete process.env.PLANE_PROJECT; + } else { + process.env.PLANE_PROJECT = ORIGINAL_PLANE_PROJECT; + } + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + process.chdir(ORIGINAL_CWD); + fs.rmSync(tempHome, { force: true, recursive: true }); +}); + +describe("user config layering", () => { + it("uses the nearest local config over global config", async () => { + const { + findNearestLocalConfigFilePath, + getConfigDetails, + getLocalConfigFilePath, + writeGlobalStoredConfig, + writeLocalStoredConfig, + } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + const appDir = path.join(repoDir, "apps", "web"); + const nestedDir = path.join(appDir, "src"); + fs.mkdirSync(nestedDir, { recursive: true }); + + writeGlobalStoredConfig({ + token: "global-token", + host: "https://global.plane.local", + workspace: "global-workspace", + defaultProject: "GLOBAL", + }); + writeLocalStoredConfig( + { + workspace: "repo-workspace", + defaultProject: "REPO", + }, + { cwd: repoDir, target: "cwd" }, + ); + writeLocalStoredConfig( + { + host: "https://app.plane.local/", + defaultProject: "APP", + }, + { cwd: appDir, target: "cwd" }, + ); + + const config = getConfigDetails(nestedDir); + + expect(findNearestLocalConfigFilePath(nestedDir)).toBe( + getLocalConfigFilePath(appDir), + ); + expect(config.token).toBe("global-token"); + expect(config.workspace).toBe("global-workspace"); + // Security: local host is ignored when the token comes from global + // to prevent an untrusted repo from redirecting a real token. + expect(config.host).toBe("https://global.plane.local"); + expect(config.defaultProject).toBe("APP"); + expect(config.sources.token).toBe("global"); + expect(config.sources.host).toBe("global"); + expect(config.sources.workspace).toBe("global"); + expect(config.sources.defaultProject).toBe("local"); + }); + + it("uses local host when the local config also provides a token", async () => { + const { + getConfigDetails, + writeGlobalStoredConfig, + writeLocalStoredConfig, + } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + fs.mkdirSync(repoDir, { recursive: true }); + + writeGlobalStoredConfig({ + token: "global-token", + host: "https://global.plane.local", + workspace: "global-workspace", + }); + writeLocalStoredConfig( + { + token: "local-token", + host: "https://local.plane.local", + workspace: "local-workspace", + }, + { cwd: repoDir, target: "cwd" }, + ); + + const config = getConfigDetails(repoDir); + + expect(config.token).toBe("local-token"); + expect(config.host).toBe("https://local.plane.local"); + expect(config.workspace).toBe("local-workspace"); + expect(config.sources.token).toBe("local"); + expect(config.sources.host).toBe("local"); + expect(config.sources.workspace).toBe("local"); + }); + + it("applies canonical env vars above local and global config", async () => { + const { + getConfigDetails, + writeGlobalStoredConfig, + writeLocalStoredConfig, + } = await import("@/user-config"); + const repoDir = path.join(tempHome, "repo"); + const nestedDir = path.join(repoDir, "packages", "sdk"); + fs.mkdirSync(nestedDir, { recursive: true }); + + writeGlobalStoredConfig({ + token: "global-token", + host: "https://global.plane.local", + workspace: "global-workspace", + defaultProject: "GLOBAL", + }); + writeLocalStoredConfig( + { + token: "local-token", + host: "https://local.plane.local", + workspace: "local-workspace", + defaultProject: "LOCAL", + }, + { cwd: repoDir, target: "cwd" }, + ); + + process.env.PLANE_API_TOKEN = "env-token"; + process.env.PLANE_HOST = "https://env.plane.local/"; + process.env.PLANE_WORKSPACE = "env-workspace"; + process.env.PLANE_PROJECT = "ENV"; + + const config = getConfigDetails(nestedDir); + + expect(config.token).toBe("env-token"); + expect(config.host).toBe("https://env.plane.local"); + expect(config.workspace).toBe("env-workspace"); + expect(config.defaultProject).toBe("ENV"); + expect(config.sources.token).toBe("env"); + expect(config.sources.host).toBe("env"); + expect(config.sources.workspace).toBe("env"); + expect(config.sources.defaultProject).toBe("env"); + }); + + it("normalizes inherited hosts without an explicit scheme", async () => { + const { getConfigDetails, writeGlobalStoredConfig } = await import( + "@/user-config" + ); + + writeGlobalStoredConfig({ + host: "plane.domain.com/", + workspace: "workspace-1", + token: "token-1", + }); + + const config = getConfigDetails(tempHome); + + expect(config.host).toBe("https://plane.domain.com"); + expect(config.sources.host).toBe("global"); + + process.env.PLANE_HOST = "api.plane.local"; + const envConfig = getConfigDetails(tempHome); + expect(envConfig.host).toBe("https://api.plane.local"); + expect(envConfig.sources.host).toBe("env"); + }); +}); diff --git a/tests/xml-output.test.ts b/tests/xml-output.test.ts index 5f42e5d..bc6f452 100644 --- a/tests/xml-output.test.ts +++ b/tests/xml-output.test.ts @@ -10,13 +10,13 @@ import { describe, expect, it, + mock, } from "bun:test"; -import { Effect } from "effect"; -import { http, HttpResponse } from "msw"; +import { Effect, Option } from "effect"; +import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; -import { mock } from "bun:test"; -import { _clearProjectCache } from "@/resolve"; import { toXml } from "@/output"; +import { _clearProjectCache } from "@/resolve"; // Set xmlMode=true for this entire test file before command modules load mock.module("@/output", () => ({ @@ -29,6 +29,16 @@ const BASE = "http://xml-output-test.local"; const WS = "testws"; const PROJECTS = [{ id: "proj-acme", identifier: "ACME", name: "Acme" }]; +const PROJECT_DETAIL = { + id: "proj-acme", + identifier: "ACME", + name: "Acme", + module_view: true, + cycle_view: true, + issue_views_view: true, + page_view: true, + inbox_view: true, +}; const ISSUES = [ { id: "i1", @@ -49,17 +59,17 @@ const CYCLES = [ ]; const CYCLE_ISSUES = [ { - id: "ci1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" }, + id: "i1", + sequence_id: 1, + name: "Issue One", }, ]; const MODULES = [{ id: "mod1", name: "Module Alpha", status: "in-progress" }]; const MODULE_ISSUES = [ { - id: "mi1", - issue: "i1", - issue_detail: { id: "i1", sequence_id: 1, name: "Issue One" }, + id: "i1", + sequence_id: 1, + name: "Issue One", }, ]; const INTAKE_ISSUES = [ @@ -124,6 +134,9 @@ const server = setupServer( http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () => HttpResponse.json({ results: PROJECTS }), ), + http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/`, () => + HttpResponse.json(PROJECT_DETAIL), + ), http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () => HttpResponse.json({ results: ISSUES }), ), @@ -153,13 +166,17 @@ const server = setupServer( () => HttpResponse.json({ results: ACTIVITIES }), ), http.get( - `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/issue-links/`, + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/links/`, () => HttpResponse.json({ results: LINKS }), ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`, () => HttpResponse.json({ results: COMMENTS }), ), + http.get( + `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/work-items/i1/worklogs/`, + () => new HttpResponse('{"error":"Page not found."}', { status: 404 }), + ), http.get( `${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/worklogs/`, () => HttpResponse.json({ results: WORKLOGS }), @@ -182,16 +199,16 @@ afterAll(() => { beforeEach(() => { _clearProjectCache(); - process.env["PLANE_HOST"] = BASE; - process.env["PLANE_WORKSPACE"] = WS; - process.env["PLANE_API_TOKEN"] = "test-token"; + 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"]; + delete process.env.PLANE_HOST; + delete process.env.PLANE_WORKSPACE; + delete process.env.PLANE_API_TOKEN; }); async function captureLogs(fn: () => Promise): Promise { @@ -208,9 +225,9 @@ async function captureLogs(fn: () => Promise): Promise { describe("cyclesList --xml", () => { it("outputs XML of cycles", async () => { - const { cyclesList } = await import("@/commands/cycles"); + const { cyclesListHandler } = await import("@/commands/cycles"); const output = await captureLogs(() => - Effect.runPromise((cyclesList as any).handler({ project: "ACME" })), + Effect.runPromise(cyclesListHandler({ project: "ACME" })), ); expect(output).toContain(""); expect(output).toContain("cyc1"); @@ -219,22 +236,22 @@ describe("cyclesList --xml", () => { describe("cycleIssuesList --xml", () => { it("outputs XML of cycle issues", async () => { - const { cycleIssuesList } = await import("@/commands/cycles"); + const { cycleIssuesListHandler } = await import("@/commands/cycles"); const output = await captureLogs(() => Effect.runPromise( - (cycleIssuesList as any).handler({ project: "ACME", cycleId: "cyc1" }), + cycleIssuesListHandler({ project: "ACME", cycleId: "cyc1" }), ), ); expect(output).toContain(""); - expect(output).toContain("ci1"); + expect(output).toContain("Issue One"); }); }); describe("modulesList --xml", () => { it("outputs XML of modules", async () => { - const { modulesList } = await import("@/commands/modules"); + const { modulesListHandler } = await import("@/commands/modules"); const output = await captureLogs(() => - Effect.runPromise((modulesList as any).handler({ project: "ACME" })), + Effect.runPromise(modulesListHandler({ project: "ACME" })), ); expect(output).toContain(""); expect(output).toContain("mod1"); @@ -243,25 +260,26 @@ describe("modulesList --xml", () => { describe("moduleIssuesList --xml", () => { it("outputs XML of module issues", async () => { - const { moduleIssuesList } = await import("@/commands/modules"); + const { moduleIssuesListHandler } = await import("@/commands/modules"); const output = await captureLogs(() => Effect.runPromise( - (moduleIssuesList as any).handler({ + moduleIssuesListHandler({ project: "ACME", moduleId: "mod1", }), ), ); expect(output).toContain(""); - expect(output).toContain("mi1"); + expect(output).toContain('id="i1"'); + expect(output).toContain('sequence_id="1"'); }); }); describe("intakeList --xml", () => { it("outputs XML of intake issues", async () => { - const { intakeList } = await import("@/commands/intake"); + const { intakeListHandler } = await import("@/commands/intake"); const output = await captureLogs(() => - Effect.runPromise((intakeList as any).handler({ project: "ACME" })), + Effect.runPromise(intakeListHandler({ project: "ACME" })), ); expect(output).toContain(""); expect(output).toContain("int1"); @@ -270,9 +288,9 @@ describe("intakeList --xml", () => { describe("pagesList --xml", () => { it("outputs XML of pages", async () => { - const { pagesList } = await import("@/commands/pages"); + const { pagesListHandler } = await import("@/commands/pages"); const output = await captureLogs(() => - Effect.runPromise((pagesList as any).handler({ project: "ACME" })), + Effect.runPromise(pagesListHandler({ project: "ACME" })), ); expect(output).toContain(""); expect(output).toContain("pg1"); @@ -281,9 +299,9 @@ describe("pagesList --xml", () => { describe("issueActivity --xml", () => { it("outputs XML of activities", async () => { - const { issueActivity } = await import("@/commands/issue"); + const { issueActivityHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueActivity as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueActivityHandler({ ref: "ACME-1" })), ); expect(output).toContain(""); expect(output).toContain("act1"); @@ -292,9 +310,9 @@ describe("issueActivity --xml", () => { describe("issueLinkList --xml", () => { it("outputs XML of links", async () => { - const { issueLinkList } = await import("@/commands/issue"); + const { issueLinkListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueLinkList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueLinkListHandler({ ref: "ACME-1" })), ); expect(output).toContain(""); expect(output).toContain("lnk1"); @@ -303,9 +321,9 @@ describe("issueLinkList --xml", () => { describe("issueCommentsList --xml", () => { it("outputs XML of comments", async () => { - const { issueCommentsList } = await import("@/commands/issue"); + const { issueCommentsListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueCommentsList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueCommentsListHandler({ ref: "ACME-1" })), ); expect(output).toContain(""); expect(output).toContain("cmt1"); @@ -314,9 +332,9 @@ describe("issueCommentsList --xml", () => { describe("issueWorklogsList --xml", () => { it("outputs XML of worklogs", async () => { - const { issueWorklogsList } = await import("@/commands/issue"); + const { issueWorklogsListHandler } = await import("@/commands/issue"); const output = await captureLogs(() => - Effect.runPromise((issueWorklogsList as any).handler({ ref: "ACME-1" })), + Effect.runPromise(issueWorklogsListHandler({ ref: "ACME-1" })), ); expect(output).toContain(""); expect(output).toContain("wl1"); @@ -325,14 +343,14 @@ describe("issueWorklogsList --xml", () => { describe("issuesList --xml", () => { it("outputs XML of issues", async () => { - const { issuesList } = await import("@/commands/issues"); + const { issuesListHandler } = await import("@/commands/issues"); const output = await captureLogs(() => Effect.runPromise( - (issuesList as any).handler({ + issuesListHandler({ project: "ACME", - state: { _tag: "None" }, - assignee: { _tag: "None" }, - priority: { _tag: "None" }, + state: Option.none(), + assignee: Option.none(), + priority: Option.none(), }), ), );