Skip to content

Commit 30332c9

Browse files
author
Alex (datable AI)
committed
feat: add issue search command (schpet#143)
- Add `linear issue search <query>` command for semantic search - Uses Linear's issueSearch GraphQL API - Supports --limit and --include-archived options - Displays results in a table with ID, priority, title, state, and updated time - Add snapshot tests for search command
1 parent e584e7e commit 30332c9

4 files changed

Lines changed: 286 additions & 0 deletions

File tree

src/commands/issue/issue-search.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { Command } from "@cliffy/command"
2+
import { unicodeWidth } from "@std/cli"
3+
import { rgb24 } from "@std/fmt/colors"
4+
import { gql } from "../../__codegen__/gql.ts"
5+
import { getGraphQLClient } from "../../utils/graphql.ts"
6+
import {
7+
getPriorityDisplay,
8+
getTimeAgo,
9+
padDisplay,
10+
truncateText,
11+
} from "../../utils/display.ts"
12+
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
13+
import { header, muted } from "../../utils/styling.ts"
14+
import { handleError, ValidationError } from "../../utils/errors.ts"
15+
16+
const IssueSearchQuery = gql(`
17+
query IssueSearch($query: String!, $first: Int, $includeArchived: Boolean) {
18+
issueSearch(
19+
query: $query
20+
first: $first
21+
includeArchived: $includeArchived
22+
) {
23+
nodes {
24+
id
25+
identifier
26+
title
27+
priority
28+
state {
29+
name
30+
color
31+
}
32+
assignee {
33+
displayName
34+
}
35+
updatedAt
36+
}
37+
}
38+
}
39+
`)
40+
41+
export const searchCommand = new Command()
42+
.name("search")
43+
.description("Search for issues using semantic search")
44+
.arguments("<query:string>")
45+
.option("-n, --limit <limit:number>", "Maximum number of results", {
46+
default: 20,
47+
})
48+
.option("-a, --include-archived", "Include archived issues in results")
49+
.action(async ({ limit, includeArchived }, query) => {
50+
try {
51+
if (!query || query.trim().length === 0) {
52+
throw new ValidationError("Search query cannot be empty")
53+
}
54+
55+
const { Spinner } = await import("@std/cli/unstable-spinner")
56+
const showSpinner = shouldShowSpinner()
57+
const spinner = showSpinner ? new Spinner() : null
58+
spinner?.start()
59+
60+
const client = getGraphQLClient()
61+
const result = await client.request(IssueSearchQuery, {
62+
query: query.trim(),
63+
first: limit,
64+
includeArchived: includeArchived ?? false,
65+
})
66+
67+
spinner?.stop()
68+
69+
const issues = result.issueSearch.nodes
70+
71+
if (issues.length === 0) {
72+
console.log(`No issues found matching "${query}"`)
73+
return
74+
}
75+
76+
// Calculate column widths
77+
const { columns } = Deno.stdout.isTerminal()
78+
? Deno.consoleSize()
79+
: { columns: 120 }
80+
81+
const ID_WIDTH = Math.max(
82+
2,
83+
...issues.map((i) => unicodeWidth(i.identifier)),
84+
)
85+
const PRI_WIDTH = 2
86+
const STATE_WIDTH = Math.max(
87+
5,
88+
...issues.map((i) => unicodeWidth(i.state.name)),
89+
)
90+
const AGO_WIDTH = 10
91+
const SPACE_WIDTH = 4
92+
93+
const fixed = ID_WIDTH + PRI_WIDTH + STATE_WIDTH + AGO_WIDTH + SPACE_WIDTH
94+
const PADDING = 1
95+
const maxTitleWidth = Math.max(
96+
5,
97+
...issues.map((i) => unicodeWidth(i.title)),
98+
)
99+
const availableWidth = Math.max(columns - PADDING - fixed, 0)
100+
const titleWidth = Math.min(maxTitleWidth, availableWidth)
101+
102+
// Print header
103+
const headerCells = [
104+
padDisplay("ID", ID_WIDTH),
105+
padDisplay("P", PRI_WIDTH),
106+
padDisplay("TITLE", titleWidth),
107+
padDisplay("STATE", STATE_WIDTH),
108+
padDisplay("UPDATED", AGO_WIDTH),
109+
]
110+
console.log(header(headerCells.join(" ")))
111+
112+
// Print issues
113+
for (const issue of issues) {
114+
const id = padDisplay(issue.identifier, ID_WIDTH)
115+
const pri = getPriorityDisplay(issue.priority)
116+
const title = truncateText(issue.title, titleWidth)
117+
const paddedTitle = padDisplay(title, titleWidth)
118+
const state = padDisplay(issue.state.name, STATE_WIDTH)
119+
const stateColored = rgb24(state, parseInt(issue.state.color.slice(1), 16))
120+
const ago = muted(padDisplay(getTimeAgo(new Date(issue.updatedAt)), AGO_WIDTH))
121+
122+
console.log(`${id} ${pri} ${paddedTitle} ${stateColored} ${ago}`)
123+
}
124+
125+
console.log("")
126+
console.log(muted(`Found ${issues.length} issue${issues.length === 1 ? "" : "s"}`))
127+
} catch (error) {
128+
handleError(error, "Failed to search issues")
129+
}
130+
})

src/commands/issue/issue.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { idCommand } from "./issue-id.ts"
99
import { listCommand } from "./issue-list.ts"
1010
import { pullRequestCommand } from "./issue-pull-request.ts"
1111
import { relationCommand } from "./issue-relation.ts"
12+
import { searchCommand } from "./issue-search.ts"
1213
import { startCommand } from "./issue-start.ts"
1314
import { titleCommand } from "./issue-title.ts"
1415
import { updateCommand } from "./issue-update.ts"
@@ -22,6 +23,7 @@ export const issueCommand = new Command()
2223
})
2324
.command("id", idCommand)
2425
.command("list", listCommand)
26+
.command("search", searchCommand)
2527
.command("title", titleCommand)
2628
.command("start", startCommand)
2729
.command("view", viewCommand)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export const snapshot = {};
2+
3+
snapshot[`Issue Search Command - Help Text 1`] = `
4+
stdout:
5+
"
6+
Usage: search <query>
7+
8+
Description:
9+
10+
Search for issues using semantic search
11+
12+
Options:
13+
14+
-h, --help - Show this help.
15+
-n, --limit <limit> - Maximum number of results (Default: \\x1b[33m20\\x1b[39m)
16+
-a, --include-archived - Include archived issues in results
17+
18+
"
19+
stderr:
20+
""
21+
`;
22+
23+
snapshot[`Issue Search Command - Returns Results 1`] = `
24+
stdout:
25+
"ID P TITLE STATE UPDATED
26+
ENG-123 ▄▆█ Fix login bug In Progress 55 days ago
27+
ENG-456 ▄▆ Update login page Todo 56 days ago
28+
29+
Found 2 issues
30+
"
31+
stderr:
32+
""
33+
`;
34+
35+
snapshot[`Issue Search Command - No Results 1`] = `
36+
stdout:
37+
'No issues found matching "nonexistent-query-xyz"
38+
'
39+
stderr:
40+
""
41+
`;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { snapshotTest } from "@cliffy/testing"
2+
import { searchCommand } from "../../../src/commands/issue/issue-search.ts"
3+
import { MockLinearServer } from "../../utils/mock_linear_server.ts"
4+
import { commonDenoArgs } from "../../utils/test-helpers.ts"
5+
6+
// Test help output
7+
await snapshotTest({
8+
name: "Issue Search Command - Help Text",
9+
meta: import.meta,
10+
colors: false,
11+
args: ["--help"],
12+
denoArgs: commonDenoArgs,
13+
async fn() {
14+
await searchCommand.parse()
15+
},
16+
})
17+
18+
// Test search with results
19+
await snapshotTest({
20+
name: "Issue Search Command - Returns Results",
21+
meta: import.meta,
22+
colors: false,
23+
args: ["login bug"],
24+
denoArgs: commonDenoArgs,
25+
async fn() {
26+
const server = new MockLinearServer([
27+
{
28+
queryName: "IssueSearch",
29+
response: {
30+
data: {
31+
issueSearch: {
32+
nodes: [
33+
{
34+
id: "issue-1",
35+
identifier: "ENG-123",
36+
title: "Fix login bug",
37+
priority: 2,
38+
state: {
39+
name: "In Progress",
40+
color: "#0066ff",
41+
},
42+
assignee: {
43+
displayName: "Alice",
44+
},
45+
updatedAt: "2026-01-15T08:00:00Z",
46+
},
47+
{
48+
id: "issue-2",
49+
identifier: "ENG-456",
50+
title: "Update login page",
51+
priority: 3,
52+
state: {
53+
name: "Todo",
54+
color: "#888888",
55+
},
56+
assignee: null,
57+
updatedAt: "2026-01-14T10:30:00Z",
58+
},
59+
],
60+
},
61+
},
62+
},
63+
},
64+
])
65+
66+
try {
67+
await server.start()
68+
Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint())
69+
Deno.env.set("LINEAR_API_KEY", "Bearer test-token")
70+
71+
await searchCommand.parse()
72+
} finally {
73+
await server.stop()
74+
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
75+
Deno.env.delete("LINEAR_API_KEY")
76+
}
77+
},
78+
})
79+
80+
// Test search with no results
81+
await snapshotTest({
82+
name: "Issue Search Command - No Results",
83+
meta: import.meta,
84+
colors: false,
85+
args: ["nonexistent-query-xyz"],
86+
denoArgs: commonDenoArgs,
87+
async fn() {
88+
const server = new MockLinearServer([
89+
{
90+
queryName: "IssueSearch",
91+
response: {
92+
data: {
93+
issueSearch: {
94+
nodes: [],
95+
},
96+
},
97+
},
98+
},
99+
])
100+
101+
try {
102+
await server.start()
103+
Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint())
104+
Deno.env.set("LINEAR_API_KEY", "Bearer test-token")
105+
106+
await searchCommand.parse()
107+
} finally {
108+
await server.stop()
109+
Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT")
110+
Deno.env.delete("LINEAR_API_KEY")
111+
}
112+
},
113+
})

0 commit comments

Comments
 (0)