diff --git a/.changeset/clickup-support.md b/.changeset/clickup-support.md new file mode 100644 index 0000000..393b9a0 --- /dev/null +++ b/.changeset/clickup-support.md @@ -0,0 +1,5 @@ +--- +"@northguild/worktree": minor +--- + +Add `worktree branch --clickup ` to start a worktree from a ClickUp task, with `clickup.apiToken` and `clickup.workspaceId` config keys set by `worktree config`. diff --git a/README.md b/README.md index bf4b2c2..9ce92f9 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ line, `worktree: no default for ; pass `. the worktree, starts nothing and exits `1`. - **Source branch:** `defaultSourceBranch`, else `origin/main` with a warning. - **Removal** has no default: `remove -f`. -- **Bounded calls:** GitHub, Jira and Linear requests 15 s, `git fetch` 60 s, `gh auth token` and the session listing +- **Bounded calls:** GitHub, Jira, Linear and ClickUp requests 15 s, `git fetch` 60 s, `gh auth token` and the session listing 10 s, the install 10 min. A command still running at its bound is sent SIGTERM, then SIGKILL 2 s later, so one that ignores SIGTERM cannot hold the run open. @@ -315,7 +315,7 @@ worktree branch --github 42 --json --agent-file brief.md "warnings":[]} ``` -- `issue` is `{provider:"github",number,url}`, `{provider:"jira",key,url}`, `{provider:"linear",key,url}`, or `null`. +- `issue` is `{provider:"github",number,url}`, `{provider:"jira",key,url}`, `{provider:"linear",key,url}`, `{provider:"clickup",key,url}`, or `null`. - `assigned` is `null` when no assignment was attempted. `herdr` and `agent` are `null` when skipped, and `agent.command` leaves the brief out. `agent.name` is `null` where this CLI named nothing (a detached start). - `installed` is `{ran:false,reason}` when skipped. A failed install still prints the whole document, with @@ -469,4 +469,4 @@ MIT - [x] Integrate with GitHub for automated branch naming - [x] Integrate with JIRA for automated branch naming - [x] Integrate with Linear for automated branch naming -- [ ] Integrate with ClickUp for automated branch naming +- [x] Integrate with ClickUp for automated branch naming diff --git a/context/stack.md b/context/stack.md index 042daf9..8d1cfa4 100644 --- a/context/stack.md +++ b/context/stack.md @@ -25,7 +25,7 @@ confusing errors rather than obvious ones — see the prerequisites and *Prepare bin/ oclif entry point — bin/run.js, produced by tsc src/commands/ one file per CLI command; each default-exports a BaseCommand subclass src/lib/ CLI helpers — git integration, validators, env, types, constants, cli -src/integrations/ GitHub, Jira and Linear integrations +src/integrations/ GitHub, Jira, Linear and ClickUp integrations scripts/ version-sync script, run by `pnpm sync-version`; `release-notes.mjs`, which publish.yml uses to cut a version's CHANGELOG section .changeset/ Changesets config and pending release notes (`.changeset/.md`) patches/ pnpm patches applied through `patchedDependencies` in `pnpm-workspace.yaml`; `nextra-theme-docs@4.6.1` makes its layout's `children` optional, which zod >=4.4 otherwise rejects at prerender. Drop it once nextra ships a fix @@ -81,7 +81,7 @@ untrue on one of these surfaces is fixed by the phase that makes it untrue, not | Surface | Who reads it | What has to reach it | |---|---|---| | [`README.md`](../README.md) | someone deciding whether to install; npm renders it as the package page | the fast path — a new command, a renamed flag, a change to what the tool is for | -| `docs/src/app/docs/` | users looking something up, at https://northguild.github.io/worktree | any user-visible CLI behaviour. One page per command under `commands/` (7), plus `configuration/`, `getting-started/`, `faq/`, and `guides/` (8) | +| `docs/src/app/docs/` | users looking something up, at https://northguild.github.io/worktree | any user-visible CLI behaviour. One page per command under `commands/` (7), plus `configuration/`, `getting-started/`, `faq/`, and `guides/` (9) | | [`docs/README.md`](../docs/README.md) | someone working on the docs app | how the docs app is run or built | | [`docs/worker/README.md`](../docs/worker/README.md) | someone working on the chat proxy | the Worker's routes, secrets or deploy | | `skills/worktree/SKILL.md` | agents that install the skill with `npx skills add`; shipped in the npm package; also embedded in the docs chat system prompt | a command or flag change that the skill describes — **but see the caveat below** | diff --git a/docs/src/app/docs/commands/branch/page.mdx b/docs/src/app/docs/commands/branch/page.mdx index c42bb53..14cc3ba 100644 --- a/docs/src/app/docs/commands/branch/page.mdx +++ b/docs/src/app/docs/commands/branch/page.mdx @@ -16,6 +16,7 @@ worktree branch --github --assign worktree branch --github --no-assign worktree branch --jira worktree branch --linear +worktree branch --clickup worktree branch --agent "" worktree branch --agent-file worktree branch --agent-stdin @@ -39,6 +40,8 @@ worktree branch --jira DEV-123 worktree branch --jira dev-123 worktree branch --linear ENG-123 worktree branch --linear eng-123 +worktree branch --clickup 9hx +worktree branch --clickup DEV-123 worktree branch feature/add-bulk-actions --agent "add bulk actions to the table" worktree branch --github 42 --agent "implement the issue" worktree branch --github 42 --agent-file brief.md @@ -119,9 +122,9 @@ worktree config github.autoAssign true # always assign, stop asking - The assignment happens before the worktree is created, so the question is never buried behind creation output or a launched editor. Assigning is idempotent — GitHub does not replace existing assignees — so retrying after a failed creation is harmless. -- `--assign` and `--no-assign` both require `--github`, `--jira` or `--linear`; on their own there is no issue to act - on. `--assign --jira` and `--assign --linear` warn that assignment is not supported for that provider yet - and carry on. +- `--assign` and `--no-assign` both require one of `--github`, `--jira`, `--linear` or `--clickup`; on their own there is no issue to act + on. `--assign --jira`, `--assign --linear` and `--assign --clickup` warn that assignment is not supported for that + provider yet and carry on. ## Installing dependencies @@ -175,7 +178,7 @@ Jira authentication requires three configuration keys: ## Linear Issue Branching Use the `--linear` flag (short: `-l`) to create a suggested branch name from an issue in your Linear workspace. It -cannot be combined with `--github` or `--jira`. See the [Linear guide](/docs/guides/linear-integration). +cannot be combined with `--github`, `--jira` or `--clickup`. See the [Linear guide](/docs/guides/linear-integration). - The issue identifier can be passed in any case (`eng-123`, `ENG-123`, etc.). - The generated name is pre-filled in the prompt and still editable. @@ -189,6 +192,27 @@ If no prefix is configured, `worktree` falls back to an empty prefix and still g Linear authentication requires one configuration key, `northguild.worktree.linear.apiKey`, your Linear personal API key. Without it the command offers to run `worktree config`, and when there is still no key (or the run is non-interactive) exits `2` with `missing_value`. Assigning the issue is not supported yet. +## ClickUp Task Branching + +Use the `--clickup` flag (short: `-c`) to create a suggested branch name from a task in your ClickUp Workspace. It +cannot be combined with `--github`, `--jira` or `--linear`. See the [ClickUp guide](/docs/guides/clickup-integration). + +- Pass the task id in either form: the short id in the task's URL and ID field (`9hx`, typed without `CU-`; case + matters), or a custom id (`DEV-123`, sent to ClickUp as typed and upper-cased in the branch name) when your Workspace has custom task ids switched on. + Anything else, including `CU-9hx`, exits `2` with `invalid_value` before any request. +- A custom id needs `clickup.workspaceId`; without it the command exits `2` with `missing_value`. +- A short id becomes `CU-` in the branch name (`feature/CU-9hx-fix-login`); a custom id is upper-cased + (`fix/DEV-123-fix-login`). +- The generated name is pre-filled in the prompt and still editable. +- ClickUp's tags pick the optional prefix, by exact name and ignoring case: + - `branchPrefix.bugfix` for a `bug` tag + - `branchPrefix.chore` for a `chore` or `maintenance` tag, when there is no `bug` tag + - `branchPrefix.feature` for anything else, including no tag + +ClickUp authentication requires one configuration key, `northguild.worktree.clickup.apiToken`, your ClickUp personal +API token. Without it the command offers to run `worktree config`, and when there is still no token (or the run is +non-interactive) exits `2` with `missing_value`. Assigning the task is not supported yet. + ## Agent Dispatch Use the `--agent` flag (short: `-a`) to hand the new worktree to a coding agent with a prompt, called the @@ -284,7 +308,7 @@ person goes to stderr instead: the spinner lines, the env-file list, notes and w | Key | Value | | --- | --- | | `path`, `branch`, `source` | the worktree's absolute path, its branch, and the ref it was created from | -| `issue` | `{provider:"github", number, url}`, `{provider:"jira", key, url}` or `{provider:"linear", key, url}`; `null` when no issue was used. `url` is `null` when it is not known | +| `issue` | `{provider:"github", number, url}`, `{provider:"jira", key, url}`, `{provider:"linear", key, url}` or `{provider:"clickup", key, url}`; `null` when no issue was used. `url` is `null` when it is not known | | `assigned` | `true` or `false` once an assignment was attempted; `null` when none was (no issue, `--no-assign`, or a configured `false`) | | `envFilesCopied` | the env files copied, relative to the repository root; `[]` when there were none | | `installed` | `{ran:true, command, inferred, ok}`, plus `reason` when `ok` is `false`; `{ran:false, reason}` when the step was skipped | @@ -307,7 +331,7 @@ describing it goes to stderr as usual: | `code` | Means | Exit | | --- | --- | --- | | `missing_value` | a value with no default was not given; `details` names it and the flag | `2` | -| `invalid_value` | a value or flag combination was refused: a bad branch name, an empty brief, a malformed `--github`, `--jira` or `--linear` issue id, a `--github` number that is a pull request | `2` | +| `invalid_value` | a value or flag combination was refused: a bad branch name, an empty brief, a malformed `--github`, `--jira`, `--linear` or `--clickup` issue id, a `--github` number that is a pull request | `2` | | `not_found` | something named does not exist, such as the `--source` branch | `2` | | `timeout` | a call that is bounded did not answer in time | `1` | | `failed` | anything else | `1` | diff --git a/docs/src/app/docs/commands/config/page.mdx b/docs/src/app/docs/commands/config/page.mdx index 83c6a4c..b7539e8 100644 --- a/docs/src/app/docs/commands/config/page.mdx +++ b/docs/src/app/docs/commands/config/page.mdx @@ -19,7 +19,7 @@ worktree config --missing --names worktree config --yes --names ``` -`--list` shows `github.token`, `jira.apiToken` and `linear.apiKey` as `=***` when set, so a listing is safe to +`--list` shows `github.token`, `jira.apiToken`, `linear.apiKey` and `clickup.apiToken` as `=***` when set, so a listing is safe to paste or screen-share. `worktree config ` with no value prints the current value and nothing else, secrets included, so `token=$(worktree config github.token)` works in a script. A key with no value prints nothing and exits `0`. @@ -29,7 +29,7 @@ exits `0`. - With `--list`, it only prints config names that currently have no value. - Without `--list`, it only prompts for missing values. - `--names` limits prompting to specific config keys. Naming keys is asking for them, so the optional group - confirms (Jira, GitHub, Linear, prefixes, opener, editor, Herdr, agent, post-create) are not asked for them. + confirms (Jira, GitHub, Linear, ClickUp, prefixes, opener, editor, Herdr, agent, post-create) are not asked for them. - `--yes` (`-y`) makes the run [non-interactive](/docs/commands/branch#non-interactive-runs): nothing is asked. Each key takes its default, or the value it already holds, and the run fails with exit `2` naming `worktree config ` for a key with neither, such as a token. Without `--names` the optional groups have no default and the run fails naming `--names `. @@ -82,9 +82,10 @@ regardless, so a machine can be configured before Herdr is installed. yes/no. The `github.autoAssign` prompt has three choices: "Yes, always", "No, never" and "Ask me each time". The last leaves the key unset, clearing any stored answer, which is the "ask me" state — so `--missing` goes on offering the prompt until you settle it either way. Leaving the `github.token`, -`jira.apiToken` or `linear.apiKey` prompt empty, or answering with only spaces, keeps any token you already have, +`jira.apiToken`, `linear.apiKey` or `clickup.apiToken` prompt empty, or answering with only spaces, keeps any token you already have, rather than clearing it. A pasted value is stored without surrounding whitespace. -A direct `worktree config github.token " "` (likewise `jira.apiToken` and `linear.apiKey`) with a value that is only +`clickup.workspaceId` is optional and only matters for custom task ids such as `DEV-123`: an empty answer skips it and writes nothing, a non-interactive run skips it rather than failing, and under `--missing` pressing Enter skips its prompt too. +A direct `worktree config github.token " "` (likewise `jira.apiToken`, `linear.apiKey` and `clickup.apiToken`) with a value that is only whitespace is refused with exit `2`, and nothing is stored. ## Agent Key diff --git a/docs/src/app/docs/configuration/page.mdx b/docs/src/app/docs/configuration/page.mdx index ba7dd8f..34a1296 100644 --- a/docs/src/app/docs/configuration/page.mdx +++ b/docs/src/app/docs/configuration/page.mdx @@ -21,6 +21,8 @@ title: Configuration - `jira.email` (Your Jira email address) - `jira.apiToken` (Jira API token) - `linear.apiKey` (Linear personal API key, example: `lin_api_...`) +- `clickup.apiToken` (ClickUp personal API token, example: `pk_...`) +- `clickup.workspaceId` (optional; your ClickUp Workspace id, a number, needed only for custom task ids like `DEV-123`) - `branchPrefix.feature` (example: `feature/`) - `branchPrefix.bugfix` (example: `fix/`) - `branchPrefix.chore` (example: `chore/`) @@ -168,6 +170,8 @@ worktree config jira.host https://company.atlassian.net worktree config jira.email your-email@example.com worktree config jira.apiToken worktree config linear.apiKey +worktree config clickup.apiToken +worktree config clickup.workspaceId worktree config branchPrefix.feature feature/ worktree config branchPrefix.bugfix fix/ worktree config branchPrefix.chore chore/ @@ -222,6 +226,16 @@ worktree config github.autoAssign true `chore` or `maintenance` uses `branchPrefix.chore`, anything else uses `branchPrefix.feature`. - Issue identifiers are case-insensitive (both `eng-123` and `ENG-123` work). +## ClickUp Integration Notes + +- `worktree branch --clickup ` reads tasks from the ClickUp Workspace your token belongs to. +- `clickup.apiToken` is required. `clickup.workspaceId` is optional and is needed only when you pass a custom task id + such as `DEV-123`; a short id such as `9hx` never needs it. An empty answer to its prompt skips it. +- The branch prefix comes from the task's tags: `bug` uses `branchPrefix.bugfix`, + `chore` or `maintenance` uses `branchPrefix.chore`, anything else uses `branchPrefix.feature`. +- A short id is case-sensitive and typed without `CU-` (`9hx`); the branch name gains `CU-`. A custom id is + sent to ClickUp as typed and upper-cased in the branch name (`dev-123` gives `DEV-123` in the name). + ## Recommended Team Baseline 1. Agree on one source branch (`origin/main` for most teams). @@ -229,3 +243,4 @@ worktree config github.autoAssign true 3. Keep editor command consistent across local setups. 4. For Jira integration, share workspace URL and branch prefix conventions. 5. For Linear integration, share which labels map to bug and chore work, since the label decides the prefix. +6. For ClickUp integration, share which tags map to bug and chore work, since the tag decides the prefix, and whether your Workspace uses custom task ids (then also the workspace id). diff --git a/docs/src/app/docs/guides/_meta.ts b/docs/src/app/docs/guides/_meta.ts index 19a6e18..ece8261 100644 --- a/docs/src/app/docs/guides/_meta.ts +++ b/docs/src/app/docs/guides/_meta.ts @@ -4,6 +4,7 @@ const meta: MetaRecord = { "github-issue-integration": "GitHub Issue Integration", "jira-integration": "Jira Issue Integration", "linear-integration": "Linear Issue Integration", + "clickup-integration": "ClickUp Task Integration", "context-switching": "Context Switching", "team-conventions": "Team Conventions", "env-files": "Environment Files", diff --git a/docs/src/app/docs/guides/clickup-integration/page.mdx b/docs/src/app/docs/guides/clickup-integration/page.mdx new file mode 100644 index 0000000..788e7c5 --- /dev/null +++ b/docs/src/app/docs/guides/clickup-integration/page.mdx @@ -0,0 +1,126 @@ +--- +title: ClickUp Task Integration +--- + +# ClickUp Task Integration + +`worktree` can derive branch names directly from ClickUp tasks in your ClickUp Workspace. + +## Basic Usage + +```bash +worktree branch --clickup 9hx +worktree branch --clickup DEV-123 +worktree branch -c 9hx +``` + +The generated branch name is shown as the default in the prompt, so you can accept it or edit it before creation. + +`--clickup` cannot be combined with `--github`, `--jira` or `--linear`. `--assign` does not work with `--clickup`: +assigning a ClickUp task is not supported yet, so the flag warns `Assignment is not supported for ClickUp tasks yet.` +and the worktree is still created. + +## Task Ids + +ClickUp shows a task under two kinds of id, and `--clickup` accepts both, told apart by their shape: + +- **Short id** (`9hx`, `86a1b2c3d`): letters and digits, no hyphen, the id in the task's URL. Type it **without** the + `CU-` that ClickUp shows in the UI. The case you type is kept, because short ids are case-sensitive. A short id + needs only `clickup.apiToken`. +- **Custom id** (`DEV-123`): a prefix, a hyphen and digits, available when your Workspace has custom task ids + switched on. It is upper-cased in the branch name but sent to ClickUp as you typed it. Reading a task by custom id needs `clickup.workspaceId`; without it the + command exits with code `2` and `missing_value`, naming `worktree config clickup.workspaceId `. + +Anything else is refused with exit code `2` and `invalid_value` before any request is made, and `CU-9hx` is among +them: pass `9hx`. A pasted task URL is not accepted. + +## How Branch Names Are Built + +1. Read the task's id, name and tags from the ClickUp API. +2. Sanitize the name into a branch-safe slug, cut to at most 48 characters at the last dash so it ends on a whole word. A single word longer than 48 characters is cut at 48. +3. Prepend a configured prefix chosen from the task's tags. + +The name is the prefix, the id and the slug: + +- a short id gains `CU-`, the form ClickUp's GitHub integration looks for, for example `feature/CU-9hx-fix-login-redirect` +- a custom id is upper-cased, for example `fix/DEV-123-fix-login-redirect` + +The prefix comes from the task's tags, which are compared by exact name, ignoring case: + +- a `bug` tag -> `branchPrefix.bugfix` +- otherwise a `chore` or `maintenance` tag -> `branchPrefix.chore` +- otherwise (any other tag, or none) -> `branchPrefix.feature` + +When a task carries several of these tags, `bug` wins. A tag such as `bugfix` is not `bug`, so it does not match. +If a prefix key is not configured, no prefix is applied. + +## What Is Read From the Task + +**Task content is used for the branch name and nothing else.** `worktree` reads the task's id, its custom id, its +name, its URL and the names of its tags. The name is reduced to lowercase letters, digits and `-` before it becomes +part of the branch name, and the tags only select one of the configured `branchPrefix.*` values, or none. The task +description and comments are never requested, never printed, never written to the `--json` document and never passed +to an agent. + +With `--json`, `issue` is `{"provider":"clickup","key":"DEV-123","url":"https://app.clickup.com/t/..."}`. The `key` is +the task's custom id when it has one, otherwise its short id, and the `url` is the one ClickUp returns (`null` only if +ClickUp sent none). + +Requests to ClickUp are cut off after 15 seconds. + +## Authentication Resolution + +Authentication uses `clickup.apiToken`, read from local git config. `clickup.workspaceId` is optional and is read +only for a custom id. In a terminal, `branch --clickup` offers to run `worktree config` when the token is missing. +Without a token after that, and in any non-interactive run, the command exits with code `2` and `missing_value`, +naming `worktree config clickup.apiToken `, before a worktree is created. + +## Suggested Setup + +```bash +worktree config clickup.apiToken +worktree config clickup.workspaceId # only if you use custom task ids +worktree config branchPrefix.feature feature/ +worktree config branchPrefix.bugfix fix/ +worktree config branchPrefix.chore chore/ +``` + +You can also run `worktree config` and answer yes to the ClickUp group, or configure only missing values: + +```bash +worktree config --missing --names clickup.apiToken,branchPrefix.feature,branchPrefix.bugfix,branchPrefix.chore +``` + +An empty answer to the token prompt keeps the token already stored rather than clearing it. An empty answer to the +workspace id prompt skips it, and under `--missing` pressing Enter skips that prompt too. + +## Getting a ClickUp API Token + +1. In ClickUp, click your avatar, then Settings -> Apps -> API Token +2. Click "Generate" (or "Regenerate") and copy the token +3. Use it with `worktree config clickup.apiToken` + +The token is a personal token and is sent to ClickUp as given, without a `Bearer` prefix. It is stored locally in git +config and never sent anywhere except to ClickUp, and `worktree` never prints it: any `pk_` token that would reach the +output is replaced first. The token is checked the first time `--clickup` uses it; `worktree config` refuses a token +that is only whitespace but does not check it with ClickUp. + +## Getting the Workspace Id + +Only custom task ids need it. It is the numeric id of your Workspace, which ClickUp's API calls `team_id`, and it must be digits only. Leave it unset if your Workspace does not use custom task ids. + +## What Is Not Verified Yet + +This integration was written from ClickUp's API documentation and has not been run against a live Workspace. Four +things are assumptions: + +- Whether a custom id in a branch name links the branch to its task in ClickUp's GitHub App. A short id with `CU-` + is the form ClickUp documents; a custom id is not confirmed. +- Whether ClickUp resolves a lower-case custom id (`dev-123`). The id is sent as typed, so type it the way ClickUp + shows it if a lookup fails. +- Whether a short id can ever contain a hyphen. It is assumed not, which is how `9hx` and `DEV-123` are told apart. +- The tag rule is a guess carried over from the Linear integration: `bug`, `chore` and `maintenance` may not be how + your team tags tasks. Task types are not used. + +ClickUp's exact status for a task your token cannot see is also unconfirmed, so a 401 and a 403 both report +`Authentication failed. Please verify clickup.apiToken in your config.` diff --git a/docs/src/app/docs/guides/linear-integration/page.mdx b/docs/src/app/docs/guides/linear-integration/page.mdx index 6a0da3b..d7b41ee 100644 --- a/docs/src/app/docs/guides/linear-integration/page.mdx +++ b/docs/src/app/docs/guides/linear-integration/page.mdx @@ -19,9 +19,9 @@ case. A pasted issue URL is not accepted. The generated branch name is shown as the default in the prompt, so you can accept it or edit it before creation. -`--linear` cannot be combined with `--github` or `--jira`. `--assign` does not work with `--linear`: assigning a -Linear issue is not supported yet, so the flag warns `Assignment is not supported for Linear issues yet.` and the -worktree is still created. +`--linear` cannot be combined with `--github`, `--jira` or `--clickup`. `--assign` does not work with `--linear`: +assigning a Linear issue is not supported yet, so the flag warns `Assignment is not supported for Linear issues yet.` +and the worktree is still created. ## How Branch Names Are Built diff --git a/docs/src/app/docs/guides/page.mdx b/docs/src/app/docs/guides/page.mdx index 27c8881..9962653 100644 --- a/docs/src/app/docs/guides/page.mdx +++ b/docs/src/app/docs/guides/page.mdx @@ -10,6 +10,7 @@ Practical workflows and conventions for teams using `worktree` every day. - [GitHub Issue Integration](/docs/guides/github-issue-integration) - [Jira Issue Integration](/docs/guides/jira-integration) - [Linear Issue Integration](/docs/guides/linear-integration) +- [ClickUp Task Integration](/docs/guides/clickup-integration) - [Context Switching](/docs/guides/context-switching) - [Team Conventions](/docs/guides/team-conventions) - [Environment Files](/docs/guides/env-files) diff --git a/docs/src/chat/components/MarkdownContent.tsx b/docs/src/chat/components/MarkdownContent.tsx index 34ba90c..ccda7e3 100644 --- a/docs/src/chat/components/MarkdownContent.tsx +++ b/docs/src/chat/components/MarkdownContent.tsx @@ -78,6 +78,8 @@ const DOCS_ROUTE_MAP: Record = { "jira integration": "/docs/guides/jira-integration", "linear issue integration": "/docs/guides/linear-integration", "linear integration": "/docs/guides/linear-integration", + "clickup task integration": "/docs/guides/clickup-integration", + "clickup integration": "/docs/guides/clickup-integration", "context switching": "/docs/guides/context-switching", "team conventions": "/docs/guides/team-conventions", "environment files": "/docs/guides/env-files", diff --git a/docs/src/chat/docs-routes.generated.ts b/docs/src/chat/docs-routes.generated.ts index 0c9041d..c44d02d 100644 --- a/docs/src/chat/docs-routes.generated.ts +++ b/docs/src/chat/docs-routes.generated.ts @@ -16,6 +16,7 @@ export const VALID_DOC_ROUTES: ReadonlySet = new Set([ "/docs/faq", "/docs/getting-started", "/docs/guides", + "/docs/guides/clickup-integration", "/docs/guides/context-switching", "/docs/guides/editor-integration", "/docs/guides/env-files", diff --git a/skills/worktree/SKILL.md b/skills/worktree/SKILL.md index 68f395d..47936fa 100644 --- a/skills/worktree/SKILL.md +++ b/skills/worktree/SKILL.md @@ -3,7 +3,7 @@ name: worktree description: > Use when creating, listing, opening, removing or cleaning up git worktrees with the `worktree` CLI (@northguild/worktree), including starting a - worktree from a GitHub, Jira or Linear issue, handing a new worktree to a coding + worktree from a GitHub, Jira or Linear issue or a ClickUp task, handing a new worktree to a coding agent, opening worktrees as Herdr spaces, and driving worktrees from a script, CI or another coding agent with `--json`. --- @@ -62,7 +62,7 @@ worktree checkout origin/feature/fix-login-timeout Use `checkout` only for branches that already exist on the remote. Use `branch` to create something new. -### Derive a branch name from a GitHub, Jira or Linear issue +### Derive a branch name from a GitHub, Jira or Linear issue or a ClickUp task ```bash # GitHub — requires github.token in config (or gh CLI authenticated) @@ -82,12 +82,17 @@ worktree branch --jira dev-123 # Linear — requires linear.apiKey in config worktree branch --linear ENG-123 worktree branch --linear eng-123 + +# ClickUp — requires clickup.apiToken in config; a custom id (DEV-123) also +# needs clickup.workspaceId. A short id is typed without CU- (CU-9hx is refused) +worktree branch --clickup 9hx +worktree branch --clickup DEV-123 ``` -`--github`, `--jira` and `--linear` are mutually exclusive. `--assign` and -`--no-assign` need one of the three; `--assign --linear` (as `--assign --jira`) -skips the assignment with a warning, because Linear assignment is not -supported yet. `--linear` alone warns nothing. +`--github`, `--jira`, `--linear` and `--clickup` are mutually exclusive. `--assign` and +`--no-assign` need one of the four; `--assign --linear` (as `--assign --jira` +and `--assign --clickup`) skips the assignment with a warning, because +assignment is not supported for them yet. `--linear` alone warns nothing. With neither flag, `github.autoAssign` decides whether the issue is assigned: `true` always, `false` never, and unset means you are asked once @@ -110,9 +115,15 @@ and by exact name: a `bug` label → `branchPrefix.bugfix`; else a `chore` or others. The name is the prefix, the upper-cased identifier and the slugged title, such as `fix/ENG-123-fix-login`. +ClickUp's tags decide the same way, with the same `bug`, `chore` and +`maintenance` rule (a guess carried over from Linear, not confirmed against a +live Workspace). A short id such as `9hx` becomes `CU-9hx` in the name +(`feature/CU-9hx-fix-login`); a custom id is upper-cased in the name only, and sent to ClickUp as typed +(`fix/DEV-123-fix-login`). + **Issue content is used for the branch name and nothing else.** `--github`, -`--jira` and `--linear` read the issue's number or key, its title (Jira: -summary) and its type (Linear: its labels); `--github` also reads whether the +`--jira`, `--linear` and `--clickup` read the issue's number or key, its title (Jira: +summary) and its type (Linear: its labels; ClickUp: its tags); `--github` also reads whether the issue is closed and whether the number is a pull request. A pull request is refused, naming `worktree checkout ` when its branch is on this repository and its name is only letters, digits, `_`, `.`, `/` and `-`, and a closed issue warns (interactively, it asks first). The title is reduced to lowercase letters, digits and `-` before it @@ -191,7 +202,7 @@ Defaults when non-interactive: and exits `1`. - **Source branch:** `defaultSourceBranch`, else `origin/main` with a warning. - **Removal** has no default: `remove -f`. -- **Bounded calls:** GitHub, Jira and Linear requests 15 s, `git fetch` 60 s, +- **Bounded calls:** GitHub, Jira, Linear and ClickUp requests 15 s, `git fetch` 60 s, `gh auth token` and the session listing 10 s, the install 10 min. A command still running at its bound is sent SIGTERM, then SIGKILL 2 s later. @@ -211,8 +222,8 @@ document: "warnings":[]} ``` -- `issue` is `{provider:"github",number,url}`, `{provider:"jira",key,url}`, `{provider:"linear",key,url}`, or - `null`; `url` can itself be `null`. +- `issue` is `{provider:"github",number,url}`, `{provider:"jira",key,url}`, `{provider:"linear",key,url}`, + `{provider:"clickup",key,url}`, or `null`; `url` can itself be `null`. - `assigned` is `null` when no assignment was attempted. - `herdr` and `agent` are `null` when skipped. `agent.command` leaves the brief out. `agent.name` is `null` where this CLI named nothing: a detached @@ -376,9 +387,11 @@ under `northguild.worktree.*`. | `jira.email` | `you@company.com` | `--jira` flag | | `jira.apiToken` | `ATATT...` | `--jira` flag | | `linear.apiKey` | `lin_api_...` | `--linear` flag | -| `branchPrefix.feature` | `feature/` | auto-prefix on Feature issues, and on Linear issues with no bug, chore or maintenance label | -| `branchPrefix.bugfix` | `fix/` | auto-prefix on Bug issues, and on Linear issues labelled `bug` | -| `branchPrefix.chore` | `chore/` | auto-prefix on Task issues, and on Linear issues labelled `chore` or `maintenance` | +| `clickup.apiToken` | `pk_...` | `--clickup` flag | +| `clickup.workspaceId` | `12345678` | optional; only a custom task id (`--clickup DEV-123`) needs it | +| `branchPrefix.feature` | `feature/` | auto-prefix on Feature issues, on Linear issues with no bug, chore or maintenance label, and on ClickUp tasks with no such tag | +| `branchPrefix.bugfix` | `fix/` | auto-prefix on Bug issues, on Linear issues labelled `bug`, and on ClickUp tasks tagged `bug` | +| `branchPrefix.chore` | `chore/` | auto-prefix on Task issues, on Linear issues labelled `chore` or `maintenance`, and on ClickUp tasks tagged so | ```bash # Configure only missing keys @@ -515,6 +528,31 @@ Source: `docs/guides/linear-integration`, `src/integrations/linear.ts` --- +### HIGH `--clickup` flag used without a ClickUp API token + +Wrong: + +```bash +worktree branch --clickup 9hx +``` + +Correct: + +```bash +worktree config clickup.apiToken pk_... +worktree branch --clickup 9hx +``` + +`clickup.apiToken` is required. Without it `branch --clickup` exits `2` +(`missing_value`) before any worktree is created, and a non-interactive run +never prompts for it. A custom id (`DEV-123`) also needs `clickup.workspaceId` +(`worktree config clickup.workspaceId `) and exits `2` without it. Type a +short id without `CU-`: `CU-9hx` is refused as an invalid id (`invalid_value`). + +Source: `docs/guides/clickup-integration`, `src/integrations/clickup.ts` + +--- + ### HIGH `--agent` used without `agent.command` configured Wrong: diff --git a/src/commands/branch.test.ts b/src/commands/branch.test.ts index cc234cd..aa39da9 100644 --- a/src/commands/branch.test.ts +++ b/src/commands/branch.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { confirm, input } from "@inquirer/prompts"; import ora from "ora"; +import * as clickupIntegration from "../integrations/clickup.js"; import * as githubIntegration from "../integrations/github.js"; import * as herdr from "../integrations/herdr.js"; import * as jiraIntegration from "../integrations/jira.js"; @@ -970,6 +971,324 @@ describe("branch command", () => { }); }); + describe("--clickup flag", () => { + // Expected names come from D6: `${prefix}CU--` for a short id, + // `${prefix}-` for a custom one. + const shortTask = { + id: "9hx", + customId: null, + name: "Add dark mode", + url: "https://app.clickup.com/t/9hx", + tags: [] as string[], + }; + const customTask = { + id: "86abc", + customId: "DEV-123", + name: "Add dark mode", + url: "https://app.clickup.com/t/86abc", + tags: [] as string[], + }; + + function stubConfig(values: Record = {}) { + const config: Record = { + "has-called-config": "true", + defaultSourceBranch: "origin/main", + "clickup.apiToken": "pk_test", + "branchPrefix.feature": "feature/", + "branchPrefix.bugfix": "fix/", + ...values, + }; + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => + Promise.resolve(config[key] ?? ""), + ); + } + + it("names a short id CU- with the feature prefix", async () => { + stubConfig(); + const fetchSpy = vi + .spyOn(clickupIntegration, "fetchClickUpTask") + .mockResolvedValue(shortTask); + mockInput.mockImplementation(async (config: any) => config.default); + const create = vi + .spyOn(git, "gitCreateWorktree") + .mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { clickup: "9hx" }, + }); + + await branch.run(); + + // One fetch feeds both the name and `issue.url` (D9). + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(create).toHaveBeenCalledWith( + "feature/CU-9hx-add-dark-mode", + "origin/main", + ); + }); + + it("names a custom id upper-cased, with the bugfix prefix for a bug tag", async () => { + stubConfig(); + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockResolvedValue({ + ...customTask, + tags: ["Bug"], + }); + mockInput.mockImplementation(async (config: any) => config.default); + const create = vi + .spyOn(git, "gitCreateWorktree") + .mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { clickup: "dev-123" }, + }); + + await branch.run(); + + expect(create).toHaveBeenCalledWith( + "fix/DEV-123-add-dark-mode", + "origin/main", + ); + }); + + it("names DEV-123 with the feature prefix", async () => { + stubConfig(); + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockResolvedValue( + customTask, + ); + mockInput.mockImplementation(async (config: any) => config.default); + const create = vi + .spyOn(git, "gitCreateWorktree") + .mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { clickup: "DEV-123" }, + }); + + await branch.run(); + + expect(create).toHaveBeenCalledWith( + "feature/DEV-123-add-dark-mode", + "origin/main", + ); + }); + + it("reports a short id's key and the url the API returned", async () => { + stubConfig(); + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockResolvedValue( + shortTask, + ); + mockInput.mockImplementation(async (config: any) => config.default); + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { clickup: "9hx" }, + }); + + const document = await branch.run(); + + expect(document.issue).toEqual({ + provider: "clickup", + key: "9hx", + url: "https://app.clickup.com/t/9hx", + }); + expect(document.assigned).toBeNull(); + }); + + it("reports the task's custom_id as the key, not the id that was typed (D9)", async () => { + stubConfig(); + // A short id typed against a task that also has a custom id: D9 says the + // key is custom_id; D6 says the name still follows the typed shape. + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockResolvedValue({ + ...shortTask, + customId: "DEV-123", + }); + mockInput.mockImplementation(async (config: any) => config.default); + const create = vi + .spyOn(git, "gitCreateWorktree") + .mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { clickup: "9hx" }, + }); + + const document = await branch.run(); + + expect(document.issue).toMatchObject({ + provider: "clickup", + key: "DEV-123", + }); + expect(create).toHaveBeenCalledWith( + "feature/CU-9hx-add-dark-mode", + "origin/main", + ); + }); + + it("reports the custom id as the key when the task has one (D9)", async () => { + stubConfig(); + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockResolvedValue( + customTask, + ); + mockInput.mockImplementation(async (config: any) => config.default); + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { clickup: "DEV-123" }, + }); + + const document = await branch.run(); + + expect(document.issue).toEqual({ + provider: "clickup", + key: "DEV-123", + url: "https://app.clickup.com/t/86abc", + }); + }); + + it("includes clickup.apiToken in the config it verifies", async () => { + stubConfig(); + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockResolvedValue( + shortTask, + ); + mockInput.mockImplementation(async (config: any) => config.default); + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue("/path/to/worktree"); + const verify = vi.spyOn(branch as any, "verifyConfig"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { clickup: "9hx" }, + }); + + await branch.run(); + + expect(verify).toHaveBeenCalledWith([ + "defaultSourceBranch", + "clickup.apiToken", + ]); + }); + + it("warns and carries on for --assign", async () => { + stubConfig(); + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockResolvedValue( + shortTask, + ); + const mockAssign = vi.spyOn(githubIntegration, "assignGitHubIssue"); + const mockWarn = vi + .spyOn(branch, "warn") + .mockImplementation((input: any) => input); + mockInput.mockImplementation(async (config: any) => config.default); + const create = vi + .spyOn(git, "gitCreateWorktree") + .mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { clickup: "9hx", assign: true }, + }); + + await branch.run(); + + expect(mockWarn).toHaveBeenCalledWith( + "Assignment is not supported for ClickUp tasks yet.", + ); + expect(mockAssign).not.toHaveBeenCalled(); + expect(create).toHaveBeenCalled(); + }); + + describe("with --json", () => { + let output: ReturnType; + const originalExitCode = process.exitCode; + + beforeEach(() => { + output = captureOutput(); + process.exitCode = undefined; + }); + + afterEach(() => { + output.restore(); + process.exitCode = originalExitCode; + }); + + async function runJson(flags: Record) { + const command = new Branch(["--json"], await loadConfig()); + (command as any).parse = vi.fn().mockResolvedValue({ args: {}, flags }); + (command as any).parsed = true; + vi.spyOn(command as any, "openWorktreePath").mockResolvedValue({ + opener: "none", + }); + return (command as any)._run(); + } + + it("carries {provider, key, url} in the document", async () => { + stubConfig(); + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockResolvedValue( + shortTask, + ); + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue( + "/path/to/worktree", + ); + + await runJson({ clickup: "9hx" }); + + expect(output.document()).toMatchObject({ + branch: "feature/CU-9hx-add-dark-mode", + issue: { + provider: "clickup", + key: "9hx", + url: "https://app.clickup.com/t/9hx", + }, + assigned: null, + }); + }); + + it("gives a missing token as missing_value with exit 2 and no worktree", async () => { + stubConfig({ "clickup.apiToken": "" }); + const fetchSpy = vi.spyOn(clickupIntegration, "fetchClickUpTask"); + const create = vi.spyOn(git, "gitCreateWorktree"); + + await runJson({ clickup: "9hx" }); + + expect(output.document()).toMatchObject({ + error: { code: "missing_value" }, + }); + expect(process.exitCode).toBe(2); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it("gives a custom id with no workspace id as missing_value with exit 2 and no request", async () => { + stubConfig(); + // Earlier tests leave fetchClickUpTask stubbed; this one needs the real + // function, which refuses before any request. + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockRestore(); + const httpSpy = vi.spyOn(globalThis, "fetch"); + const create = vi.spyOn(git, "gitCreateWorktree"); + + await runJson({ clickup: "DEV-123" }); + + expect(output.document()).toMatchObject({ + error: { code: "missing_value" }, + }); + expect(process.exitCode).toBe(2); + expect(httpSpy).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it("gives a failed fetch as failed with exit 1 and no worktree", async () => { + stubConfig(); + vi.spyOn(clickupIntegration, "fetchClickUpTask").mockRejectedValue( + new Error("ClickUp: Unable to find task 9hx."), + ); + const create = vi.spyOn(git, "gitCreateWorktree"); + + await runJson({ clickup: "9hx" }); + + expect(output.document()).toMatchObject({ + error: { code: "failed" }, + }); + expect(process.exitCode).toBe(1); + expect(create).not.toHaveBeenCalled(); + }); + }); + }); + describe("flag validation", () => { it.each([ ["--github", { github: "42", linear: "ENG-1" }], @@ -986,7 +1305,28 @@ describe("branch command", () => { await expect(branch.run()).rejects.toThrow("refused"); expect(mockError).toHaveBeenCalledWith( - "Please provide only one of --github, --jira or --linear, not several.", + "Please provide only one of --github, --jira, --linear or --clickup, not several.", + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ["--github", { github: "42", clickup: "9hx" }], + ["--jira", { jira: "DEV-42", clickup: "9hx" }], + ["--linear", { linear: "ENG-1", clickup: "9hx" }], + ])("refuses --clickup with %s", async (_label, flags) => { + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => + Promise.resolve(key === "has-called-config" ? "true" : ""), + ); + const fetchSpy = vi.spyOn(clickupIntegration, "fetchClickUpTask"); + (branch as any).parse = vi.fn().mockResolvedValue({ args: {}, flags }); + const mockError = vi.spyOn(branch, "error").mockImplementation(() => { + throw new Error("refused"); + }); + + await expect(branch.run()).rejects.toThrow("refused"); + expect(mockError).toHaveBeenCalledWith( + "Please provide only one of --github, --jira, --linear or --clickup, not several.", ); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -1004,15 +1344,15 @@ describe("branch command", () => { const mockError = vi.spyOn(branch, "error").mockImplementation(() => { throw new Error( - "Please provide only one of --github, --jira or --linear, not several.", + "Please provide only one of --github, --jira, --linear or --clickup, not several.", ); }); await expect(branch.run()).rejects.toThrow( - "Please provide only one of --github, --jira or --linear, not several.", + "Please provide only one of --github, --jira, --linear or --clickup, not several.", ); expect(mockError).toHaveBeenCalledWith( - "Please provide only one of --github, --jira or --linear, not several.", + "Please provide only one of --github, --jira, --linear or --clickup, not several.", ); }); }); @@ -1359,14 +1699,14 @@ describe("branch command", () => { it.each([ [true], [false], - ])("errors when the assign flag is %s with none of --github, --jira or --linear", async (assign) => { + ])("errors when the assign flag is %s with none of --github, --jira, --linear or --clickup", async (assign) => { vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => { if (key === "has-called-config") return Promise.resolve("true"); return Promise.resolve(""); }); const mockError = vi.spyOn(branch, "error").mockImplementation(() => { throw new Error( - "--assign/--no-assign requires one of --github, --jira or --linear.", + "--assign/--no-assign requires one of --github, --jira, --linear or --clickup.", ); }); @@ -1378,10 +1718,10 @@ describe("branch command", () => { // Both spellings are one flag under `allowNo`, and neither has an // issue to act on here. await expect(branch.run()).rejects.toThrow( - "--assign/--no-assign requires one of --github, --jira or --linear.", + "--assign/--no-assign requires one of --github, --jira, --linear or --clickup.", ); expect(mockError).toHaveBeenCalledWith( - "--assign/--no-assign requires one of --github, --jira or --linear.", + "--assign/--no-assign requires one of --github, --jira, --linear or --clickup.", ); }); @@ -2292,6 +2632,12 @@ describe("branch command — --help", () => { expect(help).toContain("1 failure"); expect(help).toContain("2 usage"); expect(help).toContain('{provider:"linear", key, url|null}'); + expect(help).toContain('{provider:"clickup", key'); + }); + + // D1: `--clickup` is `-c`. + it("gives --clickup the short flag -c", () => { + expect(Branch.flags.clickup.char).toBe("c"); }); // D1: `--linear` is `-l`, beside `-g` and `-j`. diff --git a/src/commands/branch.ts b/src/commands/branch.ts index cfc4fc1..29ec8a4 100644 --- a/src/commands/branch.ts +++ b/src/commands/branch.ts @@ -1,4 +1,8 @@ import { Args, Flags } from "@oclif/core"; +import { + fetchClickUpTask, + getClickUpBranchNameFromTask, +} from "../integrations/clickup.js"; import { assignGitHubIssue, fetchGitHubIssue } from "../integrations/github.js"; import { getJiraBranchNameFromIssue } from "../integrations/jira.js"; import { @@ -44,7 +48,8 @@ export default class Branch extends BaseCommand { path, branch, source, issue, assigned, envFilesCopied, installed, herdr, agent, warnings issue: {provider:"github", number, url|null}, {provider:"jira", key, -url|null}, {provider:"linear", key, url|null} or null +url|null}, {provider:"linear", key, url|null}, {provider:"clickup", key, +url|null} or null assigned: boolean, or null if no assignment was attempted installed: {ran:true, command, inferred, ok, reason?} or {ran:false, reason} herdr: {space, pane, agent|null}, or null if Herdr was not opened @@ -67,6 +72,8 @@ was not delivered "<%= config.bin %> <%= command.id %> --github 42 --assign", "<%= config.bin %> <%= command.id %> --jira DEV-123", "<%= config.bin %> <%= command.id %> --linear ENG-123", + "<%= config.bin %> <%= command.id %> --clickup 9hx", + "<%= config.bin %> <%= command.id %> --clickup DEV-123", '<%= config.bin %> <%= command.id %> --github 42 --agent "implement the issue"', "<%= config.bin %> <%= command.id %> --github 42 --agent-file brief.md", "<%= config.bin %> <%= command.id %> --github 42 --no-agent", @@ -91,6 +98,10 @@ was not delivered char: "l", description: "Create a branch from a Linear issue (issue identifier)", }), + clickup: Flags.string({ + char: "c", + description: "Create a branch from a ClickUp task (task id)", + }), // The three brief sources are one choice (D13), and `--no-agent` says there // is no brief to deliver, so it excludes all of them. agent: Flags.string({ @@ -120,7 +131,7 @@ was not delivered assign: Flags.boolean({ allowNo: true, description: - "Assign the issue to you when creating a branch from --github (not supported for --jira or --linear yet)", + "Assign the issue to you when creating a branch from --github (not supported for --jira, --linear or --clickup yet)", }), // Unset is meaningful: it defers to postCreate and the run's mode (D9), so // there is no `default`. `--no-install` is the one switch that wins over both. @@ -356,6 +367,31 @@ was not delivered } } + private async getClickUpTaskBranchName(taskIdFlag: string): Promise<{ + branchName: string; + issue: BranchIssue; + }> { + const typedId = taskIdFlag.trim(); + const spinner = createSpinner(`Fetching ClickUp task ${typedId}`).start(); + try { + // One fetch gives both the name and the url the API reports (D9). + const task = await fetchClickUpTask(taskIdFlag); + const branchName = await getClickUpBranchNameFromTask(task, taskIdFlag); + spinner.succeed(); + return { + branchName, + issue: { + provider: "clickup", + key: task.customId ?? task.id, + url: task.url, + }, + }; + } catch (error) { + spinner.fail(); + throw error; + } + } + /** * Flag, then config, then true when non-interactive, then prompt (D4, D8). The key is tri-state through unset * (D3): `true` always assigns, `false` never does, and unset means ask. @@ -449,12 +485,18 @@ was not delivered private async getBranchName( branchNameArg?: string, - flags?: { github?: string; jira?: string; linear?: string }, + flags?: { + github?: string; + jira?: string; + linear?: string; + clickup?: string; + }, ): Promise<{ branchName: string; issue: BranchIssue | null }> { if ( !flags?.github && !flags?.jira && !flags?.linear && + !flags?.clickup && branchNameArg && this.validateBranchName(branchNameArg) ) { @@ -467,7 +509,9 @@ was not delivered ? await this.getJiraIssueBranchName(flags.jira) : flags?.linear ? await this.getLinearIssueBranchName(flags.linear) - : undefined; + : flags?.clickup + ? await this.getClickUpTaskBranchName(flags.clickup) + : undefined; const defaultValue = derived?.branchName ?? ""; const branchName = await askInput( @@ -490,9 +534,12 @@ was not delivered public async run(): Promise { const { args, flags } = await this.parse(Branch); - if ([flags.github, flags.jira, flags.linear].filter(Boolean).length > 1) { + if ( + [flags.github, flags.jira, flags.linear, flags.clickup].filter(Boolean) + .length > 1 + ) { this.error( - "Please provide only one of --github, --jira or --linear, not several.", + "Please provide only one of --github, --jira, --linear or --clickup, not several.", ); } @@ -503,10 +550,11 @@ was not delivered flags.assign !== undefined && !flags.github && !flags.jira && - !flags.linear + !flags.linear && + !flags.clickup ) { this.error( - "--assign/--no-assign requires one of --github, --jira or --linear.", + "--assign/--no-assign requires one of --github, --jira, --linear or --clickup.", ); } @@ -544,6 +592,10 @@ was not delivered configNames.push("linear.apiKey"); } + if (flags.clickup) { + configNames.push("clickup.apiToken"); + } + // If there is no source flag provided, make sure defaultSourceBranch is configured await this.verifyConfig(configNames); @@ -555,6 +607,12 @@ was not delivered "`worktree config linear.apiKey `", ); } + if (flags.clickup && !(await gitGetConfigValue("clickup.apiToken"))) { + throw new MissingValueError( + "the ClickUp API token", + "`worktree config clickup.apiToken `", + ); + } const { branchName, issue } = await this.getBranchName( args.branchName, flags, @@ -571,6 +629,8 @@ was not delivered this.warn("Assignment is not supported for Jira issues yet."); } else if (flags.linear && flags.assign) { this.warn("Assignment is not supported for Linear issues yet."); + } else if (flags.clickup && flags.assign) { + this.warn("Assignment is not supported for ClickUp tasks yet."); } else if (flags.github) { if (await this.shouldAssignGithubIssue(flags.assign)) { assigned = await this.assignGithubIssue(flags.github); diff --git a/src/commands/config.test.ts b/src/commands/config.test.ts index 3c64488..a32760d 100644 --- a/src/commands/config.test.ts +++ b/src/commands/config.test.ts @@ -82,6 +82,7 @@ describe("config command", () => { const secrets: Record = { "jira.apiToken": "jira-secret-value", "linear.apiKey": "linear-secret-value", + "clickup.apiToken": "clickup-secret-value", "github.token": "github-secret-value", }; vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => @@ -218,6 +219,7 @@ describe("config command", () => { "github.token", "jira.apiToken", "linear.apiKey", + "clickup.apiToken", ])("should refuse a whitespace-only %s and store nothing", async (name) => { vi.spyOn(validators, "validateConfigValue").mockRestore(); const mockSetConfigValue = vi @@ -1401,6 +1403,217 @@ describe("config command", () => { }); }); + describe("ClickUp prompts", () => { + const clickUpRun = (flags: Record) => { + (config as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { list: false, missing: false, ...flags }, + }); + }; + const groupMessage = "Do you want to configure ClickUp integration?"; + + it("asks one confirm, then the token with instructions, then the workspace id", async () => { + mockConfirm.mockImplementation( + async ({ message }) => message === groupMessage, + ); + mockInput.mockImplementation(async ({ message }) => + message === "ClickUp API token" ? "pk_prompted" : "12345", + ); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + clickUpRun({}); + + await config.run(); + + expect(mockConfirm).toHaveBeenCalledWith({ message: groupMessage }); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining("avatar -> Settings -> Apps -> API Token"), + ); + expect(mockSet).toHaveBeenCalledWith("clickup.apiToken", "pk_prompted"); + expect(mockSet).toHaveBeenCalledWith("clickup.workspaceId", "12345"); + }); + + it("skips both prompts when the group is declined", async () => { + mockConfirm.mockResolvedValue(false); + clickUpRun({}); + + await config.run(); + + expect(mockConfirm).toHaveBeenCalledWith({ message: groupMessage }); + expect(mockInput).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "ClickUp API token" }), + ); + expect(mockInput).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("ClickUp workspace id"), + }), + ); + }); + + it("answers the group yes without asking for --names clickup.apiToken, and asks only the token", async () => { + mockInput.mockResolvedValue("pk_named"); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + clickUpRun({ names: "clickup.apiToken" }); + + await config.run(); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockInput).toHaveBeenCalledTimes(1); + expect(mockSet).toHaveBeenCalledWith("clickup.apiToken", "pk_named"); + }); + + it("never offers the stored token as a default or prefill", async () => { + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => + Promise.resolve( + key === "has-called-config" + ? "true" + : key === "clickup.apiToken" + ? "pk_stored" + : "", + ), + ); + mockInput.mockResolvedValue("pk_new"); + clickUpRun({ names: "clickup.apiToken" }); + + await config.run(); + + const options = mockInput.mock.calls.at(0)?.[0] as { + default?: string; + prefill?: string; + }; + expect(options.default).toBeUndefined(); + expect(options.prefill).toBeUndefined(); + }); + + it.each([ + "", + " ", + ])("keeps the stored token for an empty answer (%j)", async (answer) => { + mockInput.mockResolvedValue(answer); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + clickUpRun({ names: "clickup.apiToken" }); + + await config.run(); + + expect(mockSet).not.toHaveBeenCalledWith( + "clickup.apiToken", + expect.anything(), + ); + }); + + it("stores a pasted token without surrounding whitespace", async () => { + mockInput.mockResolvedValue(" pk_pasted \n"); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + clickUpRun({ names: "clickup.apiToken" }); + + await config.run(); + + expect(mockSet).toHaveBeenCalledWith("clickup.apiToken", "pk_pasted"); + }); + + it.each([ + "", + " ", + ])("skips the workspace id for an empty answer (%j)", async (answer) => { + mockInput.mockResolvedValue(answer); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + clickUpRun({ names: "clickup.workspaceId" }); + + await config.run(); + + expect(mockInput).toHaveBeenCalledTimes(1); + expect(mockSet).not.toHaveBeenCalledWith( + "clickup.workspaceId", + expect.anything(), + ); + }); + + it("stores a trimmed workspace id and validates it as digits", async () => { + mockInput.mockResolvedValue(" 9001 "); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + clickUpRun({ names: "clickup.workspaceId" }); + + await config.run(); + + expect(mockSet).toHaveBeenCalledWith("clickup.workspaceId", "9001"); + const options = mockInput.mock.calls.at(0)?.[0] as { + validate: (value: string) => true | string | Promise; + }; + expect(await options.validate("")).toBe(true); + expect(await options.validate("abc")).not.toBe(true); + }); + + it("lists the keys under --missing when unset, and not when set", async () => { + clickUpRun({ list: true, missing: true }); + await config.run(); + expect(mockConsoleLog).toHaveBeenCalledWith("clickup.apiToken"); + expect(mockConsoleLog).toHaveBeenCalledWith("clickup.workspaceId"); + + mockConsoleLog.mockClear(); + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => + Promise.resolve( + key === "has-called-config" + ? "true" + : key === "clickup.apiToken" + ? "pk_stored" + : "", + ), + ); + await config.run(); + expect(mockConsoleLog).not.toHaveBeenCalledWith("clickup.apiToken"); + expect(mockConsoleLog).not.toHaveBeenCalledWith( + "clickup.apiToken=pk_stored", + ); + }); + + it("prompts for the token under a bare --missing run", async () => { + mockConfirm.mockImplementation( + async ({ message }) => message === groupMessage, + ); + mockInput.mockResolvedValue("pk_missing"); + clickUpRun({ missing: true, names: undefined }); + + await config.run(); + + expect(mockInput).toHaveBeenCalledWith( + expect.objectContaining({ message: "ClickUp API token" }), + ); + }); + + it("fails naming clickup.apiToken when a non-interactive run has no token", async () => { + setNonInteractive(true); + clickUpRun({ names: "clickup.apiToken" }); + + await expect(config.run()).rejects.toMatchObject({ + message: + "no default for clickup.apiToken; pass worktree config clickup.apiToken ", + }); + expect(mockInput).not.toHaveBeenCalled(); + }); + + it("takes an empty workspace id without failing in a non-interactive run", async () => { + setNonInteractive(true); + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => + Promise.resolve( + key === "has-called-config" + ? "true" + : key === "clickup.apiToken" + ? "pk_stored" + : "", + ), + ); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + clickUpRun({ names: "clickup.apiToken,clickup.workspaceId" }); + + await config.run(); + + expect(mockInput).not.toHaveBeenCalled(); + expect(mockSet).not.toHaveBeenCalledWith( + "clickup.workspaceId", + expect.anything(), + ); + }); + }); + // Expected values: an answer is written only when non-empty after // `String.prototype.trim` (ECMA-262), and then as the trimmed text; this is // F-047's "guard on the trimmed value, write the trimmed value", and the diff --git a/src/commands/config.ts b/src/commands/config.ts index bb8bc07..a8f08c4 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -19,6 +19,7 @@ import { isValidCommand, isValidCommandLine, isValidEmail, + isValidWorkspaceId, validateConfigValue, } from "../lib/validators.js"; @@ -211,6 +212,10 @@ export default class Config extends BaseCommand { name.startsWith("linear."), ); + const hasClickUpPrompt = configNames.some((name) => + name.startsWith("clickup."), + ); + // First check if there is anything to prompt if (configNames.length === 0) { this.log("No missing config found."); @@ -396,6 +401,67 @@ export default class Config extends BaseCommand { } } + // Two keys, one confirm. The token is required and secret, so it is asked + // like `linear.apiKey`; the workspace id matters only to a custom task id + // and most Workspaces have those off, so an empty answer skips it (as + // `herdr.agent` and `postCreate` accept empty) and a non-interactive run + // takes that empty default instead of failing. Nothing is validated against + // ClickUp here: the token is checked when `branch --clickup` first uses it. + if ( + hasClickUpPrompt && + (await this.confirmGroup( + "Do you want to configure ClickUp integration?", + "ClickUp", + flags.names, + )) + ) { + if (shouldPrompt("clickup.apiToken")) { + const clickUpTokenInstructions = [ + "To find your personal API token, follow these steps:", + " - Go to ClickUp -> your avatar -> Settings -> Apps -> API Token", + " - Click 'Generate' (or 'Regenerate') and copy the token", + ]; + + this.log(clickUpTokenInstructions.join(EOL) + EOL); + // No default and no prefill, exactly as `jira.apiToken` is asked: a + // token is not echoed back as a prompt default. + const clickUpApiToken = await askInput( + { message: "ClickUp API token" }, + { + value: "clickup.apiToken", + flag: "worktree config clickup.apiToken ", + // The stored token if there is one, never an empty one. + fallback: + (await gitGetConfigValue("clickup.apiToken")) || undefined, + }, + ); + // Only a real answer is written: an empty answer keeps the stored + // token instead of clearing it. + if (clickUpApiToken.trim()) { + await gitSetConfigValue("clickup.apiToken", clickUpApiToken.trim()); + } + } + + if (shouldPrompt("clickup.workspaceId")) { + const clickUpWorkspaceId = await this.askConfigInput( + "clickup.workspaceId", + { + message: + "ClickUp workspace id (only for custom task ids like DEV-123; empty to skip)", + validate: (value: string) => + value.trim() === "" || isValidWorkspaceId(value.trim()), + }, + ); + // Empty skips the key rather than writing an empty value. + if (clickUpWorkspaceId.trim()) { + await gitSetConfigValue( + "clickup.workspaceId", + clickUpWorkspaceId.trim(), + ); + } + } + } + if (shouldPrompt("defaultSourceBranch")) { const defaultBranchName = await this.askConfigInput( "defaultSourceBranch", diff --git a/src/integrations/clickup.test.ts b/src/integrations/clickup.test.ts new file mode 100644 index 0000000..8675d2e --- /dev/null +++ b/src/integrations/clickup.test.ts @@ -0,0 +1,573 @@ +import * as cli from "../lib/cli.js"; +import { expectCommands } from "../test-setup.js"; +import { + fetchClickUpTask, + getClickUpBranchNameFromTask, + getClickUpBranchType, + parseClickUpTaskId, + resolveClickUpToken, + resolveClickUpWorkspaceId, +} from "./clickup.js"; + +// Built at runtime: a literal in this shape trips GitHub push protection. +const TOKEN = `pk_${"AbCdEf0123".repeat(3)}`; + +// The fields ClickUp's Get Task answers with that this client reads +// (https://developer.clickup.com/reference/gettask). +function taskPayload(overrides: Record = {}) { + return { + id: "9hx", + custom_id: null, + name: "Add dark mode", + url: "https://app.clickup.com/t/9hx", + tags: [{ name: "Feature" }], + ...overrides, + }; +} + +function respond(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + statusText: "", + json: vi.fn().mockResolvedValue(body), + }; +} + +function stubFetch(response: unknown) { + const fetchSpy = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", fetchSpy); + return fetchSpy; +} + +function stubToken(value = TOKEN) { + expectCommands("git config northguild.worktree.clickup.apiToken"); + vi.spyOn(cli, "run").mockResolvedValueOnce(value); +} + +function stubTokenAndWorkspace(workspaceId: string, token = TOKEN) { + expectCommands( + "git config northguild.worktree.clickup.apiToken", + "git config northguild.worktree.clickup.workspaceId", + ); + vi.spyOn(cli, "run") + .mockResolvedValueOnce(token) + .mockResolvedValueOnce(workspaceId); +} + +describe("ClickUp integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + // D3: a short id has no hyphen and keeps its case; a custom id is + // PREFIX-; anything else is invalid_value, exit 2. + describe("parseClickUpTaskId", () => { + it.each([ + ["9hx", { kind: "short", id: "9hx" }], + ["86a1B2c3d", { kind: "short", id: "86a1B2c3d" }], + [" 9hx ", { kind: "short", id: "9hx" }], + ["DEV-123", { kind: "custom", id: "DEV-123" }], + ["dev-7", { kind: "custom", id: "dev-7" }], + ] as const)("reads %j", (input, expected) => { + expect(parseClickUpTaskId(input)).toEqual(expected); + }); + + it.each([ + "", + " ", + "DEV-", + "-123", + "123-DEV", + "DEV-1a", + "DEV--1", + "a b", + "9hx/../team", + "9hx?x=1", + "9hx#frag", + "CU-9hx", + ])("refuses %j", (input) => { + expect(() => parseClickUpTaskId(input)).toThrow( + `ClickUp: Invalid task id "${input}".`, + ); + try { + parseClickUpTaskId(input); + } catch (error) { + expect(error).toMatchObject({ + code: "invalid_value", + oclif: { exit: 2 }, + }); + } + }); + }); + + describe("resolveClickUpToken", () => { + it("reads clickup.apiToken from git config", async () => { + stubToken(); + + await expect(resolveClickUpToken()).resolves.toBe(TOKEN); + }); + + it("tells the user to run worktree config when the token is missing", async () => { + stubToken(""); + + await expect(resolveClickUpToken()).rejects.toThrow( + 'ClickUp: Missing config value "clickup.apiToken". Run "worktree config" to configure ClickUp integration.', + ); + }); + }); + + describe("resolveClickUpWorkspaceId", () => { + it("reads clickup.workspaceId from git config", async () => { + expectCommands("git config northguild.worktree.clickup.workspaceId"); + vi.spyOn(cli, "run").mockResolvedValueOnce("9012345"); + + await expect(resolveClickUpWorkspaceId()).resolves.toBe("9012345"); + }); + + it("is undefined when it is not set", async () => { + expectCommands("git config northguild.worktree.clickup.workspaceId"); + vi.spyOn(cli, "run").mockResolvedValueOnce(""); + + await expect(resolveClickUpWorkspaceId()).resolves.toBeUndefined(); + }); + }); + + describe("fetchClickUpTask", () => { + it.each([ + "DEV-", + "a b", + "", + ])("refuses the id %j before any call", async (id) => { + const fetchSpy = stubFetch(respond(200, taskPayload())); + const refusal = fetchClickUpTask(id); + + await expect(refusal).rejects.toThrow( + `ClickUp: Invalid task id "${id}".`, + ); + await expect(refusal).rejects.toMatchObject({ + code: "invalid_value", + oclif: { exit: 2 }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("fails with the config hint when clickup.apiToken is missing", async () => { + stubToken(""); + const fetchSpy = stubFetch(respond(200, taskPayload())); + + await expect(fetchClickUpTask("9hx")).rejects.toThrow( + 'ClickUp: Missing config value "clickup.apiToken"', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // D3/D4/D5: GET the one task URL, the bare token in Authorization (no + // Bearer), and no query for a short id. + it("gets the task with the bare token and no query for a short id", async () => { + stubToken(); + const fetchSpy = stubFetch(respond(200, taskPayload())); + + const task = await fetchClickUpTask("9hx"); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.clickup.com/api/v2/task/9hx"); + expect(init.method).toBe("GET"); + expect(init.headers).toEqual({ + "Content-Type": "application/json", + Authorization: TOKEN, + }); + expect(init.body).toBeUndefined(); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(task).toEqual({ + id: "9hx", + customId: null, + name: "Add dark mode", + url: "https://app.clickup.com/t/9hx", + tags: ["Feature"], + }); + }); + + it("keeps the case of a short id in the URL", async () => { + stubToken(); + const fetchSpy = stubFetch(respond(200, taskPayload({ id: "86aB" }))); + + await fetchClickUpTask("86aB"); + + expect(fetchSpy.mock.calls[0]?.[0]).toBe( + "https://api.clickup.com/api/v2/task/86aB", + ); + }); + + // D3: custom_task_ids and team_id travel with a custom id, and only then. + it("adds custom_task_ids and the workspace id for a custom id", async () => { + stubTokenAndWorkspace("9012345"); + const fetchSpy = stubFetch( + respond(200, taskPayload({ custom_id: "DEV-123" })), + ); + + const task = await fetchClickUpTask("DEV-123"); + + expect(fetchSpy.mock.calls[0]?.[0]).toBe( + "https://api.clickup.com/api/v2/task/DEV-123?custom_task_ids=true&team_id=9012345", + ); + expect(task.customId).toBe("DEV-123"); + }); + + it("never reads the workspace id for a short id", async () => { + stubToken(); + stubFetch(respond(200, taskPayload())); + + await fetchClickUpTask("9hx"); + + expect(vi.mocked(cli.run)).toHaveBeenCalledTimes(1); + }); + + it("asks for the workspace id when a custom id has none, before any call", async () => { + stubTokenAndWorkspace(""); + const fetchSpy = stubFetch(respond(200, taskPayload())); + const failure = fetchClickUpTask("DEV-123"); + + await expect(failure).rejects.toThrow(/clickup\.workspaceId/); + await expect(failure).rejects.toMatchObject({ + name: "MissingValueError", + oclif: { exit: 2 }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // An id that passed the closed pattern needs no encoding today; the + // workspace id comes from config and can hold anything `git config` does. + it("encodes the workspace id placed in the query", async () => { + stubTokenAndWorkspace("1&custom_task_ids=false"); + const fetchSpy = stubFetch(respond(200, taskPayload())); + + await fetchClickUpTask("DEV-1"); + + expect(fetchSpy.mock.calls[0]?.[0]).toBe( + "https://api.clickup.com/api/v2/task/DEV-1?custom_task_ids=true&team_id=1%26custom_task_ids%3Dfalse", + ); + }); + + // Task content is the branch name's input and nothing else. + it("never asks for subtasks, the description or comments", async () => { + stubToken(); + const fetchSpy = stubFetch(respond(200, taskPayload())); + + await fetchClickUpTask("9hx"); + + expect(fetchSpy.mock.calls[0]?.[0]).not.toMatch( + /subtasks|description|comment/i, + ); + }); + + it("does not return fields the branch name does not need", async () => { + stubToken(); + stubFetch( + respond( + 200, + taskPayload({ + description: "secret plans", + text_content: "secret plans", + comments: ["x"], + }), + ), + ); + + const task = await fetchClickUpTask("9hx"); + + expect(Object.keys(task).sort()).toEqual([ + "customId", + "id", + "name", + "tags", + "url", + ]); + }); + + it("returns a null url when the API sends none", async () => { + stubToken(); + stubFetch(respond(200, taskPayload({ url: undefined }))); + + await expect(fetchClickUpTask("9hx")).resolves.toMatchObject({ + url: null, + }); + }); + + it("treats an empty custom_id as none", async () => { + stubToken(); + stubFetch(respond(200, taskPayload({ custom_id: "" }))); + + await expect(fetchClickUpTask("9hx")).resolves.toMatchObject({ + customId: null, + }); + }); + + it("rejects a request that never answers, naming the call and not the token", async () => { + stubToken(); + vi.spyOn(AbortSignal, "timeout").mockReturnValue( + AbortSignal.abort(new DOMException("timed out", "TimeoutError")), + ); + vi.stubGlobal( + "fetch", + vi.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + if (init.signal?.aborted) { + reject(init.signal.reason); + } + }), + ), + ); + + const failure = fetchClickUpTask("9hx"); + + // TIMEOUT_MESSAGE in base-command.ts is /did not (?:answer|finish) within \d+s/ + await expect(failure).rejects.toThrow( + "ClickUp: the request did not answer within 15s.", + ); + await expect(failure).rejects.not.toThrow(/pk_/); + expect(AbortSignal.timeout).toHaveBeenCalledWith(15_000); + }); + + it.each([ + 401, 403, + ])("reports an authentication failure on HTTP %i", async (status) => { + stubToken(); + stubFetch(respond(status, {})); + + await expect(fetchClickUpTask("9hx")).rejects.toThrow( + "ClickUp: Authentication failed. Please verify clickup.apiToken in your config.", + ); + }); + + it("reports not found on HTTP 404", async () => { + stubToken(); + stubFetch(respond(404, { err: "Task not found", ECODE: "ITEM_015" })); + + await expect(fetchClickUpTask("9hx")).rejects.toThrow( + "ClickUp: Unable to find task 9hx.", + ); + }); + + it("says so on HTTP 429", async () => { + stubToken(); + stubFetch(respond(429, {})); + + await expect(fetchClickUpTask("9hx")).rejects.toThrow( + "ClickUp: Rate limited. ClickUp limits requests per API token; wait a moment and try again.", + ); + }); + + it("reports the status on any other non-2xx answer", async () => { + stubToken(); + const answer = respond(500, ""); + answer.statusText = "Internal Server Error"; + stubFetch(answer); + + await expect(fetchClickUpTask("9hx")).rejects.toThrow( + "ClickUp: Failed to fetch task 9hx. 500 Internal Server Error", + ); + }); + + it("reports a body that is not JSON", async () => { + stubToken(); + const answer = respond(200, null); + answer.json.mockRejectedValue(new SyntaxError("Unexpected token <")); + stubFetch(answer); + + await expect(fetchClickUpTask("9hx")).rejects.toThrow( + "ClickUp: Unexpected response for task 9hx.", + ); + }); + + // The fetch signal also bounds the body: headers in time and a body that + // stalls past 15s rejects response.json() with the signal's TimeoutError. + it("reports a body that stalls past the timeout as a timeout", async () => { + stubToken(); + const answer = respond(200, null); + answer.json.mockRejectedValue( + new DOMException("timed out", "TimeoutError"), + ); + stubFetch(answer); + + const failure = fetchClickUpTask("9hx"); + + // TIMEOUT_MESSAGE in base-command.ts is /did not (?:answer|finish) within \d+s/ + await expect(failure).rejects.toThrow( + "ClickUp: the request did not answer within 15s.", + ); + await expect(failure).rejects.not.toThrow(/pk_/); + }); + + it.each([ + ["a body that is not an object", "oops"], + ["a list", []], + ["a task without a name", taskPayload({ name: undefined })], + ["a task without an id", taskPayload({ id: undefined })], + ["tags that are not a list", taskPayload({ tags: "bug" })], + ["a tag without a name", taskPayload({ tags: [{}] })], + ])("reports %s", async (_name, body) => { + stubToken(); + stubFetch(respond(200, body)); + + await expect(fetchClickUpTask("9hx")).rejects.toThrow( + "ClickUp: Unexpected response for task 9hx.", + ); + }); + + it("never puts the token in an error message", async () => { + stubToken(); + stubFetch(respond(401, {})); + + await expect(fetchClickUpTask("9hx")).rejects.not.toThrow(/pk_/); + }); + }); + + // D7, chosen in the plan: bug -> bugfix; chore|maintenance -> chore; else feature. + describe("getClickUpBranchType", () => { + it.each([ + [["Bug"], "bugfix"], + [["bug"], "bugfix"], + [["BUG"], "bugfix"], + [["Chore"], "chore"], + [["MAINTENANCE"], "chore"], + [["maintenance"], "chore"], + [["Feature"], "feature"], + [["Improvement"], "feature"], + [[], "feature"], + [["Bug", "Chore"], "bugfix"], + [["chore", "maintenance", "Bug"], "bugfix"], + [["Improvement", "Chore"], "chore"], + [["Feature", "Improvement"], "feature"], + ] as const)("maps tags %j to %s", (tags, expected) => { + expect(getClickUpBranchType([...tags])).toBe(expected); + }); + }); + + // D6: prefix + (CU- | CUSTOM-ID) + "-" + slugified name (cut to 48 at a + // word boundary), "issue" when it empties. + describe("getClickUpBranchNameFromTask", () => { + const task = { + id: "9hx", + customId: null, + name: "Fix login bug", + url: null, + tags: ["Bug"], + }; + + it("builds the name from the tag prefix, CU- and the name for a short id", async () => { + expectCommands("git config northguild.worktree.branchPrefix.bugfix"); + vi.spyOn(cli, "run").mockResolvedValueOnce("fix/"); + + const name = await getClickUpBranchNameFromTask(task, "9hx"); + + expect(name).toBe("fix/CU-9hx-fix-login-bug"); + }); + + it("keeps the case of a short id", async () => { + expectCommands("git config northguild.worktree.branchPrefix.bugfix"); + vi.spyOn(cli, "run").mockResolvedValueOnce("fix/"); + + const name = await getClickUpBranchNameFromTask( + { ...task, id: "86aBc" }, + "86aBc", + ); + + expect(name).toBe("fix/CU-86aBc-fix-login-bug"); + }); + + it("uses the custom id, upper-cased, when a custom id was typed", async () => { + expectCommands("git config northguild.worktree.branchPrefix.bugfix"); + vi.spyOn(cli, "run").mockResolvedValueOnce("fix/"); + + const name = await getClickUpBranchNameFromTask( + { ...task, customId: "DEV-42" }, + "dev-42", + ); + + expect(name).toBe("fix/DEV-42-fix-login-bug"); + }); + + // D6: the segment follows the id the user typed, not the task's custom_id. + it("uses CU- when a short id was typed for a task that has a custom id", async () => { + expectCommands("git config northguild.worktree.branchPrefix.bugfix"); + vi.spyOn(cli, "run").mockResolvedValueOnce("fix/"); + + const name = await getClickUpBranchNameFromTask( + { ...task, customId: "DEV-42" }, + "9hx", + ); + + expect(name).toBe("fix/CU-9hx-fix-login-bug"); + }); + + it("uses the typed custom id, upper-cased, even when the response carries no custom_id", async () => { + expectCommands("git config northguild.worktree.branchPrefix.bugfix"); + vi.spyOn(cli, "run").mockResolvedValueOnce("fix/"); + + const name = await getClickUpBranchNameFromTask( + { ...task, customId: null }, + "dev-42", + ); + + expect(name).toBe("fix/DEV-42-fix-login-bug"); + }); + + it("uses branchPrefix.feature when no tag matches", async () => { + expectCommands("git config northguild.worktree.branchPrefix.feature"); + vi.spyOn(cli, "run").mockResolvedValueOnce("feature/"); + + const name = await getClickUpBranchNameFromTask( + { ...task, name: "Add dark mode", tags: [] }, + "9hx", + ); + + expect(name).toBe("feature/CU-9hx-add-dark-mode"); + }); + + it("uses branchPrefix.chore for a maintenance tag", async () => { + expectCommands("git config northguild.worktree.branchPrefix.chore"); + vi.spyOn(cli, "run").mockResolvedValueOnce("chore/"); + + const name = await getClickUpBranchNameFromTask( + { ...task, name: "Bump deps", tags: ["Maintenance"] }, + "9hx", + ); + + expect(name).toBe("chore/CU-9hx-bump-deps"); + }); + + it("cuts a long name to a whole-word slug of at most 48 characters", async () => { + expectCommands("git config northguild.worktree.branchPrefix.feature"); + vi.spyOn(cli, "run").mockResolvedValueOnce("feature/"); + + const name = await getClickUpBranchNameFromTask( + { + ...task, + name: "Login redirect loops forever when the session cookie expires during a multi step checkout flow", + tags: [], + }, + "9hx", + ); + + expect(name).toBe( + "feature/CU-9hx-login-redirect-loops-forever-when-the-session", + ); + }); + + it('falls back to "issue" when the name leaves no slug', async () => { + expectCommands("git config northguild.worktree.branchPrefix.feature"); + vi.spyOn(cli, "run").mockResolvedValueOnce("feature/"); + + const name = await getClickUpBranchNameFromTask( + { ...task, name: "!!!", tags: [] }, + "9hx", + ); + + expect(name).toBe("feature/CU-9hx-issue"); + }); + }); +}); diff --git a/src/integrations/clickup.ts b/src/integrations/clickup.ts new file mode 100644 index 0000000..035c762 --- /dev/null +++ b/src/integrations/clickup.ts @@ -0,0 +1,232 @@ +import { gitGetConfigValue } from "../lib/git.js"; +import { InvalidValueError, MissingValueError } from "../lib/prompt.js"; +import { slugifyBranchTitle } from "../lib/utils.js"; + +export interface ClickUpTask { + id: string; + customId: string | null; + name: string; + url: string | null; + tags: string[]; +} + +export interface ClickUpTaskId { + kind: "short" | "custom"; + id: string; +} + +const CLICKUP_API = "https://api.clickup.com/api/v2"; +const CLICKUP_REQUEST_TIMEOUT_MS = 15_000; + +const AUTH_FAILED_MESSAGE = + "ClickUp: Authentication failed. Please verify clickup.apiToken in your config."; + +// D3: a custom id is PREFIX-, shaped like a Jira key; a short id is +// alphanumeric with no hyphen. Short ids are case-sensitive, so the case the +// user typed is kept. +export function parseClickUpTaskId(input: string): ClickUpTaskId { + const id = input.trim(); + + if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(id)) { + return { kind: "custom", id }; + } + + if (/^[A-Za-z0-9]+$/.test(id)) { + return { kind: "short", id }; + } + + throw new InvalidValueError(`ClickUp: Invalid task id "${input}".`); +} + +export async function resolveClickUpToken(): Promise { + const token = await gitGetConfigValue("clickup.apiToken"); + + if (!token) { + throw new Error( + 'ClickUp: Missing config value "clickup.apiToken". Run "worktree config" to configure ClickUp integration.', + ); + } + + return token; +} + +export async function resolveClickUpWorkspaceId(): Promise { + const workspaceId = await gitGetConfigValue("clickup.workspaceId"); + + return workspaceId || undefined; +} + +function isTimeout(error: unknown): boolean { + return error instanceof Error && error.name === "TimeoutError"; +} + +// The timeout message matches TIMEOUT_MESSAGE in base-command.ts; it names no +// header and never the token. +function timeoutError(cause: unknown): Error { + return new Error( + `ClickUp: the request did not answer within ${CLICKUP_REQUEST_TIMEOUT_MS / 1000}s.`, + { cause }, + ); +} + +// Every ClickUp request is bounded, body included: the signal also aborts a +// body that stalls after the headers arrive (see readBody). +export async function fetchClickUp( + path: string, + token: string, +): Promise { + try { + return await fetch(`${CLICKUP_API}${path}`, { + method: "GET", + // D4: the raw token, no `Bearer` prefix (that is for OAuth tokens). + headers: { "Content-Type": "application/json", Authorization: token }, + signal: AbortSignal.timeout(CLICKUP_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + if (isTimeout(error)) { + throw timeoutError(error); + } + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +// A body that is not JSON is an unexpected response; one that timed out is a +// timeout, not a parse failure. +async function readBody( + response: Response, +): Promise | undefined> { + try { + const body: unknown = await response.json(); + return isRecord(body) ? body : undefined; + } catch (error) { + if (isTimeout(error)) { + throw timeoutError(error); + } + return undefined; + } +} + +// D5: only id, custom_id, name, url and tags[].name are read. +function parseClickUpTask( + body: Record, +): ClickUpTask | undefined { + const { id, custom_id: customId, name, url, tags } = body; + + if ( + typeof id !== "string" || + typeof name !== "string" || + !Array.isArray(tags) || + !tags.every((tag) => isRecord(tag) && typeof tag.name === "string") + ) { + return undefined; + } + + return { + id, + customId: typeof customId === "string" && customId !== "" ? customId : null, + name, + url: typeof url === "string" ? url : null, + tags: tags.map((tag) => (tag as { name: string }).name), + }; +} + +export async function fetchClickUpTask(taskId: string): Promise { + const parsed = parseClickUpTaskId(taskId); + const token = await resolveClickUpToken(); + + // D3: custom ids need the workspace id in the same request. + let query = ""; + if (parsed.kind === "custom") { + const workspaceId = await resolveClickUpWorkspaceId(); + + if (!workspaceId) { + throw new MissingValueError( + "the ClickUp workspace id", + "`worktree config clickup.workspaceId `", + ); + } + + query = `?custom_task_ids=true&team_id=${encodeURIComponent(workspaceId)}`; + } + + const response = await fetchClickUp( + `/task/${encodeURIComponent(parsed.id)}${query}`, + token, + ); + + // D11: ClickUp's status for a task the token cannot see is unconfirmed, so + // 401 and 403 share the authentication message. + if (response.status === 401 || response.status === 403) { + throw new Error(AUTH_FAILED_MESSAGE); + } + + if (response.status === 404) { + throw new Error(`ClickUp: Unable to find task ${parsed.id}.`); + } + + // The reset header's format is unconfirmed, so it is not parsed. + if (response.status === 429) { + throw new Error( + "ClickUp: Rate limited. ClickUp limits requests per API token; wait a moment and try again.", + ); + } + + if (!response.ok) { + throw new Error( + `ClickUp: Failed to fetch task ${parsed.id}. ${response.status} ${response.statusText}`.trimEnd(), + ); + } + + const body = await readBody(response); + const task = body ? parseClickUpTask(body) : undefined; + + if (!task) { + throw new Error(`ClickUp: Unexpected response for task ${parsed.id}.`); + } + + return task; +} + +// D7: tags decide, as Linear's labels do. Bug wins over the rest. +export function getClickUpBranchType(tags: string[]) { + const names = tags.map((tag) => tag.toLowerCase()); + + if (names.includes("bug")) { + return "bugfix" as const; + } + + if (names.includes("chore") || names.includes("maintenance")) { + return "chore" as const; + } + + return "feature" as const; +} + +// Takes the task already fetched so a caller can keep its url from the same +// request. D6: `CU-` for a short id, the custom id upper-cased otherwise; +// it is told apart by the shape the user typed, not by `custom_id`, so the +// branch carries the id that was asked for. +export async function getClickUpBranchNameFromTask( + task: ClickUpTask, + typedId: string, +): Promise { + const branchType = getClickUpBranchType(task.tags); + + const prefix = await gitGetConfigValue( + branchType === "bugfix" + ? "branchPrefix.bugfix" + : branchType === "chore" + ? "branchPrefix.chore" + : "branchPrefix.feature", + ); + + const parsed = parseClickUpTaskId(typedId); + const segment = + parsed.kind === "custom" ? parsed.id.toUpperCase() : `CU-${task.id}`; + + return `${prefix}${segment}-${slugifyBranchTitle(task.name)}`; +} diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index b25068c..629a4ee 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -71,7 +71,7 @@ const JSON_ERROR_CODES: readonly JsonErrorCode[] = [ "failed", ]; -// The timeout messages this CLI writes — github.ts, jira.ts, linear.ts, git.ts, herdr.ts +// The timeout messages this CLI writes — github.ts, jira.ts, linear.ts, clickup.ts, git.ts, herdr.ts // and cli.ts all say " did not answer|finish within s". const TIMEOUT_MESSAGE = /did not (?:answer|finish) within \d+s/; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a0986d8..c5f2e88 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -6,6 +6,8 @@ export const CONFIG_NAMES = [ "jira.email", "jira.apiToken", "linear.apiKey", + "clickup.apiToken", + "clickup.workspaceId", "github.token", "github.autoAssign", "branchPrefix.feature", @@ -25,6 +27,7 @@ export const CONFIG_NAMES = [ export const SECRET_CONFIG_NAMES = [ "jira.apiToken", "linear.apiKey", + "clickup.apiToken", "github.token", ] as const satisfies readonly ConfigName[]; diff --git a/src/lib/redact.test.ts b/src/lib/redact.test.ts index 9dbd358..385a27d 100644 --- a/src/lib/redact.test.ts +++ b/src/lib/redact.test.ts @@ -12,6 +12,28 @@ describe("redactSecrets", () => { expect(redacted).not.toContain("lin_api_"); }); + // D10: ClickUp personal tokens start `pk_`; the layout after it is not relied on. + it("replaces a ClickUp personal token in text", () => { + const token = `pk_${"AbCdEf0123".repeat(3)}`; + + const redacted = redactSecrets(`ClickUp rejected ${token} just now`); + + expect(redacted).toBe("ClickUp rejected *** just now"); + expect(redacted).not.toContain("pk_"); + }); + + it("replaces a ClickUp token that carries a user id and underscores", () => { + expect(redactSecrets(`token pk_12345678_${"A1".repeat(16)}`)).toBe( + "token ***", + ); + }); + + it("leaves a short pk_ word alone", () => { + expect(redactSecrets("see pk_short and pk_id")).toBe( + "see pk_short and pk_id", + ); + }); + it("still replaces a GitHub token", () => { expect(redactSecrets(`token ghp_${"a".repeat(36)}`)).toBe("token ***"); }); diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 9402950..33bb306 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -10,7 +10,7 @@ // `progress.ts`; living in either would make the other a cycle. const URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi; const TOKEN_SHAPES = - /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|lin_api_[A-Za-z0-9]{20,})/g; + /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|lin_api_[A-Za-z0-9]{20,}|pk_[A-Za-z0-9_]{20,})/g; export function redactSecrets(text: string): string { return text.replace(URL_CREDENTIALS, "$1***@").replace(TOKEN_SHAPES, "***"); diff --git a/src/lib/types.ts b/src/lib/types.ts index 6060a49..178b4d6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -151,7 +151,8 @@ export interface JsonErrorDocument { export type BranchIssue = | { provider: "github"; number: number; url: string | null } | { provider: "jira"; key: string; url: string | null } - | { provider: "linear"; key: string; url: string | null }; + | { provider: "linear"; key: string; url: string | null } + | { provider: "clickup"; key: string; url: string | null }; export interface BranchDocument { path: string; diff --git a/src/lib/validators.test.ts b/src/lib/validators.test.ts index 6ee3517..ff14f79 100644 --- a/src/lib/validators.test.ts +++ b/src/lib/validators.test.ts @@ -388,12 +388,34 @@ describe("isValidConfigValue token keys", () => { "github.token", "jira.apiToken", "linear.apiKey", + "clickup.apiToken", ])("refuses a whitespace-only value for %s", async (name) => { expect(await isValidConfigValue(name, " ")).toBe("Token cannot be empty"); expect(await isValidConfigValue(name, "real-token")).toBe(true); }); }); +describe("isValidConfigValue clickup.workspaceId", () => { + it.each(["9012345", " 9012345 "])("accepts %j", async (value) => { + expect(await isValidConfigValue("clickup.workspaceId", value)).toBe(true); + }); + + it.each([ + "", + " ", + "abc", + "12a", + "12 34", + "-1", + "1.5", + "123&custom_task_ids=false", + ])("refuses %j", async (value) => { + expect(await isValidConfigValue("clickup.workspaceId", value)).toBe( + "Workspace id must be digits only", + ); + }); +}); + describe("validateConfigValue", () => { it("names InvalidConfigValueError as itself, and exits 2 as an invalid value", () => { const error = new InvalidConfigValueError("Invalid email address"); diff --git a/src/lib/validators.ts b/src/lib/validators.ts index b28e32c..eb5f38d 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -120,6 +120,12 @@ export function isValidToken(value: string): true | string { return value.trim() !== "" || "Token cannot be empty"; } +// ClickUp workspace (team) ids are numeric, and the value goes into a query +// string, so anything else is refused here. +export function isValidWorkspaceId(value: string): true | string { + return /^\d+$/.test(value.trim()) || "Workspace id must be digits only"; +} + export async function isValidConfigValue( configName: string, value: string, @@ -142,7 +148,10 @@ export async function isValidConfigValue( case "github.token": case "jira.apiToken": case "linear.apiKey": + case "clickup.apiToken": return isValidToken(value); + case "clickup.workspaceId": + return isValidWorkspaceId(value); case "agent.command": // Executed in every new worktree, so it is held to the same check as the // other command line: its head has to be a program that exists.