@enrichlayer/el-linear 1.6.0 → 1.9.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 (69) hide show
  1. package/README.md +101 -5
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-app-config.d.ts +21 -0
  4. package/dist/auth/oauth-app-config.js +82 -0
  5. package/dist/auth/oauth-callback.js +14 -7
  6. package/dist/auth/oauth-fs.d.ts +22 -0
  7. package/dist/auth/oauth-fs.js +77 -6
  8. package/dist/auth/oauth-headless.d.ts +13 -8
  9. package/dist/auth/oauth-headless.js +18 -11
  10. package/dist/auth/oauth-storage.d.ts +11 -3
  11. package/dist/auth/oauth-storage.js +15 -8
  12. package/dist/auth/token-resolver.d.ts +9 -0
  13. package/dist/auth/token-resolver.js +57 -37
  14. package/dist/commands/init/defaults.d.ts +18 -1
  15. package/dist/commands/init/defaults.js +83 -2
  16. package/dist/commands/init/index.js +9 -1
  17. package/dist/commands/init/oauth.d.ts +10 -4
  18. package/dist/commands/init/oauth.js +32 -8
  19. package/dist/commands/init/token.d.ts +0 -8
  20. package/dist/commands/init/token.js +13 -1
  21. package/dist/commands/issues/branch.d.ts +17 -0
  22. package/dist/commands/issues/branch.js +40 -0
  23. package/dist/commands/issues/description.d.ts +89 -0
  24. package/dist/commands/issues/description.js +187 -0
  25. package/dist/commands/issues.js +76 -222
  26. package/dist/commands/labels.js +17 -2
  27. package/dist/commands/profile/migrate-legacy.js +10 -33
  28. package/dist/commands/profile.js +1 -10
  29. package/dist/commands/projects.d.ts +5 -1
  30. package/dist/commands/projects.js +144 -64
  31. package/dist/commands/read-shortcut.d.ts +6 -0
  32. package/dist/commands/read-shortcut.js +6 -1
  33. package/dist/commands/refs.js +2 -1
  34. package/dist/commands/teams.js +12 -2
  35. package/dist/commands/templates.js +127 -1
  36. package/dist/commands/users.js +2 -1
  37. package/dist/config/config.d.ts +19 -0
  38. package/dist/config/config.js +31 -9
  39. package/dist/config/issue-validation.js +1 -1
  40. package/dist/config/paths.d.ts +12 -0
  41. package/dist/config/paths.js +45 -6
  42. package/dist/config/term-enforcer.js +1 -1
  43. package/dist/main.js +39 -3
  44. package/dist/queries/templates.d.ts +3 -0
  45. package/dist/queries/templates.js +43 -0
  46. package/dist/utils/auth.js +7 -9
  47. package/dist/utils/auto-link-references.js +15 -1
  48. package/dist/utils/disk-cache.d.ts +51 -0
  49. package/dist/utils/disk-cache.js +179 -0
  50. package/dist/utils/formatters/summary.d.ts +62 -0
  51. package/dist/utils/formatters/summary.js +755 -0
  52. package/dist/utils/graphql-issues-service.d.ts +106 -3
  53. package/dist/utils/graphql-issues-service.js +51 -37
  54. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  55. package/dist/utils/issue-reference-extractor.js +16 -9
  56. package/dist/utils/issue-reference-wrapper.js +1 -54
  57. package/dist/utils/linear-service.d.ts +7 -3
  58. package/dist/utils/linear-service.js +27 -5
  59. package/dist/utils/markdown-prosemirror.js +17 -1
  60. package/dist/utils/mention-resolver.js +17 -5
  61. package/dist/utils/output.d.ts +5 -1
  62. package/dist/utils/output.js +28 -1
  63. package/dist/utils/protected-ranges.d.ts +33 -0
  64. package/dist/utils/protected-ranges.js +73 -0
  65. package/dist/utils/table-formatter.d.ts +36 -0
  66. package/dist/utils/table-formatter.js +46 -24
  67. package/dist/utils/validators.d.ts +9 -1
  68. package/dist/utils/validators.js +10 -0
  69. package/package.json +1 -1
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Description-handling helpers for issues create/update and the
3
+ * `issues link-references --rewrite-description` flow.
4
+ *
5
+ * Three concerns live here:
6
+ *
7
+ * 1. Resolving the description value from `--description`,
8
+ * `--description-file`, or `--template`.
9
+ * 2. The shared wrap-and-resolve pipeline (`wrapAndResolveRefs`)
10
+ * used by the create/update path and the rewrite-description
11
+ * path. Both wrap valid identifiers as markdown links and
12
+ * return the resolved id→uuid map for downstream auto-link.
13
+ * 3. The post-create/update `maybeAutoLink` hook that creates
14
+ * sidebar relations for refs in the description.
15
+ *
16
+ * Extracted from `commands/issues.ts` (ALL-938) so that file can
17
+ * focus on commander wiring + handlers.
18
+ */
19
+ import fs from "node:fs";
20
+ import { loadConfig } from "../../config/config.js";
21
+ import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
22
+ import { autoLinkReferences, } from "../../utils/auto-link-references.js";
23
+ import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
24
+ import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
25
+ import { validateReferences } from "../../utils/validate-references.js";
26
+ import { getWorkspaceUrlKey } from "../../utils/workspace-url.js";
27
+ /**
28
+ * Read description from a file path or stdin ("-").
29
+ * Avoids shell escaping issues when descriptions contain special characters.
30
+ */
31
+ export function readDescriptionFile(filePath) {
32
+ if (filePath === "-") {
33
+ return fs.readFileSync(0, "utf8").trim();
34
+ }
35
+ if (!fs.existsSync(filePath)) {
36
+ throw new Error(`Description file not found: ${filePath}`);
37
+ }
38
+ return fs.readFileSync(filePath, "utf8").trim();
39
+ }
40
+ /**
41
+ * Resolve the description from --description, --description-file, or
42
+ * --template (looked up in `config.descriptionTemplates`).
43
+ *
44
+ * Precedence: --description-file > --description > --template
45
+ *
46
+ * Passing --template alongside --description / --description-file is
47
+ * a usage error — the explicit body and a template both producing
48
+ * content would silently drop one. We throw so the user picks one.
49
+ */
50
+ export function resolveDescription(options) {
51
+ const hasInline = typeof options.description === "string" && options.description.length > 0;
52
+ const hasFile = Boolean(options.descriptionFile);
53
+ const hasTemplate = typeof options.template === "string" && options.template;
54
+ if (hasTemplate && (hasInline || hasFile)) {
55
+ throw new Error("--template is mutually exclusive with --description / --description-file. " +
56
+ "Pick one.");
57
+ }
58
+ if (hasFile) {
59
+ return readDescriptionFile(options.descriptionFile);
60
+ }
61
+ if (hasInline) {
62
+ return options.description;
63
+ }
64
+ if (hasTemplate) {
65
+ const templates = loadConfig().descriptionTemplates ?? {};
66
+ const body = templates[options.template];
67
+ if (!body) {
68
+ const available = Object.keys(templates).sort();
69
+ const hint = available.length
70
+ ? `Available templates: ${available.join(", ")}`
71
+ : "No templates configured. Add one under `descriptionTemplates` in your config.";
72
+ throw new Error(`Template "${options.template}" not found. ${hint}`);
73
+ }
74
+ return body;
75
+ }
76
+ return undefined;
77
+ }
78
+ /**
79
+ * Shared core for the wrap-and-resolve pipeline used by both the
80
+ * create/update path (`prepareAutoLinkedDescription`) and the
81
+ * `link-references --rewrite-description` path
82
+ * (`prepareDescriptionRewrite`).
83
+ *
84
+ * Returns:
85
+ * - `wrapped: undefined` when the original description had no
86
+ * resolvable refs to wrap (no-op for the caller),
87
+ * - `wrapped: <string>` when at least one ref was wrapped,
88
+ * - `preResolved` — the validated id→uuid map, passed downstream
89
+ * to `autoLinkReferences` to skip a second resolve roundtrip.
90
+ */
91
+ async function wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService) {
92
+ const refs = extractIssueReferences(description, selfIdentifier);
93
+ if (refs.length === 0) {
94
+ return { wrapped: undefined, preResolved: new Map() };
95
+ }
96
+ const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
97
+ if (preResolved.size === 0) {
98
+ return { wrapped: undefined, preResolved };
99
+ }
100
+ const validIds = new Set(preResolved.keys());
101
+ const urlKey = await getWorkspaceUrlKey(graphQLService);
102
+ const rewritten = wrapIssueReferencesAsLinks(description, validIds, urlKey);
103
+ return {
104
+ wrapped: rewritten === description ? undefined : rewritten,
105
+ preResolved,
106
+ };
107
+ }
108
+ /**
109
+ * Pre-process a description before sending to Linear:
110
+ * 1. Extract all issue references.
111
+ * 2. Validate them (drop ones that don't resolve in the workspace —
112
+ * handles "ISO-1424"-style false positives).
113
+ * 3. Wrap valid identifiers as markdown links — skipping any
114
+ * already inside a link, code block, or backtick span.
115
+ *
116
+ * No-ops (returns the original description with an empty map) when:
117
+ * - description is empty/undefined
118
+ * - the user passed `--no-auto-link`
119
+ */
120
+ export async function prepareAutoLinkedDescription(description, options, selfIdentifier, linearService, graphQLService) {
121
+ if (!description || options.autoLink === false) {
122
+ return { description, preResolved: new Map(), rewritten: false };
123
+ }
124
+ const { wrapped, preResolved } = await wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService);
125
+ return {
126
+ description: wrapped ?? description,
127
+ preResolved,
128
+ rewritten: wrapped !== undefined,
129
+ };
130
+ }
131
+ /**
132
+ * Prepare a description rewrite for `link-references --rewrite-description`.
133
+ * Same wrap-and-resolve core; different return shape so the caller can
134
+ * skip the rewrite mutation when nothing would change.
135
+ */
136
+ export async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
137
+ if (!description) {
138
+ return { preResolved: undefined, wrapped: undefined };
139
+ }
140
+ const { wrapped, preResolved } = await wrapAndResolveRefs(description, selfIdentifier, linearService, graphQLService);
141
+ return {
142
+ // `link-references --rewrite-description` historically used
143
+ // `preResolved: undefined` to mean "no refs at all"; preserve
144
+ // that signal so the existing call site keeps the same shape.
145
+ preResolved: preResolved.size === 0 ? undefined : preResolved,
146
+ wrapped,
147
+ };
148
+ }
149
+ export async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
150
+ const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
151
+ id: issueUuid,
152
+ input: { description },
153
+ });
154
+ const mutation = updateResult.issueUpdate;
155
+ if (!mutation?.success) {
156
+ throw new Error("Failed to rewrite description");
157
+ }
158
+ }
159
+ /**
160
+ * Run auto-linking for issue references found in a description, unless
161
+ * the user opted out with `--no-auto-link` or no description was
162
+ * provided. Returns undefined when nothing was linked / skipped /
163
+ * failed (so callers can omit the field from JSON output).
164
+ */
165
+ export async function maybeAutoLink(input) {
166
+ const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
167
+ if (options.autoLink === false) {
168
+ return;
169
+ }
170
+ if (!description) {
171
+ return;
172
+ }
173
+ const result = await autoLinkReferences({
174
+ issueId,
175
+ identifier,
176
+ description,
177
+ graphQLService,
178
+ linearService,
179
+ preResolved,
180
+ });
181
+ if (result.linked.length === 0 &&
182
+ result.skipped.length === 0 &&
183
+ result.failed.length === 0) {
184
+ return;
185
+ }
186
+ return result;
187
+ }
@@ -1,29 +1,26 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import fs from "node:fs";
3
2
  import { loadConfig } from "../config/config.js";
