@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
@@ -11,6 +11,7 @@ import { resolveMentions } from "../utils/mention-resolver.js";
11
11
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
12
12
  import { getRootOpts } from "../utils/root-opts.js";
13
13
  import { validateReferences } from "../utils/validate-references.js";
14
+ import { parsePositiveInt } from "../utils/validators.js";
14
15
  import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
15
16
  // Match Linear's bodyData validation error in multiple phrasings so a wording
16
17
  // tweak on their side doesn't silently regress the fallback path.
@@ -28,22 +29,20 @@ function readBody(options) {
28
29
  async function fetchSelfUserId(graphQLService) {
29
30
  try {
30
31
  const result = await graphQLService.rawRequest("{ viewer { id } }");
31
- const viewer = result.viewer;
32
- return viewer?.id;
32
+ return result.viewer?.id;
33
33
  }
34
34
  catch {
35
35
  return;
36
36
  }
37
37
  }
38
38
  function transformComment(comment) {
39
- const user = comment.user;
40
39
  return {
41
40
  id: comment.id,
42
41
  body: comment.body,
43
42
  user: {
44
- id: user.id,
45
- name: resolveUserDisplayName(user.id, user.name),
46
- url: user.url || undefined,
43
+ id: comment.user.id,
44
+ name: resolveUserDisplayName(comment.user.id, comment.user.name),
45
+ url: comment.user.url ?? undefined,
47
46
  },
48
47
  createdAt: comment.createdAt,
49
48
  updatedAt: comment.updatedAt,
@@ -133,9 +132,7 @@ async function handleCreateComment(issueId, options, command) {
133
132
  }
134
133
  let result;
135
134
  try {
136
- result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
137
- input,
138
- });
135
+ result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, { input });
139
136
  }
140
137
  catch (err) {
141
138
  // If the bodyData ProseMirror document is rejected (invalid shape,
@@ -147,16 +144,14 @@ async function handleCreateComment(issueId, options, command) {
147
144
  issueId: resolvedIssueId,
148
145
  body,
149
146
  };
150
- result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
151
- input: fallbackInput,
152
- });
147
+ result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, { input: fallbackInput });
153
148
  }
154
149
  else {
155
150
  throw err;
156
151
  }
157
152
  }
158
153
  const mutation = result.commentCreate;
159
- if (!mutation.success) {
154
+ if (!mutation.success || !mutation.comment) {
160
155
  throw new Error("Failed to create comment");
161
156
  }
162
157
  // We don't have the parent issue's identifier from the create mutation response,
@@ -201,22 +196,18 @@ async function handleUpdateComment(commentId, options, command) {
201
196
  else {
202
197
  input.body = body;
203
198
  }
204
- const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, {
205
- id: commentId,
206
- input,
207
- });
199
+ const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, { id: commentId, input });
208
200
  const mutation = result.commentUpdate;
