Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions skills/linear-cli/references/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand Down Expand Up @@ -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)
Expand Down
118 changes: 118 additions & 0 deletions src/commands/issue/issue-link.ts
Original file line number Diff line number Diff line change
@@ -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("<urlOrIssueId:string> [url:string]")
.option("-t, --title <title:string>", "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")
}
})
2 changes: 2 additions & 0 deletions src/commands/issue/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -34,4 +35,5 @@ export const issueCommand = new Command()
.command("update", updateCommand)
.command("comment", commentCommand)
.command("attach", attachCommand)
.command("link", linkCommand)
.command("relation", relationCommand)
59 changes: 59 additions & 0 deletions test/commands/issue/__snapshots__/issue-link.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export const snapshot = {};

snapshot[`Issue Link Command - Help Text 1`] = `
stdout:
'
Usage: link <urlOrIssueId> [url]

Description:

Link a URL to an issue

Options:

-h, --help - Show this help.
-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"

'
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
"
`;
151 changes: 151 additions & 0 deletions test/commands/issue/issue-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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()
}
},
})
Loading