4
3
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
5
4
  import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
6
5
  import { resolveDefaultStatus } from "../config/status-defaults.js";
7
6
  import { enforceTerms } from "../config/term-enforcer.js";
8
7
  import { LIST_COMMENTS_QUERY } from "../queries/comments.js";
9
- import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, ISSUE_RELATION_CREATE_MUTATION, SCAN_ISSUES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
8
+ import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, ISSUE_RELATION_CREATE_MUTATION, SCAN_ISSUES_QUERY, } from "../queries/issues.js";
10
9
  import { autoLinkReferences, } from "../utils/auto-link-references.js";
11
- import { downloadLinearUploads } from "../utils/download-uploads.js";
12
10
  import { createFileService } from "../utils/file-service.js";
13
11
  import { applyFooter } from "../utils/footer.js";
14
12
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
15
- import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
13
+ import { GraphQLIssuesService, } from "../utils/graphql-issues-service.js";
16
14
  import { createGraphQLService, } from "../utils/graphql-service.js";
17
- import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
18
- import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
19
15
  import { createLinearService, } from "../utils/linear-service.js";
20
16
  import { logger } from "../utils/logger.js";
21
17
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
22
18
  import { getRootOpts } from "../utils/root-opts.js";
23
19
  import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
24
- import { validateReferences } from "../utils/validate-references.js";
25
20
  import { parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
26
- import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
21
+ import { gitCheckoutBranch, toBranchName } from "./issues/branch.js";
22
+ import { maybeAutoLink, prepareAutoLinkedDescription, prepareDescriptionRewrite, pushDescriptionUpdate, readDescriptionFile, resolveDescription, } from "./issues/description.js";
23
+ import { readIssues } from "./read-shortcut.js";
27
24
  const IMAGE_EXTENSIONS = new Set([
28
25
  ".png",
29
26
  ".jpg",
@@ -33,90 +30,7 @@ const IMAGE_EXTENSIONS = new Set([
33
30
  ".svg",
34
31
  ".ico",
35
32
  ]);
36
- const LINEAR_BRANCH_REGEX = /^([a-zA-Z]+)-(\d+)-(.+)$/;
37
33
  const COMMIT_HASH_PREFIX_REGEX = /^[a-f0-9]+ /;
38
- /**
39
- * Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
40
- * "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
41
- */
42
- function toBranchName(linearBranchName, prefix = "feature/") {
43
- // Linear branch names look like "dev-123-some-slug"
44
- // We need to uppercase the team key: "DEV-123-some-slug"
45
- const match = linearBranchName.match(LINEAR_BRANCH_REGEX);
46
- if (!match) {
47
- return `${prefix}${linearBranchName}`;
48
- }
49
- const [, teamKey, number, slug] = match;
50
- return `${prefix}${teamKey.toUpperCase()}-${number}-${slug}`;
51
- }
52
- /**
53
- * Check out a new git branch. Warns and skips if not in a git repo.
54
- * Throws if the branch already exists.
55
- */
56
- function gitCheckoutBranch(branchName) {
57
- try {
58
- execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
59
- stdio: "pipe",
60
- });
61
- }
62
- catch {
63
- outputWarning("Not inside a git repository — skipping branch checkout.");
64
- return;
65
- }
66
- execFileSync("git", ["checkout", "-b", branchName], { stdio: "pipe" });
67
- }
68
- /**
69
- * Read description from a file path or stdin ("-").
70
- * Avoids shell escaping issues when descriptions contain special characters.
71
- */
72
- function readDescriptionFile(filePath) {
73
- if (filePath === "-") {
74
- return fs.readFileSync(0, "utf8").trim();
75
- }
76
- if (!fs.existsSync(filePath)) {
77
- throw new Error(`Description file not found: ${filePath}`);
78
- }
79
- return fs.readFileSync(filePath, "utf8").trim();
80
- }
81
- /**
82
- * Resolve the description from --description, --description-file, or
83
- * --template (looked up in `config.descriptionTemplates`).
84
- *
85
- * Precedence:
86
- * --description-file > --description > --template
87
- *
88
- * Passing --template alongside --description or --description-file is a
89
- * usage error — the explicit body and a template both producing content
90
- * would be silently dropping one. We throw so the user picks one.
91
- */
92
- function resolveDescription(options) {
93
- const hasInline = typeof options.description === "string" && options.description.length > 0;
94
- const hasFile = Boolean(options.descriptionFile);
95
- const hasTemplate = typeof options.template === "string" && options.template;
96
- if (hasTemplate && (hasInline || hasFile)) {
97
- throw new Error("--template is mutually exclusive with --description / --description-file. " +
98
- "Pick one.");
99
- }
100
- if (hasFile) {
101
- return readDescriptionFile(options.descriptionFile);
102
- }
103
- if (hasInline) {
104
- return options.description;
105
- }
106
- if (hasTemplate) {
107
- const templates = loadConfig().descriptionTemplates ?? {};
108
- const body = templates[options.template];
109
- if (!body) {
110
- const available = Object.keys(templates).sort();
111
- const hint = available.length
112
- ? `Available templates: ${available.join(", ")}`
113
- : "No templates configured. Add one under `descriptionTemplates` in your config.";
114
- throw new Error(`Template "${options.template}" not found. ${hint}`);
115
- }
116
- return body;
117
- }
118
- return undefined;
119
- }
120
34
  function isImageFile(filename) {
121
35
  const ext = filename.lastIndexOf(".");
122
36
  return ext !== -1 && IMAGE_EXTENSIONS.has(filename.slice(ext).toLowerCase());
@@ -168,67 +82,6 @@ async function createRelations(sourceId, options, graphQLService, linearService)
168
82
  }
169
83
  return relations;
170
84
  }
