1#!/usr/bin/env node
2const child_process = require("node:child_process");
3const fs = require("node:fs");
4const path = require("node:path");
5const process = require("node:process");
6
7const TEAM_KEY = "ALPHA";
8const API_URL = "https://api.linear.app/graphql";
9const API_KEY_PATH = path.join(process.env.HOME ?? "", ".config", "linear-api-key");
10const ISSUE_ID_RE = /^ALPHA-\d+$/i;
11const WORK_REPO_RE = /^\d+$/;
12const OPEN_STATE_TYPES = ["triage", "backlog", "unstarted", "started"];
13const OPEN_STATE_TYPE_SET = new Set(OPEN_STATE_TYPES);
14
15const OPEN_ALPHA_FILTER = {
16 team: { key: { eq: TEAM_KEY } },
17 state: { type: { in: OPEN_STATE_TYPES } },
18};
19
20const SEARCH_ISSUES = `
21 query SearchAlphaIssues(
22 $term: String!
23 $filter: IssueFilter
24 $first: Int!
25 $includeComments: Boolean!
26 ) {
27 searchIssues(
28 term: $term
29 filter: $filter
30 first: $first
31 includeArchived: false
32 includeComments: $includeComments
33 ) {
34 nodes {
35 id
36 identifier
37 title
38 url
39 priority
40 priorityLabel
41 description
42 createdAt
43 updatedAt
44 labelIds
45 state {
46 id
47 name
48 type
49 }
50 team {
51 id
52 key
53 name
54 }
55 }
56 }
57 }
58`;
59
60const ISSUE_QUERY = `
61 query AlphaIssue($id: String!) {
62 issue(id: $id) {
63 id
64 identifier
65 title
66 url
67 priority
68 priorityLabel
69 description
70 createdAt
71 updatedAt
72 labelIds
73 labels(first: 50) {
74 nodes {
75 id
76 name
77 }
78 }
79 state {
80 id
81 name
82 type
83 }
84 team {
85 id
86 key
87 name
88 }
89 }
90 }
91`;
92
93main().catch((error) => {
94 const message = error instanceof Error ? error.message : String(error);
95 console.error(`error: ${message}`);
96 process.exit(1);
97});
98
99async function main() {
100 const [command, ...argv] = process.argv.slice(2);
101
102 if (!command || command === "-h" || command === "--help") {
103 usage(0);
104 }
105
106 if (command === "search") {
107 const options = parseSearchArgs(argv);
108 assertWorkRepo();
109 await search(options);
110 } else if (command === "issue") {
111 const options = parseIssueArgs(argv);
112 assertWorkRepo();
113 await showIssue(options);
114 } else {
115 throw new Error(`unknown command ${JSON.stringify(command)}`);
116 }
117}
118
119async function search(options) {
120 const found = new Map();
121
122 for (const query of options.queries) {
123 const data = await linearRequest(SEARCH_ISSUES, {
124 term: query,
125 filter: OPEN_ALPHA_FILTER,
126 first: options.limit,
127 includeComments: options.includeComments,
128 });
129
130 for (const issue of data.searchIssues.nodes) {
131 if (!isOpenIssue(issue)) {
132 continue;
133 }
134
135 const existing = found.get(issue.identifier);
136 if (existing) {
137 existing.matchedQueries.push(query);
138 } else {
139 found.set(issue.identifier, { ...issue, matchedQueries: [query] });
140 }
141 }
142 }
143
144 const issues = [...found.values()];
145 if (options.json) {
146 writeJson({ team: TEAM_KEY, queries: options.queries, issues: issues.map(issueJson) });
147 return;
148 }
149
150 if (issues.length === 0) {
151 console.log("no open alpha issues matched");
152 return;
153 }
154
155 for (const issue of issues) {
156 console.log(formatIssue(issue));
157 }
158}
159
160async function showIssue(options) {
161 const issue = await fetchIssue(options.issueId);
162 if (options.json) {
163 writeJson(issueJson(issue));
164 return;
165 }
166 console.log(formatIssue(issue, { includeLabels: true, fullDescription: true }));
167}
168
169async function fetchIssue(issueId) {
170 const id = normalizeIssueId(issueId);
171 const data = await linearRequest(ISSUE_QUERY, { id });
172 if (!data.issue) {
173 throw new Error(`${id} not found`);
174 }
175 if (data.issue.team?.key !== TEAM_KEY) {
176 throw new Error(`${id} belongs to ${data.issue.team?.key ?? "unknown"}, expected ${TEAM_KEY}`);
177 }
178 assertOpenIssue(data.issue);
179 return data.issue;
180}
181
182async function linearRequest(query, variables) {
183 const apiKey = readLinearApiKey();
184 if (!apiKey) {
185 throw new Error(`Linear API key is required in ${API_KEY_PATH} or LINEAR_API_KEY`);
186 }
187
188 const response = await fetch(API_URL, {
189 method: "POST",
190 headers: {
191 Authorization: apiKey,
192 "Content-Type": "application/json",
193 },
194 body: JSON.stringify({ query, variables }),
195 });
196
197 const body = await response.text();
198 let json;
199 try {
200 json = JSON.parse(body);
201 } catch {
202 throw new Error(`Linear returned non-JSON response (${response.status})`);
203 }
204
205 if (!response.ok) {
206 throw new Error(`Linear HTTP ${response.status}: ${body.slice(0, 500)}`);
207 }
208 if (json.errors?.length > 0) {
209 throw new Error(json.errors.map((error) => error.message).join("; "));
210 }
211
212 return json.data;
213}
214
215function readLinearApiKey() {
216 const envKey = process.env.LINEAR_API_KEY?.trim();
217 if (envKey) {
218 return envKey;
219 }
220
221 if (!process.env.HOME) {
222 return "";
223 }
224
225 try {
226 return fs.readFileSync(API_KEY_PATH, "utf8").trim();
227 } catch (error) {
228 if (error?.code === "ENOENT") {
229 return "";
230 }
231 throw error;
232 }
233}
234
235function parseSearchArgs(argv) {
236 const options = { json: false, limit: 8, includeComments: false, queries: [] };
237
238 while (argv.length > 0) {
239 const arg = argv.shift();
240 if (arg === "-h" || arg === "--help") {
241 searchUsage(0);
242 } else if (arg === "--json") {
243 options.json = true;
244 } else if (arg === "--comments") {
245 options.includeComments = true;
246 } else if (arg === "--limit") {
247 options.limit = parseLimit(argv.shift());
248 } else if (arg === "--") {
249 options.queries.push(...argv);
250 break;
251 } else if (arg.startsWith("-")) {
252 throw new Error(`unknown search option ${JSON.stringify(arg)}`);
253 } else {
254 options.queries.push(arg);
255 }
256 }
257
258 if (options.queries.length === 0 && !process.stdin.isTTY) {
259 options.queries.push(...fs.readFileSync(0, "utf8").split(/\r?\n/));
260 }
261
262 options.queries = options.queries.map((query) => query.trim()).filter(Boolean);
263 if (options.queries.length === 0) {
264 searchUsage(1);
265 }
266
267 return options;
268}
269
270function parseIssueArgs(argv) {
271 const options = { json: false, issueId: undefined };
272
273 while (argv.length > 0) {
274 const arg = argv.shift();
275 if (arg === "-h" || arg === "--help") {
276 issueUsage(0);
277 } else if (arg === "--json") {
278 options.json = true;
279 } else if (arg.startsWith("-")) {
280 throw new Error(`unknown issue option ${JSON.stringify(arg)}`);
281 } else if (!options.issueId) {
282 options.issueId = arg;
283 } else {
284 throw new Error(`unexpected issue argument ${JSON.stringify(arg)}`);
285 }
286 }
287
288 if (!options.issueId) {
289 issueUsage(1);
290 }
291
292 options.issueId = normalizeIssueId(options.issueId);
293 return options;
294}
295
296function assertWorkRepo() {
297 const result = child_process.spawnSync("jj", ["workspace", "root"], { encoding: "utf8" });
298 if (result.error || result.status !== 0) {
299 throw new Error("agent-linear must run inside a jj workspace");
300 }
301
302 const root = result.stdout.trim();
303 const home = process.env.HOME;
304 const relative = home ? path.relative(path.join(home, "dev"), root) : "";
305 if (!home || path.isAbsolute(relative) || relative.startsWith("..") || !WORK_REPO_RE.test(relative)) {
306 throw new Error(`agent-linear only runs from a numbered work repo under ~/dev, current workspace is ${root}`);
307 }
308}
309
310function normalizeIssueId(issueId) {
311 if (!ISSUE_ID_RE.test(issueId)) {
312 throw new Error(`expected an ${TEAM_KEY}-123 issue id, got ${JSON.stringify(issueId)}`);
313 }
314 return issueId.toUpperCase();
315}
316
317function parseLimit(value) {
318 const limit = Number(value);
319 if (!Number.isInteger(limit) || limit < 1 || limit > 25) {
320 throw new Error("--limit must be an integer from 1 to 25");
321 }
322 return limit;
323}
324
325function assertOpenIssue(issue) {
326 if (!isOpenIssue(issue)) {
327 throw new Error(`${issue.identifier} is closed (${issue.state?.name ?? "unknown"}/${issue.state?.type ?? "unknown"})`);
328 }
329}
330
331function isOpenIssue(issue) {
332 return OPEN_STATE_TYPE_SET.has(issue.state?.type);
333}
334
335function issueJson(issue) {
336 return {
337 id: issue.id,
338 identifier: issue.identifier,
339 title: issue.title,
340 url: issue.url,
341 state: issue.state ? { name: issue.state.name, type: issue.state.type } : null,
342 priority: issue.priority,
343 priorityLabel: issue.priorityLabel,
344 labels: issue.labels?.nodes?.map((label) => label.name) ?? undefined,
345 labelIds: issue.labelIds ?? [],
346 matchedQueries: issue.matchedQueries,
347 description: issue.description,
348 createdAt: issue.createdAt,
349 updatedAt: issue.updatedAt,
350 };
351}
352
353function formatIssue(issue, options = {}) {
354 const lines = [
355 `${issue.identifier} [${issue.state?.name ?? "unknown"}/${issue.state?.type ?? "unknown"}] ${issue.title}`,
356 ` ${issue.url}`,
357 ];
358 if (issue.priorityLabel) {
359 lines.push(` priority: ${issue.priorityLabel}`);
360 }
361 if (issue.matchedQueries) {
362 lines.push(` matched: ${issue.matchedQueries.join(" | ")}`);
363 }
364 if (options.includeLabels && issue.labels?.nodes?.length > 0) {
365 lines.push(` labels: ${issue.labels.nodes.map((label) => label.name).join(", ")}`);
366 }
367
368 if (options.fullDescription) {
369 const description = (issue.description ?? "").trim();
370 if (description) {
371 lines.push(" desc:");
372 lines.push(...description.split(/\r?\n/).map((line) => ` ${line}`));
373 }
374 } else {
375 const description = excerpt(issue.description);
376 if (description) {
377 lines.push(` desc: ${description}`);
378 }
379 }
380
381 return `${lines.join("\n")}\n`;
382}
383
384function excerpt(value) {
385 return (value ?? "").replace(/\s+/g, " ").trim().slice(0, 320);
386}
387
388function writeJson(value) {
389 process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
390}
391
392function usage(code) {
393 const stream = code === 0 ? process.stdout : process.stderr;
394 stream.write(`usage: agent-linear <command> [options]\n`);
395 stream.write(`\n`);
396 stream.write(`commands:\n`);
397 stream.write(` search search open ALPHA Linear issues\n`);
398 stream.write(` issue show one open ALPHA issue\n`);
399 stream.write(`\n`);
400 stream.write(`api key: ~/.config/linear-api-key or LINEAR_API_KEY\n`);
401 stream.write(`scope: only runs from numbered work repos under ~/dev\n`);
402 process.exit(code);
403}
404
405function searchUsage(code) {
406 const stream = code === 0 ? process.stdout : process.stderr;
407 stream.write(`usage: agent-linear search [--json] [--comments] [--limit n] <query>...\n`);
408 stream.write(`\n`);
409 stream.write(`each query is sent to Linear searchIssues with open ALPHA issue filters\n`);
410 process.exit(code);
411}
412
413function issueUsage(code) {
414 const stream = code === 0 ? process.stdout : process.stderr;
415 stream.write(`usage: agent-linear issue [--json] <ALPHA-123>\n`);
416 process.exit(code);
417}