209
- if (!mutation.success) {
201
+ if (!mutation.success || !mutation.comment) {
210
202
  throw new Error("Failed to update comment");
211
203
  }
212
204
  const comment = mutation.comment;
213
- const issue = comment.issue;
214
205
  let autoLinked;
215
- if (issue) {
206
+ if (comment.issue) {
216
207
  // rawBody (pre-wrap) — see note in handleCreateComment about keyword inference
217
208
  autoLinked = await autoLinkCommentReferences({
218
- parentIssueUuid: issue.id,
219
- parentIssueIdentifier: issue.identifier ?? "",
209
+ parentIssueUuid: comment.issue.id,
210
+ parentIssueIdentifier: comment.issue.identifier,
220
211
  body: rawBody,
221
212
  preResolved,
222
213
  options,
@@ -234,19 +225,17 @@ async function handleListComments(issueId, options, command) {
234
225
  const resolvedId = await linearService.resolveIssueId(issueId);
235
226
  const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
236
227
  issueId: resolvedId,
237
- first: Number.parseInt(options.limit, 10),
228
+ first: parsePositiveInt(options.limit, "--limit"),
238
229
  });
239
- const issue = result.issue;
240
- if (!issue) {
230
+ if (!result.issue) {
241
231
  throw new Error(`Issue "${issueId}" not found`);
242
232
  }
243
- const commentsData = issue.comments;
244
- const nodes = commentsData.nodes.map(transformComment);
233
+ const nodes = result.issue.comments.nodes.map(transformComment);
245
234
  outputSuccess({
246
235
  data: nodes,
247
236
  meta: {
248
237
  count: nodes.length,
249
- issue: issue.identifier,
238
+ issue: result.issue.identifier,
250
239
  },
251
240
  });
252
241
  }
@@ -3,6 +3,7 @@ import { invalidParameterError, notFoundError, requiresParameterError, } from ".
3
3
  import { createLinearService } from "../utils/linear-service.js";
4
4
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
5
5
  import { getRootOpts } from "../utils/root-opts.js";
6
+ import { parsePositiveInt } from "../utils/validators.js";
6
7
  export function setupCyclesCommands(program) {
7
8
  const cycles = program
8
9
  .command("cycles")
@@ -23,7 +24,7 @@ export function setupCyclesCommands(program) {
23
24
  const rootOpts = getRootOpts(command);
24
25
  const teamFilter = options.team ? resolveTeam(options.team) : undefined;
25
26
  const linearService = await createLinearService(rootOpts);
26
- const allCycles = await linearService.getCycles(teamFilter, options.active || undefined, Number.parseInt(options.limit, 10));
27
+ const allCycles = await linearService.getCycles(teamFilter, options.active || undefined, parsePositiveInt(options.limit, "--limit"));
27
28
  if (options.aroundActive) {
28
29
  const n = Number.parseInt(options.aroundActive, 10);
29
30
  if (Number.isNaN(n) || n < 0) {
@@ -3,6 +3,7 @@ import { createGraphQLDocumentsService } from "../utils/graphql-documents-servic
3
3
  import { createLinearService } from "../utils/linear-service.js";
4
4
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
5
5
  import { getRootOpts } from "../utils/root-opts.js";
6
+ import { parsePositiveInt } from "../utils/validators.js";
6
7
  function extractDocumentIdFromUrl(url) {
7
8
  try {
8
9
  const parsed = new URL(url);
@@ -70,7 +71,7 @@ async function handleListDocuments(options, command) {
70
71
  const rootOpts = getRootOpts(command);
71
72
  const documentsService = await createGraphQLDocumentsService(rootOpts);
72
73
  const linearService = await createLinearService(rootOpts);
73
- const limit = Number.parseInt(options.limit || "50", 10);
74
+ const limit = parsePositiveInt(options.limit || "50", "--limit");
74
75
  if (Number.isNaN(limit) || limit < 1) {
75
76
  throw new Error(`Invalid limit "${options.limit}": must be a positive number`);
76
77
  }
@@ -46,16 +46,14 @@ export function setupGraphQLCommands(program) {
46
46
  const graphQLService = await createGraphQLService(rootOpts);
47
47
  if (typeName) {
48
48
  const result = await graphQLService.rawRequest(INTROSPECT_TYPE_QUERY, { typeName });
49
- const typeInfo = result.__type;
50
- if (!typeInfo) {
49
+ if (!result.__type) {
51
50
  throw new Error(`Type "${typeName}" not found in schema`);
52
51
  }
53
- outputSuccess(typeInfo);
52
+ outputSuccess(result.__type);
54
53
  }
55
54
  else {
56
55
  const result = await graphQLService.rawRequest(INTROSPECT_ROOT_QUERY);
57
- const queryType = result.__type;
58
- let fields = queryType?.fields ?? [];
56
+ let fields = result.__type?.fields ?? [];
59
57
  if (options.filter) {
60
58
  const pattern = options.filter.toLowerCase();
61
59
  fields = fields.filter((f) => f.name.toLowerCase().includes(pattern));
@@ -63,7 +61,7 @@ export function setupGraphQLCommands(program) {
63
61
  const summary = fields.map((f) => ({
64
62
  name: f.name,
65
63
  description: f.description ?? null,
66
- args: (f.args ?? []).map((a) => a.name),
64
+ args: f.args.map((a) => a.name),
67
65
  }));
68
66
  outputSuccess({ fields: summary, count: summary.length });
69
67
  }
@@ -63,6 +63,7 @@ export function setupInitCommands(program) {
63
63
  .option("--revoke", "revoke and remove the stored OAuth tokens")
64
64
  .option("--no-browser", "skip the browser-open + localhost listener; paste the code manually")
65
65
  .option("--port <port>", "localhost callback port (default 8765)", (value) => Number.parseInt(value, 10))
66
+ .option("--unsafe-bare-code", "allow pasting a bare authorization code in the headless flow (skips the OAuth `state` CSRF check; opt-in only)")
66
67
  .action(withCleanExit(async (options) => {
67
68
  printStep("oauth", "Linear OAuth (PKCE)");
68
69
  if (options.revoke) {
@@ -75,6 +76,7 @@ export function setupInitCommands(program) {
75
76
  // commander's `--no-browser` produces `browser: false`.
76
77
  noBrowser: options.browser === false,
77
78
  port: options.port,
79
+ unsafeBareCode: options.unsafeBareCode === true,
78
80
  });
79
81
  }));
80
82
  init
@@ -39,6 +39,12 @@ export interface OAuthStepOptions {
39
39
  noBrowser?: boolean;
40
40
  /** Override the localhost port. Default 8765. */
41
41
  port?: number;
42
+ /**
43
+ * Allow pasting a bare authorization code (no surrounding URL) in
44
+ * the headless flow. Bypasses the OAuth `state` CSRF check, so
45
+ * opt-in only — see `oauth-headless.ts` for the rationale.
46
+ */
47
+ unsafeBareCode?: boolean;
42
48
  /** Test seam for the OAuth token endpoint. */
43
49
  fetchImpl?: FetchLike;
44
50
  /**
@@ -270,11 +270,17 @@ export async function runOAuthStep(options = {}) {
270
270
  catch (err) {
271
271
  const raw = err instanceof Error ? err.message : String(err);
272
272
  logLine(TS(`Localhost listener failed (${sanitizeForLog(raw)}). Falling back to manual paste.`));
273
- callback = await promptForPastedCode({ expectedState: state });
273
+ callback = await promptForPastedCode({
274
+ expectedState: state,
275
+ unsafeBareCode: options.unsafeBareCode,
276
+ });
274
277
  }
275
278
  }
276
279
  else {
277
- callback = await promptForPastedCode({ expectedState: state });
280
+ callback = await promptForPastedCode({
281
+ expectedState: state,
282
+ unsafeBareCode: options.unsafeBareCode,
283
+ });
278
284
  }
279
285
  logLine(TS("Exchanging authorization code for tokens…"));
280
286
  const exchanged = await exchangeCodeForTokens({
@@ -21,14 +21,6 @@ export interface TokenStepResult {
21
21
  token: string;
22
22
  viewer: ViewerResponse["viewer"];
23
23
  }
24
- /**
25
- * Strip anything that looks like a Linear API token from a string. Defense in
26
- * depth: today the @linear/sdk error message embeds {query, variables} but not
27
- * the Authorization header. A future SDK upgrade that includes headers (which
28
- * upstream graphql-request has done historically) would otherwise silently
29
- * write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
30
- * also catches token shapes that may show up in custom error wrappers.
31
- */
32
24
  export declare function sanitizeForLog(text: string): string;
33
25
  /**
34
26
  * Validate a Linear API token by fetching the viewer. Throws with a
@@ -31,8 +31,20 @@ const VIEWER_QUERY = /* GraphQL */ `
31
31
  * write `Bearer lin_api_…` into stdout / shell history / CI logs. The regex
32
32
  * also catches token shapes that may show up in custom error wrappers.
33
33
  */
34
+ // Personal-API tokens (`lin_api_…`) and OAuth access/refresh tokens
35
+ // (`lin_oauth_…`). Pre-fix the regex only matched personal tokens.
36
+ const TOKEN_PREFIX_RE = /lin_(api|oauth)_[A-Za-z0-9_-]{16,}/g;
37
+ // High-entropy bearer payload fallback: catches generic Bearer-style
38
+ // strings adjacent to Authorization / Bearer keywords. Useful for
39
+ // future SDK error wrappers that might leak headers without the
40
+ // `lin_` prefix.
41
+ const BEARER_PAYLOAD_RE = /(\b(?:Authorization|Bearer)\b[:\s]*)([A-Za-z0-9_\-/+=]{40,})/gi;
34
42
  export function sanitizeForLog(text) {
35
- return text.replace(/lin_api_[A-Za-z0-9_-]{16,}/g, "lin_api_***REDACTED***");
43
+ return text
44
+ .replace(TOKEN_PREFIX_RE, (m) => m.startsWith("lin_oauth_")
45
+ ? "lin_oauth_***REDACTED***"
46
+ : "lin_api_***REDACTED***")
47
+ .replace(BEARER_PAYLOAD_RE, "$1***REDACTED***");
36
48
  }
37
49
  /**
38
50
  * Strict shape check on the viewer response. Treats whitespace-only fields as
@@ -75,9 +75,7 @@ export function setupIssueIdCommand(program) {
75
75
  const service = await createGraphQLService({
76
76
  apiToken: rootOpts.apiToken,
77
77
  });
78
- const result = (await service.rawRequest(ISSUE_QUERY, {
79
- id: parsed.issueId,
80
- }));
78
+ const result = await service.rawRequest(ISSUE_QUERY, { id: parsed.issueId });
81
79
  outputSuccess({ ...parsed, issue: result.issue ?? null });
82
80
  }));
83
81
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Git branch helpers used by `issues create --branch` and the
3
+ * `issues retrolink` flow.
4
+ *
5
+ * Extracted from `commands/issues.ts` (ALL-938) to keep that file
6
+ * focused on commander wiring + handlers.
7
+ */
8
+ /**
9
+ * Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
10
+ * "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
11
+ */
12
+ export declare function toBranchName(linearBranchName: string, prefix?: string): string;
13
+ /**
14
+ * Check out a new git branch. Warns and skips if not in a git repo.
15
+ * Throws if the branch already exists.
16
+ */
17
+ export declare function gitCheckoutBranch(branchName: string): void;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Git branch helpers used by `issues create --branch` and the
3
+ * `issues retrolink` flow.
4
+ *
5
+ * Extracted from `commands/issues.ts` (ALL-938) to keep that file
6
+ * focused on commander wiring + handlers.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import { outputWarning } from "../../utils/output.js";
10
+ const LINEAR_BRANCH_REGEX = /^([a-zA-Z]+)-(\d+)-(.+)$/;
11
+ /**
12
+ * Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
13
+ * "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
14
+ */
15
+ export function toBranchName(linearBranchName, prefix = "feature/") {
16
+ // Linear branch names look like "dev-123-some-slug"
17
+ // We need to uppercase the team key: "DEV-123-some-slug"
18
+ const match = linearBranchName.match(LINEAR_BRANCH_REGEX);
19
+ if (!match) {
20
+ return `${prefix}${linearBranchName}`;
21
+ }
22
+ const [, teamKey, number, slug] = match;
23
+ return `${prefix}${teamKey.toUpperCase()}-${number}-${slug}`;
24
+ }
25
+ /**
26
+ * Check out a new git branch. Warns and skips if not in a git repo.
27
+ * Throws if the branch already exists.
28
+ */
29
+ export function gitCheckoutBranch(branchName) {
30
+ try {
31
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
32
+ stdio: "pipe",
33
+ });
34
+ }
35
+ catch {
36
+ outputWarning("Not inside a git repository — skipping branch checkout.");
37
+ return;
38
+ }
39
+ execFileSync("git", ["checkout", "-b", branchName], { stdio: "pipe" });
40
+ }
@@ -0,0 +1,89 @@
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 type { OptionValues } from "commander";
20
+ import { type AutoLinkResult } from "../../utils/auto-link-references.js";
21
+ import type { GraphQLService } from "../../utils/graphql-service.js";
22
+ import type { LinearService } from "../../utils/linear-service.js";
23
+ /**
24
+ * Read description from a file path or stdin ("-").
25
+ * Avoids shell escaping issues when descriptions contain special characters.
26
+ */
27
+ export declare function readDescriptionFile(filePath: string): string;
28
+ /**
29
+ * Resolve the description from --description, --description-file, or
30
+ * --template (looked up in `config.descriptionTemplates`).
31
+ *
32
+ * Precedence: --description-file > --description > --template
33
+ *
34
+ * Passing --template alongside --description / --description-file is
35
+ * a usage error — the explicit body and a template both producing
36
+ * content would silently drop one. We throw so the user picks one.
37
+ */
38
+ export declare function resolveDescription(options: OptionValues): string | undefined;
39
+ export interface PreparedDescription {
40
+ /** The (possibly rewritten) description text to send to Linear */
41
+ description: string | undefined;
42
+ /** Map<identifier, uuid> of refs that resolved — passed to autoLink to avoid re-resolution */
43
+ preResolved: Map<string, string>;
44
+ /** True when the original description was rewritten (i.e. at least one link was wrapped) */
45
+ rewritten: boolean;
46
+ }
47
+ /**
48
+ * Pre-process a description before sending to Linear:
49
+ * 1. Extract all issue references.
50
+ * 2. Validate them (drop ones that don't resolve in the workspace —
51
+ * handles "ISO-1424"-style false positives).
52
+ * 3. Wrap valid identifiers as markdown links — skipping any
53
+ * already inside a link, code block, or backtick span.
54
+ *
55
+ * No-ops (returns the original description with an empty map) when:
56
+ * - description is empty/undefined
57
+ * - the user passed `--no-auto-link`
58
+ */
59
+ export declare function prepareAutoLinkedDescription(description: string | undefined, options: OptionValues, selfIdentifier: string | undefined, linearService: LinearService, graphQLService: GraphQLService): Promise<PreparedDescription>;
60
+ export interface PreparedRewrite {
61
+ /** Resolved id→uuid map (passed to autoLink to skip duplicate resolution) */
62
+ preResolved: Map<string, string> | undefined;
63
+ /** New description text — undefined when wrapping wouldn't change anything */
64
+ wrapped: string | undefined;
65
+ }
66
+ /**
67
+ * Prepare a description rewrite for `link-references --rewrite-description`.
68
+ * Same wrap-and-resolve core; different return shape so the caller can
69
+ * skip the rewrite mutation when nothing would change.
70
+ */
71
+ export declare function prepareDescriptionRewrite(description: string, selfIdentifier: string, linearService: LinearService, graphQLService: GraphQLService): Promise<PreparedRewrite>;
72
+ export declare function pushDescriptionUpdate(issueUuid: string, description: string, graphQLService: GraphQLService): Promise<void>;
73
+ interface MaybeAutoLinkInput {
74
+ description: string | null | undefined;
75
+ graphQLService: GraphQLService;
76
+ identifier: string;
77
+ issueId: string;
78
+ linearService: LinearService;
79
+ options: OptionValues;
80
+ preResolved?: Map<string, string>;
81
+ }
82
+ /**
83
+ * Run auto-linking for issue references found in a description, unless
84
+ * the user opted out with `--no-auto-link` or no description was
85
+ * provided. Returns undefined when nothing was linked / skipped /
86
+ * failed (so callers can omit the field from JSON output).
87
+ */
88
+ export declare function maybeAutoLink(input: MaybeAutoLinkInput): Promise<AutoLinkResult | undefined>;
89
+ export {};
@@ -0,0 +1,183 @@
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, { id: issueUuid, input: { description } });
151
+ if (!updateResult.issueUpdate.success) {
152
+ throw new Error("Failed to rewrite description");
153
+ }
154
+ }
155
+ /**
156
+ * Run auto-linking for issue references found in a description, unless
157
+ * the user opted out with `--no-auto-link` or no description was
158
+ * provided. Returns undefined when nothing was linked / skipped /
159
+ * failed (so callers can omit the field from JSON output).
160
+ */
161
+ export async function maybeAutoLink(input) {
162
+ const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
163
+ if (options.autoLink === false) {
164
+ return;
165
+ }
166
+ if (!description) {
167
+ return;
168
+ }
169
+ const result = await autoLinkReferences({
170
+ issueId,
171
+ identifier,
172
+ description,
173
+ graphQLService,
174
+ linearService,
175
+ preResolved,
176
+ });
177
+ if (result.linked.length === 0 &&
178
+ result.skipped.length === 0 &&
179
+ result.failed.length === 0) {
180
+ return;
181
+ }
182
+ return result;
183
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `el-linear issues link-references` handler.
3
+ *
4
+ * Two modes:
5
+ * - Single-issue (`<issueId>`): scan one issue's description (and
6
+ * optionally its comments) for issue refs, create sidebar
7
+ * relations for any that aren't already linked, optionally
8
+ * rewrite the description to wrap the bare refs as markdown
9
+ * links.
10
+ * - Batch (`--team <key>`): walk the team's recent issues and run
11
+ * the same auto-link pass on each. Description rewrite is
12
+ * intentionally disabled in batch mode — bulk rewrites
13
+ * bot-author every issue's revision history, which makes
14
+ * "who edited this?" attribution noisy.
15
+ *
16
+ * Extracted from `commands/issues.ts` (ALL-938) so that file can
17
+ * focus on commander wiring + the create / update / read / search
18
+ * handlers.
19
+ */
20
+ import type { Command, OptionValues } from "commander";
21
+ export declare function handleLinkReferencesIssue(issueIdOrTeamFlag: string | undefined, options: OptionValues, command: Command): Promise<void>;