@enrichlayer/el-linear 1.7.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +82 -1
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-callback.js +14 -7
  4. package/dist/auth/oauth-fs.d.ts +22 -0
  5. package/dist/auth/oauth-fs.js +77 -6
  6. package/dist/auth/oauth-headless.d.ts +13 -8
  7. package/dist/auth/oauth-headless.js +18 -11
  8. package/dist/auth/oauth-storage.d.ts +11 -3
  9. package/dist/auth/oauth-storage.js +15 -8
  10. package/dist/auth/token-resolver.d.ts +9 -0
  11. package/dist/auth/token-resolver.js +57 -37
  12. package/dist/commands/attachments.js +2 -1
  13. package/dist/commands/comments.js +17 -28
  14. package/dist/commands/cycles.js +2 -1
  15. package/dist/commands/documents.js +2 -1
  16. package/dist/commands/graphql.js +4 -6
  17. package/dist/commands/init/index.js +2 -0
  18. package/dist/commands/init/oauth.d.ts +6 -0
  19. package/dist/commands/init/oauth.js +8 -2
  20. package/dist/commands/init/token.d.ts +0 -8
  21. package/dist/commands/init/token.js +13 -1
  22. package/dist/commands/issue-id.js +1 -3
  23. package/dist/commands/issues/branch.d.ts +17 -0
  24. package/dist/commands/issues/branch.js +40 -0
  25. package/dist/commands/issues/description.d.ts +89 -0
  26. package/dist/commands/issues/description.js +183 -0
  27. package/dist/commands/issues/link-references.d.ts +21 -0
  28. package/dist/commands/issues/link-references.js +171 -0
  29. package/dist/commands/issues/relations.d.ts +55 -0
  30. package/dist/commands/issues/relations.js +132 -0
  31. package/dist/commands/issues.js +97 -497
  32. package/dist/commands/labels.js +20 -28
  33. package/dist/commands/profile/migrate-legacy.js +10 -33
  34. package/dist/commands/profile.js +1 -10
  35. package/dist/commands/project-milestones.js +13 -20
  36. package/dist/commands/projects.d.ts +5 -1
  37. package/dist/commands/projects.js +202 -102
  38. package/dist/commands/read-shortcut.d.ts +6 -0
  39. package/dist/commands/read-shortcut.js +6 -1
  40. package/dist/commands/refs.js +10 -1
  41. package/dist/commands/releases.js +26 -30
  42. package/dist/commands/search.js +21 -30
  43. package/dist/commands/teams.js +2 -1
  44. package/dist/commands/templates.js +128 -7
  45. package/dist/commands/users.js +3 -1
  46. package/dist/config/config.js +31 -9
  47. package/dist/config/issue-validation.js +1 -1
  48. package/dist/config/paths.d.ts +11 -0
  49. package/dist/config/paths.js +44 -6
  50. package/dist/config/resolver.js +2 -3
  51. package/dist/config/term-enforcer.js +1 -1
  52. package/dist/main.js +37 -2
  53. package/dist/queries/attachments-types.d.ts +30 -0
  54. package/dist/queries/attachments-types.js +5 -0
  55. package/dist/queries/comments-types.d.ts +49 -0
  56. package/dist/queries/comments-types.js +5 -0
  57. package/dist/queries/documents-types.d.ts +61 -0
  58. package/dist/queries/documents-types.js +9 -0
  59. package/dist/queries/introspect-types.d.ts +57 -0
  60. package/dist/queries/introspect-types.js +10 -0
  61. package/dist/queries/issues-types.d.ts +416 -0
  62. package/dist/queries/issues-types.js +23 -0
  63. package/dist/queries/issues.d.ts +2 -0
  64. package/dist/queries/issues.js +22 -0
  65. package/dist/queries/labels-types.d.ts +64 -0
  66. package/dist/queries/labels-types.js +5 -0
  67. package/dist/queries/project-milestones-types.d.ts +91 -0
  68. package/dist/queries/project-milestones-types.js +10 -0
  69. package/dist/queries/projects-types.d.ts +75 -0
  70. package/dist/queries/projects-types.js +5 -0
  71. package/dist/queries/projects.d.ts +2 -0
  72. package/dist/queries/projects.js +22 -0
  73. package/dist/queries/releases-types.d.ts +84 -0
  74. package/dist/queries/releases-types.js +5 -0
  75. package/dist/queries/search-types.d.ts +86 -0
  76. package/dist/queries/search-types.js +6 -0
  77. package/dist/queries/templates-types.d.ts +61 -0
  78. package/dist/queries/templates-types.js +9 -0
  79. package/dist/queries/templates.d.ts +3 -0
  80. package/dist/queries/templates.js +43 -0
  81. package/dist/types/linear.d.ts +8 -2
  82. package/dist/utils/auth.js +7 -9
  83. package/dist/utils/auto-link-references.js +44 -25
  84. package/dist/utils/disk-cache.js +2 -1
  85. package/dist/utils/formatters/summary.d.ts +62 -0
  86. package/dist/utils/formatters/summary.js +755 -0
  87. package/dist/utils/graphql-attachments-service.js +6 -9
  88. package/dist/utils/graphql-documents-service.js +19 -25
  89. package/dist/utils/graphql-issues-service.d.ts +118 -5
  90. package/dist/utils/graphql-issues-service.js +202 -209
  91. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  92. package/dist/utils/issue-reference-extractor.js +16 -9
  93. package/dist/utils/issue-reference-wrapper.js +1 -54
  94. package/dist/utils/linear-service.d.ts +7 -3
  95. package/dist/utils/linear-service.js +27 -5
  96. package/dist/utils/markdown-prosemirror.js +17 -1
  97. package/dist/utils/mention-resolver.js +17 -5
  98. package/dist/utils/output.d.ts +7 -1
  99. package/dist/utils/output.js +38 -1
  100. package/dist/utils/protected-ranges.d.ts +33 -0
  101. package/dist/utils/protected-ranges.js +73 -0
  102. package/dist/utils/table-formatter.d.ts +36 -0
  103. package/dist/utils/table-formatter.js +46 -24
  104. package/dist/utils/validators.d.ts +9 -1
  105. package/dist/utils/validators.js +10 -0
  106. package/dist/utils/workspace-url.d.ts +5 -1
  107. package/dist/utils/workspace-url.js +35 -5
  108. package/package.json +1 -1
