diff --git a/.github/workflows/ci-validate.yml b/.github/workflows/ci-validate.yml index 443990a2..b255f165 100644 --- a/.github/workflows/ci-validate.yml +++ b/.github/workflows/ci-validate.yml @@ -268,13 +268,53 @@ jobs: fi base_ref="origin/${GITHUB_BASE_REF:-main}" - output=$(git log "$base_ref"..HEAD --name-status --pretty=format: -- CHANGELOG.md | sed '/^$/d') - if [ -n "$output" ]; then + # Repairing notes the release workflow itself lost is the one + # legitimate reason for a PR to touch CHANGELOG.md — see + # "Changelog ownership" in CONTRIBUTING.md. Exempt commits whose + # subject is exactly the reserved backfill subject, and only when + # they change nothing outside CHANGELOG.md and only add lines. + exempt_subject="chore(release): backfill changelog notes" + + # Capture into a variable rather than feeding `git log` straight into + # a here-string: a here-string swallows the command substitution's + # exit status, so an unresolvable "$base_ref" would read as an empty + # commit list and the guard would silently pass. + candidates=$(git log "$base_ref"..HEAD --format=%H -- CHANGELOG.md) + + offenders="" + while read -r sha; do + [ -n "$sha" ] || continue + subject=$(git log -1 --format=%s "$sha") + if [ "$subject" = "$exempt_subject" ]; then + violation="" + + extra=$(git show --name-only --pretty=format: "$sha" | sed '/^$/d' | grep -v '^CHANGELOG.md$' || true) + if [ -n "$extra" ]; then + echo "::error::$exempt_subject commit $sha also touches files other than CHANGELOG.md" + echo "$extra" + violation="yes" + fi + + deletions=$(git show --numstat --pretty=format: "$sha" -- CHANGELOG.md | awk 'NF { total += $2 } END { print total + 0 }') + if [ "$deletions" -ne 0 ]; then + echo "::error::$exempt_subject commit $sha removes $deletions CHANGELOG.md line(s); a backfill may only add notes" + violation="yes" + fi + + if [ -z "$violation" ]; then + continue + fi + fi + offenders="$offenders$sha $subject"$'\n' + done <<< "$candidates" + + if [ -n "$offenders" ]; then echo "::error::CHANGELOG.md is release-workflow-owned and must not appear in PR branch history" echo "Drop or amend commits that touch CHANGELOG.md, then push with --force-with-lease" + echo "The only exception is an additions-only, CHANGELOG.md-only commit whose subject is exactly: $exempt_subject" echo - echo "$output" + echo "$offenders" exit 1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e61f3b..22a11fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,147 @@ +## [2026.8.0-next.2](https://github.com/linearis-oss/linearis/compare/v2026.8.0-next.1...v2026.8.0-next.2) (2026-08-10) + +### ⚠ BREAKING CHANGES + +* **milestones:** a milestone name that the scoped project does not carry +now fails instead of resolving to a same-named milestone in another +project. +* **projects:** `linearis projects archive ` is removed. Use +`linearis projects delete ` to trash a project and +`linearis projects unarchive ` to restore it. Linear treats +archived and trashed projects as one state, so the replacement is +behaviourally identical. + +### Features + +* **labels:** full CRUD and retire/restore for project labels ([4bf8dcc](https://github.com/linearis-oss/linearis/commit/4bf8dcc50e69b764f87bc8c1712c2a72f3dcdd76)) +* **labels:** make the group and parent flags reversible ([59d5890](https://github.com/linearis-oss/linearis/commit/59d58900933f939aba1af08439d006f6bde51533)) +* **projects:** administer the workspace project status flow ([a6e1a16](https://github.com/linearis-oss/linearis/commit/a6e1a1613b0beb76a94c807b7dfb7caa7ee19403)) +* **projects:** chronological project activity timeline ([4e81324](https://github.com/linearis-oss/linearis/commit/4e813240026de27618e388c9322c919151e24f02)) +* **projects:** drop archive in favour of delete ([47b509e](https://github.com/linearis-oss/linearis/commit/47b509e79339d5882cfddfd32fca920217f02ce9)) +* **projects:** full-text search and external sync disable ([de351cf](https://github.com/linearis-oss/linearis/commit/de351cfda0b093261f1d3d4c6ae1e0830061bceb)) +* **projects:** manage project dependency relations ([bdd3de0](https://github.com/linearis-oss/linearis/commit/bdd3de0726ca334dc0de4220a120b139c71e1205)) +* **projects:** post and manage project status updates ([c66da2b](https://github.com/linearis-oss/linearis/commit/c66da2b7860438683fd90a39d3e8f91a9ce34709)) +* **projects:** read one dependency and page them workspace-wide ([2e9440c](https://github.com/linearis-oss/linearis/commit/2e9440c3b21b376b37eecaf7115203cd04341853)) + +### Bug Fixes + +* **labels:** reject an empty --parent on update ([fd13029](https://github.com/linearis-oss/linearis/commit/fd13029a135cc2208640d31533425074971ef87c)) +* **labels:** scope --parent to the same team as the label ([d99ee89](https://github.com/linearis-oss/linearis/commit/d99ee89f6302f4e45a276acb73cbb65a4a9e0a99)) +* **milestones:** keep a milestone lookup inside its project ([b143d2b](https://github.com/linearis-oss/linearis/commit/b143d2bf1bd801e757fc913fda2f5157ada7c470)) +* **projects:** anchor relation updates to the right end ([9f5e87a](https://github.com/linearis-oss/linearis/commit/9f5e87ab3a05564297f9dc7e5bdafee01f8afbeb)) +* **projects:** bound and page the project status flow ([9ebdef7](https://github.com/linearis-oss/linearis/commit/9ebdef7301346bcd9ea222b5a1ba989698aa74e3)) +* **projects:** count archived statuses when appending ([1e3b428](https://github.com/linearis-oss/linearis/commit/1e3b4283e7cb375732fa1f1c54475a5f104dcf81)) +* **projects:** refuse a lone relation match past the page bound ([d5eb26d](https://github.com/linearis-oss/linearis/commit/d5eb26d58302b05756837b0c70827df46de2e526)) +* **projects:** refuse to guess between same-named project statuses ([1be4ab1](https://github.com/linearis-oss/linearis/commit/1be4ab102f03ce6582f02100090e46c4f268fac2)) +* **projects:** refuse to guess which relation a project pair means ([fe3508c](https://github.com/linearis-oss/linearis/commit/fe3508c6ec2d8c5e817c4f7b584a4ae97bd68aa5)) +* **projects:** reject a malformed status position ([e01ca0e](https://github.com/linearis-oss/linearis/commit/e01ca0e900d3c731784d2d75aa84a88ae4d81c20)) +* **projects:** reject an empty relation update before resolving it ([d94e51e](https://github.com/linearis-oss/linearis/commit/d94e51eab34fdc55825f448b6b59b95ba47478e6)) +* **projects:** reject pagination flags on per-project relations ([ed7cc89](https://github.com/linearis-oss/linearis/commit/ed7cc89e22368bea83373a8a7b989bc9b81c535b)) +* **projects:** report the relation page bound instead of a miss ([b3886e4](https://github.com/linearis-oss/linearis/commit/b3886e4bc55774d441d021f272664495893487f9)) +* **projects:** say where the projects went when archiving fails ([30664cd](https://github.com/linearis-oss/linearis/commit/30664cd7583443140eebf76a64287d0888bd09aa)) +* **projects:** scope relation milestones on the UUID path ([5824b02](https://github.com/linearis-oss/linearis/commit/5824b02c2a3d3cc51cf36af67f81efa009b3f8ec)) + +## [2026.8.0-next.1](https://github.com/linearis-oss/linearis/compare/v2026.7.0...v2026.8.0-next.1) (2026-08-10) + +### Features + +* **attachments:** add disable-sync ([9e7cf04](https://github.com/linearis-oss/linearis/commit/9e7cf044fe240dc665f31a51f5f2b8b820bd6f3c)) +* **issues:** accept subscribers and delegate in batch create ([67ea490](https://github.com/linearis-oss/linearis/commit/67ea490da975d9cc5ded0d2ba2b82ed302710347)) +* **issues:** add batch create and batch update ([fb68bcf](https://github.com/linearis-oss/linearis/commit/fb68bcfd2cc2a47bc9dde0018d88bc2d2374a8f4)) +* **issues:** add from-branch to find an issue by its git branch ([1c0ee8c](https://github.com/linearis-oss/linearis/commit/1c0ee8c97864dea24a4ba2b895f18a4c07639f32)) +* **issues:** add order-by, unassigned, state-type and subscriber filters ([2e01ad3](https://github.com/linearis-oss/linearis/commit/2e01ad344b9dbd3f815b7c15a957283687b4faf2)) +* **issues:** add restore and snooze ([00bf191](https://github.com/linearis-oss/linearis/commit/00bf1919625da595cd51b7f51b7185b24cbd655b)) +* **issues:** add subscribe, share and remind commands ([1530903](https://github.com/linearis-oss/linearis/commit/1530903ddf5962faf53365f17c9600f540b553d7)) +* **issues:** let batch update clear a cycle or milestone ([70aafb9](https://github.com/linearis-oss/linearis/commit/70aafb94c28741056ea49eebf8d5184f96d05f69)) +* **issues:** publish a JSON Schema for batch create documents ([13ed81a](https://github.com/linearis-oss/linearis/commit/13ed81a20e27d97d079b49ab9770564a70095781)) +* **issues:** return url, creator, delegate and lifecycle timestamps ([2d4885e](https://github.com/linearis-oss/linearis/commit/2d4885e9c737bcd1c3e8d7657969c91eb3046ba8)) +* **issues:** support team moves, subscribers and delegates ([8c96db1](https://github.com/linearis-oss/linearis/commit/8c96db11c2f3d61c8cb37174c6e92d633a51f936)) +* **issues:** take batch update from a JSON document too ([60b9491](https://github.com/linearis-oss/linearis/commit/60b94914b724155e4ee9993558e6fc7f13225fd6)) + +### Bug Fixes + +* **issues:** await user lookups so failures stay JSON ([cd42347](https://github.com/linearis-oss/linearis/commit/cd42347d321d51d609ddda92a3434baeebd40a45)) +* **issues:** guard batch update labels across teams too ([c716701](https://github.com/linearis-oss/linearis/commit/c7167010810526ca8d0c790ead1cc0788fb0bd22)) +* **issues:** let a UUID pass the mixed-team batch guard ([5a248a8](https://github.com/linearis-oss/linearis/commit/5a248a8c102af4c3be7a6e4f7f9adcc21464bf22)) +* **issues:** locate the entry when a batch list is malformed ([b83ea52](https://github.com/linearis-oss/linearis/commit/b83ea52695a356ba9650cc68b506010a3ed7bd66)) +* **issues:** make --include-archived surface archived issues ([cb14ec7](https://github.com/linearis-oss/linearis/commit/cb14ec7469a8d421c4ccbd7c7e3112fe95a566d3)) +* **issues:** make archived issues reachable by identifier ([6ac9829](https://github.com/linearis-oss/linearis/commit/6ac98297ed9d4c675d0e77ec59e395313efa4f84)) +* **issues:** name the flag when a relative offset overflows ([634f846](https://github.com/linearis-oss/linearis/commit/634f846900a27993c5ccff402db90e7bc6aa7560)) +* **issues:** resolve a `me` assignee against the viewer ([d5f8198](https://github.com/linearis-oss/linearis/commit/d5f81985ab9632db78b968a043398260c97a336b)) +* **issues:** validate a moved issue's estimate against its new team ([c8d94f1](https://github.com/linearis-oss/linearis/commit/c8d94f1518f0066d5b7153c4a5d0c26f75a430b8)) +* **issues:** validate batch update estimates against the team scale ([31634dc](https://github.com/linearis-oss/linearis/commit/31634dce7b6ebf384475f0188adeb4941b77f6f3)) +* **release:** pin conventionalcommits preset to the writer-v8-compatible line ([fc0276d](https://github.com/linearis-oss/linearis/commit/fc0276d163cf7edce50c41c0533afd902a18a534)) + +### Performance Improvements + +* **issues:** bound the fan-out of batch create ID resolution ([f623be9](https://github.com/linearis-oss/linearis/commit/f623be9c49de173d872c66774a0e0e90dcd61ace)) + ## [2026.7.0](https://github.com/linearis-oss/linearis/compare/v2026.6.0...v2026.7.0) (2026-08-07) +### Features + +* **initiatives:** add --clear-owner to initiatives update ([fb401ea](https://github.com/linearis-oss/linearis/commit/fb401ea029bd559140567ea61c6ff1493609b3ea)), closes [#282](https://github.com/linearis-oss/linearis/issues/282) +* **issues:** add --clear-assignee and --clear-project to issues update ([9a5ca75](https://github.com/linearis-oss/linearis/commit/9a5ca7529efdec30d95c77838f947f6e7255b225)), closes [#282](https://github.com/linearis-oss/linearis/issues/282) [#282](https://github.com/linearis-oss/linearis/issues/282) + +### Bug Fixes + +* **cli:** classify the two option-shaped parse failures ([19ec395](https://github.com/linearis-oss/linearis/commit/19ec3955285f7fcad3d74e21b294604f59d54ccb)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) +* **cli:** disable Commander's implicit help subcommand ([de405af](https://github.com/linearis-oss/linearis/commit/de405afd709abe27cac3fedf0e06165e7b0becc5)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) +* **cli:** emit JSON envelope for argument-parse errors ([3bd0e38](https://github.com/linearis-oss/linearis/commit/3bd0e3899d64bfa5195009d686cf2cbdddc4ed06)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) +* **cli:** give every bare command group the same MISSING_SUBCOMMAND envelope ([b4c3a8b](https://github.com/linearis-oss/linearis/commit/b4c3a8b58690da398c702147b1c1446f00ab34ca)) +* **cli:** keep the usage-error message on a single line ([a447807](https://github.com/linearis-oss/linearis/commit/a4478075c73af9e0c0d3af699c68402559c05343)) +* **common:** point auth recovery at 'auth login', not the bare group ([cca71ec](https://github.com/linearis-oss/linearis/commit/cca71ec64c86148dbaa27987933d6fae05541082)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) +* **deps:** update dependency commander to v15 ([290424a](https://github.com/linearis-oss/linearis/commit/290424ae2a7f53d3b014de822187429d4fc350b6)) +* **deps:** update dependency graphql to v16.14.2 ([ca72bf7](https://github.com/linearis-oss/linearis/commit/ca72bf7ac32f40643f8b34b7bcafe15675f07226)) +* **deps:** update dependency graphql to v17 ([05bb7af](https://github.com/linearis-oss/linearis/commit/05bb7af33f14b5ab91e6c9797d613ac192c58c80)) +* **projects:** bound project query connections to avoid complexity limit ([dfe97b8](https://github.com/linearis-oss/linearis/commit/dfe97b8f0c3cf15c5fc40a7861f2cd422fdc30b9)), closes [#276](https://github.com/linearis-oss/linearis/issues/276) [#283](https://github.com/linearis-oss/linearis/issues/283) +* **projects:** surface truncation on bounded connections, lock bounds in tests ([a3145c1](https://github.com/linearis-oss/linearis/commit/a3145c1b99273c11b1e7f077cf2799f63ae4c141)), closes [#276](https://github.com/linearis-oss/linearis/issues/276) [#284](https://github.com/linearis-oss/linearis/issues/284) +* **usage:** list nested group subcommands in domain usage ([2bff44f](https://github.com/linearis-oss/linearis/commit/2bff44ff745df8e186d4a46f92feaac65ac4740c)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) + ## [2026.7.0-next.6](https://github.com/linearis-oss/linearis/compare/v2026.7.0-next.5...v2026.7.0-next.6) (2026-08-07) +### Bug Fixes + +* **projects:** bound project query connections to avoid complexity limit ([dfe97b8](https://github.com/linearis-oss/linearis/commit/dfe97b8f0c3cf15c5fc40a7861f2cd422fdc30b9)), closes [#276](https://github.com/linearis-oss/linearis/issues/276) [#283](https://github.com/linearis-oss/linearis/issues/283) +* **projects:** surface truncation on bounded connections, lock bounds in tests ([a3145c1](https://github.com/linearis-oss/linearis/commit/a3145c1b99273c11b1e7f077cf2799f63ae4c141)), closes [#276](https://github.com/linearis-oss/linearis/issues/276) [#284](https://github.com/linearis-oss/linearis/issues/284) + ## [2026.7.0-next.5](https://github.com/linearis-oss/linearis/compare/v2026.7.0-next.4...v2026.7.0-next.5) (2026-08-06) +### Bug Fixes + +* **deps:** update dependency graphql to v17 ([05bb7af](https://github.com/linearis-oss/linearis/commit/05bb7af33f14b5ab91e6c9797d613ac192c58c80)) + ## [2026.7.0-next.4](https://github.com/linearis-oss/linearis/compare/v2026.7.0-next.3...v2026.7.0-next.4) (2026-08-06) +### Bug Fixes + +* **cli:** classify the two option-shaped parse failures ([19ec395](https://github.com/linearis-oss/linearis/commit/19ec3955285f7fcad3d74e21b294604f59d54ccb)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) +* **cli:** disable Commander's implicit help subcommand ([de405af](https://github.com/linearis-oss/linearis/commit/de405afd709abe27cac3fedf0e06165e7b0becc5)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) +* **cli:** emit JSON envelope for argument-parse errors ([3bd0e38](https://github.com/linearis-oss/linearis/commit/3bd0e3899d64bfa5195009d686cf2cbdddc4ed06)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) +* **cli:** give every bare command group the same MISSING_SUBCOMMAND envelope ([b4c3a8b](https://github.com/linearis-oss/linearis/commit/b4c3a8b58690da398c702147b1c1446f00ab34ca)) +* **cli:** keep the usage-error message on a single line ([a447807](https://github.com/linearis-oss/linearis/commit/a4478075c73af9e0c0d3af699c68402559c05343)) +* **common:** point auth recovery at 'auth login', not the bare group ([cca71ec](https://github.com/linearis-oss/linearis/commit/cca71ec64c86148dbaa27987933d6fae05541082)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) +* **usage:** list nested group subcommands in domain usage ([2bff44f](https://github.com/linearis-oss/linearis/commit/2bff44ff745df8e186d4a46f92feaac65ac4740c)), closes [#281](https://github.com/linearis-oss/linearis/issues/281) + ## [2026.7.0-next.3](https://github.com/linearis-oss/linearis/compare/v2026.7.0-next.2...v2026.7.0-next.3) (2026-08-06) +### Features + +* **initiatives:** add --clear-owner to initiatives update ([fb401ea](https://github.com/linearis-oss/linearis/commit/fb401ea029bd559140567ea61c6ff1493609b3ea)), closes [#282](https://github.com/linearis-oss/linearis/issues/282) +* **issues:** add --clear-assignee and --clear-project to issues update ([9a5ca75](https://github.com/linearis-oss/linearis/commit/9a5ca7529efdec30d95c77838f947f6e7255b225)), closes [#282](https://github.com/linearis-oss/linearis/issues/282) [#282](https://github.com/linearis-oss/linearis/issues/282) + ## [2026.7.0-next.2](https://github.com/linearis-oss/linearis/compare/v2026.7.0-next.1...v2026.7.0-next.2) (2026-07-06) +### Bug Fixes + +* **deps:** update dependency commander to v15 ([290424a](https://github.com/linearis-oss/linearis/commit/290424ae2a7f53d3b014de822187429d4fc350b6)) + ## [2026.7.0-next.1](https://github.com/linearis-oss/linearis/compare/v2026.6.0...v2026.7.0-next.1) (2026-07-06) +### Bug Fixes + +* **deps:** update dependency graphql to v16.14.2 ([ca72bf7](https://github.com/linearis-oss/linearis/commit/ca72bf7ac32f40643f8b34b7bcafe15675f07226)) + ## [2026.6.0](https://github.com/linearis-oss/linearis/compare/v2026.5.0...v2026.6.0) (2026-07-04) ### Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 01464a42..13386049 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,28 @@ For the authoritative workflow trigger matrix, required check names, and verific `CHANGELOG.md` is release-workflow-owned. Do not edit it in feature/fix PRs. If CI reports changelog history violations, rebase on `main` and drop/amend commits that touched `CHANGELOG.md`. +There is one exception, for repairing releases whose notes semantic-release +failed to render — as happened for `2026.7.0-next.1` … `2026.7.0`, when the +`conventionalcommits` preset resolved to a version incompatible with the notes +generator's writer. `guard-changelog-history` in `ci-validate.yml` lets through +a commit whose subject is exactly: + +``` +chore(release): backfill changelog notes +``` + +and only when that commit changes nothing but `CHANGELOG.md` and only adds +lines to it — a backfill that removes or rewrites an existing line fails the +guard. Every other `CHANGELOG.md` edit stays blocked. + +Such a repair must be generated rather than hand-written: re-render the notes +for each empty section using the commit range from the compare link already in +its heading, and splice in only the body so the heading's original version, +date and compare link survive untouched — which is also what keeps the diff +additions-only, as the guard requires. +GitHub Release bodies are damaged the same way and cannot be fixed by a +committed file; a maintainer patches those separately with `gh api`. + ## Pull Requests 1. Fork the repo and create your branch from `main` diff --git a/README.md b/README.md index d664ffee..ef89277d 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,46 @@ linearis issues replies linearis issues reply --body "I found the root cause" ``` +### Batch operations + +Both batch commands take a JSON document instead of flags, and apply it in a single transaction — either every issue changes or none does. Unknown keys are rejected rather than ignored, so a typo fails the command instead of quietly dropping a field. + +`issues batch create` takes an array with one object per issue, keys named after the `issues create` flags: + +```json +[ + { "title": "Fix login redirect loop", "team": "ENG", "labels": ["bug"] }, + { "title": "Document the SSO flow", "team": "ENG", "project": "Q3 Auth" } +] +``` + +`issues batch update` takes the targets plus the one patch they share, keys named after the `issues update` flags, where `null` clears a field: + +```json +{ + "issues": ["ENG-42", "ENG-43"], + "patch": { "status": "In Progress", "assignee": "alice", "cycle": null } +} +``` + +```bash +linearis issues batch create --file issues.json +linearis issues batch update --file patch.json + +# - reads stdin, and --json takes the document inline for one-offs +generate-issues | linearis issues batch create --file - +linearis issues batch update --json '{"issues":["ENG-42"],"patch":{"status":"Done"}}' +``` + +Both formats are published as JSON Schema (draft 2020-12) in [`schemas/`](schemas/), shipped in the npm package and served raw from the default branch — point a validator or an editor at them to check a document before spending an API call on it. + +A schema is the input contract only: it cannot know your team's workflow states, label names, or estimation scale, so a document that validates can still be rejected when a name does not resolve. + ## Coverage -Linear's GraphQL API exposes **537 root operations** (164 queries, 373 mutations). Linearis wires about **75 of them** directly, plus a number of nested reads — chosen to cover planning and issue work end to end rather than the whole API. +Linear's GraphQL API exposes **520 root operations** (159 queries, 361 mutations). Linearis wires **114 of them** directly, plus a number of nested reads — chosen to cover planning and issue work end to end rather than the whole API. + +Both figures are checkable rather than asserted: `npm run count:root-fields -- --verify` parses `graphql/**/*.graphql` and cross-checks the result against a live introspection of the schema. The table below is the honest picture of the whole surface — what works today, and what you'll need the [Linear MCP](#linearis-vs-linear-mcp) or a raw API call for. @@ -119,26 +156,26 @@ The table below is the honest picture of the whole surface — what works today, | Area | Extent | What you can do | Not covered | |---|---|---|---| | `auth` | ✅ | Interactive login, token status, logout | — | -| Discussions | ✅ | Root threads and replies on issues, projects, and initiatives; edit, delete, resolve/unresolve; emoji reactions on any of them | Custom workspace emoji management | -| `issues` | 🟡 | List, filter, full-text search, read, create, update, archive/unarchive, delete; assign labels/assignee/state/priority/project/cycle; relations (list/add/remove); activity history | Batch create/update, subscribe/unsubscribe, share links, reminders, external sync toggles | +| Discussions | ✅ | Root threads and replies on issues, projects, and initiatives; edit, delete, resolve/unresolve; emoji reactions on any of them | Custom workspace emoji management, and comment threads on a status update — `CommentCreateInput.projectUpdateId` makes an update its own discussion surface, which neither `projects updates` nor `initiatives updates` exposes | +| `issues` | ✅ | List, filter, full-text search, read, create, update, batch create/update, archive/unarchive, delete/restore, snooze; assign labels/assignee/delegate/state/priority/project/cycle/team (including moves between teams); subscribe/unsubscribe, share/unshare, reminders; find the issue for a git branch (`from-branch`); relations (list/add/remove); activity history | Deliberately excluded: the AI-assist and integration-suggestion queries (Figma file lookup, filter/repository suggestions, title-from-customer-request) — see the Integrations row — and `issuePriorityValues`, a static list already in the help text | | `initiatives` | 🟡 | List, read, create, update, archive/unarchive, delete; attach/detach projects; initiative-to-initiative relations; initiative updates (list, read, create, update, archive/unarchive); discussions | Initiative labels, lead-team reassignment, relation reordering | -| `projects` | 🟡 | List, read, create, update, archive/unarchive, delete; assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); discussions | Project updates (status posts), project-label CRUD, project relations, project status administration, Slack channel creation | +| `projects` | ✅ | List, full-text search, read, create, update, delete (trash) and unarchive (restore), disable external sync; assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); dependency relations (list, read, add, update, remove); administer the workspace project status flow (`projects statuses`); discussions; activity timeline | Deliberately excluded: Slack-channel creation and the AI filter suggestion, which are `[Internal]` integration plumbing — see the Integrations row. Project labels live under `labels --type project`; milestone delete/move under `milestones` | | `documents` | 🟡 | List, read, create, update, delete | Content history, document full-text search, unarchive | | `milestones` | 🟡 | List, read, create, update (per project) | Delete, reordering/move between projects | -| `attachments` | 🟡 | List on an issue, create from a URL, delete | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | +| `attachments` | 🟡 | List on an issue, create from a URL, delete, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | | `files` | 🟡 | Upload a file, download via signed URL | Delete uploads, image-from-URL, CSV export reports | | `teams` | 🟡 | List, read, create, update; list/add/remove members | Delete, workflow-state administration, triage responsibility, git automation, SLA configuration | -| `labels` | 🟠 | Issue labels: list, read, create, update, delete; project labels: list (`--type project`) | Project-label create/update/delete, initiative labels, retire/restore | +| `labels` | 🟡 | Issue and project labels alike (`--type issue\|project`): list, read, create, update, delete, retire/restore; label groups (`--group`, `--parent`) | Initiative labels | | `cycles` | 🟠 | List cycles, read a cycle with its issues | Create, update, archive, shift all, start upcoming cycle | | `users` | 🟠 | List workspace members | Read a single user, update, role changes, suspend/unsuspend, user settings, session management | -| Integrations | 🔴 | — | All 73 integration root fields (65 mutations, 8 queries): Slack, GitHub, GitLab, Jira, Figma, Sentry, PagerDuty, Intercom, Salesforce, and more | +| Integrations | 🔴 | — | All 70 integration root fields (62 mutations, 8 queries): Slack, GitHub, GitLab, Jira, Figma, Sentry, PagerDuty, Intercom, Salesforce, and more. Also the `[Internal]` per-entity integration plumbing excluded from the `issues` and `projects` rows: Slack channel creation and dismissal on a project, Slack/Teams/Jira project posts, and the AI-assist suggestion queries (issue and project filter suggestions, Figma file lookup, title-from-customer-request) | | Organization & admin | 🔴 | — | Org settings, invites, domains, webhooks, OAuth apps, audit log, SSO | | Releases | 🔴 | — | Releases, release pipelines, stages, release notes | | Customers (CRM) | 🔴 | — | Customers, needs, tiers, customer statuses | | Views & templates | 🔴 | — | Custom views, favorites, templates, view preferences | | Notifications | 🔴 | — | Inbox, subscriptions, snooze, mark read, push subscriptions | | Agent sessions | 🔴 | — | Agent sessions, activities, skills, semantic search | -| Roadmaps | 🔴 | — | Roadmaps and roadmap-to-project links | +| Roadmaps | 🔴 | — | Roadmaps and roadmap-to-project links. `roadmapToProject*` is deprecated in favour of `initiativeToProject*`, which `initiatives` already wires | | Imports & exports | 🔴 | — | Jira/Asana/Clubhouse/GitHub/CSV import jobs | | Schedules | 🔴 | — | Time schedules and on-call rotations | @@ -231,6 +268,7 @@ npx skills add linearis-oss/linearis - [MIGRATION_2026.4.9.md](MIGRATION_2026.4.9.md) — migrating from the deprecated `comments` domain to discussions (v2026.4.9). - [`docs/`](docs/) — architecture, development, testing, and build-system references. +- [`schemas/`](schemas/) — JSON Schemas for the commands that take a JSON document ([batch operations](#batch-operations)). - [`docs/ci-run-model.md`](docs/ci-run-model.md) — the authoritative CI/release trigger matrix. - [CONTRIBUTING.md](CONTRIBUTING.md) — contributor guidelines. - [SECURITY.md](SECURITY.md) — how to report security issues. diff --git a/docs/architecture.md b/docs/architecture.md index d8c9477e..277a028b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,7 +127,7 @@ Shared utilities used across layers. - **src/commands/cycles.ts** - Cycle listing and reading - **src/commands/teams.ts** - Team listing - **src/commands/users.ts** - User listing -- **src/commands/projects.ts** - Project listing +- **src/commands/projects/** - Project commands (`index.ts` registers the domain, `entity.ts` holds CRUD and discussions) - **src/commands/labels.ts** - Label listing - **src/commands/comments.ts** - Comment creation - **src/commands/embeds.ts** - File operations diff --git a/docs/files.md b/docs/files.md index 788e6edf..f39b7c9e 100644 --- a/docs/files.md +++ b/docs/files.md @@ -22,7 +22,9 @@ Each resolver converts a human-friendly identifier (name, key, or slug) into a U - **cycle-resolver.ts** -- `resolveCycleId(client, nameOrId, teamFilter?)` - **status-resolver.ts** -- `resolveStatusId(client, nameOrId, teamId?)` - **issue-resolver.ts** -- `resolveIssueId(client, issueIdOrIdentifier)` -- **milestone-resolver.ts** -- `resolveMilestoneId(gqlClient, sdkClient, nameOrId, projectNameOrId?)` +- **milestone-resolver.ts** -- `resolveMilestoneId(gqlClient, nameOrId, projectNameOrId?)` — a project scope is authoritative, never widened to the workspace +- **project-status-resolver.ts** -- `resolveProjectStatusId(client, nameOrId, { includeArchived? })` +- **project-relation-resolver.ts** -- `resolveProjectRelation(client, relationOrProjectId, relatedProjectId?)` — returns `{ id, inverted }`; `inverted` tells callers writing per-end fields to swap the two ends ## Service Layer (`src/services/`) @@ -35,8 +37,12 @@ Business logic and CRUD operations. Services use `GraphQLClient` exclusively and - **cycle-service.ts** -- `listCycles`, `getCycle` - **team-service.ts** -- `listTeams` - **user-service.ts** -- `listUsers` -- **project-service.ts** -- `listProjects` -- **label-service.ts** -- `listLabels` +- **project-service.ts** -- `listProjects`, `searchProjects`, `getProject`, `createProject`, `updateProject`, `applyProjectLabels`, `disableProjectExternalSync`, `unarchiveProject`, `deleteProject` +- **project-update-service.ts** -- Project status posts: `listProjectUpdates`, `getProjectUpdate`, `createProjectUpdate`, `editProjectUpdate`, archive/unarchive, `remindProjectUpdate` +- **project-status-service.ts** -- The workspace project status flow: list/get/create/update, `reassignProjectStatus`, archive/unarchive +- **project-relation-service.ts** -- Project dependencies: list (per project and workspace-wide), get, create, update, delete +- **project-activity-service.ts** -- Merges project discussions, history and status updates into one chronological timeline +- **label-service.ts** -- `listLabels`, `listProjectLabels`, and get/create/update/delete/retire/restore dispatching on `LabelType` - **comment-service.ts** -- `createComment` - **file-service.ts** -- File upload and download operations for Linear uploads @@ -46,13 +52,14 @@ CLI orchestration. Each file registers a command group via a `setup*Commands(pro - **auth.ts** -- `auth login`, `auth status`, `auth logout` — interactive authentication (for humans) - **issues.ts** -- `issue list`, `issue search`, `issue read`, `issue create`, `issue update` +- **issues-batch.ts** -- `issues batch create`, `issues batch update` — the bulk subgroup, split out because it takes a JSON document rather than flags - **documents.ts** -- Document commands with attachment support - **project-milestones.ts** -- Milestone CRUD commands - **cycles.ts** -- Cycle listing and detail reading - **teams.ts** -- Team listing - **users.ts** -- User listing -- **projects.ts** -- Project listing -- **labels.ts** -- Label listing +- **projects/** -- Project commands. `index.ts` owns `PROJECTS_META` and registers the domain; `entity.ts` holds CRUD, search, sync and discussions; `updates.ts`, `statuses.ts` and `relations.ts` hold the subgroups +- **labels.ts** -- Label commands for both issue and project labels (`--type`) - **comments.ts** -- Comment creation - **embeds.ts** -- File download from Linear upload URLs @@ -66,7 +73,9 @@ Shared utilities used across all layers. - **encryption.ts** -- AES-256-CBC encryption for token storage. - **output.ts** -- `outputSuccess()`, `outputError()`, and `handleCommand()` wrapper for consistent JSON output and error handling. - **errors.ts** -- `notFoundError()`, `multipleMatchesError()`, `invalidParameterError()`, `requiresParameterError()`. -- **identifier.ts** -- `isUuid()`, `parseIssueIdentifier()`, `tryParseIssueIdentifier()`. +- **identifier.ts** -- `isUuid()`, `parseIssueIdentifier()`, `tryParseIssueIdentifier()`, `parseDueDate()` (Linear's timeless `TimelessDate`). +- **datetime.ts** -- `parseDateTimeOption(flag, value, now?)` for the `DateTime` flags (`remind --at`, `snooze --until`): ISO-8601 or a `+2h`/`+3d` offset, normalized to UTC. +- **git.ts** -- `getCurrentBranch()` for `issues from-branch`; shells out via `execFileSync`, never a shell. - **types.ts** -- Type aliases derived from codegen output (e.g., `Issue`, `IssueDetail`, `Document`). - **embed-parser.ts** -- `extractEmbeds()`, `isLinearUploadUrl()`, `extractFilenameFromUrl()` for parsing embedded files in markdown content. - **usage.ts** -- Token-optimized two-tier usage system with `DomainMeta` interface, `formatOverview()` for tier 1 (all domains), and `formatDomainUsage()` for tier 2 (domain detail). Generates USAGE.md via build pipeline. @@ -100,6 +109,15 @@ Source `.graphql` files that feed into code generation. - `mutations/files.graphql` - `mutations/project-milestones.graphql` +## Input Schemas (`schemas/`) + +Hand-written JSON Schemas for the commands that take a JSON document instead of flags. Nothing reads them at runtime — they exist for callers (editors, validators, agents) and are shipped in the npm package via `files` in `package.json`. + +- **issues-batch-create.schema.json** -- the `issues batch create` document (an array of issues to create). +- **issues-batch-update.schema.json** -- the `issues batch update` document (`issues` plus the one `patch` they share, where `null` clears a field). + +Both are kept in step with their parsers in `src/commands/issues-batch.ts` (`parseBatchCreateEntries`, `parseBatchUpdateDocument`) by `tests/unit/commands/issues-batch-schema.test.ts`; extend both when adding a field. + ## Tests (`tests/`) Unit tests mirror the source structure. Resolver and service tests both mock the `GraphQLClient` (`request`); common tests require no mocks. diff --git a/graphql/mutations/attachments.graphql b/graphql/mutations/attachments.graphql index 7e12274a..2ab7980b 100644 --- a/graphql/mutations/attachments.graphql +++ b/graphql/mutations/attachments.graphql @@ -22,6 +22,20 @@ mutation AttachmentCreate($input: AttachmentCreateInput!) { } } +# Stop syncing an issue with the external resource behind an attachment +# +# Despite the `issue*` prefix the mutation is keyed by attachment, not issue: +# a single issue can carry several synced attachments and they are disabled +# one at a time. It returns the affected issue. +mutation AttachmentExternalSyncDisable($attachmentId: String!) { + issueExternalSyncDisable(attachmentId: $attachmentId) { + success + issue { + ...CompleteIssueFields + } + } +} + # Delete an attachment # # Deletes an attachment and returns success status. diff --git a/graphql/mutations/issues.graphql b/graphql/mutations/issues.graphql index 7943ff29..5b367dca 100644 --- a/graphql/mutations/issues.graphql +++ b/graphql/mutations/issues.graphql @@ -51,6 +51,84 @@ mutation UnarchiveIssue($id: String!) { } } +# Create many issues in one transaction +# +# The batch payload returns the created issues without the per-issue comment +# thread: a freshly created issue has no comments, and CompleteIssueFields +# keeps the response proportional to the batch size. +mutation BatchCreateIssues($input: IssueBatchCreateInput!) { + issueBatchCreate(input: $input) { + success + issues { + ...CompleteIssueFields + } + } +} + +# Apply one patch to an explicit list of issues +mutation BatchUpdateIssues($ids: [UUID!]!, $input: IssueUpdateInput!) { + issueBatchUpdate(ids: $ids, input: $input) { + success + issues { + ...CompleteIssueFields + } + } +} + +# Subscribe a user to an issue's notifications +# +# The API accepts either userId or userEmail; the CLI always resolves to a +# UUID first (resolvers own ID resolution), so only userId is threaded through. +mutation SubscribeToIssue($id: String!, $userId: String!) { + issueSubscribe(id: $id, userId: $userId) { + success + issue { + ...CompleteIssueWithDefaultCommentsFields + } + } +} + +mutation UnsubscribeFromIssue($id: String!, $userId: String!) { + issueUnsubscribe(id: $id, userId: $userId) { + success + issue { + ...CompleteIssueWithDefaultCommentsFields + } + } +} + +# Grant a user access to an issue they could not otherwise see +# +# Despite the name this does not mint a shareable link — the issue's own +# permalink is the `url` field on the read payload. +mutation ShareIssue($id: String!, $userId: String!) { + issueShare(id: $id, userId: $userId) { + success + issue { + ...CompleteIssueWithDefaultCommentsFields + } + } +} + +mutation UnshareIssue($id: String!, $userId: String!) { + issueUnshare(id: $id, userId: $userId) { + success + issue { + ...CompleteIssueWithDefaultCommentsFields + } + } +} + +# Schedule a reminder for the viewer on an issue +mutation RemindOnIssue($id: String!, $reminderAt: DateTime!) { + issueReminder(id: $id, reminderAt: $reminderAt) { + success + issue { + ...CompleteIssueWithDefaultCommentsFields + } + } +} + mutation DeleteIssue($id: String!) { issueDelete(id: $id) { success diff --git a/graphql/mutations/labels.graphql b/graphql/mutations/labels.graphql index 460afeed..44f7ee9b 100644 --- a/graphql/mutations/labels.graphql +++ b/graphql/mutations/labels.graphql @@ -1,5 +1,8 @@ # ------------------------------------------------------------ -# GraphQL mutations for Linear issue labels +# GraphQL mutations for Linear issue and project labels +# +# The two label kinds are separate types with parallel mutations; +# `labels --type issue|project` picks between them. # ------------------------------------------------------------ mutation CreateIssueLabel($input: IssueLabelCreateInput!) { @@ -26,3 +29,68 @@ mutation DeleteIssueLabel($id: String!) { entityId } } + +# Retire an issue label +# +# Retired labels stay on the issues that already carry them but cannot be +# applied to new ones — a softer alternative to delete. +mutation RetireIssueLabel($id: String!) { + issueLabelRetire(id: $id) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation RestoreIssueLabel($id: String!) { + issueLabelRestore(id: $id) { + success + issueLabel { + ...LabelFields + } + } +} + +mutation CreateProjectLabel($input: ProjectLabelCreateInput!) { + projectLabelCreate(input: $input) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation UpdateProjectLabel($id: String!, $input: ProjectLabelUpdateInput!) { + projectLabelUpdate(id: $id, input: $input) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation DeleteProjectLabel($id: String!) { + projectLabelDelete(id: $id) { + success + entityId + } +} + +mutation RetireProjectLabel($id: String!) { + projectLabelRetire(id: $id) { + success + projectLabel { + ...ProjectLabelFields + } + } +} + +mutation RestoreProjectLabel($id: String!) { + projectLabelRestore(id: $id) { + success + projectLabel { + ...ProjectLabelFields + } + } +} diff --git a/graphql/mutations/project-relations.graphql b/graphql/mutations/project-relations.graphql new file mode 100644 index 00000000..57aafca2 --- /dev/null +++ b/graphql/mutations/project-relations.graphql @@ -0,0 +1,31 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear project dependency relations +# ------------------------------------------------------------ + +mutation CreateProjectRelation($input: ProjectRelationCreateInput!) { + projectRelationCreate(input: $input) { + success + projectRelation { + ...ProjectRelationCoreFields + } + } +} + +mutation UpdateProjectRelation( + $id: String! + $input: ProjectRelationUpdateInput! +) { + projectRelationUpdate(id: $id, input: $input) { + success + projectRelation { + ...ProjectRelationCoreFields + } + } +} + +mutation DeleteProjectRelation($id: String!) { + projectRelationDelete(id: $id) { + success + entityId + } +} diff --git a/graphql/mutations/project-statuses.graphql b/graphql/mutations/project-statuses.graphql new file mode 100644 index 00000000..70aa2a33 --- /dev/null +++ b/graphql/mutations/project-statuses.graphql @@ -0,0 +1,58 @@ +# ------------------------------------------------------------ +# GraphQL mutations for the workspace project status flow +# ------------------------------------------------------------ + +mutation CreateProjectStatus($input: ProjectStatusCreateInput!) { + projectStatusCreate(input: $input) { + success + status { + ...ProjectStatusCoreFields + } + } +} + +mutation UpdateProjectStatus($id: String!, $input: ProjectStatusUpdateInput!) { + projectStatusUpdate(id: $id, input: $input) { + success + status { + ...ProjectStatusCoreFields + } + } +} + +# Archive a status +# +# Linear refuses this while projects are still assigned to the status, or +# when it is the last status of its type. +mutation ArchiveProjectStatus($id: String!) { + projectStatusArchive(id: $id) { + success + entity { + ...ProjectStatusCoreFields + } + } +} + +mutation UnarchiveProjectStatus($id: String!) { + projectStatusUnarchive(id: $id) { + success + entity { + ...ProjectStatusCoreFields + } + } +} + +# Move every project off one status and onto another +# +# The payload carries no entity — only whether the reassignment ran. +mutation ReassignProjectStatus( + $originalProjectStatusId: String! + $newProjectStatusId: String! +) { + projectReassignStatus( + originalProjectStatusId: $originalProjectStatusId + newProjectStatusId: $newProjectStatusId + ) { + success + } +} diff --git a/graphql/mutations/project-updates.graphql b/graphql/mutations/project-updates.graphql new file mode 100644 index 00000000..c0be183f --- /dev/null +++ b/graphql/mutations/project-updates.graphql @@ -0,0 +1,52 @@ +# ------------------------------------------------------------ +# GraphQL mutations for Linear project status updates +# +# `projectUpdateDelete` is deliberately not wired: Linear deprecates it +# in favour of `projectUpdateArchive`, which is reversible. +# ------------------------------------------------------------ + +mutation CreateProjectUpdate($input: ProjectUpdateCreateInput!) { + projectUpdateCreate(input: $input) { + success + projectUpdate { + ...ProjectUpdateCoreFields + } + } +} + +mutation EditProjectUpdate($id: String!, $input: ProjectUpdateUpdateInput!) { + projectUpdateUpdate(id: $id, input: $input) { + success + projectUpdate { + ...ProjectUpdateCoreFields + } + } +} + +mutation ArchiveProjectUpdate($id: String!) { + projectUpdateArchive(id: $id) { + success + entity { + ...ProjectUpdateCoreFields + } + } +} + +mutation UnarchiveProjectUpdate($id: String!) { + projectUpdateUnarchive(id: $id) { + success + entity { + ...ProjectUpdateCoreFields + } + } +} + +# Nudge someone to post the next update +# +# The payload carries no entity — there is nothing to return but whether +# the notification was created. +mutation CreateProjectUpdateReminder($projectId: String!, $userId: String) { + createProjectUpdateReminder(projectId: $projectId, userId: $userId) { + success + } +} diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index b0ce91c0..e505b746 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -30,8 +30,12 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { } } -mutation ArchiveProject($id: String!) { - projectArchive(id: $id) { +# Restore a trashed project +# +# Linear collapses "archived" and "trashed" into a single state, so this +# restores whatever projectDelete put away. +mutation UnarchiveProject($id: String!) { + projectUnarchive(id: $id) { success entity { ...ProjectDetailWithDefaultConnectionsFields @@ -39,8 +43,11 @@ mutation ArchiveProject($id: String!) { } } -mutation UnarchiveProject($id: String!) { - projectUnarchive(id: $id) { +# Trash a project +# +# Reversible: UnarchiveProject restores it. +mutation DeleteProject($id: String!) { + projectDelete(id: $id) { success entity { ...ProjectDetailWithDefaultConnectionsFields @@ -48,10 +55,39 @@ mutation UnarchiveProject($id: String!) { } } -mutation DeleteProject($id: String!) { - projectDelete(id: $id) { +# Stop syncing a project with an external tracker +# +# Mirrors `attachments disable-sync`: the link stays, the updates stop. +mutation DisableProjectExternalSync( + $projectId: String! + $syncSource: ExternalSyncService! +) { + projectExternalSyncDisable(projectId: $projectId, syncSource: $syncSource) { success - entity { + project { + ...ProjectDetailWithDefaultConnectionsFields + } + } +} + +# Add one label to a project +# +# Incremental, unlike `ProjectUpdateInput.labelIds`, which replaces the +# whole set — so `--label-mode add` needs no read of the current labels +# and cannot drop the ones it did not see. +mutation AddProjectLabel($id: String!, $labelId: String!) { + projectAddLabel(id: $id, labelId: $labelId) { + success + project { + ...ProjectDetailWithDefaultConnectionsFields + } + } +} + +mutation RemoveProjectLabel($id: String!, $labelId: String!) { + projectRemoveLabel(id: $id, labelId: $labelId) { + success + project { ...ProjectDetailWithDefaultConnectionsFields } } diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 32d936d2..50b03a4b 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -17,20 +17,35 @@ fragment CompleteIssueFields on Issue { identifier title description + url branchName priority estimate dueDate createdAt updatedAt + startedAt + completedAt + canceledAt + archivedAt + snoozedUntilAt + trashed state { id name } + creator { + id + name + } assignee { id name } + delegate { + id + name + } team { id key @@ -90,6 +105,26 @@ fragment CompleteIssueFields on Issue { } } +# Per-issue detail that is deliberately absent from CompleteIssueFields +# +# A subscriber roster and a sharing summary are worth one round-trip when the +# caller asked for one issue, but they would multiply across every row of a +# 50-issue list for no benefit — so they hang off the single-read fragments +# only, not off the shared list fragment. +fragment IssueDetailOnlyFields on Issue { + subscribers { + nodes { + id + name + } + } + sharedAccess { + isShared + sharedWithCount + viewerHasOnlySharedAccess + } +} + # Minimal comment fields preserved for default issue reads fragment IssueReadDefaultCommentFields on Comment { id @@ -115,6 +150,7 @@ fragment IssueReadCommentFields on Comment { # discussion metadata behind explicit flags. fragment CompleteIssueWithDefaultCommentsFields on Issue { ...CompleteIssueFields + ...IssueDetailOnlyFields comments { nodes { ...IssueReadDefaultCommentFields @@ -129,6 +165,7 @@ fragment CompleteIssueWithDefaultCommentsFields on Issue { # relationships (state, assignee, team, project, labels, comments). fragment CompleteIssueWithCommentsFields on Issue { ...CompleteIssueFields + ...IssueDetailOnlyFields comments { nodes { ...IssueReadCommentFields @@ -154,20 +191,35 @@ fragment CompleteIssueSearchFields on IssueSearchResult { identifier title description + url branchName priority estimate dueDate createdAt updatedAt + startedAt + completedAt + canceledAt + archivedAt + snoozedUntilAt + trashed state { id name } + creator { + id + name + } assignee { id name } + delegate { + id + name + } team { id key @@ -227,28 +279,6 @@ fragment CompleteIssueSearchFields on IssueSearchResult { } } -# Get issues list with all relationships in single query -# -# Fetches paginated issues excluding completed ones, -# ordered by most recently updated. Includes all relationships -# for comprehensive issue data. -query GetIssues($first: Int!, $after: String, $orderBy: PaginationOrderBy) { - issues( - first: $first - after: $after - orderBy: $orderBy - filter: { state: { type: { neq: "completed" } } } - ) { - nodes { - ...CompleteIssueFields - } - pageInfo { - hasNextPage - endCursor - } - } -} - # Get single issue by UUID with lean fields # # Fetches issue data by direct UUID lookup with the backward-compatible @@ -263,10 +293,14 @@ query GetIssueById($id: String!) { # # Fetches issue using TEAM-123 format with the backward-compatible # default comment payload. +# +# includeArchived matches the UUID path (`issue(id:)` returns archived issues): +# reading an issue by its identifier must not silently 404 once it is archived. query GetIssueByIdentifier($teamKey: String!, $number: Float!) { issues( filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } first: 1 + includeArchived: true ) { nodes { ...CompleteIssueWithDefaultCommentsFields @@ -286,6 +320,7 @@ query GetIssueByIdentifierWithComments($teamKey: String!, $number: Float!) { issues( filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } first: 1 + includeArchived: true ) { nodes { ...CompleteIssueWithCommentsFields @@ -305,6 +340,7 @@ query GetIssueByIdentifierWithReactions($teamKey: String!, $number: Float!) { issues( filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } first: 1 + includeArchived: true ) { nodes { ...CompleteIssueWithReactionsFields @@ -332,13 +368,14 @@ query SearchIssues( $first: Int! $after: String $filter: IssueFilter + $includeArchived: Boolean = false ) { searchIssues( term: $term first: $first after: $after filter: $filter - includeArchived: false + includeArchived: $includeArchived ) { nodes { ...CompleteIssueSearchFields @@ -352,20 +389,22 @@ query SearchIssues( # Search issues with advanced filters and all relationships in single query # -# Supports filtering by team, assignee, project, and states. -# Used by the advanced search functionality with multiple criteria. +# Supports filtering by team, assignee, project, and states, and also backs the +# unfiltered `issues list` — the implicit "hide completed" narrowing is built in +# the service (buildListIssuesFilter) so that `--include-archived` can drop it. query FilteredSearchIssues( $first: Int! $after: String $filter: IssueFilter $orderBy: PaginationOrderBy + $includeArchived: Boolean = false ) { issues( first: $first after: $after filter: $filter orderBy: $orderBy - includeArchived: false + includeArchived: $includeArchived ) { nodes { ...CompleteIssueFields @@ -858,10 +897,16 @@ query FindWorkflowStates($filter: WorkflowStateFilter, $first: Int = 1) { # ({ number: { eq }, team: { key: { eq } } }). team { id key } is selected so # the estimate-context resolver can derive the owning team without a second # round-trip. +# +# includeArchived is fixed to true: this query exists purely to turn a human +# identifier into a UUID, and an archived issue still has one. Without it +# `issues unarchive ENG-42` could never work, because the identifier could not +# be resolved in the first place. query FindIssues($filter: IssueFilter, $first: Int = 1) { - issues(filter: $filter, first: $first) { + issues(filter: $filter, first: $first, includeArchived: true) { nodes { id + number team { id key @@ -870,6 +915,16 @@ query FindIssues($filter: IssueFilter, $first: Int = 1) { } } +# Find the issue a VCS branch belongs to +# +# Linear derives a branch name per issue (the `branchName` field), and this is +# the reverse lookup. Returns null when the branch is not one of Linear's. +query IssueVcsBranchSearch($branchName: String!) { + issueVcsBranchSearch(branchName: $branchName) { + ...CompleteIssueWithDefaultCommentsFields + } +} + # Complete issue fragment with attachments fragment CompleteIssueWithAttachmentsFields on Issue { ...CompleteIssueWithDefaultCommentsFields @@ -892,6 +947,7 @@ query GetIssueByIdentifierWithAttachments($teamKey: String!, $number: Float!) { issues( filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } first: 1 + includeArchived: true ) { nodes { ...CompleteIssueWithAttachmentsFields diff --git a/graphql/queries/labels.graphql b/graphql/queries/labels.graphql index 6c66ceca..843b614e 100644 --- a/graphql/queries/labels.graphql +++ b/graphql/queries/labels.graphql @@ -20,6 +20,14 @@ fragment LabelFields on IssueLabel { name color description + isGroup + # Retired labels stay on the entities that already carry them but cannot + # be applied to new ones, so a null here is what "usable" means. + retiredAt + parent { + id + name + } } fragment ProjectLabelFields on ProjectLabel { @@ -27,6 +35,12 @@ fragment ProjectLabelFields on ProjectLabel { name color description + isGroup + retiredAt + parent { + id + name + } } query GetIssueLabel($id: String!) { @@ -67,6 +81,12 @@ query GetLabels( } } +query GetProjectLabel($id: String!) { + projectLabel(id: $id) { + ...ProjectLabelFields + } +} + query GetProjectLabels($first: Int = 50, $after: String) { projectLabels(first: $first, after: $after) { nodes { diff --git a/graphql/queries/project-activity.graphql b/graphql/queries/project-activity.graphql new file mode 100644 index 00000000..83751779 --- /dev/null +++ b/graphql/queries/project-activity.graphql @@ -0,0 +1,54 @@ +# ------------------------------------------------------------ +# GraphQL queries backing `projects activity` +# +# The project timeline merges three independent connections: project +# history, discussion threads, and status updates. Linear exposes no +# unified activity connection, so each is exhausted separately and the +# service interleaves them. +# ------------------------------------------------------------ + +query GetProjectActivityRef($id: String!) { + project(id: $id) { + id + name + } +} + +# A project's history events +# +# Unlike `IssueHistory`, which has ~40 typed from*/to* fields, every +# `ProjectHistory` event body lives in one opaque `entries` JSONObject. +# There is nothing to normalize into a typed change list, and no actor +# field either — the shape a caller gets here is whatever Linear put in +# `entries`. +query ListProjectActivityHistory($id: String!, $first: Int, $after: String) { + project(id: $id) { + history(first: $first, after: $after) { + nodes { + id + createdAt + entries + } + pageInfo { + hasNextPage + endCursor + } + } + } +} + +query ListProjectActivityUpdates($projectId: ID!, $first: Int, $after: String) { + projectUpdates( + first: $first + after: $after + filter: { project: { id: { eq: $projectId } } } + ) { + nodes { + ...ProjectUpdateCoreFields + } + pageInfo { + hasNextPage + endCursor + } + } +} diff --git a/graphql/queries/project-milestones.graphql b/graphql/queries/project-milestones.graphql index 24ed9a55..b144a0c5 100644 --- a/graphql/queries/project-milestones.graphql +++ b/graphql/queries/project-milestones.graphql @@ -74,7 +74,7 @@ query FindProjectMilestoneScoped($name: String!, $projectId: String!) { } } -# Find project milestone by name globally (fallback) +# Find project milestone by name globally # # Searches for a project milestone by name across all projects. query FindProjectMilestoneGlobal($name: String!) { diff --git a/graphql/queries/project-relations.graphql b/graphql/queries/project-relations.graphql new file mode 100644 index 00000000..9b66a924 --- /dev/null +++ b/graphql/queries/project-relations.graphql @@ -0,0 +1,89 @@ +# ------------------------------------------------------------ +# GraphQL queries for Linear project dependency relations +# +# A project relation links a point on one project to a point on +# another: "this must finish before that starts". Both `type` and the +# two anchor fields are plain `String!` in the schema with no enum, so +# the accepted literals were read off a live workspace rather than +# derived from the schema. See `src/services/project-relation-service.ts`. +# ------------------------------------------------------------ + +fragment ProjectRelationCoreFields on ProjectRelation { + id + type + anchorType + relatedAnchorType + createdAt + updatedAt + project { + id + name + } + projectMilestone { + id + name + } + relatedProject { + id + name + } + relatedProjectMilestone { + id + name + } +} + +query GetProjectRelation($id: String!) { + projectRelation(id: $id) { + ...ProjectRelationCoreFields + } +} + +# Every dependency in the workspace +# +# The root connection takes no filter argument, so it cannot be narrowed +# to one project — that is what GetProjectRelations is for. This is the +# whole-workspace view, and the only way to page dependencies without +# already knowing which project to ask about. +query ListAllProjectRelations($first: Int = 50, $after: String) { + projectRelations(first: $first, after: $after) { + nodes { + ...ProjectRelationCoreFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +# Both directions of a project's dependencies +# +# `relations` are the ones this project declares; `inverseRelations` are +# the ones pointing at it. A caller asking "what does this depend on" +# needs both, so they are fetched together and merged by the service. +# +# `$first` bounds each direction separately. Callers pass their own page size +# so the number is stated once, next to the code that reports the truncation. +query GetProjectRelations($projectId: String!, $first: Int = 100) { + project(id: $projectId) { + id + name + relations(first: $first) { + nodes { + ...ProjectRelationCoreFields + } + pageInfo { + hasNextPage + } + } + inverseRelations(first: $first) { + nodes { + ...ProjectRelationCoreFields + } + pageInfo { + hasNextPage + } + } + } +} diff --git a/graphql/queries/project-statuses.graphql b/graphql/queries/project-statuses.graphql new file mode 100644 index 00000000..a7981d8d --- /dev/null +++ b/graphql/queries/project-statuses.graphql @@ -0,0 +1,61 @@ +# ------------------------------------------------------------ +# GraphQL queries for the workspace project status flow +# +# Project statuses are workspace-scoped, not per-team: every project in +# the workspace draws its status from this one ordered list. +# ------------------------------------------------------------ + +fragment ProjectStatusCoreFields on ProjectStatus { + id + name + description + color + type + position + indefinite + createdAt + updatedAt + archivedAt +} + +# List the workspace's project statuses +# +# The connection takes no name filter, which is why +# `resolveProjectStatusId()` matches names client-side. +# +# `first` overrides the API's default page of 50: archived statuses keep +# accumulating, so `--include-archived` can pass 50 in a long-lived +# workspace. pageInfo is selected so a caller that hits even the raised +# bound learns the list is partial instead of reading it as complete. +query ListProjectStatuses( + $includeArchived: Boolean = false + $first: Int = 250 +) { + projectStatuses(includeArchived: $includeArchived, first: $first) { + nodes { + ...ProjectStatusCoreFields + } + pageInfo { + hasNextPage + } + } +} + +query GetProjectStatus($id: String!) { + projectStatus(id: $id) { + ...ProjectStatusCoreFields + } +} + +# How many projects currently sit in a status +# +# Folded into `projects statuses read` rather than exposed as its own +# verb: the count only means anything next to the status it describes, +# and it is what tells you whether an archive will be refused. +query GetProjectStatusProjectCount($id: String!) { + projectStatusProjectCount(id: $id) { + count + privateCount + archivedTeamCount + } +} diff --git a/graphql/queries/project-updates.graphql b/graphql/queries/project-updates.graphql new file mode 100644 index 00000000..178e4ff7 --- /dev/null +++ b/graphql/queries/project-updates.graphql @@ -0,0 +1,60 @@ +# ------------------------------------------------------------ +# GraphQL queries for Linear project status updates +# +# A project update is a dated status post on a project: a markdown +# body plus a health signal. It is a different entity from the +# `projectUpdate` mutation, which edits the project itself. +# ------------------------------------------------------------ + +fragment ProjectUpdateCoreFields on ProjectUpdate { + id + body + health + isDiffHidden + isStale + url + createdAt + updatedAt + editedAt + archivedAt + project { + id + name + } + user { + id + name + } +} + +# List the status updates posted on one project +# +# `projectUpdates` is workspace-wide, so the project is applied as a +# filter rather than traversed from the project itself. +query ListProjectUpdates( + $projectId: ID! + $first: Int = 50 + $after: String + $includeArchived: Boolean = false +) { + projectUpdates( + first: $first + after: $after + includeArchived: $includeArchived + filter: { project: { id: { eq: $projectId } } } + ) { + nodes { + ...ProjectUpdateCoreFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +query GetProjectUpdate($id: String!) { + projectUpdate(id: $id) { + ...ProjectUpdateCoreFields + } +} diff --git a/graphql/queries/projects.graphql b/graphql/queries/projects.graphql index b8d9c310..14d8237f 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -110,6 +110,15 @@ fragment ProjectDetailFields on Project { hasNextPage } } + # The project's `health` is derived from its latest status update, so the + # read that reports the health also reports where it came from. + healthUpdatedAt + lastUpdate { + id + health + body + createdAt + } } fragment ProjectDetailWithDefaultConnectionsFields on Project { @@ -214,41 +223,101 @@ query GetProjectWithReactions($id: String!, $first: Int, $after: String) { } } -# List all project statuses in the workspace +# The search fields, mirroring ProjectListFields # -# Fetches project statuses for name-to-UUID resolution. -# The API does not support filter args on this connection, -# so all statuses are fetched and filtered client-side. -query GetProjectStatuses { - projectStatuses { +# `searchProjects` returns `ProjectSearchResult`, a sibling type of +# `Project` rather than the thing itself, so the list fragment cannot be +# reused. Two differences are real: `state` (deprecated on `Project`) does +# not exist here, and `metadata` carries the search-ranking detail. +fragment ProjectSearchFields on ProjectSearchResult { + id + name + description + status { + id + name + type + } + slugId + priority + priorityLabel + health + progress + startDate + targetDate + url + metadata + lead { + id + name + } + teams(first: 25) { + nodes { + id + key + name + } + } + labels(first: 25) { nodes { id name + color } } } -# Get only a project's label IDs +# Full-text search across projects # -# Lean lookup used by `projects update --label-mode add|remove`, which -# previously fetched the full project detail (milestones + issues) just -# to read the current label set — an over-budget query on real -# workspaces (#283). +# Ranked by relevance rather than by `updatedAt`, so there is no orderBy +# knob here — asking for one would silently discard the ranking that +# makes the search worth running. # -# 250 is Linear's per-connection maximum, so this read cannot be raised -# further; hasNextPage lets the service refuse a truncated label set -# instead of writing it back as if it were complete (labelIds is a -# full-replacement input). -query GetProjectLabelIds($id: String!) { - project(id: $id) { - id - labels(first: 250) { - nodes { - id - } - pageInfo { - hasNextPage - } +# `searchProjects` is rate-limited more tightly than the plain +# `projects` connection; use `projects list` when a filter would do. +query SearchProjects( + $term: String! + $first: Int = 25 + $after: String + $includeArchived: Boolean = false + $teamId: String +) { + searchProjects( + term: $term + first: $first + after: $after + includeArchived: $includeArchived + teamId: $teamId + ) { + nodes { + ...ProjectSearchFields + } + pageInfo { + hasNextPage + endCursor + } + } +} + +# List all project statuses in the workspace +# +# Fetches project statuses for name-to-UUID resolution. +# The API does not support filter args on this connection, +# so all statuses are fetched and filtered client-side. +# archivedAt is selected so that an ambiguity between an archived status +# and a live one of the same name can be spelled out to the caller. +# `first` raises the API's default page of 50, which archived statuses can +# outgrow; pageInfo lets the resolver refuse rather than report a name it +# simply did not fetch as missing. +query GetProjectStatuses($includeArchived: Boolean = false, $first: Int = 250) { + projectStatuses(includeArchived: $includeArchived, first: $first) { + nodes { + id + name + archivedAt + } + pageInfo { + hasNextPage } } } diff --git a/knip.json b/knip.json index 39a1645f..1a4ae2e8 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,6 @@ "ignoreDependencies": [ "@semantic-release/github", "@semantic-release/npm", - "@semantic-release/release-notes-generator" + "conventional-changelog-conventionalcommits" ] } diff --git a/package-lock.json b/package-lock.json index 0f0f3188..698c66db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.7.0", + "version": "2026.8.0-next.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.7.0", + "version": "2026.8.0-next.2", "license": "MIT", "dependencies": { "commander": "15.0.0", @@ -24,10 +24,10 @@ "@graphql-codegen/cli": "^7.0.0", "@graphql-codegen/client-preset": "^6.0.0", "@graphql-typed-document-node/core": "3.2.0", - "@semantic-release/changelog": "^6.0.3", + "@semantic-release/changelog": "^7.0.0", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", - "@semantic-release/git": "^10.0.1", + "@semantic-release/git": "^11.0.0", "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.0.0", "@semantic-release/release-notes-generator": "^14.1.0", @@ -35,6 +35,7 @@ "@vitest/coverage-v8": "^4.0.0", "@vitest/ui": "^4.0.0", "clean-publish": "^7.0.0", + "conventional-changelog-conventionalcommits": "9.3.1", "knip": "^6.24.0", "lefthook": "^2.1.0", "semantic-release": "^25.0.1", @@ -424,9 +425,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.6.tgz", - "integrity": "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz", + "integrity": "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -440,20 +441,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.6", - "@biomejs/cli-darwin-x64": "2.5.6", - "@biomejs/cli-linux-arm64": "2.5.6", - "@biomejs/cli-linux-arm64-musl": "2.5.6", - "@biomejs/cli-linux-x64": "2.5.6", - "@biomejs/cli-linux-x64-musl": "2.5.6", - "@biomejs/cli-win32-arm64": "2.5.6", - "@biomejs/cli-win32-x64": "2.5.6" + "@biomejs/cli-darwin-arm64": "2.5.7", + "@biomejs/cli-darwin-x64": "2.5.7", + "@biomejs/cli-linux-arm64": "2.5.7", + "@biomejs/cli-linux-arm64-musl": "2.5.7", + "@biomejs/cli-linux-x64": "2.5.7", + "@biomejs/cli-linux-x64-musl": "2.5.7", + "@biomejs/cli-win32-arm64": "2.5.7", + "@biomejs/cli-win32-x64": "2.5.7" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz", + "integrity": "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==", "cpu": [ "arm64" ], @@ -468,9 +469,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.6.tgz", - "integrity": "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz", + "integrity": "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==", "cpu": [ "x64" ], @@ -485,9 +486,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.6.tgz", - "integrity": "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz", + "integrity": "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==", "cpu": [ "arm64" ], @@ -505,9 +506,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz", + "integrity": "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==", "cpu": [ "arm64" ], @@ -525,9 +526,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.6.tgz", - "integrity": "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz", + "integrity": "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==", "cpu": [ "x64" ], @@ -545,9 +546,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz", + "integrity": "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==", "cpu": [ "x64" ], @@ -565,9 +566,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.6.tgz", - "integrity": "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz", + "integrity": "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==", "cpu": [ "arm64" ], @@ -582,9 +583,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.6.tgz", - "integrity": "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz", + "integrity": "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==", "cpu": [ "x64" ], @@ -646,6 +647,19 @@ "node": ">=22.12.0" } }, + "node_modules/@commitlint/config-conventional/node_modules/conventional-changelog-conventionalcommits": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.1.tgz", + "integrity": "sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@conventional-changelog/template": "^1.2.1" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@commitlint/config-validator": { "version": "21.2.0", "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.2.0.tgz", @@ -1000,9 +1014,9 @@ } }, "node_modules/@conventional-changelog/template": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.0.tgz", - "integrity": "sha512-12qHxvlKjHmP0PQ+17EREgC7lWyLwbph1RKcZQZ7k7ZWGmrxfxC9gadHGfvzr0g0u8BhiBGg3tks93txodlyRQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.2.1.tgz", + "integrity": "sha512-TzlTVpKPjaqW6qOYjQcYUDuGsLCNsvFHVBXkYGTAnf5V37jCWrE5haKNXzz0WZUtVHjrpV76L1buANjwXMfT8w==", "dev": true, "license": "MIT", "engines": { @@ -1630,9 +1644,9 @@ } }, "node_modules/@graphql-codegen/client-preset": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-6.1.0.tgz", - "integrity": "sha512-mGmBuwrOU5oRoaWFodx8g9xu1jecYIiydqvk88QsAIsyMcZwuoybs1lyne85TovpBHjH5CC2wnZGsbDQfcgOCQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-6.1.1.tgz", + "integrity": "sha512-OpVkYpz6f7jAiVaOZZ3Mt5XTCRCdXgNSiMIfZfUCv8fUknqnd2IsD3VFCyjrkNi71J1PAwmwdJbkxDhdL23fjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1643,8 +1657,8 @@ "@graphql-codegen/plugin-helpers": "^7.1.0", "@graphql-codegen/typed-document-node": "^7.1.0", "@graphql-codegen/typescript": "^6.1.0", - "@graphql-codegen/typescript-operations": "^6.1.0", - "@graphql-codegen/visitor-plugin-common": "^7.2.0", + "@graphql-codegen/typescript-operations": "^6.1.3", + "@graphql-codegen/visitor-plugin-common": "^7.2.3", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^11.2.0", "@graphql-typed-document-node/core": "3.2.0", @@ -1781,15 +1795,15 @@ } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-6.1.2.tgz", - "integrity": "sha512-EP9xry09q4cOVaf/aC4NO3/SwvXRNzlJIe4dhfA0xyy45Taix5yDL3jeJpmJIv03sa7nK5udVs5vZqdHFU8Xmw==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-6.1.5.tgz", + "integrity": "sha512-ZiQ2CB6jiYYxFetdrutSsbNsiukh47UbVY9y3NjwWI8IUlslD+rDYN7MLDJrPFA5YXpv38ZBxh6q8PywRf1KfA==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^7.1.0", "@graphql-codegen/schema-ast": "^6.1.0", - "@graphql-codegen/visitor-plugin-common": "^7.2.2", + "@graphql-codegen/visitor-plugin-common": "^7.2.3", "auto-bind": "^5.0.0", "tslib": "^2.8.0" }, @@ -1807,9 +1821,9 @@ } }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-7.2.2.tgz", - "integrity": "sha512-nOkAVd8J8r2YdHm9Z4YrBiy+3IQgE8Ndn/EiRWTvWuemMEhioBWyvdlnbq5rHmIo2bNdRQ5ghs0Q3jw7YBrwLQ==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-7.2.3.tgz", + "integrity": "sha512-uP7lF8vAbOLefjrkLyz85dcDTfOkw0TV1xY+sFFvVK6cguTjWIokxxwWRgEeKaghWyOMvyFd6WXN4kqQxdVIiQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3941,22 +3955,67 @@ "license": "MIT" }, "node_modules/@semantic-release/changelog": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-6.0.3.tgz", - "integrity": "sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/changelog/-/changelog-7.0.0.tgz", + "integrity": "sha512-TNPyag5db24o7jWjre7UwKB4EcL8oJxbRhnDQ7hmZRAYqzreAc6PgdxQuU3pppp5xQinYtiumL0iG8SSKvnlzg==", "dev": true, "license": "MIT", "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.0.0", - "fs-extra": "^11.0.0", - "lodash": "^4.17.4" + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "lodash-es": "^4.17.21" }, "engines": { - "node": ">=14.17" + "node": "^22.22.2 || >=24.15" }, "peerDependencies": { - "semantic-release": ">=18.0.0" + "semantic-release": ">=20.1.0" + } + }, + "node_modules/@semantic-release/changelog/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/changelog/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/changelog/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@semantic-release/commit-analyzer": { @@ -3983,13 +4042,13 @@ } }, "node_modules/@semantic-release/error": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-3.0.0.tgz", - "integrity": "sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", + "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.17" + "node": ">=18" } }, "node_modules/@semantic-release/exec": { @@ -4013,16 +4072,6 @@ "semantic-release": ">=24.1.0" } }, - "node_modules/@semantic-release/exec/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@semantic-release/exec/node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -4067,46 +4116,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/exec/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@semantic-release/exec/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/exec/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/exec/node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -4125,25 +4134,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/exec/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "node_modules/@semantic-release/git": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-11.0.1.tgz", + "integrity": "sha512-Zr8BUYCTZMc8V6wDKN2dpR7nJgewd9I6THL3ydLTnp3OEdTo1/4RBLNYaeRucYMsjMv+BXoCNfXA0NADj1kwhw==", "dev": true, "license": "MIT", + "dependencies": { + "@semantic-release/error": "^4.0.0", + "aggregate-error": "^5.0.0", + "debug": "^4.0.0", + "dir-glob": "^3.0.0", + "execa": "^10.0.0", + "lodash-es": "^4.17.21", + "micromatch": "^4.0.0", + "p-reduce": "^3.0.0" + }, "engines": { - "node": ">=12" + "node": "^22.22.2 || >=24.15" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/exec/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "node_modules/@semantic-release/git/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", "dev": true, "license": "MIT", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, "engines": { "node": ">=18" }, @@ -4151,40 +4174,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/exec/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "node_modules/@semantic-release/git/node_modules/clean-stack": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.3.0.tgz", + "integrity": "sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==", "dev": true, "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, "engines": { - "node": ">=18" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/git": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", - "integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==", + "node_modules/@semantic-release/git/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, "license": "MIT", - "dependencies": { - "@semantic-release/error": "^3.0.0", - "aggregate-error": "^3.0.0", - "debug": "^4.0.0", - "dir-glob": "^3.0.0", - "execa": "^5.0.0", - "lodash": "^4.17.4", - "micromatch": "^4.0.0", - "p-reduce": "^2.0.0" - }, "engines": { - "node": ">=14.17" + "node": ">=12" }, - "peerDependencies": { - "semantic-release": ">=18.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@semantic-release/github": { @@ -4219,16 +4235,6 @@ "semantic-release": ">=24.1.0" } }, - "node_modules/@semantic-release/github/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@semantic-release/github/node_modules/aggregate-error": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", @@ -4305,16 +4311,6 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/npm/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@semantic-release/npm/node_modules/aggregate-error": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", @@ -4405,16 +4401,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@semantic-release/npm/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/@semantic-release/npm/node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -4428,19 +4414,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/npm/node_modules/lru-cache": { "version": "11.3.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", @@ -4466,23 +4439,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@semantic-release/npm/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/npm/node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -4501,19 +4457,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/npm/node_modules/read-pkg": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", @@ -4563,32 +4506,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", @@ -6021,16 +5938,16 @@ } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.0.tgz", - "integrity": "sha512-UtlM9GqolY7OmlQh5L/UEVoKsTUpTgUVy1PU8JN5gl5Ydaejb7WRklGliG1SKPxxj7hzA173eG3Kt5fYWE2pmg==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", + "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", "dev": true, "license": "ISC", "dependencies": { - "@conventional-changelog/template": "^1.2.0" + "compare-func": "^2.0.0" }, "engines": { - "node": ">=22" + "node": ">=18" } }, "node_modules/conventional-changelog-writer": { @@ -6622,62 +6539,49 @@ "license": "MIT" }, "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-10.0.1.tgz", + "integrity": "sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "@sindresorhus/merge-streams": "^4.0.0", + "figures": "^6.1.0", + "get-stream": "^9.0.1", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.3.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "which-command": "^0.1.0", + "yoctocolors": "^2.1.2" }, "engines": { - "node": ">=10" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/execa/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" }, "engines": { - "node": ">=6" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -7040,9 +6944,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", "dev": true, "license": "MIT", "dependencies": { @@ -7380,13 +7284,13 @@ } }, "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=10.17.0" + "node": ">=18.18.0" } }, "node_modules/iconv-lite": { @@ -7639,13 +7543,13 @@ } }, "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7905,9 +7809,9 @@ } }, "node_modules/knip": { - "version": "6.31.0", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.31.0.tgz", - "integrity": "sha512-NbeIEmUS2VUMjAkbiSNOKPJeV9wpCsr0660sUyKyMQbk4Iom0++nTLInVp4MJ+LfR4kORnw67bDi5tvO7YLnzA==", + "version": "6.32.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.32.0.tgz", + "integrity": "sha512-KDX9OmmOFmlvmxTkrx6Z0GHISMut+pXMSKR8eg84bovaxJKx2NdQD4JYCXveSbvieRe107W6vCD2xCpmz0qBYA==", "dev": true, "funding": [ { @@ -7923,7 +7827,7 @@ "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", - "get-tsconfig": "4.14.0", + "get-tsconfig": "4.14.1", "jiti": "^2.7.0", "oxc-parser": "^0.142.0", "oxc-resolver": "11.24.2", @@ -8508,13 +8412,6 @@ "node": ">=4" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -9217,16 +9114,46 @@ } }, "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/npm/node_modules/@gar/promise-retry": { @@ -11188,13 +11115,16 @@ } }, "node_modules/p-reduce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-2.1.0.tgz", - "integrity": "sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", + "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-timeout": { @@ -11785,9 +11715,9 @@ "license": "MIT" }, "node_modules/semantic-release": { - "version": "25.0.8", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.8.tgz", - "integrity": "sha512-w/iZ0bur36rKffXZYmIUmy068eoBY3Ij1DCCddx2JwWEM5Tg+eU9ld/E9qSInVvPASyyR2Ln/XGfQ9OZrMlhtw==", + "version": "25.0.9", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.9.tgz", + "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", "dev": true, "license": "MIT", "dependencies": { @@ -11827,16 +11757,6 @@ "node": "^22.14.0 || >= 24.10.0" } }, - "node_modules/semantic-release/node_modules/@semantic-release/error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@semantic-release/error/-/error-4.0.0.tgz", - "integrity": "sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/semantic-release/node_modules/aggregate-error": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", @@ -11927,16 +11847,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/semantic-release/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/semantic-release/node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -11950,19 +11860,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -11988,36 +11885,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/semantic-release/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/p-reduce": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", - "integrity": "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -12049,19 +11916,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/read-package-up": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", @@ -12113,19 +11967,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/type-fest": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", @@ -12142,19 +11983,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -12613,13 +12441,16 @@ } }, "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { @@ -12958,9 +12789,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz", - "integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==", + "version": "4.23.10", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.10.tgz", + "integrity": "sha512-0Vb9eKU47njkxv/6B8CRZRDsxNDT/Pz+BIU+M5jw7xL3TdzAjSxlZUxu0xFL/kLpaG3sHZ0LH2wbK1T1yo7CUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13440,6 +13271,22 @@ "node": ">= 8" } }, + "node_modules/which-command": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/which-command/-/which-command-0.1.0.tgz", + "integrity": "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==", + "dev": true, + "license": "MIT", + "bin": { + "which-command": "cli.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/which-command?sponsor=1" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index aaacb287..df56cafd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.7.0", + "version": "2026.8.0-next.2", "description": "CLI tool for Linear.app with JSON output, smart ID resolution, and optimized GraphQL queries. Designed for LLM agents and humans who prefer structured data.", "main": "dist/main.js", "type": "module", @@ -10,6 +10,7 @@ }, "files": [ "dist/", + "schemas/", "README.md", "LICENSE.md", "USAGE.md" @@ -35,6 +36,7 @@ "check:ci": "biome check .", "knip": "knip --no-config-hints", "knip:ci": "knip --no-config-hints --reporter markdown", + "count:root-fields": "node scripts/count-root-fields.mjs", "verify:packed-binaries": "node scripts/verify-packed-binaries.mjs", "release": "npm test && npm run build && npm run verify:packed-binaries && rm -rf .clean-pkg && npx clean-publish --without-publish --temp-dir .clean-pkg && npm publish ./.clean-pkg --access public && rm -rf .clean-pkg", "prestart": "npm run generate", @@ -81,10 +83,10 @@ "@graphql-codegen/cli": "^7.0.0", "@graphql-codegen/client-preset": "^6.0.0", "@graphql-typed-document-node/core": "3.2.0", - "@semantic-release/changelog": "^6.0.3", + "@semantic-release/changelog": "^7.0.0", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", - "@semantic-release/git": "^10.0.1", + "@semantic-release/git": "^11.0.0", "@semantic-release/github": "^12.0.0", "@semantic-release/npm": "^13.0.0", "@semantic-release/release-notes-generator": "^14.1.0", @@ -92,6 +94,7 @@ "@vitest/coverage-v8": "^4.0.0", "@vitest/ui": "^4.0.0", "clean-publish": "^7.0.0", + "conventional-changelog-conventionalcommits": "9.3.1", "knip": "^6.24.0", "lefthook": "^2.1.0", "semantic-release": "^25.0.1", diff --git a/renovate.json b/renovate.json index f2f81624..06ee019b 100644 --- a/renovate.json +++ b/renovate.json @@ -22,6 +22,11 @@ "matchUpdateTypes": ["minor", "patch"], "groupName": "dev dependencies (non-major)" }, + { + "description": "Hold conventional-changelog-conventionalcommits on the 9.x line: v10 moved to the @conventional-changelog/writer@2 API (function template/commitPartial), which @semantic-release/release-notes-generator's Handlebars writer@8 cannot render. The result is silently empty release notes, not a build failure. Lift once release-notes-generator ships writer@2 support.", + "matchPackageNames": ["conventional-changelog-conventionalcommits"], + "allowedVersions": "<10" + }, { "description": "Group GitHub Actions updates (SHA-pinned actions + docker digests)", "matchManagers": ["github-actions"], diff --git a/schemas/issues-batch-create.schema.json b/schemas/issues-batch-create.schema.json new file mode 100644 index 00000000..1b3230e3 --- /dev/null +++ b/schemas/issues-batch-create.schema.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-create.schema.json", + "title": "linearis issues batch create document", + "description": "Input document for `linearis issues batch create --file ` (or `--json `). A JSON array of issues to create in a single transaction. The keys mirror the `issues create` flags with the leading dashes dropped; unknown keys are rejected rather than ignored.", + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/entry" }, + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "entry": { + "type": "object", + "additionalProperties": false, + "required": ["title", "team"], + "dependentRequired": { + "projectMilestone": ["project"] + }, + "properties": { + "title": { + "$ref": "#/$defs/nonEmptyString", + "description": "Issue title." + }, + "team": { + "$ref": "#/$defs/nonEmptyString", + "description": "Team key (ENG), team name, or team UUID. Every entry names its own team." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Issue description in Markdown." + }, + "assignee": { + "$ref": "#/$defs/nonEmptyString", + "description": "Assignee as display name, email, UUID, or `me`." + }, + "priority": { + "type": "integer", + "minimum": 1, + "maximum": 4, + "description": "1=urgent, 2=high, 3=medium, 4=low. Omit for no priority." + }, + "estimate": { + "type": "integer", + "minimum": 0, + "description": "Estimate points. Validated against the team's estimation scale (fibonacci, exponential, linear, or t-shirt sizes), so the accepted values depend on the team." + }, + "project": { + "$ref": "#/$defs/nonEmptyString", + "description": "Project name or UUID." + }, + "projectMilestone": { + "$ref": "#/$defs/nonEmptyString", + "description": "Milestone name or UUID. Milestones are scoped by project, so `project` must be set as well." + }, + "cycle": { + "$ref": "#/$defs/nonEmptyString", + "description": "Cycle number, name, `current`/`next`/`previous`, or UUID." + }, + "status": { + "$ref": "#/$defs/nonEmptyString", + "description": "Workflow state name (Todo, In Progress, ...) or UUID, resolved within the entry's team." + }, + "parentTicket": { + "$ref": "#/$defs/nonEmptyString", + "description": "Parent issue as identifier (ABC-123) or UUID." + }, + "labels": { + "description": "Label names or UUIDs, either as an array or as a comma-separated string.", + "oneOf": [ + { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/nonEmptyString" } + }, + { "$ref": "#/$defs/nonEmptyString" } + ] + }, + "subscribers": { + "description": "Users to subscribe on creation, each a display name, email, UUID, or `me`. Either an array or a comma-separated string.", + "oneOf": [ + { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/nonEmptyString" } + }, + { "$ref": "#/$defs/nonEmptyString" } + ] + }, + "delegate": { + "$ref": "#/$defs/nonEmptyString", + "description": "Delegate as display name, email, UUID, or `me`. A delegate acts for the assignee." + }, + "dueDate": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "Due date as YYYY-MM-DD. The date must exist in the calendar." + } + } + } + }, + "examples": [ + [ + { + "title": "Fix login redirect loop", + "team": "ENG", + "description": "Users bounce between /login and /dashboard after SSO.", + "assignee": "alice", + "priority": 1, + "labels": ["bug", "auth"], + "dueDate": "2026-09-01" + }, + { + "title": "Document the SSO flow", + "team": "ENG", + "project": "Q3 Auth", + "projectMilestone": "Beta", + "status": "Todo", + "estimate": 2, + "subscribers": ["bob", "carol@example.com"], + "delegate": "me" + } + ] + ] +} diff --git a/schemas/issues-batch-update.schema.json b/schemas/issues-batch-update.schema.json new file mode 100644 index 00000000..7943c004 --- /dev/null +++ b/schemas/issues-batch-update.schema.json @@ -0,0 +1,123 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-update.schema.json", + "title": "linearis issues batch update document", + "description": "Input document for `linearis issues batch update --file ` (or `--json `). One patch applied to an explicit list of issues in a single transaction. The patch keys mirror the `issues update` flags with the leading dashes dropped; `null` clears a field, the way the `--clear-*` flags do. Unknown keys are rejected rather than ignored.", + "type": "object", + "additionalProperties": false, + "required": ["issues", "patch"], + "properties": { + "issues": { + "description": "Issues to patch, each an identifier (ABC-123) or UUID. Either an array or a comma-separated string.", + "$ref": "#/$defs/stringList" + }, + "patch": { "$ref": "#/$defs/patch" } + }, + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "stringList": { + "oneOf": [ + { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/nonEmptyString" } + }, + { "$ref": "#/$defs/nonEmptyString" } + ] + }, + "nullableString": { + "oneOf": [{ "$ref": "#/$defs/nonEmptyString" }, { "type": "null" }] + }, + "patch": { + "description": "The fields to change. Every listed issue receives this same patch, so a status, cycle or label named by word requires all targets to share one team — pass a UUID otherwise.", + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "dependentSchemas": { + "projectMilestone": { + "anyOf": [ + { "properties": { "projectMilestone": { "type": "null" } } }, + { + "required": ["project"], + "properties": { "project": { "type": "string" } } + } + ] + } + }, + "properties": { + "title": { + "$ref": "#/$defs/nonEmptyString", + "description": "New title for every listed issue." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "New description in Markdown, replacing the current one." + }, + "status": { + "$ref": "#/$defs/nonEmptyString", + "description": "Workflow state name (Todo, In Progress, ...) or UUID. A name resolves within the targets' single team." + }, + "priority": { + "type": "integer", + "minimum": 1, + "maximum": 4, + "description": "1=urgent, 2=high, 3=medium, 4=low." + }, + "estimate": { + "oneOf": [{ "type": "integer", "minimum": 0 }, { "type": "null" }], + "description": "Estimate points, or null to clear. Validated against the estimation scale of every team the batch spans, so the accepted values depend on those teams." + }, + "assignee": { + "$ref": "#/$defs/nullableString", + "description": "Assignee as display name, email, UUID, or `me`. Null unassigns." + }, + "project": { + "$ref": "#/$defs/nullableString", + "description": "Project name or UUID. Null removes the issues from their project, and their project milestone with it." + }, + "labels": { + "description": "Label names or UUIDs, either as an array or as a comma-separated string. Overwrites the current labels; null removes them all. Named labels may be team-scoped, so they require all targets to share one team.", + "oneOf": [{ "$ref": "#/$defs/stringList" }, { "type": "null" }] + }, + "parentTicket": { + "$ref": "#/$defs/nullableString", + "description": "Parent issue as identifier (ABC-123) or UUID. Null detaches from the parent." + }, + "projectMilestone": { + "$ref": "#/$defs/nullableString", + "description": "Milestone name or UUID. Milestones are scoped by project, so setting one requires `project` as well. Null detaches from the milestone." + }, + "cycle": { + "$ref": "#/$defs/nullableString", + "description": "Cycle number, name, `current`/`next`/`previous`, or UUID. Null removes the issues from their cycle." + }, + "dueDate": { + "oneOf": [ + { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + { "type": "null" } + ], + "description": "Due date as YYYY-MM-DD, or null to clear. The date must exist in the calendar." + } + } + } + }, + "examples": [ + { + "issues": ["ENG-42", "ENG-43", "ENG-44"], + "patch": { + "status": "In Progress", + "assignee": "alice", + "priority": 2, + "cycle": "current", + "dueDate": null + } + } + ] +} diff --git a/scripts/count-root-fields.mjs b/scripts/count-root-fields.mjs new file mode 100644 index 00000000..60102b91 --- /dev/null +++ b/scripts/count-root-fields.mjs @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/** + * Counts the Linear root fields this CLI wires. + * + * The README's coverage section quotes a number ("wires N of them"). Before + * this script that number was asserted by hand and drifted — it read 83 while + * the documents held 81. Run this and paste the result rather than guessing. + * + * A "wired root field" is a top-level selection inside an operation in + * `graphql/{queries,mutations}/*.graphql`. Fragments are skipped; a field + * selected in several operations counts once. Note that a handful of names — + * `projectUpdate`, for one — exist as both a query and a mutation, so the + * operation count `--verify` reports is slightly higher than the name count. + * + * node scripts/count-root-fields.mjs # the count and the list + * node scripts/count-root-fields.mjs --json # machine-readable + * node scripts/count-root-fields.mjs --verify # also check against the live + * # schema (needs network; the + * # endpoint allows anonymous + * # introspection) + * + * `--verify` reports the API-wide totals the README also quotes, and flags any + * name that is not actually a root field — which would mean this parser has + * mistaken a nested selection for one. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const graphqlRoot = path.join(repoRoot, "graphql"); + +/** Strip comments and string literals so braces and names parse cleanly. */ +function stripNoise(source) { + return source + .replace(/"""[\s\S]*?"""/g, " ") + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + .replace(/#[^\n]*/g, ""); +} + +/** + * Extract the top-level selections of every operation in one document. + * + * Walks brace depth rather than using a GraphQL parser so the script stays + * dependency-free: names at depth 1 inside an operation body are root fields. + */ +function rootFieldsIn(source) { + const text = stripNoise(source); + const found = new Set(); + const operation = /\b(query|mutation)\b[^{]*\{/g; + + let match = operation.exec(text); + while (match !== null) { + let depth = 1; + let index = match.index + match[0].length; + let expectFieldName = true; + + while (index < text.length && depth > 0) { + const char = text[index]; + + if (char === "{") { + depth += 1; + expectFieldName = false; + index += 1; + continue; + } + + if (char === "}") { + depth -= 1; + expectFieldName = depth === 1; + index += 1; + continue; + } + + if (char === "(") { + // Skip the argument list wholesale; nothing in it is a root field. + let parens = 1; + index += 1; + while (index < text.length && parens > 0) { + if (text[index] === "(") parens += 1; + if (text[index] === ")") parens -= 1; + index += 1; + } + expectFieldName = false; + continue; + } + + const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(index)); + if (name) { + if (depth === 1 && expectFieldName) { + // `alias: field` — the name before the colon is the caller's label, + // not a root field. Leave expectFieldName set so the real one lands. + const isAlias = /^\s*:/.test(text.slice(index + name[0].length)); + if (!isAlias) { + found.add(name[0]); + expectFieldName = false; + } + } + index += name[0].length; + continue; + } + + if (char === "\n" || char === ",") { + // A newline or comma at depth 1 ends one selection and starts the next. + if (depth === 1) expectFieldName = true; + } else if (char === ":") { + // An alias: the name after the colon is the real field. + if (depth === 1) expectFieldName = true; + } + + index += 1; + } + + operation.lastIndex = index; + match = operation.exec(text); + } + + return found; +} + +const documents = ["queries", "mutations"].flatMap((kind) => { + const dir = path.join(graphqlRoot, kind); + return fs + .readdirSync(dir) + .filter((file) => file.endsWith(".graphql")) + .map((file) => path.join(dir, file)); +}); + +const byField = new Map(); +for (const file of documents) { + const relative = path.relative(repoRoot, file); + for (const field of rootFieldsIn(fs.readFileSync(file, "utf8"))) { + const sources = byField.get(field) ?? []; + sources.push(relative); + byField.set(field, sources); + } +} + +const fields = [...byField.keys()].sort(); + +const ENDPOINT = "https://api.linear.app/graphql"; + +async function introspectRootFields() { + const response = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: + 'query { q: __type(name: "Query") { fields { name } } m: __type(name: "Mutation") { fields { name } } }', + }), + }); + + if (!response.ok) { + throw new Error(`introspection failed: HTTP ${response.status}`); + } + + const { data, errors } = await response.json(); + if (errors) { + throw new Error(`introspection failed: ${JSON.stringify(errors)}`); + } + + return { + queries: data.q.fields.map((field) => field.name), + mutations: data.m.fields.map((field) => field.name), + }; +} + +const verify = process.argv.includes("--verify"); +const schema = verify ? await introspectRootFields() : null; + +let report = null; +if (schema) { + const wired = new Set(fields); + const queries = schema.queries.filter((name) => wired.has(name)); + const mutations = schema.mutations.filter((name) => wired.has(name)); + const known = new Set([...schema.queries, ...schema.mutations]); + + report = { + wired: queries.length + mutations.length, + queries: { wired: queries.length, total: schema.queries.length }, + mutations: { wired: mutations.length, total: schema.mutations.length }, + total: schema.queries.length + schema.mutations.length, + // A name like `projectUpdate` is both a query and a mutation, so the + // operation count exceeds the count of distinct names. + sharedNames: queries.filter((name) => mutations.includes(name)), + notRootFields: fields.filter((name) => !known.has(name)), + }; +} + +if (process.argv.includes("--json")) { + console.log( + JSON.stringify( + { count: fields.length, fields, ...(report && { report }) }, + null, + 2, + ), + ); +} else { + for (const field of fields) { + console.log(field); + } + console.log(`\n${fields.length} distinct root field names wired`); + + if (report) { + console.log( + `${report.wired} of ${report.total} root operations ` + + `(${report.queries.wired}/${report.queries.total} queries, ` + + `${report.mutations.wired}/${report.mutations.total} mutations)`, + ); + + if (report.wired !== fields.length) { + console.log( + `${report.wired - fields.length} more operations than names: ` + + "some names exist as both a query and a mutation " + + `(${report.sharedNames.join(", ")})`, + ); + } + + if (report.notRootFields.length > 0) { + console.log( + `\nnot root fields — this parser is wrong about: ${report.notRootFields.join(", ")}`, + ); + process.exitCode = 1; + } + } +} diff --git a/src/commands/attachments.ts b/src/commands/attachments.ts index 14c9ab2e..81b87147 100644 --- a/src/commands/attachments.ts +++ b/src/commands/attachments.ts @@ -10,6 +10,7 @@ import { type CreateAttachmentInput, createAttachment, deleteAttachment, + disableExternalSync, listAttachments, } from "../services/attachment-service.js"; @@ -22,12 +23,14 @@ export const ATTACHMENTS_META: DomainMeta = { "title, subtitle, sourceType (e.g. 'github', 'slack'), and metadata", "with integration-specific data. creating an attachment with the same", "url on the same issue updates the existing record (idempotent).", + "attachments created by an integration can keep the issue in sync with", + "the external resource; `disable-sync` stops that for one attachment.", ].join("\n"), arguments: { issue: "issue identifier (UUID or ABC-123)", id: "attachment UUID", }, - seeAlso: ["issues read --with-attachments"], + seeAlso: ["issues read --with-attachments", "attachments disable-sync "], }; interface ListOptions { @@ -142,6 +145,22 @@ export function setupAttachmentsCommands(program: Command): void { }), ); + attachments + .command("disable-sync ") + .description("stop syncing the issue with an attachment's external source") + .addHelpText( + "after", + "\nKeyed by attachment, not by issue: an issue can carry several synced attachments and they are disabled one at a time.", + ) + .action( + handleCommand(async (...args: unknown[]) => { + const [id, , command] = args as [string, unknown, Command]; + const ctx = createContext(getRootOpts(command)); + const result = await disableExternalSync(ctx.gql, asUuid(id)); + outputSuccess(result); + }), + ); + attachments .command("usage") .description("show detailed usage for attachments") diff --git a/src/commands/initiatives/updates.ts b/src/commands/initiatives/updates.ts index 96d16504..76c81e18 100644 --- a/src/commands/initiatives/updates.ts +++ b/src/commands/initiatives/updates.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { createContext, getRootOpts } from "../../common/context.js"; +import { parseHealth } from "../../common/domain-values.js"; import { invalidParameterError } from "../../common/errors.js"; import { asUuid } from "../../common/identifier.js"; import { @@ -15,7 +16,6 @@ import { createInitiativeUpdate, getInitiativeUpdate, listInitiativeUpdates, - parseHealth, type UpdateInitiativeUpdateInput, unarchiveInitiativeUpdate, updateInitiativeUpdate, diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts new file mode 100644 index 00000000..a7c853a2 --- /dev/null +++ b/src/commands/issues-batch.ts @@ -0,0 +1,1122 @@ +import { readFileSync } from "node:fs"; +import type { Command } from "commander"; +import type { GraphQLClient } from "../client/graphql-client.js"; +import { createContext, getRootOpts } from "../common/context.js"; +import { + invalidParameterError, + requiresParameterError, +} from "../common/errors.js"; +import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; +import { isUuid, parseDueDate, type UUID } from "../common/identifier.js"; +import { parseCommaSeparated } from "../common/issue-filter.js"; +import { + parseEstimateOption, + parsePriorityOption, +} from "../common/number-options.js"; +import { commandAction, outputSuccess } from "../common/output.js"; +import { + type ResolveCreateIssueIdsInput, + type ResolvedCreateIssueIds, + type ResolveUpdateIssueIdsInput, + resolveBatchCreateIssueIds, + resolveUpdateIssueIds, + type UpdateIssueContext, +} from "../resolvers/issue-mutation-resolver.js"; +import { + type ResolvedIssueRef, + resolveIssueRefs, +} from "../resolvers/issue-resolver.js"; +import { resolveTeamEstimateContext } from "../resolvers/team-resolver.js"; +import { + batchCreateIssues, + batchUpdateIssues, + type CreateIssueInput, + type UpdateIssueInput, +} from "../services/issue-service.js"; + +/** + * `issues batch create` / `issues batch update`. + * + * Kept beside `issues.ts` rather than inside it: the batch subgroup carries its + * own input format (a JSON document rather than flags) and its own + * single-team constraint, and `issues.ts` is already the largest command file + * in the project. + */ + +/** + * One entry of a `batch create` document. + * + * The keys are deliberately the single-issue flag names with the leading + * dashes dropped, so a caller who knows `issues create` already knows this + * format and there is no second schema to learn. + */ +interface BatchCreateEntry { + title: string; + team: string; + description?: string; + assignee?: string; + priority?: number; + estimate?: number; + project?: string; + labels?: string[]; + projectMilestone?: string; + cycle?: string; + status?: string; + parentTicket?: string; + dueDate?: string; + subscribers?: string[]; + delegate?: string; +} + +/** The two ways every batch command takes its JSON document. */ +interface DocumentOptions { + file?: string; + json?: string; +} + +type BatchCreateOptions = DocumentOptions; + +interface BatchUpdateOptions extends DocumentOptions { + issues?: string; + title?: string; + description?: string; + status?: string; + priority?: string; + estimate?: string; + clearEstimate?: boolean; + assignee?: string; + clearAssignee?: boolean; + project?: string; + clearProject?: boolean; + labels?: string; + clearLabels?: boolean; + parentTicket?: string; + clearParentTicket?: boolean; + projectMilestone?: string; + clearProjectMilestone?: boolean; + cycle?: string; + clearCycle?: boolean; + dueDate?: string; + clearDueDate?: boolean; +} + +/** + * A `batch update` patch in normalised form, shared by both input paths. + * + * `undefined` leaves a field alone and `null` clears it — the distinction the + * flags draw with their `--clear-*` pairs and the document draws with a JSON + * `null`. Both forms are normalised into this one shape so the guard rails + * below (team scoping, estimate validation, the built mutation input) have a + * single thing to reason about. + */ +export interface BatchUpdatePatch { + title?: string; + description?: string; + status?: string; + priority?: number; + estimate?: number | null; + assignee?: string | null; + project?: string | null; + labels?: string[] | null; + parentTicket?: string | null; + projectMilestone?: string | null; + cycle?: string | null; + dueDate?: string | null; +} + +/** A parsed `batch update` request: the targets and the one patch they share. */ +interface BatchUpdateRequest { + issues: string[]; + patch: BatchUpdatePatch; +} + +/** + * Where the published copies of the batch schemas live. + * + * Pinned to the raw files on the default branch rather than a tag: a schema + * tracks the parser below, and a caller validating against it wants the + * contract of the CLI they will actually run, not the one at release time. + */ +const SCHEMA_BASE_URL = + "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas"; +const BATCH_CREATE_SCHEMA_URL = `${SCHEMA_BASE_URL}/issues-batch-create.schema.json`; +const BATCH_UPDATE_SCHEMA_URL = `${SCHEMA_BASE_URL}/issues-batch-update.schema.json`; + +/** + * Exported so the schema drift test can assert that + * `schemas/issues-batch-create.schema.json` still describes exactly the keys + * this parser accepts — the schema is what callers write against, so the two + * silently diverging is worse than either being wrong on its own. + */ +export const KNOWN_ENTRY_KEYS: ReadonlySet = new Set([ + "title", + "team", + "description", + "assignee", + "priority", + "estimate", + "project", + "labels", + "projectMilestone", + "cycle", + "status", + "parentTicket", + "dueDate", + "subscribers", + "delegate", +]); + +/** + * Keys a `batch update` patch may carry, mirroring the update flags. + * + * Exported for the same reason as {@link KNOWN_ENTRY_KEYS}: the schema drift + * test asserts that `schemas/issues-batch-update.schema.json` describes exactly + * this set. + */ +export const KNOWN_PATCH_KEYS: ReadonlySet = new Set([ + "title", + "description", + "status", + "priority", + "estimate", + "assignee", + "project", + "labels", + "parentTicket", + "projectMilestone", + "cycle", + "dueDate", +]); + +/** + * Patch keys that accept `null` to clear the field. + * + * These are exactly the fields with a `--clear-*` flag. `title`, `description`, + * `status` and `priority` have none, because Linear has no empty state for them + * that a batch could sensibly write. + */ +export const CLEARABLE_PATCH_KEYS: ReadonlySet = new Set([ + "estimate", + "assignee", + "project", + "labels", + "parentTicket", + "projectMilestone", + "cycle", + "dueDate", +]); + +/** Reads the batch document from `--json`, a file, or stdin via `--file -`. */ +function readBatchDocument(options: DocumentOptions): string { + if (options.json !== undefined && options.file !== undefined) { + throw invalidParameterError("--json", "cannot be combined with --file"); + } + + if (options.json !== undefined) { + return options.json; + } + + if (options.file === undefined) { + throw invalidParameterError("--file", "is required (use - for stdin)"); + } + + return readFileSync(options.file === "-" ? 0 : options.file, "utf8"); +} + +/** + * Parses and validates the batch document. + * + * Validation is strict about unknown keys: a typo like `assingee` would + * otherwise create the whole batch with the field silently dropped, and a + * partially-wrong batch of issues is far more annoying to unpick than a + * rejected command. + */ +export function parseBatchCreateEntries(document: string): BatchCreateEntry[] { + let parsed: unknown; + + try { + parsed = JSON.parse(document); + } catch (error) { + throw invalidParameterError( + "batch document", + `is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!Array.isArray(parsed)) { + throw invalidParameterError( + "batch document", + "must be a JSON array of issue objects", + ); + } + + if (parsed.length === 0) { + throw invalidParameterError("batch document", "must not be empty"); + } + + return parsed.map((entry, index) => parseBatchCreateEntry(entry, index)); +} + +function parseBatchCreateEntry( + entry: unknown, + index: number, +): BatchCreateEntry { + const at = `batch document entry ${index}`; + + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + throw invalidParameterError(at, "must be an object"); + } + + const record = entry as Record; + + for (const key of Object.keys(record)) { + if (!KNOWN_ENTRY_KEYS.has(key)) { + throw invalidParameterError( + at, + `has unknown key "${key}" (expected one of: ${[...KNOWN_ENTRY_KEYS].join(", ")})`, + ); + } + } + + const parsedEntry: BatchCreateEntry = { + title: requireString(record, "title", at), + team: requireString(record, "team", at), + }; + + for (const key of [ + "description", + "assignee", + "project", + "projectMilestone", + "cycle", + "status", + "parentTicket", + "delegate", + ] as const) { + const value = optionalString(record, key, at); + if (value !== undefined) parsedEntry[key] = value; + } + + const priority = optionalInteger(record, "priority", at, 1, 4); + if (priority !== undefined) parsedEntry.priority = priority; + + const estimate = optionalInteger( + record, + "estimate", + at, + 0, + Number.MAX_SAFE_INTEGER, + ); + if (estimate !== undefined) parsedEntry.estimate = estimate; + + const dueDate = optionalString(record, "dueDate", at); + if (dueDate !== undefined) parsedEntry.dueDate = parseDueDate(dueDate); + + if (record["labels"] !== undefined) { + parsedEntry.labels = parseStringList(record["labels"], "labels", at); + } + + if (record["subscribers"] !== undefined) { + parsedEntry.subscribers = parseStringList( + record["subscribers"], + "subscribers", + at, + ); + } + + if ( + parsedEntry.projectMilestone !== undefined && + parsedEntry.project === undefined + ) { + throw invalidParameterError(at, "has projectMilestone without project"); + } + + return parsedEntry; +} + +function requireString( + record: Record, + key: string, + at: string, +): string { + const value = record[key]; + + if (typeof value !== "string" || value.trim() === "") { + throw invalidParameterError(at, `requires a non-empty string "${key}"`); + } + + return value; +} + +function optionalString( + record: Record, + key: string, + at: string, +): string | undefined { + const value = record[key]; + + if (value === undefined) return undefined; + if (typeof value !== "string" || value.trim() === "") { + throw invalidParameterError(at, `has a non-string or empty "${key}"`); + } + + return value; +} + +function optionalInteger( + record: Record, + key: string, + at: string, + min: number, + max: number, +): number | undefined { + const value = record[key]; + + if (value === undefined) return undefined; + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < min || + value > max + ) { + throw invalidParameterError( + at, + `has "${key}" outside the allowed range (integer ${min}-${max})`, + ); + } + + return value; +} + +/** Accepts both a JSON array and the comma-separated form the flags take. */ +function parseStringList(value: unknown, key: string, at: string): string[] { + if (typeof value === "string") { + // parseCommaSeparated speaks in flags, not documents: on `"a,,b"` it would + // report a bare "comma-separated list", leaving the caller to guess which + // of a hundred entries it came from. Restate it with the same locator the + // array branch uses. + try { + return parseCommaSeparated(value); + } catch { + throw invalidParameterError( + at, + `has "${key}" with empty segments in its comma-separated value`, + ); + } + } + + if ( + !Array.isArray(value) || + value.length === 0 || + !value.every((item) => typeof item === "string" && item.trim() !== "") + ) { + throw invalidParameterError( + at, + `has "${key}" that is not a non-empty array of strings`, + ); + } + + return value; +} + +/** As {@link optionalString}, but `null` survives as the "clear it" marker. */ +function nullableString( + record: Record, + key: string, + at: string, +): string | null | undefined { + return record[key] === null ? null : optionalString(record, key, at); +} + +/** As {@link optionalInteger}, but `null` survives as the "clear it" marker. */ +function nullableInteger( + record: Record, + key: string, + at: string, + min: number, + max: number, +): number | null | undefined { + return record[key] === null + ? null + : optionalInteger(record, key, at, min, max); +} + +/** + * Parses a `batch update` document: the issues to patch, and the single patch + * every one of them receives. + * + * The shape is `{"issues": [...], "patch": {...}}` rather than a per-issue + * array, because that is what the underlying mutation can actually do — one + * patch, one transaction. An array of per-issue patches would have to fan out + * into N mutations and lose the all-or-nothing guarantee that is the reason to + * batch in the first place. + * + * Unknown keys are rejected here just as they are in `batch create`: a typo + * that silently skipped a field would leave a half-applied mass edit to unpick. + */ +export function parseBatchUpdateDocument(document: string): BatchUpdateRequest { + let parsed: unknown; + + try { + parsed = JSON.parse(document); + } catch (error) { + throw invalidParameterError( + "batch document", + `is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw invalidParameterError( + "batch document", + 'must be a JSON object with "issues" and "patch"', + ); + } + + const record = parsed as Record; + + for (const key of Object.keys(record)) { + if (key !== "issues" && key !== "patch") { + throw invalidParameterError( + "batch document", + `has unknown key "${key}" (expected one of: issues, patch)`, + ); + } + } + + if (record["issues"] === undefined) { + throw invalidParameterError("batch document", 'requires "issues"'); + } + + const patch = record["patch"]; + + if (typeof patch !== "object" || patch === null || Array.isArray(patch)) { + throw invalidParameterError( + "batch document", + 'requires "patch" as an object of fields to change', + ); + } + + return { + issues: parseStringList(record["issues"], "issues", "batch document"), + patch: parseBatchUpdatePatch(patch as Record), + }; +} + +function parseBatchUpdatePatch( + record: Record, +): BatchUpdatePatch { + const at = "batch document patch"; + const keys = Object.keys(record); + + if (keys.length === 0) { + throw invalidParameterError(at, "needs at least one field to change"); + } + + for (const key of keys) { + if (!KNOWN_PATCH_KEYS.has(key)) { + throw invalidParameterError( + at, + `has unknown key "${key}" (expected one of: ${[...KNOWN_PATCH_KEYS].join(", ")})`, + ); + } + + if (record[key] === null && !CLEARABLE_PATCH_KEYS.has(key)) { + throw invalidParameterError(at, `cannot clear "${key}" with null`); + } + } + + const patch: BatchUpdatePatch = {}; + + for (const key of ["title", "description", "status"] as const) { + const value = optionalString(record, key, at); + if (value !== undefined) patch[key] = value; + } + + for (const key of [ + "assignee", + "project", + "parentTicket", + "projectMilestone", + "cycle", + ] as const) { + const value = nullableString(record, key, at); + if (value !== undefined) patch[key] = value; + } + + const priority = optionalInteger(record, "priority", at, 1, 4); + if (priority !== undefined) patch.priority = priority; + + const estimate = nullableInteger( + record, + "estimate", + at, + 0, + Number.MAX_SAFE_INTEGER, + ); + if (estimate !== undefined) patch.estimate = estimate; + + const dueDate = nullableString(record, "dueDate", at); + if (dueDate !== undefined) { + patch.dueDate = dueDate === null ? null : parseDueDate(dueDate); + } + + if (record["labels"] !== undefined) { + patch.labels = + record["labels"] === null + ? null + : parseStringList(record["labels"], "labels", at); + } + + // Milestones are scoped by project, and a batch has no single "current" + // project to fall back on the way a single-issue update does. + if ( + typeof patch.projectMilestone === "string" && + typeof patch.project !== "string" + ) { + throw invalidParameterError(at, "has projectMilestone without project"); + } + + return patch; +} + +function toResolverInput(entry: BatchCreateEntry): ResolveCreateIssueIdsInput { + const input: ResolveCreateIssueIdsInput = { + team: entry.team, + withEstimateContext: entry.estimate !== undefined, + }; + + if (entry.assignee !== undefined) input.assignee = entry.assignee; + if (entry.project !== undefined) input.project = entry.project; + if (entry.labels !== undefined) input.labels = entry.labels; + if (entry.projectMilestone !== undefined) { + input.projectMilestone = entry.projectMilestone; + } + if (entry.cycle !== undefined) input.cycle = entry.cycle; + if (entry.status !== undefined) input.status = entry.status; + if (entry.parentTicket !== undefined) input.parentTicket = entry.parentTicket; + if (entry.subscribers !== undefined) input.subscribers = entry.subscribers; + if (entry.delegate !== undefined) input.delegate = entry.delegate; + + return input; +} + +function toCreateInput( + entry: BatchCreateEntry, + ids: ResolvedCreateIssueIds, +): CreateIssueInput { + const input: CreateIssueInput = { title: entry.title, teamId: ids.teamId }; + + if (entry.description !== undefined) input.description = entry.description; + if (entry.priority !== undefined) input.priority = entry.priority; + if (entry.estimate !== undefined) input.estimate = entry.estimate; + if (entry.dueDate !== undefined) input.dueDate = entry.dueDate; + if (ids.assigneeId) input.assigneeId = ids.assigneeId; + if (ids.projectId) input.projectId = ids.projectId; + if (ids.labelIds) input.labelIds = ids.labelIds; + if (ids.projectMilestoneId) input.projectMilestoneId = ids.projectMilestoneId; + if (ids.cycleId) input.cycleId = ids.cycleId; + if (ids.stateId) input.stateId = ids.stateId; + if (ids.parentId) input.parentId = ids.parentId; + if (ids.subscriberIds) input.subscriberIds = ids.subscriberIds; + if (ids.delegateId) input.delegateId = ids.delegateId; + + return input; +} + +/** + * Derives the lookup scope for a batch patch from the targets themselves. + * + * `issueBatchUpdate` applies one `stateId`/`cycleId`/`labelIds` to every + * target, so a status, cycle or label named by word is only meaningful when all + * targets live in the same team. Rejecting the mixed-team case is better than + * resolving against an arbitrary one of them and moving four issues into a + * fifth team's workflow state. + * + * Labels are in that set because Linear labels may be team-scoped: two teams + * can each own a "bug", the name lookup matches both, and the first hit wins — + * so half the batch would silently get the other team's label. + * + * A UUID needs no team to resolve against, so it is the documented escape + * hatch and must pass the guard — `resolveUpdateIssueIds` hands UUIDs straight + * through without consulting the scope. `--labels` is checked entry by entry, + * since it takes a list that may mix UUIDs and names. + * + * Exported so that escape hatch can be tested without driving a full command. + */ +export function buildBatchUpdateContext( + targets: readonly ResolvedIssueRef[], + patch: BatchUpdatePatch, +): UpdateIssueContext { + const teamKeys = [...new Set(targets.map((target) => target.teamKey))]; + const [onlyTarget] = targets; + + if (teamKeys.length > 1) { + const crossTeam = (field: string): never => { + throw invalidParameterError( + field, + `cannot be resolved by name across teams ${teamKeys.join(", ")} — pass a UUID, or split the batch per team`, + ); + }; + + for (const field of ["status", "cycle"] as const) { + const value = patch[field]; + if (typeof value === "string" && !isUuid(value)) crossTeam(field); + } + + if ( + Array.isArray(patch.labels) && + patch.labels.some((label) => !isUuid(label)) + ) { + crossTeam("labels"); + } + + return {}; + } + + return onlyTarget + ? { teamId: onlyTarget.teamId, teamKey: onlyTarget.teamKey } + : {}; +} + +/** + * Validates `--estimate` against the estimation scale of every team the batch + * touches. + * + * `issues update` and `batch create` both reject an off-scale estimate before + * sending anything; without this, `batch update` was the one path that let + * `--estimate 7` reach a fibonacci team and come back as a raw API error. + * + * Every distinct team is checked, not just the single-team case: one patch + * applies the same estimate to all targets, so it has to be valid on each of + * their scales. That is one extra lookup per distinct team, and a batch + * spanning teams is already the rare shape. + * + * Exported so the validation can be tested without driving a full command. + */ +export async function validateBatchUpdateEstimate( + client: GraphQLClient, + targets: readonly ResolvedIssueRef[], + patch: BatchUpdatePatch, +): Promise { + const estimate = patch.estimate; + + if (typeof estimate !== "number") return; + + const teamIds = [...new Set(targets.map((target) => target.teamId))]; + const teams = await Promise.all( + teamIds.map((teamId) => resolveTeamEstimateContext(client, teamId)), + ); + + for (const team of teams) { + validateEstimateAgainstTeamConfig(estimate, { + teamKey: team.teamKey, + issueEstimationType: team.issueEstimationType, + issueEstimationExtended: team.issueEstimationExtended, + issueEstimationAllowZero: team.issueEstimationAllowZero, + }); + } +} + +function buildBatchUpdateResolverInput( + patch: BatchUpdatePatch, +): ResolveUpdateIssueIdsInput { + const input: ResolveUpdateIssueIdsInput = {}; + + for (const key of [ + "assignee", + "project", + "projectMilestone", + "cycle", + "status", + "parentTicket", + ] as const) { + const value = patch[key]; + if (typeof value === "string") input[key] = value; + } + + if (Array.isArray(patch.labels)) input.labels = patch.labels; + + return input; +} + +/** + * The update flags, minus the two that select the document form. + * + * Kept as a list so mixing a document with flags can be refused by name: a + * silently ignored `--status` on a document run would apply a different patch + * than the caller wrote, to every issue at once. + */ +const BATCH_UPDATE_FLAG_KEYS = [ + "issues", + "title", + "description", + "status", + "priority", + "estimate", + "clearEstimate", + "assignee", + "clearAssignee", + "project", + "clearProject", + "labels", + "clearLabels", + "parentTicket", + "clearParentTicket", + "projectMilestone", + "clearProjectMilestone", + "cycle", + "clearCycle", + "dueDate", + "clearDueDate", +] as const satisfies ReadonlyArray; + +/** Picks the input path — a JSON document, or the flags — and parses it. */ +function readBatchUpdateRequest( + options: BatchUpdateOptions, +): BatchUpdateRequest { + if (options.file === undefined && options.json === undefined) { + if (options.issues === undefined) { + throw invalidParameterError( + "--issues", + "is required (or pass a document with --file/--json)", + ); + } + + return { + issues: parseCommaSeparated(options.issues), + patch: patchFromFlags(options), + }; + } + + const used = BATCH_UPDATE_FLAG_KEYS.filter( + (key) => options[key] !== undefined, + ); + + if (used.length > 0) { + throw invalidParameterError( + used.map((key) => `--${toFlagName(key)}`).join(", "), + "cannot be combined with a JSON document — put the field in the document instead", + ); + } + + return parseBatchUpdateDocument(readBatchDocument(options)); +} + +function toFlagName(key: string): string { + return key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} + +/** + * Normalises the update flags into a {@link BatchUpdatePatch}. + * + * The `--clear-*` flags collapse into a `null` here, which is also what the + * document form writes — past this point nothing needs to know which of the two + * input paths the patch came from. + */ +function patchFromFlags(options: BatchUpdateOptions): BatchUpdatePatch { + validateBatchUpdateOptions(options); + + const patch: BatchUpdatePatch = {}; + + if (options.title) patch.title = options.title; + if (options.description) patch.description = options.description; + if (options.status) patch.status = options.status; + if (options.priority !== undefined) { + patch.priority = parsePriorityOption(options.priority); + } + + if (options.clearEstimate) { + patch.estimate = null; + } else if (options.estimate !== undefined) { + patch.estimate = parseEstimateOption(options.estimate); + } + + if (options.clearDueDate) { + patch.dueDate = null; + } else if (options.dueDate) { + patch.dueDate = parseDueDate(options.dueDate); + } + + if (options.clearLabels) { + patch.labels = null; + } else if (options.labels) { + patch.labels = parseCommaSeparated(options.labels); + } + + for (const [key, value, cleared] of [ + ["assignee", options.assignee, options.clearAssignee], + ["project", options.project, options.clearProject], + ["parentTicket", options.parentTicket, options.clearParentTicket], + [ + "projectMilestone", + options.projectMilestone, + options.clearProjectMilestone, + ], + ["cycle", options.cycle, options.clearCycle], + ] as const) { + if (cleared) { + patch[key] = null; + } else if (value) { + patch[key] = value; + } + } + + return patch; +} + +function validateBatchUpdateOptions(options: BatchUpdateOptions): void { + const exclusions: Array<[string, unknown, string, unknown]> = [ + ["--assignee", options.assignee, "--clear-assignee", options.clearAssignee], + ["--project", options.project, "--clear-project", options.clearProject], + ["--labels", options.labels, "--clear-labels", options.clearLabels], + ["--estimate", options.estimate, "--clear-estimate", options.clearEstimate], + ["--due-date", options.dueDate, "--clear-due-date", options.clearDueDate], + [ + "--parent-ticket", + options.parentTicket, + "--clear-parent-ticket", + options.clearParentTicket, + ], + [ + "--project-milestone", + options.projectMilestone, + "--clear-project-milestone", + options.clearProjectMilestone, + ], + ["--cycle", options.cycle, "--clear-cycle", options.clearCycle], + ]; + + for (const [flag, value, clearFlag, clearValue] of exclusions) { + if (value && clearValue) { + throw invalidParameterError(flag, `cannot be used with ${clearFlag}`); + } + } + + // Milestones are scoped by project, and a batch has no single "current" + // project to fall back on the way a single-issue update does. + if (options.projectMilestone && !options.project) { + throw requiresParameterError("--project-milestone", "--project"); + } +} + +export function addBatchCommands(issues: Command): void { + const batch = issues + .command("batch") + .description("Bulk issue operations in a single transaction"); + + batch + .command("create") + .description("create many issues from a JSON document") + .addHelpText( + "after", + [ + "", + "The document is a JSON array whose keys mirror the `issues create` flags:", + ' [{"title":"Fix login","team":"ENG","assignee":"alice","labels":["bug"]}]', + "Unknown keys are rejected rather than ignored.", + "", + `The full input contract is published as JSON Schema (draft 2020-12) at ${BATCH_CREATE_SCHEMA_URL}`, + "Point an editor or a validator at it to check a document before sending it:", + " check-jsonschema --schemafile issues.json", + ].join("\n"), + ) + .option( + "--file ", + "path to the JSON document, or - for stdin (see `issues usage` for the JSON Schema)", + ) + .option("--json ", "the JSON document inline") + .action( + commandAction<[BatchCreateOptions, Command]>(async (options, command) => { + const entries = parseBatchCreateEntries(readBatchDocument(options)); + const ctx = createContext(getRootOpts(command)); + + const resolved = await resolveBatchCreateIssueIds( + ctx.gql, + entries.map(toResolverInput), + ); + + const inputs = entries.map((entry, index) => { + // Positional pairing is safe: resolveBatchCreateIssueIds preserves + // input order even when it collapses duplicate lookups. + const ids = resolved[index] as ResolvedCreateIssueIds; + + if (entry.estimate !== undefined && ids.estimateContext) { + validateEstimateAgainstTeamConfig(entry.estimate, { + teamKey: ids.estimateContext.teamKey, + issueEstimationType: ids.estimateContext.issueEstimationType, + issueEstimationExtended: + ids.estimateContext.issueEstimationExtended, + issueEstimationAllowZero: + ids.estimateContext.issueEstimationAllowZero, + }); + } + + return toCreateInput(entry, ids); + }); + + const result = await batchCreateIssues(ctx.gql, inputs); + outputSuccess(result); + }), + ); + + batch + .command("update") + .description("apply one patch to an explicit list of issues") + .addHelpText( + "after", + [ + "", + "The patch is applied to every listed issue in one transaction.", + "This is deliberately not filter-driven: a mass mutation selected by", + "filter has no dry-run story. Name the issues you mean.", + "", + "The targets and the patch can also come from a JSON document, where", + "null clears a field the way the --clear-* flags do:", + ' {"issues":["ENG-1","ENG-2"],"patch":{"status":"Done","cycle":null}}', + "Unknown keys are rejected rather than ignored.", + "", + `The full input contract is published as JSON Schema (draft 2020-12) at ${BATCH_UPDATE_SCHEMA_URL}`, + "Point an editor or a validator at it to check a document before sending it:", + " check-jsonschema --schemafile patch.json", + ].join("\n"), + ) + .option("--issues ", "issues to update (comma-separated)") + .option( + "--file ", + "path to a JSON patch document, or - for stdin (replaces the flags below)", + ) + .option("--json ", "the JSON patch document inline") + .option("--title ", "new title") + .option("--description ", "new description") + .option("--status ", "new status") + .option("--priority <1-4>", "1=urgent 2=high 3=medium 4=low") + .option("--assignee ", "new assignee") + .option("--clear-assignee", "clear assignee") + .option("--project ", "new project") + .option("--clear-project", "clear project") + .option( + "--labels ", + "labels to apply (comma-separated, overwrites)", + ) + .option("--clear-labels", "remove all labels") + .option("--parent-ticket ", "set parent issue") + .option("--clear-parent-ticket", "clear parent") + .option( + "--project-milestone ", + "set project milestone (requires --project)", + ) + .option("--clear-project-milestone", "clear project milestone") + .option("--cycle ", "set cycle") + .option("--clear-cycle", "remove issues from their cycle") + .option("--estimate ", "new estimate") + .option("--clear-estimate", "clear estimate") + .option("--due-date ", "set due date (YYYY-MM-DD)") + .option("--clear-due-date", "clear due date") + .action( + commandAction<[BatchUpdateOptions, Command]>(async (options, command) => { + const { issues, patch } = readBatchUpdateRequest(options); + + const ctx = createContext(getRootOpts(command)); + const targets = await resolveIssueRefs(ctx.gql, issues); + const context = buildBatchUpdateContext(targets, patch); + + await validateBatchUpdateEstimate(ctx.gql, targets, patch); + + const resolverInput = buildBatchUpdateResolverInput(patch); + const ids = + Object.keys(resolverInput).length > 0 + ? await resolveUpdateIssueIds(ctx.gql, resolverInput, context) + : {}; + + const input = buildBatchUpdateInput(patch, ids); + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "batch update", + "needs at least one field to change", + ); + } + + const result = await batchUpdateIssues( + ctx.gql, + targets.map((target) => target.id), + input, + ); + outputSuccess(result); + }), + ); +} + +/** + * Turns the flags into the single patch every target receives. + * + * Exported so the clear-versus-set branches can be asserted without driving a + * full command; `null` and "absent" mean different things to the API, and only + * the built input shows which one a flag produced. + */ +export function buildBatchUpdateInput( + patch: BatchUpdatePatch, + ids: { + assigneeId?: UUID; + projectId?: UUID; + labelIds?: UUID[]; + projectMilestoneId?: UUID; + cycleId?: UUID; + stateId?: UUID; + parentId?: UUID; + }, +): UpdateIssueInput { + const input: UpdateIssueInput = {}; + + if (patch.title !== undefined) input.title = patch.title; + if (patch.description !== undefined) input.description = patch.description; + if (patch.priority !== undefined) input.priority = patch.priority; + if (patch.estimate !== undefined) input.estimate = patch.estimate; + if (patch.dueDate !== undefined) input.dueDate = patch.dueDate; + + if (patch.assignee === null) { + input.assigneeId = null; + } else if (ids.assigneeId) { + input.assigneeId = ids.assigneeId; + } + + if (patch.project === null) { + input.projectId = null; + input.projectMilestoneId = null; + } else if (ids.projectId) { + input.projectId = ids.projectId; + } + + // Only overwrite semantics here: add/remove would need each target's current + // label set, which a single-patch mutation cannot express. + if (patch.labels === null) { + input.labelIds = []; + } else if (ids.labelIds) { + input.labelIds = ids.labelIds; + } + + if (patch.parentTicket === null) { + input.parentId = null; + } else if (ids.parentId) { + input.parentId = ids.parentId; + } + + // Clearing the project already nulls the milestone above; clearing the + // milestone alone has to work too, for a batch that stays in its project. + if (patch.projectMilestone === null) { + input.projectMilestoneId = null; + } else if (ids.projectMilestoneId) { + input.projectMilestoneId = ids.projectMilestoneId; + } + + if (patch.cycle === null) { + input.cycleId = null; + } else if (ids.cycleId) { + input.cycleId = ids.cycleId; + } + + if (ids.stateId) input.stateId = ids.stateId; + + return input; +} diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 15ca9c4d..cf817c6a 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -2,10 +2,16 @@ import type { Command } from "commander"; import { firstOrThrow } from "../common/array.js"; import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; -import { parseLabelMode } from "../common/domain-values.js"; +import { parseDateTimeOption } from "../common/datetime.js"; +import { + parseLabelMode, + parseSetMode, + type SetMode, +} from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; +import { getCurrentBranch } from "../common/git.js"; import { asUuid, isUuid, @@ -13,16 +19,22 @@ import { parseIssueIdentifier, type UUID, } from "../common/identifier.js"; -import type { RawFilterFlags } from "../common/issue-filter.js"; +import { + parseCommaSeparated, + type RawFilterFlags, +} from "../common/issue-filter.js"; import { parseEstimateOption, parsePriorityOption, } from "../common/number-options.js"; import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; import { resolveFilterOptions } from "../common/resolve-filters.js"; -import { buildPaginationOptions } from "../common/types.js"; +import { + buildPaginationOptions, + type PaginationOptions, +} from "../common/types.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; -import type { IssueRelationType } from "../gql/graphql.js"; +import type { IssueRelationType, PaginationOrderBy } from "../gql/graphql.js"; import { type ResolveCreateIssueIdsInput, type ResolvedUpdateIssueIds, @@ -35,6 +47,8 @@ import { resolveIssueEstimateContext, resolveIssueId, } from "../resolvers/issue-resolver.js"; +import { resolveTeamEstimateContext } from "../resolvers/team-resolver.js"; +import { resolveUserId, resolveViewerId } from "../resolvers/user-resolver.js"; import { getIssueActivity } from "../services/activity-service.js"; import { createDiscussionCommentReaction, @@ -65,6 +79,7 @@ import { type CreateIssueInput, createIssue, deleteIssue, + findIssueByBranch, getIssue, getIssueByIdentifier, getIssueByIdentifierWithAttachments, @@ -75,10 +90,19 @@ import { getIssueWithComments, getIssueWithCommentThreads, getIssueWithReactions, + type IssueDetail, + type IssueReadOptions, listIssues, + remindOnIssue, + restoreIssue, searchIssues, + shareIssue, + snoozeIssue, + subscribeToIssue, type UpdateIssueInput, unarchiveIssue, + unshareIssue, + unsubscribeFromIssue, updateIssue, } from "../services/issue-service.js"; import { @@ -86,11 +110,14 @@ import { deleteOwnReactionByEmoji, deleteOwnReactionById, } from "../services/reaction-service.js"; +import { addBatchCommands } from "./issues-batch.js"; interface FilterOptions extends RawFilterFlags { limit: string; after?: string; query?: string; + includeArchived?: boolean; + orderBy?: string; } interface CreateOptions { @@ -105,6 +132,8 @@ interface CreateOptions { cycle?: string; status?: string; parentTicket?: string; + subscribers?: string; + delegate?: string; dueDate?: string; blocks?: string; blockedBy?: string; @@ -133,6 +162,12 @@ interface UpdateOptions { clearProjectMilestone?: boolean; cycle?: string; clearCycle?: boolean; + team?: string; + subscribers?: string; + subscriberMode?: string; + clearSubscribers?: boolean; + delegate?: string; + clearDelegate?: boolean; dueDate?: string; clearDueDate?: boolean; blocks?: string; @@ -164,6 +199,90 @@ function validateReadOptions(options: ReadOptions): void { } } +interface SubscriberOptions { + user?: string; +} + +interface ShareOptions { + with: string; +} + +interface RemindOptions { + at: string; +} + +interface SnoozeOptions { + until?: string; + clear?: boolean; +} + +/** `--until ` snoozes; `--clear` wakes. Exactly one is required. */ +function parseSnoozeTarget(options: SnoozeOptions): string | null { + if (options.until && options.clear) { + throw invalidParameterError("--until", "cannot be used with --clear"); + } + + if (options.clear) { + return null; + } + + if (!options.until) { + throw invalidParameterError("--until", "is required (or pass --clear)"); + } + + return parseDateTimeOption("--until", options.until); +} + +/** + * Resolves the issue and the user a subscribe/share command acts on. + * + * The two lookups are independent, so they run concurrently. An omitted user + * means the caller themselves — subscribing yourself is the overwhelmingly + * common case, and `me` is accepted as the explicit spelling of the same thing + * (see `resolveUserId`). + */ +async function resolveIssueAndUser( + ctx: CommandContext, + issue: string, + user: string | undefined, +): Promise<[UUID, UUID]> { + return Promise.all([ + resolveIssueId(ctx.gql, issue), + user === undefined + ? resolveViewerId(ctx.gql) + : resolveUserId(ctx.gql, user), + ]); +} + +/** + * Combines a set-valued flag with the issue's current members. + * + * `overwrite` (and an omitted mode) replaces, matching the API's own + * replace-the-list semantics for `labelIds`/`subscriberIds`; `add` and + * `remove` are computed here from the issue's current values because the API + * has no incremental form for either field. + */ +function applySetMode( + mode: SetMode | undefined, + current: readonly UUID[], + requested: readonly UUID[], +): UUID[] { + if (mode === "add") { + return [...new Set([...current, ...requested])]; + } + + if (mode === "remove") { + return current.filter((id) => !requested.includes(id)); + } + + return [...requested]; +} + +/** The issue's current subscriber UUIDs, for `--subscriber-mode add|remove`. */ +function currentSubscriberIds(issue: IssueDetail | undefined): UUID[] { + return (issue?.subscribers?.nodes ?? []).map((user) => asUuid(user.id)); +} + interface ReactionOptions { shortcode?: string; } @@ -267,18 +386,69 @@ export const ISSUES_META: DomainMeta = { "issues can have labels, a due date, belong to a project, be part of a", "cycle (sprint), and reference a project milestone. parent-child", "relationships and issue relations (blocks, blocked-by, relates-to,", - "duplicate-of) are supported.", + "duplicate-of) are supported. an issue can also be moved between teams", + "with `update --team`.", + "", + "an issue has three separate 'put it away' states, and they do not", + "overlap: archive (`archive`/`unarchive`), trash (`delete`/`restore`),", + "and snooze until a time (`snooze --until|--clear`). archived issues are", + "reachable by identifier everywhere, but excluded from `list`/`search`", + "unless you pass --include-archived.", + "", + "`list` also hides completed issues by default. saying anything about", + "state lifts that narrowing: --status and --state-type replace it with", + "what you asked for, and --include-archived drops it too. so on `list`", + "--include-archived widens the result twice — archived issues are nearly", + "always completed, and keeping the default clause would hide the very", + "issues the flag was passed to surface. to see completed work without", + "archived issues, pass --state-type completed instead.", + "", + "full-text search does not narrow by state: `search`, and `list --query`", + "which runs the same query, return completed issues whether or not you", + "pass --include-archived. there --include-archived only adds archived", + "issues. filter with --state-type if you want a state-bounded search.", + "", + "people attach to an issue in four ways: assignee (one, owns it),", + "delegate (one, acts for the assignee), subscribers (many, get notified),", + "and shared access (`share --with`, which grants a user visibility of an", + "issue they otherwise could not see — it does not produce a link; the", + "issue's permalink is the `url` field on any read).", + "", + "both batch commands take a JSON document instead of flags, with unknown", + "keys rejected rather than ignored. `batch create` takes an array with one", + "object per issue, keys named after the `issues create` flags. `batch", + 'update` takes {"issues": [...], "patch": {...}}, keys named after the', + "`issues update` flags, where null clears a field the way --clear-* does.", + "each contract is published as JSON Schema (draft 2020-12) in `schemas/`,", + "also shipped in the npm package and served raw from the repository's", + "default branch:", + "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-create.schema.json", + "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-update.schema.json", + "write the document against the schema, validate it locally, then pass it", + "with --file (or - for stdin).", ].join("\n"), arguments: { issue: "issue identifier (UUID or ABC-123)", title: "string", query: "full-text search term", + user: "display name, email, UUID, or `me` for yourself", + when: "ISO-8601 instant (2026-08-14T09:00:00Z) or offset (+2h, +3d)", }, seeAlso: [ "issues activity ", + "issues batch create --file issues.json", + "issues batch update --issues ENG-1,ENG-2 --status Done", + "issues batch update --file patch.json", + "issues from-branch", + "issues subscribe [--user ]", + "issues share --with ", + "issues remind --at +2h", + "issues snooze --until 2026-08-20", + "issues restore ", "comments create ", "documents list --issue ", "attachments list ", + "attachments disable-sync ", "issues read --with-attachments", "issues archive ", "issues unarchive ", @@ -492,6 +662,35 @@ async function resolveAndApplyRelations( } } +/** + * Fold `--include-archived` into the pagination options. The key is left absent + * rather than set to `false` so the request matches the pre-flag shape exactly + * under `exactOptionalPropertyTypes`. + */ +function buildIssueReadOptions( + pagination: PaginationOptions, + options: Pick, +): IssueReadOptions { + return { + ...pagination, + ...(options.includeArchived ? { includeArchived: true } : {}), + ...(options.orderBy ? { orderBy: parseOrderBy(options.orderBy) } : {}), + }; +} + +/** + * Maps the CLI's `created`/`updated` onto Linear's `PaginationOrderBy`. + * + * The API spells them `createdAt`/`updatedAt`; both spellings are accepted so a + * caller who read the field name in a payload is not told they are wrong. + */ +function parseOrderBy(value: string): PaginationOrderBy { + if (value === "created" || value === "createdAt") return "createdAt"; + if (value === "updated" || value === "updatedAt") return "updatedAt"; + + throw invalidParameterError("--order-by", "must be 'created' or 'updated'"); +} + function addFilterOptions(cmd: ReturnType): typeof cmd { return cmd .option("--team ", "filter by team") @@ -520,12 +719,24 @@ function addFilterOptions(cmd: ReturnType): typeof cmd { .option("--updated-after ", "updated after date (YYYY-MM-DD)") .option("--updated-before ", "updated before date (YYYY-MM-DD)") .option("--has-blockers", "only issues that are blocked") - .option("--is-blocking", "only issues that block others"); + .option("--is-blocking", "only issues that block others") + .option("--unassigned", "only issues with no assignee") + .option( + "--state-type ", + "filter by state category (triage, backlog, unstarted, started, completed, canceled)", + ) + .option("--subscriber ", "filter by subscriber") + .option( + "--include-archived", + "include archived issues (on `list`, also drops the default 'hide completed' narrowing; full-text search never applies it)", + ); } export function setupIssuesCommands(program: Command): void { const issues = program.command("issues").description("Issue operations"); + addBatchCommands(issues); + const relations = issues .command("relations") .description("Issue relation operations"); @@ -599,15 +810,25 @@ export function setupIssuesCommands(program: Command): void { .command("list") .description("list issues with optional filters") .option("--query ", "deprecated: use `issues search `") + .option("--order-by ", "created | updated (default: updated)") .option("-l, --limit ", "max results", "50") .option("--after ", "cursor for next page"), ).action( commandAction<[FilterOptions, Command]>(async (options, command) => { + // Full-text results come back relevance-ordered from the API, so + // --order-by has nothing to act on down that path. + if (options.orderBy && options.query) { + throw invalidParameterError( + "--order-by", + "cannot be combined with --query, whose results are relevance-ordered", + ); + } + const ctx = createContext(getRootOpts(command)); - const paginationOptions = buildPaginationOptions( - parseLimit(options.limit), - options.after, + const readOptions = buildIssueReadOptions( + buildPaginationOptions(parseLimit(options.limit), options.after), + options, ); const filterOptions = await resolveFilterOptions(ctx, options); @@ -617,14 +838,14 @@ export function setupIssuesCommands(program: Command): void { const result = await searchIssues( ctx.gql, options.query, - paginationOptions, + readOptions, filter, ); outputSuccess(result); return; } - const result = await listIssues(ctx.gql, paginationOptions, filter); + const result = await listIssues(ctx.gql, readOptions, filter); outputSuccess(result); }), ); @@ -640,19 +861,14 @@ export function setupIssuesCommands(program: Command): void { async (query, options, command) => { const ctx = createContext(getRootOpts(command)); - const paginationOptions = buildPaginationOptions( - parseLimit(options.limit), - options.after, + const readOptions = buildIssueReadOptions( + buildPaginationOptions(parseLimit(options.limit), options.after), + options, ); const filterOptions = await resolveFilterOptions(ctx, options); const filter = buildIssueFilter(filterOptions); - const result = await searchIssues( - ctx.gql, - query, - paginationOptions, - filter, - ); + const result = await searchIssues(ctx.gql, query, readOptions, filter); outputSuccess(result); }, ), @@ -1142,6 +1358,8 @@ export function setupIssuesCommands(program: Command): void { .option("--status ", "set status") .option("--estimate ", "set estimate") .option("--parent-ticket ", "set parent issue") + .option("--subscribers ", "subscribe users (comma-separated)") + .option("--delegate ", "delegate to a user") .option("--due-date ", "due date (YYYY-MM-DD)") .option("--blocks ", "this issue blocks ") .option("--blocked-by ", "this issue is blocked by ") @@ -1190,6 +1408,10 @@ export function setupIssuesCommands(program: Command): void { if (options.status) idsInput.status = options.status; if (options.parentTicket) idsInput.parentTicket = options.parentTicket; + if (options.subscribers) { + idsInput.subscribers = parseCommaSeparated(options.subscribers); + } + if (options.delegate) idsInput.delegate = options.delegate; const ids = await resolveCreateIssueIds(ctx.gql, idsInput); @@ -1249,6 +1471,14 @@ export function setupIssuesCommands(program: Command): void { input.parentId = ids.parentId; } + if (ids.subscriberIds) { + input.subscriberIds = ids.subscriberIds; + } + + if (ids.delegateId) { + input.delegateId = ids.delegateId; + } + if (options.dueDate) { input.dueDate = parseDueDate(options.dueDate); } @@ -1292,6 +1522,12 @@ export function setupIssuesCommands(program: Command): void { .option("--clear-project-milestone", "clear project milestone") .option("--cycle ", "set cycle") .option("--clear-cycle", "clear cycle") + .option("--team ", "move the issue to another team") + .option("--subscribers ", "subscribers to apply (comma-separated)") + .option("--subscriber-mode ", "add | remove | overwrite") + .option("--clear-subscribers", "remove all subscribers") + .option("--delegate ", "set delegate") + .option("--clear-delegate", "clear delegate") .option("--estimate ", "new estimate") .option("--clear-estimate", "clear estimate") .option("--due-date ", "set due date (YYYY-MM-DD)") @@ -1363,7 +1599,35 @@ export function setupIssuesCommands(program: Command): void { throw new Error("--clear-labels cannot be used with --label-mode"); } + if (options.subscriberMode && !options.subscribers) { + throw new Error( + "--subscriber-mode requires --subscribers to be specified", + ); + } + + if (options.clearSubscribers && options.subscribers) { + throw new Error( + "--clear-subscribers cannot be used with --subscribers", + ); + } + + if (options.clearSubscribers && options.subscriberMode) { + throw new Error( + "--clear-subscribers cannot be used with --subscriber-mode", + ); + } + + if (options.delegate && options.clearDelegate) { + throw new Error( + "Cannot use --delegate and --clear-delegate together", + ); + } + const labelMode = parseLabelMode(options.labelMode); + const subscriberMode = parseSetMode( + "--subscriber-mode", + options.subscriberMode, + ); const parsedPriority = options.priority !== undefined @@ -1378,8 +1642,18 @@ export function setupIssuesCommands(program: Command): void { const ctx = createContext(getRootOpts(command)); + // The estimate has to satisfy the scale of the team that ends up + // owning the issue. With --team that is the destination, not the + // team the issue is leaving: validating against the current team + // would reject a value the move makes legal and wave through one it + // makes illegal, which then comes back as a raw API error. + const destinationEstimateTeam = + parsedEstimate !== undefined && options.team + ? await resolveTeamEstimateContext(ctx.gql, options.team) + : undefined; + const issueEstimateContext = - parsedEstimate !== undefined + parsedEstimate !== undefined && !destinationEstimateTeam ? await resolveIssueEstimateContext(ctx.gql, issue) : undefined; @@ -1387,15 +1661,15 @@ export function setupIssuesCommands(program: Command): void { ? issueEstimateContext.issueId : await resolveIssueId(ctx.gql, issue); - if (parsedEstimate !== undefined && issueEstimateContext) { + const estimateTeam = + destinationEstimateTeam ?? issueEstimateContext?.team; + + if (parsedEstimate !== undefined && estimateTeam) { validateEstimateAgainstTeamConfig(parsedEstimate, { - teamKey: issueEstimateContext.team.teamKey, - issueEstimationType: - issueEstimateContext.team.issueEstimationType, - issueEstimationExtended: - issueEstimateContext.team.issueEstimationExtended, - issueEstimationAllowZero: - issueEstimateContext.team.issueEstimationAllowZero, + teamKey: estimateTeam.teamKey, + issueEstimationType: estimateTeam.issueEstimationType, + issueEstimationExtended: estimateTeam.issueEstimationExtended, + issueEstimationAllowZero: estimateTeam.issueEstimationAllowZero, }); } @@ -1403,7 +1677,10 @@ export function setupIssuesCommands(program: Command): void { options.status || options.projectMilestone || options.cycle || - (options.labels && (labelMode === "add" || labelMode === "remove")); + (options.labels && + (labelMode === "add" || labelMode === "remove")) || + (options.subscribers && + (subscriberMode === "add" || subscriberMode === "remove")); const issueContext = needsContext ? await getIssue(ctx.gql, resolvedIssueId) : undefined; @@ -1443,6 +1720,13 @@ export function setupIssuesCommands(program: Command): void { if (!options.clearParentTicket && options.parentTicket) { updIdsInput.parentTicket = options.parentTicket; } + if (options.team) updIdsInput.team = options.team; + if (!options.clearSubscribers && options.subscribers) { + updIdsInput.subscribers = parseCommaSeparated(options.subscribers); + } + if (!options.clearDelegate && options.delegate) { + updIdsInput.delegate = options.delegate; + } const needsResolution = updIdsInput.assignee !== undefined || @@ -1451,7 +1735,10 @@ export function setupIssuesCommands(program: Command): void { updIdsInput.projectMilestone !== undefined || updIdsInput.cycle !== undefined || updIdsInput.status !== undefined || - updIdsInput.parentTicket !== undefined; + updIdsInput.parentTicket !== undefined || + updIdsInput.team !== undefined || + updIdsInput.subscribers !== undefined || + updIdsInput.delegate !== undefined; const ids: ResolvedUpdateIssueIds = needsResolution ? await resolveUpdateIssueIds(ctx.gql, updIdsInput, updContext) @@ -1504,15 +1791,7 @@ export function setupIssuesCommands(program: Command): void { ? issueContext.labels.nodes.map((l) => asUuid(l.id)) : []; - if (labelMode === "add") { - input.labelIds = [...new Set([...currentLabels, ...labelIds])]; - } else if (labelMode === "remove") { - input.labelIds = currentLabels.filter( - (id) => !labelIds.includes(id), - ); - } else { - input.labelIds = labelIds; - } + input.labelIds = applySetMode(labelMode, currentLabels, labelIds); } if (options.clearParentTicket) { @@ -1539,6 +1818,26 @@ export function setupIssuesCommands(program: Command): void { input.cycleId = ids.cycleId; } + if (ids.teamId) { + input.teamId = ids.teamId; + } + + if (options.clearSubscribers) { + input.subscriberIds = []; + } else if (options.subscribers && ids.subscriberIds) { + input.subscriberIds = applySetMode( + subscriberMode, + currentSubscriberIds(issueContext), + ids.subscriberIds, + ); + } + + if (options.clearDelegate) { + input.delegateId = null; + } else if (ids.delegateId) { + input.delegateId = ids.delegateId; + } + if (options.clearDueDate) { input.dueDate = null; } else if (options.dueDate) { @@ -1560,6 +1859,142 @@ export function setupIssuesCommands(program: Command): void { ), ); + issues + .command("from-branch [branch]") + .description("find the issue a git branch belongs to") + .addHelpText( + "after", + "\nWith no argument the current checkout's branch is used, so this works as `linearis issues from-branch` inside a worktree.", + ) + .action( + commandAction<[string | undefined, unknown, Command]>( + async (branch, _unused1, command) => { + const branchName = branch ?? getCurrentBranch(); + const ctx = createContext(getRootOpts(command)); + const result = await findIssueByBranch(ctx.gql, branchName); + + outputSuccess(result); + }, + ), + ); + + issues + .command("subscribe ") + .description("subscribe a user to an issue's notifications") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, + ) + .option("--user ", "user to subscribe (defaults to you)") + .action( + commandAction<[string, SubscriberOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); + const [issueId, userId] = await resolveIssueAndUser( + ctx, + issue, + options.user, + ); + const result = await subscribeToIssue(ctx.gql, issueId, userId); + + outputSuccess(result); + }, + ), + ); + + issues + .command("unsubscribe ") + .description("remove a user from an issue's subscribers") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, + ) + .option("--user ", "user to unsubscribe (defaults to you)") + .action( + commandAction<[string, SubscriberOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); + const [issueId, userId] = await resolveIssueAndUser( + ctx, + issue, + options.user, + ); + const result = await unsubscribeFromIssue(ctx.gql, issueId, userId); + + outputSuccess(result); + }, + ), + ); + + issues + .command("share ") + .description("grant a user access to an issue") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.\nThis grants access; it does not mint a link. The issue's permalink is the \`url\` field on \`issues read\`.`, + ) + .requiredOption("--with ", "user to grant access to") + .action( + commandAction<[string, ShareOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); + const [issueId, userId] = await resolveIssueAndUser( + ctx, + issue, + options.with, + ); + const result = await shareIssue(ctx.gql, issueId, userId); + + outputSuccess(result); + }, + ), + ); + + issues + .command("unshare ") + .description("revoke a user's access to an issue") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.`, + ) + .requiredOption("--with ", "user to revoke access from") + .action( + commandAction<[string, ShareOptions, Command]>( + async (issue, options, command) => { + const ctx = createContext(getRootOpts(command)); + const [issueId, userId] = await resolveIssueAndUser( + ctx, + issue, + options.with, + ); + const result = await unshareIssue(ctx.gql, issueId, userId); + + outputSuccess(result); + }, + ), + ); + + issues + .command("remind ") + .description("schedule a reminder for yourself on an issue") + .addHelpText( + "after", + `\nWhen passing issue IDs, both UUID and identifiers like ABC-123 are supported.\n--at accepts an ISO-8601 instant (2026-08-14T09:00:00Z) or a relative offset (+2h, +3d).`, + ) + .requiredOption("--at ", "when to be reminded") + .action( + commandAction<[string, RemindOptions, Command]>( + async (issue, options, command) => { + const reminderAt = parseDateTimeOption("--at", options.at); + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await remindOnIssue(ctx.gql, issueId, reminderAt); + + outputSuccess(result); + }, + ), + ); + issues .command("archive ") .description("archive an issue") @@ -1588,6 +2023,47 @@ export function setupIssuesCommands(program: Command): void { ), ); + issues + .command("restore ") + .description("restore an issue from the trash") + .addHelpText( + "after", + "\n`issues delete` trashes rather than destroys, and this is the way back. Archiving is a separate state — use `issues unarchive` for that.", + ) + .action( + commandAction<[string, unknown, Command]>( + async (issue, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await restoreIssue(ctx.gql, issueId); + + outputSuccess(result); + }, + ), + ); + + issues + .command("snooze ") + .description("snooze an issue until a given time, or wake it") + .addHelpText( + "after", + "\n--until accepts an ISO-8601 instant (2026-08-20T09:00:00Z) or a relative offset (+2h, +3d).", + ) + .option("--until ", "snooze until this instant") + .option("--clear", "wake the issue now") + .action( + commandAction<[string, SnoozeOptions, Command]>( + async (issue, options, command) => { + const snoozedUntilAt = parseSnoozeTarget(options); + const ctx = createContext(getRootOpts(command)); + const issueId = await resolveIssueId(ctx.gql, issue); + const result = await snoozeIssue(ctx.gql, issueId, snoozedUntilAt); + + outputSuccess(result); + }, + ), + ); + issues .command("delete ") .description("delete an issue") diff --git a/src/commands/labels.ts b/src/commands/labels.ts index a22e4bdf..9a4ce232 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -11,8 +11,10 @@ import { handleCommand, outputSuccess, parseLimit } from "../common/output.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; import { type LabelResolverScope, + type ResolveLabelOptions, resolveLabelId, } from "../resolvers/label-resolver.js"; +import { resolveProjectLabelId } from "../resolvers/project-resolver.js"; import { resolveTeamId } from "../resolvers/team-resolver.js"; import { type CreateLabelInput, @@ -23,6 +25,8 @@ import { type LabelType, listLabels, listProjectLabels, + restoreLabel, + retireLabel, type UpdateLabelInput, updateLabel, } from "../services/label-service.js"; @@ -36,20 +40,28 @@ interface ListLabelsOptions extends CommandOptions { } interface LabelLookupOptions extends CommandOptions { + type?: string; team?: string; scope?: string; } interface CreateLabelOptions extends CommandOptions { + type?: string; team?: string; color?: string; description?: string; + parent?: string; + group?: boolean; } interface UpdateLabelOptions extends LabelLookupOptions { name?: string; color?: string; description?: string; + parent?: string; + clearParent?: boolean; + group?: boolean; + notGroup?: boolean; } function parseLabelType(value?: string): LabelType { @@ -83,14 +95,85 @@ function parseLabelColor(value?: string): string | undefined { return value; } -async function resolveIssueLabelLookup( +/** + * Project labels have no team dimension at all, so silently ignoring + * `--team`/`--scope` would answer a question the caller did not ask. + */ +function rejectTeamScopingForProjectLabels( + team: string | undefined, + scope: LabelScope | undefined, +): void { + if (team) { + throw invalidParameterError( + "--team", + "cannot be used with --type project because project labels are workspace-scoped", + ); + } + + if (scope) { + throw invalidParameterError( + "--scope", + "cannot be used with --type project because project labels are always workspace-scoped", + ); + } +} + +/** + * Resolves `--parent` against the same label kind as the label being written. + * + * A group and its children are always the same kind, so routing the parent + * through the other resolver could only ever produce a not-found or a + * cross-kind parent the API would reject. + * + * Issue-label lookups take the same team scoping the written label was + * resolved with. Without it the workspace-wide name match wins, so in a + * workspace where two teams each own a group called "Area" the new label would + * land in whichever one the API returned first. + */ +async function resolveLabelParentId( + client: ReturnType["gql"], + parent: string, + type: LabelType, + scoping: ResolveLabelOptions, +): Promise { + return type === "project" + ? resolveProjectLabelId(client, parent) + : resolveLabelId(client, parent, scoping); +} + +/** + * Resolves `