From fc0276d163cf7edce50c41c0533afd902a18a534 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:37:07 +0200 Subject: [PATCH 01/70] fix(release): pin conventionalcommits preset to the writer-v8-compatible line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every release since 2026.7.0-next.1 (2026-07-06) shipped with empty release notes. The CHANGELOG.md sections, the GitHub Release bodies and the `chore(release):` commit bodies all contain nothing but the version heading. .releaserc.cjs asks release-notes-generator for `preset: "conventionalcommits"`, but the preset package was never declared here — it was only reachable because @commitlint/config-conventional@21 depends on it and npm hoisted its copy to the root of node_modules. Renovate's "dev dependencies (non-major)" group bumped that transitive copy from 9.3.1 to 10.2.0 in d9d5458, and preset v10 switched to the @conventional-changelog/writer@2 API, returning `template` and `commitPartial` as JS functions. release-notes-generator@14 (latest) still renders with the Handlebars-based conventional-changelog-writer@8, which reads `options.mainTemplate` and registers partials through Handlebars. So the main template silently fell back to writer@8's default (no `### ` headings) and every commit partial rendered as "". Only `headerPartial` happened to still work, which is why the heading survived and the failure was silent instead of loud. commit-analyzer was unaffected, because its loader reads `loadedConfig.parser`, which v10 does provide — so releases kept happening, just empty. Declare the preset as an exact devDependency on 9.3.1. npm then places 9.3.1 at the root, where release-notes-generator resolves it, and gives @commitlint/config-conventional its own nested 10.x; both consumers get a compatible copy and commitlint is unchanged. Alternatives considered, and why not: upgrading semantic-release (14.1.1 is latest and still on writer@8 — there is no upstream fix yet); hand-writing `writerOpts` in .releaserc.cjs (duplicates preset internals and drifts); switching to the `angular` preset (changes heading levels and type/section mapping, making the existing CHANGELOG.md internally inconsistent). Two guards against recurrence: - renovate.json holds the package below 10 with the reason inline, so the weekly dev-dependency group cannot reintroduce the break. - tests/unit/release/release-notes.test.ts renders synthetic feat/fix commits through the real .releaserc.cjs plugin options and asserts on the grouped sections, bullets and issue links. It fails on next today and fails again if the preset is forced back to 10.x, so a future resolution drift is caught in CI rather than in a shipped release. Reading the options out of .releaserc.cjs (instead of restating them) means the test also follows any config change. Because that test now imports @semantic-release/release-notes-generator for real, it comes off knip's ignoreDependencies; the preset itself takes its place there, since nothing imports it by specifier. --- knip.json | 2 +- package-lock.json | 30 ++++++-- package.json | 1 + renovate.json | 5 ++ tests/types/semantic-release-plugins.d.ts | 12 +++ tests/unit/release/release-notes.test.ts | 94 +++++++++++++++++++++++ 6 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 tests/types/semantic-release-plugins.d.ts create mode 100644 tests/unit/release/release-notes.test.ts 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..21c4fca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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": { @@ -6021,16 +6035,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": { diff --git a/package.json b/package.json index aaacb287..da398ac3 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,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/tests/types/semantic-release-plugins.d.ts b/tests/types/semantic-release-plugins.d.ts new file mode 100644 index 00000000..e5e60465 --- /dev/null +++ b/tests/types/semantic-release-plugins.d.ts @@ -0,0 +1,12 @@ +/** + * Minimal ambient types for the semantic-release plugins exercised by the + * release tests. The upstream packages ship no declarations, and we only call + * a single entry point, so a hand-written surface is cheaper (and clearer) + * than pulling in a full third-party type dependency. + */ +declare module "@semantic-release/release-notes-generator" { + export function generateNotes( + pluginConfig: Record, + context: Record, + ): Promise; +} diff --git a/tests/unit/release/release-notes.test.ts b/tests/unit/release/release-notes.test.ts new file mode 100644 index 00000000..64c073a8 --- /dev/null +++ b/tests/unit/release/release-notes.test.ts @@ -0,0 +1,94 @@ +import { createRequire } from "node:module"; +import { generateNotes } from "@semantic-release/release-notes-generator"; +import { describe, expect, it } from "vitest"; + +/** + * Regression guard for silently empty release notes. + * + * `.releaserc.cjs` selects the `conventionalcommits` preset by bare specifier, + * so the preset version that actually gets loaded is whatever npm resolves + * from release-notes-generator's own directory. Preset v10 switched to the + * `@conventional-changelog/writer@2` API (function `template`/`commitPartial`), + * which release-notes-generator's Handlebars-based writer@8 cannot render: the + * heading survives, every `### ` section and bullet disappears, and the + * release still succeeds. These tests turn that silent data loss into a + * failing check. + */ + +type PluginEntry = string | [string, Record]; + +const require = createRequire(import.meta.url); + +function releaseNotesPluginConfig(): Record { + const config = require("../../../.releaserc.cjs") as { + plugins: PluginEntry[]; + }; + + const entry = config.plugins.find( + (plugin): plugin is [string, Record] => + Array.isArray(plugin) && + plugin[0] === "@semantic-release/release-notes-generator", + ); + + if (!entry) { + throw new Error( + "no @semantic-release/release-notes-generator entry in .releaserc.cjs", + ); + } + + return entry[1]; +} + +const COMMITS = [ + { + hash: "1111111111111111111111111111111111111111", + message: "feat(issues): add activity command\n\nCloses #144", + }, + { + hash: "2222222222222222222222222222222222222222", + message: "fix(labels): allow clearing label description", + }, + { + hash: "3333333333333333333333333333333333333333", + message: "chore(deps): bump something unreleasable", + }, +]; + +async function render(): Promise { + return generateNotes(releaseNotesPluginConfig(), { + cwd: process.cwd(), + options: { repositoryUrl: "https://github.com/linearis-oss/linearis" }, + lastRelease: { gitTag: "v2026.6.0", version: "2026.6.0" }, + nextRelease: { gitTag: "v2026.7.0", version: "2026.7.0", channel: null }, + commits: COMMITS, + logger: { log: () => {}, error: () => {} }, + }); +} + +describe("release notes generation", () => { + it("renders grouped sections and bullets for releasable commits", async () => { + const notes = await render(); + + expect(notes).toContain("### Features"); + expect(notes).toContain("### Bug Fixes"); + expect(notes).toContain("* **issues:** add activity command"); + expect(notes).toContain("* **labels:** allow clearing label description"); + }); + + it("links commits and referenced issues", async () => { + const notes = await render(); + + expect(notes).toContain( + "https://github.com/linearis-oss/linearis/commit/1111111", + ); + expect(notes).toContain( + "https://github.com/linearis-oss/linearis/issues/144", + ); + }); + + it("omits non-deliverable commit types", async () => { + const notes = await render(); + + expect(notes).not.toContain("bump something unreleasable"); + }); +}); From 0c4d7b065db74003772680fa4bc1deb3f4c1d62d Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:02:59 +0200 Subject: [PATCH 02/70] ci(release): exempt the one-off changelog backfill commit from the guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven releases (2026.7.0-next.1 through 2026.7.0) shipped with nothing but a version heading, and the preset pin cannot repair what was already published. Landing the regenerated bodies means committing CHANGELOG.md, which guard-changelog-history rejects outright. Rather than add label or permission plumbing, exempt exactly one commit subject — `chore(release): backfill changelog notes` — and only when that commit changes nothing but CHANGELOG.md and only adds lines to it. The guard walks the matching commits individually instead of reading `--name-status` in bulk, so a mixed commit is still reported, and the error messages name the exemption and the additions-only rule so both are discoverable from a failing run. The extra-file and deletion checks report independently rather than short-circuiting, so a commit that violates both gets both errors in one run instead of one per push. Every other CHANGELOG.md edit stays blocked. The commit list is captured into a variable rather than fed to the loop's here-string directly. Command substitution inside a here-string discards the substituted command's exit status, so `set -e` never fires: if `origin/$GITHUB_BASE_REF` is not resolvable in the runner's clone, git writes "fatal: bad revision" to stderr, the loop reads a single empty line, and the step reports "No CHANGELOG.md history violations — OK" — failing open exactly when it cannot see the history it is supposed to police. Verified against both a resolvable and a bogus base ref. The generator script that produced the backfill is deliberately not committed. It is a one-time repair for a defect the pin now prevents, so a permanent `scripts/` entry and npm script would be dead weight that still has to be maintained, type-checked and kept out of knip's way. CONTRIBUTING.md records the exemption and every property the guard enforces — reserved subject, CHANGELOG.md only, additions only — plus the generated-not-hand-written requirement, which is the part worth keeping. Alternative considered: dropping the exemption entirely and requiring an admin merge for backfills. Rejected because the repair is rare but real, and an enforced narrow path leaves a better audit trail than a bypassed check. --- .github/workflows/ci-validate.yml | 46 +++++++++++++++++++++++++++++-- CONTRIBUTING.md | 22 +++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) 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/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` From ddceefd0262a2c395601e0544ae31f0b2a0c2455 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:03:00 +0200 Subject: [PATCH 03/70] chore(release): backfill changelog notes Restores the seven release bodies that semantic-release dropped between 2026.7.0-next.1 and 2026.7.0, when the conventionalcommits preset resolved to a version the notes generator's writer could not render. Generated, not hand-edited: for each empty section the notes were re-rendered from the commit range in the heading's own compare link, using the real .releaserc.cjs plugin options, and only the body was spliced in. Every heading keeps its original version, date and compare link, so the diff is 52 insertions and zero deletions. This subject is the one guard-changelog-history exempts, so this commit contains CHANGELOG.md and nothing else. --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e61f3b..2daae1e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,69 @@ ## [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 From 52553de336340114a12d7d434010409e7d15ede4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:02:46 +0000 Subject: [PATCH 04/70] chore(deps): update dev dependencies (non-major) --- package-lock.json | 120 +++++++++++++++++++++++----------------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21c4fca4..3fd36444 100644 --- a/package-lock.json +++ b/package-lock.json @@ -425,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": { @@ -441,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" ], @@ -469,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" ], @@ -486,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" ], @@ -506,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" ], @@ -526,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" ], @@ -546,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" ], @@ -566,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" ], @@ -583,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" ], @@ -1644,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": { @@ -1657,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", @@ -1795,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" }, @@ -1821,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": { @@ -7054,9 +7054,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": { @@ -7919,9 +7919,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": [ { @@ -7937,7 +7937,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", @@ -11799,9 +11799,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": { @@ -12972,9 +12972,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": { From b816d7b2498edd23ecb52ae6e644602fb62c102f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:05:40 +0000 Subject: [PATCH 05/70] chore(deps): update semantic-release monorepo --- package-lock.json | 559 ++++++++++++++++------------------------------ package.json | 4 +- 2 files changed, 198 insertions(+), 365 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3fd36444..2069e1ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -3955,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": { @@ -3997,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": { @@ -4027,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", @@ -4081,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", @@ -4139,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" }, @@ -4165,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": { @@ -4233,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", @@ -4319,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", @@ -4419,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", @@ -4442,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", @@ -4480,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", @@ -4515,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", @@ -4577,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", @@ -6636,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", @@ -7394,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": { @@ -7653,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" @@ -8522,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", @@ -9231,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": { @@ -11202,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": { @@ -11841,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", @@ -11941,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", @@ -11964,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", @@ -12002,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", @@ -12063,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", @@ -12127,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", @@ -12156,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", @@ -12627,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": { @@ -13454,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 da398ac3..f64041ab 100644 --- a/package.json +++ b/package.json @@ -81,10 +81,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", From 6ac98297ed9d4c675d0e77ec59e395313efa4f84 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:22:28 +0200 Subject: [PATCH 06/70] fix(issues): make archived issues reachable by identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveIssueId` and every `GetIssueByIdentifier*` read went through `issues(filter: …)` without `includeArchived`, which the Linear API defaults to `false`. An archived issue therefore could not be found by its `ENG-42` identifier at all, so `issues unarchive ENG-42` failed with "Issue not found" and only worked when the caller already knew the UUID — the one case where they least need the command. The identifier lookups now pin `includeArchived: true`. That is the correct fixed value rather than a flag: these queries exist only to turn a human identifier into a UUID (or to read one specific issue the caller named), and an archived issue still has both. It also aligns the identifier path with the UUID path, where `issue(id:)` already returned archived issues — the two spellings of "read ENG-42" no longer disagree. Collection reads are a different question, so they stay opt-in: `list` and `search` gain `--include-archived`, threaded through a new `IssueReadOptions` in the service layer and defaulting to `false` so the default result set is unchanged. `SearchIssues`/`FilteredSearchIssues` previously hardcoded `includeArchived: false` with no way to override. --- graphql/queries/issues.graphql | 28 +++++++++-- src/commands/issues.ts | 47 ++++++++++++------ src/services/issue-service.ts | 20 ++++++-- tests/unit/services/issue-service.test.ts | 60 ++++++++++++++++++++++- 4 files changed, 130 insertions(+), 25 deletions(-) diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 32d936d2..dce433aa 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -232,11 +232,17 @@ fragment CompleteIssueSearchFields on IssueSearchResult { # 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) { +query GetIssues( + $first: Int! + $after: String + $orderBy: PaginationOrderBy + $includeArchived: Boolean = false +) { issues( first: $first after: $after orderBy: $orderBy + includeArchived: $includeArchived filter: { state: { type: { neq: "completed" } } } ) { nodes { @@ -263,10 +269,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 +296,7 @@ query GetIssueByIdentifierWithComments($teamKey: String!, $number: Float!) { issues( filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } first: 1 + includeArchived: true ) { nodes { ...CompleteIssueWithCommentsFields @@ -305,6 +316,7 @@ query GetIssueByIdentifierWithReactions($teamKey: String!, $number: Float!) { issues( filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } } first: 1 + includeArchived: true ) { nodes { ...CompleteIssueWithReactionsFields @@ -332,13 +344,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 @@ -359,13 +372,14 @@ query FilteredSearchIssues( $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,8 +872,13 @@ 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 team { @@ -892,6 +911,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/src/commands/issues.ts b/src/commands/issues.ts index 15ca9c4d..a57ddfbe 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -20,7 +20,10 @@ import { } 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 { @@ -75,6 +78,7 @@ import { getIssueWithComments, getIssueWithCommentThreads, getIssueWithReactions, + type IssueReadOptions, listIssues, searchIssues, type UpdateIssueInput, @@ -91,6 +95,7 @@ interface FilterOptions extends RawFilterFlags { limit: string; after?: string; query?: string; + includeArchived?: boolean; } interface CreateOptions { @@ -492,6 +497,20 @@ 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 options.includeArchived + ? { ...pagination, includeArchived: true } + : pagination; +} + function addFilterOptions(cmd: ReturnType): typeof cmd { return cmd .option("--team ", "filter by team") @@ -520,7 +539,8 @@ 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("--include-archived", "include archived issues in the results"); } export function setupIssuesCommands(program: Command): void { @@ -605,9 +625,9 @@ export function setupIssuesCommands(program: Command): void { commandAction<[FilterOptions, Command]>(async (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); @@ -617,14 +637,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 +660,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); }, ), diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 11dca0f4..4882330d 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -131,6 +131,15 @@ export type UpdateIssueInput = BrandUuidFields< | "cycleId" >; +/** + * Pagination plus the read-scope knobs shared by `listIssues` and + * `searchIssues`. Archived issues are excluded unless asked for, matching the + * Linear API default and the historical CLI behavior. + */ +export interface IssueReadOptions extends PaginationOptions { + includeArchived?: boolean; +} + const NON_COMPLETED_ISSUES_FILTER: IssueFilter = { state: { type: { neq: "completed" } }, }; @@ -268,10 +277,10 @@ function normalizeIssueReactions< export async function listIssues( client: GraphQLClient, - options: PaginationOptions = {}, + options: IssueReadOptions = {}, filter?: IssueFilter, ): Promise> { - const { limit = 25, after } = options; + const { limit = 25, after, includeArchived = false } = options; if (filter) { const result = await client.request(FilteredSearchIssuesDocument, { @@ -279,6 +288,7 @@ export async function listIssues( after, filter: buildListIssuesFilter(filter), orderBy: "updatedAt", + includeArchived, }); return { nodes: result.issues?.nodes ?? [], @@ -290,6 +300,7 @@ export async function listIssues( first: limit, after, orderBy: "updatedAt", + includeArchived, }); return { nodes: result.issues?.nodes ?? [], @@ -433,14 +444,15 @@ export async function getIssueByIdentifierWithAttachments( export async function searchIssues( client: GraphQLClient, term: string, - options: PaginationOptions = {}, + options: IssueReadOptions = {}, filter?: IssueFilter, ): Promise> { - const { limit = 25, after } = options; + const { limit = 25, after, includeArchived = false } = options; const variables: SearchIssuesQueryVariables = { term, first: limit, after, + includeArchived, ...(filter && { filter }), }; const result = await client.request(SearchIssuesDocument, variables); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 9ef206e5..61028538 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -1,4 +1,9 @@ -import { type DocumentNode, type FragmentDefinitionNode, Kind } from "graphql"; +import { + type DocumentNode, + type FragmentDefinitionNode, + Kind, + print, +} from "graphql"; import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { asUuid } from "../../../src/common/identifier.js"; @@ -6,6 +11,7 @@ import { ArchiveIssueDocument, DeleteIssueDocument, FilteredSearchIssuesDocument, + FindIssuesDocument, GetIssueByIdDocument, GetIssueByIdentifierDocument, GetIssueByIdentifierWithAttachmentsDocument, @@ -105,7 +111,51 @@ describe("attachment issue read documents", () => { }); }); +describe("archived issue reachability", () => { + it("resolves identifiers against archived issues too", () => { + // resolveIssueId / the identifier read path go through these documents; if + // they stop including archived issues, `issues unarchive ENG-42` breaks. + const documents = [ + FindIssuesDocument, + GetIssueByIdentifierDocument, + GetIssueByIdentifierWithCommentsDocument, + GetIssueByIdentifierWithReactionsDocument, + GetIssueByIdentifierWithAttachmentsDocument, + ]; + + for (const document of documents) { + expect(print(document)).toContain("includeArchived: true"); + } + }); + + it("keeps list and search opt-in rather than always-archived", () => { + for (const document of [ + GetIssuesDocument, + FilteredSearchIssuesDocument, + SearchIssuesDocument, + ]) { + expect(print(document)).toContain("$includeArchived: Boolean = false"); + } + }); +}); + describe("listIssues", () => { + it("forwards includeArchived on both the filtered and unfiltered paths", async () => { + for (const filter of [undefined, { priority: { eq: 1 } }]) { + const client = mockGqlClient({ + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listIssues(client, { limit: 10, includeArchived: true }, filter); + expect(client.request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ includeArchived: true }), + ); + } + }); + it("returns issues from query", async () => { const client = mockGqlClient({ issues: { @@ -146,6 +196,7 @@ describe("listIssues", () => { first: 25, after: undefined, orderBy: "updatedAt", + includeArchived: false, }); }); @@ -161,6 +212,7 @@ describe("listIssues", () => { first: 5, after: "cursor1", orderBy: "updatedAt", + includeArchived: false, }); }); @@ -198,6 +250,7 @@ describe("listIssues", () => { ], }, orderBy: "updatedAt", + includeArchived: false, }); }); @@ -222,6 +275,7 @@ describe("listIssues", () => { after: undefined, filter, orderBy: "updatedAt", + includeArchived: false, }); }); @@ -237,6 +291,7 @@ describe("listIssues", () => { first: 25, after: undefined, orderBy: "updatedAt", + includeArchived: false, }); }); }); @@ -786,6 +841,7 @@ describe("searchIssues", () => { term: "query", first: 5, after: "prevCursor", + includeArchived: false, }); }); @@ -803,6 +859,7 @@ describe("searchIssues", () => { term: "bug", first: 10, after: undefined, + includeArchived: false, filter, }); }); @@ -819,6 +876,7 @@ describe("searchIssues", () => { term: "test", first: 25, after: undefined, + includeArchived: false, }); }); }); From 2d4885e9c737bcd1c3e8d7657969c91eb3046ba8 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:24:06 +0200 Subject: [PATCH 07/70] feat(issues): return url, creator, delegate and lifecycle timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every issue payload identified the issue only by `identifier`, so a consumer that wanted to link to it had to reconstruct the Linear URL from the team key and number — the workspace slug is not even derivable from the CLI output, so that reconstruction was guesswork. `url` is a non-null field on both `Issue` and `IssueSearchResult`; selecting it is what "share links" in the README coverage cell actually needed. Alongside it the read fragments now carry the rest of the issue's shape that was being dropped: `creator` and `delegate` (the assignee's two siblings), the lifecycle timestamps `startedAt`/`completedAt`/ `canceledAt`, and the state markers `archivedAt`, `snoozedUntilAt` and `trashed`. Without those last three a caller cannot tell an archived or trashed issue from a live one in any CLI output. `subscribers` and `sharedAccess` are deliberately *not* on the shared `CompleteIssueFields`: a subscriber roster is worth fetching for one issue but would multiply across all 50 rows of a default `issues list` for no benefit, and this CLI is optimized for token cost. They live in a new `IssueDetailOnlyFields` fragment spread into the single-issue read fragments only. The alternative — a `--with-subscribers` flag — was rejected as a third axis on top of the existing `--with-*` flags for data that is a handful of bytes on a single read. --- graphql/queries/issues.graphql | 52 +++++++++++++++++++++++ tests/unit/services/issue-service.test.ts | 43 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index dce433aa..8cc6bc5e 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 diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 61028538..763a66d1 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -111,6 +111,49 @@ describe("attachment issue read documents", () => { }); }); +describe("issue read payload", () => { + const scalarNames = (document: DocumentNode, fragment: string): string[] => + getFragment(document, fragment) + .selectionSet.selections.filter( + (selection) => selection.kind === Kind.FIELD, + ) + .map((selection) => selection.name.value); + + it("exposes the issue url and lifecycle timestamps on list and search", () => { + const expected = [ + "url", + "startedAt", + "completedAt", + "canceledAt", + "archivedAt", + "snoozedUntilAt", + "trashed", + "creator", + "delegate", + ]; + + expect(scalarNames(GetIssuesDocument, "CompleteIssueFields")).toEqual( + expect.arrayContaining(expected), + ); + expect( + scalarNames(SearchIssuesDocument, "CompleteIssueSearchFields"), + ).toEqual(expect.arrayContaining(expected)); + }); + + it("keeps subscribers and sharedAccess off the list fragment", () => { + // They belong to single-issue reads only — see IssueDetailOnlyFields. + const listFields = scalarNames(GetIssuesDocument, "CompleteIssueFields"); + expect(listFields).not.toContain("subscribers"); + expect(listFields).not.toContain("sharedAccess"); + + expect(print(GetIssueByIdDocument)).toContain("IssueDetailOnlyFields"); + expect(print(GetIssueByIdWithCommentsDocument)).toContain( + "IssueDetailOnlyFields", + ); + expect(print(GetIssuesDocument)).not.toContain("IssueDetailOnlyFields"); + }); +}); + describe("archived issue reachability", () => { it("resolves identifiers against archived issues too", () => { // resolveIssueId / the identifier read path go through these documents; if From 1530903ddf5962faf53365f17c9600f540b553d7 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:27:46 +0200 Subject: [PATCH 08/70] feat(issues): add subscribe, share and remind commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five issue-scoped root mutations had no CLI surface: `issueSubscribe`, `issueUnsubscribe`, `issueShare`, `issueUnshare` and `issueReminder`. Naming needed care on two of them. `issueShare` does not mint a URL — it grants a named user access to an issue they otherwise cannot see — so the command takes `--with ` rather than a positional argument, which keeps `issues share ENG-1` from reading like "print me a link". (The link is the `url` field added to the read payload.) Subscribe and unsubscribe default `--user` to the authenticated viewer, since subscribing yourself is the common case; `me`/`@me` are accepted as the explicit spelling and now resolve in `resolveUserId` for every flag that takes a user, not just these. `resolveViewerId` lands in `user-resolver.ts` as the single owner of "who am I". It queries `viewer` directly, which is an architectural exception to the lean-lookup rule documented at the call site: no filter can select the caller. `reaction-service.ts` keeps its own private copy rather than importing the resolver, which the layer rules forbid. `--at` accepts either an ISO-8601 instant or a `+2h`/`+3d` offset, parsed by a new pure `parseDateTimeOption` helper. It sits in `common/datetime.ts` rather than next to `parseDueDate`, which covers Linear's timeless `TimelessDate` scalar and cannot grow a clock time. The helper takes `now` as a parameter so it stays testable, normalizes everything to UTC, and reads a zoneless value as UTC so the same command means the same instant wherever it runs. --- graphql/mutations/issues.graphql | 54 +++++++ src/commands/issues.ts | 157 +++++++++++++++++++++ src/common/datetime.ts | 107 ++++++++++++++ src/resolvers/user-resolver.ts | 28 +++- src/services/issue-service.ts | 94 ++++++++++++ tests/unit/common/datetime.test.ts | 69 +++++++++ tests/unit/resolvers/user-resolver.test.ts | 33 ++++- tests/unit/services/issue-service.test.ts | 96 +++++++++++++ 8 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 src/common/datetime.ts create mode 100644 tests/unit/common/datetime.test.ts diff --git a/graphql/mutations/issues.graphql b/graphql/mutations/issues.graphql index 7943ff29..74e7c804 100644 --- a/graphql/mutations/issues.graphql +++ b/graphql/mutations/issues.graphql @@ -51,6 +51,60 @@ mutation UnarchiveIssue($id: String!) { } } +# 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/src/commands/issues.ts b/src/commands/issues.ts index a57ddfbe..427597ea 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -2,6 +2,7 @@ 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 { parseDateTimeOption } from "../common/datetime.js"; import { parseLabelMode } from "../common/domain-values.js"; import { resolveReactionEmojiInput } from "../common/emoji.js"; import { invalidParameterError } from "../common/errors.js"; @@ -38,6 +39,7 @@ import { resolveIssueEstimateContext, resolveIssueId, } from "../resolvers/issue-resolver.js"; +import { resolveUserId, resolveViewerId } from "../resolvers/user-resolver.js"; import { getIssueActivity } from "../services/activity-service.js"; import { createDiscussionCommentReaction, @@ -80,9 +82,14 @@ import { getIssueWithReactions, type IssueReadOptions, listIssues, + remindOnIssue, searchIssues, + shareIssue, + subscribeToIssue, type UpdateIssueInput, unarchiveIssue, + unshareIssue, + unsubscribeFromIssue, updateIssue, } from "../services/issue-service.js"; import { @@ -169,6 +176,39 @@ function validateReadOptions(options: ReadOptions): void { } } +interface SubscriberOptions { + user?: string; +} + +interface ShareOptions { + with: string; +} + +interface RemindOptions { + at: string; +} + +/** + * 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), + ]); +} + interface ReactionOptions { shortcode?: string; } @@ -1575,6 +1615,123 @@ export function setupIssuesCommands(program: Command): void { ), ); + 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") diff --git a/src/common/datetime.ts b/src/common/datetime.ts new file mode 100644 index 00000000..775f6ddb --- /dev/null +++ b/src/common/datetime.ts @@ -0,0 +1,107 @@ +import { invalidParameterError } from "./errors.js"; + +/** + * Parsing for the CLI's point-in-time flags (`issues remind --at`, + * `issues snooze --until`). + * + * `parseDueDate` in `identifier.ts` covers Linear's `TimelessDate` scalar — + * a calendar day with no clock time. The flags here map to `DateTime`, so they + * need a different shape: a full instant, plus a relative shorthand, because + * "remind me in two hours" is the overwhelmingly common case and spelling it + * as an absolute timestamp on a command line is miserable. + */ + +/** `+2h`, `+30m`, `+3d`, `+1w` — a whole number of units after `now`. */ +const RELATIVE_REGEX = /^\+(\d+)([mhdw])$/; + +/** + * ISO-8601 instants, with the time and zone both optional. A bare date is + * accepted and read as midnight UTC; a date-time with no zone is also read as + * UTC rather than local time, so the same command produces the same instant + * regardless of where it runs. + */ +const ISO_REGEX = + /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?)(Z|[+-]\d{2}:?\d{2})?)?$/; + +const UNIT_MILLISECONDS: Record = { + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 604_800_000, +}; + +/** + * Parses a point-in-time flag value into an ISO-8601 UTC timestamp. + * + * @param flag - Flag name, used verbatim in the error message (e.g. `--at`) + * @param value - Either `+[mhdw]` or an ISO-8601 date / date-time + * @param now - Reference instant for relative values; injected so the parser + * stays pure and testable + * @throws Error if the value matches neither form, or names a date that does + * not exist (e.g. `2026-02-30`) + */ +export function parseDateTimeOption( + flag: string, + value: string, + now: Date = new Date(), +): string { + const relative = RELATIVE_REGEX.exec(value); + if (relative) { + const [, amountRaw, unit] = relative; + // Both groups are guaranteed present when the regex matches. + const amount = Number(amountRaw); + const unitMs = UNIT_MILLISECONDS[unit as string]; + + if (amount === 0 || unitMs === undefined) { + throw invalidParameterError( + flag, + `"${value}" must be a positive offset, e.g. +2h`, + ); + } + + return new Date(now.getTime() + amount * unitMs).toISOString(); + } + + const iso = ISO_REGEX.exec(value); + if (!iso) { + throw invalidParameterError( + flag, + `"${value}" must be an ISO-8601 date or date-time (2026-08-14T09:00:00Z) ` + + `or a relative offset (+2h, +3d)`, + ); + } + + const [, date, time, zone] = iso; + + // The calendar day is checked on its own rather than by round-tripping the + // parsed instant: with an explicit offset the UTC day legitimately differs + // from the written one (`2026-08-14T01:00+05:00` is the 13th in UTC), so a + // round-trip comparison would reject valid input. + if (!isRealCalendarDay(date as string)) { + throw invalidParameterError(flag, `"${value}" is not a real date`); + } + + const parsed = new Date(`${date}T${time ?? "00:00:00"}${zone ?? "Z"}`); + + if (Number.isNaN(parsed.getTime())) { + throw invalidParameterError(flag, `"${value}" is not a real date-time`); + } + + return parsed.toISOString(); +} + +/** True when `YYYY-MM-DD` names a day that exists (rejects 2026-02-30). */ +function isRealCalendarDay(date: string): boolean { + const [year, month, day] = date.split("-").map(Number) as [ + number, + number, + number, + ]; + const utc = new Date(Date.UTC(year, month - 1, day)); + + return ( + utc.getUTCFullYear() === year && + utc.getUTCMonth() === month - 1 && + utc.getUTCDate() === day + ); +} diff --git a/src/resolvers/user-resolver.ts b/src/resolvers/user-resolver.ts index d051099d..7f162d43 100644 --- a/src/resolvers/user-resolver.ts +++ b/src/resolvers/user-resolver.ts @@ -1,14 +1,40 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { multipleMatchesError, notFoundError } from "../common/errors.js"; import { asUuid, isUuid, type UUID } from "../common/identifier.js"; -import { FindUsersDocument } from "../gql/graphql.js"; +import { FindUsersDocument, GetViewerDocument } from "../gql/graphql.js"; +/** Spellings of "the authenticated user" accepted wherever a user is expected. */ +const VIEWER_ALIASES: ReadonlySet = new Set(["me", "@me"]); + +/** + * Resolves the authenticated user's UUID. + * + * ARCHITECTURAL EXCEPTION: this resolver queries `viewer` directly rather than + * going through a lean filter-based lookup. There is no filter that selects + * "the caller" — `viewer` is the only way to ask — and the query is already as + * lean as it gets (three scalars on a single node). + */ +export async function resolveViewerId(client: GraphQLClient): Promise { + const { viewer } = await client.request(GetViewerDocument); + return asUuid(viewer.id); +} + +/** + * Resolves a user reference to a UUID. + * + * Accepts a UUID, a display name, an email address, or `me`/`@me` for the + * authenticated user. + */ export async function resolveUserId( client: GraphQLClient, nameOrEmailOrId: string, ): Promise { if (isUuid(nameOrEmailOrId)) return asUuid(nameOrEmailOrId); + if (VIEWER_ALIASES.has(nameOrEmailOrId.toLowerCase())) { + return resolveViewerId(client); + } + // Try by display name first (case-insensitive) const { users: byName } = await client.request(FindUsersDocument, { filter: { displayName: { eqIgnoreCase: nameOrEmailOrId } }, diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 4882330d..25fbe7e9 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -30,10 +30,15 @@ import { type IssueCreateInput, type IssueFilter, type IssueUpdateInput, + RemindOnIssueDocument, SearchIssuesDocument, type SearchIssuesQuery, type SearchIssuesQueryVariables, + ShareIssueDocument, + SubscribeToIssueDocument, UnarchiveIssueDocument, + UnshareIssueDocument, + UnsubscribeFromIssueDocument, UpdateIssueDocument, type UpdateIssueMutation, } from "../gql/graphql.js"; @@ -518,6 +523,95 @@ export async function unarchiveIssue( ); } +/** + * Adds a user to an issue's subscriber list. + * + * `issueSubscribe` also accepts `userEmail`, but the CLI resolves user + * references to UUIDs in the resolver layer, so only `userId` is used. + */ +export async function subscribeToIssue( + client: GraphQLClient, + id: UUID, + userId: UUID, +): Promise { + const result = await client.request(SubscribeToIssueDocument, { id, userId }); + + return requireMutationEntity( + result.issueSubscribe, + "issue", + `Failed to subscribe user "${userId}" to issue "${id}"`, + ); +} + +export async function unsubscribeFromIssue( + client: GraphQLClient, + id: UUID, + userId: UUID, +): Promise { + const result = await client.request(UnsubscribeFromIssueDocument, { + id, + userId, + }); + + return requireMutationEntity( + result.issueUnsubscribe, + "issue", + `Failed to unsubscribe user "${userId}" from issue "${id}"`, + ); +} + +/** + * Grants a user access to an issue they cannot otherwise see. + * + * Note this is an access grant, not a link generator — the issue's permalink + * is the `url` field on any read payload. + */ +export async function shareIssue( + client: GraphQLClient, + id: UUID, + userId: UUID, +): Promise { + const result = await client.request(ShareIssueDocument, { id, userId }); + + return requireMutationEntity( + result.issueShare, + "issue", + `Failed to share issue "${id}" with user "${userId}"`, + ); +} + +export async function unshareIssue( + client: GraphQLClient, + id: UUID, + userId: UUID, +): Promise { + const result = await client.request(UnshareIssueDocument, { id, userId }); + + return requireMutationEntity( + result.issueUnshare, + "issue", + `Failed to unshare issue "${id}" from user "${userId}"`, + ); +} + +/** Schedules a reminder for the authenticated user at an ISO-8601 instant. */ +export async function remindOnIssue( + client: GraphQLClient, + id: UUID, + reminderAt: string, +): Promise { + const result = await client.request(RemindOnIssueDocument, { + id, + reminderAt, + }); + + return requireMutationEntity( + result.issueReminder, + "issue", + `Failed to set a reminder on issue "${id}"`, + ); +} + export async function deleteIssue( client: GraphQLClient, id: UUID, diff --git a/tests/unit/common/datetime.test.ts b/tests/unit/common/datetime.test.ts new file mode 100644 index 00000000..b84c164a --- /dev/null +++ b/tests/unit/common/datetime.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { parseDateTimeOption } from "../../../src/common/datetime.js"; + +// Fixed reference instant so relative offsets are deterministic. +const now = new Date("2026-08-10T12:00:00.000Z"); + +describe("parseDateTimeOption", () => { + it("resolves relative offsets against the supplied instant", () => { + expect(parseDateTimeOption("--at", "+30m", now)).toBe( + "2026-08-10T12:30:00.000Z", + ); + expect(parseDateTimeOption("--at", "+2h", now)).toBe( + "2026-08-10T14:00:00.000Z", + ); + expect(parseDateTimeOption("--at", "+3d", now)).toBe( + "2026-08-13T12:00:00.000Z", + ); + expect(parseDateTimeOption("--at", "+1w", now)).toBe( + "2026-08-17T12:00:00.000Z", + ); + }); + + it("normalizes ISO-8601 input to UTC", () => { + expect(parseDateTimeOption("--at", "2026-08-14T09:00:00Z", now)).toBe( + "2026-08-14T09:00:00.000Z", + ); + expect(parseDateTimeOption("--at", "2026-08-14T09:00:00+02:00", now)).toBe( + "2026-08-14T07:00:00.000Z", + ); + }); + + it("reads a bare date and a zoneless date-time as UTC", () => { + expect(parseDateTimeOption("--until", "2026-08-20", now)).toBe( + "2026-08-20T00:00:00.000Z", + ); + expect(parseDateTimeOption("--until", "2026-08-20T17:30", now)).toBe( + "2026-08-20T17:30:00.000Z", + ); + }); + + it("keeps a written day that falls on the previous UTC day", () => { + // Guards the calendar check: this is valid input whose UTC day differs + // from the day as written. + expect(parseDateTimeOption("--at", "2026-08-14T01:00:00+05:00", now)).toBe( + "2026-08-13T20:00:00.000Z", + ); + }); + + it("rejects a zero offset", () => { + expect(() => parseDateTimeOption("--at", "+0h", now)).toThrow( + /must be a positive offset/, + ); + }); + + it("rejects a day that does not exist", () => { + expect(() => parseDateTimeOption("--at", "2026-02-30", now)).toThrow( + /is not a real date/, + ); + }); + + it("rejects free-form input, naming the flag", () => { + expect(() => parseDateTimeOption("--at", "tomorrow", now)).toThrow( + /Invalid --at: "tomorrow" must be an ISO-8601 date/, + ); + expect(() => parseDateTimeOption("--at", "-2h", now)).toThrow( + /must be an ISO-8601 date/, + ); + }); +}); diff --git a/tests/unit/resolvers/user-resolver.test.ts b/tests/unit/resolvers/user-resolver.test.ts index d794ec7c..ba602b21 100644 --- a/tests/unit/resolvers/user-resolver.test.ts +++ b/tests/unit/resolvers/user-resolver.test.ts @@ -1,7 +1,11 @@ // tests/unit/resolvers/user-resolver.test.ts import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; -import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; +import { GetViewerDocument } from "../../../src/gql/graphql.js"; +import { + resolveUserId, + resolveViewerId, +} from "../../../src/resolvers/user-resolver.js"; interface MockUser { id: string; @@ -77,3 +81,30 @@ describe("resolveUserId", () => { ); }); }); + +describe("resolveViewerId", () => { + it("returns the authenticated user's UUID", async () => { + const request = vi + .fn() + .mockResolvedValue({ viewer: { id: "viewer-uuid" } }); + const client = { request } as unknown as GraphQLClient; + + await expect(resolveViewerId(client)).resolves.toBe("viewer-uuid"); + expect(request).toHaveBeenCalledWith(GetViewerDocument); + }); +}); + +describe("resolveUserId viewer aliases", () => { + it.each(["me", "@me", "ME"])( + "resolves %s to the viewer without a user lookup", + async (alias) => { + const request = vi + .fn() + .mockResolvedValue({ viewer: { id: "viewer-uuid" } }); + const client = { request } as unknown as GraphQLClient; + + await expect(resolveUserId(client, alias)).resolves.toBe("viewer-uuid"); + expect(request).toHaveBeenCalledExactlyOnceWith(GetViewerDocument); + }, + ); +}); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 763a66d1..5ba0f74b 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -21,8 +21,13 @@ import { GetIssueByIdWithCommentsDocument, GetIssueByIdWithReactionsDocument, GetIssuesDocument, + RemindOnIssueDocument, SearchIssuesDocument, + ShareIssueDocument, + SubscribeToIssueDocument, UnarchiveIssueDocument, + UnshareIssueDocument, + UnsubscribeFromIssueDocument, } from "../../../src/gql/graphql.js"; import { archiveIssue, @@ -39,8 +44,13 @@ import { getIssueWithCommentThreads, getIssueWithReactions, listIssues, + remindOnIssue, searchIssues, + shareIssue, + subscribeToIssue, unarchiveIssue, + unshareIssue, + unsubscribeFromIssue, updateIssue, } from "../../../src/services/issue-service.js"; @@ -1006,3 +1016,89 @@ describe("deleteIssue", () => { ); }); }); + +describe("subscriber and sharing mutations", () => { + const issueId = asUuid("issue-1"); + const userId = asUuid("user-1"); + + const cases = [ + { + name: "subscribeToIssue", + call: subscribeToIssue, + document: SubscribeToIssueDocument, + payloadKey: "issueSubscribe", + failure: 'Failed to subscribe user "user-1" to issue "issue-1"', + }, + { + name: "unsubscribeFromIssue", + call: unsubscribeFromIssue, + document: UnsubscribeFromIssueDocument, + payloadKey: "issueUnsubscribe", + failure: 'Failed to unsubscribe user "user-1" from issue "issue-1"', + }, + { + name: "shareIssue", + call: shareIssue, + document: ShareIssueDocument, + payloadKey: "issueShare", + failure: 'Failed to share issue "issue-1" with user "user-1"', + }, + { + name: "unshareIssue", + call: unshareIssue, + document: UnshareIssueDocument, + payloadKey: "issueUnshare", + failure: 'Failed to unshare issue "issue-1" from user "user-1"', + }, + ] as const; + + it.each(cases)("$name returns the updated issue", async (testCase) => { + const client = mockGqlClient({ + [testCase.payloadKey]: { success: true, issue: { id: "issue-1" } }, + }); + + await expect(testCase.call(client, issueId, userId)).resolves.toEqual({ + id: "issue-1", + }); + expect(client.request).toHaveBeenCalledWith(testCase.document, { + id: "issue-1", + userId: "user-1", + }); + }); + + it.each(cases)("$name throws when the mutation fails", async (testCase) => { + const client = mockGqlClient({ + [testCase.payloadKey]: { success: false, issue: null }, + }); + + await expect(testCase.call(client, issueId, userId)).rejects.toThrow( + testCase.failure, + ); + }); +}); + +describe("remindOnIssue", () => { + it("passes the reminder instant through untouched", async () => { + const client = mockGqlClient({ + issueReminder: { success: true, issue: { id: "issue-1" } }, + }); + + await expect( + remindOnIssue(client, asUuid("issue-1"), "2026-08-14T09:00:00.000Z"), + ).resolves.toEqual({ id: "issue-1" }); + expect(client.request).toHaveBeenCalledWith(RemindOnIssueDocument, { + id: "issue-1", + reminderAt: "2026-08-14T09:00:00.000Z", + }); + }); + + it("throws when the mutation fails", async () => { + const client = mockGqlClient({ + issueReminder: { success: false, issue: null }, + }); + + await expect( + remindOnIssue(client, asUuid("issue-1"), "2026-08-14T09:00:00.000Z"), + ).rejects.toThrow('Failed to set a reminder on issue "issue-1"'); + }); +}); From fb68bcfd2cc2a47bc9dde0018d88bc2d2374a8f4 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:34:13 +0200 Subject: [PATCH 09/70] feat(issues): add batch create and batch update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issueBatchCreate` and `issueBatchUpdate` had no CLI surface, so bulk work meant N invocations and N transactions. `batch create` takes a JSON array whose keys are the `issues create` flag names with the dashes dropped, so there is no second schema to learn. Unknown keys are rejected rather than ignored: a typo like `assingee` would otherwise create the entire batch with the field silently missing, and unpicking that costs far more than a rejected command. Input arrives via `--file`, `--file -` (stdin) or `--json`. `batch update` applies one patch to an explicit `--issues` list, mirroring `issueBatchUpdate(ids, input)`. Selection is deliberately not driven by a filter, because a filter-selected mass mutation is a footgun with no dry-run story. If that is wanted later it should arrive as `issues list … | issues batch update --issues -`, which keeps the selection visible in the pipeline. Two constraints fall out of the API shape and are enforced rather than papered over. The mutation takes a single `stateId`/`cycleId`, so a status or cycle named by word is only meaningful when every target shares one team — the mixed-team case now errors instead of resolving against an arbitrary target's workflow. And labels are overwrite-only, because add/remove would need each target's current label set, which one shared patch cannot express. Resolution goes through a new `resolveBatchCreateIssueIds`, which routes every entry through the existing single-issue resolver so disambiguation and not-found behavior are identical, while collapsing entries that name the same references onto one in-flight request. A batch sharing a team and project therefore costs one lookup, not one per row. Field-level memoization would collapse more but cannot be keyed correctly, because status, cycle and milestone lookups are scoped by the entry's own team and project. `resolveIssueRefs` joins the issue resolver to resolve the whole `--issues` list in one request and return each issue's team, which is what the single-team check needs. UUID references are looked up rather than passed through, since a UUID does not carry a team. The batch subgroup lives in its own `issues-batch.ts` because it has a distinct input format and its own constraints, and `issues.ts` was already the largest command file in the project. --- graphql/mutations/issues.graphql | 24 + graphql/queries/issues.graphql | 1 + src/commands/issues-batch.ts | 615 ++++++++++++++++++ src/commands/issues.ts | 3 + src/resolvers/issue-mutation-resolver.ts | 54 ++ src/resolvers/issue-resolver.ts | 67 ++ src/services/issue-service.ts | 55 +- tests/unit/commands/issues-batch.test.ts | 107 +++ .../resolvers/issue-mutation-resolver.test.ts | 70 ++ tests/unit/resolvers/issue-resolver.test.ts | 89 +++ tests/unit/services/issue-service.test.ts | 67 ++ 11 files changed, 1151 insertions(+), 1 deletion(-) create mode 100644 src/commands/issues-batch.ts create mode 100644 tests/unit/commands/issues-batch.test.ts diff --git a/graphql/mutations/issues.graphql b/graphql/mutations/issues.graphql index 74e7c804..5b367dca 100644 --- a/graphql/mutations/issues.graphql +++ b/graphql/mutations/issues.graphql @@ -51,6 +51,30 @@ 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 diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index 8cc6bc5e..c479b704 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -933,6 +933,7 @@ query FindIssues($filter: IssueFilter, $first: Int = 1) { issues(filter: $filter, first: $first, includeArchived: true) { nodes { id + number team { id key diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts new file mode 100644 index 00000000..b979af76 --- /dev/null +++ b/src/commands/issues-batch.ts @@ -0,0 +1,615 @@ +import { readFileSync } from "node:fs"; +import type { Command } from "commander"; +import { createContext, getRootOpts } from "../common/context.js"; +import { + invalidParameterError, + requiresParameterError, +} from "../common/errors.js"; +import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; +import { 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 { + 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; +} + +interface BatchCreateOptions { + file?: string; + json?: string; +} + +interface BatchUpdateOptions { + 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; + cycle?: string; + dueDate?: string; + clearDueDate?: boolean; +} + +const KNOWN_ENTRY_KEYS: ReadonlySet = new Set([ + "title", + "team", + "description", + "assignee", + "priority", + "estimate", + "project", + "labels", + "projectMilestone", + "cycle", + "status", + "parentTicket", + "dueDate", +]); + +/** Reads the batch document from `--json`, a file, or stdin via `--file -`. */ +function readBatchDocument(options: BatchCreateOptions): 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", + ] 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 = parseLabels(record["labels"], 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 parseLabels(value: unknown, at: string): string[] { + if (typeof value === "string") { + return parseCommaSeparated(value); + } + + if ( + !Array.isArray(value) || + value.length === 0 || + !value.every((label) => typeof label === "string" && label.trim() !== "") + ) { + throw invalidParameterError( + at, + 'has "labels" that is not a non-empty array of strings', + ); + } + + return value; +} + +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; + + 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; + + return input; +} + +/** + * Derives the lookup scope for a batch patch from the targets themselves. + * + * `issueBatchUpdate` applies one `stateId`/`cycleId` to every target, so a + * status or cycle 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. + */ +function buildBatchUpdateContext( + targets: readonly ResolvedIssueRef[], + options: BatchUpdateOptions, +): UpdateIssueContext { + const teamKeys = [...new Set(targets.map((target) => target.teamKey))]; + const [onlyTarget] = targets; + + if (teamKeys.length > 1) { + for (const flag of ["status", "cycle"] as const) { + if (options[flag] !== undefined) { + throw invalidParameterError( + `--${flag}`, + `cannot be resolved across teams ${teamKeys.join(", ")} — pass a UUID, or split the batch per team`, + ); + } + } + + return {}; + } + + return onlyTarget + ? { teamId: onlyTarget.teamId, teamKey: onlyTarget.teamKey } + : {}; +} + +function buildBatchUpdateResolverInput( + options: BatchUpdateOptions, +): ResolveUpdateIssueIdsInput { + const input: ResolveUpdateIssueIdsInput = {}; + + if (!options.clearAssignee && options.assignee) + input.assignee = options.assignee; + if (!options.clearProject && options.project) input.project = options.project; + if (!options.clearLabels && options.labels) { + input.labels = parseCommaSeparated(options.labels); + } + if (options.projectMilestone) + input.projectMilestone = options.projectMilestone; + if (options.cycle) input.cycle = options.cycle; + if (options.status) input.status = options.status; + if (!options.clearParentTicket && options.parentTicket) { + input.parentTicket = options.parentTicket; + } + + return input; +} + +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, + ], + ]; + + 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.", + ].join("\n"), + ) + .option("--file ", "path to the JSON document, or - for stdin") + .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.", + ].join("\n"), + ) + .requiredOption("--issues ", "issues to update (comma-separated)") + .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("--cycle ", "set 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) => { + validateBatchUpdateOptions(options); + + const refs = parseCommaSeparated(options.issues); + const ctx = createContext(getRootOpts(command)); + const targets = await resolveIssueRefs(ctx.gql, refs); + const context = buildBatchUpdateContext(targets, options); + + const resolverInput = buildBatchUpdateResolverInput(options); + const ids = + Object.keys(resolverInput).length > 0 + ? await resolveUpdateIssueIds(ctx.gql, resolverInput, context) + : {}; + + const input = buildBatchUpdateInput(options, 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); + }), + ); +} + +function buildBatchUpdateInput( + options: BatchUpdateOptions, + ids: { + assigneeId?: UUID; + projectId?: UUID; + labelIds?: UUID[]; + projectMilestoneId?: UUID; + cycleId?: UUID; + stateId?: UUID; + parentId?: UUID; + }, +): UpdateIssueInput { + const input: UpdateIssueInput = {}; + + if (options.title) input.title = options.title; + if (options.description) input.description = options.description; + if (options.priority !== undefined) { + input.priority = parsePriorityOption(options.priority); + } + + if (options.clearEstimate) { + input.estimate = null; + } else if (options.estimate !== undefined) { + input.estimate = parseEstimateOption(options.estimate); + } + + if (options.clearAssignee) { + input.assigneeId = null; + } else if (ids.assigneeId) { + input.assigneeId = ids.assigneeId; + } + + if (options.clearProject) { + 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 (options.clearLabels) { + input.labelIds = []; + } else if (ids.labelIds) { + input.labelIds = ids.labelIds; + } + + if (options.clearParentTicket) { + input.parentId = null; + } else if (ids.parentId) { + input.parentId = ids.parentId; + } + + if (ids.projectMilestoneId) input.projectMilestoneId = ids.projectMilestoneId; + if (ids.cycleId) input.cycleId = ids.cycleId; + if (ids.stateId) input.stateId = ids.stateId; + + if (options.clearDueDate) { + input.dueDate = null; + } else if (options.dueDate) { + input.dueDate = parseDueDate(options.dueDate); + } + + return input; +} diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 427597ea..810446eb 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -97,6 +97,7 @@ import { deleteOwnReactionByEmoji, deleteOwnReactionById, } from "../services/reaction-service.js"; +import { addBatchCommands } from "./issues-batch.js"; interface FilterOptions extends RawFilterFlags { limit: string; @@ -586,6 +587,8 @@ function addFilterOptions(cmd: ReturnType): typeof cmd { 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"); diff --git a/src/resolvers/issue-mutation-resolver.ts b/src/resolvers/issue-mutation-resolver.ts index d1daaa10..268e8fd1 100644 --- a/src/resolvers/issue-mutation-resolver.ts +++ b/src/resolvers/issue-mutation-resolver.ts @@ -212,6 +212,60 @@ export async function resolveCreateIssueIds( return resolved; } +/** + * Resolves the human identifiers for a whole batch of issues to create. + * + * Each entry still resolves through {@link resolveCreateIssueIds}, so the + * disambiguation and not-found semantics are identical to a single create — + * a batch must not quietly accept a team name that `issues create` would + * reject. What changes is the round-trip count: entries naming the same set of + * references (the usual case, where a batch shares a team and project and + * differs only in title) are collapsed onto one in-flight request via a + * promise cache, so the cost is one `BatchResolveForCreate` per *distinct* + * reference set rather than per row. + * + * Field-level memoization would collapse more, but status, cycle and milestone + * lookups are scoped by the entry's own team and project, so a per-field cache + * cannot be keyed correctly without duplicating that scoping here. + */ +export async function resolveBatchCreateIssueIds( + client: GraphQLClient, + entries: readonly ResolveCreateIssueIdsInput[], +): Promise { + const inFlight = new Map>(); + + return Promise.all( + entries.map((entry) => { + const key = batchCreateCacheKey(entry); + const cached = inFlight.get(key); + if (cached) return cached; + + const pending = resolveCreateIssueIds(client, entry); + inFlight.set(key, pending); + return pending; + }), + ); +} + +/** + * Stable cache key over exactly the fields {@link resolveCreateIssueIds} reads. + * Title and description are absent by construction — they never reach the + * resolver — so two entries with the same key resolve to the same UUIDs. + */ +function batchCreateCacheKey(entry: ResolveCreateIssueIdsInput): string { + return JSON.stringify([ + entry.team, + entry.assignee ?? null, + entry.project ?? null, + entry.labels ?? null, + entry.projectMilestone ?? null, + entry.cycle ?? null, + entry.status ?? null, + entry.parentTicket ?? null, + entry.withEstimateContext ?? false, + ]); +} + function findTeamNode(nodes: TeamNode[], raw: string): TeamNode | undefined { return nodes.find((n) => n.key === raw) ?? nodes.find((n) => n.name === raw); } diff --git a/src/resolvers/issue-resolver.ts b/src/resolvers/issue-resolver.ts index aceacb1b..98812bda 100644 --- a/src/resolvers/issue-resolver.ts +++ b/src/resolvers/issue-resolver.ts @@ -59,6 +59,73 @@ export async function resolveIssueId( ); } +/** An issue reference resolved to its UUID plus the team that scopes it. */ +export interface ResolvedIssueRef { + ref: string; + id: UUID; + teamId: UUID; + teamKey: string; +} + +/** + * Resolves a list of issue references in one request. + * + * Unlike {@link resolveIssueId} this also returns each issue's team, because + * the batch-update caller needs it: `issueBatchUpdate` applies a single patch + * to every target, so a status or cycle named by word can only be resolved + * when all targets share one team. UUID references are looked up rather than + * passed straight through for the same reason — the team is not derivable from + * a UUID. + * + * Duplicate references collapse to one entry, preserving first-seen order. + * + * @throws Error if any reference does not match an issue + */ +export async function resolveIssueRefs( + client: GraphQLClient, + refs: readonly string[], +): Promise { + const unique = [...new Set(refs)]; + + if (unique.length === 0) { + return []; + } + + const { issues } = await client.request(FindIssuesDocument, { + filter: { or: unique.map(issueLookupFilter) }, + first: unique.length, + }); + + return unique.map((ref) => { + const node = issues.nodes.find((candidate) => + isUuid(ref) + ? candidate.id === ref + : matchesIdentifier(candidate, parseIssueIdentifier(ref)), + ); + + if (!node) { + throw notFoundError("Issue", ref); + } + + return { + ref, + id: asUuid(node.id), + teamId: asUuid(node.team.id), + teamKey: node.team.key, + }; + }); +} + +function matchesIdentifier( + node: { number: number; team: { key: string } }, + identifier: { teamKey: string; issueNumber: number }, +): boolean { + return ( + node.number === identifier.issueNumber && + node.team.key === identifier.teamKey + ); +} + export async function resolveIssueEstimateContext( client: GraphQLClient, issueIdOrIdentifier: string, diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 25fbe7e9..ab8fa3e1 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -1,10 +1,16 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { firstOrThrow } from "../common/array.js"; import type { BrandUuidFields, UUID } from "../common/identifier.js"; -import { requireMutationEntity } from "../common/mutation-payload.js"; +import { + requireMutationEntity, + requireMutationSuccess, +} from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { ArchiveIssueDocument, + BatchCreateIssuesDocument, + type BatchCreateIssuesMutation, + BatchUpdateIssuesDocument, CreateIssueDocument, type CreateIssueMutation, DeleteIssueDocument, @@ -83,6 +89,9 @@ export type CreatedIssue = NonNullable< export type UpdatedIssue = NonNullable< UpdateIssueMutation["issueUpdate"]["issue"] >; +/** An issue as returned by the batch mutations (no comment payload). */ +export type BatchIssue = + BatchCreateIssuesMutation["issueBatchCreate"]["issues"][0]; // Service-owned input types (UUIDs pre-resolved by the command). export type CreateIssueInput = BrandUuidFields< @@ -523,6 +532,50 @@ export async function unarchiveIssue( ); } +/** + * Creates many issues in one `issueBatchCreate` transaction. + * + * Ordering of the returned issues follows the API response, which need not + * match the input order — callers should key off `identifier`/`title` rather + * than position. + */ +export async function batchCreateIssues( + client: GraphQLClient, + inputs: readonly CreateIssueInput[], +): Promise { + const issues: IssueCreateInput[] = [...inputs]; + const result = await client.request(BatchCreateIssuesDocument, { + input: { issues }, + }); + + requireMutationSuccess( + result.issueBatchCreate, + `Failed to create ${inputs.length} issues`, + ); + + return result.issueBatchCreate.issues; +} + +/** Applies one patch to every issue in an explicit UUID list. */ +export async function batchUpdateIssues( + client: GraphQLClient, + ids: readonly UUID[], + input: UpdateIssueInput, +): Promise { + const gqlInput: IssueUpdateInput = input; + const result = await client.request(BatchUpdateIssuesDocument, { + ids: [...ids], + input: gqlInput, + }); + + requireMutationSuccess( + result.issueBatchUpdate, + `Failed to update ${ids.length} issues`, + ); + + return result.issueBatchUpdate.issues; +} + /** * Adds a user to an issue's subscriber list. * diff --git a/tests/unit/commands/issues-batch.test.ts b/tests/unit/commands/issues-batch.test.ts new file mode 100644 index 00000000..2e400e37 --- /dev/null +++ b/tests/unit/commands/issues-batch.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { parseBatchCreateEntries } from "../../../src/commands/issues-batch.js"; + +describe("parseBatchCreateEntries", () => { + it("accepts the single-issue flag names as keys", () => { + const entries = parseBatchCreateEntries( + JSON.stringify([ + { + title: "Fix login", + team: "ENG", + assignee: "alice", + priority: 2, + estimate: 3, + project: "Auth", + projectMilestone: "Beta", + labels: ["bug", "urgent"], + cycle: "Cycle 4", + status: "Todo", + parentTicket: "ENG-1", + description: "body", + dueDate: "2026-09-01", + }, + ]), + ); + + expect(entries).toEqual([ + { + title: "Fix login", + team: "ENG", + assignee: "alice", + priority: 2, + estimate: 3, + project: "Auth", + projectMilestone: "Beta", + labels: ["bug", "urgent"], + cycle: "Cycle 4", + status: "Todo", + parentTicket: "ENG-1", + description: "body", + dueDate: "2026-09-01", + }, + ]); + }); + + it("accepts labels in the comma-separated flag form", () => { + const [entry] = parseBatchCreateEntries( + JSON.stringify([{ title: "T", team: "ENG", labels: "bug, urgent" }]), + ); + + expect(entry?.labels).toEqual(["bug", "urgent"]); + }); + + it("rejects an unknown key rather than dropping it", () => { + // A typo that silently created the whole batch without the assignee would + // be much harder to unpick than a rejected command. + expect(() => + parseBatchCreateEntries( + JSON.stringify([{ title: "T", team: "ENG", assingee: "alice" }]), + ), + ).toThrow(/entry 0: has unknown key "assingee"/); + }); + + it("requires title and team on every entry, naming the entry", () => { + expect(() => + parseBatchCreateEntries( + JSON.stringify([{ title: "T", team: "ENG" }, { title: "U" }]), + ), + ).toThrow(/entry 1: requires a non-empty string "team"/); + }); + + it("requires a project alongside a milestone", () => { + expect(() => + parseBatchCreateEntries( + JSON.stringify([{ title: "T", team: "ENG", projectMilestone: "Beta" }]), + ), + ).toThrow(/entry 0: has projectMilestone without project/); + }); + + it("validates due dates with the same parser as the flag", () => { + expect(() => + parseBatchCreateEntries( + JSON.stringify([{ title: "T", team: "ENG", dueDate: "01-09-2026" }]), + ), + ).toThrow(/Invalid due date format/); + }); + + it("rejects an out-of-range priority", () => { + expect(() => + parseBatchCreateEntries( + JSON.stringify([{ title: "T", team: "ENG", priority: 9 }]), + ), + ).toThrow(/entry 0: has "priority" outside the allowed range/); + }); + + it("rejects documents that are not a non-empty array of objects", () => { + expect(() => parseBatchCreateEntries("not json")).toThrow( + /is not valid JSON/, + ); + expect(() => parseBatchCreateEntries('{"title":"T"}')).toThrow( + /must be a JSON array of issue objects/, + ); + expect(() => parseBatchCreateEntries("[]")).toThrow(/must not be empty/); + expect(() => parseBatchCreateEntries("[1]")).toThrow( + /entry 0: must be an object/, + ); + }); +}); diff --git a/tests/unit/resolvers/issue-mutation-resolver.test.ts b/tests/unit/resolvers/issue-mutation-resolver.test.ts index 8598511b..f9ab2277 100644 --- a/tests/unit/resolvers/issue-mutation-resolver.test.ts +++ b/tests/unit/resolvers/issue-mutation-resolver.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { + resolveBatchCreateIssueIds, resolveCreateIssueIds, resolveUpdateIssueIds, } from "../../../src/resolvers/issue-mutation-resolver.js"; @@ -412,3 +413,72 @@ describe("resolveUpdateIssueIds", () => { expect(result).toEqual({ assigneeId: UUID, projectId: UUID }); }); }); + +describe("resolveBatchCreateIssueIds", () => { + const createResponse = (teamId: string, teamKey = "ENG") => ({ + teams: { + nodes: [ + { + id: teamId, + key: teamKey, + name: "Engineering", + issueEstimationType: "fibonacci", + issueEstimationExtended: false, + issueEstimationAllowZero: false, + }, + ], + }, + assignees: { nodes: [] }, + projects: { nodes: [] }, + labels: { nodes: [] }, + statuses: { nodes: [] }, + cycles: { nodes: [] }, + parentIssues: { nodes: [] }, + }); + + it("collapses entries that name the same references onto one request", async () => { + const request = vi.fn().mockResolvedValue(createResponse("team-uuid")); + const client = { request } as unknown as GraphQLClient; + + const resolved = await resolveBatchCreateIssueIds(client, [ + { team: "ENG" }, + { team: "ENG" }, + { team: "ENG" }, + ]); + + expect(resolved.map((ids) => ids.teamId)).toEqual([ + "team-uuid", + "team-uuid", + "team-uuid", + ]); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("resolves distinct reference sets separately, preserving input order", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(createResponse("team-a")) + .mockResolvedValueOnce(createResponse("team-b", "DES")); + const client = { request } as unknown as GraphQLClient; + + const resolved = await resolveBatchCreateIssueIds(client, [ + { team: "ENG" }, + { team: "DES" }, + ]); + + expect(resolved.map((ids) => ids.teamId)).toEqual(["team-a", "team-b"]); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("propagates a not-found reference instead of skipping the entry", async () => { + const request = vi.fn().mockResolvedValue({ + ...createResponse("team-uuid"), + teams: { nodes: [] }, + }); + const client = { request } as unknown as GraphQLClient; + + await expect( + resolveBatchCreateIssueIds(client, [{ team: "NOPE" }]), + ).rejects.toThrow('Team "NOPE" not found'); + }); +}); diff --git a/tests/unit/resolvers/issue-resolver.test.ts b/tests/unit/resolvers/issue-resolver.test.ts index eab3159b..a0a0fed7 100644 --- a/tests/unit/resolvers/issue-resolver.test.ts +++ b/tests/unit/resolvers/issue-resolver.test.ts @@ -4,6 +4,7 @@ import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { resolveIssueEstimateContext, resolveIssueId, + resolveIssueRefs, } from "../../../src/resolvers/issue-resolver.js"; type IssueNode = { @@ -130,3 +131,91 @@ describe("resolveIssueEstimateContext", () => { ).rejects.toThrow('Issue "ENG-999" not found'); }); }); + +describe("resolveIssueRefs", () => { + const nodes = [ + { + id: "550e8400-e29b-41d4-a716-4466554400e1", + number: 1, + team: { id: teamId, key: "ENG" }, + }, + { id: "eng-2-uuid", number: 2, team: { id: teamId, key: "ENG" } }, + { id: "des-1-uuid", number: 1, team: { id: "des-team", key: "DES" } }, + ]; + + function mockRefsClient() { + const request = vi.fn().mockResolvedValue({ issues: { nodes } }); + return { request, client: { request } as unknown as GraphQLClient }; + } + + it("resolves every reference in a single request", async () => { + const { request, client } = mockRefsClient(); + + const resolved = await resolveIssueRefs(client, ["ENG-1", "DES-1"]); + + expect(resolved).toEqual([ + { + ref: "ENG-1", + id: "550e8400-e29b-41d4-a716-4466554400e1", + teamId, + teamKey: "ENG", + }, + { ref: "DES-1", id: "des-1-uuid", teamId: "des-team", teamKey: "DES" }, + ]); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith(expect.anything(), { + filter: { + or: [ + { number: { eq: 1 }, team: { key: { eq: "ENG" } } }, + { number: { eq: 1 }, team: { key: { eq: "DES" } } }, + ], + }, + first: 2, + }); + }); + + it("distinguishes the same issue number across teams", async () => { + const { client } = mockRefsClient(); + + const resolved = await resolveIssueRefs(client, ["DES-1"]); + + expect(resolved[0]?.id).toBe("des-1-uuid"); + }); + + it("collapses duplicate references, preserving first-seen order", async () => { + const { client } = mockRefsClient(); + + const resolved = await resolveIssueRefs(client, [ + "ENG-2", + "ENG-1", + "ENG-2", + ]); + + expect(resolved.map((entry) => entry.ref)).toEqual(["ENG-2", "ENG-1"]); + }); + + it("looks up UUID references too, since a UUID carries no team", async () => { + const { client } = mockRefsClient(); + + const resolved = await resolveIssueRefs(client, [ + "550e8400-e29b-41d4-a716-4466554400e1", + ]); + + expect(resolved[0]?.teamKey).toBe("ENG"); + }); + + it("throws for a reference with no match", async () => { + const { client } = mockRefsClient(); + + await expect(resolveIssueRefs(client, ["ENG-99"])).rejects.toThrow( + 'Issue "ENG-99" not found', + ); + }); + + it("makes no request for an empty list", async () => { + const { request, client } = mockRefsClient(); + + await expect(resolveIssueRefs(client, [])).resolves.toEqual([]); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 5ba0f74b..4e897626 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -9,6 +9,8 @@ import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { asUuid } from "../../../src/common/identifier.js"; import { ArchiveIssueDocument, + BatchCreateIssuesDocument, + BatchUpdateIssuesDocument, DeleteIssueDocument, FilteredSearchIssuesDocument, FindIssuesDocument, @@ -31,6 +33,8 @@ import { } from "../../../src/gql/graphql.js"; import { archiveIssue, + batchCreateIssues, + batchUpdateIssues, createIssue, deleteIssue, getIssue, @@ -1102,3 +1106,66 @@ describe("remindOnIssue", () => { ).rejects.toThrow('Failed to set a reminder on issue "issue-1"'); }); }); + +describe("batchCreateIssues", () => { + it("wraps the inputs in the batch input shape", async () => { + const client = mockGqlClient({ + issueBatchCreate: { success: true, issues: [{ id: "issue-1" }] }, + }); + + await expect( + batchCreateIssues(client, [ + { title: "A", teamId: asUuid("team-1") }, + { title: "B", teamId: asUuid("team-1") }, + ]), + ).resolves.toEqual([{ id: "issue-1" }]); + + expect(client.request).toHaveBeenCalledWith(BatchCreateIssuesDocument, { + input: { + issues: [ + { title: "A", teamId: "team-1" }, + { title: "B", teamId: "team-1" }, + ], + }, + }); + }); + + it("throws when the transaction reports failure", async () => { + const client = mockGqlClient({ + issueBatchCreate: { success: false, issues: [] }, + }); + + await expect( + batchCreateIssues(client, [{ title: "A", teamId: asUuid("team-1") }]), + ).rejects.toThrow("Failed to create 1 issues"); + }); +}); + +describe("batchUpdateIssues", () => { + it("sends the id list and one shared patch", async () => { + const client = mockGqlClient({ + issueBatchUpdate: { success: true, issues: [{ id: "issue-1" }] }, + }); + + await expect( + batchUpdateIssues(client, [asUuid("issue-1"), asUuid("issue-2")], { + priority: 2, + }), + ).resolves.toEqual([{ id: "issue-1" }]); + + expect(client.request).toHaveBeenCalledWith(BatchUpdateIssuesDocument, { + ids: ["issue-1", "issue-2"], + input: { priority: 2 }, + }); + }); + + it("throws when the transaction reports failure", async () => { + const client = mockGqlClient({ + issueBatchUpdate: { success: false, issues: [] }, + }); + + await expect( + batchUpdateIssues(client, [asUuid("issue-1")], { priority: 2 }), + ).rejects.toThrow("Failed to update 1 issues"); + }); +}); From 1c0ee8c97864dea24a4ba2b895f18a4c07639f32 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:36:17 +0200 Subject: [PATCH 10/70] feat(issues): add from-branch to find an issue by its git branch `issueVcsBranchSearch` was the highest-leverage unwired issue query for a CLI: the caller is almost always sitting in a checkout of the very branch Linear generated for the issue they want to act on, and had no way to get from there to the identifier without opening the web app. The branch argument is optional; with none, the current checkout's branch is read via `git rev-parse --abbrev-ref HEAD`. That runs through `execFileSync`, not a shell, so no branch name or repository path can be interpreted as shell syntax. A missing git, a non-repository directory and a detached HEAD all produce the same actionable error, because from the caller's side they are the same situation: there is no branch to infer, so name one. The lookup returns the same single-issue payload as `issues read`, so `issues from-branch | jq .identifier` is enough to chain into any other issue command. --- graphql/queries/issues.graphql | 10 ++++++ src/commands/issues.ts | 21 ++++++++++++ src/common/git.ts | 38 +++++++++++++++++++++ src/services/issue-service.ts | 24 ++++++++++++++ tests/unit/common/git.test.ts | 40 +++++++++++++++++++++++ tests/unit/services/issue-service.test.ts | 25 ++++++++++++++ 6 files changed, 158 insertions(+) create mode 100644 src/common/git.ts create mode 100644 tests/unit/common/git.test.ts diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index c479b704..b3a3cb96 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -942,6 +942,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 diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 810446eb..9957ff0d 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -7,6 +7,7 @@ import { parseLabelMode } 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, @@ -70,6 +71,7 @@ import { type CreateIssueInput, createIssue, deleteIssue, + findIssueByBranch, getIssue, getIssueByIdentifier, getIssueByIdentifierWithAttachments, @@ -1618,6 +1620,25 @@ 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") diff --git a/src/common/git.ts b/src/common/git.ts new file mode 100644 index 00000000..489ea7c3 --- /dev/null +++ b/src/common/git.ts @@ -0,0 +1,38 @@ +import { execFileSync } from "node:child_process"; +import { invalidParameterError } from "./errors.js"; + +/** + * Reads the checked-out branch name. + * + * Uses `execFileSync` rather than `exec` so nothing reaches a shell. Both a + * missing/failing `git` and a detached HEAD surface as the same actionable + * error, because from the caller's point of view they are the same situation: + * there is no branch to infer, so name one explicitly. + * + * @throws Error if the current directory has no checked-out branch + */ +export function getCurrentBranch(): string { + let branch: string; + + try { + branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + throw invalidParameterError( + "branch", + "could not be read from git — pass a branch name explicitly", + ); + } + + // `rev-parse --abbrev-ref HEAD` prints "HEAD" when the checkout is detached. + if (branch === "" || branch === "HEAD") { + throw invalidParameterError( + "branch", + "is not available (detached HEAD) — pass a branch name explicitly", + ); + } + + return branch; +} diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index ab8fa3e1..3b4fc9b0 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -1,5 +1,6 @@ import type { GraphQLClient } from "../client/graphql-client.js"; import { firstOrThrow } from "../common/array.js"; +import { notFoundError } from "../common/errors.js"; import type { BrandUuidFields, UUID } from "../common/identifier.js"; import { requireMutationEntity, @@ -36,6 +37,7 @@ import { type IssueCreateInput, type IssueFilter, type IssueUpdateInput, + IssueVcsBranchSearchDocument, RemindOnIssueDocument, SearchIssuesDocument, type SearchIssuesQuery, @@ -476,6 +478,28 @@ export async function searchIssues( }; } +/** + * Finds the issue a VCS branch belongs to. + * + * The reverse of the `branchName` field on a read payload. + * + * @throws Error if no issue owns the branch + */ +export async function findIssueByBranch( + client: GraphQLClient, + branchName: string, +): Promise { + const result = await client.request(IssueVcsBranchSearchDocument, { + branchName, + }); + + if (!result.issueVcsBranchSearch) { + throw notFoundError("Issue for branch", branchName); + } + + return result.issueVcsBranchSearch; +} + export async function createIssue( client: GraphQLClient, input: CreateIssueInput, diff --git a/tests/unit/common/git.test.ts b/tests/unit/common/git.test.ts new file mode 100644 index 00000000..f272e800 --- /dev/null +++ b/tests/unit/common/git.test.ts @@ -0,0 +1,40 @@ +import { execFileSync } from "node:child_process"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getCurrentBranch } from "../../../src/common/git.js"; + +vi.mock("node:child_process", () => ({ execFileSync: vi.fn() })); + +const execFileSyncMock = vi.mocked(execFileSync); + +describe("getCurrentBranch", () => { + beforeEach(() => { + execFileSyncMock.mockReset(); + }); + + it("returns the trimmed branch name", () => { + execFileSyncMock.mockReturnValue("feature/login\n"); + + expect(getCurrentBranch()).toBe("feature/login"); + expect(execFileSyncMock).toHaveBeenCalledWith( + "git", + ["rev-parse", "--abbrev-ref", "HEAD"], + expect.objectContaining({ encoding: "utf8" }), + ); + }); + + it("tells the caller to name a branch when git fails", () => { + execFileSyncMock.mockImplementation(() => { + throw new Error("not a git repository"); + }); + + expect(() => getCurrentBranch()).toThrow( + /could not be read from git — pass a branch name explicitly/, + ); + }); + + it("treats a detached HEAD as having no branch", () => { + execFileSyncMock.mockReturnValue("HEAD\n"); + + expect(() => getCurrentBranch()).toThrow(/detached HEAD/); + }); +}); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 4e897626..df21f103 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -23,6 +23,7 @@ import { GetIssueByIdWithCommentsDocument, GetIssueByIdWithReactionsDocument, GetIssuesDocument, + IssueVcsBranchSearchDocument, RemindOnIssueDocument, SearchIssuesDocument, ShareIssueDocument, @@ -37,6 +38,7 @@ import { batchUpdateIssues, createIssue, deleteIssue, + findIssueByBranch, getIssue, getIssueByIdentifier, getIssueByIdentifierWithAttachments, @@ -1169,3 +1171,26 @@ describe("batchUpdateIssues", () => { ).rejects.toThrow("Failed to update 1 issues"); }); }); + +describe("findIssueByBranch", () => { + it("returns the issue owning the branch", async () => { + const client = mockGqlClient({ + issueVcsBranchSearch: { id: "issue-1", identifier: "ENG-1" }, + }); + + await expect( + findIssueByBranch(client, "fabian/eng-1-login"), + ).resolves.toEqual({ id: "issue-1", identifier: "ENG-1" }); + expect(client.request).toHaveBeenCalledWith(IssueVcsBranchSearchDocument, { + branchName: "fabian/eng-1-login", + }); + }); + + it("throws when the branch belongs to no issue", async () => { + const client = mockGqlClient({ issueVcsBranchSearch: null }); + + await expect(findIssueByBranch(client, "main")).rejects.toThrow( + 'Issue for branch "main" not found', + ); + }); +}); From 8c96db11c2f3d61c8cb37174c6e92d633a51f936 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:40:32 +0200 Subject: [PATCH 11/70] feat(issues): support team moves, subscribers and delegates Three writable fields on `IssueUpdateInput` had no CLI surface. `teamId` is the largest gap of the three: moving an issue between teams was simply impossible from the CLI, despite being a routine correction when a ticket is filed in the wrong place. A team move rescopes the other lookups, so `--team` is resolved before the batch resolve request rather than alongside it: a status or cycle named in the same invocation belongs to the destination team's workflow, not the workflow of the team the issue is leaving. Resolving it against the old team would either fail or, worse, find a same-named state and move the issue into a state its new team does not own. `--subscribers` and `--delegate` land on both `create` and `update`, with the `--subscriber-mode add|remove|overwrite` and `--clear-*` counterparts the file already uses for labels. Because the mutation replaces `subscriberIds` wholesale, add and remove are computed against the issue's current roster, which the read payload now carries. Two pieces of shared machinery fall out. `parseLabelMode` becomes a thin wrapper over `parseSetMode(flag, value)` so `--subscriber-mode` reuses the parser without inheriting `--label-mode`'s name in its error message, and the label add/remove/overwrite arithmetic in the update command becomes `applySetMode`, now used by both flags. Subscriber and delegate references resolve through `resolveUserId` in parallel with the batch request rather than inside it. The batch query has a single `$assigneeQuery` variable and no case-insensitive list comparator, so a list of users and a second single user cannot be expressed there; going through the shared resolver also means `me` and name/email disambiguation behave the same as on every other user flag. --- src/commands/issues.ts | 144 ++++++++++++++++-- src/common/domain-values.ts | 23 ++- src/resolvers/issue-mutation-resolver.ts | 75 ++++++++- src/services/issue-service.ts | 10 ++ tests/unit/common/domain-values.test.ts | 20 ++- .../resolvers/issue-mutation-resolver.test.ts | 68 +++++++++ 6 files changed, 313 insertions(+), 27 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 9957ff0d..c7ea5724 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -3,7 +3,11 @@ import { firstOrThrow } from "../common/array.js"; import type { CommandContext } from "../common/context.js"; import { createContext, getRootOpts } from "../common/context.js"; import { parseDateTimeOption } from "../common/datetime.js"; -import { parseLabelMode } from "../common/domain-values.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"; @@ -15,7 +19,10 @@ 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, @@ -82,6 +89,7 @@ import { getIssueWithComments, getIssueWithCommentThreads, getIssueWithReactions, + type IssueDetail, type IssueReadOptions, listIssues, remindOnIssue, @@ -120,6 +128,8 @@ interface CreateOptions { cycle?: string; status?: string; parentTicket?: string; + subscribers?: string; + delegate?: string; dueDate?: string; blocks?: string; blockedBy?: string; @@ -148,6 +158,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; @@ -212,6 +228,35 @@ async function resolveIssueAndUser( ]); } +/** + * 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; } @@ -1202,6 +1247,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 ") @@ -1250,6 +1297,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); @@ -1309,6 +1360,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); } @@ -1352,6 +1411,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)") @@ -1423,7 +1488,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 @@ -1463,7 +1556,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; @@ -1503,6 +1599,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 || @@ -1511,7 +1614,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) @@ -1564,15 +1670,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) { @@ -1599,6 +1697,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) { diff --git a/src/common/domain-values.ts b/src/common/domain-values.ts index 0bd28bc9..e5e79e37 100644 --- a/src/common/domain-values.ts +++ b/src/common/domain-values.ts @@ -3,17 +3,30 @@ import { invalidParameterError } from "./errors.js"; /** Linear priority scale: 0=none, 1=urgent, 2=high, 3=medium, 4=low. */ export type Priority = 0 | 1 | 2 | 3 | 4; -/** How `issues update --labels` combines with existing labels. */ -export type LabelMode = "add" | "remove" | "overwrite"; +/** + * How a set-valued update flag combines with the issue's existing values — + * shared by `--label-mode` and `--subscriber-mode`. + */ +export type SetMode = "add" | "remove" | "overwrite"; -export function parseLabelMode( +/** + * @param flag - Flag name for the error message, so a shared parser still + * reports the flag the caller actually typed + */ +export function parseSetMode( + flag: string, value: string | undefined, -): LabelMode | undefined { +): SetMode | undefined { if (value === undefined) return undefined; if (value === "add" || value === "remove" || value === "overwrite") return value; throw invalidParameterError( - "--label-mode", + flag, "must be one of 'add', 'remove', or 'overwrite'", ); } + +/** How `issues update --labels` combines with existing labels. */ +export function parseLabelMode(value: string | undefined): SetMode | undefined { + return parseSetMode("--label-mode", value); +} diff --git a/src/resolvers/issue-mutation-resolver.ts b/src/resolvers/issue-mutation-resolver.ts index 268e8fd1..99d54a10 100644 --- a/src/resolvers/issue-mutation-resolver.ts +++ b/src/resolvers/issue-mutation-resolver.ts @@ -22,7 +22,8 @@ import { type ProjectNode, type TeamNode, } from "./batch-resolve-mappers.js"; -import type { TeamEstimateContext } from "./team-resolver.js"; +import { resolveTeamId, type TeamEstimateContext } from "./team-resolver.js"; +import { resolveUserId } from "./user-resolver.js"; /** * Batch resolver for issue create / update. @@ -72,6 +73,9 @@ export interface ResolveCreateIssueIdsInput { cycle?: string; status?: string; parentTicket?: string; + /** User references (name, email, UUID or `me`) to subscribe on creation. */ + subscribers?: string[]; + delegate?: string; /** When true, resolve the team's estimation config for `--estimate` validation. */ withEstimateContext?: boolean; } @@ -86,6 +90,34 @@ export interface ResolvedCreateIssueIds { cycleId?: UUID; stateId?: UUID; parentId?: UUID; + subscriberIds?: UUID[]; + delegateId?: UUID; +} + +/** + * Resolves the user-valued references that the `BatchResolve*` queries cannot. + * + * `--subscribers` is a list and `--delegate` a second single user, neither of + * which the batch query's one `$assigneeQuery` variable can express, and a + * `me` alias needs the viewer lookup. They go through `resolveUserId` in + * parallel instead, which keeps the name/email disambiguation identical to + * every other user flag and costs nothing when the flags are absent. + */ +async function resolveIssueUserRefs( + client: GraphQLClient, + input: { subscribers?: string[]; delegate?: string }, +): Promise<{ subscriberIds?: UUID[]; delegateId?: UUID }> { + const [subscriberIds, delegateId] = await Promise.all([ + input.subscribers + ? Promise.all(input.subscribers.map((ref) => resolveUserId(client, ref))) + : undefined, + input.delegate ? resolveUserId(client, input.delegate) : undefined, + ]); + + return { + ...(subscriberIds && { subscriberIds }), + ...(delegateId && { delegateId }), + }; } /** @@ -117,6 +149,8 @@ export async function resolveCreateIssueIds( ? parseIssueIdentifier(input.parentTicket) : null; + const userRefsPromise = resolveIssueUserRefs(client, input); + const response = await client.request(BatchResolveForCreateDocument, { teamKey: teamIsUuid ? null : input.team, teamName: teamIsUuid ? null : input.team, @@ -209,7 +243,7 @@ export async function resolveCreateIssueIds( : mapParent(response.parentIssues.nodes, input.parentTicket); } - return resolved; + return { ...resolved, ...(await userRefsPromise) }; } /** @@ -262,6 +296,8 @@ function batchCreateCacheKey(entry: ResolveCreateIssueIdsInput): string { entry.cycle ?? null, entry.status ?? null, entry.parentTicket ?? null, + entry.subscribers ?? null, + entry.delegate ?? null, entry.withEstimateContext ?? false, ]); } @@ -295,6 +331,10 @@ export interface ResolveUpdateIssueIdsInput { cycle?: string; status?: string; parentTicket?: string; + /** Destination team for a move; also rescopes status / cycle resolution. */ + team?: string; + subscribers?: string[]; + delegate?: string; } export interface ResolvedUpdateIssueIds { @@ -306,6 +346,10 @@ export interface ResolvedUpdateIssueIds { cycleId?: UUID; stateId?: UUID; parentId?: UUID; + teamId?: UUID; + /** Resolved subscriber UUIDs (add/remove/overwrite set math stays in the command). */ + subscriberIds?: UUID[]; + delegateId?: UUID; } /** @@ -348,6 +392,19 @@ export async function resolveUpdateIssueIds( ? parseIssueIdentifier(input.parentTicket) : null; + const userRefsPromise = resolveIssueUserRefs(client, input); + + // A team move rescopes the lookups: a status or cycle named alongside + // `--team` belongs to the *destination* team's workflow, not the team the + // issue is leaving. This is the one lookup that cannot be batched with the + // rest, since its result is an input to them. + const destinationTeamId = input.team + ? await resolveTeamId(client, input.team) + : undefined; + const scope: UpdateIssueContext = destinationTeamId + ? { teamId: destinationTeamId } + : context; + const response = await client.request(BatchResolveForUpdateDocument, { assigneeQuery, projectName: projectNameVar, @@ -355,14 +412,16 @@ export async function resolveUpdateIssueIds( labelFilter: buildLabelFilter(labelNames), statusName, cycleName, - teamKey: context.teamKey ?? null, - teamId: context.teamId ?? null, + teamKey: scope.teamKey ?? null, + teamId: scope.teamId ?? null, milestoneName, parentTeamKey: parent?.teamKey ?? null, parentIssueNumber: parent?.issueNumber ?? null, }); - const resolved: ResolvedUpdateIssueIds = {}; + const resolved: ResolvedUpdateIssueIds = destinationTeamId + ? { teamId: destinationTeamId } + : {}; if (input.assignee) { resolved.assigneeId = isUuid(input.assignee) @@ -401,7 +460,7 @@ export async function resolveUpdateIssueIds( if (input.cycle) { resolved.cycleId = isUuid(input.cycle) ? asUuid(input.cycle) - : mapCycle(response.cycles.nodes, input.cycle, context.teamKey); + : mapCycle(response.cycles.nodes, input.cycle, scope.teamKey); } if (input.status) { @@ -410,7 +469,7 @@ export async function resolveUpdateIssueIds( : mapStatus( response.statuses.nodes, input.status, - context.teamId ? `for team ${context.teamId}` : undefined, + scope.teamId ? `for team ${scope.teamId}` : undefined, ); } @@ -420,5 +479,5 @@ export async function resolveUpdateIssueIds( : mapParent(response.parentIssues.nodes, input.parentTicket); } - return resolved; + return { ...resolved, ...(await userRefsPromise) }; } diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 3b4fc9b0..37428278 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -112,6 +112,8 @@ export type CreateIssueInput = BrandUuidFields< | "stateId" | "parentId" | "dueDate" + | "subscriberIds" + | "delegateId" >, | "teamId" | "assigneeId" @@ -121,6 +123,8 @@ export type CreateIssueInput = BrandUuidFields< | "cycleId" | "stateId" | "parentId" + | "subscriberIds" + | "delegateId" >; export type UpdateIssueInput = BrandUuidFields< Pick< @@ -137,6 +141,9 @@ export type UpdateIssueInput = BrandUuidFields< | "projectMilestoneId" | "cycleId" | "dueDate" + | "teamId" + | "subscriberIds" + | "delegateId" >, | "stateId" | "assigneeId" @@ -145,6 +152,9 @@ export type UpdateIssueInput = BrandUuidFields< | "parentId" | "projectMilestoneId" | "cycleId" + | "teamId" + | "subscriberIds" + | "delegateId" >; /** diff --git a/tests/unit/common/domain-values.test.ts b/tests/unit/common/domain-values.test.ts index c802505f..064b3eb1 100644 --- a/tests/unit/common/domain-values.test.ts +++ b/tests/unit/common/domain-values.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { parseLabelMode } from "../../../src/common/domain-values.js"; +import { + parseLabelMode, + parseSetMode, +} from "../../../src/common/domain-values.js"; describe("parseLabelMode", () => { it("returns undefined when value is undefined", () => { @@ -21,3 +24,18 @@ describe("parseLabelMode", () => { ); }); }); + +describe("parseSetMode", () => { + it("names the caller's flag in the error, not a hardcoded one", () => { + // --subscriber-mode shares parseLabelMode's implementation; the message + // must still point at the flag the caller actually typed. + expect(() => parseSetMode("--subscriber-mode", "replace")).toThrow( + "Invalid --subscriber-mode: must be one of 'add', 'remove', or 'overwrite'", + ); + }); + + it("narrows valid values and passes undefined through", () => { + expect(parseSetMode("--subscriber-mode", undefined)).toBeUndefined(); + expect(parseSetMode("--subscriber-mode", "add")).toBe("add"); + }); +}); diff --git a/tests/unit/resolvers/issue-mutation-resolver.test.ts b/tests/unit/resolvers/issue-mutation-resolver.test.ts index f9ab2277..d3dfbc0b 100644 --- a/tests/unit/resolvers/issue-mutation-resolver.test.ts +++ b/tests/unit/resolvers/issue-mutation-resolver.test.ts @@ -482,3 +482,71 @@ describe("resolveBatchCreateIssueIds", () => { ).rejects.toThrow('Team "NOPE" not found'); }); }); + +describe("resolveUpdateIssueIds team move", () => { + it("scopes the status lookup to the destination team, not the current one", async () => { + // resolveTeamId runs first (FindTeams), then the batch request. + const request = vi + .fn() + .mockResolvedValueOnce({ + teams: { nodes: [{ id: "des-team", key: "DES", name: "Design" }] }, + }) + .mockResolvedValueOnce( + buildResponse({ + statuses: [ + { + id: "des-review", + name: "In Review", + team: { id: "des-team", key: "DES" }, + }, + ], + }), + ); + const client = { request } as unknown as GraphQLClient; + + const result = await resolveUpdateIssueIds( + client, + { team: "DES", status: "In Review" }, + { teamId: "eng-team" as never, teamKey: "ENG" }, + ); + + expect(result.teamId).toBe("des-team"); + expect(result.stateId).toBe("des-review"); + expect(request).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ teamId: "des-team", teamKey: null }), + ); + }); +}); + +describe("resolveUpdateIssueIds user references", () => { + it("resolves subscribers and delegate through the user resolver", async () => { + const request = vi.fn().mockImplementation((_document, variables) => { + const filter = ( + variables as { filter?: Record } | undefined + )?.filter; + + if (filter && "displayName" in filter) { + const name = (filter as { displayName: { eqIgnoreCase: string } }) + .displayName.eqIgnoreCase; + return Promise.resolve({ + users: { + nodes: [{ id: `${name}-uuid`, name, email: `${name}@x.io` }], + }, + }); + } + + return Promise.resolve(buildResponse({})); + }); + const client = { request } as unknown as GraphQLClient; + + const result = await resolveUpdateIssueIds( + client, + { subscribers: ["alice", "bob"], delegate: "carol" }, + {}, + ); + + expect(result.subscriberIds).toEqual(["alice-uuid", "bob-uuid"]); + expect(result.delegateId).toBe("carol-uuid"); + }); +}); From 00bf1919625da595cd51b7f51b7185b24cbd655b Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:41:33 +0200 Subject: [PATCH 12/70] feat(issues): add restore and snooze `issues delete` maps to `issueDelete`, which trashes rather than destroys, but there was no way back: `issues unarchive` covers a different state, and `issueUpdate(trashed: false)` was unreachable. A mistaken `delete` therefore had to be undone in the web app. `issues restore` closes that loop, and `issues snooze --until ` exposes `snoozedUntilAt`, with `--clear` to wake an issue again. Both are named service functions rather than raw `issues update` flags because both are lifecycle transitions with a single meaning, and `--trashed false` would read as an odd way to spell "undelete". `--until` shares the `parseDateTimeOption` parser with `remind --at`, so `+3d` works for both. `snoozedById` is left to the API, which attributes the snooze to the authenticated user. --- src/commands/issues.ts | 65 +++++++++++++++++++++++ src/services/issue-service.ts | 30 +++++++++++ tests/unit/services/issue-service.test.ts | 55 +++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index c7ea5724..8702ca20 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -93,8 +93,10 @@ import { type IssueReadOptions, listIssues, remindOnIssue, + restoreIssue, searchIssues, shareIssue, + snoozeIssue, subscribeToIssue, type UpdateIssueInput, unarchiveIssue, @@ -207,6 +209,28 @@ 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. * @@ -1902,6 +1926,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/services/issue-service.ts b/src/services/issue-service.ts index 37428278..512dd4ce 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -144,6 +144,8 @@ export type UpdateIssueInput = BrandUuidFields< | "teamId" | "subscriberIds" | "delegateId" + | "snoozedUntilAt" + | "trashed" >, | "stateId" | "assigneeId" @@ -540,6 +542,34 @@ export async function updateIssue( ); } +/** + * Restores an issue from the trash. + * + * `issues delete` maps to `issueDelete`, which trashes rather than destroys; + * `trashed: false` is the only way back, and `issueUnarchive` does not cover it + * — archiving and trashing are separate states. + */ +export async function restoreIssue( + client: GraphQLClient, + id: UUID, +): Promise { + return updateIssue(client, id, { trashed: false }); +} + +/** + * Snoozes an issue until an instant, or wakes it with `null`. + * + * `snoozedById` is left to the API, which attributes the snooze to the + * authenticated user. + */ +export async function snoozeIssue( + client: GraphQLClient, + id: UUID, + snoozedUntilAt: string | null, +): Promise { + return updateIssue(client, id, { snoozedUntilAt }); +} + export async function archiveIssue( client: GraphQLClient, id: UUID, diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index df21f103..0e068eff 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -31,6 +31,7 @@ import { UnarchiveIssueDocument, UnshareIssueDocument, UnsubscribeFromIssueDocument, + UpdateIssueDocument, } from "../../../src/gql/graphql.js"; import { archiveIssue, @@ -51,8 +52,10 @@ import { getIssueWithReactions, listIssues, remindOnIssue, + restoreIssue, searchIssues, shareIssue, + snoozeIssue, subscribeToIssue, unarchiveIssue, unshareIssue, @@ -1194,3 +1197,55 @@ describe("findIssueByBranch", () => { ); }); }); + +describe("restoreIssue", () => { + it("untrashes via issueUpdate, since issueUnarchive does not cover trash", async () => { + const client = mockGqlClient({ + issueUpdate: { success: true, issue: { id: "issue-1" } }, + }); + + await expect(restoreIssue(client, asUuid("issue-1"))).resolves.toEqual({ + id: "issue-1", + }); + expect(client.request).toHaveBeenCalledWith(UpdateIssueDocument, { + id: "issue-1", + input: { trashed: false }, + }); + }); +}); + +describe("snoozeIssue", () => { + it("sets the snooze instant", async () => { + const client = mockGqlClient({ + issueUpdate: { success: true, issue: { id: "issue-1" } }, + }); + + await snoozeIssue(client, asUuid("issue-1"), "2026-08-20T00:00:00.000Z"); + expect(client.request).toHaveBeenCalledWith(UpdateIssueDocument, { + id: "issue-1", + input: { snoozedUntilAt: "2026-08-20T00:00:00.000Z" }, + }); + }); + + it("wakes the issue with an explicit null", async () => { + const client = mockGqlClient({ + issueUpdate: { success: true, issue: { id: "issue-1" } }, + }); + + await snoozeIssue(client, asUuid("issue-1"), null); + expect(client.request).toHaveBeenCalledWith(UpdateIssueDocument, { + id: "issue-1", + input: { snoozedUntilAt: null }, + }); + }); + + it("surfaces a failed update", async () => { + const client = mockGqlClient({ + issueUpdate: { success: false, issue: null }, + }); + + await expect(snoozeIssue(client, asUuid("issue-1"), null)).rejects.toThrow( + "Failed to update issue", + ); + }); +}); From 2e01ad344b9dbd3f815b7c15a957283687b4faf2 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:43:53 +0200 Subject: [PATCH 13/70] feat(issues): add order-by, unassigned, state-type and subscriber filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IssueFilter` exposes several dimensions the CLI could not reach. Three of them come up constantly in triage: issues with nobody on them, issues in a state *category* rather than a specific named state, and issues a particular person follows. Sort order was hardcoded to `updatedAt` with no way to ask for creation order. `--state-type` takes the documented `WorkflowState.type` categories, which is what makes it worth having over `--status`: state names are per-team, so "everything in progress across four teams" was previously unaskable without enumerating each team's workflow. `duplicate` is excluded from the accepted values — it is not a category anyone filters a work list by, and Linear models duplicates as a relation. `--unassigned` rejects being combined with `--assignee`. They describe the same field in contradictory ways, and the resulting filter would silently match nothing rather than fail. `--order-by` lives on `list` alone rather than in the shared filter options. `search` and `list --query` go through `searchIssues`, whose results are relevance-ordered by the API, so the flag would have nothing to act on there; passing it alongside `--query` is an error rather than a silent no-op. The implicit "exclude completed" clause on an unfiltered `list` survives the orderBy parameterization, and is now covered by a regression test — it is the one piece of default behavior these knobs could plausibly have knocked out. --- src/commands/issues.ts | 42 +++++++++++++++++--- src/common/issue-filter.ts | 34 ++++++++++++++++ src/common/resolve-filters.ts | 27 +++++++++++-- src/services/issue-filter.ts | 11 ++++++ src/services/issue-service.ts | 14 +++++-- tests/unit/common/issue-filter.test.ts | 16 ++++++++ tests/unit/common/resolve-filters.test.ts | 46 ++++++++++++++++++++++ tests/unit/services/issue-filter.test.ts | 20 ++++++++++ tests/unit/services/issue-service.test.ts | 48 +++++++++++++++++++++++ 9 files changed, 247 insertions(+), 11 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 8702ca20..ec2772a6 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -34,7 +34,7 @@ import { 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, @@ -116,6 +116,7 @@ interface FilterOptions extends RawFilterFlags { after?: string; query?: string; includeArchived?: boolean; + orderBy?: string; } interface CreateOptions { @@ -616,11 +617,26 @@ async function resolveAndApplyRelations( */ function buildIssueReadOptions( pagination: PaginationOptions, - options: Pick, + options: Pick, ): IssueReadOptions { - return options.includeArchived - ? { ...pagination, includeArchived: true } - : pagination; + 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 { @@ -652,6 +668,12 @@ function addFilterOptions(cmd: ReturnType): typeof cmd { .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("--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 in the results"); } @@ -733,10 +755,20 @@ 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 readOptions = buildIssueReadOptions( diff --git a/src/common/issue-filter.ts b/src/common/issue-filter.ts index 20726b71..0e00f5e7 100644 --- a/src/common/issue-filter.ts +++ b/src/common/issue-filter.ts @@ -23,6 +23,9 @@ export interface IssueFilterOptions { updatedBefore?: string; hasBlockers?: boolean; isBlocking?: boolean; + unassigned?: boolean; + stateType?: WorkflowStateType; + subscriberId?: string; } export interface RawFilterFlags { @@ -47,6 +50,37 @@ export interface RawFilterFlags { updatedBefore?: string; hasBlockers?: boolean; isBlocking?: boolean; + unassigned?: boolean; + stateType?: string; + subscriber?: string; +} + +/** + * Workflow-state categories, as documented on `WorkflowState.type`. + * + * `duplicate` is omitted: it is not a category a caller filters a work list by, + * and Linear surfaces duplicates through the relation instead. + */ +export const WORKFLOW_STATE_TYPES = [ + "triage", + "backlog", + "unstarted", + "started", + "completed", + "canceled", +] as const; + +export type WorkflowStateType = (typeof WORKFLOW_STATE_TYPES)[number]; + +export function parseWorkflowStateType(value: string): WorkflowStateType { + if ((WORKFLOW_STATE_TYPES as readonly string[]).includes(value)) { + return value as WorkflowStateType; + } + + throw invalidParameterError( + "--state-type", + `must be one of ${WORKFLOW_STATE_TYPES.join(", ")}`, + ); } export function validatePriority(value: number): void { diff --git a/src/common/resolve-filters.ts b/src/common/resolve-filters.ts index 02bf5b40..82a4003a 100644 --- a/src/common/resolve-filters.ts +++ b/src/common/resolve-filters.ts @@ -1,11 +1,13 @@ import { resolveSearchFilterIds } from "../resolvers/issue-filter-resolver.js"; import { resolveMilestoneId } from "../resolvers/milestone-resolver.js"; +import { resolveUserId } from "../resolvers/user-resolver.js"; import type { CommandContext } from "./context.js"; import { invalidParameterError } from "./errors.js"; import { parseDueDate } from "./identifier.js"; import { type IssueFilterOptions, parseCommaSeparated, + parseWorkflowStateType, type RawFilterFlags, validateDateRange, validateEstimate, @@ -82,9 +84,22 @@ export async function resolveFilterOptions( validateEstimate(parsedEstimate); } + const parsedStateType = opts.stateType + ? parseWorkflowStateType(opts.stateType) + : undefined; + // 2. Dependency validation validateFilterDependencies(opts); + // --unassigned and --assignee describe the same field in contradictory ways; + // combining them would silently produce a filter that matches nothing. + if (opts.unassigned && opts.assignee) { + throw invalidParameterError( + "--unassigned", + "cannot be combined with --assignee", + ); + } + // 3. Date range validation validateDateRange(opts.dueAfter, opts.dueBefore, "due date"); validateDateRange(opts.createdAfter, opts.createdBefore, "created date"); @@ -122,9 +137,12 @@ export async function resolveFilterOptions( ) : {}; - const milestoneId = opts.milestone - ? await resolveMilestoneId(ctx.gql, opts.milestone, opts.project) - : undefined; + const [milestoneId, subscriberId] = await Promise.all([ + opts.milestone + ? resolveMilestoneId(ctx.gql, opts.milestone, opts.project) + : undefined, + opts.subscriber ? resolveUserId(ctx.gql, opts.subscriber) : undefined, + ]); const resolved: IssueFilterOptions = omitUndefined({ ...batchResolved, @@ -141,6 +159,9 @@ export async function resolveFilterOptions( updatedBefore: opts.updatedBefore, hasBlockers: opts.hasBlockers, isBlocking: opts.isBlocking, + unassigned: opts.unassigned, + stateType: parsedStateType, + subscriberId, }); return resolved; diff --git a/src/services/issue-filter.ts b/src/services/issue-filter.ts index c61c0810..f110ce58 100644 --- a/src/services/issue-filter.ts +++ b/src/services/issue-filter.ts @@ -63,6 +63,17 @@ export function buildIssueFilter( if (options.updatedBefore) { fragments.push({ updatedAt: { lt: options.updatedBefore } }); } + if (options.unassigned) { + fragments.push({ assignee: { null: true } }); + } + if (options.stateType) { + fragments.push({ state: { type: { eq: options.stateType } } }); + } + if (options.subscriberId) { + fragments.push({ + subscribers: { some: { id: { eq: options.subscriberId } } }, + }); + } if (options.hasBlockers !== undefined) { fragments.push({ hasBlockedByRelations: { eq: options.hasBlockers } }); } diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index 512dd4ce..a6f58248 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -38,6 +38,7 @@ import { type IssueFilter, type IssueUpdateInput, IssueVcsBranchSearchDocument, + type PaginationOrderBy, RemindOnIssueDocument, SearchIssuesDocument, type SearchIssuesQuery, @@ -166,6 +167,8 @@ export type UpdateIssueInput = BrandUuidFields< */ export interface IssueReadOptions extends PaginationOptions { includeArchived?: boolean; + /** Defaults to `updatedAt` — most-recently-touched first. */ + orderBy?: PaginationOrderBy; } const NON_COMPLETED_ISSUES_FILTER: IssueFilter = { @@ -308,14 +311,19 @@ export async function listIssues( options: IssueReadOptions = {}, filter?: IssueFilter, ): Promise> { - const { limit = 25, after, includeArchived = false } = options; + const { + limit = 25, + after, + includeArchived = false, + orderBy = "updatedAt", + } = options; if (filter) { const result = await client.request(FilteredSearchIssuesDocument, { first: limit, after, filter: buildListIssuesFilter(filter), - orderBy: "updatedAt", + orderBy, includeArchived, }); return { @@ -327,7 +335,7 @@ export async function listIssues( const result = await client.request(GetIssuesDocument, { first: limit, after, - orderBy: "updatedAt", + orderBy, includeArchived, }); return { diff --git a/tests/unit/common/issue-filter.test.ts b/tests/unit/common/issue-filter.test.ts index a706f265..a5afb152 100644 --- a/tests/unit/common/issue-filter.test.ts +++ b/tests/unit/common/issue-filter.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { parseCommaSeparated, + parseWorkflowStateType, validateDateRange, validateEstimate, validateFilterDependencies, validatePriority, + WORKFLOW_STATE_TYPES, } from "../../../src/common/issue-filter.js"; describe("validatePriority", () => { @@ -158,3 +160,17 @@ describe("parseCommaSeparated", () => { expect(() => parseCommaSeparated("a, ,b")).toThrow("empty"); }); }); + +describe("parseWorkflowStateType", () => { + it("accepts every documented category", () => { + for (const type of WORKFLOW_STATE_TYPES) { + expect(parseWorkflowStateType(type)).toBe(type); + } + }); + + it("rejects anything else, listing the valid values", () => { + expect(() => parseWorkflowStateType("done")).toThrow( + "Invalid --state-type: must be one of triage, backlog, unstarted, started, completed, canceled", + ); + }); +}); diff --git a/tests/unit/common/resolve-filters.test.ts b/tests/unit/common/resolve-filters.test.ts index cfa372e1..0b14d2b2 100644 --- a/tests/unit/common/resolve-filters.test.ts +++ b/tests/unit/common/resolve-filters.test.ts @@ -4,6 +4,7 @@ import type { CommandContext } from "../../../src/common/context.js"; import { resolveFilterOptions } from "../../../src/common/resolve-filters.js"; import { resolveSearchFilterIds } from "../../../src/resolvers/issue-filter-resolver.js"; import { resolveMilestoneId } from "../../../src/resolvers/milestone-resolver.js"; +import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; vi.mock("../../../src/resolvers/issue-filter-resolver.js", () => ({ resolveSearchFilterIds: vi.fn().mockResolvedValue({ @@ -22,6 +23,10 @@ vi.mock("../../../src/resolvers/milestone-resolver.js", () => ({ resolveMilestoneId: vi.fn().mockResolvedValue("milestone-uuid"), })); +vi.mock("../../../src/resolvers/user-resolver.js", () => ({ + resolveUserId: vi.fn().mockResolvedValue("subscriber-uuid"), +})); + function mockContext(): CommandContext { return { gql: {} as unknown as GraphQLClient, @@ -207,3 +212,44 @@ describe("resolveFilterOptions", () => { expect(resolveSearchFilterIds).not.toHaveBeenCalled(); }); }); + +describe("resolveFilterOptions state and subscriber flags", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects --unassigned together with --assignee", async () => { + // The two describe the same field in contradictory ways; combining them + // would silently build a filter that matches nothing. + await expect( + resolveFilterOptions(mockContext(), { + unassigned: true, + assignee: "alice", + }), + ).rejects.toThrow( + "Invalid --unassigned: cannot be combined with --assignee", + ); + }); + + it("validates --state-type before making any request", async () => { + await expect( + resolveFilterOptions(mockContext(), { stateType: "done" }), + ).rejects.toThrow(/Invalid --state-type/); + expect(resolveSearchFilterIds).not.toHaveBeenCalled(); + }); + + it("resolves --subscriber to a user UUID", async () => { + const result = await resolveFilterOptions(mockContext(), { + subscriber: "alice", + stateType: "started", + unassigned: true, + }); + + expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "alice"); + expect(result).toMatchObject({ + subscriberId: "subscriber-uuid", + stateType: "started", + unassigned: true, + }); + }); +}); diff --git a/tests/unit/services/issue-filter.test.ts b/tests/unit/services/issue-filter.test.ts index 1f8b0bd2..e8879e26 100644 --- a/tests/unit/services/issue-filter.test.ts +++ b/tests/unit/services/issue-filter.test.ts @@ -184,3 +184,23 @@ describe("buildIssueFilter", () => { }); }); }); + +describe("buildIssueFilter state, assignee and subscriber scoping", () => { + it("maps --unassigned to a null assignee", () => { + expect(buildIssueFilter({ unassigned: true })).toEqual({ + and: [{ assignee: { null: true } }], + }); + }); + + it("maps --state-type to the state category", () => { + expect(buildIssueFilter({ stateType: "started" })).toEqual({ + and: [{ state: { type: { eq: "started" } } }], + }); + }); + + it("maps --subscriber to a subscribers.some clause", () => { + expect(buildIssueFilter({ subscriberId: "user-1" })).toEqual({ + and: [{ subscribers: { some: { id: { eq: "user-1" } } } }], + }); + }); +}); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index 0e068eff..fb3525d7 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -218,6 +218,54 @@ describe("listIssues", () => { } }); + it("keeps updatedAt ordering by default and honours an override", async () => { + for (const [options, expected] of [ + [{ limit: 10 }, "updatedAt"], + [{ limit: 10, orderBy: "createdAt" as const }, "createdAt"], + ] as const) { + const client = mockGqlClient({ + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + await listIssues(client, options); + expect(client.request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ orderBy: expected }), + ); + } + }); + + it("still excludes completed issues by default when a filter is given", async () => { + // Regression guard: parameterizing orderBy/includeArchived must not drop + // the implicit non-completed clause. + const client = mockGqlClient({ + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + await listIssues( + client, + { limit: 10, orderBy: "createdAt", includeArchived: true }, + { priority: { eq: 1 } }, + ); + + expect(client.request).toHaveBeenCalledWith( + FilteredSearchIssuesDocument, + expect.objectContaining({ + filter: { + and: [ + { state: { type: { neq: "completed" } } }, + { priority: { eq: 1 } }, + ], + }, + }), + ); + }); + it("returns issues from query", async () => { const client = mockGqlClient({ issues: { From 9e7cf044fe240dc665f31a51f5f2b8b820bd6f3c Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:44:57 +0200 Subject: [PATCH 14/70] feat(attachments): add disable-sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issueExternalSyncDisable` is the last unwired mutation in the attachment surface. Its name puts it in the issue domain, but its only argument is `attachmentId`: one issue can carry several synced attachments — a GitHub PR and a Sentry issue, say — and they are disabled one at a time. It therefore belongs on `attachments`, and the README's issue row was miscounting it as an issue-domain gap. The command is `attachments disable-sync ` and returns the affected issue, which is what the mutation's payload carries. --- graphql/mutations/attachments.graphql | 14 +++++++++ src/commands/attachments.ts | 21 ++++++++++++- src/services/attachment-service.ts | 27 +++++++++++++++++ .../unit/services/attachment-service.test.ts | 30 +++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) 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/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/services/attachment-service.ts b/src/services/attachment-service.ts index 435f03cb..886b3a90 100644 --- a/src/services/attachment-service.ts +++ b/src/services/attachment-service.ts @@ -9,6 +9,8 @@ import { type AttachmentCreateInput, type AttachmentCreateMutation, AttachmentDeleteDocument, + AttachmentExternalSyncDisableDocument, + type AttachmentExternalSyncDisableMutation, type AttachmentFilter, ListAttachmentsDocument, type ListAttachmentsQuery, @@ -19,6 +21,9 @@ export type AttachmentListItem = ListAttachmentsQuery["issue"]["attachments"]["nodes"][0]; export type CreatedAttachment = AttachmentCreateMutation["attachmentCreate"]["attachment"]; +export type ExternalSyncDisabledIssue = NonNullable< + AttachmentExternalSyncDisableMutation["issueExternalSyncDisable"]["issue"] +>; // Service-owned input type (UUIDs pre-resolved by the command). export type CreateAttachmentInput = BrandUuidFields< @@ -89,6 +94,28 @@ export async function deleteAttachment( return { id: result.attachmentDelete.entityId, success: true }; } +/** + * Stops syncing the issue with the external resource behind an attachment. + * + * The mutation is `issueExternalSyncDisable`, but it is keyed by attachment + * rather than issue, which is why it belongs here and not on the issue + * service: one issue can carry several synced attachments. + */ +export async function disableExternalSync( + client: GraphQLClient, + attachmentId: UUID, +): Promise { + const result = await client.request(AttachmentExternalSyncDisableDocument, { + attachmentId, + }); + + return requireMutationEntity( + result.issueExternalSyncDisable, + "issue", + `Failed to disable external sync for attachment "${attachmentId}"`, + ); +} + export async function listAttachments( client: GraphQLClient, issueId: UUID, diff --git a/tests/unit/services/attachment-service.test.ts b/tests/unit/services/attachment-service.test.ts index 386af314..d40c209b 100644 --- a/tests/unit/services/attachment-service.test.ts +++ b/tests/unit/services/attachment-service.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { asUuid } from "../../../src/common/identifier.js"; +import { AttachmentExternalSyncDisableDocument } from "../../../src/gql/graphql.js"; import { createAttachment, deleteAttachment, + disableExternalSync, listAttachments, } from "../../../src/services/attachment-service.js"; @@ -114,3 +116,31 @@ describe("listAttachments", () => { ); }); }); + +describe("disableExternalSync", () => { + it("returns the affected issue", async () => { + const client = mockGqlClient({ + issueExternalSyncDisable: { success: true, issue: { id: "issue-1" } }, + }); + + await expect( + disableExternalSync(client, asUuid("attachment-1")), + ).resolves.toEqual({ id: "issue-1" }); + expect(client.request).toHaveBeenCalledWith( + AttachmentExternalSyncDisableDocument, + { attachmentId: "attachment-1" }, + ); + }); + + it("names the attachment when the mutation fails", async () => { + const client = mockGqlClient({ + issueExternalSyncDisable: { success: false, issue: null }, + }); + + await expect( + disableExternalSync(client, asUuid("attachment-1")), + ).rejects.toThrow( + 'Failed to disable external sync for attachment "attachment-1"', + ); + }); +}); From c334e1f2871bd104f75b895be74a9527255f5096 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:47:30 +0200 Subject: [PATCH 15/70] docs: mark the issues coverage row complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `issues` row named five gaps — batch create/update, subscribe/ unsubscribe, share links, reminders, external sync toggles — all of which are now wired, along with several the row did not mention: archived reachability, team moves, restore, snooze, `from-branch`, the issue `url`, and the delegate/subscriber fields. Two of the five were also miscategorized, and the row is corrected rather than just ticked. "Share links" described `issueShare`, which grants a user access rather than minting a URL; the actual link need was the `url` field, and both are now covered but as separate things. "External sync toggles" is `issueExternalSyncDisable`, whose only argument is an `attachmentId` — it moves to the `attachments` row, where it now ships as `disable-sync`. What remains unwired in the issue domain is listed as a deliberate exclusion rather than a gap: the AI-assist and integration-suggestion queries belong to the Integrations row, and `issuePriorityValues` is a static list already present in the help text. Naming them keeps the ✅ honest — the legend's bar is "complete for practical purposes", not "every root field". The headline counts are unchanged: 537/164/373 is still correct when deprecated root fields are counted, which is the figure the sentence has always used. The wired count moves from "about 75" to an exact 83. `ISSUES_META` gains the three things a caller now has to distinguish and cannot infer from flag names: that archive, trash and snooze are three separate states rather than synonyms; that assignee, delegate, subscribers and shared access are four different relationships; and that `share` does not produce a link. --- README.md | 6 +++--- docs/files.md | 5 ++++- src/commands/issues.ts | 26 +++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d664ffee..40dc3c32 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ linearis issues reply --body "I found the root cause" ## 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 **537 root operations** (164 queries, 373 mutations). Linearis wires **83 of them** directly, plus a number of nested reads — chosen to cover planning and issue work end to end rather than the whole API. 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. @@ -120,12 +120,12 @@ The table below is the honest picture of the whole surface — what works today, |---|---|---|---| | `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 | +| `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 | | `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 | diff --git a/docs/files.md b/docs/files.md index 788e6edf..2cee2e87 100644 --- a/docs/files.md +++ b/docs/files.md @@ -46,6 +46,7 @@ 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 @@ -66,7 +67,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. diff --git a/src/commands/issues.ts b/src/commands/issues.ts index ec2772a6..aaaaead0 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -385,18 +385,42 @@ 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.", + "", + "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).", ].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 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 ", From 5a248a8c102af4c3be7a6e4f7f9adcc21464bf22 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:02:15 +0200 Subject: [PATCH 16/70] fix(issues): let a UUID pass the mixed-team batch guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues batch update` refuses to resolve a status or cycle when the targets span several teams, because `issueBatchUpdate` applies one stateId/cycleId to every issue and resolving a word against an arbitrary one of the teams would silently move issues into a foreign workflow state. The error told the caller to pass a UUID instead — but the guard fired on the flag being present at all, so the advised escape hatch hit the very same error. Check the value rather than its presence: a UUID needs no team to resolve against, and `resolveUpdateIssueIds` already hands UUIDs through untouched, so letting it past the guard is safe and makes the message true. The message now says "by name" to match what is actually rejected. `buildBatchUpdateContext` is exported so the escape hatch can be covered without driving a full command through Commander. --- src/commands/issues-batch.ts | 15 +++++-- tests/unit/commands/issues-batch.test.ts | 51 +++++++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts index b979af76..86a638d9 100644 --- a/src/commands/issues-batch.ts +++ b/src/commands/issues-batch.ts @@ -6,7 +6,7 @@ import { requiresParameterError, } from "../common/errors.js"; import { validateEstimateAgainstTeamConfig } from "../common/estimate-validation.js"; -import { parseDueDate, type UUID } from "../common/identifier.js"; +import { isUuid, parseDueDate, type UUID } from "../common/identifier.js"; import { parseCommaSeparated } from "../common/issue-filter.js"; import { parseEstimateOption, @@ -348,8 +348,14 @@ function toCreateInput( * 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. + * + * 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. + * + * Exported so that escape hatch can be tested without driving a full command. */ -function buildBatchUpdateContext( +export function buildBatchUpdateContext( targets: readonly ResolvedIssueRef[], options: BatchUpdateOptions, ): UpdateIssueContext { @@ -358,10 +364,11 @@ function buildBatchUpdateContext( if (teamKeys.length > 1) { for (const flag of ["status", "cycle"] as const) { - if (options[flag] !== undefined) { + const value = options[flag]; + if (value !== undefined && !isUuid(value)) { throw invalidParameterError( `--${flag}`, - `cannot be resolved across teams ${teamKeys.join(", ")} — pass a UUID, or split the batch per team`, + `cannot be resolved by name across teams ${teamKeys.join(", ")} — pass a UUID, or split the batch per team`, ); } } diff --git a/tests/unit/commands/issues-batch.test.ts b/tests/unit/commands/issues-batch.test.ts index 2e400e37..b9a8df5c 100644 --- a/tests/unit/commands/issues-batch.test.ts +++ b/tests/unit/commands/issues-batch.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parseBatchCreateEntries } from "../../../src/commands/issues-batch.js"; +import { + buildBatchUpdateContext, + parseBatchCreateEntries, +} from "../../../src/commands/issues-batch.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import type { ResolvedIssueRef } from "../../../src/resolvers/issue-resolver.js"; describe("parseBatchCreateEntries", () => { it("accepts the single-issue flag names as keys", () => { @@ -105,3 +110,47 @@ describe("parseBatchCreateEntries", () => { ); }); }); + +describe("buildBatchUpdateContext", () => { + const target = (teamKey: string): ResolvedIssueRef => ({ + ref: `${teamKey}-1`, + id: asUuid("11111111-1111-4111-8111-111111111111"), + teamId: asUuid("22222222-2222-4222-8222-222222222222"), + teamKey, + }); + + it("scopes lookups to the only team when all targets share one", () => { + const context = buildBatchUpdateContext([target("ENG"), target("ENG")], { + issues: "ENG-1,ENG-2", + status: "Todo", + }); + + expect(context).toEqual({ + teamId: asUuid("22222222-2222-4222-8222-222222222222"), + teamKey: "ENG", + }); + }); + + it("rejects a named status or cycle spanning teams", () => { + for (const options of [ + { issues: "ENG-1,OPS-1", status: "Todo" }, + { issues: "ENG-1,OPS-1", cycle: "Cycle 4" }, + ]) { + expect(() => + buildBatchUpdateContext([target("ENG"), target("OPS")], options), + ).toThrow(/cannot be resolved by name across teams ENG, OPS/); + } + }); + + it("lets a UUID status or cycle through as the documented escape hatch", () => { + // The error message advises passing a UUID; a UUID needs no team to + // resolve against, so the guard must not reject it as well. + const context = buildBatchUpdateContext([target("ENG"), target("OPS")], { + issues: "ENG-1,OPS-1", + status: "33333333-3333-4333-8333-333333333333", + cycle: "44444444-4444-4444-8444-444444444444", + }); + + expect(context).toEqual({}); + }); +}); From cd42347d321d51d609ddda92a3434baeebd40a45 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:02:24 +0200 Subject: [PATCH 17/70] fix(issues): await user lookups so failures stay JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subscriber/delegate lookups were started eagerly and only awaited on the success path, so any earlier throw — an unknown --project, --status or --team, or the batch request itself failing — returned from the resolver with that promise still pending. When it then rejected, nothing was listening: Node 22 terminates on an unhandled rejection, so the user got a raw stack trace on stderr instead of the `{"error": ...}` envelope every other failure produces. For a CLI whose entire contract is "stdout is JSON", that is a broken contract, not just noisy output. Await the lookups in the same expression that awaits the work they run alongside — the batch request for create, the destination-team resolution for update — so both rejections are handled whichever loses the race. The concurrency the eager start bought is preserved; only the awaiting changes. Both resolvers get a regression test that fails every lookup and asserts no unhandled rejection escapes, since a plain rejects.toThrow() passes either way. --- src/resolvers/issue-mutation-resolver.ts | 55 ++++++++++-------- .../resolvers/issue-mutation-resolver.test.ts | 58 +++++++++++++++++++ 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/resolvers/issue-mutation-resolver.ts b/src/resolvers/issue-mutation-resolver.ts index 99d54a10..82d1da4e 100644 --- a/src/resolvers/issue-mutation-resolver.ts +++ b/src/resolvers/issue-mutation-resolver.ts @@ -149,22 +149,27 @@ export async function resolveCreateIssueIds( ? parseIssueIdentifier(input.parentTicket) : null; - const userRefsPromise = resolveIssueUserRefs(client, input); - - const response = await client.request(BatchResolveForCreateDocument, { - teamKey: teamIsUuid ? null : input.team, - teamName: teamIsUuid ? null : input.team, - teamId: teamIsUuid ? input.team : null, - assigneeQuery, - projectName, - projectId: projectIdVar, - labelFilter: buildLabelFilter(labelNames), - statusName, - cycleName, - milestoneName, - parentTeamKey: parent?.teamKey ?? null, - parentIssueNumber: parent?.issueNumber ?? null, - }); + // Awaited together with the batch request: starting the user lookups without + // awaiting them in the same expression would leave a rejection unhandled + // whenever the batch request throws first, and an unhandled rejection kills + // the process with a stack trace instead of the JSON error envelope. + const [userRefs, response] = await Promise.all([ + resolveIssueUserRefs(client, input), + client.request(BatchResolveForCreateDocument, { + teamKey: teamIsUuid ? null : input.team, + teamName: teamIsUuid ? null : input.team, + teamId: teamIsUuid ? input.team : null, + assigneeQuery, + projectName, + projectId: projectIdVar, + labelFilter: buildLabelFilter(labelNames), + statusName, + cycleName, + milestoneName, + parentTeamKey: parent?.teamKey ?? null, + parentIssueNumber: parent?.issueNumber ?? null, + }), + ]); // Team (required). Prefer key match, then name, then id — mirrors resolveTeamId. const teamNode = teamIsUuid @@ -243,7 +248,7 @@ export async function resolveCreateIssueIds( : mapParent(response.parentIssues.nodes, input.parentTicket); } - return { ...resolved, ...(await userRefsPromise) }; + return { ...resolved, ...userRefs }; } /** @@ -392,15 +397,19 @@ export async function resolveUpdateIssueIds( ? parseIssueIdentifier(input.parentTicket) : null; - const userRefsPromise = resolveIssueUserRefs(client, input); - + // Both lookups are awaited in the same expression: starting one without + // awaiting it would leave a rejection unhandled whenever the other throws + // first, and an unhandled rejection kills the process with a stack trace + // instead of the JSON error envelope. + // // A team move rescopes the lookups: a status or cycle named alongside // `--team` belongs to the *destination* team's workflow, not the team the // issue is leaving. This is the one lookup that cannot be batched with the // rest, since its result is an input to them. - const destinationTeamId = input.team - ? await resolveTeamId(client, input.team) - : undefined; + const [userRefs, destinationTeamId] = await Promise.all([ + resolveIssueUserRefs(client, input), + input.team ? resolveTeamId(client, input.team) : undefined, + ]); const scope: UpdateIssueContext = destinationTeamId ? { teamId: destinationTeamId } : context; @@ -479,5 +488,5 @@ export async function resolveUpdateIssueIds( : mapParent(response.parentIssues.nodes, input.parentTicket); } - return { ...resolved, ...(await userRefsPromise) }; + return { ...resolved, ...userRefs }; } diff --git a/tests/unit/resolvers/issue-mutation-resolver.test.ts b/tests/unit/resolvers/issue-mutation-resolver.test.ts index d3dfbc0b..a4a8961b 100644 --- a/tests/unit/resolvers/issue-mutation-resolver.test.ts +++ b/tests/unit/resolvers/issue-mutation-resolver.test.ts @@ -550,3 +550,61 @@ describe("resolveUpdateIssueIds user references", () => { expect(result.delegateId).toBe("carol-uuid"); }); }); + +describe("user reference lookups and unhandled rejections", () => { + /** + * The user lookups run concurrently with the batch request. If one is only + * awaited on the success path, a failure of the other leaves its rejection + * unhandled — which in Node 22 tears the process down with a stack trace + * instead of letting handleCommand print the JSON error envelope. + */ + async function collectUnhandled( + run: () => Promise, + ): Promise { + const unhandled: unknown[] = []; + const listener = (reason: unknown): void => { + unhandled.push(reason); + }; + + process.on("unhandledRejection", listener); + try { + await expect(run()).rejects.toThrow(); + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off("unhandledRejection", listener); + } + + return unhandled; + } + + const failingClient = (): GraphQLClient => + ({ + request: vi + .fn() + .mockImplementation(() => Promise.reject(new Error("lookup failed"))), + }) as unknown as GraphQLClient; + + it("surfaces create lookup failures without leaking a rejection", async () => { + const unhandled = await collectUnhandled(() => + resolveCreateIssueIds(failingClient(), { + team: "ENG", + subscribers: ["alice"], + delegate: "carol", + }), + ); + + expect(unhandled).toEqual([]); + }); + + it("surfaces update lookup failures without leaking a rejection", async () => { + const unhandled = await collectUnhandled(() => + resolveUpdateIssueIds( + failingClient(), + { team: "DES", subscribers: ["alice"], delegate: "carol" }, + {}, + ), + ); + + expect(unhandled).toEqual([]); + }); +}); From cb14ec7469a8d421c4ccbd7c7e3112fe95a566d3 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:02:34 +0200 Subject: [PATCH 18/70] fix(issues): make --include-archived surface archived issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues list` has always hidden completed work by default. On the filtered path that default lives in the service (buildListIssuesFilter, skipped when the caller names a state), but the unfiltered path used a separate GetIssues query with `state: { type: { neq: "completed" } }` baked into the document, where no flag could reach it. Archived issues are nearly always completed or canceled, so `issues list --include-archived` on its own hid exactly the issues it was passed to surface, and the two paths disagreed about what the flag means. Treat --include-archived as the caller saying something about state, the same way an explicit state filter already does, and drop the implicit clause on both paths. Someone who asks for archived issues wants to see them; narrowing state from there is what the filter flags are for. With the default expressed only in the service, the two queries were the same query, so GetIssues is deleted and FilteredSearchIssues backs both paths. It selects the identical CompleteIssueFields fragment, so IssueListItem and the shared PageInfo type just repoint at it — no payload change. --- graphql/queries/issues.graphql | 33 ++------------ src/common/types.ts | 4 +- src/services/issue-service.ts | 46 ++++++++++++-------- tests/unit/services/issue-service.test.ts | 53 ++++++++++++++++++----- 4 files changed, 73 insertions(+), 63 deletions(-) diff --git a/graphql/queries/issues.graphql b/graphql/queries/issues.graphql index b3a3cb96..50b03a4b 100644 --- a/graphql/queries/issues.graphql +++ b/graphql/queries/issues.graphql @@ -279,34 +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 - $includeArchived: Boolean = false -) { - issues( - first: $first - after: $after - orderBy: $orderBy - includeArchived: $includeArchived - 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 @@ -417,8 +389,9 @@ 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 diff --git a/src/common/types.ts b/src/common/types.ts index 0ed84247..eb9dac4a 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,7 +1,7 @@ -import type { GetIssuesQuery } from "../gql/graphql.js"; +import type { FilteredSearchIssuesQuery } from "../gql/graphql.js"; // Pagination types -type PageInfo = GetIssuesQuery["issues"]["pageInfo"]; +type PageInfo = FilteredSearchIssuesQuery["issues"]["pageInfo"]; export interface PaginatedResult { nodes: T[]; diff --git a/src/services/issue-service.ts b/src/services/issue-service.ts index a6f58248..a6238fe2 100644 --- a/src/services/issue-service.ts +++ b/src/services/issue-service.ts @@ -16,6 +16,7 @@ import { type CreateIssueMutation, DeleteIssueDocument, FilteredSearchIssuesDocument, + type FilteredSearchIssuesQuery, GetIssueByIdDocument, GetIssueByIdentifierDocument, type GetIssueByIdentifierQuery, @@ -32,8 +33,6 @@ import { type GetIssueByIdWithCommentsQuery, GetIssueByIdWithReactionsDocument, type GetIssueByIdWithReactionsQuery, - GetIssuesDocument, - type GetIssuesQuery, type IssueCreateInput, type IssueFilter, type IssueUpdateInput, @@ -54,7 +53,7 @@ import { import { normalizeReactions } from "./reaction-service.js"; // Issue projection types -export type IssueListItem = GetIssuesQuery["issues"]["nodes"][0]; +export type IssueListItem = FilteredSearchIssuesQuery["issues"]["nodes"][0]; export type IssueDetail = NonNullable; export type IssueByIdentifier = GetIssueByIdentifierQuery["issues"]["nodes"][0]; export type IssueDetailWithComments = NonNullable< @@ -187,7 +186,26 @@ function hasExplicitStateFilter(filter: IssueFilter): boolean { return filter.or?.some(hasExplicitStateFilter) ?? false; } -function buildListIssuesFilter(filter: IssueFilter): IssueFilter { +/** + * Applies the implicit "hide completed work" narrowing that `issues list` + * has always had, unless the caller has already said something about state. + * + * `includeArchived` counts as saying something: archived issues are nearly + * always completed or canceled, so keeping the default clause would make + * `--include-archived` hide the very issues it was passed to surface. + */ +function buildListIssuesFilter( + filter: IssueFilter | undefined, + includeArchived: boolean, +): IssueFilter | undefined { + if (includeArchived) { + return filter; + } + + if (!filter) { + return NON_COMPLETED_ISSUES_FILTER; + } + if (hasExplicitStateFilter(filter)) { return filter; } @@ -318,23 +336,13 @@ export async function listIssues( orderBy = "updatedAt", } = options; - if (filter) { - const result = await client.request(FilteredSearchIssuesDocument, { - first: limit, - after, - filter: buildListIssuesFilter(filter), - orderBy, - includeArchived, - }); - return { - nodes: result.issues?.nodes ?? [], - pageInfo: result.issues.pageInfo, - }; - } - - const result = await client.request(GetIssuesDocument, { + // One query for both the filtered and the unfiltered path: the default + // state narrowing lives in buildListIssuesFilter, so a query that hardcoded + // it could not honor `--include-archived`. + const result = await client.request(FilteredSearchIssuesDocument, { first: limit, after, + filter: buildListIssuesFilter(filter, includeArchived), orderBy, includeArchived, }); diff --git a/tests/unit/services/issue-service.test.ts b/tests/unit/services/issue-service.test.ts index fb3525d7..37c3f706 100644 --- a/tests/unit/services/issue-service.test.ts +++ b/tests/unit/services/issue-service.test.ts @@ -22,7 +22,6 @@ import { GetIssueByIdWithAttachmentsDocument, GetIssueByIdWithCommentsDocument, GetIssueByIdWithReactionsDocument, - GetIssuesDocument, IssueVcsBranchSearchDocument, RemindOnIssueDocument, SearchIssuesDocument, @@ -151,9 +150,9 @@ describe("issue read payload", () => { "delegate", ]; - expect(scalarNames(GetIssuesDocument, "CompleteIssueFields")).toEqual( - expect.arrayContaining(expected), - ); + expect( + scalarNames(FilteredSearchIssuesDocument, "CompleteIssueFields"), + ).toEqual(expect.arrayContaining(expected)); expect( scalarNames(SearchIssuesDocument, "CompleteIssueSearchFields"), ).toEqual(expect.arrayContaining(expected)); @@ -161,7 +160,10 @@ describe("issue read payload", () => { it("keeps subscribers and sharedAccess off the list fragment", () => { // They belong to single-issue reads only — see IssueDetailOnlyFields. - const listFields = scalarNames(GetIssuesDocument, "CompleteIssueFields"); + const listFields = scalarNames( + FilteredSearchIssuesDocument, + "CompleteIssueFields", + ); expect(listFields).not.toContain("subscribers"); expect(listFields).not.toContain("sharedAccess"); @@ -169,7 +171,9 @@ describe("issue read payload", () => { expect(print(GetIssueByIdWithCommentsDocument)).toContain( "IssueDetailOnlyFields", ); - expect(print(GetIssuesDocument)).not.toContain("IssueDetailOnlyFields"); + expect(print(FilteredSearchIssuesDocument)).not.toContain( + "IssueDetailOnlyFields", + ); }); }); @@ -192,7 +196,6 @@ describe("archived issue reachability", () => { it("keeps list and search opt-in rather than always-archived", () => { for (const document of [ - GetIssuesDocument, FilteredSearchIssuesDocument, SearchIssuesDocument, ]) { @@ -238,8 +241,8 @@ describe("listIssues", () => { }); it("still excludes completed issues by default when a filter is given", async () => { - // Regression guard: parameterizing orderBy/includeArchived must not drop - // the implicit non-completed clause. + // Regression guard: parameterizing orderBy must not drop the implicit + // non-completed clause. const client = mockGqlClient({ issues: { nodes: [], @@ -249,7 +252,7 @@ describe("listIssues", () => { await listIssues( client, - { limit: 10, orderBy: "createdAt", includeArchived: true }, + { limit: 10, orderBy: "createdAt" }, { priority: { eq: 1 } }, ); @@ -305,6 +308,7 @@ describe("listIssues", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 25, after: undefined, + filter: { state: { type: { neq: "completed" } } }, orderBy: "updatedAt", includeArchived: false, }); @@ -321,6 +325,7 @@ describe("listIssues", () => { expect(client.request).toHaveBeenCalledWith(expect.anything(), { first: 5, after: "cursor1", + filter: { state: { type: { neq: "completed" } } }, orderBy: "updatedAt", includeArchived: false, }); @@ -389,7 +394,7 @@ describe("listIssues", () => { }); }); - it("uses GetIssues query when no filter provided (no regression)", async () => { + it("applies the non-completed default on the unfiltered path too", async () => { const client = mockGqlClient({ issues: { nodes: [], @@ -397,13 +402,37 @@ describe("listIssues", () => { }, }); await listIssues(client); - expect(client.request).toHaveBeenCalledWith(GetIssuesDocument, { + expect(client.request).toHaveBeenCalledWith(FilteredSearchIssuesDocument, { first: 25, after: undefined, + filter: { state: { type: { neq: "completed" } } }, orderBy: "updatedAt", includeArchived: false, }); }); + + it("drops the non-completed default when archived issues are requested", async () => { + // Archived issues are almost always completed, so keeping the implicit + // clause would make --include-archived hide what it was passed to show. + for (const [filter, expected] of [ + [undefined, undefined], + [{ priority: { eq: 1 } }, { priority: { eq: 1 } }], + ] as const) { + const client = mockGqlClient({ + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + await listIssues(client, { limit: 10, includeArchived: true }, filter); + + expect(client.request).toHaveBeenCalledWith( + FilteredSearchIssuesDocument, + expect.objectContaining({ filter: expected, includeArchived: true }), + ); + } + }); }); describe("getIssue", () => { From 13ed81a20e27d97d079b49ab9770564a70095781 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:03:37 +0200 Subject: [PATCH 19/70] feat(issues): publish a JSON Schema for batch create documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues batch create` is the only command whose input is a JSON document rather than flags, and until now that document's shape existed only as a TypeScript interface and a one-line example in the help text. Callers — especially agents generating a batch programmatically — had no way to check a document before spending an API call on it, and the failure mode is expensive: the parser rejects unknown keys, so a single typo throws away the whole batch. Publish the contract as JSON Schema (draft 2020-12) in `schemas/issues-batch-create.schema.json`, ship it in the npm package, and point at it from the three places a caller actually looks: the `batch create` help text (with a check-jsonschema invocation), the `--file` option description, and the `issues usage` context block that `USAGE.md` is generated from — help text after-blocks do not make it into USAGE.md, so the context block is what agents reading the generated reference will see. README gains a section covering file/stdin/inline input, validation, and editor wiring. The schema is hand-written rather than generated: it encodes constraints the TypeScript interface cannot (non-empty strings, the 1-4 priority range, the YYYY-MM-DD due-date shape, projectMilestone requiring project) and generating it from the interface would lose exactly those. The cost of hand-writing is drift, so `KNOWN_ENTRY_KEYS` is exported and a test asserts the schema's property set, required fields, dependency and additionalProperties setting still match the parser, and that every documented example parses. The URL is pinned to the raw file on `next` rather than a release tag: someone validating a document wants the contract of the CLI they are about to run. --- README.md | 39 +++++++ docs/files.md | 6 + package.json | 1 + schemas/issues-batch-create.schema.json | 110 ++++++++++++++++++ src/commands/issues-batch.ts | 27 ++++- src/commands/issues.ts | 9 ++ .../unit/commands/issues-batch-schema.test.ts | 63 ++++++++++ 7 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 schemas/issues-batch-create.schema.json create mode 100644 tests/unit/commands/issues-batch-schema.test.ts diff --git a/README.md b/README.md index 40dc3c32..449592f7 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,44 @@ linearis issues replies linearis issues reply --body "I found the root cause" ``` +### Batch issue creation + +`issues batch create` is the one command that takes a JSON document instead of flags: a JSON array with one object per issue, keys named after the `issues create` flags with the leading dashes dropped. Unknown keys are rejected rather than ignored, so a typo fails the command instead of quietly dropping a field. + +```bash +# From a file +linearis issues batch create --file issues.json + +# From stdin +generate-issues | linearis issues batch create --file - + +# Inline, for one-offs +linearis issues batch create --json '[{"title":"Fix login","team":"ENG"}]' +``` + +The document format is published as JSON Schema (draft 2020-12) at [`schemas/issues-batch-create.schema.json`](schemas/issues-batch-create.schema.json). It ships in the npm package and is served raw from the default branch, so you can validate a document before spending an API call on it: + +```bash +check-jsonschema \ + --schemafile https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-create.schema.json \ + issues.json +``` + +Editors pick it up the same way. In VS Code, map it once in `.vscode/settings.json` and any matching file gets completion and inline validation: + +```json +{ + "json.schemas": [ + { + "fileMatch": ["*.linear-issues.json"], + "url": "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-create.schema.json" + } + ] +} +``` + +The schema is the input contract only — it cannot know your team's workflow states, label names, or estimation scale. A document that validates can still be rejected by the CLI when a name does not resolve, and the whole batch is one transaction: either every issue is created or none is. + ## Coverage Linear's GraphQL API exposes **537 root operations** (164 queries, 373 mutations). Linearis wires **83 of them** directly, plus a number of nested reads — chosen to cover planning and issue work end to end rather than the whole API. @@ -231,6 +269,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 issue creation](#batch-issue-creation)). - [`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/files.md b/docs/files.md index 2cee2e87..194515f2 100644 --- a/docs/files.md +++ b/docs/files.md @@ -103,6 +103,12 @@ 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. Kept in step with `parseBatchCreateEntries` in `src/commands/issues-batch.ts` 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/package.json b/package.json index f64041ab..295ff133 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "files": [ "dist/", + "schemas/", "README.md", "LICENSE.md", "USAGE.md" diff --git a/schemas/issues-batch-create.schema.json b/schemas/issues-batch-create.schema.json new file mode 100644 index 00000000..e68098f1 --- /dev/null +++ b/schemas/issues-batch-create.schema.json @@ -0,0 +1,110 @@ +{ + "$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" } + ] + }, + "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 + } + ] + ] +} diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts index 86a638d9..962fea6c 100644 --- a/src/commands/issues-batch.ts +++ b/src/commands/issues-batch.ts @@ -91,7 +91,23 @@ interface BatchUpdateOptions { clearDueDate?: boolean; } -const KNOWN_ENTRY_KEYS: ReadonlySet = new Set([ +/** + * Where the published copy of `schemas/issues-batch-create.schema.json` lives. + * + * Pinned to the raw file on the default branch rather than a tag: the 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 BATCH_CREATE_SCHEMA_URL = + "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-create.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", @@ -446,9 +462,16 @@ export function addBatchCommands(issues: Command): void { "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") + .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) => { diff --git a/src/commands/issues.ts b/src/commands/issues.ts index aaaaead0..563de6ce 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -399,6 +399,15 @@ export const ISSUES_META: DomainMeta = { "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).", + "", + "`batch create` takes a JSON array instead of flags — one object per", + "issue, keys named after the `issues create` flags, unknown keys", + "rejected. the contract is published as JSON Schema (draft 2020-12) in", + "`schemas/issues-batch-create.schema.json`, 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", + "write the document against that schema, validate it locally, then pass", + "it with --file (or - for stdin).", ].join("\n"), arguments: { issue: "issue identifier (UUID or ABC-123)", diff --git a/tests/unit/commands/issues-batch-schema.test.ts b/tests/unit/commands/issues-batch-schema.test.ts new file mode 100644 index 00000000..4cb88bc7 --- /dev/null +++ b/tests/unit/commands/issues-batch-schema.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + KNOWN_ENTRY_KEYS, + parseBatchCreateEntries, +} from "../../../src/commands/issues-batch.js"; + +/** + * The published schema is the contract callers write their batch documents + * against, so it has to stay in step with the parser that actually accepts + * them. Nothing at runtime reads the schema — these tests are the only thing + * standing between a new field and a schema that silently rejects it. + */ + +const SCHEMA_PATH = fileURLToPath( + new URL("../../../schemas/issues-batch-create.schema.json", import.meta.url), +); + +interface BatchCreateSchema { + $defs: { + entry: { + properties: Record; + required: string[]; + additionalProperties: boolean; + dependentRequired: Record; + }; + }; + examples: unknown[]; +} + +const schema = JSON.parse( + readFileSync(SCHEMA_PATH, "utf8"), +) as BatchCreateSchema; +const entry = schema.$defs.entry; + +describe("issues-batch-create.schema.json", () => { + it("describes exactly the keys the parser accepts", () => { + expect(Object.keys(entry.properties).sort()).toEqual( + [...KNOWN_ENTRY_KEYS].sort(), + ); + }); + + it("rejects unknown keys, as the parser does", () => { + expect(entry.additionalProperties).toBe(false); + }); + + it("requires the same fields the parser requires", () => { + expect(entry.required.sort()).toEqual(["team", "title"]); + }); + + it("ties projectMilestone to project, as the parser does", () => { + expect(entry.dependentRequired).toEqual({ projectMilestone: ["project"] }); + }); + + it("only documents examples the parser accepts", () => { + for (const example of schema.examples) { + expect(() => + parseBatchCreateEntries(JSON.stringify(example)), + ).not.toThrow(); + } + }); +}); From 67ea490da975d9cc5ded0d2ba2b82ed302710347 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:17:13 +0200 Subject: [PATCH 20/70] feat(issues): accept subscribers and delegate in batch create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues batch create` documented its keys as "the single-issue flag names with the leading dashes dropped", but `--subscribers` and `--delegate` were added to `issues create` on this branch without reaching the batch parser. Since unknown keys are hard-rejected rather than ignored, a document carrying either field failed outright — the strictness that makes typos safe also made this omission a wall. The resolver side already worked: `ResolveCreateIssueIdsInput` carries both fields and `resolveIssueUserRefs` resolves them, so this only wires them through the parser, `toResolverInput`, `toCreateInput`, and the published JSON Schema. `parseLabels` becomes `parseStringList`, taking the key name so the subscribers error names `subscribers` rather than `labels`. Subscribers accept the same array-or-comma-separated shapes as labels, matching what `--subscribers` takes on the command line. --- schemas/issues-batch-create.schema.json | 19 +++++++++++++++++- src/commands/issues-batch.ts | 25 ++++++++++++++++++++---- tests/unit/commands/issues-batch.test.ts | 24 +++++++++++++++++++++-- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/schemas/issues-batch-create.schema.json b/schemas/issues-batch-create.schema.json index e68098f1..1b3230e3 100644 --- a/schemas/issues-batch-create.schema.json +++ b/schemas/issues-batch-create.schema.json @@ -78,6 +78,21 @@ { "$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}$", @@ -103,7 +118,9 @@ "project": "Q3 Auth", "projectMilestone": "Beta", "status": "Todo", - "estimate": 2 + "estimate": 2, + "subscribers": ["bob", "carol@example.com"], + "delegate": "me" } ] ] diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts index 962fea6c..fbeecb75 100644 --- a/src/commands/issues-batch.ts +++ b/src/commands/issues-batch.ts @@ -62,6 +62,8 @@ interface BatchCreateEntry { status?: string; parentTicket?: string; dueDate?: string; + subscribers?: string[]; + delegate?: string; } interface BatchCreateOptions { @@ -121,6 +123,8 @@ export const KNOWN_ENTRY_KEYS: ReadonlySet = new Set([ "status", "parentTicket", "dueDate", + "subscribers", + "delegate", ]); /** Reads the batch document from `--json`, a file, or stdin via `--file -`. */ @@ -208,6 +212,7 @@ function parseBatchCreateEntry( "cycle", "status", "parentTicket", + "delegate", ] as const) { const value = optionalString(record, key, at); if (value !== undefined) parsedEntry[key] = value; @@ -229,7 +234,15 @@ function parseBatchCreateEntry( if (dueDate !== undefined) parsedEntry.dueDate = parseDueDate(dueDate); if (record["labels"] !== undefined) { - parsedEntry.labels = parseLabels(record["labels"], at); + parsedEntry.labels = parseStringList(record["labels"], "labels", at); + } + + if (record["subscribers"] !== undefined) { + parsedEntry.subscribers = parseStringList( + record["subscribers"], + "subscribers", + at, + ); } if ( @@ -297,7 +310,7 @@ function optionalInteger( } /** Accepts both a JSON array and the comma-separated form the flags take. */ -function parseLabels(value: unknown, at: string): string[] { +function parseStringList(value: unknown, key: string, at: string): string[] { if (typeof value === "string") { return parseCommaSeparated(value); } @@ -305,11 +318,11 @@ function parseLabels(value: unknown, at: string): string[] { if ( !Array.isArray(value) || value.length === 0 || - !value.every((label) => typeof label === "string" && label.trim() !== "") + !value.every((item) => typeof item === "string" && item.trim() !== "") ) { throw invalidParameterError( at, - 'has "labels" that is not a non-empty array of strings', + `has "${key}" that is not a non-empty array of strings`, ); } @@ -331,6 +344,8 @@ function toResolverInput(entry: BatchCreateEntry): ResolveCreateIssueIdsInput { 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; } @@ -352,6 +367,8 @@ function toCreateInput( 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; } diff --git a/tests/unit/commands/issues-batch.test.ts b/tests/unit/commands/issues-batch.test.ts index b9a8df5c..28ded139 100644 --- a/tests/unit/commands/issues-batch.test.ts +++ b/tests/unit/commands/issues-batch.test.ts @@ -24,6 +24,8 @@ describe("parseBatchCreateEntries", () => { parentTicket: "ENG-1", description: "body", dueDate: "2026-09-01", + subscribers: ["bob"], + delegate: "carol", }, ]), ); @@ -43,16 +45,34 @@ describe("parseBatchCreateEntries", () => { parentTicket: "ENG-1", description: "body", dueDate: "2026-09-01", + subscribers: ["bob"], + delegate: "carol", }, ]); }); - it("accepts labels in the comma-separated flag form", () => { + it("accepts labels and subscribers in the comma-separated flag form", () => { const [entry] = parseBatchCreateEntries( - JSON.stringify([{ title: "T", team: "ENG", labels: "bug, urgent" }]), + JSON.stringify([ + { + title: "T", + team: "ENG", + labels: "bug, urgent", + subscribers: "bob, carol", + }, + ]), ); expect(entry?.labels).toEqual(["bug", "urgent"]); + expect(entry?.subscribers).toEqual(["bob", "carol"]); + }); + + it("names the offending key when a list is not a list of strings", () => { + expect(() => + parseBatchCreateEntries( + JSON.stringify([{ title: "T", team: "ENG", subscribers: [1] }]), + ), + ).toThrow(/entry 0: has "subscribers" that is not a non-empty array/); }); it("rejects an unknown key rather than dropping it", () => { From f623be9c49de173d872c66774a0e0e90dcd61ace Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:18:25 +0200 Subject: [PATCH 21/70] perf(issues): bound the fan-out of batch create ID resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveBatchCreateIssueIds` collapsed entries naming the same reference set onto one request, which covers the common batch that shares a team and project and differs only in title. It did not bound anything else: every *distinct* set started concurrently, so a heterogeneous import — varying assignee, project and status per row, which is the case this command exists for — issued N simultaneous `BatchResolveForCreate` requests plus their user lookups. That is how a large import earns a rate-limit rejection instead of a result. Distinct sets now resolve in waves of five. Deduplication moved ahead of the fan-out so the window counts real requests: a hundred rows sharing one reference set still costs a single wave, unchanged from before. Five is a deliberate compromise — high enough that the small mixed batch sees no practical slowdown, low enough that a hundred-row import stays well inside Linear's request budget. An adaptive window keyed off observed rate-limit headers would be better, but the client does not surface them today. --- src/resolvers/issue-mutation-resolver.ts | 42 ++++++++++++---- .../resolvers/issue-mutation-resolver.test.ts | 50 +++++++++++++++++++ 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/src/resolvers/issue-mutation-resolver.ts b/src/resolvers/issue-mutation-resolver.ts index 82d1da4e..eb5e995c 100644 --- a/src/resolvers/issue-mutation-resolver.ts +++ b/src/resolvers/issue-mutation-resolver.ts @@ -266,24 +266,44 @@ export async function resolveCreateIssueIds( * Field-level memoization would collapse more, but status, cycle and milestone * lookups are scoped by the entry's own team and project, so a per-field cache * cannot be keyed correctly without duplicating that scoping here. + * + * Distinct reference sets are resolved in bounded waves rather than all at + * once. Collapsing helps the common batch that shares a team and project, but + * a heterogeneous import — the case this command exists for — has as many + * distinct sets as rows, and firing every `BatchResolveForCreate` (plus its + * user lookups) simultaneously is how a large import earns a rate-limit + * rejection instead of a result. */ +const BATCH_CREATE_RESOLVE_CONCURRENCY = 5; + export async function resolveBatchCreateIssueIds( client: GraphQLClient, entries: readonly ResolveCreateIssueIdsInput[], ): Promise { - const inFlight = new Map>(); + // Deduplicate first so the concurrency window counts real requests: a batch + // of 100 rows sharing one reference set should still cost one wave. + const keys = entries.map(batchCreateCacheKey); + const unique = new Map(); + for (const [index, key] of keys.entries()) { + if (!unique.has(key)) { + unique.set(key, entries[index] as ResolveCreateIssueIdsInput); + } + } - return Promise.all( - entries.map((entry) => { - const key = batchCreateCacheKey(entry); - const cached = inFlight.get(key); - if (cached) return cached; + const resolvedByKey = new Map(); + const pending = [...unique]; - const pending = resolveCreateIssueIds(client, entry); - inFlight.set(key, pending); - return pending; - }), - ); + for (let i = 0; i < pending.length; i += BATCH_CREATE_RESOLVE_CONCURRENCY) { + const wave = pending.slice(i, i + BATCH_CREATE_RESOLVE_CONCURRENCY); + const resolved = await Promise.all( + wave.map(([, entry]) => resolveCreateIssueIds(client, entry)), + ); + for (const [offset, [key]] of wave.entries()) { + resolvedByKey.set(key, resolved[offset] as ResolvedCreateIssueIds); + } + } + + return keys.map((key) => resolvedByKey.get(key) as ResolvedCreateIssueIds); } /** diff --git a/tests/unit/resolvers/issue-mutation-resolver.test.ts b/tests/unit/resolvers/issue-mutation-resolver.test.ts index a4a8961b..f05a0cf5 100644 --- a/tests/unit/resolvers/issue-mutation-resolver.test.ts +++ b/tests/unit/resolvers/issue-mutation-resolver.test.ts @@ -481,6 +481,56 @@ describe("resolveBatchCreateIssueIds", () => { resolveBatchCreateIssueIds(client, [{ team: "NOPE" }]), ).rejects.toThrow('Team "NOPE" not found'); }); + + it("bounds how many distinct reference sets are in flight at once", async () => { + // A heterogeneous import has one distinct set per row, so an unbounded + // fan-out would issue every request simultaneously and invite a + // rate-limit rejection. + let inFlight = 0; + let peak = 0; + const request = vi.fn().mockImplementation(async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await Promise.resolve(); + inFlight -= 1; + return createResponse("team-uuid"); + }); + const client = { request } as unknown as GraphQLClient; + + // Distinct assignee UUIDs: they pass straight through the mappers, so + // each entry is its own reference set without needing 20 response shapes. + const entries = Array.from({ length: 20 }, (_, i) => ({ + team: "ENG", + assignee: `550e8400-e29b-41d4-a716-4466554400${String(i).padStart(2, "0")}`, + })); + const resolved = await resolveBatchCreateIssueIds(client, entries); + + expect(resolved).toHaveLength(20); + expect(request).toHaveBeenCalledTimes(20); + // Exactly the wave size: still concurrent, just bounded. + expect(peak).toBe(5); + }); + + it("keeps input order when the deduplicated sets resolve out of order", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(createResponse("team-a")) + .mockResolvedValueOnce(createResponse("team-b", "DES")); + const client = { request } as unknown as GraphQLClient; + + const resolved = await resolveBatchCreateIssueIds(client, [ + { team: "ENG" }, + { team: "DES" }, + { team: "ENG" }, + ]); + + expect(resolved.map((ids) => ids.teamId)).toEqual([ + "team-a", + "team-b", + "team-a", + ]); + expect(request).toHaveBeenCalledTimes(2); + }); }); describe("resolveUpdateIssueIds team move", () => { From 40f40d46ece713c1104c62b01422a8d089005633 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:19:14 +0200 Subject: [PATCH 22/70] docs(issues): say that --include-archived also unhides completed work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--include-archived` does two things: it includes archived issues, and it drops the implicit "hide completed" narrowing that `list`/`search` apply by default. The second half is deliberate — archived issues are nearly always completed, so keeping the default clause would hide exactly what the flag was passed to surface — but neither the flag description nor the `issues usage` context said so, and that text is what an agent reads before choosing a flag. A caller reaching for archived issues got every completed non-archived issue as well, with nothing to explain it. The domain context now also states the default narrowing itself, which was undocumented in either place, and points at --state-type completed for the "completed but not archived" case. --- src/commands/issues.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 563de6ce..d568c4df 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -394,6 +394,14 @@ export const ISSUES_META: DomainMeta = { "reachable by identifier everywhere, but excluded from `list`/`search`", "unless you pass --include-archived.", "", + "`list`/`search` also hide 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", + "--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.", + "", "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", @@ -707,7 +715,10 @@ function addFilterOptions(cmd: ReturnType): typeof cmd { "filter by state category (triage, backlog, unstarted, started, completed, canceled)", ) .option("--subscriber ", "filter by subscriber") - .option("--include-archived", "include archived issues in the results"); + .option( + "--include-archived", + "include archived issues, and drop the default 'hide completed' narrowing", + ); } export function setupIssuesCommands(program: Command): void { From 31634dce7b6ebf384475f0188adeb4941b77f6f3 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:20:31 +0200 Subject: [PATCH 23/70] fix(issues): validate batch update estimates against the team scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues update` and `issues batch create` both check `--estimate` against the team's estimation scale before sending anything. `batch update` did not, so `--estimate 7` on a fibonacci team came back as a raw Linear API error rather than the JSON envelope naming the allowed values — the same input, rejected three different ways depending on which command you reached for. Every distinct team the batch spans is checked, not just the single-team one. A single patch applies the same estimate to all targets, so it has to be valid on each of their scales. An estimate of 8 across a fibonacci team and a linear team is legal on the first and not the second, and sending it would half-apply. That costs one lookup per distinct team, and a batch spanning teams is already the rare shape. The targets are resolved before the check runs, so their team UUIDs are in hand; `resolveTeamEstimateContext` takes it from there. --- src/commands/issues-batch.ts | 42 ++++++++++ tests/unit/commands/issues-batch.test.ts | 101 ++++++++++++++++++++++- 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts index fbeecb75..d5444a0e 100644 --- a/src/commands/issues-batch.ts +++ b/src/commands/issues-batch.ts @@ -1,5 +1,6 @@ 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, @@ -25,6 +26,7 @@ import { type ResolvedIssueRef, resolveIssueRefs, } from "../resolvers/issue-resolver.js"; +import { resolveTeamEstimateContext } from "../resolvers/team-resolver.js"; import { batchCreateIssues, batchUpdateIssues, @@ -414,6 +416,44 @@ export function buildBatchUpdateContext( : {}; } +/** + * 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[], + options: BatchUpdateOptions, +): Promise { + if (options.estimate === undefined) return; + + const estimate = parseEstimateOption(options.estimate); + 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( options: BatchUpdateOptions, ): ResolveUpdateIssueIdsInput { @@ -570,6 +610,8 @@ export function addBatchCommands(issues: Command): void { const targets = await resolveIssueRefs(ctx.gql, refs); const context = buildBatchUpdateContext(targets, options); + await validateBatchUpdateEstimate(ctx.gql, targets, options); + const resolverInput = buildBatchUpdateResolverInput(options); const ids = Object.keys(resolverInput).length > 0 diff --git a/tests/unit/commands/issues-batch.test.ts b/tests/unit/commands/issues-batch.test.ts index 28ded139..76e7df36 100644 --- a/tests/unit/commands/issues-batch.test.ts +++ b/tests/unit/commands/issues-batch.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { buildBatchUpdateContext, parseBatchCreateEntries, + validateBatchUpdateEstimate, } from "../../../src/commands/issues-batch.js"; import { asUuid } from "../../../src/common/identifier.js"; import type { ResolvedIssueRef } from "../../../src/resolvers/issue-resolver.js"; @@ -174,3 +176,100 @@ describe("buildBatchUpdateContext", () => { expect(context).toEqual({}); }); }); + +describe("validateBatchUpdateEstimate", () => { + const ENG_TEAM = "22222222-2222-4222-8222-222222222222"; + const OPS_TEAM = "55555555-5555-4555-8555-555555555555"; + + const target = (teamKey: string, teamId: string): ResolvedIssueRef => ({ + ref: `${teamKey}-1`, + id: asUuid("11111111-1111-4111-8111-111111111111"), + teamId: asUuid(teamId), + teamKey, + }); + + const teamResponse = ( + id: string, + key: string, + issueEstimationType: string, + ) => ({ + teams: { + nodes: [ + { + id, + key, + name: key, + issueEstimationType, + issueEstimationExtended: false, + issueEstimationAllowZero: false, + }, + ], + }, + }); + + it("rejects an estimate outside the team's scale before sending anything", async () => { + const request = vi + .fn() + .mockResolvedValue(teamResponse(ENG_TEAM, "ENG", "fibonacci")); + const client = { request } as unknown as GraphQLClient; + + await expect( + validateBatchUpdateEstimate(client, [target("ENG", ENG_TEAM)], { + issues: "ENG-1", + estimate: "7", + }), + ).rejects.toThrow(/must be one of \[1, 2, 3, 5, 8\] for team "ENG"/); + }); + + it("accepts an estimate that is on the scale", async () => { + const request = vi + .fn() + .mockResolvedValue(teamResponse(ENG_TEAM, "ENG", "fibonacci")); + const client = { request } as unknown as GraphQLClient; + + await expect( + validateBatchUpdateEstimate(client, [target("ENG", ENG_TEAM)], { + issues: "ENG-1", + estimate: "5", + }), + ).resolves.toBeUndefined(); + }); + + it("checks every team the batch spans, not just the first", async () => { + // One patch applies the same estimate to all targets, so an estimate that + // is valid on one team's scale and not another's must still be rejected. + const request = vi + .fn() + .mockResolvedValueOnce(teamResponse(ENG_TEAM, "ENG", "fibonacci")) + .mockResolvedValueOnce(teamResponse(OPS_TEAM, "OPS", "linear")); + const client = { request } as unknown as GraphQLClient; + + await expect( + validateBatchUpdateEstimate( + client, + [target("ENG", ENG_TEAM), target("OPS", OPS_TEAM)], + { issues: "ENG-1,OPS-1", estimate: "8" }, + ), + ).rejects.toThrow(/for team "OPS"/); + }); + + it("looks each distinct team up once and skips the lookup entirely without --estimate", async () => { + const request = vi + .fn() + .mockResolvedValue(teamResponse(ENG_TEAM, "ENG", "fibonacci")); + const client = { request } as unknown as GraphQLClient; + + await validateBatchUpdateEstimate( + client, + [target("ENG", ENG_TEAM), target("ENG", ENG_TEAM)], + { issues: "ENG-1,ENG-2", estimate: "3" }, + ); + expect(request).toHaveBeenCalledTimes(1); + + await validateBatchUpdateEstimate(client, [target("ENG", ENG_TEAM)], { + issues: "ENG-1", + title: "no estimate here", + }); + expect(request).toHaveBeenCalledTimes(1); + }); +}); From 70aafb94c28741056ea49eebf8d5184f96d05f69 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:31:04 +0200 Subject: [PATCH 24/70] feat(issues): let batch update clear a cycle or milestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues batch update` could set --cycle and --project-milestone but had no way to unset them, so detaching twenty issues from a cycle meant twenty single-issue `issues update --clear-cycle` calls. Every other set-valued field in the batch flag list already had a --clear-* counterpart; these two were the gap. Both flags mirror the single-issue semantics exactly: they send a null cycleId and a null projectMilestoneId respectively, and each is mutually exclusive with its setter through the same exclusion table the other pairs use. The milestone branch is kept separate from the one --clear-project already runs, because clearing the milestone alone is valid for a batch that stays in its project. buildBatchUpdateInput is now exported so the clear-versus-set branches can be asserted directly — a null and an absent field mean different things to the API, and only the built patch shows which one a flag produced. --- src/commands/issues-batch.ts | 41 +++++++++++++++++++++--- tests/unit/commands/issues-batch.test.ts | 30 +++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts index d5444a0e..2f43d3ec 100644 --- a/src/commands/issues-batch.ts +++ b/src/commands/issues-batch.ts @@ -90,7 +90,9 @@ interface BatchUpdateOptions { parentTicket?: string; clearParentTicket?: boolean; projectMilestone?: string; + clearProjectMilestone?: boolean; cycle?: string; + clearCycle?: boolean; dueDate?: string; clearDueDate?: boolean; } @@ -465,9 +467,10 @@ function buildBatchUpdateResolverInput( if (!options.clearLabels && options.labels) { input.labels = parseCommaSeparated(options.labels); } - if (options.projectMilestone) + if (!options.clearProjectMilestone && options.projectMilestone) { input.projectMilestone = options.projectMilestone; - if (options.cycle) input.cycle = options.cycle; + } + if (!options.clearCycle && options.cycle) input.cycle = options.cycle; if (options.status) input.status = options.status; if (!options.clearParentTicket && options.parentTicket) { input.parentTicket = options.parentTicket; @@ -489,6 +492,13 @@ function validateBatchUpdateOptions(options: BatchUpdateOptions): void { "--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) { @@ -596,7 +606,9 @@ export function addBatchCommands(issues: Command): void { "--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)") @@ -637,7 +649,14 @@ export function addBatchCommands(issues: Command): void { ); } -function buildBatchUpdateInput( +/** + * 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( options: BatchUpdateOptions, ids: { assigneeId?: UUID; @@ -690,8 +709,20 @@ function buildBatchUpdateInput( input.parentId = ids.parentId; } - if (ids.projectMilestoneId) input.projectMilestoneId = ids.projectMilestoneId; - if (ids.cycleId) input.cycleId = ids.cycleId; + // --clear-project already nulls the milestone above; the explicit flag has + // to work on its own too, for a batch that stays in its project. + if (options.clearProjectMilestone) { + input.projectMilestoneId = null; + } else if (ids.projectMilestoneId) { + input.projectMilestoneId = ids.projectMilestoneId; + } + + if (options.clearCycle) { + input.cycleId = null; + } else if (ids.cycleId) { + input.cycleId = ids.cycleId; + } + if (ids.stateId) input.stateId = ids.stateId; if (options.clearDueDate) { diff --git a/tests/unit/commands/issues-batch.test.ts b/tests/unit/commands/issues-batch.test.ts index 76e7df36..ea9db492 100644 --- a/tests/unit/commands/issues-batch.test.ts +++ b/tests/unit/commands/issues-batch.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { buildBatchUpdateContext, + buildBatchUpdateInput, parseBatchCreateEntries, validateBatchUpdateEstimate, } from "../../../src/commands/issues-batch.js"; @@ -273,3 +274,32 @@ describe("validateBatchUpdateEstimate", () => { expect(request).toHaveBeenCalledTimes(1); }); }); + +describe("buildBatchUpdateInput", () => { + const CYCLE = asUuid("66666666-6666-4666-8666-666666666666"); + const MILESTONE = asUuid("77777777-7777-4777-8777-777777777777"); + + it("detaches the cycle with --clear-cycle", () => { + expect( + buildBatchUpdateInput({ issues: "ENG-1", clearCycle: true }, {}), + ).toEqual({ cycleId: null }); + }); + + it("detaches the milestone with --clear-project-milestone", () => { + expect( + buildBatchUpdateInput( + { issues: "ENG-1", clearProjectMilestone: true }, + {}, + ), + ).toEqual({ projectMilestoneId: null }); + }); + + it("still sets a cycle or milestone when the clear flags are absent", () => { + expect( + buildBatchUpdateInput( + { issues: "ENG-1", cycle: "Cycle 4", projectMilestone: "Beta" }, + { cycleId: CYCLE, projectMilestoneId: MILESTONE }, + ), + ).toEqual({ cycleId: CYCLE, projectMilestoneId: MILESTONE }); + }); +}); From c8d94f1518f0066d5b7153c4a5d0c26f75a430b8 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:31:32 +0200 Subject: [PATCH 25/70] fix(issues): validate a moved issue's estimate against its new team `issues update --team OPS --estimate 4` read the estimation config from the issue's current team, but with --team the estimate lands on the destination. Moving a fibonacci-scale issue to a linear-scale team therefore rejected 4 while naming the team the issue was leaving, and the reverse waved an off-scale value through to come back as a raw API error from Linear. The scale to check is whichever team owns the issue afterwards, so --team now resolves the destination's estimation config and validates against that; the issue's own context is only looked up when the issue is staying put. Neither path costs an extra round trip, because the two are exclusive: resolving the destination also means skipping resolveIssueEstimateContext, and the issue id then comes from resolveIssueId instead. This is the same rule `issues batch update` already applies per target team. --- src/commands/issues.ts | 29 +++++++---- tests/unit/commands/issues.test.ts | 78 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index d568c4df..2edbde93 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -47,6 +47,7 @@ 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 { @@ -1631,8 +1632,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; @@ -1640,15 +1651,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, }); } diff --git a/tests/unit/commands/issues.test.ts b/tests/unit/commands/issues.test.ts index a160b6e3..530f9151 100644 --- a/tests/unit/commands/issues.test.ts +++ b/tests/unit/commands/issues.test.ts @@ -45,6 +45,17 @@ vi.mock("../../../src/resolvers/issue-resolver.js", () => ({ }), })); +vi.mock("../../../src/resolvers/team-resolver.js", () => ({ + resolveTeamEstimateContext: vi.fn().mockResolvedValue({ + teamId: "destination-team-uuid", + teamKey: "OPS", + teamName: "Operations", + issueEstimationType: "fibonacci", + issueEstimationExtended: false, + issueEstimationAllowZero: false, + }), +})); + vi.mock("../../../src/services/issue-service.js", () => ({ archiveIssue: vi.fn().mockResolvedValue({ id: "resolved-issue-uuid" }), createIssue: vi.fn().mockResolvedValue({ id: "new-issue-id" }), @@ -188,6 +199,7 @@ import { resolveIssueEstimateContext, resolveIssueId, } from "../../../src/resolvers/issue-resolver.js"; +import { resolveTeamEstimateContext } from "../../../src/resolvers/team-resolver.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -775,6 +787,72 @@ describe("issues update --estimate", () => { expect(process.exit).toHaveBeenCalledWith(1); }); + it("validates against the destination team when --team moves the issue", async () => { + // 8 is off the current team's linear scale but on the destination's + // fibonacci one, and the estimate lands on the destination. + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-42", + "--team", + "OPS", + "--estimate", + "8", + ]); + + expect(resolveTeamEstimateContext).toHaveBeenCalledWith( + expect.anything(), + "OPS", + ); + expect(resolveIssueEstimateContext).not.toHaveBeenCalled(); + expect(updateIssue).toHaveBeenCalledWith( + expect.anything(), + "resolved-issue-uuid", + expect.objectContaining({ estimate: 8 }), + ); + }); + + it("rejects an estimate off the destination team's scale", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-42", + "--team", + "OPS", + "--estimate", + "4", + ]); + + const error = JSON.parse( + vi.mocked(console.error).mock.calls[0]?.[0] as string, + ) as { error: string }; + expect(error.error).toBe( + 'Invalid --estimate: must be one of [1, 2, 3, 5, 8] for team "OPS" (fibonacci)', + ); + expect(updateIssue).not.toHaveBeenCalled(); + }); + + it("skips the destination lookup when --team is absent", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "issues", + "update", + "ENG-42", + "--estimate", + "3", + ]); + + expect(resolveTeamEstimateContext).not.toHaveBeenCalled(); + }); + it("skips scale validation when --clear-estimate is used", async () => { const program = createProgram(); await program.parseAsync([ From 7b04409e605cbbdec4a0866a3ce25819b8d5f0f8 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:31:44 +0200 Subject: [PATCH 26/70] docs(issues): stop claiming search hides completed issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default "hide completed" narrowing lives in buildListIssuesFilter, which only `listIssues` calls. `issues search` — and `issues list --query`, which runs the same full-text query — never applied it, so the usage context and the --include-archived flag description both described a behaviour the full-text path does not have. The claim is now scoped to `list`, with a separate paragraph saying what the search path actually does: it returns completed issues either way, and --include-archived there only adds archived ones. This text is what an agent reads before picking a flag, so a wrong default is worse than a missing one. The behaviour itself is left alone. Narrowing full-text results by state would silently drop matches a caller searched for by name, which is a bigger surprise than the inconsistency. --- src/commands/issues.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 2edbde93..43cac30a 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -395,14 +395,19 @@ export const ISSUES_META: DomainMeta = { "reachable by identifier everywhere, but excluded from `list`/`search`", "unless you pass --include-archived.", "", - "`list`/`search` also hide 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", + "`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", @@ -718,7 +723,7 @@ function addFilterOptions(cmd: ReturnType): typeof cmd { .option("--subscriber ", "filter by subscriber") .option( "--include-archived", - "include archived issues, and drop the default 'hide completed' narrowing", + "include archived issues (on `list`, also drops the default 'hide completed' narrowing; full-text search never applies it)", ); } From 634f846900a27993c5ccff402db90e7bc6aa7560 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:44:41 +0200 Subject: [PATCH 27/70] fix(issues): name the flag when a relative offset overflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `issues remind --at +99999999w` computed an instant outside the range a JavaScript `Date` can hold, and `toISOString()` answered that with a bare `RangeError: Invalid time value`. It still reached the caller as JSON — `handleCommand` catches everything — but it was the one error in this parser that named neither the flag nor the value it choked on, which is exactly what an agent needs to correct the call. Check the computed instant before formatting it and report it like every other rejected value here: `Invalid --at: "+99999999w" is too far in the future to represent`. The check sits after the arithmetic rather than bounding the accepted digits up front: the limit depends on `now` and the unit, so a digit cap would either reject valid input or let some invalid input through, while `Number.isNaN` on the result is exact. --- src/common/datetime.ts | 18 +++++++++++++++--- tests/unit/common/datetime.test.ts | 8 ++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/common/datetime.ts b/src/common/datetime.ts index 775f6ddb..f678d203 100644 --- a/src/common/datetime.ts +++ b/src/common/datetime.ts @@ -37,8 +37,8 @@ const UNIT_MILLISECONDS: Record = { * @param value - Either `+[mhdw]` or an ISO-8601 date / date-time * @param now - Reference instant for relative values; injected so the parser * stays pure and testable - * @throws Error if the value matches neither form, or names a date that does - * not exist (e.g. `2026-02-30`) + * @throws Error if the value matches neither form, names a date that does not + * exist (e.g. `2026-02-30`), or lands outside the range a `Date` can hold */ export function parseDateTimeOption( flag: string, @@ -59,7 +59,19 @@ export function parseDateTimeOption( ); } - return new Date(now.getTime() + amount * unitMs).toISOString(); + // A large enough offset lands outside the representable range of a Date + // (±100 million days), where `toISOString()` throws a bare RangeError that + // names neither the flag nor the value. Report it like every other bad + // value here instead. + const instant = new Date(now.getTime() + amount * unitMs); + if (Number.isNaN(instant.getTime())) { + throw invalidParameterError( + flag, + `"${value}" is too far in the future to represent`, + ); + } + + return instant.toISOString(); } const iso = ISO_REGEX.exec(value); diff --git a/tests/unit/common/datetime.test.ts b/tests/unit/common/datetime.test.ts index b84c164a..ff6fd8d7 100644 --- a/tests/unit/common/datetime.test.ts +++ b/tests/unit/common/datetime.test.ts @@ -52,6 +52,14 @@ describe("parseDateTimeOption", () => { ); }); + it("rejects an offset that overflows the Date range, naming the flag", () => { + // Without the range check this reached toISOString() and came back as a + // bare RangeError("Invalid time value") mentioning neither flag nor value. + expect(() => parseDateTimeOption("--at", "+99999999w", now)).toThrow( + /Invalid --at: "\+99999999w" is too far in the future/, + ); + }); + it("rejects a day that does not exist", () => { expect(() => parseDateTimeOption("--at", "2026-02-30", now)).toThrow( /is not a real date/, From b83ea52695a356ba9650cc68b506010a3ed7bd66 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:45:11 +0200 Subject: [PATCH 28/70] fix(issues): locate the entry when a batch list is malformed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `labels` and `subscribers` accept the comma-separated flag form as well as a JSON array, and the flag form is parsed by `parseCommaSeparated`, which throws its own message. So `{"labels": "a,,b"}` came back as `Invalid comma-separated list: contains empty segments` — no key, and no `batch document entry N:` prefix, which is the only thing that says which of a hundred entries to fix. Every other rejection in this parser, including the array branch of the same field, carries that locator. Restate the failure with the entry's own locator and the key that carried it. The message is rewritten rather than wrapped: the underlying error adds nothing the new one does not say, and prefixing it would read as two stacked "Invalid ..." clauses. `parseCommaSeparated` itself is left alone — it is shared with the flag paths, where "comma-separated list" is exactly the right noun. --- src/commands/issues-batch.ts | 13 ++++++++++++- tests/unit/commands/issues-batch.test.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts index 2f43d3ec..f62b15db 100644 --- a/src/commands/issues-batch.ts +++ b/src/commands/issues-batch.ts @@ -316,7 +316,18 @@ function optionalInteger( /** 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") { - return parseCommaSeparated(value); + // 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 ( diff --git a/tests/unit/commands/issues-batch.test.ts b/tests/unit/commands/issues-batch.test.ts index ea9db492..10ad9886 100644 --- a/tests/unit/commands/issues-batch.test.ts +++ b/tests/unit/commands/issues-batch.test.ts @@ -70,6 +70,20 @@ describe("parseBatchCreateEntries", () => { expect(entry?.subscribers).toEqual(["bob", "carol"]); }); + it("locates the entry when a comma-separated list has empty segments", () => { + // parseCommaSeparated speaks in flags: on its own it reports a bare + // "comma-separated list", which in a hundred-entry document says nothing + // about where to look. + expect(() => + parseBatchCreateEntries( + JSON.stringify([ + { title: "A", team: "ENG" }, + { title: "B", team: "ENG", labels: "a,,b" }, + ]), + ), + ).toThrow(/batch document entry 1: has "labels" with empty segments/); + }); + it("names the offending key when a list is not a list of strings", () => { expect(() => parseBatchCreateEntries( From c7167010810526ca8d0c790ead1cc0788fb0bd22 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:45:21 +0200 Subject: [PATCH 29/70] fix(issues): guard batch update labels across teams too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildBatchUpdateContext` rejects a named `--status` or `--cycle` when the targets span teams, because `issueBatchUpdate` applies one `stateId` to all of them and there is no single team to resolve the name against. `--labels` has exactly that property and was not covered: Linear labels can be team-scoped, so two teams may each own a "bug", the lookup filter matches on name alone and `mapLabels` takes the first hit. A cross-team `--labels bug` therefore tagged half the batch with the other team's label — silently, since both names read the same in the output. Extend the guard to `--labels`, checking the list entry by entry so a UUID still passes: the error tells callers to pass a UUID instead, and `--labels` is the one flag here whose value can mix the two forms. The three checks now share a `crossTeam` helper rather than repeating the message; the wording is unchanged so existing callers (and the tests) still see the same guidance. --- src/commands/issues-batch.ts | 38 ++++++++++++++++-------- tests/unit/commands/issues-batch.test.ts | 24 ++++++++++++++- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/commands/issues-batch.ts b/src/commands/issues-batch.ts index f62b15db..3d9f6573 100644 --- a/src/commands/issues-batch.ts +++ b/src/commands/issues-batch.ts @@ -391,15 +391,20 @@ function toCreateInput( /** * Derives the lookup scope for a batch patch from the targets themselves. * - * `issueBatchUpdate` applies one `stateId`/`cycleId` to every target, so a - * status or cycle 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. + * `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. + * 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. */ @@ -411,14 +416,23 @@ export function buildBatchUpdateContext( const [onlyTarget] = targets; if (teamKeys.length > 1) { + const crossTeam = (flag: string): never => { + throw invalidParameterError( + flag, + `cannot be resolved by name across teams ${teamKeys.join(", ")} — pass a UUID, or split the batch per team`, + ); + }; + for (const flag of ["status", "cycle"] as const) { const value = options[flag]; - if (value !== undefined && !isUuid(value)) { - throw invalidParameterError( - `--${flag}`, - `cannot be resolved by name across teams ${teamKeys.join(", ")} — pass a UUID, or split the batch per team`, - ); - } + if (value !== undefined && !isUuid(value)) crossTeam(`--${flag}`); + } + + if ( + options.labels !== undefined && + parseCommaSeparated(options.labels).some((label) => !isUuid(label)) + ) { + crossTeam("--labels"); } return {}; diff --git a/tests/unit/commands/issues-batch.test.ts b/tests/unit/commands/issues-batch.test.ts index 10ad9886..6c94f559 100644 --- a/tests/unit/commands/issues-batch.test.ts +++ b/tests/unit/commands/issues-batch.test.ts @@ -179,13 +179,35 @@ describe("buildBatchUpdateContext", () => { } }); - it("lets a UUID status or cycle through as the documented escape hatch", () => { + it("rejects named labels spanning teams", () => { + // Labels can be team-scoped, so two teams may each own a "bug"; the name + // lookup takes the first hit and half the batch gets the wrong label. + expect(() => + buildBatchUpdateContext([target("ENG"), target("OPS")], { + issues: "ENG-1,OPS-1", + labels: "bug", + }), + ).toThrow(/--labels: cannot be resolved by name across teams ENG, OPS/); + }); + + it("rejects a label list that mixes a UUID with a name", () => { + expect(() => + buildBatchUpdateContext([target("ENG"), target("OPS")], { + issues: "ENG-1,OPS-1", + labels: "66666666-6666-4666-8666-666666666666,bug", + }), + ).toThrow(/--labels/); + }); + + it("lets a UUID status, cycle or label through as the documented escape hatch", () => { // The error message advises passing a UUID; a UUID needs no team to // resolve against, so the guard must not reject it as well. const context = buildBatchUpdateContext([target("ENG"), target("OPS")], { issues: "ENG-1,OPS-1", status: "33333333-3333-4333-8333-333333333333", cycle: "44444444-4444-4444-8444-444444444444", + labels: + "66666666-6666-4666-8666-666666666666,77777777-7777-4777-8777-777777777777", }); expect(context).toEqual({}); From d5f81985ab9632db78b968a043398260c97a336b Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:45:46 +0200 Subject: [PATCH 30/70] fix(issues): resolve a `me` assignee against the viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `me`/`@me` is documented as a valid user reference everywhere a user is named — the JSON Schema's `assignee` description, `ISSUES_META`'s `user` argument, the `--assignee` help — but only `--subscribers` and `--delegate` actually honoured it, because those are the two flags that go through `resolveUserId`. Everything else hands the reference to the batch queries as `$assigneeQuery`/`$creatorQuery`, which match display name and email; `me` is neither, so `issues create --assignee me`, `update`, `batch create` and the `--assignee`/`--creator` filters all answered `User "me" not found`. Divert the alias before the query is built: `buildUserQuery` yields null for a UUID and for a viewer alias alike (both resolve elsewhere), and the alias takes a `viewer` lookup issued alongside the batch request. The mutation resolvers already run one concurrent user-lookup phase for subscribers and delegate, so the assignee case joins it and costs no extra round trip in the common case; the filter resolver gains one, and resolves `--assignee me --creator me` with a single lookup for both. Both call sites await the lookup in the same expression as the batch request. Starting it without awaiting it there would leave a rejection unhandled when the other side throws first, which kills the process with a stack trace instead of the JSON error envelope — the same reasoning already recorded for the subscriber lookups. `isViewerAlias` is exported rather than duplicating the alias set, so `me` and `@me` keep one definition. --- src/resolvers/batch-resolve-mappers.ts | 15 ++++ src/resolvers/issue-filter-resolver.ts | 86 +++++++++++++------ src/resolvers/issue-mutation-resolver.ts | 55 +++++++----- src/resolvers/user-resolver.ts | 13 ++- .../resolvers/issue-filter-resolver.test.ts | 42 ++++++++- .../resolvers/issue-mutation-resolver.test.ts | 78 +++++++++++++++++ 6 files changed, 241 insertions(+), 48 deletions(-) diff --git a/src/resolvers/batch-resolve-mappers.ts b/src/resolvers/batch-resolve-mappers.ts index e3e6c757..dda55c7f 100644 --- a/src/resolvers/batch-resolve-mappers.ts +++ b/src/resolvers/batch-resolve-mappers.ts @@ -4,6 +4,7 @@ import type { BatchResolveForCreateQuery, IssueLabelFilter, } from "../gql/graphql.js"; +import { isViewerAlias } from "./user-resolver.js"; /** * Pure mappers shared by the batch resolvers (issue create/update and issue @@ -38,6 +39,20 @@ export function buildLabelFilter(names: string[]): IssueLabelFilter { return { or: names.map((name) => ({ name: { eqIgnoreCase: name } })) }; } +/** + * Builds the `$assigneeQuery`/`$creatorQuery` variable for a user reference. + * + * Yields `null` — a lookup the batch query skips — for every reference the + * query cannot match by display name or email: an absent flag, a UUID, and the + * `me` alias, which names the caller rather than a value and is resolved + * against `viewer` by the caller instead. Sending `me` as a name would only + * come back empty, and {@link mapUser} would report it as an unknown user. + */ +export function buildUserQuery(ref: string | undefined): string | null { + if (!ref || isUuid(ref) || isViewerAlias(ref)) return null; + return ref; +} + /** * Two-phase user precedence, replicated purely from the `or:[displayName,email]` * result: an exact (case-insensitive) display-name match wins; multiple name diff --git a/src/resolvers/issue-filter-resolver.ts b/src/resolvers/issue-filter-resolver.ts index df7f1e7c..6c88483f 100644 --- a/src/resolvers/issue-filter-resolver.ts +++ b/src/resolvers/issue-filter-resolver.ts @@ -9,14 +9,17 @@ import { import { BatchResolveForSearchDocument } from "../gql/graphql.js"; import { buildLabelFilter, + buildUserQuery, mapCycle, mapLabels, mapParent, mapProjectId, mapUser, + type UserNode, } from "./batch-resolve-mappers.js"; import { resolveCycleId } from "./cycle-resolver.js"; import { resolveStatusId } from "./status-resolver.js"; +import { isViewerAlias, resolveViewerId } from "./user-resolver.js"; export interface SearchFilterResolutionInput { team?: string; @@ -58,10 +61,11 @@ export async function resolveSearchFilterIds( const team = input.team; const teamIsUuid = team ? isUuid(team) : false; - const assigneeQuery = - input.assignee && !isUuid(input.assignee) ? input.assignee : null; - const creatorQuery = - input.creator && !isUuid(input.creator) ? input.creator : null; + const assigneeQuery = buildUserQuery(input.assignee); + const creatorQuery = buildUserQuery(input.creator); + const wantsViewer = + (!!input.assignee && isViewerAlias(input.assignee)) || + (!!input.creator && isViewerAlias(input.creator)); const projectName = input.project && !isUuid(input.project) ? input.project : null; const projectIdVar = @@ -74,20 +78,29 @@ export async function resolveSearchFilterIds( ? parseIssueIdentifier(input.parent) : null; - const response = await gqlClient.request(BatchResolveForSearchDocument, { - teamKey: team && !teamIsUuid ? team : null, - teamName: team && !teamIsUuid ? team : null, - teamId: team && teamIsUuid ? team : null, - assigneeQuery, - creatorQuery, - projectName, - projectId: projectIdVar, - labelFilter: buildLabelFilter(labelNames), - cycleName, - parentTeamKey: parent?.teamKey ?? null, - parentIssueNumber: parent?.issueNumber ?? null, - milestoneName: null, - }); + // The viewer lookup is awaited together with the batch request: starting it + // without awaiting it in the same expression would leave a rejection + // unhandled whenever the batch request throws first, and an unhandled + // rejection kills the process with a stack trace instead of the JSON error + // envelope. One lookup serves both flags — `--assignee me --creator me` is a + // perfectly ordinary "what am I working on that I filed" query. + const [viewerId, response] = await Promise.all([ + wantsViewer ? resolveViewerId(gqlClient) : undefined, + gqlClient.request(BatchResolveForSearchDocument, { + teamKey: team && !teamIsUuid ? team : null, + teamName: team && !teamIsUuid ? team : null, + teamId: team && teamIsUuid ? team : null, + assigneeQuery, + creatorQuery, + projectName, + projectId: projectIdVar, + labelFilter: buildLabelFilter(labelNames), + cycleName, + parentTeamKey: parent?.teamKey ?? null, + parentIssueNumber: parent?.issueNumber ?? null, + milestoneName: null, + }), + ]); const resolved: SearchFilterResolution = {}; @@ -98,15 +111,21 @@ export async function resolveSearchFilterIds( } if (input.assignee) { - resolved.assigneeId = isUuid(input.assignee) - ? asUuid(input.assignee) - : mapUser(response.assignees.nodes, input.assignee); + resolved.assigneeId = pickUserId( + input.assignee, + assigneeQuery, + response.assignees.nodes, + viewerId, + ); } if (input.creator) { - resolved.creatorId = isUuid(input.creator) - ? asUuid(input.creator) - : mapUser(response.creators.nodes, input.creator); + resolved.creatorId = pickUserId( + input.creator, + creatorQuery, + response.creators.nodes, + viewerId, + ); } if (input.project) { @@ -164,6 +183,25 @@ export async function resolveSearchFilterIds( return resolved; } +/** + * Picks the id for one user-valued filter flag. + * + * `query` is non-null exactly when the batch response holds candidates to match + * against; otherwise the reference is a UUID that passes through, or the `me` + * alias, which takes the viewer id fetched alongside the batch request. + */ +function pickUserId( + raw: string, + query: string | null, + nodes: UserNode[], + viewerId: UUID | undefined, +): UUID { + if (query) return mapUser(nodes, query); + if (isUuid(raw)) return asUuid(raw); + if (viewerId) return viewerId; + throw notFoundError("User", raw); +} + type SearchTeamNode = { id: string; key: string; name: string }; /** Mirrors resolveTeamId: prefer key match, then name; else not-found. */ diff --git a/src/resolvers/issue-mutation-resolver.ts b/src/resolvers/issue-mutation-resolver.ts index eb5e995c..3f5325a1 100644 --- a/src/resolvers/issue-mutation-resolver.ts +++ b/src/resolvers/issue-mutation-resolver.ts @@ -12,6 +12,7 @@ import { } from "../gql/graphql.js"; import { buildLabelFilter, + buildUserQuery, mapCycle, mapLabels, mapMilestone, @@ -23,7 +24,11 @@ import { type TeamNode, } from "./batch-resolve-mappers.js"; import { resolveTeamId, type TeamEstimateContext } from "./team-resolver.js"; -import { resolveUserId } from "./user-resolver.js"; +import { + isViewerAlias, + resolveUserId, + resolveViewerId, +} from "./user-resolver.js"; /** * Batch resolver for issue create / update. @@ -98,25 +103,34 @@ export interface ResolvedCreateIssueIds { * Resolves the user-valued references that the `BatchResolve*` queries cannot. * * `--subscribers` is a list and `--delegate` a second single user, neither of - * which the batch query's one `$assigneeQuery` variable can express, and a - * `me` alias needs the viewer lookup. They go through `resolveUserId` in - * parallel instead, which keeps the name/email disambiguation identical to - * every other user flag and costs nothing when the flags are absent. + * which the batch query's one `$assigneeQuery` variable can express. They go + * through `resolveUserId` in parallel instead, which keeps the name/email + * disambiguation identical to every other user flag and costs nothing when the + * flags are absent. + * + * `--assignee` normally *is* the batch query's variable, but `me` matches no + * display name or email, so that one spelling is diverted here too — otherwise + * an alias the help text and the JSON Schema both advertise would come back as + * `User "me" not found`. */ async function resolveIssueUserRefs( client: GraphQLClient, - input: { subscribers?: string[]; delegate?: string }, -): Promise<{ subscriberIds?: UUID[]; delegateId?: UUID }> { - const [subscriberIds, delegateId] = await Promise.all([ + input: { subscribers?: string[]; delegate?: string; assignee?: string }, +): Promise<{ subscriberIds?: UUID[]; delegateId?: UUID; assigneeId?: UUID }> { + const [subscriberIds, delegateId, assigneeId] = await Promise.all([ input.subscribers ? Promise.all(input.subscribers.map((ref) => resolveUserId(client, ref))) : undefined, input.delegate ? resolveUserId(client, input.delegate) : undefined, + input.assignee && isViewerAlias(input.assignee) + ? resolveViewerId(client) + : undefined, ]); return { ...(subscriberIds && { subscriberIds }), ...(delegateId && { delegateId }), + ...(assigneeId && { assigneeId }), }; } @@ -129,8 +143,7 @@ export async function resolveCreateIssueIds( input: ResolveCreateIssueIdsInput, ): Promise { const teamIsUuid = isUuid(input.team); - const assigneeQuery = - input.assignee && !isUuid(input.assignee) ? input.assignee : null; + const assigneeQuery = buildUserQuery(input.assignee); const projectName = input.project && !isUuid(input.project) ? input.project : null; const projectIdVar = @@ -197,10 +210,12 @@ export async function resolveCreateIssueIds( }; } - if (input.assignee) { - resolved.assigneeId = isUuid(input.assignee) - ? asUuid(input.assignee) - : mapUser(response.assignees.nodes, input.assignee); + // A `me` assignee has no name to match; it is resolved against `viewer` in + // userRefs and spread over `resolved` on the way out. + if (assigneeQuery) { + resolved.assigneeId = mapUser(response.assignees.nodes, assigneeQuery); + } else if (input.assignee && isUuid(input.assignee)) { + resolved.assigneeId = asUuid(input.assignee); } let matchedProject: ProjectNode | undefined; @@ -391,8 +406,7 @@ export async function resolveUpdateIssueIds( input: ResolveUpdateIssueIdsInput, context: UpdateIssueContext, ): Promise { - const assigneeQuery = - input.assignee && !isUuid(input.assignee) ? input.assignee : null; + const assigneeQuery = buildUserQuery(input.assignee); // projectName scopes both --project resolution and milestone lookup: the new // project when --project is a name, else the issue's current project. @@ -452,10 +466,11 @@ export async function resolveUpdateIssueIds( ? { teamId: destinationTeamId } : {}; - if (input.assignee) { - resolved.assigneeId = isUuid(input.assignee) - ? asUuid(input.assignee) - : mapUser(response.assignees.nodes, input.assignee); + // As in create: `me` carries no name to match and arrives via userRefs. + if (assigneeQuery) { + resolved.assigneeId = mapUser(response.assignees.nodes, assigneeQuery); + } else if (input.assignee && isUuid(input.assignee)) { + resolved.assigneeId = asUuid(input.assignee); } // The projects field matches by projectNameVar/projectIdVar; the matched node diff --git a/src/resolvers/user-resolver.ts b/src/resolvers/user-resolver.ts index 7f162d43..40b38b5e 100644 --- a/src/resolvers/user-resolver.ts +++ b/src/resolvers/user-resolver.ts @@ -6,6 +6,17 @@ import { FindUsersDocument, GetViewerDocument } from "../gql/graphql.js"; /** Spellings of "the authenticated user" accepted wherever a user is expected. */ const VIEWER_ALIASES: ReadonlySet = new Set(["me", "@me"]); +/** + * True when a user reference names the caller rather than a lookup value. + * + * Exported for the batch resolvers: they resolve most user references through + * a name/email GraphQL filter, and `me` matches neither, so they have to divert + * it to {@link resolveViewerId} before building the query. + */ +export function isViewerAlias(nameOrEmailOrId: string): boolean { + return VIEWER_ALIASES.has(nameOrEmailOrId.toLowerCase()); +} + /** * Resolves the authenticated user's UUID. * @@ -31,7 +42,7 @@ export async function resolveUserId( ): Promise { if (isUuid(nameOrEmailOrId)) return asUuid(nameOrEmailOrId); - if (VIEWER_ALIASES.has(nameOrEmailOrId.toLowerCase())) { + if (isViewerAlias(nameOrEmailOrId)) { return resolveViewerId(client); } diff --git a/tests/unit/resolvers/issue-filter-resolver.test.ts b/tests/unit/resolvers/issue-filter-resolver.test.ts index a64fad32..f622eea5 100644 --- a/tests/unit/resolvers/issue-filter-resolver.test.ts +++ b/tests/unit/resolvers/issue-filter-resolver.test.ts @@ -53,8 +53,8 @@ type BatchNodes = { parentIssues?: Array<{ id: string; identifier: string }>; }; -function mockGql(nodes: BatchNodes) { - const request = vi.fn().mockResolvedValue({ +function buildBatchResponse(nodes: BatchNodes) { + return { teams: { nodes: nodes.teams ?? [] }, assignees: { nodes: nodes.assignees ?? [] }, creators: { nodes: nodes.creators ?? [] }, @@ -68,7 +68,11 @@ function mockGql(nodes: BatchNodes) { statuses: { nodes: nodes.statuses ?? [] }, cycles: { nodes: nodes.cycles ?? [] }, parentIssues: { nodes: nodes.parentIssues ?? [] }, - }); + }; +} + +function mockGql(nodes: BatchNodes) { + const request = vi.fn().mockResolvedValue(buildBatchResponse(nodes)); return { client: { request } as unknown as GraphQLClient, request }; } @@ -189,4 +193,36 @@ describe("resolveSearchFilterIds", () => { assigneeId: "550e8400-e29b-41d4-a716-446655440000", }); }); + + it("resolves `me` against the viewer, once for both user filters", async () => { + const { client, request } = mockGql({}); + // `viewer` is the one request here that takes no variables. + request.mockImplementation((_document, variables) => + variables === undefined + ? Promise.resolve({ + viewer: { + id: "viewer-uuid", + name: "Me", + email: "me@example.com", + }, + }) + : Promise.resolve(buildBatchResponse({})), + ); + + const result = await resolveSearchFilterIds(client, { + assignee: "me", + creator: "@me", + }); + + expect(result).toEqual({ + assigneeId: "viewer-uuid", + creatorId: "viewer-uuid", + }); + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ assigneeQuery: null, creatorQuery: null }), + ); + // One viewer lookup plus the batch request — not one lookup per flag. + expect(request).toHaveBeenCalledTimes(2); + }); }); diff --git a/tests/unit/resolvers/issue-mutation-resolver.test.ts b/tests/unit/resolvers/issue-mutation-resolver.test.ts index f05a0cf5..71737fd3 100644 --- a/tests/unit/resolvers/issue-mutation-resolver.test.ts +++ b/tests/unit/resolvers/issue-mutation-resolver.test.ts @@ -601,6 +601,84 @@ describe("resolveUpdateIssueIds user references", () => { }); }); +describe("the `me` assignee alias", () => { + /** + * `viewer` is queried without variables; every other request in these + * resolvers passes some. That is enough to route the mock. + */ + function mockGqlWithViewer(nodes: Nodes, viewerId = "viewer-uuid") { + const request = vi.fn().mockImplementation((_document, variables) => + variables === undefined + ? Promise.resolve({ + viewer: { id: viewerId, name: "Me", email: "me@example.com" }, + }) + : Promise.resolve(buildResponse(nodes)), + ); + return { client: { request } as unknown as GraphQLClient, request }; + } + + it("resolves a create assignee against the viewer", async () => { + const { client, request } = mockGqlWithViewer({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "me", + }); + + expect(result.assigneeId).toBe("viewer-uuid"); + // "me" is not a display name — sending it as one would only come back + // empty and be reported as an unknown user. + expect(request).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ assigneeQuery: null }), + ); + }); + + it("accepts the @me spelling and ignores case", async () => { + const { client } = mockGqlWithViewer({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "@ME", + }); + + expect(result.assigneeId).toBe("viewer-uuid"); + }); + + it("resolves an update assignee against the viewer", async () => { + const { client } = mockGqlWithViewer({}); + + const result = await resolveUpdateIssueIds(client, { assignee: "me" }, {}); + + expect(result.assigneeId).toBe("viewer-uuid"); + }); + + it("still resolves a named assignee through the batch response", async () => { + const { client } = mockGqlWithViewer({ + teams: [{ id: "team-uuid", key: "ENG", name: "Engineering" }], + assignees: [ + { + id: "user-uuid", + name: "John", + email: "john@example.com", + displayName: "John Doe", + }, + ], + }); + + const result = await resolveCreateIssueIds(client, { + team: "ENG", + assignee: "John Doe", + }); + + expect(result.assigneeId).toBe("user-uuid"); + }); +}); + describe("user reference lookups and unhandled rejections", () => { /** * The user lookups run concurrently with the batch request. If one is only From b00488b1e005574ebf36441e8544276b637229e4 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:45:53 +0200 Subject: [PATCH 31/70] docs(issues): attach the batch-create doc to its function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block describing `resolveBatchCreateIssueIds` sat directly above `BATCH_CREATE_RESOLVE_CONCURRENCY`, so every editor attributed twenty lines about batching semantics to the number 5 and showed the exported function itself with no hover doc at all. Move it onto the function and leave the constant a one-line comment. The text also still described a "promise cache" collapsing entries onto one in-flight request — the shape before the concurrency bound landed. The implementation now deduplicates reference sets into a Map up front and resolves them in waves, so say that instead. --- src/resolvers/issue-mutation-resolver.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/resolvers/issue-mutation-resolver.ts b/src/resolvers/issue-mutation-resolver.ts index 3f5325a1..c0a87d24 100644 --- a/src/resolvers/issue-mutation-resolver.ts +++ b/src/resolvers/issue-mutation-resolver.ts @@ -266,6 +266,9 @@ export async function resolveCreateIssueIds( return { ...resolved, ...userRefs }; } +/** How many distinct reference sets resolve at once (see below for why). */ +const BATCH_CREATE_RESOLVE_CONCURRENCY = 5; + /** * Resolves the human identifiers for a whole batch of issues to create. * @@ -274,23 +277,20 @@ export async function resolveCreateIssueIds( * a batch must not quietly accept a team name that `issues create` would * reject. What changes is the round-trip count: entries naming the same set of * references (the usual case, where a batch shares a team and project and - * differs only in title) are collapsed onto one in-flight request via a - * promise cache, so the cost is one `BatchResolveForCreate` per *distinct* - * reference set rather than per row. + * differs only in title) are deduplicated up front, so the cost is one + * `BatchResolveForCreate` per *distinct* reference set rather than per row. * * Field-level memoization would collapse more, but status, cycle and milestone * lookups are scoped by the entry's own team and project, so a per-field cache * cannot be keyed correctly without duplicating that scoping here. * - * Distinct reference sets are resolved in bounded waves rather than all at - * once. Collapsing helps the common batch that shares a team and project, but - * a heterogeneous import — the case this command exists for — has as many + * The distinct sets are then resolved in bounded waves rather than all at once. + * Deduplication helps the common batch that shares a team and project, but a + * heterogeneous import — the case this command exists for — has as many * distinct sets as rows, and firing every `BatchResolveForCreate` (plus its * user lookups) simultaneously is how a large import earns a rate-limit * rejection instead of a result. */ -const BATCH_CREATE_RESOLVE_CONCURRENCY = 5; - export async function resolveBatchCreateIssueIds( client: GraphQLClient, entries: readonly ResolveCreateIssueIdsInput[], From 60b94914b724155e4ee9993558e6fc7f13225fd6 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:04:23 +0200 Subject: [PATCH 32/70] feat(issues): take batch update from a JSON document too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `batch create` accepted a JSON document and had a published schema to write it against; `batch update` was flag-only, so the one command that edits many issues at once had no contract a caller could validate before firing a mass mutation. Generating a long `--clear-*`-laden command line is also the awkward half of the pair for an agent driving the CLI. `batch update` now accepts `--file`/`--json` carrying `{"issues": [...], "patch": {...}}`, published as `schemas/issues-batch-update.schema.json`. The patch keys mirror the update flags with the dashes dropped, and `null` clears a field the way `--clear-*` does — cleaner than a document with both `assignee` and `clearAssignee` keys and a mutual-exclusion rule to state twice. The document is one patch over a list of targets rather than an array of per-issue patches, because that is what `issueBatchUpdate` can do. Per -issue patches would fan out into N mutations and lose the all-or- nothing guarantee that is the reason to batch. Both input paths normalise into one `BatchUpdatePatch` before anything else touches them, so the team-scope guard, the estimate validation and the built mutation input stay single implementations. Mixing a document with flags is refused by name instead of silently ignored — a dropped `--status` would apply a different patch to every issue in the batch. The cross-team error now names `status`/`cycle`/`labels` without dashes, since the rule covers both forms. --- docs/files.md | 5 +- schemas/issues-batch-update.schema.json | 123 +++++ src/commands/issues-batch.ts | 496 +++++++++++++++--- src/commands/issues.ts | 19 +- .../unit/commands/issues-batch-schema.test.ts | 88 +++- tests/unit/commands/issues-batch.test.ts | 218 ++++++-- 6 files changed, 833 insertions(+), 116 deletions(-) create mode 100644 schemas/issues-batch-update.schema.json diff --git a/docs/files.md b/docs/files.md index 194515f2..c5a46abe 100644 --- a/docs/files.md +++ b/docs/files.md @@ -107,7 +107,10 @@ Source `.graphql` files that feed into code generation. 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. Kept in step with `parseBatchCreateEntries` in `src/commands/issues-batch.ts` by `tests/unit/commands/issues-batch-schema.test.ts`; extend both when adding a field. +- **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/`) 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/src/commands/issues-batch.ts b/src/commands/issues-batch.ts index 3d9f6573..a7c853a2 100644 --- a/src/commands/issues-batch.ts +++ b/src/commands/issues-batch.ts @@ -68,13 +68,16 @@ interface BatchCreateEntry { delegate?: string; } -interface BatchCreateOptions { +/** The two ways every batch command takes its JSON document. */ +interface DocumentOptions { file?: string; json?: string; } -interface BatchUpdateOptions { - issues: string; +type BatchCreateOptions = DocumentOptions; + +interface BatchUpdateOptions extends DocumentOptions { + issues?: string; title?: string; description?: string; status?: string; @@ -98,14 +101,46 @@ interface BatchUpdateOptions { } /** - * Where the published copy of `schemas/issues-batch-create.schema.json` lives. + * A `batch update` patch in normalised form, shared by both input paths. * - * Pinned to the raw file on the default branch rather than a tag: the schema + * `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 BATCH_CREATE_SCHEMA_URL = - "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-create.schema.json"; +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 @@ -131,8 +166,48 @@ export const KNOWN_ENTRY_KEYS: ReadonlySet = new Set([ "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: BatchCreateOptions): string { +function readBatchDocument(options: DocumentOptions): string { if (options.json !== undefined && options.file !== undefined) { throw invalidParameterError("--json", "cannot be combined with --file"); } @@ -344,6 +419,167 @@ function parseStringList(value: unknown, key: string, at: string): string[] { 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, @@ -410,29 +646,29 @@ function toCreateInput( */ export function buildBatchUpdateContext( targets: readonly ResolvedIssueRef[], - options: BatchUpdateOptions, + patch: BatchUpdatePatch, ): UpdateIssueContext { const teamKeys = [...new Set(targets.map((target) => target.teamKey))]; const [onlyTarget] = targets; if (teamKeys.length > 1) { - const crossTeam = (flag: string): never => { + const crossTeam = (field: string): never => { throw invalidParameterError( - flag, + field, `cannot be resolved by name across teams ${teamKeys.join(", ")} — pass a UUID, or split the batch per team`, ); }; - for (const flag of ["status", "cycle"] as const) { - const value = options[flag]; - if (value !== undefined && !isUuid(value)) crossTeam(`--${flag}`); + for (const field of ["status", "cycle"] as const) { + const value = patch[field]; + if (typeof value === "string" && !isUuid(value)) crossTeam(field); } if ( - options.labels !== undefined && - parseCommaSeparated(options.labels).some((label) => !isUuid(label)) + Array.isArray(patch.labels) && + patch.labels.some((label) => !isUuid(label)) ) { - crossTeam("--labels"); + crossTeam("labels"); } return {}; @@ -461,11 +697,12 @@ export function buildBatchUpdateContext( export async function validateBatchUpdateEstimate( client: GraphQLClient, targets: readonly ResolvedIssueRef[], - options: BatchUpdateOptions, + patch: BatchUpdatePatch, ): Promise { - if (options.estimate === undefined) return; + const estimate = patch.estimate; + + if (typeof estimate !== "number") return; - const estimate = parseEstimateOption(options.estimate); const teamIds = [...new Set(targets.map((target) => target.teamId))]; const teams = await Promise.all( teamIds.map((teamId) => resolveTeamEstimateContext(client, teamId)), @@ -482,26 +719,150 @@ export async function validateBatchUpdateEstimate( } function buildBatchUpdateResolverInput( - options: BatchUpdateOptions, + patch: BatchUpdatePatch, ): ResolveUpdateIssueIdsInput { const input: ResolveUpdateIssueIdsInput = {}; - if (!options.clearAssignee && options.assignee) - input.assignee = options.assignee; - if (!options.clearProject && options.project) input.project = options.project; - if (!options.clearLabels && options.labels) { - input.labels = parseCommaSeparated(options.labels); + for (const key of [ + "assignee", + "project", + "projectMilestone", + "cycle", + "status", + "parentTicket", + ] as const) { + const value = patch[key]; + if (typeof value === "string") input[key] = value; } - if (!options.clearProjectMilestone && options.projectMilestone) { - input.projectMilestone = options.projectMilestone; + + 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), + }; } - if (!options.clearCycle && options.cycle) input.cycle = options.cycle; - if (options.status) input.status = options.status; - if (!options.clearParentTicket && options.parentTicket) { - input.parentTicket = options.parentTicket; + + 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 input; + 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 { @@ -609,9 +970,23 @@ export function addBatchCommands(issues: Command): void { "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"), ) - .requiredOption("--issues ", "issues to update (comma-separated)") + .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") @@ -640,22 +1015,21 @@ export function addBatchCommands(issues: Command): void { .option("--clear-due-date", "clear due date") .action( commandAction<[BatchUpdateOptions, Command]>(async (options, command) => { - validateBatchUpdateOptions(options); + const { issues, patch } = readBatchUpdateRequest(options); - const refs = parseCommaSeparated(options.issues); const ctx = createContext(getRootOpts(command)); - const targets = await resolveIssueRefs(ctx.gql, refs); - const context = buildBatchUpdateContext(targets, options); + const targets = await resolveIssueRefs(ctx.gql, issues); + const context = buildBatchUpdateContext(targets, patch); - await validateBatchUpdateEstimate(ctx.gql, targets, options); + await validateBatchUpdateEstimate(ctx.gql, targets, patch); - const resolverInput = buildBatchUpdateResolverInput(options); + const resolverInput = buildBatchUpdateResolverInput(patch); const ids = Object.keys(resolverInput).length > 0 ? await resolveUpdateIssueIds(ctx.gql, resolverInput, context) : {}; - const input = buildBatchUpdateInput(options, ids); + const input = buildBatchUpdateInput(patch, ids); if (Object.keys(input).length === 0) { throw invalidParameterError( @@ -682,7 +1056,7 @@ export function addBatchCommands(issues: Command): void { * the built input shows which one a flag produced. */ export function buildBatchUpdateInput( - options: BatchUpdateOptions, + patch: BatchUpdatePatch, ids: { assigneeId?: UUID; projectId?: UUID; @@ -695,25 +1069,19 @@ export function buildBatchUpdateInput( ): UpdateIssueInput { const input: UpdateIssueInput = {}; - if (options.title) input.title = options.title; - if (options.description) input.description = options.description; - if (options.priority !== undefined) { - input.priority = parsePriorityOption(options.priority); - } - - if (options.clearEstimate) { - input.estimate = null; - } else if (options.estimate !== undefined) { - input.estimate = parseEstimateOption(options.estimate); - } + 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 (options.clearAssignee) { + if (patch.assignee === null) { input.assigneeId = null; } else if (ids.assigneeId) { input.assigneeId = ids.assigneeId; } - if (options.clearProject) { + if (patch.project === null) { input.projectId = null; input.projectMilestoneId = null; } else if (ids.projectId) { @@ -722,27 +1090,27 @@ export function buildBatchUpdateInput( // Only overwrite semantics here: add/remove would need each target's current // label set, which a single-patch mutation cannot express. - if (options.clearLabels) { + if (patch.labels === null) { input.labelIds = []; } else if (ids.labelIds) { input.labelIds = ids.labelIds; } - if (options.clearParentTicket) { + if (patch.parentTicket === null) { input.parentId = null; } else if (ids.parentId) { input.parentId = ids.parentId; } - // --clear-project already nulls the milestone above; the explicit flag has - // to work on its own too, for a batch that stays in its project. - if (options.clearProjectMilestone) { + // 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 (options.clearCycle) { + if (patch.cycle === null) { input.cycleId = null; } else if (ids.cycleId) { input.cycleId = ids.cycleId; @@ -750,11 +1118,5 @@ export function buildBatchUpdateInput( if (ids.stateId) input.stateId = ids.stateId; - if (options.clearDueDate) { - input.dueDate = null; - } else if (options.dueDate) { - input.dueDate = parseDueDate(options.dueDate); - } - return input; } diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 43cac30a..cf817c6a 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -414,14 +414,18 @@ export const ISSUES_META: DomainMeta = { "issue they otherwise could not see — it does not produce a link; the", "issue's permalink is the `url` field on any read).", "", - "`batch create` takes a JSON array instead of flags — one object per", - "issue, keys named after the `issues create` flags, unknown keys", - "rejected. the contract is published as JSON Schema (draft 2020-12) in", - "`schemas/issues-batch-create.schema.json`, also shipped in the npm", - "package and served raw from the repository's default branch:", + "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", - "write the document against that schema, validate it locally, then pass", - "it with --file (or - for stdin).", + "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)", @@ -434,6 +438,7 @@ export const ISSUES_META: DomainMeta = { "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 ", diff --git a/tests/unit/commands/issues-batch-schema.test.ts b/tests/unit/commands/issues-batch-schema.test.ts index 4cb88bc7..b812d139 100644 --- a/tests/unit/commands/issues-batch-schema.test.ts +++ b/tests/unit/commands/issues-batch-schema.test.ts @@ -2,20 +2,24 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { + CLEARABLE_PATCH_KEYS, KNOWN_ENTRY_KEYS, + KNOWN_PATCH_KEYS, parseBatchCreateEntries, + parseBatchUpdateDocument, } from "../../../src/commands/issues-batch.js"; /** - * The published schema is the contract callers write their batch documents - * against, so it has to stay in step with the parser that actually accepts - * them. Nothing at runtime reads the schema — these tests are the only thing + * The published schemas are the contract callers write their batch documents + * against, so they have to stay in step with the parsers that actually accept + * them. Nothing at runtime reads a schema — these tests are the only thing * standing between a new field and a schema that silently rejects it. */ -const SCHEMA_PATH = fileURLToPath( - new URL("../../../schemas/issues-batch-create.schema.json", import.meta.url), -); +const schemaPath = (name: string): string => + fileURLToPath(new URL(`../../../schemas/${name}`, import.meta.url)); + +const SCHEMA_PATH = schemaPath("issues-batch-create.schema.json"); interface BatchCreateSchema { $defs: { @@ -61,3 +65,75 @@ describe("issues-batch-create.schema.json", () => { } }); }); + +interface SchemaBranch { + type?: string; + $ref?: string; +} + +interface BatchUpdateSchema { + required: string[]; + additionalProperties: boolean; + $defs: { + patch: { + properties: Record; + additionalProperties: boolean; + minProperties: number; + }; + }; + examples: unknown[]; +} + +const updateSchema = JSON.parse( + readFileSync(schemaPath("issues-batch-update.schema.json"), "utf8"), +) as BatchUpdateSchema; +const patch = updateSchema.$defs.patch; + +/** + * A property is clearable when the schema lets `null` through — either as an + * inline `oneOf` branch or via the shared `nullableString` definition. + */ +const acceptsNull = (name: string): boolean => { + const property = updateSchema.$defs.patch.properties[name]; + const branches = [property, ...(property?.oneOf ?? [])]; + + return branches.some( + (branch) => + branch?.type === "null" || branch?.$ref === "#/$defs/nullableString", + ); +}; + +describe("issues-batch-update.schema.json", () => { + it("describes exactly the patch keys the parser accepts", () => { + expect(Object.keys(patch.properties).sort()).toEqual( + [...KNOWN_PATCH_KEYS].sort(), + ); + }); + + it("rejects unknown keys at both levels, as the parser does", () => { + expect(updateSchema.additionalProperties).toBe(false); + expect(patch.additionalProperties).toBe(false); + }); + + it("requires the targets and a patch that changes something", () => { + expect(updateSchema.required.sort()).toEqual(["issues", "patch"]); + expect(patch.minProperties).toBe(1); + }); + + it("allows null on exactly the fields the parser lets you clear", () => { + // A schema that accepted null on a non-clearable field would wave through a + // document the CLI then rejects; one that refused null on a clearable field + // would flag a document that works. + expect(Object.keys(patch.properties).filter(acceptsNull).sort()).toEqual( + [...CLEARABLE_PATCH_KEYS].sort(), + ); + }); + + it("only documents examples the parser accepts", () => { + for (const example of updateSchema.examples) { + expect(() => + parseBatchUpdateDocument(JSON.stringify(example)), + ).not.toThrow(); + } + }); +}); diff --git a/tests/unit/commands/issues-batch.test.ts b/tests/unit/commands/issues-batch.test.ts index 6c94f559..f98d0668 100644 --- a/tests/unit/commands/issues-batch.test.ts +++ b/tests/unit/commands/issues-batch.test.ts @@ -4,6 +4,7 @@ import { buildBatchUpdateContext, buildBatchUpdateInput, parseBatchCreateEntries, + parseBatchUpdateDocument, validateBatchUpdateEstimate, } from "../../../src/commands/issues-batch.js"; import { asUuid } from "../../../src/common/identifier.js"; @@ -148,6 +149,164 @@ describe("parseBatchCreateEntries", () => { }); }); +describe("parseBatchUpdateDocument", () => { + it("reads the targets and the patch, mirroring the update flags", () => { + expect( + parseBatchUpdateDocument( + JSON.stringify({ + issues: ["ENG-1", "ENG-2"], + patch: { + title: "New title", + description: "body", + status: "In Progress", + priority: 2, + estimate: 3, + assignee: "alice", + project: "Auth", + projectMilestone: "Beta", + labels: ["bug", "urgent"], + parentTicket: "ENG-9", + cycle: "current", + dueDate: "2026-09-01", + }, + }), + ), + ).toEqual({ + issues: ["ENG-1", "ENG-2"], + patch: { + title: "New title", + description: "body", + status: "In Progress", + priority: 2, + estimate: 3, + assignee: "alice", + project: "Auth", + projectMilestone: "Beta", + labels: ["bug", "urgent"], + parentTicket: "ENG-9", + cycle: "current", + dueDate: "2026-09-01", + }, + }); + }); + + it("reads null as the clear that the --clear-* flags express", () => { + const { patch } = parseBatchUpdateDocument( + JSON.stringify({ + issues: "ENG-1,ENG-2", + patch: { + assignee: null, + project: null, + labels: null, + parentTicket: null, + projectMilestone: null, + cycle: null, + estimate: null, + dueDate: null, + }, + }), + ); + + expect(patch).toEqual({ + assignee: null, + project: null, + labels: null, + parentTicket: null, + projectMilestone: null, + cycle: null, + estimate: null, + dueDate: null, + }); + }); + + it("refuses to clear a field that has no clear flag", () => { + // Linear has no empty state for a title, so a null there is a mistake the + // caller wants to hear about rather than a no-op applied to every issue. + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ issues: ["ENG-1"], patch: { title: null } }), + ), + ).toThrow(/patch: cannot clear "title" with null/); + }); + + it("rejects an unknown patch key rather than dropping it", () => { + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ issues: ["ENG-1"], patch: { assingee: "alice" } }), + ), + ).toThrow(/patch: has unknown key "assingee"/); + }); + + it("requires a project alongside a milestone", () => { + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ + issues: ["ENG-1"], + patch: { projectMilestone: "Beta" }, + }), + ), + ).toThrow(/patch: has projectMilestone without project/); + + // Clearing the project takes the milestone with it, so naming both is a + // contradiction rather than a shorthand. + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ + issues: ["ENG-1"], + patch: { project: null, projectMilestone: "Beta" }, + }), + ), + ).toThrow(/patch: has projectMilestone without project/); + }); + + it("rejects a patch with nothing to change", () => { + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ issues: ["ENG-1"], patch: {} }), + ), + ).toThrow(/patch: needs at least one field to change/); + }); + + it("validates due dates and priorities as the flags do", () => { + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ issues: ["ENG-1"], patch: { dueDate: "01-09-2026" } }), + ), + ).toThrow(/Invalid due date format/); + + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ issues: ["ENG-1"], patch: { priority: 9 } }), + ), + ).toThrow(/patch: has "priority" outside the allowed range/); + }); + + it("rejects documents that are not an issues/patch object", () => { + expect(() => parseBatchUpdateDocument("not json")).toThrow( + /is not valid JSON/, + ); + expect(() => parseBatchUpdateDocument("[]")).toThrow( + /must be a JSON object with "issues" and "patch"/, + ); + expect(() => + parseBatchUpdateDocument(JSON.stringify({ patch: { title: "T" } })), + ).toThrow(/requires "issues"/); + expect(() => + parseBatchUpdateDocument(JSON.stringify({ issues: ["ENG-1"] })), + ).toThrow(/requires "patch" as an object/); + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ issues: ["ENG-1"], patch: {}, extra: 1 }), + ), + ).toThrow(/batch document: has unknown key "extra"/); + expect(() => + parseBatchUpdateDocument( + JSON.stringify({ issues: [], patch: { title: "T" } }), + ), + ).toThrow(/has "issues" that is not a non-empty array/); + }); +}); + describe("buildBatchUpdateContext", () => { const target = (teamKey: string): ResolvedIssueRef => ({ ref: `${teamKey}-1`, @@ -158,7 +317,6 @@ describe("buildBatchUpdateContext", () => { it("scopes lookups to the only team when all targets share one", () => { const context = buildBatchUpdateContext([target("ENG"), target("ENG")], { - issues: "ENG-1,ENG-2", status: "Todo", }); @@ -169,12 +327,9 @@ describe("buildBatchUpdateContext", () => { }); it("rejects a named status or cycle spanning teams", () => { - for (const options of [ - { issues: "ENG-1,OPS-1", status: "Todo" }, - { issues: "ENG-1,OPS-1", cycle: "Cycle 4" }, - ]) { + for (const patch of [{ status: "Todo" }, { cycle: "Cycle 4" }]) { expect(() => - buildBatchUpdateContext([target("ENG"), target("OPS")], options), + buildBatchUpdateContext([target("ENG"), target("OPS")], patch), ).toThrow(/cannot be resolved by name across teams ENG, OPS/); } }); @@ -184,30 +339,29 @@ describe("buildBatchUpdateContext", () => { // lookup takes the first hit and half the batch gets the wrong label. expect(() => buildBatchUpdateContext([target("ENG"), target("OPS")], { - issues: "ENG-1,OPS-1", - labels: "bug", + labels: ["bug"], }), - ).toThrow(/--labels: cannot be resolved by name across teams ENG, OPS/); + ).toThrow(/labels: cannot be resolved by name across teams ENG, OPS/); }); it("rejects a label list that mixes a UUID with a name", () => { expect(() => buildBatchUpdateContext([target("ENG"), target("OPS")], { - issues: "ENG-1,OPS-1", - labels: "66666666-6666-4666-8666-666666666666,bug", + labels: ["66666666-6666-4666-8666-666666666666", "bug"], }), - ).toThrow(/--labels/); + ).toThrow(/labels/); }); it("lets a UUID status, cycle or label through as the documented escape hatch", () => { // The error message advises passing a UUID; a UUID needs no team to // resolve against, so the guard must not reject it as well. const context = buildBatchUpdateContext([target("ENG"), target("OPS")], { - issues: "ENG-1,OPS-1", status: "33333333-3333-4333-8333-333333333333", cycle: "44444444-4444-4444-8444-444444444444", - labels: - "66666666-6666-4666-8666-666666666666,77777777-7777-4777-8777-777777777777", + labels: [ + "66666666-6666-4666-8666-666666666666", + "77777777-7777-4777-8777-777777777777", + ], }); expect(context).toEqual({}); @@ -252,8 +406,7 @@ describe("validateBatchUpdateEstimate", () => { await expect( validateBatchUpdateEstimate(client, [target("ENG", ENG_TEAM)], { - issues: "ENG-1", - estimate: "7", + estimate: 7, }), ).rejects.toThrow(/must be one of \[1, 2, 3, 5, 8\] for team "ENG"/); }); @@ -266,8 +419,7 @@ describe("validateBatchUpdateEstimate", () => { await expect( validateBatchUpdateEstimate(client, [target("ENG", ENG_TEAM)], { - issues: "ENG-1", - estimate: "5", + estimate: 5, }), ).resolves.toBeUndefined(); }); @@ -285,7 +437,7 @@ describe("validateBatchUpdateEstimate", () => { validateBatchUpdateEstimate( client, [target("ENG", ENG_TEAM), target("OPS", OPS_TEAM)], - { issues: "ENG-1,OPS-1", estimate: "8" }, + { estimate: 8 }, ), ).rejects.toThrow(/for team "OPS"/); }); @@ -299,12 +451,11 @@ describe("validateBatchUpdateEstimate", () => { await validateBatchUpdateEstimate( client, [target("ENG", ENG_TEAM), target("ENG", ENG_TEAM)], - { issues: "ENG-1,ENG-2", estimate: "3" }, + { estimate: 3 }, ); expect(request).toHaveBeenCalledTimes(1); await validateBatchUpdateEstimate(client, [target("ENG", ENG_TEAM)], { - issues: "ENG-1", title: "no estimate here", }); expect(request).toHaveBeenCalledTimes(1); @@ -315,25 +466,22 @@ describe("buildBatchUpdateInput", () => { const CYCLE = asUuid("66666666-6666-4666-8666-666666666666"); const MILESTONE = asUuid("77777777-7777-4777-8777-777777777777"); - it("detaches the cycle with --clear-cycle", () => { - expect( - buildBatchUpdateInput({ issues: "ENG-1", clearCycle: true }, {}), - ).toEqual({ cycleId: null }); + it("detaches the cycle when the patch clears it", () => { + expect(buildBatchUpdateInput({ cycle: null }, {})).toEqual({ + cycleId: null, + }); }); - it("detaches the milestone with --clear-project-milestone", () => { - expect( - buildBatchUpdateInput( - { issues: "ENG-1", clearProjectMilestone: true }, - {}, - ), - ).toEqual({ projectMilestoneId: null }); + it("detaches the milestone when the patch clears it", () => { + expect(buildBatchUpdateInput({ projectMilestone: null }, {})).toEqual({ + projectMilestoneId: null, + }); }); - it("still sets a cycle or milestone when the clear flags are absent", () => { + it("still sets a cycle or milestone when the patch names one", () => { expect( buildBatchUpdateInput( - { issues: "ENG-1", cycle: "Cycle 4", projectMilestone: "Beta" }, + { cycle: "Cycle 4", projectMilestone: "Beta" }, { cycleId: CYCLE, projectMilestoneId: MILESTONE }, ), ).toEqual({ cycleId: CYCLE, projectMilestoneId: MILESTONE }); From b95d020556f7f5239ca9951b8726114abc6066e3 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:04:29 +0200 Subject: [PATCH 33/70] docs(readme): condense the batch schema section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README spelled out the batch-create schema at length: the raw URL twice, a check-jsonschema invocation, and a VS Code settings block. That is validator and editor documentation rather than linearis documentation, and it buried the thing a reader actually needs — what the document looks like. With a second schema now published for batch update, repeating that treatment would have doubled it. Instead the section covers both commands with one example document each, the commands that consume them, and a pointer to schemas/ for anyone who wants to wire up validation. --- README.md | 51 ++++++++++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 449592f7..0f987fd2 100644 --- a/README.md +++ b/README.md @@ -108,43 +108,40 @@ linearis issues replies linearis issues reply --body "I found the root cause" ``` -### Batch issue creation +### Batch operations -`issues batch create` is the one command that takes a JSON document instead of flags: a JSON array with one object per issue, keys named after the `issues create` flags with the leading dashes dropped. Unknown keys are rejected rather than ignored, so a typo fails the command instead of quietly dropping a field. +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. -```bash -# From a file -linearis issues batch create --file issues.json - -# From stdin -generate-issues | linearis issues batch create --file - +`issues batch create` takes an array with one object per issue, keys named after the `issues create` flags: -# Inline, for one-offs -linearis issues batch create --json '[{"title":"Fix login","team":"ENG"}]' -``` - -The document format is published as JSON Schema (draft 2020-12) at [`schemas/issues-batch-create.schema.json`](schemas/issues-batch-create.schema.json). It ships in the npm package and is served raw from the default branch, so you can validate a document before spending an API call on it: - -```bash -check-jsonschema \ - --schemafile https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-create.schema.json \ - issues.json +```json +[ + { "title": "Fix login redirect loop", "team": "ENG", "labels": ["bug"] }, + { "title": "Document the SSO flow", "team": "ENG", "project": "Q3 Auth" } +] ``` -Editors pick it up the same way. In VS Code, map it once in `.vscode/settings.json` and any matching file gets completion and inline validation: +`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 { - "json.schemas": [ - { - "fileMatch": ["*.linear-issues.json"], - "url": "https://raw.githubusercontent.com/linearis-oss/linearis/next/schemas/issues-batch-create.schema.json" - } - ] + "issues": ["ENG-42", "ENG-43"], + "patch": { "status": "In Progress", "assignee": "alice", "cycle": null } } ``` -The schema is the input contract only — it cannot know your team's workflow states, label names, or estimation scale. A document that validates can still be rejected by the CLI when a name does not resolve, and the whole batch is one transaction: either every issue is created or none is. +```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 @@ -269,7 +266,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 issue creation](#batch-issue-creation)). +- [`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. From 54a6932fc25bee13f366f538c692de9f8cbd6de5 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 10 Aug 2026 12:31:17 +0000 Subject: [PATCH 34/70] chore(release): 2026.8.0-next.1 [skip ci] ## [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)) --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2daae1e7..c1ac712c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## [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 diff --git a/package-lock.json b/package-lock.json index 2069e1ab..a75f057a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "linearis", - "version": "2026.7.0", + "version": "2026.8.0-next.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "linearis", - "version": "2026.7.0", + "version": "2026.8.0-next.1", "license": "MIT", "dependencies": { "commander": "15.0.0", diff --git a/package.json b/package.json index 295ff133..e7440839 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linearis", - "version": "2026.7.0", + "version": "2026.8.0-next.1", "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", From 47b509e79339d5882cfddfd32fca920217f02ce9 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:50:32 +0200 Subject: [PATCH 35/70] feat(projects)!: drop archive in favour of delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear's schema marks `projectArchive` as "Deprecated in favor of projectDelete", and documents `projectDelete` as "Deletes (trashes) a project. The project can be restored later with projectUnarchive". The two mutations are two verbs over one state — Linear collapsed archiving and trashing into a single put-away flag. Exposing both as `projects archive` and `projects delete` therefore sold a distinction that does not exist. A caller who archived and then looked for their project among the trashed ones, or vice versa, was reading a difference the API never made. It also left the CLI standing on a mutation Linear has signalled it may remove. Remove `projects archive`, `archiveProject()`, the `ArchiveProject` mutation and the `ArchivedProject` type. `projects delete` trashes and `projects unarchive` restores; that pair is the whole lifecycle, and `PROJECTS_META.context` now says so, because it is the one thing a caller cannot infer from the verb names alone. Keeping `archive` as an alias for `delete` was considered and rejected. An alias that silently does something other than what its name says is worse than its absence, and the deprecation gives no reason to believe the underlying mutation will outlive the CLI. BREAKING CHANGE: `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. --- README.md | 2 +- graphql/mutations/projects.graphql | 16 +++++------ src/commands/projects.ts | 22 ++++---------- src/services/project-service.ts | 27 ++++++----------- tests/unit/commands/projects.test.ts | 25 ++-------------- tests/unit/services/project-service.test.ts | 32 --------------------- 6 files changed, 25 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 0f987fd2..02d607f8 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The table below is the honest picture of the whole surface — what works today, | 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, 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, read, create, update, delete (trash) and unarchive (restore); 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 | | `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, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | diff --git a/graphql/mutations/projects.graphql b/graphql/mutations/projects.graphql index b0ce91c0..84a7d3ff 100644 --- a/graphql/mutations/projects.graphql +++ b/graphql/mutations/projects.graphql @@ -30,15 +30,10 @@ mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { } } -mutation ArchiveProject($id: String!) { - projectArchive(id: $id) { - success - entity { - ...ProjectDetailWithDefaultConnectionsFields - } - } -} - +# 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 @@ -48,6 +43,9 @@ mutation UnarchiveProject($id: String!) { } } +# Trash a project +# +# Reversible: UnarchiveProject restores it. mutation DeleteProject($id: String!) { projectDelete(id: $id) { success diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 23a53453..58472721 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -32,7 +32,6 @@ import { unresolveDiscussion, } from "../services/discussion-service.js"; import { - archiveProject, type CreateProjectInput, createProject, deleteProject, @@ -183,6 +182,9 @@ export const PROJECTS_META: DomainMeta = { "have a status (backlog, planned, started, paused, completed,", "canceled), priority (0-4), health (onTrack, atRisk, offTrack),", "and can be assigned labels, a lead, and members.", + "", + "projects have one put-away state, not two: `delete` trashes a project", + "and `unarchive` restores it. there is no `archive` verb.", ].join("\n"), arguments: { project: "project identifier (UUID or name)", @@ -837,23 +839,9 @@ export function setupProjectsCommands(program: Command): void { ), ); - projects - .command("archive ") - .description("archive a project") - .action( - commandAction<[string, unknown, Command]>( - async (project, _unused1, command) => { - const ctx = createContext(getRootOpts(command)); - const projectId = await resolveProjectId(ctx.gql, project); - const result = await archiveProject(ctx.gql, projectId); - outputSuccess(result); - }, - ), - ); - projects .command("unarchive ") - .description("unarchive a project") + .description("restore a project from the trash") .action( commandAction<[string, unknown, Command]>( async (project, _unused1, command) => { @@ -869,7 +857,7 @@ export function setupProjectsCommands(program: Command): void { projects .command("delete ") - .description("delete a project") + .description("move a project to the trash (restore with unarchive)") .action( commandAction<[string, unknown, Command]>( async (project, _unused1, command) => { diff --git a/src/services/project-service.ts b/src/services/project-service.ts index 4482d3c2..3fd73672 100644 --- a/src/services/project-service.ts +++ b/src/services/project-service.ts @@ -10,8 +10,6 @@ import { } from "../common/mutation-payload.js"; import type { PaginatedResult, PaginationOptions } from "../common/types.js"; import { - ArchiveProjectDocument, - type ArchiveProjectMutation, CreateProjectDocument, type CreateProjectMutation, DeleteProjectDocument, @@ -37,9 +35,6 @@ export type CreatedProject = NonNullable< export type UpdatedProject = NonNullable< UpdateProjectMutation["projectUpdate"]["project"] >; -export type ArchivedProject = NonNullable< - ArchiveProjectMutation["projectArchive"]["entity"] ->; export type UnarchivedProject = NonNullable< UnarchiveProjectMutation["projectUnarchive"]["entity"] >; @@ -205,19 +200,12 @@ export async function updateProject( ); } -export async function archiveProject( - client: GraphQLClient, - id: UUID, -): Promise { - const result = await client.request(ArchiveProjectDocument, { id }); - - return requireMutationEntity( - result.projectArchive, - "entity", - `Failed to archive project "${id}"`, - ); -} - +/** + * Restores a project from the trash. + * + * Linear has one put-away state for projects, so this is the inverse of + * {@link deleteProject} — there is no separate archived state to restore from. + */ export async function unarchiveProject( client: GraphQLClient, id: UUID, @@ -231,6 +219,9 @@ export async function unarchiveProject( ); } +/** + * Trashes a project. Reversible via {@link unarchiveProject}. + */ export async function deleteProject( client: GraphQLClient, id: UUID, diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 321ec669..7287c72e 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -35,7 +35,6 @@ vi.mock("../../../src/resolvers/user-resolver.js", () => ({ })); vi.mock("../../../src/services/project-service.js", () => ({ - archiveProject: vi.fn().mockResolvedValue({ id: "proj-1", name: "Archived" }), listProjects: vi.fn().mockResolvedValue({ nodes: [], pageInfo: {} }), getProject: vi.fn().mockResolvedValue({ id: "proj-1" }), getProjectLabelIds: vi.fn().mockResolvedValue([]), @@ -136,7 +135,6 @@ import { unresolveDiscussion, } from "../../../src/services/discussion-service.js"; import { - archiveProject, createProject, deleteProject, getProject, @@ -259,28 +257,11 @@ describe("projects lifecycle", () => { vi.spyOn(process, "exit").mockImplementation(() => undefined as never); }); - it("archive resolves project and outputs result", async () => { + it("does not register an archive command", () => { const program = createProgram(); - await program.parseAsync([ - "node", - "test", - "projects", - "archive", - "My Project", - ]); + const projects = program.commands.find((c) => c.name() === "projects"); - expect(resolveProjectId).toHaveBeenCalledWith( - expect.anything(), - "My Project", - ); - expect(archiveProject).toHaveBeenCalledWith( - expect.anything(), - "resolved-project-uuid", - ); - expect(outputSuccess).toHaveBeenCalledWith({ - id: "proj-1", - name: "Archived", - }); + expect(projects?.commands.map((c) => c.name())).not.toContain("archive"); }); it("unarchive resolves project and outputs result", async () => { diff --git a/tests/unit/services/project-service.test.ts b/tests/unit/services/project-service.test.ts index 20a1595e..84ad4002 100644 --- a/tests/unit/services/project-service.test.ts +++ b/tests/unit/services/project-service.test.ts @@ -5,14 +5,12 @@ import { describe, expect, it, vi } from "vitest"; import type { GraphQLClient } from "../../../src/client/graphql-client.js"; import { asUuid } from "../../../src/common/identifier.js"; import { - ArchiveProjectDocument, GetProjectDocument, GetProjectLabelIdsDocument, GetProjectWithReactionsDocument, UpdateProjectDocument, } from "../../../src/gql/graphql.js"; import { - archiveProject, createProject, deleteProject, getProject, @@ -597,36 +595,6 @@ describe("updateProject", () => { }); }); -describe("archiveProject", () => { - it("returns archived project on success", async () => { - const client = mockGqlClient({ - projectArchive: { - success: true, - entity: { id: "proj-1", name: "Archived Project" }, - }, - }); - - await expect(archiveProject(client, asUuid("proj-1"))).resolves.toEqual({ - id: "proj-1", - name: "Archived Project", - }); - - expect(client.request).toHaveBeenCalledWith(ArchiveProjectDocument, { - id: "proj-1", - }); - }); - - it("throws on failure", async () => { - const client = mockGqlClient({ - projectArchive: { success: false, entity: null }, - }); - - await expect(archiveProject(client, asUuid("proj-1"))).rejects.toThrow( - 'Failed to archive project "proj-1"', - ); - }); -}); - describe("unarchiveProject", () => { it("returns unarchived project on success", async () => { const client = mockGqlClient({ From 0357b2d4324b8ede69ccbbc8bc6d5c61d04e5c43 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:51:22 +0200 Subject: [PATCH 36/70] refactor(projects): split the command module by subgroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/commands/projects.ts` was a single 890-line file, and the work queued behind this commit adds three more subgroups to it (status updates, relations, status administration). Growing one file to ~2000 lines would make it the largest command module in the repo by a wide margin and would bury the domain registration under the CRUD. Adopt the layout `src/commands/initiatives/` already uses: `index.ts` owns `PROJECTS_META`, the domain command, and the `usage` subcommand; `entity.ts` owns everything registered on it. Each new subgroup lands as its own sibling file wired from `index.ts`, so a reader looking for "what does `projects` expose" reads one short file instead of scanning for `.command(` calls. Discussions stay in `entity.ts` rather than moving to a file of their own, matching `initiatives/entity.ts` — they are registered flat on the domain, not as a subgroup, and splitting them would diverge from the module this layout is copied from. Pure move plus import-path adjustment; no command, flag, or output changed. --- docs/architecture.md | 2 +- .../{projects.ts => projects/entity.ts} | 68 +++++-------------- src/commands/projects/index.ts | 42 ++++++++++++ src/main.ts | 5 +- tests/unit/commands/projects.test.ts | 2 +- 5 files changed, 66 insertions(+), 53 deletions(-) rename src/commands/{projects.ts => projects/entity.ts} (92%) create mode 100644 src/commands/projects/index.ts 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/src/commands/projects.ts b/src/commands/projects/entity.ts similarity index 92% rename from src/commands/projects.ts rename to src/commands/projects/entity.ts index 58472721..efb3d5d4 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects/entity.ts @@ -1,19 +1,22 @@ import type { Command } from "commander"; -import { createContext, getRootOpts } from "../common/context.js"; -import { type Priority, parseLabelMode } from "../common/domain-values.js"; -import { resolveReactionEmojiInput } from "../common/emoji.js"; -import { invalidParameterError } from "../common/errors.js"; -import { asUuid } from "../common/identifier.js"; -import { commandAction, outputSuccess, parseLimit } from "../common/output.js"; -import { buildPaginationOptions } from "../common/types.js"; -import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; +import { createContext, getRootOpts } from "../../common/context.js"; +import { type Priority, parseLabelMode } from "../../common/domain-values.js"; +import { resolveReactionEmojiInput } from "../../common/emoji.js"; +import { invalidParameterError } from "../../common/errors.js"; +import { asUuid } from "../../common/identifier.js"; +import { + commandAction, + outputSuccess, + parseLimit, +} from "../../common/output.js"; +import { buildPaginationOptions } from "../../common/types.js"; import { resolveProjectId, resolveProjectLabelIds, -} from "../resolvers/project-resolver.js"; -import { resolveProjectStatusId } from "../resolvers/project-status-resolver.js"; -import { resolveTeamId } from "../resolvers/team-resolver.js"; -import { resolveUserId } from "../resolvers/user-resolver.js"; +} from "../../resolvers/project-resolver.js"; +import { resolveProjectStatusId } from "../../resolvers/project-status-resolver.js"; +import { resolveTeamId } from "../../resolvers/team-resolver.js"; +import { resolveUserId } from "../../resolvers/user-resolver.js"; import { createDiscussionCommentReaction, deleteDiscussionComment, @@ -30,7 +33,7 @@ import { resolveDiscussion, startProjectDiscussion, unresolveDiscussion, -} from "../services/discussion-service.js"; +} from "../../services/discussion-service.js"; import { type CreateProjectInput, createProject, @@ -41,7 +44,7 @@ import { type UpdateProjectInput, unarchiveProject, updateProject, -} from "../services/project-service.js"; +} from "../../services/project-service.js"; interface ListOptions { limit: string; @@ -173,30 +176,6 @@ interface UpdateOptions { clearLabels?: boolean; } -export const PROJECTS_META: DomainMeta = { - name: "projects", - summary: "groups of issues toward a goal", - context: [ - "a project collects related issues across teams. projects can have", - "milestones to track progress toward deadlines or phases. projects", - "have a status (backlog, planned, started, paused, completed,", - "canceled), priority (0-4), health (onTrack, atRisk, offTrack),", - "and can be assigned labels, a lead, and members.", - "", - "projects have one put-away state, not two: `delete` trashes a project", - "and `unarchive` restores it. there is no `archive` verb.", - ].join("\n"), - arguments: { - project: "project identifier (UUID or name)", - name: "string", - }, - seeAlso: [ - "milestones list --project", - "documents list --project", - "issues create --project", - ], -}; - function parsePriority(value: string): Priority { const priority = Number.parseInt(value, 10); if (Number.isNaN(priority) || priority < 0 || priority > 4) { @@ -251,11 +230,7 @@ function getUpdateTeamNames(options: UpdateOptions): string[] | undefined { return parseCommaSeparatedOption(options.teams ? "--teams" : "--team", teams); } -export function setupProjectsCommands(program: Command): void { - const projects = program - .command("projects") - .description("Project operations"); - +export function setupProjectEntityCommands(projects: Command): void { projects .command("list") .description("list projects") @@ -870,11 +845,4 @@ export function setupProjectsCommands(program: Command): void { }, ), ); - - projects - .command("usage") - .description("show detailed usage for projects") - .action(() => { - console.log(formatDomainUsage(projects, PROJECTS_META)); - }); } diff --git a/src/commands/projects/index.ts b/src/commands/projects/index.ts new file mode 100644 index 00000000..18c9f7f9 --- /dev/null +++ b/src/commands/projects/index.ts @@ -0,0 +1,42 @@ +import type { Command } from "commander"; +import { type DomainMeta, formatDomainUsage } from "../../common/usage.js"; +import { setupProjectEntityCommands } from "./entity.js"; + +export const PROJECTS_META: DomainMeta = { + name: "projects", + summary: "groups of issues toward a goal", + context: [ + "a project collects related issues across teams. projects can have", + "milestones to track progress toward deadlines or phases. projects", + "have a status (backlog, planned, started, paused, completed,", + "canceled), priority (0-4), health (onTrack, atRisk, offTrack),", + "and can be assigned labels, a lead, and members.", + "", + "projects have one put-away state, not two: `delete` trashes a project", + "and `unarchive` restores it. there is no `archive` verb.", + ].join("\n"), + arguments: { + project: "project identifier (UUID or name)", + name: "string", + }, + seeAlso: [ + "milestones list --project", + "documents list --project", + "issues create --project", + ], +}; + +export function setupProjectsCommands(program: Command): void { + const projects = program + .command("projects") + .description("Project operations"); + + setupProjectEntityCommands(projects); + + projects + .command("usage") + .description("show detailed usage for projects") + .action(() => { + console.log(formatDomainUsage(projects, PROJECTS_META)); + }); +} diff --git a/src/main.ts b/src/main.ts index 0455281a..a0c95671 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,7 +24,10 @@ import { MILESTONES_META, setupMilestonesCommands, } from "./commands/milestones.js"; -import { PROJECTS_META, setupProjectsCommands } from "./commands/projects.js"; +import { + PROJECTS_META, + setupProjectsCommands, +} from "./commands/projects/index.js"; import { setupTeamsCommands, TEAMS_META } from "./commands/teams.js"; import { setupUsersCommands, USERS_META } from "./commands/users.js"; import { setupVersionCommands, VERSION_META } from "./commands/version.js"; diff --git a/tests/unit/commands/projects.test.ts b/tests/unit/commands/projects.test.ts index 7287c72e..02c58661 100644 --- a/tests/unit/commands/projects.test.ts +++ b/tests/unit/commands/projects.test.ts @@ -110,7 +110,7 @@ vi.mock("../../../src/services/discussion-service.js", () => ({ .mockResolvedValue({ id: "reaction-1", success: true }), })); -import { setupProjectsCommands } from "../../../src/commands/projects.js"; +import { setupProjectsCommands } from "../../../src/commands/projects/index.js"; import { outputSuccess } from "../../../src/common/output.js"; import { resolveProjectId, From c66da2b7860438683fd90a39d3e8f91a9ce34709 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:55:49 +0200 Subject: [PATCH 37/70] feat(projects): post and manage project status updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project's `health` field is derived from its most recent status update, and the CLI wired the read but not the write. Callers could see that a project was `atRisk` and had no way to say so — the only route to changing health was the Linear web app. Wire the seven `projectUpdate*` root fields as `projects updates`, using the same subgroup shape as `initiatives updates` (list/read/create/ update/archive/unarchive) so an agent that has learned one knows the other without reading help text. `remind` maps `createProjectUpdateReminder`; its payload carries no entity, so the service echoes the project id rather than emitting a bare `{success: true}` that says nothing about what succeeded. `projectUpdateDelete` is deliberately left unwired — Linear deprecates it in favour of `projectUpdateArchive`, which is reversible. Two supporting changes: - `parseHealth()` moves from `initiative-update-service.ts` to `common/domain-values.ts`. Both domains need it and a service importing another service would break the layer rules. Linear declares the health enum twice with identical members, so the shared parser returns one `UpdateHealth` union that satisfies both codegen types. - `projects read` now selects `lastUpdate` and `healthUpdatedAt`. The read that reports `health` should say where that health came from, and it makes the newly-wired updates discoverable from a call callers already make rather than requiring them to know the subgroup exists. --- README.md | 2 +- graphql/mutations/project-updates.graphql | 52 ++++ graphql/queries/project-updates.graphql | 60 +++++ graphql/queries/projects.graphql | 9 + src/commands/initiatives/updates.ts | 2 +- src/commands/projects/index.ts | 6 + src/commands/projects/updates.ts | 215 +++++++++++++++ src/common/domain-values.ts | 27 ++ src/services/initiative-update-service.ts | 17 -- src/services/project-update-service.ts | 193 ++++++++++++++ tests/unit/commands/project-updates.test.ts | 219 ++++++++++++++++ tests/unit/common/domain-values.test.ts | 20 ++ .../services/project-update-service.test.ts | 247 ++++++++++++++++++ 13 files changed, 1050 insertions(+), 19 deletions(-) create mode 100644 graphql/mutations/project-updates.graphql create mode 100644 graphql/queries/project-updates.graphql create mode 100644 src/commands/projects/updates.ts create mode 100644 src/services/project-update-service.ts create mode 100644 tests/unit/commands/project-updates.test.ts create mode 100644 tests/unit/services/project-update-service.test.ts diff --git a/README.md b/README.md index 02d607f8..998010f3 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The table below is the honest picture of the whole surface — what works today, | 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, 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, delete (trash) and unarchive (restore); 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, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); discussions | Project-label CRUD, project relations, project status administration, Slack channel creation | | `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, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | 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/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..774d313f 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 { 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/projects/index.ts b/src/commands/projects/index.ts index 18c9f7f9..fb97e1a5 100644 --- a/src/commands/projects/index.ts +++ b/src/commands/projects/index.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { type DomainMeta, formatDomainUsage } from "../../common/usage.js"; import { setupProjectEntityCommands } from "./entity.js"; +import { setupProjectUpdateCommands } from "./updates.js"; export const PROJECTS_META: DomainMeta = { name: "projects", @@ -14,9 +15,13 @@ export const PROJECTS_META: DomainMeta = { "", "projects have one put-away state, not two: `delete` trashes a project", "and `unarchive` restores it. there is no `archive` verb.", + "", + "a project's health is derived from its most recent status update, so", + "changing health means posting one with `projects updates create`.", ].join("\n"), arguments: { project: "project identifier (UUID or name)", + update: "project status update identifier (UUID)", name: "string", }, seeAlso: [ @@ -32,6 +37,7 @@ export function setupProjectsCommands(program: Command): void { .description("Project operations"); setupProjectEntityCommands(projects); + setupProjectUpdateCommands(projects); projects .command("usage") diff --git a/src/commands/projects/updates.ts b/src/commands/projects/updates.ts new file mode 100644 index 00000000..305e3699 --- /dev/null +++ b/src/commands/projects/updates.ts @@ -0,0 +1,215 @@ +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 { + commandAction, + outputSuccess, + parseLimit, +} from "../../common/output.js"; +import { buildPaginationOptions } from "../../common/types.js"; +import { resolveProjectId } from "../../resolvers/project-resolver.js"; +import { resolveUserId } from "../../resolvers/user-resolver.js"; +import { + archiveProjectUpdate, + type CreateProjectUpdateInput, + createProjectUpdate, + type EditProjectUpdateInput, + editProjectUpdate, + getProjectUpdate, + listProjectUpdates, + remindProjectUpdate, + unarchiveProjectUpdate, +} from "../../services/project-update-service.js"; + +interface ProjectUpdatesListOptions { + project: string; + limit: string; + after?: string; + includeArchived?: boolean; +} + +interface ProjectUpdatesCreateOptions { + project: string; + body?: string; + health?: string; + hideDiff?: boolean; +} + +interface ProjectUpdatesUpdateOptions { + body?: string; + health?: string; +} + +interface ProjectUpdatesRemindOptions { + project: string; + user?: string; +} + +export function setupProjectUpdateCommands(projects: Command): void { + const updates = projects + .command("updates") + .description("project status update operations"); + + updates + .command("list") + .description("list project status updates") + .requiredOption("--project ", "project name or UUID") + .option("-l, --limit ", "max results", "50") + .option("--after ", "cursor for next page") + .option("--include-archived", "include archived updates") + .action( + commandAction<[ProjectUpdatesListOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + + const projectId = await resolveProjectId(ctx.gql, options.project); + + const result = await listProjectUpdates(ctx.gql, { + projectId, + ...buildPaginationOptions(parseLimit(options.limit), options.after), + includeArchived: options.includeArchived ?? false, + }); + + outputSuccess(result); + }, + ), + ); + + updates + .command("read ") + .description("get project status update details") + .action( + commandAction<[string, unknown, Command]>( + async (updateId, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await getProjectUpdate(ctx.gql, asUuid(updateId)); + outputSuccess(result); + }, + ), + ); + + updates + .command("create") + .description("post a project status update") + .requiredOption("--project ", "project name or UUID") + .option("--body ", "update body (markdown)") + .option("--health ", "onTrack, atRisk, offTrack") + .option("--hide-diff", "hide the diff against the previous update") + .action( + commandAction<[ProjectUpdatesCreateOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + + const projectId = await resolveProjectId(ctx.gql, options.project); + + const input: CreateProjectUpdateInput = { projectId }; + + if (options.body !== undefined) { + input.body = options.body; + } + + const health = parseHealth(options.health); + if (health) { + input.health = health; + } + + if (options.hideDiff) { + input.isDiffHidden = true; + } + + const result = await createProjectUpdate(ctx.gql, input); + outputSuccess(result); + }, + ), + ); + + updates + .command("update ") + .description("edit a project status update") + .option("--body ", "new body (markdown)") + .option("--health ", "onTrack, atRisk, offTrack") + .action( + commandAction<[string, ProjectUpdatesUpdateOptions, Command]>( + async (updateId, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const input: EditProjectUpdateInput = {}; + + if (options.body !== undefined) { + input.body = options.body; + } + + const health = parseHealth(options.health); + if (health) { + input.health = health; + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one option must be provided", + ); + } + + const result = await editProjectUpdate( + ctx.gql, + asUuid(updateId), + input, + ); + outputSuccess(result); + }, + ), + ); + + updates + .command("archive ") + .description("archive a project status update") + .action( + commandAction<[string, unknown, Command]>( + async (updateId, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await archiveProjectUpdate(ctx.gql, asUuid(updateId)); + outputSuccess(result); + }, + ), + ); + + updates + .command("unarchive ") + .description("unarchive a project status update") + .action( + commandAction<[string, unknown, Command]>( + async (updateId, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await unarchiveProjectUpdate( + ctx.gql, + asUuid(updateId), + ); + outputSuccess(result); + }, + ), + ); + + updates + .command("remind") + .description("notify someone that the project is due an update") + .requiredOption("--project ", "project name or UUID") + .option("--user ", "user to remind; omitted, Linear picks the target") + .action( + commandAction<[ProjectUpdatesRemindOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + + const projectId = await resolveProjectId(ctx.gql, options.project); + const userId = options.user + ? await resolveUserId(ctx.gql, options.user) + : undefined; + + const result = await remindProjectUpdate(ctx.gql, projectId, userId); + outputSuccess(result); + }, + ), + ); +} diff --git a/src/common/domain-values.ts b/src/common/domain-values.ts index e5e79e37..191459fe 100644 --- a/src/common/domain-values.ts +++ b/src/common/domain-values.ts @@ -30,3 +30,30 @@ export function parseSetMode( export function parseLabelMode(value: string | undefined): SetMode | undefined { return parseSetMode("--label-mode", value); } + +/** + * Health of a status update. + * + * Linear declares this twice — `InitiativeUpdateHealthType` and + * `ProjectUpdateHealthType` — with identical members, so one union serves + * both codegen enums. + */ +export type UpdateHealth = "onTrack" | "atRisk" | "offTrack"; + +/** + * Parses `--health` case-insensitively, because the API spelling is + * camelCase and nobody types `atRisk` on a shell prompt reliably. + */ +export function parseHealth(value?: string): UpdateHealth | undefined { + if (!value) return undefined; + + const normalized = value.trim().toLowerCase(); + if (normalized === "ontrack") return "onTrack"; + if (normalized === "atrisk") return "atRisk"; + if (normalized === "offtrack") return "offTrack"; + + throw invalidParameterError( + "--health", + 'must be one of: "onTrack", "atRisk", "offTrack"', + ); +} diff --git a/src/services/initiative-update-service.ts b/src/services/initiative-update-service.ts index fd2aa4f4..8c09c9fd 100644 --- a/src/services/initiative-update-service.ts +++ b/src/services/initiative-update-service.ts @@ -11,7 +11,6 @@ import { GetInitiativeUpdateDocument, type GetInitiativeUpdateQuery, type InitiativeUpdateCreateInput, - type InitiativeUpdateHealthType, type InitiativeUpdateUpdateInput, ListInitiativeUpdatesDocument, type ListInitiativeUpdatesQuery, @@ -57,22 +56,6 @@ export type UpdateInitiativeUpdateInput = Pick< "body" | "health" >; -export function parseHealth( - value?: string, -): InitiativeUpdateHealthType | undefined { - if (!value) return undefined; - - const normalized = value.trim().toLowerCase(); - if (normalized === "ontrack") return "onTrack"; - if (normalized === "atrisk") return "atRisk"; - if (normalized === "offtrack") return "offTrack"; - - throw invalidParameterError( - "--health", - 'must be one of: "onTrack", "atRisk", "offTrack"', - ); -} - export async function listInitiativeUpdates( client: GraphQLClient, options: InitiativeUpdateListOptions, diff --git a/src/services/project-update-service.ts b/src/services/project-update-service.ts new file mode 100644 index 00000000..4605a8e0 --- /dev/null +++ b/src/services/project-update-service.ts @@ -0,0 +1,193 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { invalidParameterError } from "../common/errors.js"; +import type { BrandUuidFields, UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; +import type { PaginatedResult } from "../common/types.js"; +import { + ArchiveProjectUpdateDocument, + type ArchiveProjectUpdateMutation, + CreateProjectUpdateDocument, + type CreateProjectUpdateMutation, + CreateProjectUpdateReminderDocument, + EditProjectUpdateDocument, + type EditProjectUpdateMutation, + GetProjectUpdateDocument, + type GetProjectUpdateQuery, + ListProjectUpdatesDocument, + type ListProjectUpdatesQuery, + type ProjectUpdateCreateInput, + type ProjectUpdateUpdateInput, + UnarchiveProjectUpdateDocument, + type UnarchiveProjectUpdateMutation, +} from "../gql/graphql.js"; + +// Project update projection types +export type ProjectUpdateListItem = + ListProjectUpdatesQuery["projectUpdates"]["nodes"][0]; +export type ProjectUpdateDetail = NonNullable< + GetProjectUpdateQuery["projectUpdate"] +>; +export type CreatedProjectUpdate = NonNullable< + CreateProjectUpdateMutation["projectUpdateCreate"]["projectUpdate"] +>; +export type EditedProjectUpdate = NonNullable< + EditProjectUpdateMutation["projectUpdateUpdate"]["projectUpdate"] +>; +export type ArchivedProjectUpdate = NonNullable< + ArchiveProjectUpdateMutation["projectUpdateArchive"]["entity"] +>; +export type UnarchivedProjectUpdate = NonNullable< + UnarchiveProjectUpdateMutation["projectUpdateUnarchive"]["entity"] +>; +export type ProjectUpdateReminder = { + projectId: string; + success: true; +}; + +export interface ProjectUpdateListOptions { + projectId: UUID; + limit?: number; + after?: string; + includeArchived?: boolean; +} + +// Service-owned input types (UUIDs pre-resolved by the command). +export type CreateProjectUpdateInput = BrandUuidFields< + Pick< + ProjectUpdateCreateInput, + "projectId" | "body" | "health" | "isDiffHidden" + >, + "projectId" +>; +export type EditProjectUpdateInput = Pick< + ProjectUpdateUpdateInput, + "body" | "health" +>; + +export async function listProjectUpdates( + client: GraphQLClient, + options: ProjectUpdateListOptions, +): Promise> { + const { projectId, limit = 50, after, includeArchived = false } = options; + + const result = await client.request(ListProjectUpdatesDocument, { + projectId, + first: limit, + after, + includeArchived, + }); + + return { + nodes: result.projectUpdates.nodes, + pageInfo: result.projectUpdates.pageInfo, + }; +} + +export async function getProjectUpdate( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(GetProjectUpdateDocument, { id }); + + if (!result.projectUpdate) { + throw new Error(`Project update with ID "${id}" not found`); + } + + return result.projectUpdate; +} + +export async function createProjectUpdate( + client: GraphQLClient, + input: CreateProjectUpdateInput, +): Promise { + const gqlInput: ProjectUpdateCreateInput = input; + const result = await client.request(CreateProjectUpdateDocument, { + input: gqlInput, + }); + + return requireMutationEntity( + result.projectUpdateCreate, + "projectUpdate", + "Failed to create project update", + ); +} + +export async function editProjectUpdate( + client: GraphQLClient, + id: UUID, + input: EditProjectUpdateInput, +): Promise { + const hasAtLeastOneField = Object.values(input).some( + (value) => value !== undefined, + ); + + if (!hasAtLeastOneField) { + throw invalidParameterError( + "update options", + "at least one update field must be provided", + ); + } + + const gqlInput: ProjectUpdateUpdateInput = input; + const result = await client.request(EditProjectUpdateDocument, { + id, + input: gqlInput, + }); + + return requireMutationEntity( + result.projectUpdateUpdate, + "projectUpdate", + `Failed to update project update "${id}"`, + ); +} + +export async function archiveProjectUpdate( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(ArchiveProjectUpdateDocument, { id }); + + return requireMutationEntity( + result.projectUpdateArchive, + "entity", + `Failed to archive project update "${id}"`, + ); +} + +export async function unarchiveProjectUpdate( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(UnarchiveProjectUpdateDocument, { id }); + + return requireMutationEntity( + result.projectUpdateUnarchive, + "entity", + `Failed to unarchive project update "${id}"`, + ); +} + +/** + * Asks Linear to notify someone that the project is due an update. + * + * The payload carries no entity, so the project is echoed back to keep the + * JSON self-describing rather than returning a bare `{ success: true }`. + */ +export async function remindProjectUpdate( + client: GraphQLClient, + projectId: UUID, + userId?: UUID, +): Promise { + const result = await client.request(CreateProjectUpdateReminderDocument, { + projectId, + userId, + }); + + if (!result.createProjectUpdateReminder.success) { + throw new Error( + `Failed to create an update reminder for project "${projectId}"`, + ); + } + + return { projectId, success: true }; +} diff --git a/tests/unit/commands/project-updates.test.ts b/tests/unit/commands/project-updates.test.ts new file mode 100644 index 00000000..9dd79f73 --- /dev/null +++ b/tests/unit/commands/project-updates.test.ts @@ -0,0 +1,219 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/common/context.js", () => ({ + createContext: vi.fn(() => ({ gql: { request: vi.fn() } })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), +})); + +vi.mock("../../../src/common/output.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, outputSuccess: vi.fn() }; +}); + +vi.mock("../../../src/resolvers/project-resolver.js", () => ({ + resolveProjectId: vi.fn().mockResolvedValue("resolved-project-uuid"), + resolveProjectLabelIds: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../../src/resolvers/user-resolver.js", () => ({ + resolveUserId: vi.fn().mockResolvedValue("resolved-user-uuid"), +})); + +vi.mock("../../../src/services/project-update-service.js", () => ({ + listProjectUpdates: vi.fn().mockResolvedValue({ nodes: [], pageInfo: {} }), + getProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-1" }), + createProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-new" }), + editProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-1" }), + archiveProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-1" }), + unarchiveProjectUpdate: vi.fn().mockResolvedValue({ id: "upd-1" }), + remindProjectUpdate: vi + .fn() + .mockResolvedValue({ projectId: "proj-1", success: true }), +})); + +import { setupProjectUpdateCommands } from "../../../src/commands/projects/updates.js"; +import { outputSuccess } from "../../../src/common/output.js"; +import { resolveProjectId } from "../../../src/resolvers/project-resolver.js"; +import { resolveUserId } from "../../../src/resolvers/user-resolver.js"; +import { + archiveProjectUpdate, + createProjectUpdate, + editProjectUpdate, + getProjectUpdate, + listProjectUpdates, + remindProjectUpdate, +} from "../../../src/services/project-update-service.js"; + +function createProgram(): Command { + const program = new Command(); + program.option("--api-token "); + const projects = program.command("projects"); + setupProjectUpdateCommands(projects); + return program; +} + +describe("projects updates", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("list resolves the project and forwards pagination", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "list", + "--project", + "My Project", + "--limit", + "10", + "--include-archived", + ]); + + expect(resolveProjectId).toHaveBeenCalledWith( + expect.anything(), + "My Project", + ); + expect(listProjectUpdates).toHaveBeenCalledWith(expect.anything(), { + projectId: "resolved-project-uuid", + limit: 10, + after: undefined, + includeArchived: true, + }); + }); + + it("read passes the update ID straight through", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "read", + "upd-1", + ]); + + expect(getProjectUpdate).toHaveBeenCalledWith(expect.anything(), "upd-1"); + expect(outputSuccess).toHaveBeenCalledWith({ id: "upd-1" }); + }); + + it("create maps --health and --hide-diff onto the input", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "create", + "--project", + "My Project", + "--body", + "Week 1", + "--health", + "atrisk", + "--hide-diff", + ]); + + expect(createProjectUpdate).toHaveBeenCalledWith(expect.anything(), { + projectId: "resolved-project-uuid", + body: "Week 1", + health: "atRisk", + isDiffHidden: true, + }); + }); + + it("create rejects an unknown health value", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "create", + "--project", + "My Project", + "--health", + "sideways", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("--health"), + ); + expect(createProjectUpdate).not.toHaveBeenCalled(); + }); + + it("update requires at least one field", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "update", + "upd-1", + ]); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("at least one option must be provided"), + ); + expect(editProjectUpdate).not.toHaveBeenCalled(); + }); + + it("archive passes the update ID straight through", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "archive", + "upd-1", + ]); + + expect(archiveProjectUpdate).toHaveBeenCalledWith( + expect.anything(), + "upd-1", + ); + }); + + it("remind resolves the target user when one is named", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "remind", + "--project", + "My Project", + "--user", + "alice", + ]); + + expect(resolveUserId).toHaveBeenCalledWith(expect.anything(), "alice"); + expect(remindProjectUpdate).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + "resolved-user-uuid", + ); + }); + + it("remind leaves the target unset when no user is named", async () => { + await createProgram().parseAsync([ + "node", + "test", + "projects", + "updates", + "remind", + "--project", + "My Project", + ]); + + expect(resolveUserId).not.toHaveBeenCalled(); + expect(remindProjectUpdate).toHaveBeenCalledWith( + expect.anything(), + "resolved-project-uuid", + undefined, + ); + }); +}); diff --git a/tests/unit/common/domain-values.test.ts b/tests/unit/common/domain-values.test.ts index 064b3eb1..b1fa58e8 100644 --- a/tests/unit/common/domain-values.test.ts +++ b/tests/unit/common/domain-values.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + parseHealth, parseLabelMode, parseSetMode, } from "../../../src/common/domain-values.js"; @@ -39,3 +40,22 @@ describe("parseSetMode", () => { expect(parseSetMode("--subscriber-mode", "add")).toBe("add"); }); }); + +describe("parseHealth", () => { + it("returns undefined for an absent or empty value", () => { + expect(parseHealth(undefined)).toBeUndefined(); + expect(parseHealth("")).toBeUndefined(); + }); + + it("accepts any casing and returns the API spelling", () => { + expect(parseHealth("ontrack")).toBe("onTrack"); + expect(parseHealth("atRisk")).toBe("atRisk"); + expect(parseHealth(" OFFTRACK ")).toBe("offTrack"); + }); + + it("throws for an unknown health value", () => { + expect(() => parseHealth("sideways")).toThrow( + 'Invalid --health: must be one of: "onTrack", "atRisk", "offTrack"', + ); + }); +}); diff --git a/tests/unit/services/project-update-service.test.ts b/tests/unit/services/project-update-service.test.ts new file mode 100644 index 00000000..11a7c0eb --- /dev/null +++ b/tests/unit/services/project-update-service.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + ArchiveProjectUpdateDocument, + CreateProjectUpdateDocument, + CreateProjectUpdateReminderDocument, + EditProjectUpdateDocument, + GetProjectUpdateDocument, + ListProjectUpdatesDocument, + UnarchiveProjectUpdateDocument, +} from "../../../src/gql/graphql.js"; +import { + archiveProjectUpdate, + createProjectUpdate, + editProjectUpdate, + getProjectUpdate, + listProjectUpdates, + remindProjectUpdate, + unarchiveProjectUpdate, +} from "../../../src/services/project-update-service.js"; + +function mockGqlClient(response: Record): { + client: GraphQLClient; + request: ReturnType; +} { + const request = vi.fn().mockResolvedValue(response); + return { + client: { request } as unknown as GraphQLClient, + request, + }; +} + +describe("listProjectUpdates", () => { + it("forwards the project filter and pagination", async () => { + const { client, request } = mockGqlClient({ + projectUpdates: { + nodes: [{ id: "upd-1", body: "Week 1" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }); + + await expect( + listProjectUpdates(client, { + projectId: asUuid("proj-1"), + limit: 5, + after: "cursor-1", + includeArchived: true, + }), + ).resolves.toEqual({ + nodes: [{ id: "upd-1", body: "Week 1" }], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + + expect(request).toHaveBeenCalledWith(ListProjectUpdatesDocument, { + projectId: "proj-1", + first: 5, + after: "cursor-1", + includeArchived: true, + }); + }); + + it("defaults to 50 results and excludes archived updates", async () => { + const { client, request } = mockGqlClient({ + projectUpdates: { nodes: [], pageInfo: { hasNextPage: false } }, + }); + + await listProjectUpdates(client, { projectId: asUuid("proj-1") }); + + expect(request).toHaveBeenCalledWith(ListProjectUpdatesDocument, { + projectId: "proj-1", + first: 50, + after: undefined, + includeArchived: false, + }); + }); +}); + +describe("getProjectUpdate", () => { + it("returns the update when found", async () => { + const update = { id: "upd-1", body: "Week 1", health: "onTrack" }; + const { client, request } = mockGqlClient({ projectUpdate: update }); + + await expect(getProjectUpdate(client, asUuid("upd-1"))).resolves.toEqual( + update, + ); + expect(request).toHaveBeenCalledWith(GetProjectUpdateDocument, { + id: "upd-1", + }); + }); + + it("throws when the update is missing", async () => { + const { client } = mockGqlClient({ projectUpdate: null }); + + await expect(getProjectUpdate(client, asUuid("upd-1"))).rejects.toThrow( + 'Project update with ID "upd-1" not found', + ); + }); +}); + +describe("createProjectUpdate", () => { + it("returns the created update", async () => { + const { client, request } = mockGqlClient({ + projectUpdateCreate: { + success: true, + projectUpdate: { id: "upd-1", body: "Week 1" }, + }, + }); + + await expect( + createProjectUpdate(client, { + projectId: asUuid("proj-1"), + body: "Week 1", + health: "atRisk", + isDiffHidden: true, + }), + ).resolves.toEqual({ id: "upd-1", body: "Week 1" }); + + expect(request).toHaveBeenCalledWith(CreateProjectUpdateDocument, { + input: { + projectId: "proj-1", + body: "Week 1", + health: "atRisk", + isDiffHidden: true, + }, + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient({ + projectUpdateCreate: { success: false, projectUpdate: null }, + }); + + await expect( + createProjectUpdate(client, { projectId: asUuid("proj-1") }), + ).rejects.toThrow("Failed to create project update"); + }); +}); + +describe("editProjectUpdate", () => { + it("returns the edited update", async () => { + const { client, request } = mockGqlClient({ + projectUpdateUpdate: { + success: true, + projectUpdate: { id: "upd-1", body: "Revised" }, + }, + }); + + await expect( + editProjectUpdate(client, asUuid("upd-1"), { body: "Revised" }), + ).resolves.toEqual({ id: "upd-1", body: "Revised" }); + + expect(request).toHaveBeenCalledWith(EditProjectUpdateDocument, { + id: "upd-1", + input: { body: "Revised" }, + }); + }); + + it("rejects an empty patch before calling the API", async () => { + const { client, request } = mockGqlClient({}); + + await expect( + editProjectUpdate(client, asUuid("upd-1"), {}), + ).rejects.toThrow("at least one update field must be provided"); + + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe("archiveProjectUpdate", () => { + it("returns the archived update", async () => { + const { client, request } = mockGqlClient({ + projectUpdateArchive: { success: true, entity: { id: "upd-1" } }, + }); + + await expect( + archiveProjectUpdate(client, asUuid("upd-1")), + ).resolves.toEqual({ id: "upd-1" }); + + expect(request).toHaveBeenCalledWith(ArchiveProjectUpdateDocument, { + id: "upd-1", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient({ + projectUpdateArchive: { success: false, entity: null }, + }); + + await expect(archiveProjectUpdate(client, asUuid("upd-1"))).rejects.toThrow( + 'Failed to archive project update "upd-1"', + ); + }); +}); + +describe("unarchiveProjectUpdate", () => { + it("returns the restored update", async () => { + const { client, request } = mockGqlClient({ + projectUpdateUnarchive: { success: true, entity: { id: "upd-1" } }, + }); + + await expect( + unarchiveProjectUpdate(client, asUuid("upd-1")), + ).resolves.toEqual({ id: "upd-1" }); + + expect(request).toHaveBeenCalledWith(UnarchiveProjectUpdateDocument, { + id: "upd-1", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient({ + projectUpdateUnarchive: { success: false, entity: null }, + }); + + await expect( + unarchiveProjectUpdate(client, asUuid("upd-1")), + ).rejects.toThrow('Failed to unarchive project update "upd-1"'); + }); +}); + +describe("remindProjectUpdate", () => { + it("echoes the project because the payload carries no entity", async () => { + const { client, request } = mockGqlClient({ + createProjectUpdateReminder: { success: true }, + }); + + await expect( + remindProjectUpdate(client, asUuid("proj-1"), asUuid("user-1")), + ).resolves.toEqual({ projectId: "proj-1", success: true }); + + expect(request).toHaveBeenCalledWith(CreateProjectUpdateReminderDocument, { + projectId: "proj-1", + userId: "user-1", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient({ + createProjectUpdateReminder: { success: false }, + }); + + await expect(remindProjectUpdate(client, asUuid("proj-1"))).rejects.toThrow( + 'Failed to create an update reminder for project "proj-1"', + ); + }); +}); From a6e1a1613b0beb76a94c807b7dfb7caa7ee19403 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:00:09 +0200 Subject: [PATCH 38/70] feat(projects): administer the workspace project status flow `projects create --status ` and `projects update --status ` already resolved status names, but nothing could list what those names are, let alone change them. The status flow was readable only as a side effect of a failed resolution. Wire the seven `projectStatus*` root fields as `projects statuses`. Statuses are workspace-scoped rather than per-project, but callers meet them through `projects --status`, so that is where they will look for them; the subgroup's help text says the scope out loud so nobody assumes per-team flows. Three shaping decisions: - `projects statuses read` folds `projectStatusProjectCount` into the payload instead of adding a `count` verb. The count is exactly what decides whether an archive will be refused, so a caller who reads a status and then hits that refusal had the answer in hand already. - `projectReassignStatus` is `[INTERNAL]` and reassignment on its own is not a task anyone sets out to do, so it becomes `archive --reassign-to ` rather than its own command. Linear refuses to archive a status that still holds projects; this is the one flag that makes the documented failure recoverable in a single step. Reassigning a status onto itself is rejected up front, since the API would accept it as a no-op and the archive would then fail anyway. - `position` is `Float!` on `ProjectStatusCreateInput`, but "where in the flow" is rarely what a caller has in mind. Omitting `--position` reads the current flow and appends past the highest position, so creating a status does not require first learning the numbering scheme. `resolveProjectStatusId()` gains an `includeArchived` option. `statuses unarchive ` names a status that is by definition not in the default set, so without it the command could only ever take a UUID. --- README.md | 2 +- graphql/mutations/project-statuses.graphql | 58 ++++ graphql/queries/project-statuses.graphql | 50 +++ graphql/queries/projects.graphql | 4 +- src/commands/projects/index.ts | 6 + src/commands/projects/statuses.ts | 277 +++++++++++++++ src/resolvers/project-status-resolver.ts | 8 +- src/services/project-status-service.ts | 210 ++++++++++++ tests/unit/commands/project-statuses.test.ts | 180 ++++++++++ .../resolvers/project-status-resolver.test.ts | 21 ++ .../services/project-status-service.test.ts | 314 ++++++++++++++++++ 11 files changed, 1126 insertions(+), 4 deletions(-) create mode 100644 graphql/mutations/project-statuses.graphql create mode 100644 graphql/queries/project-statuses.graphql create mode 100644 src/commands/projects/statuses.ts create mode 100644 src/services/project-status-service.ts create mode 100644 tests/unit/commands/project-statuses.test.ts create mode 100644 tests/unit/services/project-status-service.test.ts diff --git a/README.md b/README.md index 998010f3..9fbdb4dc 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ The table below is the honest picture of the whole surface — what works today, | 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, 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, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); discussions | Project-label CRUD, project relations, project status administration, Slack channel creation | +| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); administer the workspace project status flow (`projects statuses`); discussions | Project-label CRUD, project relations, Slack channel creation | | `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, disable external sync | Update, and the provider-specific link mutations (GitHub PR/issue, GitLab MR, Slack, Jira, Zendesk, Intercom, Front, Salesforce, Discord) | 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/queries/project-statuses.graphql b/graphql/queries/project-statuses.graphql new file mode 100644 index 00000000..6ddaa5b6 --- /dev/null +++ b/graphql/queries/project-statuses.graphql @@ -0,0 +1,50 @@ +# ------------------------------------------------------------ +# 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. +query ListProjectStatuses($includeArchived: Boolean = false) { + projectStatuses(includeArchived: $includeArchived) { + nodes { + ...ProjectStatusCoreFields + } + } +} + +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/projects.graphql b/graphql/queries/projects.graphql index 774d313f..24502d45 100644 --- a/graphql/queries/projects.graphql +++ b/graphql/queries/projects.graphql @@ -228,8 +228,8 @@ query GetProjectWithReactions($id: String!, $first: Int, $after: String) { # 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 { +query GetProjectStatuses($includeArchived: Boolean = false) { + projectStatuses(includeArchived: $includeArchived) { nodes { id name diff --git a/src/commands/projects/index.ts b/src/commands/projects/index.ts index fb97e1a5..4c0cf12e 100644 --- a/src/commands/projects/index.ts +++ b/src/commands/projects/index.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { type DomainMeta, formatDomainUsage } from "../../common/usage.js"; import { setupProjectEntityCommands } from "./entity.js"; +import { setupProjectStatusCommands } from "./statuses.js"; import { setupProjectUpdateCommands } from "./updates.js"; export const PROJECTS_META: DomainMeta = { @@ -18,10 +19,14 @@ export const PROJECTS_META: DomainMeta = { "", "a project's health is derived from its most recent status update, so", "changing health means posting one with `projects updates create`.", + "", + "project statuses are workspace-scoped, not per-team: `projects", + "statuses` administers the one ordered flow every project draws from.", ].join("\n"), arguments: { project: "project identifier (UUID or name)", update: "project status update identifier (UUID)", + status: "project status identifier (UUID or name)", name: "string", }, seeAlso: [ @@ -38,6 +43,7 @@ export function setupProjectsCommands(program: Command): void { setupProjectEntityCommands(projects); setupProjectUpdateCommands(projects); + setupProjectStatusCommands(projects); projects .command("usage") diff --git a/src/commands/projects/statuses.ts b/src/commands/projects/statuses.ts new file mode 100644 index 00000000..4fcd6b4e --- /dev/null +++ b/src/commands/projects/statuses.ts @@ -0,0 +1,277 @@ +import type { Command } from "commander"; +import { createContext, getRootOpts } from "../../common/context.js"; +import { invalidParameterError } from "../../common/errors.js"; +import { commandAction, outputSuccess } from "../../common/output.js"; +import type { ProjectStatusType } from "../../gql/graphql.js"; +import { resolveProjectStatusId } from "../../resolvers/project-status-resolver.js"; +import { + archiveProjectStatus, + type CreateProjectStatusInput, + createProjectStatus, + getProjectStatus, + listProjectStatuses, + reassignProjectStatus, + type UpdateProjectStatusInput, + unarchiveProjectStatus, + updateProjectStatus, +} from "../../services/project-status-service.js"; + +const STATUS_TYPES = [ + "backlog", + "planned", + "started", + "paused", + "completed", + "canceled", +] as const satisfies readonly ProjectStatusType[]; + +interface StatusesListOptions { + includeArchived?: boolean; +} + +// `--type` and `--color` are requiredOption, so commander guarantees them. +interface StatusesCreateOptions { + type: string; + color: string; + description?: string; + position?: string; + indefinite?: boolean; +} + +interface StatusesUpdateOptions { + name?: string; + type?: string; + color?: string; + description?: string; + position?: string; + indefinite?: boolean; + notIndefinite?: boolean; +} + +interface StatusesArchiveOptions { + reassignTo?: string; +} + +function parseStatusType(value: string): ProjectStatusType { + const match = STATUS_TYPES.find((type) => type === value); + if (!match) { + throw invalidParameterError( + "--type", + `must be one of: ${STATUS_TYPES.join(", ")}`, + ); + } + return match; +} + +function parsePosition(value: string): number { + const position = Number.parseFloat(value); + if (!Number.isFinite(position)) { + throw invalidParameterError("--position", "must be a number"); + } + return position; +} + +export function setupProjectStatusCommands(projects: Command): void { + const statuses = projects + .command("statuses") + .description("workspace project status flow operations") + .addHelpText( + "after", + "\nProject statuses are workspace-scoped, not per-team: every project\n" + + "in the workspace draws its status from this one ordered list.", + ); + + statuses + .command("list") + .description("list the workspace project statuses") + .option("--include-archived", "include archived statuses") + .action( + commandAction<[StatusesListOptions, Command]>( + async (options, command) => { + const ctx = createContext(getRootOpts(command)); + const result = await listProjectStatuses( + ctx.gql, + options.includeArchived ?? false, + ); + outputSuccess(result); + }, + ), + ); + + statuses + .command("read ") + .description("get a project status with its project count") + .action( + commandAction<[string, unknown, Command]>( + async (status, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const statusId = await resolveProjectStatusId(ctx.gql, status, { + includeArchived: true, + }); + const result = await getProjectStatus(ctx.gql, statusId); + outputSuccess(result); + }, + ), + ); + + statuses + .command("create ") + .description("create a project status") + .requiredOption("--type ", STATUS_TYPES.join(" | ")) + .requiredOption("--color ", "status color as a hex string") + .option("--description ", "status description") + .option("--position ", "position in the flow; default is last") + .option("--indefinite", "projects may stay in this status indefinitely") + .action( + commandAction<[string, StatusesCreateOptions, Command]>( + async (name, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const input: CreateProjectStatusInput = { + name, + type: parseStatusType(options.type), + color: options.color, + }; + + if (options.description !== undefined) { + input.description = options.description; + } + + if (options.position !== undefined) { + input.position = parsePosition(options.position); + } + + if (options.indefinite) { + input.indefinite = true; + } + + const result = await createProjectStatus(ctx.gql, input); + outputSuccess(result); + }, + ), + ); + + statuses + .command("update ") + .description("update a project status") + .option("--name ", "new name") + .option("--type ", STATUS_TYPES.join(" | ")) + .option("--color ", "new color as a hex string") + .option("--description ", "new description") + .option("--position ", "new position in the flow") + .option("--indefinite", "projects may stay in this status indefinitely") + .option("--not-indefinite", "projects may not stay in this status forever") + .action( + commandAction<[string, StatusesUpdateOptions, Command]>( + async (status, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const input: UpdateProjectStatusInput = {}; + + if (options.name !== undefined) { + input.name = options.name; + } + + if (options.type !== undefined) { + input.type = parseStatusType(options.type); + } + + if (options.color !== undefined) { + input.color = options.color; + } + + if (options.description !== undefined) { + input.description = options.description; + } + + if (options.position !== undefined) { + input.position = parsePosition(options.position); + } + + if (options.indefinite && options.notIndefinite) { + throw invalidParameterError( + "--indefinite", + "cannot be combined with --not-indefinite", + ); + } + + if (options.indefinite) { + input.indefinite = true; + } else if (options.notIndefinite) { + input.indefinite = false; + } + + if (Object.keys(input).length === 0) { + throw invalidParameterError( + "update options", + "at least one option must be provided", + ); + } + + const statusId = await resolveProjectStatusId(ctx.gql, status, { + includeArchived: true, + }); + const result = await updateProjectStatus(ctx.gql, statusId, input); + outputSuccess(result); + }, + ), + ); + + statuses + .command("archive ") + .description("archive a project status") + .addHelpText( + "after", + "\nLinear refuses to archive a status that still has projects in it.\n" + + "--reassign-to moves them first, which is the only way to make that\n" + + "failure recoverable in one step.", + ) + .option( + "--reassign-to ", + "move projects onto this status before archiving", + ) + .action( + commandAction<[string, StatusesArchiveOptions, Command]>( + async (status, options, command) => { + const ctx = createContext(getRootOpts(command)); + + const statusId = await resolveProjectStatusId(ctx.gql, status); + + if (options.reassignTo !== undefined) { + const newStatusId = await resolveProjectStatusId( + ctx.gql, + options.reassignTo, + ); + + if (newStatusId === statusId) { + throw invalidParameterError( + "--reassign-to", + "must name a different status than the one being archived", + ); + } + + await reassignProjectStatus(ctx.gql, statusId, newStatusId); + } + + const result = await archiveProjectStatus(ctx.gql, statusId); + outputSuccess(result); + }, + ), + ); + + statuses + .command("unarchive ") + .description("unarchive a project status") + .action( + commandAction<[string, unknown, Command]>( + async (status, _unused1, command) => { + const ctx = createContext(getRootOpts(command)); + const statusId = await resolveProjectStatusId(ctx.gql, status, { + includeArchived: true, + }); + const result = await unarchiveProjectStatus(ctx.gql, statusId); + outputSuccess(result); + }, + ), + ); +} diff --git a/src/resolvers/project-status-resolver.ts b/src/resolvers/project-status-resolver.ts index a998280b..53406491 100644 --- a/src/resolvers/project-status-resolver.ts +++ b/src/resolvers/project-status-resolver.ts @@ -13,16 +13,22 @@ import { GetProjectStatusesDocument } from "../gql/graphql.js"; * * @param client - GraphQL client for querying project statuses * @param nameOrId - Status name or UUID + * @param options.includeArchived - Search archived statuses too. Needed by + * `projects statuses unarchive`, where the status being named is by + * definition not in the default set. * @returns Status UUID * @throws Error if status name not found */ export async function resolveProjectStatusId( client: GraphQLClient, nameOrId: string, + options: { includeArchived?: boolean } = {}, ): Promise { if (isUuid(nameOrId)) return asUuid(nameOrId); - const result = await client.request(GetProjectStatusesDocument); + const result = await client.request(GetProjectStatusesDocument, { + includeArchived: options.includeArchived ?? false, + }); const match = result.projectStatuses.nodes.find( (s) => s.name.toLowerCase() === nameOrId.toLowerCase(), ); diff --git a/src/services/project-status-service.ts b/src/services/project-status-service.ts new file mode 100644 index 00000000..7a08d4f6 --- /dev/null +++ b/src/services/project-status-service.ts @@ -0,0 +1,210 @@ +import type { GraphQLClient } from "../client/graphql-client.js"; +import { invalidParameterError } from "../common/errors.js"; +import type { UUID } from "../common/identifier.js"; +import { requireMutationEntity } from "../common/mutation-payload.js"; +import { + ArchiveProjectStatusDocument, + type ArchiveProjectStatusMutation, + CreateProjectStatusDocument, + type CreateProjectStatusMutation, + GetProjectStatusDocument, + GetProjectStatusProjectCountDocument, + type GetProjectStatusProjectCountQuery, + type GetProjectStatusQuery, + ListProjectStatusesDocument, + type ListProjectStatusesQuery, + type ProjectStatusCreateInput, + type ProjectStatusUpdateInput, + ReassignProjectStatusDocument, + UnarchiveProjectStatusDocument, + type UnarchiveProjectStatusMutation, + UpdateProjectStatusDocument, + type UpdateProjectStatusMutation, +} from "../gql/graphql.js"; + +// Project status projection types +export type ProjectStatusListItem = + ListProjectStatusesQuery["projectStatuses"]["nodes"][0]; +export type ProjectStatusDetail = NonNullable< + GetProjectStatusQuery["projectStatus"] +> & { + projectCount: GetProjectStatusProjectCountQuery["projectStatusProjectCount"]; +}; +export type CreatedProjectStatus = NonNullable< + CreateProjectStatusMutation["projectStatusCreate"]["status"] +>; +export type UpdatedProjectStatus = NonNullable< + UpdateProjectStatusMutation["projectStatusUpdate"]["status"] +>; +export type ArchivedProjectStatus = NonNullable< + ArchiveProjectStatusMutation["projectStatusArchive"]["entity"] +>; +export type UnarchivedProjectStatus = NonNullable< + UnarchiveProjectStatusMutation["projectStatusUnarchive"]["entity"] +>; + +// Service-owned input types. Project statuses carry no UUID references, +// so these are the codegen inputs unchanged apart from `position`. +export type CreateProjectStatusInput = Omit< + ProjectStatusCreateInput, + "id" | "position" +> & { + /** Omitted places the status last in the workspace flow. */ + position?: number; +}; +export type UpdateProjectStatusInput = ProjectStatusUpdateInput; + +export async function listProjectStatuses( + client: GraphQLClient, + includeArchived = false, +): Promise<{ nodes: ProjectStatusListItem[] }> { + const result = await client.request(ListProjectStatusesDocument, { + includeArchived, + }); + + return { nodes: result.projectStatuses.nodes }; +} + +/** + * Reads one status together with how many projects sit in it. + * + * The count is what decides whether {@link archiveProjectStatus} will be + * refused, so returning the status without it would leave callers making a + * second call they cannot know they need. + */ +export async function getProjectStatus( + client: GraphQLClient, + id: UUID, +): Promise { + const [statusResult, countResult] = await Promise.all([ + client.request(GetProjectStatusDocument, { id }), + client.request(GetProjectStatusProjectCountDocument, { id }), + ]); + + if (!statusResult.projectStatus) { + throw new Error(`Project status with ID "${id}" not found`); + } + + return { + ...statusResult.projectStatus, + projectCount: countResult.projectStatusProjectCount, + }; +} + +/** + * Appends a status to the end of the workspace flow. + * + * `position` is required by the API but rarely what a caller has in mind, so + * an omitted position reads the current flow and takes the next slot. + */ +async function nextProjectStatusPosition( + client: GraphQLClient, +): Promise { + const { nodes } = await listProjectStatuses(client); + const highest = nodes.reduce( + (max, status) => Math.max(max, status.position), + 0, + ); + + return highest + 1; +} + +export async function createProjectStatus( + client: GraphQLClient, + input: CreateProjectStatusInput, +): Promise { + const { position, ...rest } = input; + const gqlInput: ProjectStatusCreateInput = { + ...rest, + position: position ?? (await nextProjectStatusPosition(client)), + }; + + const result = await client.request(CreateProjectStatusDocument, { + input: gqlInput, + }); + + return requireMutationEntity( + result.projectStatusCreate, + "status", + `Failed to create project status "${input.name}"`, + ); +} + +export async function updateProjectStatus( + client: GraphQLClient, + id: UUID, + input: UpdateProjectStatusInput, +): Promise { + const hasAtLeastOneField = Object.values(input).some( + (value) => value !== undefined, + ); + + if (!hasAtLeastOneField) { + throw invalidParameterError( + "update options", + "at least one update field must be provided", + ); + } + + const result = await client.request(UpdateProjectStatusDocument, { + id, + input, + }); + + return requireMutationEntity( + result.projectStatusUpdate, + "status", + `Failed to update project status "${id}"`, + ); +} + +/** + * Moves every project off one status and onto another. + * + * Exposed only through `projects statuses archive --reassign-to`, because + * Linear marks the mutation `[INTERNAL]` and reassignment on its own is not + * a task anyone sets out to do. + */ +export async function reassignProjectStatus( + client: GraphQLClient, + originalProjectStatusId: UUID, + newProjectStatusId: UUID, +): Promise { + const result = await client.request(ReassignProjectStatusDocument, { + originalProjectStatusId, + newProjectStatusId, + }); + + if (!result.projectReassignStatus.success) { + throw new Error( + `Failed to reassign projects from status "${originalProjectStatusId}" ` + + `to "${newProjectStatusId}"`, + ); + } +} + +export async function archiveProjectStatus( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(ArchiveProjectStatusDocument, { id }); + + return requireMutationEntity( + result.projectStatusArchive, + "entity", + `Failed to archive project status "${id}"`, + ); +} + +export async function unarchiveProjectStatus( + client: GraphQLClient, + id: UUID, +): Promise { + const result = await client.request(UnarchiveProjectStatusDocument, { id }); + + return requireMutationEntity( + result.projectStatusUnarchive, + "entity", + `Failed to unarchive project status "${id}"`, + ); +} diff --git a/tests/unit/commands/project-statuses.test.ts b/tests/unit/commands/project-statuses.test.ts new file mode 100644 index 00000000..5b6c1505 --- /dev/null +++ b/tests/unit/commands/project-statuses.test.ts @@ -0,0 +1,180 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/common/context.js", () => ({ + createContext: vi.fn(() => ({ gql: { request: vi.fn() } })), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), +})); + +vi.mock("../../../src/common/output.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, outputSuccess: vi.fn() }; +}); + +vi.mock("../../../src/resolvers/project-status-resolver.js", () => ({ + resolveProjectStatusId: vi + .fn() + .mockImplementation(async (_client: unknown, nameOrId: string) => + nameOrId === "In Review" ? "status-uuid-2" : "status-uuid-1", + ), +})); + +vi.mock("../../../src/services/project-status-service.js", () => ({ + listProjectStatuses: vi.fn().mockResolvedValue({ nodes: [] }), + getProjectStatus: vi.fn().mockResolvedValue({ id: "st-1" }), + createProjectStatus: vi.fn().mockResolvedValue({ id: "st-new" }), + updateProjectStatus: vi.fn().mockResolvedValue({ id: "st-1" }), + reassignProjectStatus: vi.fn().mockResolvedValue(undefined), + archiveProjectStatus: vi.fn().mockResolvedValue({ id: "st-1" }), + unarchiveProjectStatus: vi.fn().mockResolvedValue({ id: "st-1" }), +})); + +import { setupProjectStatusCommands } from "../../../src/commands/projects/statuses.js"; +import { resolveProjectStatusId } from "../../../src/resolvers/project-status-resolver.js"; +import { + archiveProjectStatus, + createProjectStatus, + listProjectStatuses, + reassignProjectStatus, + unarchiveProjectStatus, + updateProjectStatus, +} from "../../../src/services/project-status-service.js"; + +function createProgram(): Command { + const program = new Command(); + program.option("--api-token "); + setupProjectStatusCommands(program.command("projects")); + return program; +} + +async function run(...argv: string[]): Promise { + await createProgram().parseAsync(["node", "test", "projects", ...argv]); +} + +describe("projects statuses", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + }); + + it("list forwards --include-archived", async () => { + await run("statuses", "list", "--include-archived"); + + expect(listProjectStatuses).toHaveBeenCalledWith(expect.anything(), true); + }); + + it("read resolves archived statuses too", async () => { + await run("statuses", "read", "Done"); + + expect(resolveProjectStatusId).toHaveBeenCalledWith( + expect.anything(), + "Done", + { includeArchived: true }, + ); + }); + + it("create leaves position unset so the service appends", async () => { + await run( + "statuses", + "create", + "Blocked", + "--type", + "paused", + "--color", + "#B45309", + ); + + expect(createProjectStatus).toHaveBeenCalledWith(expect.anything(), { + name: "Blocked", + type: "paused", + color: "#B45309", + }); + }); + + it("create rejects a type outside the enum", async () => { + await run( + "statuses", + "create", + "Blocked", + "--type", + "stalled", + "--color", + "#B45309", + ); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("Invalid --type"), + ); + expect(createProjectStatus).not.toHaveBeenCalled(); + }); + + it("update maps --not-indefinite to false", async () => { + await run("statuses", "update", "Done", "--not-indefinite"); + + expect(updateProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + { indefinite: false }, + ); + }); + + it("update refuses contradictory indefinite flags", async () => { + await run("statuses", "update", "Done", "--indefinite", "--not-indefinite"); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("cannot be combined with --not-indefinite"), + ); + expect(updateProjectStatus).not.toHaveBeenCalled(); + }); + + it("archive reassigns before archiving when --reassign-to is given", async () => { + await run("statuses", "archive", "Done", "--reassign-to", "In Review"); + + expect(reassignProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + "status-uuid-2", + ); + expect(archiveProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + ); + }); + + it("archive refuses to reassign a status onto itself", async () => { + await run("statuses", "archive", "Done", "--reassign-to", "Done"); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("must name a different status"), + ); + expect(reassignProjectStatus).not.toHaveBeenCalled(); + expect(archiveProjectStatus).not.toHaveBeenCalled(); + }); + + it("archive skips reassignment when the flag is absent", async () => { + await run("statuses", "archive", "Done"); + + expect(reassignProjectStatus).not.toHaveBeenCalled(); + expect(archiveProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + ); + }); + + it("unarchive resolves archived statuses", async () => { + await run("statuses", "unarchive", "Done"); + + expect(resolveProjectStatusId).toHaveBeenCalledWith( + expect.anything(), + "Done", + { includeArchived: true }, + ); + expect(unarchiveProjectStatus).toHaveBeenCalledWith( + expect.anything(), + "status-uuid-1", + ); + }); +}); diff --git a/tests/unit/resolvers/project-status-resolver.test.ts b/tests/unit/resolvers/project-status-resolver.test.ts index 488580e3..9efca19d 100644 --- a/tests/unit/resolvers/project-status-resolver.test.ts +++ b/tests/unit/resolvers/project-status-resolver.test.ts @@ -33,6 +33,27 @@ describe("resolveProjectStatusId", () => { expect(result).toBe("status-uuid"); }); + it("excludes archived statuses unless asked", async () => { + const client = mockGqlClient([{ id: "status-uuid", name: "Started" }]); + await resolveProjectStatusId(client, "Started"); + + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + includeArchived: false, + }); + }); + + it("searches archived statuses when asked", async () => { + const client = mockGqlClient([{ id: "status-uuid", name: "Retired" }]); + const result = await resolveProjectStatusId(client, "Retired", { + includeArchived: true, + }); + + expect(result).toBe("status-uuid"); + expect(client.request).toHaveBeenCalledWith(expect.anything(), { + includeArchived: true, + }); + }); + it("throws when status not found", async () => { const client = mockGqlClient([]); await expect(resolveProjectStatusId(client, "Nonexistent")).rejects.toThrow( diff --git a/tests/unit/services/project-status-service.test.ts b/tests/unit/services/project-status-service.test.ts new file mode 100644 index 00000000..9c670945 --- /dev/null +++ b/tests/unit/services/project-status-service.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { asUuid } from "../../../src/common/identifier.js"; +import { + ArchiveProjectStatusDocument, + CreateProjectStatusDocument, + GetProjectStatusDocument, + GetProjectStatusProjectCountDocument, + ListProjectStatusesDocument, + ReassignProjectStatusDocument, + UnarchiveProjectStatusDocument, + UpdateProjectStatusDocument, +} from "../../../src/gql/graphql.js"; +import { + archiveProjectStatus, + createProjectStatus, + getProjectStatus, + listProjectStatuses, + reassignProjectStatus, + unarchiveProjectStatus, + updateProjectStatus, +} from "../../../src/services/project-status-service.js"; + +/** Routes each document to its own canned response. */ +function mockGqlClient(responses: Map): { + client: GraphQLClient; + request: ReturnType; +} { + const request = vi.fn(async (document: unknown) => { + if (!responses.has(document)) { + throw new Error("unexpected document"); + } + return responses.get(document); + }); + + return { client: { request } as unknown as GraphQLClient, request }; +} + +describe("listProjectStatuses", () => { + it("excludes archived statuses by default", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ListProjectStatusesDocument, { projectStatuses: { nodes: [] } }], + ]), + ); + + await expect(listProjectStatuses(client)).resolves.toEqual({ nodes: [] }); + expect(request).toHaveBeenCalledWith(ListProjectStatusesDocument, { + includeArchived: false, + }); + }); +}); + +describe("getProjectStatus", () => { + it("folds the project count into the status payload", async () => { + const { client } = mockGqlClient( + new Map([ + [GetProjectStatusDocument, { projectStatus: { id: "st-1" } }], + [ + GetProjectStatusProjectCountDocument, + { + projectStatusProjectCount: { + count: 3, + privateCount: 1, + archivedTeamCount: 0, + }, + }, + ], + ]), + ); + + await expect(getProjectStatus(client, asUuid("st-1"))).resolves.toEqual({ + id: "st-1", + projectCount: { count: 3, privateCount: 1, archivedTeamCount: 0 }, + }); + }); + + it("throws when the status is missing", async () => { + const { client } = mockGqlClient( + new Map([ + [GetProjectStatusDocument, { projectStatus: null }], + [ + GetProjectStatusProjectCountDocument, + { projectStatusProjectCount: { count: 0 } }, + ], + ]), + ); + + await expect(getProjectStatus(client, asUuid("st-1"))).rejects.toThrow( + 'Project status with ID "st-1" not found', + ); + }); +}); + +describe("createProjectStatus", () => { + it("appends past the highest existing position when none is given", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + ListProjectStatusesDocument, + { projectStatuses: { nodes: [{ position: 2 }, { position: 5 }] } }, + ], + [ + CreateProjectStatusDocument, + { projectStatusCreate: { success: true, status: { id: "st-new" } } }, + ], + ]), + ); + + await expect( + createProjectStatus(client, { + name: "Blocked", + type: "paused", + color: "#B45309", + }), + ).resolves.toEqual({ id: "st-new" }); + + expect(request).toHaveBeenCalledWith(CreateProjectStatusDocument, { + input: { + name: "Blocked", + type: "paused", + color: "#B45309", + position: 6, + }, + }); + }); + + it("uses an explicit position without reading the flow", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + CreateProjectStatusDocument, + { projectStatusCreate: { success: true, status: { id: "st-new" } } }, + ], + ]), + ); + + await createProjectStatus(client, { + name: "Blocked", + type: "paused", + color: "#B45309", + position: 1.5, + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith(CreateProjectStatusDocument, { + input: { + name: "Blocked", + type: "paused", + color: "#B45309", + position: 1.5, + }, + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient( + new Map([ + [ + CreateProjectStatusDocument, + { projectStatusCreate: { success: false, status: null } }, + ], + ]), + ); + + await expect( + createProjectStatus(client, { + name: "Blocked", + type: "paused", + color: "#B45309", + position: 1, + }), + ).rejects.toThrow('Failed to create project status "Blocked"'); + }); +}); + +describe("updateProjectStatus", () => { + it("forwards the patch", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + UpdateProjectStatusDocument, + { projectStatusUpdate: { success: true, status: { id: "st-1" } } }, + ], + ]), + ); + + await expect( + updateProjectStatus(client, asUuid("st-1"), { indefinite: false }), + ).resolves.toEqual({ id: "st-1" }); + + expect(request).toHaveBeenCalledWith(UpdateProjectStatusDocument, { + id: "st-1", + input: { indefinite: false }, + }); + }); + + it("rejects an empty patch before calling the API", async () => { + const { client, request } = mockGqlClient(new Map()); + + await expect( + updateProjectStatus(client, asUuid("st-1"), {}), + ).rejects.toThrow("at least one update field must be provided"); + + expect(request).not.toHaveBeenCalled(); + }); +}); + +describe("reassignProjectStatus", () => { + it("resolves when the mutation succeeds", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + ReassignProjectStatusDocument, + { projectReassignStatus: { success: true } }, + ], + ]), + ); + + await expect( + reassignProjectStatus(client, asUuid("st-1"), asUuid("st-2")), + ).resolves.toBeUndefined(); + + expect(request).toHaveBeenCalledWith(ReassignProjectStatusDocument, { + originalProjectStatusId: "st-1", + newProjectStatusId: "st-2", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient( + new Map([ + [ + ReassignProjectStatusDocument, + { projectReassignStatus: { success: false } }, + ], + ]), + ); + + await expect( + reassignProjectStatus(client, asUuid("st-1"), asUuid("st-2")), + ).rejects.toThrow( + 'Failed to reassign projects from status "st-1" to "st-2"', + ); + }); +}); + +describe("archiveProjectStatus", () => { + it("returns the archived status", async () => { + const { client } = mockGqlClient( + new Map([ + [ + ArchiveProjectStatusDocument, + { projectStatusArchive: { success: true, entity: { id: "st-1" } } }, + ], + ]), + ); + + await expect(archiveProjectStatus(client, asUuid("st-1"))).resolves.toEqual( + { id: "st-1" }, + ); + }); + + it("throws when Linear refuses the archive", async () => { + const { client } = mockGqlClient( + new Map([ + [ + ArchiveProjectStatusDocument, + { projectStatusArchive: { success: false, entity: null } }, + ], + ]), + ); + + await expect(archiveProjectStatus(client, asUuid("st-1"))).rejects.toThrow( + 'Failed to archive project status "st-1"', + ); + }); +}); + +describe("unarchiveProjectStatus", () => { + it("returns the restored status", async () => { + const { client, request } = mockGqlClient( + new Map([ + [ + UnarchiveProjectStatusDocument, + { projectStatusUnarchive: { success: true, entity: { id: "st-1" } } }, + ], + ]), + ); + + await expect( + unarchiveProjectStatus(client, asUuid("st-1")), + ).resolves.toEqual({ id: "st-1" }); + + expect(request).toHaveBeenCalledWith(UnarchiveProjectStatusDocument, { + id: "st-1", + }); + }); + + it("throws when the mutation reports failure", async () => { + const { client } = mockGqlClient( + new Map([ + [ + UnarchiveProjectStatusDocument, + { projectStatusUnarchive: { success: false, entity: null } }, + ], + ]), + ); + + await expect( + unarchiveProjectStatus(client, asUuid("st-1")), + ).rejects.toThrow('Failed to unarchive project status "st-1"'); + }); +}); From 4bf8dcc50e69b764f87bc8c1712c2a72f3dcdd76 Mon Sep 17 00:00:00 2001 From: Fabian Jocks <24557998+iamfj@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:05:44 +0200 Subject: [PATCH 39/70] feat(labels): full CRUD and retire/restore for project labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `labels` already selected its entity kind with `--type issue|project`, but only `list` honoured it. `read`, `update` and `delete` were issue-only, so a project label could be applied by name through `projects --labels` and then never edited or removed from the CLI. Thread `--type` through every verb. `read/update/delete` now route to the `projectLabel*` operations when asked, `create` gains `--type`, and `retire`/`restore` are added for both kinds. Retire is the reversible alternative to delete — the label stays on whatever already carries it but cannot be applied to anything new — which is what you actually want when killing off a taxonomy that history still references. Extending `labels` rather than adding `projects labels` closes the project-label gap and the `labels` gap in one place. A second home for the same nouns would mean two commands to learn and two places to look. Notes on the shape: - The service takes `type` as a trailing parameter defaulting to `"issue"`, so every existing call site keeps its meaning and the dispatch lives in one place instead of six `if`s in the command. - `--team`/`--scope` are rejected under `--type project` on every verb, reusing the guard `labels list` already had. Project labels have no team dimension, so ignoring the flags would answer a question the caller did not ask. - `--parent` resolves against the same label kind as the label being written. Groups and their children are always the same kind, so the other resolver could only produce a not-found or a parent the API would reject. - The label fragments now select `isGroup`, `retiredAt` and `parent`. Without `retiredAt` the retire/restore commands would report success with no visible difference in their own output. --- README.md | 4 +- graphql/mutations/labels.graphql | 70 +++++++- graphql/queries/labels.graphql | 20 +++ src/commands/labels.ts | 208 ++++++++++++++++++---- src/services/label-service.ts | 186 ++++++++++++++++--- tests/unit/commands/labels.test.ts | 173 ++++++++++++++++-- tests/unit/services/label-service.test.ts | 179 +++++++++++++++++++ 7 files changed, 767 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 9fbdb4dc..3e93a011 100644 --- a/README.md +++ b/README.md @@ -157,13 +157,13 @@ The table below is the honest picture of the whole surface — what works today, | 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, 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, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); administer the workspace project status flow (`projects statuses`); discussions | Project-label CRUD, project relations, Slack channel creation | +| `projects` | 🟡 | List, read, create, update, delete (trash) and unarchive (restore); assign project labels by name (`--labels`, `--label-mode`, `--clear-labels`); status updates (list, read, create, edit, archive/unarchive, remind); administer the workspace project status flow (`projects statuses`); discussions | Project relations, Slack channel creation | | `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, 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 | 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/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/src/commands/labels.ts b/src/commands/labels.ts index a22e4bdf..c8127ee7 100644 --- a/src/commands/labels.ts +++ b/src/commands/labels.ts @@ -13,6 +13,7 @@ import { type LabelResolverScope, 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 +24,8 @@ import { type LabelType, listLabels, listProjectLabels, + restoreLabel, + retireLabel, type UpdateLabelInput, updateLabel, } from "../services/label-service.js"; @@ -36,20 +39,26 @@ 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; + group?: boolean; } function parseLabelType(value?: string): LabelType { @@ -83,14 +92,76 @@ 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. + */ +async function resolveLabelParentId( + client: ReturnType["gql"], + parent: string, + type: LabelType, +): Promise { + return type === "project" + ? resolveProjectLabelId(client, parent) + : resolveLabelId(client, parent); +} + +/** + * Resolves `