From 9ec965d906bb37bfb332a36664b38f083fccb3b6 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Fri, 18 Sep 2026 14:23:24 -0700 Subject: [PATCH] Accept a pasted Linear URL wherever an identifier goes Copying a link out of Linear and pasting it into the CLI is the obvious way to refer to something, and it did not work. `issue view ` failed outright with "Could not determine issue ID". Project and document URLs appeared to work, but only because Linear's API quietly extracts a slug when a whole URL is handed to a `slugId` filter -- undocumented behavior the CLI should not lean on, and one that produces a bare "not found: https://..." the moment the URL points at anything else. Parse the URL locally instead. A new module classifies a reference as ordinary input, a Linear URL naming something the CLI can look up, or a Linear URL it cannot use. The first falls through untouched, so every existing ID, slug, name and UUID lookup is unchanged -- and so is `issue link`, where a lone URL argument still means the thing being linked rather than the issue to link it to. The third is reported rather than retried as a name, which is what turns `/settings` from a mysterious missing project into a sentence. Wiring it into the shared resolvers covers most of the surface at once, since positional arguments and flags like --project, --parent and --team already funnel through them. The rest needed finding: ten commands define their own local resolver shadowing the shared one, and five document commands pass an id straight to `document(id:)`. Each gets the same extraction. A URL for the wrong kind of thing now names what it actually points at, and one from another workspace names both. The workspace comparison trusts the configured workspace even under a raw LINEAR_API_KEY, where the key's organization is not knowable locally: refusing to guess there would disable the check for the most common setup, which is exactly when a URL from the wrong workspace gets pasted. Cycle URLs are supported in the three forms the app actually produces, copied out of it rather than guessed: /team/ENG/cycle/5, /cycle/active and /cycle/upcoming, the last being what the CLI already calls `next`. Cycle has no url field in the schema, so these could only come from the app. A cycle URL carries its team, so `cycle view ` uses it; a --team naming a different team is refused, since resolving the URL's number against the wrong team finds a plausible-looking wrong cycle. The number is matched as a number only -- the general lookup also matches cycle names, so a cycle named "7" could otherwise win. Comment IDs are deliberately refused rather than guessed: the #comment- anchor keeps only the first eight characters of the UUID. The issue is in the path, so an issue-scoped prefix search looked feasible, but the issue query fetches comments(first: 50) with no pagination, and a lookup that can silently miss a match on page two has no business backing `issue comment delete`. A comment URL still names its issue, so `issue view` takes one. A URL resolves by slug alone, never through the name-first lookups: a twelve-hex slug ID is also a legal project name, and the general project lookup tries names first, so a project named after another project's slug would otherwise win -- and `project delete ` would delete it. The strict lookup keeps each command's archived semantics, so `initiative unarchive ` still finds the archived initiative it exists to act on. Project URLs are accepted from any of the project's tabs (/overview, /issues, /updates, /activity), since that is usually where one is copied from. The parser is also covered by property-based tests (fast-check, added as a test-only dependency). They generate references, render them the ways people paste them, and check they parse back; that only linear.app itself is ever read as Linear, with the WHATWG URL parser as the reference; that ordinary IDs and names are never mistaken for URLs; and that junk and damaged URLs never throw anything but a ValidationError. A 600,000-case campaign found nothing. To show that is evidence rather than a toothless fuzzer, three bugs were planted in turn -- a loosened host check, slug extraction from the wrong dash, an unguarded percent-decode -- and each was caught and shrunk to a minimal counterexample. Every run uses a fresh seed; a failure prints the seed, and FC_SEED/FC_PATH replay it. --- CHANGELOG.md | 1 + README.md | 1 + deno.json | 1 + deno.lock | 11 + docs/usage.md | 13 + src/commands/cycle/cycle-view.ts | 11 +- src/commands/document/attachment-target.ts | 15 +- src/commands/document/document-comment-add.ts | 6 +- .../document/document-comment-list.ts | 6 +- src/commands/document/document-delete.ts | 13 +- src/commands/document/document-update.ts | 4 +- src/commands/document/document-view.ts | 8 +- .../initiative-update-create.ts | 15 + .../initiative-update-list.ts | 15 + .../initiative/initiative-add-project.ts | 29 ++ src/commands/initiative/initiative-archive.ts | 15 + src/commands/initiative/initiative-delete.ts | 17 + .../initiative/initiative-remove-project.ts | 29 ++ .../initiative/initiative-unarchive.ts | 17 + src/commands/initiative/initiative-update.ts | 16 +- src/commands/initiative/initiative-view.ts | 15 + .../issue/issue-agent-session-view.ts | 2 + src/commands/issue/issue-comment-delete.ts | 3 + src/commands/issue/issue-comment-update.ts | 3 + src/commands/label/label-delete.ts | 2 + src/commands/milestone/milestone-delete.ts | 9 + src/commands/milestone/milestone-update.ts | 7 + src/commands/project/project-create.ts | 15 + src/utils/comments.ts | 6 + src/utils/linear-url.ts | 464 ++++++++++++++++++ src/utils/linear.ts | 204 +++++++- src/utils/templates.ts | 2 + test/commands/document/document-list.test.ts | 74 +++ .../initiative/initiative-unarchive.test.ts | 87 ++++ .../issue/issue-url-reference.test.ts | 101 ++++ test/utils/comments.test.ts | 20 +- test/utils/linear-url.property.test.ts | 440 +++++++++++++++++ test/utils/linear-url.test.ts | 391 +++++++++++++++ test/utils/linear.test.ts | 294 +++++++++++ 39 files changed, 2341 insertions(+), 41 deletions(-) create mode 100644 src/utils/linear-url.ts create mode 100644 test/commands/initiative/initiative-unarchive.test.ts create mode 100644 test/commands/issue/issue-url-reference.test.ts create mode 100644 test/utils/linear-url.property.test.ts create mode 100644 test/utils/linear-url.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c21cda..8c786cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added +- every command that takes an issue, project, document, initiative, team, or cycle now also takes the URL you copied out of Linear — `linear issue view https://linear.app/acme/issue/ENG-123/some-title`, `linear project view `, `--project`, `--parent`, `--team` and the rest, since they all resolve through the same lookups. `issue view ` did not work at all before; project and document URLs happened to work through an undocumented server-side behavior in Linear's API, which the CLI no longer relies on. A URL pointing at the wrong kind of thing now says so ("that is an issue URL, not a project URL") instead of reporting the whole URL as a missing name, as does one from another workspace or a page that names nothing (`/settings`). Cycle URLs work in all three forms the app produces — `/team/ENG/cycle/5`, `/cycle/active` and `/cycle/upcoming` (the CLI's `next`) — and carry their team: `cycle view ` uses it, and a `--team` that names a different team is refused rather than used to look up that team's cycle with the same number. Commands whose identifiers have no URL at all — milestones, labels, templates, releases — say that plainly. A comment link carries only the first eight characters of the comment's ID, so it names its issue but cannot be used as a comment ID. `issue link ` is unchanged: a lone URL there is still the thing being linked - `project view` now shows what Linear's project page shows: the long-form overview body (`content`), milestones with their status and progress, resources (`externalLinks`), documents, attachments, related projects with their dependency direction, labels, members, initiatives, and Linear's own progress percentage. Only `description` — the 255-character summary — was rendered before, so a project whose body was written with `project create --content-file` displayed nothing of it. A project reference can now be a UUID, slug ID, or exact name everywhere, including with `--web`/`--app`, and long output pages like `issue view` does (`--no-pager` to disable). `--json` keeps the GraphQL field names and the `{ nodes, pageInfo }` shape of every connection - `project view` with no argument opens a searchable list of projects to pick from, scoped like `project list` — the configured team, or the whole workspace when no team is set. It only prompts when stdin and stdout are both terminals; piped, redirected, in CI, or with `--json` it says a project is required instead of hanging on a prompt nobody can answer - `issue archive ` archives an issue through Linear's `issueArchive` mutation, distinct from `issue delete`, which trashes it. It resolves identifiers like the other issue commands, prompts with the identifier and title unless `--confirm`/`-y` is passed, reports an already-archived issue instead of silently succeeding, and takes `--bulk`, `--bulk-file`, and `--bulk-stdin` like `issue delete` ([#285](https://github.com/schpet/linear-cli/pull/285); thanks @martin-piliar for the command and the report in [#284](https://github.com/schpet/linear-cli/issues/284)) diff --git a/README.md b/README.md index ca3aeef8..8f9e7e3c 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ linear user list --json # machine-readable output ```bash linear project list # list projects +linear project view https://linear.app/acme/project/mobile-launch-272f50ef9250 # paste a URL from Linear linear project view # pick from a searchable list of projects linear project view # overview, milestones, resources, documents, related projects linear project view "Mobile launch" # a UUID, slug ID, or exact name all work diff --git a/deno.json b/deno.json index ec06322e..a216ded0 100644 --- a/deno.json +++ b/deno.json @@ -39,6 +39,7 @@ "@std/path": "jsr:@std/path@^1.1.4", "@std/toml": "jsr:@std/toml@^1.0.11", "@types/mdast": "npm:@types/mdast@^4.0.4", + "fast-check": "npm:fast-check@^4.10.2", "mdast": "npm:@types/mdast@^4.0.4", "graphql": "npm:graphql@^16.13.2", "./__generated__/graphql": "./src/__codegen__/graphql.ts", diff --git a/deno.lock b/deno.lock index 32373cac..5179a165 100644 --- a/deno.lock +++ b/deno.lock @@ -46,6 +46,7 @@ "npm:@graphql-typed-document-node/core@^3.2.0": "3.2.0_graphql@16.13.2", "npm:@types/mdast@^4.0.4": "4.0.4", "npm:@types/node@*": "24.2.0", + "npm:fast-check@^4.10.2": "4.10.2", "npm:graphql-request@^7.4.0": "7.4.0_graphql@16.13.2", "npm:graphql@^16.13.2": "16.13.2", "npm:lefthook@^2.1.4": "2.1.4", @@ -1408,6 +1409,12 @@ "extend@3.0.2": { "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "fast-check@4.10.2": { + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dependencies": [ + "pure-rand" + ] + }, "fast-glob@3.3.3": { "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dependencies": [ @@ -2286,6 +2293,9 @@ "picomatch@2.3.2": { "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" }, + "pure-rand@8.4.2": { + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==" + }, "queue-microtask@1.2.3": { "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, @@ -2686,6 +2696,7 @@ "npm:@graphql-codegen/cli@^6.2.1", "npm:@graphql-typed-document-node/core@^3.2.0", "npm:@types/mdast@^4.0.4", + "npm:fast-check@^4.10.2", "npm:graphql-request@^7.4.0", "npm:graphql@^16.13.2", "npm:lefthook@^2.1.4", diff --git a/docs/usage.md b/docs/usage.md index 8d2fdfc5..b4edd9c1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -363,6 +363,19 @@ linear project update PROJECT-ID --initiative "Q4 Bets" --initiative "Platform" linear project list ``` +#### referring to things by URL + +Anywhere the CLI takes an issue, project, document, initiative, team, or cycle, you can paste the URL from Linear instead of its ID, slug, or name. + +```bash +linear issue view https://linear.app/acme/issue/ENG-123/some-title +linear project view https://linear.app/acme/project/mobile-launch-272f50ef9250 +linear issue query --project https://linear.app/acme/project/mobile-launch-272f50ef9250 +linear cycle view https://linear.app/acme/team/ENG/cycle/active +``` + +The scheme is optional, and query strings and title slugs are ignored. A URL for the wrong kind of thing, or from another workspace, is reported as such. + #### view project details Shows the project's overview body, milestones, resources, documents, attachments, related projects, latest status update, issue counts, and details. A project is a UUID, slug ID, or exact name. diff --git a/src/commands/cycle/cycle-view.ts b/src/commands/cycle/cycle-view.ts index 0f2d9024..ccff4bf2 100644 --- a/src/commands/cycle/cycle-view.ts +++ b/src/commands/cycle/cycle-view.ts @@ -14,6 +14,7 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" const GetCycleDetails = gql(` query GetCycleDetails($id: String!) { @@ -66,7 +67,15 @@ export const viewCommand = new Command() .option("-j, --json", "Output as JSON") .action(async ({ team, json }, cycleRef) => { try { - const teamKey = team || getTeamKey() + // A pasted cycle URL names its team. With no --team, that is the team + // meant — not whichever one happens to be configured. An explicit --team + // still wins, and the lookup refuses it if it contradicts the URL. + const urlTeamKey = expectLinearUrlKind( + cycleRef, + "cycle", + "a cycle URL, number, or name", + )?.teamKey + const teamKey = team || urlTeamKey || getTeamKey() if (!teamKey) { throw new ValidationError( "Could not determine team key from directory name or team flag", diff --git a/src/commands/document/attachment-target.ts b/src/commands/document/attachment-target.ts index d930c0c4..85bc1d4e 100644 --- a/src/commands/document/attachment-target.ts +++ b/src/commands/document/attachment-target.ts @@ -16,6 +16,7 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" // A Linear document is attached to exactly one target. The API enforces // "exactly one of initiativeId, teamId, issueId, releaseId, cycleId or @@ -120,7 +121,19 @@ const GetIssueForDocumentTarget = gql(/* GraphQL */ ` async function resolveIssueId(input: string): Promise { const client = getGraphQLClient() - const id = isLinearUuid(input) ? input : input.toUpperCase() + // `--issue` on document create/list/update comes through here rather than + // `getIssueIdentifier`, so a pasted issue URL has to be read here too — + // otherwise it is uppercased whole and sent to the API as an identifier. + const urlRef = expectLinearUrlKind( + input, + "issue", + "an issue URL, identifier like ENG-123, or UUID", + ) + const id = urlRef != null + ? urlRef.identifier + : isLinearUuid(input) + ? input + : input.toUpperCase() try { const result = await client.request(GetIssueForDocumentTarget, { id }) if (result.issue) { diff --git a/src/commands/document/document-comment-add.ts b/src/commands/document/document-comment-add.ts index de1cd8f2..dd6b588b 100644 --- a/src/commands/document/document-comment-add.ts +++ b/src/commands/document/document-comment-add.ts @@ -16,6 +16,7 @@ import { REPLY_TO_DESCRIPTION, resolveCommentBody, } from "../../utils/comments.ts" +import { resolveDocumentReference } from "../../utils/linear.ts" // A document comment attaches to the document's content record, not to the // document itself, so look that id up first. `document(id:)` accepts a UUID or @@ -39,10 +40,13 @@ export const commentAddCommand = new Command() .option("-b, --body ", COMMENT_BODY_DESCRIPTION) .option("--body-file ", COMMENT_BODY_FILE_DESCRIPTION) .option("-p, --parent, --reply-to ", REPLY_TO_DESCRIPTION) - .action(async (options, document) => { + .action(async (options, rawDocument) => { const { body, bodyFile, parent } = options try { + // Inside the try: resolution rejects a wrong-kind or cross-workspace URL, + // and those errors have to reach handleError like every other failure. + const document = resolveDocumentReference(rawDocument) const textBody = await resolveCommentBody({ body, bodyFile }) const client = getGraphQLClient() diff --git a/src/commands/document/document-comment-list.ts b/src/commands/document/document-comment-list.ts index 56928182..89f71ad8 100644 --- a/src/commands/document/document-comment-list.ts +++ b/src/commands/document/document-comment-list.ts @@ -10,6 +10,7 @@ import { collectCommentPages, renderCommentThreads, } from "../../utils/comments.ts" +import { resolveDocumentReference } from "../../utils/linear.ts" // `document(id:)` accepts a UUID or a slug ID, so no resolver is needed. const GetDocumentComments = gql(` @@ -34,10 +35,13 @@ export const commentListCommand = new Command() .description("List comments on a document (by ID or slug)") .arguments("") .option("-j, --json", "Output as JSON") - .action(async (options, document) => { + .action(async (options, rawDocument) => { const { json } = options try { + // Inside the try: resolution rejects a wrong-kind or cross-workspace URL, + // and those errors have to reach handleError like every other failure. + const document = resolveDocumentReference(rawDocument) const client = getGraphQLClient() const comments = await collectCommentPages(async (after) => { const data = await translateNotFound( diff --git a/src/commands/document/document-delete.ts b/src/commands/document/document-delete.ts index e28b8e75..7bb1fadf 100644 --- a/src/commands/document/document-delete.ts +++ b/src/commands/document/document-delete.ts @@ -15,6 +15,7 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { resolveDocumentReference } from "../../utils/linear.ts" interface DocumentDeleteResult extends BulkOperationResult { title?: string @@ -71,7 +72,7 @@ export const deleteCommand = new Command() async function handleSingleDelete( // deno-lint-ignore no-explicit-any client: any, - documentId: string, + rawDocumentId: string, options: { yes?: boolean }, ): Promise { const { yes } = options @@ -87,10 +88,11 @@ async function handleSingleDelete( } `) + const documentId = resolveDocumentReference(rawDocumentId) const documentDetails = await client.request(detailsQuery, { id: documentId }) if (!documentDetails?.document) { - throw new NotFoundError("Document", documentId) + throw new NotFoundError("Document", rawDocumentId) } const document = documentDetails.document @@ -189,11 +191,14 @@ async function handleBulkDelete( } `) - let documentUuid = docId + const resolvedDocId = resolveDocumentReference(docId) + let documentUuid = resolvedDocId let title = docId try { - const details = await client.request(detailsQuery, { id: docId }) + const details = await client.request(detailsQuery, { + id: resolvedDocId, + }) if (details?.document) { documentUuid = details.document.id title = details.document.title diff --git a/src/commands/document/document-update.ts b/src/commands/document/document-update.ts index 4d8a8d86..b175556c 100644 --- a/src/commands/document/document-update.ts +++ b/src/commands/document/document-update.ts @@ -20,6 +20,7 @@ import { toDocumentTargetInput, } from "./attachment-target.ts" import { withMarkdownHint } from "../../utils/markdown-help.ts" +import { resolveDocumentReference } from "../../utils/linear.ts" const GetDocumentForEdit = gql(` query GetDocumentForEdit($id: String!) { @@ -233,9 +234,10 @@ export const updateCommand = new Command() edit, force, }, - documentId, + rawDocumentId, ) => { try { + const documentId = resolveDocumentReference(rawDocumentId) const targetOptions: DocumentTargetOptions = { project, issue, diff --git a/src/commands/document/document-view.ts b/src/commands/document/document-view.ts index b5ab4551..be1210f9 100644 --- a/src/commands/document/document-view.ts +++ b/src/commands/document/document-view.ts @@ -16,6 +16,7 @@ import { isNotFoundError, NotFoundError, } from "../../utils/errors.ts" +import { resolveDocumentReference } from "../../utils/linear.ts" const GetDocument = gql(` query GetDocument($id: String!) { @@ -174,13 +175,14 @@ export const viewCommand = new Command() .option("-w, --web", "Open document in browser") .option("--json", "Output full document as JSON") .option("--no-download", "Keep remote URLs instead of downloading files") - .action(async ({ raw, web, json, download }, id) => { + .action(async ({ raw, web, json, download }, rawId) => { const { Spinner } = await import("@std/cli/unstable-spinner") const showSpinner = shouldShowSpinner() && !raw && !json const spinner = showSpinner ? new Spinner() : null - spinner?.start() try { + const id = resolveDocumentReference(rawId) + spinner?.start() const client = getGraphQLClient() const result = json ? { document: await getDocumentWithAllComments(client, id) } @@ -293,7 +295,7 @@ export const viewCommand = new Command() // Report through handleError like every other failure; throwing from // here would escape the action and print a stack trace instead. const reported = isClientError(error) && isNotFoundError(error) - ? new NotFoundError("Document", id) + ? new NotFoundError("Document", rawId) : error handleError(reported, "Failed to view document") } diff --git a/src/commands/initiative-update/initiative-update-create.ts b/src/commands/initiative-update/initiative-update-create.ts index 00a2c493..e577118d 100644 --- a/src/commands/initiative-update/initiative-update-create.ts +++ b/src/commands/initiative-update/initiative-update-create.ts @@ -12,6 +12,8 @@ import { import { getGraphQLClient } from "../../utils/graphql.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { withMarkdownHint } from "../../utils/markdown-help.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" +import { findInitiativeIdBySlug } from "../../utils/linear.ts" const HEALTH_VALUES = ["onTrack", "atRisk", "offTrack"] as const type HealthValue = (typeof HEALTH_VALUES)[number] @@ -43,6 +45,19 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } + // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/initiative-update/initiative-update-list.ts b/src/commands/initiative-update/initiative-update-list.ts index 9233a9e3..df4e8471 100644 --- a/src/commands/initiative-update/initiative-update-list.ts +++ b/src/commands/initiative-update/initiative-update-list.ts @@ -8,6 +8,8 @@ import { import { handleError, NotFoundError } from "../../utils/errors.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" +import { findInitiativeIdBySlug } from "../../utils/linear.ts" /** * Resolve initiative ID from UUID, slug, or name @@ -17,6 +19,19 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } + // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/initiative/initiative-add-project.ts b/src/commands/initiative/initiative-add-project.ts index d72031ce..af77500e 100644 --- a/src/commands/initiative/initiative-add-project.ts +++ b/src/commands/initiative/initiative-add-project.ts @@ -3,6 +3,11 @@ import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { CliError, handleError, NotFoundError } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" +import { + findInitiativeIdBySlug, + findProjectIdBySlug, +} from "../../utils/linear.ts" const AddProjectToInitiative = gql(` mutation AddProjectToInitiative($input: InitiativeToProjectCreateInput!) { @@ -20,6 +25,18 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise<{ id: string; name: string } | undefined> { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( @@ -99,6 +116,18 @@ async function resolveProjectId( client: any, idOrSlugOrName: string, ): Promise<{ id: string; name: string } | undefined> { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "project", + "a project URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findProjectIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another project could win. + idOrSlugOrName = fromUrl + } // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/initiative/initiative-archive.ts b/src/commands/initiative/initiative-archive.ts index 3a893750..737196ff 100644 --- a/src/commands/initiative/initiative-archive.ts +++ b/src/commands/initiative/initiative-archive.ts @@ -16,6 +16,8 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" +import { findInitiativeIdBySlug } from "../../utils/linear.ts" interface InitiativeArchiveResult extends BulkOperationResult { name: string @@ -299,6 +301,19 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } + // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/initiative/initiative-delete.ts b/src/commands/initiative/initiative-delete.ts index 9c3e24d8..c9a0222c 100644 --- a/src/commands/initiative/initiative-delete.ts +++ b/src/commands/initiative/initiative-delete.ts @@ -16,6 +16,8 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" +import { findInitiativeIdBySlug } from "../../utils/linear.ts" interface InitiativeDeleteResult extends BulkOperationResult { name: string @@ -307,6 +309,21 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId, { + includeArchived: true, + }) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } + // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/initiative/initiative-remove-project.ts b/src/commands/initiative/initiative-remove-project.ts index 72081c4b..c582f1ca 100644 --- a/src/commands/initiative/initiative-remove-project.ts +++ b/src/commands/initiative/initiative-remove-project.ts @@ -9,6 +9,11 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" +import { + findInitiativeIdBySlug, + findProjectIdBySlug, +} from "../../utils/linear.ts" const GetInitiativeToProjects = gql(` query GetInitiativeToProjects($first: Int) { @@ -39,6 +44,18 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise<{ id: string; name: string } | undefined> { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( @@ -118,6 +135,18 @@ async function resolveProjectId( client: any, idOrSlugOrName: string, ): Promise<{ id: string; name: string } | undefined> { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "project", + "a project URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findProjectIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another project could win. + idOrSlugOrName = fromUrl + } // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/initiative/initiative-unarchive.ts b/src/commands/initiative/initiative-unarchive.ts index 3250e92b..b5e84942 100644 --- a/src/commands/initiative/initiative-unarchive.ts +++ b/src/commands/initiative/initiative-unarchive.ts @@ -9,6 +9,8 @@ import { NotFoundError, ValidationError, } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" +import { findInitiativeIdBySlug } from "../../utils/linear.ts" export const unarchiveCommand = new Command() .name("unarchive") @@ -124,6 +126,21 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId, { + includeArchived: true, + }) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } + // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/initiative/initiative-update.ts b/src/commands/initiative/initiative-update.ts index dd156c7e..e47a8807 100644 --- a/src/commands/initiative/initiative-update.ts +++ b/src/commands/initiative/initiative-update.ts @@ -2,9 +2,10 @@ import { Command } from "@cliffy/command" import { Input, Select } from "@cliffy/prompt" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" -import { lookupUserId } from "../../utils/linear.ts" +import { findInitiativeIdBySlug, lookupUserId } from "../../utils/linear.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { CliError, handleError, NotFoundError } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" // Initiative status options from Linear API const INITIATIVE_STATUSES = [ @@ -229,6 +230,19 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } + // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/initiative/initiative-view.ts b/src/commands/initiative/initiative-view.ts index 9d44b0e8..5f713d80 100644 --- a/src/commands/initiative/initiative-view.ts +++ b/src/commands/initiative/initiative-view.ts @@ -6,6 +6,8 @@ import { getGraphQLClient } from "../../utils/graphql.ts" import { formatRelativeTime } from "../../utils/display.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { handleError, NotFoundError } from "../../utils/errors.ts" +import { expectLinearUrlKind } from "../../utils/linear-url.ts" +import { findInitiativeIdBySlug } from "../../utils/linear.ts" const GetInitiativeDetails = gql(` query GetInitiativeDetails($id: String!) { @@ -254,6 +256,19 @@ async function resolveInitiativeId( client: any, idOrSlugOrName: string, ): Promise { + const urlRef = expectLinearUrlKind( + idOrSlugOrName, + "initiative", + "an initiative URL, UUID, slug ID, or exact name", + ) + if (urlRef != null) { + const fromUrl = await findInitiativeIdBySlug(urlRef.slugId) + if (fromUrl == null) return undefined + // Now a UUID, so the UUID branch below takes it and a URL never + // falls through to a name lookup another initiative could win. + idOrSlugOrName = fromUrl + } + // Try as UUID first if ( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( diff --git a/src/commands/issue/issue-agent-session-view.ts b/src/commands/issue/issue-agent-session-view.ts index db54e3de..04cb7dc6 100644 --- a/src/commands/issue/issue-agent-session-view.ts +++ b/src/commands/issue/issue-agent-session-view.ts @@ -5,6 +5,7 @@ import { getGraphQLClient } from "../../utils/graphql.ts" import { formatRelativeTime } from "../../utils/display.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { handleError, NotFoundError } from "../../utils/errors.ts" +import { rejectLinearUrl } from "../../utils/linear-url.ts" const GetAgentSessionDetails = gql(` query GetAgentSessionDetails($id: String!) { @@ -79,6 +80,7 @@ export const agentSessionViewCommand = new Command() .option("-j, --json", "Output as JSON") .action(async ({ json }, sessionId) => { try { + rejectLinearUrl(sessionId, "an agent session ID") const { Spinner } = await import("@std/cli/unstable-spinner") const showSpinner = shouldShowSpinner() && !json const spinner = showSpinner ? new Spinner() : null diff --git a/src/commands/issue/issue-comment-delete.ts b/src/commands/issue/issue-comment-delete.ts index 4932fb68..1711c664 100644 --- a/src/commands/issue/issue-comment-delete.ts +++ b/src/commands/issue/issue-comment-delete.ts @@ -2,6 +2,7 @@ import { Command } from "@cliffy/command" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { CliError, handleError } from "../../utils/errors.ts" +import { rejectCommentUrl, rejectLinearUrl } from "../../utils/linear-url.ts" export const commentDeleteCommand = new Command() .name("delete") @@ -9,6 +10,8 @@ export const commentDeleteCommand = new Command() .arguments("") .action(async (_options, commentId) => { try { + rejectCommentUrl(commentId) + rejectLinearUrl(commentId, "a comment UUID") const mutation = gql(` mutation DeleteComment($id: String!) { commentDelete(id: $id) { diff --git a/src/commands/issue/issue-comment-update.ts b/src/commands/issue/issue-comment-update.ts index d4779a5d..334a4ed6 100644 --- a/src/commands/issue/issue-comment-update.ts +++ b/src/commands/issue/issue-comment-update.ts @@ -4,6 +4,7 @@ import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { CliError, handleError, ValidationError } from "../../utils/errors.ts" import { withMarkdownHint } from "../../utils/markdown-help.ts" +import { rejectCommentUrl, rejectLinearUrl } from "../../utils/linear-url.ts" export const commentUpdateCommand = new Command() .name("update") @@ -18,6 +19,8 @@ export const commentUpdateCommand = new Command() const { body, bodyFile } = options try { + rejectCommentUrl(commentId) + rejectLinearUrl(commentId, "a comment UUID") // Validate that body and bodyFile are not both provided if (body && bodyFile) { throw new ValidationError( diff --git a/src/commands/label/label-delete.ts b/src/commands/label/label-delete.ts index 25a9174b..c2c6abc7 100644 --- a/src/commands/label/label-delete.ts +++ b/src/commands/label/label-delete.ts @@ -4,6 +4,7 @@ import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getTeamKey, resolveTeam } from "../../utils/linear.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" +import { rejectLinearUrl } from "../../utils/linear-url.ts" import { CliError, handleError, @@ -66,6 +67,7 @@ async function resolveLabelId( nameOrId: string, teamKey?: string, ): Promise