diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c21cda9..8c786cca1 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 ca3aeef80..8f9e7e3cf 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 ec06322e4..a216ded0c 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 32373caca..5179a1652 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 8d2fdfc56..b4edd9c19 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 0f2d9024e..ccff4bf2b 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 d930c0c44..85bc1d4ed 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 de1cd8f2a..dd6b588b7 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 56928182f..89f71ad83 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 e28b8e756..7bb1fadfb 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 4d8a8d868..b175556cf 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 b5ab4551c..be1210f98 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 00a2c493f..e577118d6 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 9233a9e30..df4e8471a 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 d72031cec..af77500e5 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 3a893750b..737196ff6 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 9c3e24d88..c9a0222c0 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 72081c4b2..c582f1cae 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 3250e92bf..b5e849422 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 dd156c7ed..e47a88072 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 9d44b0e82..5f713d801 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 db54e3dee..04cb7dc67 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 4932fb68e..1711c6646 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 d4779a5d3..334a4ed6d 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 25a9174bb..c2c6abc76 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