From 8c12cf8d682e00727e55558d348a996758258453 Mon Sep 17 00:00:00 2001 From: Luc Leray Date: Thu, 19 Mar 2026 10:43:35 +0100 Subject: [PATCH 1/3] feat: add issue link command to attach URLs to issues --- src/commands/issue/issue-link.ts | 118 ++++++++++++++ src/commands/issue/issue.ts | 2 + .../__snapshots__/issue-link.test.ts.snap | 59 +++++++ test/commands/issue/issue-link.test.ts | 148 ++++++++++++++++++ 4 files changed, 327 insertions(+) create mode 100644 src/commands/issue/issue-link.ts create mode 100644 test/commands/issue/__snapshots__/issue-link.test.ts.snap create mode 100644 test/commands/issue/issue-link.test.ts diff --git a/src/commands/issue/issue-link.ts b/src/commands/issue/issue-link.ts new file mode 100644 index 00000000..c97bf5cd --- /dev/null +++ b/src/commands/issue/issue-link.ts @@ -0,0 +1,118 @@ +import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" +import { getGraphQLClient } from "../../utils/graphql.ts" +import { getIssueId, getIssueIdentifier } from "../../utils/linear.ts" +import { + CliError, + handleError, + isClientError, + isNotFoundError, + NotFoundError, + ValidationError, +} from "../../utils/errors.ts" + +function looksLikeUrl(value: string): boolean { + return value.startsWith("http://") || value.startsWith("https://") +} + +export const linkCommand = new Command() + .name("link") + .description("Link a URL to an issue") + .arguments(" [url:string]") + .option("-t, --title ", "Custom title for the link") + .example( + "Link a URL to issue detected from branch", + "linear issue link https://github.com/org/repo/pull/123", + ) + .example( + "Link a URL to a specific issue", + "linear issue link ENG-123 https://github.com/org/repo/pull/123", + ) + .example( + "Link with a custom title", + 'linear issue link ENG-123 https://example.com --title "Design doc"', + ) + .action(async (options, urlOrIssueId, url) => { + const { title } = options + + try { + let issueIdInput: string | undefined + let linkUrl: string + + if (url != null) { + // Two args: first is issue ID, second is URL + issueIdInput = urlOrIssueId + linkUrl = url + } else if (looksLikeUrl(urlOrIssueId)) { + // One arg that looks like a URL: auto-detect issue from branch + issueIdInput = undefined + linkUrl = urlOrIssueId + } else { + throw new ValidationError( + `Expected a URL but got '${urlOrIssueId}'`, + { suggestion: "Provide a URL starting with http:// or https://." }, + ) + } + + if (!looksLikeUrl(linkUrl)) { + throw new ValidationError( + `Invalid URL: '${linkUrl}'`, + { suggestion: "Provide a URL starting with http:// or https://." }, + ) + } + + const resolvedIdentifier = await getIssueIdentifier(issueIdInput) + if (!resolvedIdentifier) { + throw new ValidationError( + "Could not determine issue ID", + { + suggestion: + "Please provide an issue ID like 'ENG-123', or run from a branch that contains an issue identifier.", + }, + ) + } + + // attachmentLinkURL needs a UUID + let issueUuid: string | undefined + try { + issueUuid = await getIssueId(resolvedIdentifier) + } catch (error) { + if (isClientError(error) && isNotFoundError(error)) { + throw new NotFoundError("Issue", resolvedIdentifier) + } + throw error + } + if (!issueUuid) { + throw new NotFoundError("Issue", resolvedIdentifier) + } + + const mutation = gql(` + mutation AttachmentLinkURL($issueId: String!, $url: String!, $title: String) { + attachmentLinkURL(issueId: $issueId, url: $url, title: $title) { + success + attachment { + id + title + url + } + } + } + `) + + const client = getGraphQLClient() + const data = await client.request(mutation, { + issueId: issueUuid, + url: linkUrl, + title, + }) + + if (!data.attachmentLinkURL.success) { + throw new CliError("Failed to link URL to issue") + } + + const attachment = data.attachmentLinkURL.attachment + console.log(`✓ Linked to ${resolvedIdentifier}: ${attachment.title}`) + } catch (error) { + handleError(error, "Failed to link URL") + } + }) diff --git a/src/commands/issue/issue.ts b/src/commands/issue/issue.ts index 6a1b7e9a..56b9f517 100644 --- a/src/commands/issue/issue.ts +++ b/src/commands/issue/issue.ts @@ -6,6 +6,7 @@ import { deleteCommand } from "./issue-delete.ts" import { describeCommand } from "./issue-describe.ts" import { commitsCommand } from "./issue-commits.ts" import { idCommand } from "./issue-id.ts" +import { linkCommand } from "./issue-link.ts" import { listCommand } from "./issue-list.ts" import { pullRequestCommand } from "./issue-pull-request.ts" import { relationCommand } from "./issue-relation.ts" @@ -34,4 +35,5 @@ export const issueCommand = new Command() .command("update", updateCommand) .command("comment", commentCommand) .command("attach", attachCommand) + .command("link", linkCommand) .command("relation", relationCommand) diff --git a/test/commands/issue/__snapshots__/issue-link.test.ts.snap b/test/commands/issue/__snapshots__/issue-link.test.ts.snap new file mode 100644 index 00000000..96b772ff --- /dev/null +++ b/test/commands/issue/__snapshots__/issue-link.test.ts.snap @@ -0,0 +1,59 @@ +export const snapshot = {}; + +snapshot[`Issue Link Command - Help Text 1`] = ` +stdout: +' +Usage: link [url] + +Description: + + Link a URL to an issue + +Options: + + -h, --help - Show this help. + -t, --title - Custom title for the link + +Examples: + + Link a URL to issue detected from branch linear issue link https://github.com/org/repo/pull/123 + Link a URL to a specific issue linear issue link ENG-123 https://github.com/org/repo/pull/123 + Link with a custom title linear issue link ENG-123 https://example.com --title "Design doc" + +' +stderr: +"" +`; + +snapshot[`Issue Link Command - link URL to issue 1`] = ` +stdout: +"✓ Linked to ENG-123: org/repo#42 +" +stderr: +"" +`; + +snapshot[`Issue Link Command - link URL with custom title 1`] = ` +stdout: +"✓ Linked to ENG-456: Design document +" +stderr: +"" +`; + +snapshot[`Issue Link Command - invalid URL shows error 1`] = ` +stdout: +"" +stderr: +"✗ Failed to link URL: Expected a URL but got 'not-a-url' + Provide a URL starting with http:// or https://. +" +`; + +snapshot[`Issue Link Command - issue not found 1`] = ` +stdout: +"" +stderr: +"✗ Failed to link URL: Issue not found: ENG-999 +" +`; diff --git a/test/commands/issue/issue-link.test.ts b/test/commands/issue/issue-link.test.ts new file mode 100644 index 00000000..c9846446 --- /dev/null +++ b/test/commands/issue/issue-link.test.ts @@ -0,0 +1,148 @@ +import { snapshotTest } from "@cliffy/testing" +import { linkCommand } from "../../../src/commands/issue/issue-link.ts" +import { + commonDenoArgs, + setupMockLinearServer, +} from "../../utils/test-helpers.ts" + +// Test help output +await snapshotTest({ + name: "Issue Link Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs: commonDenoArgs, + async fn() { + await linkCommand.parse() + }, +}) + +// Test: link a URL to a specific issue +await snapshotTest({ + name: "Issue Link Command - link URL to issue", + meta: import.meta, + colors: false, + args: ["ENG-123", "https://github.com/org/repo/pull/42"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssueId", + variables: { id: "ENG-123" }, + response: { + data: { issue: { id: "issue-uuid-123" } }, + }, + }, + { + queryName: "AttachmentLinkURL", + response: { + data: { + attachmentLinkURL: { + success: true, + attachment: { + id: "attachment-id-1", + title: "org/repo#42", + url: "https://github.com/org/repo/pull/42", + }, + }, + }, + }, + }, + ]) + + try { + await linkCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// Test: link a URL with custom title +await snapshotTest({ + name: "Issue Link Command - link URL with custom title", + meta: import.meta, + colors: false, + args: [ + "ENG-456", + "https://example.com/doc", + "--title", + "Design document", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssueId", + variables: { id: "ENG-456" }, + response: { + data: { issue: { id: "issue-uuid-456" } }, + }, + }, + { + queryName: "AttachmentLinkURL", + response: { + data: { + attachmentLinkURL: { + success: true, + attachment: { + id: "attachment-id-2", + title: "Design document", + url: "https://example.com/doc", + }, + }, + }, + }, + }, + ]) + + try { + await linkCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// Test: URL only (no issue ID) with invalid URL should error +await snapshotTest({ + name: "Issue Link Command - invalid URL shows error", + meta: import.meta, + colors: false, + canFail: true, + args: ["not-a-url"], + denoArgs: commonDenoArgs, + async fn() { + await linkCommand.parse() + }, +}) + +// Test: issue not found +await snapshotTest({ + name: "Issue Link Command - issue not found", + meta: import.meta, + colors: false, + canFail: true, + args: ["ENG-999", "https://example.com"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssueId", + variables: { id: "ENG-999" }, + response: { + errors: [{ + message: "Entity not found", + extensions: { type: "entity", userPresentableMessage: "Entity not found" }, + }], + }, + }, + ]) + + try { + await linkCommand.parse() + } finally { + await cleanup() + } + }, +}) From 4687d383791e03f53a10f0b2888eac2ab7187fbf Mon Sep 17 00:00:00 2001 From: Luc Leray <luc.leray@gmail.com> Date: Thu, 19 Mar 2026 10:47:29 +0100 Subject: [PATCH 2/3] style: fix deno fmt formatting in test --- test/commands/issue/issue-link.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/commands/issue/issue-link.test.ts b/test/commands/issue/issue-link.test.ts index c9846446..6e9bdaf0 100644 --- a/test/commands/issue/issue-link.test.ts +++ b/test/commands/issue/issue-link.test.ts @@ -133,7 +133,10 @@ await snapshotTest({ response: { errors: [{ message: "Entity not found", - extensions: { type: "entity", userPresentableMessage: "Entity not found" }, + extensions: { + type: "entity", + userPresentableMessage: "Entity not found", + }, }], }, }, From 9a96a7839fcf5652fac6a8b040e284d0126111d7 Mon Sep 17 00:00:00 2001 From: Luc Leray <luc.leray@gmail.com> Date: Thu, 19 Mar 2026 10:59:04 +0100 Subject: [PATCH 3/3] regenerate skill docs for issue link command --- skills/linear-cli/references/issue.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 12de728d..16f8bc03 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -32,6 +32,7 @@ Commands: update [issueId] - Update a linear issue comment - Manage issue comments attach <issueId> <filepath> - Attach a file to an issue + link <urlOrIssueId> [url] - Link a URL to an issue relation - Manage issue relations (dependencies) ``` @@ -419,6 +420,30 @@ Options: -c, --comment <body> - Add a comment body linked to the attachment ``` +### link + +> Link a URL to an issue + +``` +Usage: linear issue link <urlOrIssueId> [url] + +Description: + + Link a URL to an issue + +Options: + + -h, --help - Show this help. + -w, --workspace <slug> - Target workspace (uses credentials) + -t, --title <title> - Custom title for the link + +Examples: + + Link a URL to issue detected from branch linear issue link https://github.com/org/repo/pull/123 + Link a URL to a specific issue linear issue link ENG-123 https://github.com/org/repo/pull/123 + Link with a custom title linear issue link ENG-123 https://example.com --title "Design doc" +``` + ### relation > Manage issue relations (dependencies)