@@ -1,114 +1,161 @@
1
1
  import { loadConfig } from "../config/config.js";
2
2
  import { resolveTeam } from "../config/resolver.js";
3
- import { CREATE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
3
+ import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
4
4
  import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
5
5
  import { createGraphQLService } from "../utils/graphql-service.js";
6
6
  import { createLinearService } from "../utils/linear-service.js";
7
7
  import { logger } from "../utils/logger.js";
8
8
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
9
9
  import { getRootOpts } from "../utils/root-opts.js";
10
+ import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
10
11
  import { isUuid } from "../utils/uuid.js";
11
- import { splitList } from "../utils/validators.js";
12
- function formatProjectsOutput(projects, format, fieldNames) {
13
- const fields = fieldNames ?? [
14
- "name",
15
- "state",
16
- "progress",
17
- "teams",
18
- "lead",
19
- "targetDate",
20
- ];
21
- const rows = projects.map((p) => {
22
- const row = {};
23
- for (const f of fields) {
24
- switch (f) {
25
- case "name":
26
- row[f] = p.name;
27
- break;
28
- case "state":
29
- row[f] = p.state ?? "";
30
- break;
31
- case "progress":
32
- row[f] =
33
- p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "";
34
- break;
35
- case "teams":
36
- row[f] = p.teams?.map((t) => t.key).join(", ") ?? "";
37
- break;
38
- case "lead":
39
- row[f] = p.lead?.name ?? "";
40
- break;
41
- case "targetDate":
42
- row[f] = p.targetDate ?? "";
43
- break;
44
- case "id":
45
- row[f] = p.id;
46
- break;
47
- default:
48
- row[f] = String(p[f] ?? "");
49
- }
12
+ import { parsePositiveInt, splitList } from "../utils/validators.js";
13
+ const VALID_PROJECT_STATES = new Set([
14
+ "backlog",
15
+ "planned",
16
+ "started",
17
+ "paused",
18
+ "completed",
19
+ "canceled",
20
+ ]);
21
+ function parseStateList(value, flagName) {
22
+ const states = splitList(value).map((s) => s.toLowerCase());
23
+ for (const s of states) {
24
+ if (!VALID_PROJECT_STATES.has(s)) {
25
+ throw new Error(`Invalid state "${s}" for ${flagName}. Valid: ${[...VALID_PROJECT_STATES].join(", ")}.`);
50
26
  }
51
- return row;
52
- });
27
+ }
28
+ return states;
29
+ }
30
+ export function resolveProjectStateFilter(options) {
31
+ const flags = [options.state, options.excludeState, options.active].filter(Boolean).length;
32
+ if (flags > 1) {
33
+ throw new Error("--state, --exclude-state, and --active are mutually exclusive.");
34
+ }
35
+ if (options.active) {
36
+ return { excludeStates: ["completed", "canceled"] };
37
+ }
38
+ if (options.state) {
39
+ return { states: parseStateList(options.state, "--state") };
40
+ }
41
+ if (options.excludeState) {
42
+ return {
43
+ excludeStates: parseStateList(options.excludeState, "--exclude-state"),
44
+ };
45
+ }
46
+ return {};
47
+ }
48
+ // ── Project column definitions ────────────────────────────────
49
+ //
50
+ // Pre-fix this file inlined an 80-line `formatProjectsOutput`
51
+ // function that hand-rolled column width math, table padding, CSV
52
+ // quoting, and markdown pipe syntax — all duplicating
53
+ // `utils/table-formatter.ts`. Now we declare `ColumnDef<LinearProject>`
54
+ // arrays and let the shared renderers do the work. ALL-938 cleanup.
55
+ const PROJECT_COLUMNS = {
56
+ name: { key: "name", header: "Name", width: 32, extract: (p) => p.name },
57
+ state: {
58
+ key: "state",
59
+ header: "State",
60
+ width: 10,
61
+ extract: (p) => p.state ?? "",
62
+ },
63
+ progress: {
64
+ key: "progress",
65
+ header: "Progress",
66
+ width: 10,
67
+ extract: (p) => p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "",
68
+ },
69
+ teams: {
70
+ key: "teams",
71
+ header: "Teams",
72
+ width: 18,
73
+ extract: (p) => p.teams?.map((t) => t.key).join(", ") ?? "",
74
+ },
75
+ lead: {
76
+ key: "lead",
77
+ header: "Lead",
78
+ width: 18,
79
+ extract: (p) => p.lead?.name ?? "",
80
+ },
81
+ targetDate: {
82
+ key: "targetDate",
83
+ header: "Target",
84
+ width: 12,
85
+ extract: (p) => p.targetDate ?? "",
86
+ },
87
+ id: { key: "id", header: "ID", width: 38, extract: (p) => p.id },
88
+ };
89
+ const PROJECT_MD_COLUMNS = {
90
+ name: { header: "Name", extract: (p) => p.name },
91
+ state: { header: "State", extract: (p) => p.state ?? "" },
92
+ progress: {
93
+ header: "Progress",
94
+ extract: (p) => p.progress !== undefined ? `${Math.round(p.progress * 100)}%` : "",
95
+ },
96
+ teams: {
97
+ header: "Teams",
98
+ extract: (p) => p.teams?.map((t) => t.key).join(", ") ?? "",
99
+ },
100
+ lead: { header: "Lead", extract: (p) => p.lead?.name ?? "" },
101
+ targetDate: { header: "Target", extract: (p) => p.targetDate ?? "" },
102
+ id: { header: "ID", extract: (p) => p.id },
103
+ };
104
+ const DEFAULT_PROJECT_COLUMNS = [
105
+ "name",
106
+ "state",
107
+ "progress",
108
+ "teams",
109
+ "lead",
110
+ "targetDate",
111
+ ];
112
+ function formatProjectsOutput(projects, format, fieldNames) {
113
+ const keys = fieldNames ?? DEFAULT_PROJECT_COLUMNS;
53
114
  if (format === "csv") {
54
- logger.info(fields.join(","));
55
- for (const row of rows) {
56
- logger.info(fields.map((f) => `"${(row[f] ?? "").replace(/"/g, '""')}"`).join(","));
57
- }
115
+ const columns = keys
116
+ .map((k) => PROJECT_COLUMNS[k])
117
+ .filter((c) => c !== undefined);
118
+ logger.info(renderCsv(projects, columns));
58
119
  return;
59
120
  }
60
- // table and md formats
61
- const widths = fields.map((f) => Math.max(f.length, ...rows.map((r) => (r[f] ?? "").length)));
62
- const separator = format === "md"
63
- ? `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`
64
- : widths.map((w) => "-".repeat(w + 2)).join("+");
65
- const header = format === "md"
66
- ? `| ${fields.map((f, i) => f.padEnd(widths[i])).join(" | ")} |`
67
- : fields.map((f, i) => ` ${f.padEnd(widths[i])} `).join("|");
68
- logger.info(header);
69
- logger.info(separator);
70
- for (const row of rows) {
71
- const line = format === "md"
72
- ? `| ${fields.map((f, i) => (row[f] ?? "").padEnd(widths[i])).join(" | ")} |`
73
- : fields
74
- .map((f, i) => ` ${(row[f] ?? "").padEnd(widths[i])} `)
75
- .join("|");
76
- logger.info(line);
121
+ if (format === "md") {
122
+ const columns = keys
123
+ .map((k) => PROJECT_MD_COLUMNS[k])
124
+ .filter((c) => c !== undefined);
125
+ logger.info(renderMarkdownTable(projects, columns));
126
+ return;
77
127
  }
128
+ // Default: fixed-width text table.
129
+ const columns = keys
130
+ .map((k) => PROJECT_COLUMNS[k])
131
+ .filter((c) => c !== undefined);
132
+ logger.info(renderFixedWidthTable(projects, columns));
78
133
  }
79
134
  async function resolveProjectWithTeams(graphQLService, projectNameOrId) {
80
135
  if (isUuid(projectNameOrId)) {
81
- const result = await graphQLService.rawRequest(PROJECT_BY_ID_QUERY, {
82
- id: projectNameOrId,
83
- });
84
- const project = result.project;
85
- if (!project) {
136
+ const result = await graphQLService.rawRequest(PROJECT_BY_ID_QUERY, { id: projectNameOrId });
137
+ if (!result.project) {
86
138
  throw new Error(`Project "${projectNameOrId}" not found`);
87
139
  }
88
- const teams = project.teams;
89
140
  return {
90
- projectId: project.id,
91
- projectName: project.name,
92
- currentTeams: (teams?.nodes || []).map((t) => ({
141
+ projectId: result.project.id,
142
+ projectName: result.project.name,
143
+ currentTeams: result.project.teams.nodes.map((t) => ({
93
144
  id: t.id,
94
145
  key: t.key,
95
146
  name: t.name,
96
147
  })),
97
148
  };
98
149
  }
99
- const result = await graphQLService.rawRequest(GET_PROJECT_QUERY, {
100
- name: projectNameOrId,
101
- });
102
- const projectNodes = result.projects?.nodes;
103
- if (!projectNodes?.length) {
150
+ const result = await graphQLService.rawRequest(GET_PROJECT_QUERY, { name: projectNameOrId });
151
+ const project = result.projects.nodes[0];
152
+ if (!project) {
104
153
  throw new Error(`Project "${projectNameOrId}" not found`);
105
154
  }
106
- const project = projectNodes[0];
107
- const teams = project.teams;
108
155
  return {
109
156
  projectId: project.id,
110
157
  projectName: project.name,
111
- currentTeams: (teams?.nodes || []).map((t) => ({
158
+ currentTeams: project.teams.nodes.map((t) => ({
112
159
  id: t.id,
113
160
  key: t.key,
114
161
  name: t.name,
@@ -116,12 +163,14 @@ async function resolveProjectWithTeams(graphQLService, projectNameOrId) {
116
163
  };
117
164
  }
118
165
  function formatTeamsOutput(projectUpdate) {
166
+ if (!projectUpdate.project) {
167
+ throw new Error("Failed to update project");
168
+ }
119
169
  const updatedProject = projectUpdate.project;
120
- const updatedTeams = updatedProject.teams;
121
170
  return {
122
171
  id: updatedProject.id,
123
172
  name: updatedProject.name,
124
- teams: (updatedTeams.nodes || []).map((t) => ({
173
+ teams: updatedProject.teams.nodes.map((t) => ({
125
174
  id: t.id,
126
175
  key: t.key,
127
176
  name: t.name,
@@ -200,9 +249,7 @@ async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
200
249
  projectId,
201
250
  teamId: finalTeamId,
202
251
  });
203
- const project = issueCheck.project;
204
- const issues = project.issues;
205
- const issueNodes = issues.nodes || [];
252
+ const issueNodes = issueCheck.project?.issues.nodes ?? [];
206
253
  if (issueNodes.length > 0) {
207
254
  const examples = issueNodes
208
255
  .slice(0, 5)
@@ -230,14 +277,10 @@ async function handleCreateProject(name, options, command) {
230
277
  const graphQLService = await createGraphQLService(rootOpts);
231
278
  // Step 1: Check for duplicate projects (case-insensitive)
232
279
  const searchResult = await graphQLService.rawRequest(SEARCH_PROJECTS_BY_NAME_QUERY, { name });
233
- const existing = (searchResult.projects
234
- ?.nodes ?? []).filter((p) => p.name.toLowerCase() === name.toLowerCase());
280
+ const existing = searchResult.projects.nodes.filter((p) => p.name.toLowerCase() === name.toLowerCase());
235
281
  if (existing.length > 0 && !options.force) {
236
282
  const match = existing[0];
237
- const teams = (match.teams?.nodes ??
238
- [])
239
- .map((t) => t.key)
240
- .join(", ");
283
+ const teams = match.teams.nodes.map((t) => t.key).join(", ");
241
284
  throw new Error(`Project "${match.name}" already exists (state: ${match.state}, teams: ${teams}). ` +
242
285
  `Use --force to create anyway, or use the existing project.`);
243
286
  }
@@ -255,11 +298,11 @@ async function handleCreateProject(name, options, command) {
255
298
  ...(teamIds.length > 0 ? { teamIds } : {}),
256
299
  };
257
300
  const createResult = await graphQLService.rawRequest(CREATE_PROJECT_MUTATION, { input });
258
- const created = createResult.projectCreate;
259
- if (!created?.success) {
301
+ if (!createResult.projectCreate.success ||
302
+ !createResult.projectCreate.project) {
260
303
  throw new Error("Failed to create project");
261
304
  }
262
- const project = created.project;
305
+ const project = createResult.projectCreate.project;
263
306
  // Step 4: Set content if provided (separate mutation — Linear API quirk)
264
307
  if (options.content) {
265
308
  await graphQLService.rawRequest(UPDATE_PROJECT_MUTATION, {
@@ -267,10 +310,7 @@ async function handleCreateProject(name, options, command) {
267
310
  input: { content: options.content },
268
311
  });
269
312
  }
270
- const teamList = (project.teams?.nodes ??
271
- [])
272
- .map((t) => t.key)
273
- .join(", ");
313
+ const teamList = project.teams.nodes.map((t) => t.key).join(", ");
274
314
  outputSuccess({
275
315
  id: project.id,
276
316
  name: project.name,
@@ -285,6 +325,42 @@ async function handleCreateProject(name, options, command) {
285
325
  : {}),
286
326
  });
287
327
  }
328
+ async function handleArchiveProject(projectNameOrId, _options, command) {
329
+ const rootOpts = getRootOpts(command);
330
+ const graphQLService = await createGraphQLService(rootOpts);
331
+ const linearService = await createLinearService(rootOpts);
332
+ const projectId = await linearService.resolveProjectId(projectNameOrId);
333
+ const result = await graphQLService.rawRequest(ARCHIVE_PROJECT_MUTATION, { id: projectId });
334
+ const payload = result.projectArchive;
335
+ if (!payload.success) {
336
+ throw new Error(`Failed to archive project "${projectNameOrId}"`);
337
+ }
338
+ outputSuccess({
339
+ success: true,
340
+ archived: true,
341
+ id: projectId,
342
+ entity: payload.entity ?? undefined,
343
+ lastSyncId: payload.lastSyncId,
344
+ });
345
+ }
346
+ async function handleDeleteProject(projectNameOrId, _options, command) {
347
+ const rootOpts = getRootOpts(command);
348
+ const graphQLService = await createGraphQLService(rootOpts);
349
+ const linearService = await createLinearService(rootOpts);
350
+ const projectId = await linearService.resolveProjectId(projectNameOrId);
351
+ const result = await graphQLService.rawRequest(DELETE_PROJECT_MUTATION, { id: projectId });
352
+ const payload = result.projectDelete;
353
+ if (!payload.success) {
354
+ throw new Error(`Failed to delete project "${projectNameOrId}"`);
355
+ }
356
+ outputSuccess({
357
+ success: true,
358
+ deleted: true,
359
+ id: projectId,
360
+ entity: payload.entity ?? undefined,
361
+ lastSyncId: payload.lastSyncId,
362
+ });
363
+ }
288
364
  export function setupProjectsCommands(program) {
289
365
  const projects = program
290
366
  .command("projects")
@@ -299,22 +375,46 @@ export function setupProjectsCommands(program) {
299
375
  .option("--content <markdown>", "full markdown body (shown in project panel)")
300
376
  .option("--force", "create even if a project with the same name exists")
301
377
  .action(handleAsyncCommand(handleCreateProject));
378
+ projects
379
+ .command("archive <project>")
380
+ .description("Archive a project (resolves names)")
381
+ .action(handleAsyncCommand(handleArchiveProject));
382
+ projects
383
+ .command("delete <project>")
384
+ .description("Delete (trash) a project (resolves names)")
385
+ .action(handleAsyncCommand(handleDeleteProject));
302
386
  projects
303
387
  .command("list")
304
388
  .description("List projects")
305
389
  .option("-l, --limit <number>", "limit results", "100")
306
- .option("--format <format>", "output format (json, table, md, csv)", "json")
390
+ .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
307
391
  .option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
392
+ .option("--name <substring>", "filter by case-insensitive substring on project name")
393
+ .option("--state <names>", "include only projects in these states (comma-separated: backlog, planned, started, paused, completed, canceled)")
394
+ .option("--exclude-state <names>", "exclude projects in these states (comma-separated). Mutually exclusive with --state.")
395
+ .option("--active", "shorthand for --exclude-state completed,canceled (mutually exclusive with --state / --exclude-state)")
308
396
  .action(handleAsyncCommand(async (options, command) => {
309
397
  const rootOpts = getRootOpts(command);
310
- const limit = Number.parseInt(options.limit, 10);
398
+ const limit = parsePositiveInt(options.limit, "--limit");
399
+ const nameFilter = options.name;
400
+ const stateFilter = resolveProjectStateFilter(options);
311
401
  const ttl = resolveCacheTTL({
312
402
  configTTL: loadConfig().cacheTTLSeconds,
313
403
  noCacheFlag: rootOpts.cache === false,
314
404
  });
315
- const result = await cached(`projects-list-limit:${limit}`, ttl, async () => {
405
+ // Cache key includes filter inputs so different filter combinations
406
+ // don't collide.
407
+ const cacheKey = `projects-list-limit:${limit}` +
408
+ `-name:${nameFilter ?? "_all"}` +
409
+ `-states:${stateFilter.states?.join(",") ?? "_any"}` +
410
+ `-excl:${stateFilter.excludeStates?.join(",") ?? "_none"}`;
411
+ const result = await cached(cacheKey, ttl, async () => {
316
412
  const service = await createLinearService(rootOpts);
317
- return service.getProjects(limit);
413
+ return service.getProjects(limit, {
414
+ nameFilter,
415
+ states: stateFilter.states,
416
+ excludeStates: stateFilter.excludeStates,
417
+ });
318
418
  });
319
419
  const format = options.format;
320
420
  if (format === "table" ||
@@ -4,3 +4,9 @@ import type { Command } from "commander";
4
4
  * issue identifiers so `el-linear ADM-652` works without the `issues read` prefix.
5
5
  */
6
6
  export declare function setupReadShortcut(program: Command): void;
7
+ /**
8
+ * Shared `issues read` body — exported so the top-level shortcut and
9
+ * `issues read` (in `commands/issues.ts`) both call one implementation
10
+ * instead of carrying byte-equivalent duplicates. ALL-938 cleanup.
11
+ */
12
+ export declare function readIssues(issueIds: string[], _options: Record<string, unknown>, command: Command): Promise<void>;
@@ -49,7 +49,12 @@ export function setupReadShortcut(program) {
49
49
  return originalParse(argv, parseOptions);
50
50
  };
51
51
  }
52
- async function readIssues(issueIds, _options, command) {
52
+ /**
53
+ * Shared `issues read` body — exported so the top-level shortcut and
54
+ * `issues read` (in `commands/issues.ts`) both call one implementation
55
+ * instead of carrying byte-equivalent duplicates. ALL-938 cleanup.
56
+ */
57
+ export async function readIssues(issueIds, _options, command) {
53
58
  const rootOpts = getRootOpts(command);
54
59
  const graphQLService = await createGraphQLService(rootOpts);
55
60
  const linearService = await createLinearService(rootOpts);
@@ -3,6 +3,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
3
3
  import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
4
4
  import { wrapIssueReferencesAsLinks, } from "../utils/issue-reference-wrapper.js";
5
5
  import { createLinearService } from "../utils/linear-service.js";
6
+ import { logger } from "../utils/logger.js";
6
7
  import { handleAsyncCommand } from "../utils/output.js";
7
8
  import { getRootOpts } from "../utils/root-opts.js";
8
9
  import { validateReferences } from "../utils/validate-references.js";
@@ -67,13 +68,20 @@ async function handleWrap(options, command) {
67
68
  return new Set(map.keys());
68
69
  },
69
70
  async resolveUrlKey() {
71
+ // When --workspace-url-key or EL_LINEAR_WORKSPACE_URL_KEY supplies
72
+ // the key, the live lookup never fires — skip the GraphQL service
73
+ // instantiation entirely so `refs wrap --no-validate` works offline.
74
+ const override = options.workspaceUrlKey;
75
+ if (override || process.env.EL_LINEAR_WORKSPACE_URL_KEY) {
76
+ return getWorkspaceUrlKey(undefined, { override });
77
+ }
70
78
  const graphQLService = await createGraphQLService(rootOpts);
71
79
  return getWorkspaceUrlKey(graphQLService);
72
80
  },
73
81
  };
74
82
  if (!validate) {
75
83
  // stderr advisory only — keeps stdout a clean text stream for piping.
76
- process.stderr.write("el-linear refs wrap: --no-validate set; emitting links for every regex match without checking the workspace.\n");
84
+ logger.error("el-linear refs wrap: --no-validate set; emitting links for every regex match without checking the workspace.");
77
85
  }
78
86
  const wrapped = await wrapRefsCore({ text, target, validate }, deps);
79
87
  process.stdout.write(wrapped);
@@ -92,5 +100,6 @@ export function setupRefsCommands(program) {
92
100
  .option("--file <path>", "read input from a file instead of stdin")
93
101
  .option("--target <target>", "output format: markdown (default) or slack", "markdown")
94
102
  .option("--no-validate", "skip workspace validation; wrap every regex match. Faster, but may produce broken links for IDs that don't exist.")
103
+ .option("--workspace-url-key <key>", "workspace URL key (the segment after linear.app/). Overrides config + env var. With --no-validate, makes the command fully offline.")
95
104
  .action(handleAsyncCommand(handleWrap));
96
105
  }
@@ -2,18 +2,24 @@ import { CREATE_RELEASE_MUTATION, GET_RELEASE_BY_ID_QUERY, GET_RELEASE_PIPELINES
2
2
  import { createGraphQLService } from "../utils/graphql-service.js";
3
3
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
4
4
  import { getRootOpts } from "../utils/root-opts.js";
5
+ import { parsePositiveInt } from "../utils/validators.js";
5
6
  function transformRelease(release) {
7
+ // Lifecycle timestamps + dates only exist on the list/detail node shapes,
8
+ // not on the create-mutation shape; read defensively rather than splitting
9
+ // the function in two.
10
+ const r = release;
11
+ const documents = release.documents?.nodes;
6
12
  return {
7
13
  id: release.id,
8
14
  name: release.name,
9
- description: release.description || undefined,
10
- version: release.version || undefined,
11
- url: release.url || undefined,
12
- startDate: release.startDate || undefined,
13
- targetDate: release.targetDate || undefined,
14
- startedAt: release.startedAt || undefined,
15
- completedAt: release.completedAt || undefined,
16
- canceledAt: release.canceledAt || undefined,
15
+ description: release.description ?? undefined,
16
+ version: release.version ?? undefined,
17
+ url: release.url ?? undefined,
18
+ startDate: r.startDate ?? undefined,
19
+ targetDate: r.targetDate ?? undefined,
20
+ startedAt: r.startedAt ?? undefined,
21
+ completedAt: r.completedAt ?? undefined,
22
+ canceledAt: r.canceledAt ?? undefined,
17
23
  stage: release.stage
18
24
  ? {
19
25
  id: release.stage.id,
@@ -27,7 +33,7 @@ function transformRelease(release) {
27
33
  name: release.pipeline.name,
28
34
  }
29
35
  : undefined,
30
- documents: release.documents?.nodes?.map((d) => ({
36
+ documents: documents?.map((d) => ({
31
37
  id: d.id,
32
38
  title: d.title,
33
39
  slugId: d.slugId,
@@ -40,14 +46,11 @@ async function handleCreateRelease(name, options, command) {
40
46
  const rootOpts = getRootOpts(command);
41
47
  const graphQLService = await createGraphQLService(rootOpts);
42
48
  const pipelines = await graphQLService.rawRequest(GET_RELEASE_PIPELINES_QUERY, { first: 50 });
43
- const pipelineNodes = pipelines.releasePipelines
44
- ?.nodes;
45
- const pipeline = pipelineNodes?.find((p) => p.id === options.pipeline ||
49
+ const pipelineNodes = pipelines.releasePipelines.nodes;
50
+ const pipeline = pipelineNodes.find((p) => p.id === options.pipeline ||
46
51
  p.name.toLowerCase() === options.pipeline.toLowerCase());
47
52
  if (!pipeline) {
48
- const available = (pipelineNodes ?? [])
49
- .map((p) => p.name)
50
- .join(", ");
53
+ const available = pipelineNodes.map((p) => p.name).join(", ");
51
54
  throw new Error(`Pipeline "${options.pipeline}" not found. Available: ${available || "none"}`);
52
55
  }
53
56
  const input = { name, pipelineId: pipeline.id };
@@ -58,21 +61,17 @@ async function handleCreateRelease(name, options, command) {
58
61
  input.version = options.version;
59
62
  }
60
63
  if (options.stage) {
61
- const stageNodes = pipeline.stages?.nodes;
62
- const stage = stageNodes?.find((s) => s.id === options.stage ||
64
+ const stage = pipeline.stages.nodes.find((s) => s.id === options.stage ||
63
65
  s.name.toLowerCase() === options.stage.toLowerCase());
64
66
  if (stage) {
65
67
  input.stageId = stage.id;
66
68
  }
67
69
  }
68
- const result = await graphQLService.rawRequest(CREATE_RELEASE_MUTATION, {
69
- input,
70
- });
71
- const releaseCreate = result.releaseCreate;
72
- if (!releaseCreate?.success) {
70
+ const result = await graphQLService.rawRequest(CREATE_RELEASE_MUTATION, { input });
71
+ if (!result.releaseCreate.success || !result.releaseCreate.release) {
73
72
  throw new Error(`Failed to create release "${name}"`);
74
73
  }
75
- outputSuccess(transformRelease(releaseCreate.release));
74
+ outputSuccess(transformRelease(result.releaseCreate.release));
76
75
  }
77
76
  export function setupReleasesCommands(program) {
78
77
  const releases = program
@@ -92,11 +91,10 @@ export function setupReleasesCommands(program) {
92
91
  filter.pipeline = { name: { eqIgnoreCase: options.pipeline } };
93
92
  }
94
93
  const result = await graphQLService.rawRequest(GET_RELEASES_QUERY, {
95
- first: Number.parseInt(options.limit, 10),
94
+ first: parsePositiveInt(options.limit, "--limit"),
96
95
  filter: Object.keys(filter).length > 0 ? filter : undefined,
97
96
  });
98
- const data = (result.releases
99
- ?.nodes ?? []).map(transformRelease);
97
+ const data = result.releases.nodes.map(transformRelease);
100
98
  outputSuccess({ data, meta: { count: data.length } });
101
99
  }));
102
100
  releases
@@ -126,12 +124,10 @@ export function setupReleasesCommands(program) {
126
124
  const rootOpts = getRootOpts(command);
127
125
  const graphQLService = await createGraphQLService(rootOpts);
128
126
  const result = await graphQLService.rawRequest(GET_RELEASE_PIPELINES_QUERY, { first: 50 });
129
- const data = (result.releasePipelines
130
- ?.nodes ?? []).map((p) => ({
127
+ const data = result.releasePipelines.nodes.map((p) => ({
131
128
  id: p.id,
132
129
  name: p.name,
133
- stages: (p.stages
134
- ?.nodes ?? []).map((s) => ({
130
+ stages: p.stages.nodes.map((s) => ({
135
131
  id: s.id,
136
132
  name: s.name,
137
133
  type: s.type,