171
- /**
172
- * Pre-process a description before sending to Linear:
173
- * 1. Extract all issue references from the description.
174
- * 2. Validate them (drop those that don't resolve in this workspace — handles "ISO-1424"-style false positives).
175
- * 3. Wrap valid identifiers as markdown links — skipping any already inside a link, code block, or backtick span.
176
- *
177
- * Returns the rewritten description plus the validated map for downstream auto-linking.
178
- *
179
- * No-ops (returns the original description with an empty map) when:
180
- * - description is empty/undefined
181
- * - the user passed `--no-auto-link`
182
- */
183
- async function prepareAutoLinkedDescription(description, options, selfIdentifier, linearService, graphQLService) {
184
- if (!description || options.autoLink === false) {
185
- return { description, preResolved: new Map(), rewritten: false };
186
- }
187
- const refs = extractIssueReferences(description, selfIdentifier);
188
- if (refs.length === 0) {
189
- return { description, preResolved: new Map(), rewritten: false };
190
- }
191
- const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
192
- if (preResolved.size === 0) {
193
- return { description, preResolved, rewritten: false };
194
- }
195
- const validIds = new Set(preResolved.keys());
196
- const urlKey = await getWorkspaceUrlKey(graphQLService);
197
- const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
198
- return {
199
- description: wrapped,
200
- preResolved,
201
- rewritten: wrapped !== description,
202
- };
203
- }
204
- /**
205
- * Run auto-linking for issue references found in a description, unless the user opted out
206
- * with --no-auto-link or no description was provided. Returns undefined when nothing was linked
207
- * and nothing was skipped/failed (so callers can omit the field from JSON output).
208
- */
209
- async function maybeAutoLink(input) {
210
- const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
211
- if (options.autoLink === false) {
212
- return;
213
- }
214
- if (!description) {
215
- return;
216
- }
217
- const result = await autoLinkReferences({
218
- issueId,
219
- identifier,
220
- description,
221
- graphQLService,
222
- linearService,
223
- preResolved,
224
- });
225
- if (result.linked.length === 0 &&
226
- result.skipped.length === 0 &&
227
- result.failed.length === 0) {
228
- return;
229
- }
230
- return result;
231
- }
232
85
  function validateUpdateOptions(options) {
233
86
  if (options.parentTicket && options.clearParentTicket) {
234
87
  throw new Error("Cannot use --parent-ticket and --clear-parent-ticket together");
@@ -263,12 +116,20 @@ function buildUpdateArgs(issueId, options, assigneeId) {
263
116
  else if (options.labels) {
264
117
  labelIds = splitList(options.labels);
265
118
  }
119
+ // On update, fall back to config.defaultPriority only when --priority
120
+ // wasn't passed. This keeps every update setting a priority for users who
121
+ // want a workspace-wide baseline (e.g. all unassigned triage tickets bump
122
+ // to "medium"). To leave priority untouched, omit defaultPriority from
123
+ // config — the field is opt-in.
124
+ const priorityInput = typeof options.priority === "string"
125
+ ? options.priority
126
+ : loadConfig().defaultPriority;
266
127
  return {
267
128
  id: issueId,
268
129
  title: options.title,
269
130
  description: options.description,
270
131
  statusId: options.status,
271
- priority: options.priority ? validatePriority(options.priority) : undefined,
132
+ priority: priorityInput ? validatePriority(priorityInput) : undefined,
272
133
  assigneeId,
273
134
  projectId: options.project,
274
135
  labelIds,
@@ -392,17 +253,38 @@ async function handleSearchIssues(query, options, command) {
392
253
  async function resolveCreateInputs(title, options, rootOpts) {
393
254
  const config = loadConfig();
394
255
  enforceTerms([title, options.description], { strict: options.strict });
256
+ // Effective assignee: explicit --assignee wins; --no-assignee (commander
257
+ // parses as `assignee === false`) skips both flag and config; otherwise
258
+ // fall back to config.defaultAssignee. Computed BEFORE validation so the
259
+ // "assignee required" rule sees the resolved value, not undefined.
260
+ const noAssignee = options.assignee === false;
261
+ const explicitAssignee = typeof options.assignee === "string" ? options.assignee : undefined;
262
+ const effectiveAssignee = noAssignee
263
+ ? undefined
264
+ : (explicitAssignee ?? config.defaultAssignee);
265
+ // Effective priority: explicit --priority wins; otherwise fall back to
266
+ // config.defaultPriority. Both go through validatePriority so a bad config
267
+ // value fails fast with a useful error.
268
+ const effectivePriorityInput = typeof options.priority === "string"
269
+ ? options.priority
270
+ : config.defaultPriority;
395
271
  // --- Validation (labels, description, assignee, project, title) ---
396
272
  // Controlled by config.validation.enabled (default: true).
397
- // Bypassed by --skip-validation flag.
398
- if (!options.skipValidation) {
273
+ // Bypassed by --skip-validation flag, OR by --from-template which
274
+ // defers field provision to Linear's server-side template
275
+ // instantiation. With --from-template + no overrides, the
276
+ // resulting issue inherits title/description/labels/etc. from the
277
+ // template — running the local validator against undefined fields
278
+ // would produce a false-negative "Missing --labels" error.
279
+ const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
280
+ if (!options.skipValidation && !hasFromTemplate) {
399
281
  const rawLabels = options.labels ? splitList(options.labels) : null;
400
282
  const description = resolveDescription(options);
401
283
  const validationResult = validateIssueCreation({
402
284
  labels: rawLabels,
403
285
  description: description || undefined,
404
286
  title,
405
- assignee: options.assignee,
287
+ assignee: effectiveAssignee,
406
288
  project: options.project,
407
289
  });
408
290
  // Apply normalized labels back so resolution uses the canonical names
@@ -411,13 +293,13 @@ async function resolveCreateInputs(title, options, rootOpts) {
411
293
  }
412
294
  enforceValidation(validationResult);
413
295
  }
414
- if (!options.priority) {
415
- outputWarning("Creating issue without --priority. Consider specifying it for better triage.", "missing_fields");
296
+ if (!effectivePriorityInput) {
297
+ outputWarning("Creating issue without --priority. Consider specifying it for better triage.");
416
298
  }
417
299
  const teamInput = options.team || config.defaultTeam;
418
300
  const teamId = resolveTeam(teamInput);
419
- const assigneeId = options.assignee
420
- ? await resolveAssignee(options.assignee, rootOpts)
301
+ const assigneeId = effectiveAssignee
302
+ ? await resolveAssignee(effectiveAssignee, rootOpts)
421
303
  : undefined;
422
304
  let labelIds = [];
423
305
  if (options.labels) {
@@ -438,7 +320,18 @@ async function resolveCreateInputs(title, options, rootOpts) {
438
320
  if (options.subscriber) {
439
321
  subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
440
322
  }
441
- return { teamInput, teamId, assigneeId, labelIds, status, subscriberIds };
323
+ const priority = effectivePriorityInput
324
+ ? validatePriority(effectivePriorityInput)
325
+ : undefined;
326
+ return {
327
+ teamInput,
328
+ teamId,
329
+ assigneeId,
330
+ labelIds,
331
+ status,
332
+ subscriberIds,
333
+ priority,
334
+ };
442
335
  }
443
336
  async function uploadAttachmentsIfNeeded(options, rootOpts) {
444
337
  if (!options.attachment) {
@@ -472,7 +365,7 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
472
365
  }
473
366
  async function handleCreateIssue(title, options, command) {
474
367
  const rootOpts = getRootOpts(command);
475
- const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds } = await resolveCreateInputs(title, options, rootOpts);
368
+ const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
476
369
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
477
370
  const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
478
371
  // Append messageFooter (config or --footer flag) so auto-link picks up any
@@ -492,12 +385,14 @@ async function handleCreateIssue(title, options, command) {
492
385
  // because the issue doesn't exist yet — pass undefined.
493
386
  const prepared = await prepareAutoLinkedDescription(description || undefined, options, undefined, linearService, graphQLService);
494
387
  const result = await issuesService.createIssue({
495
- title,
388
+ // title is omitted when --from-template is set without an override,
389
+ // so Linear copies the template's title server-side.
390
+ ...(title ? { title } : {}),
496
391
  teamId,
497
392
  teamInput,
498
393
  description: prepared.description,
499
394
  assigneeId,
500
- priority: options.priority ? validatePriority(options.priority) : undefined,
395
+ priority,
501
396
  projectId: options.project,
502
397
  statusId: status,
503
398
  labelIds: labelIds.length > 0 ? labelIds : undefined,
@@ -506,6 +401,12 @@ async function handleCreateIssue(title, options, command) {
506
401
  cycleId: options.cycle,
507
402
  subscriberIds,
508
403
  dueDate: options.dueDate,
404
+ // Linear server-side template instantiation. When set,
405
+ // Linear copies the template's title/description/labels/priority
406
+ // onto the new issue; any explicit field above wins by override.
407
+ ...(typeof options.fromTemplate === "string" && options.fromTemplate
408
+ ? { templateId: options.fromTemplate }
409
+ : {}),
509
410
  });
510
411
  const relations = await createRelations(result.id, options, graphQLService, linearService);
511
412
  // Pass the ORIGINAL description (pre-wrap) so the extractor's prose-keyword inference
@@ -710,40 +611,6 @@ async function linkReferencesForIssue(args) {
710
611
  preResolved: args.preResolved,
711
612
  });
712
613
  }
713
- /**
714
- * Prepare a description rewrite: validate refs, wrap valid IDs as markdown links,
715
- * and return the rewritten text (or undefined if nothing would change).
716
- */
717
- async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
718
- if (!description) {
719
- return { preResolved: undefined, wrapped: undefined };
720
- }
721
- const refs = extractIssueReferences(description, selfIdentifier);
722
- if (refs.length === 0) {
723
- return { preResolved: undefined, wrapped: undefined };
724
- }
725
- const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
726
- if (preResolved.size === 0) {
727
- return { preResolved, wrapped: undefined };
728
- }
729
- const validIds = new Set(preResolved.keys());
730
- const urlKey = await getWorkspaceUrlKey(graphQLService);
731
- const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
732
- return {
733
- preResolved,
734
- wrapped: wrapped === description ? undefined : wrapped,
735
- };
736
- }
737
- async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
738
- const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
739
- id: issueUuid,
740
- input: { description },
741
- });
742
- const mutation = updateResult.issueUpdate;
743
- if (!mutation?.success) {
744
- throw new Error("Failed to rewrite description");
745
- }
746
- }
747
614
  async function handleLinkReferencesSingle(issueId, options, graphQLService, linearService) {
748
615
  const resolvedId = await linearService.resolveIssueId(issueId);
749
616
  // GET_ISSUE_RELATIONS_QUERY also returns description, so this single call gives us everything
@@ -869,25 +736,6 @@ async function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
869
736
  }
870
737
  return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
871
738
  }
872
- async function handleReadIssue(issueIds, _options, command) {
873
- const rootOpts = getRootOpts(command);
874
- const graphQLService = await createGraphQLService(rootOpts);
875
- const linearService = await createLinearService(rootOpts);
876
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
877
- const fileService = await createFileService(rootOpts);
878
- if (issueIds.length === 1) {
879
- const issue = await issuesService.getIssueById(issueIds[0]);
880
- const resolved = await downloadLinearUploads(issue, fileService);
881
- outputSuccess(resolved);
882
- }
883
- else {
884
- const results = await Promise.all(issueIds.map(async (id) => {
885
- const issue = await issuesService.getIssueById(id);
886
- return downloadLinearUploads(issue, fileService);
887
- }));
888
- outputSuccess(results);
889
- }
890
- }
891
739
  async function handleUpdateIssue(issueId, options, command) {
892
740
  // Normalize aliases
893
741
  if (options.state && !options.status) {
@@ -1048,7 +896,7 @@ export function setupIssuesCommands(program) {
1048
896
  .option("--status <status>", "filter by status (comma-separated, e.g. Todo,Backlog)")
1049
897
  .option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
1050
898
  .option("--sort <field>", "sort results (priority, status, created, updated)")
1051
- .option("--format <format>", "output format (json, table, md, csv)", "json")
899
+ .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
1052
900
  .option("--fields <fields>", "columns for table/csv (comma-separated: identifier,title,status,priority,assignee,project,team,labels,updated)")
1053
901
  .action(handleAsyncCommand(handleListIssues));
1054
902
  issues
@@ -1063,7 +911,7 @@ export function setupIssuesCommands(program) {
1063
911
  .option("--label <labels>", "alias for --labels")
1064
912
  .option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
1065
913
  .option("--sort <field>", "sort results (priority, status, created, updated)")
1066
- .option("--format <format>", "output format (json, table, md, csv)", "json")
914
+ .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
1067
915
  .option("--fields <fields>", "columns for table/csv output")
1068
916
  .option("-l, --limit <number>", "limit results", "10")
1069
917
  .action(handleAsyncCommand(handleSearchIssues));
@@ -1074,7 +922,9 @@ export function setupIssuesCommands(program) {
1074
922
  .option("-d, --description <desc>", "issue description")
1075
923
  .option("--description-file <path>", "read description from file (use - for stdin)")
1076
924
  .option("--template <name>", "use a named description template from config.descriptionTemplates")
925
+ .option("--from-template <id>", "instantiate the issue from a Linear server-side template (UUID from `el-linear templates list`). Sets templateId on the underlying issueCreate mutation; Linear copies the template's title, description, labels, priority, etc. as the new issue's defaults. Override any field with the matching --title / --description / --labels flag.")
1077
926
  .option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
927
+ .option("--no-assignee", "create unassigned even when config.defaultAssignee is set")
1078
928
  .option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
1079
929
  .option("--project <project>", "add to project (name or ID)")
1080
930
  .option("--team <team>", "team key or name (default: from config)")
@@ -1103,8 +953,12 @@ export function setupIssuesCommands(program) {
1103
953
  options.labels = options.label;
1104
954
  }
1105
955
  const title = options.title || titleArg;
1106
- if (!title) {
1107
- throw new Error("Title is required. Provide it as a positional argument or with --title.");
956
+ // --from-template lets Linear copy the template's title, so a
957
+ // local title is optional in that path. Without --from-template,
958
+ // the create mutation requires a title up front.
959
+ const fromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
960
+ if (!title && !fromTemplate) {
961
+ throw new Error("Title is required. Provide it as a positional argument, with --title, or use --from-template to copy the template's title.");
1108
962
  }
1109
963
  return handleCreateIssue(title, options, command);
1110
964
  }));
@@ -1115,7 +969,7 @@ export function setupIssuesCommands(program) {
1115
969
  .alias("show")
1116
970
  .description("Get issue details. Accepts multiple IDs for batch retrieval.")
1117
971
  .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789")
1118
- .action(handleAsyncCommand(handleReadIssue));
972
+ .action(handleAsyncCommand(readIssues));
1119
973
  issues
1120
974
  .command("update <issueId>")
1121
975
  .alias("edit")
@@ -1,5 +1,7 @@
1
+ import { loadConfig } from "../config/config.js";
1
2
  import { resolveTeam } from "../config/resolver.js";
2
3
  import { CREATE_LABEL_MUTATION, FIND_PARENT_LABEL_QUERY, RESTORE_LABEL_MUTATION, RETIRE_LABEL_MUTATION, } from "../queries/labels.js";
4
+ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
3
5
  import { createGraphQLService } from "../utils/graphql-service.js";
4
6
  import { createLinearService } from "../utils/linear-service.js";
5
7
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
@@ -71,12 +73,25 @@ export function setupLabelsCommands(program) {
71
73
  .command("list")
72
74
  .description("List all available labels")
73
75
  .option("--team <team>", "filter by team key, name, or ID")
76
+ .option("--name <substring>", "filter by case-insensitive substring on label name")
74
77
  .option("-l, --limit <number>", "limit results", "100")
75
78
  .action(handleAsyncCommand(async (options, command) => {
76
79
  const rootOpts = getRootOpts(command);
77
80
  const teamFilter = options.team ? resolveTeam(options.team) : undefined;
78
- const service = await createLinearService(rootOpts);
79
- const result = await service.getLabels(teamFilter, Number.parseInt(options.limit, 10));
81
+ const nameFilter = options.name;
82
+ const limit = Number.parseInt(options.limit, 10);
83
+ const ttl = resolveCacheTTL({
84
+ configTTL: loadConfig().cacheTTLSeconds,
85
+ noCacheFlag: rootOpts.cache === false,
86
+ });
87
+ // Cache key includes the team filter and name filter so different
88
+ // filter combinations don't collide. Same `limit` participates
89
+ // because a smaller list isn't a valid cached answer for a larger ask.
90
+ const cacheKey = `labels-list-team:${teamFilter ?? "_all"}-name:${nameFilter ?? "_all"}-limit:${limit}`;
91
+ const result = await cached(cacheKey, ttl, async () => {
92
+ const service = await createLinearService(rootOpts);
93
+ return service.getLabels(teamFilter, limit, nameFilter);
94
+ });
80
95
  outputSuccess({
81
96
  data: result.labels,
82
97
  meta: { count: result.labels.length },