@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
@@ -7,7 +7,15 @@ export interface IssueReference {
7
7
  }
8
8
  /**
9
9
  * Extract issue identifiers from text along with the inferred relation type.
10
- * Strips fenced code blocks before scanning to avoid false positives in pasted logs.
10
+ *
11
+ * Identifiers inside protected ranges are skipped — fenced code blocks,
12
+ * inline backticks, existing markdown links, Slack links, angle-bracket
13
+ * autolinks, and bare URLs. This is the same protection set used by
14
+ * `wrapIssueReferencesAsLinks`, so the two stay symmetric: a wrapped
15
+ * `[label](https://x/DEV-100)` won't have DEV-100 re-extracted as a
16
+ * phantom reference, and a bare URL like
17
+ * `https://github.com/org/repo/DEV-100.md` won't either. (See
18
+ * `protected-ranges.ts` for the shared scanner.)
11
19
  *
12
20
  * The relation type is inferred from prose keywords immediately before the identifier
13
21
  * (e.g. "blocked by DEV-100" → blocks/reverse=true). Default is `related`.
@@ -1,5 +1,4 @@
1
- const IDENTIFIER_REGEX = /\b([A-Z][A-Z0-9]*-\d+)\b/g;
2
- const FENCED_CODE_BLOCK_REGEX = /(?:^|\n)([ \t]*)(?:```|~~~)[^\n]*\n[\s\S]*?\n\1?(?:```|~~~)(?=\n|$)/g;
1
+ import { findProtectedRanges, IDENTIFIER_REGEX, isProtected, } from "./protected-ranges.js";
3
2
  /**
4
3
  * Window of text immediately before an identifier where we look for relation keywords.
5
4
  * 30 chars covers phrases like "is a prerequisite of" comfortably.
@@ -36,9 +35,6 @@ const PHRASE_PATTERNS = [
36
35
  reverse: false,
37
36
  },
38
37
  ];
39
- function stripFencedCodeBlocks(text) {
40
- return text.replace(FENCED_CODE_BLOCK_REGEX, "");
41
- }
42
38
  function inferRelation(textBefore) {
43
39
  const window = textBefore.slice(-KEYWORD_WINDOW);
44
40
  for (const { regex, type, reverse } of PHRASE_PATTERNS) {
@@ -63,7 +59,15 @@ function specificity(ref) {
63
59
  }
64
60
  /**
65
61
  * Extract issue identifiers from text along with the inferred relation type.
66
- * Strips fenced code blocks before scanning to avoid false positives in pasted logs.
62
+ *
63
+ * Identifiers inside protected ranges are skipped — fenced code blocks,
64
+ * inline backticks, existing markdown links, Slack links, angle-bracket
65
+ * autolinks, and bare URLs. This is the same protection set used by
66
+ * `wrapIssueReferencesAsLinks`, so the two stay symmetric: a wrapped
67
+ * `[label](https://x/DEV-100)` won't have DEV-100 re-extracted as a
68
+ * phantom reference, and a bare URL like
69
+ * `https://github.com/org/repo/DEV-100.md` won't either. (See
70
+ * `protected-ranges.ts` for the shared scanner.)
67
71
  *
68
72
  * The relation type is inferred from prose keywords immediately before the identifier
69
73
  * (e.g. "blocked by DEV-100" → blocks/reverse=true). Default is `related`.
@@ -75,15 +79,18 @@ export function extractIssueReferences(text, selfIdentifier) {
75
79
  if (!text) {
76
80
  return [];
77
81
  }
78
- const stripped = stripFencedCodeBlocks(text);
82
+ const ranges = findProtectedRanges(text);
79
83
  const byIdentifier = new Map();
80
- for (const match of stripped.matchAll(IDENTIFIER_REGEX)) {
84
+ for (const match of text.matchAll(IDENTIFIER_REGEX)) {
81
85
  const id = match[1];
82
86
  if (selfIdentifier && id === selfIdentifier) {
83
87
  continue;
84
88
  }
85
89
  const matchIndex = match.index ?? 0;
86
- const textBefore = stripped.slice(0, matchIndex);
90
+ if (isProtected(matchIndex, ranges)) {
91
+ continue;
92
+ }
93
+ const textBefore = text.slice(0, matchIndex);
87
94
  const { type, reverse } = inferRelation(textBefore);
88
95
  const candidate = { identifier: id, type, reverse };
89
96
  const existing = byIdentifier.get(id);
@@ -2,60 +2,7 @@
2
2
  // `getWorkspaceUrlKey(graphQLService)` to obtain it before invoking
3
3
  // `wrapIssueReferencesAsLinks`. Linear URLs look like
4
4
  // `https://linear.app/<urlKey>/issue/<identifier>/`.
5
- const IDENTIFIER_REGEX = /\b([A-Z][A-Z0-9]*-\d+)\b/g;
6
- const FENCED_CODE_BLOCK_REGEX = /(?:^|\n)([ \t]*)(?:```|~~~)[^\n]*\n[\s\S]*?\n\1?(?:```|~~~)(?=\n|$)/g;
7
- // Inline backtick spans — `EMW-258`. Markdown allows multiple backticks for spans containing
8
- // backticks; we keep it simple and match single-backtick spans.
9
- const INLINE_CODE_REGEX = /`[^`\n]+?`/g;
10
- // Existing markdown links: [text](url). We protect both the text and the url.
11
- const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]*)\)/g;
12
- // Existing Slack mrkdwn links: <url|text>. We protect both halves so identifiers
13
- // inside an already-wrapped Slack link aren't double-wrapped.
14
- const SLACK_LINK_REGEX = /<https?:\/\/[^|>\s]+\|[^>]*>/g;
15
- // Angle-bracket autolinks: <https://...>
16
- const ANGLE_AUTOLINK_REGEX = /<[^>\s]+>/g;
17
- // Bare URLs in prose. We protect these so identifiers inside paths
18
- // (e.g. "https://github.com/foo/DEV-100") don't get split by wrapping.
19
- const BARE_URL_REGEX = /https?:\/\/\S+/g;
20
- /**
21
- * Find ranges of `text` that should NOT have identifiers wrapped:
22
- * - Fenced code blocks (``` or ~~~)
23
- * - Inline backtick code spans
24
- * - Existing markdown links — both `[text]` and `(url)`
25
- * - Angle-bracket autolinks
26
- *
27
- * The returned ranges may overlap; callers only need to test "is this position
28
- * inside any protected range" so overlap is fine.
29
- */
30
- function findProtectedRanges(text) {
31
- const ranges = [];
32
- for (const re of [
33
- FENCED_CODE_BLOCK_REGEX,
34
- INLINE_CODE_REGEX,
35
- MARKDOWN_LINK_REGEX,
36
- // Slack links must be protected before generic angle-bracket autolinks,
37
- // otherwise the autolink regex would still match `<https://…|label>` because
38
- // it doesn't include the pipe boundary. matchAll resets per regex so order
39
- // in this list is irrelevant for correctness — both ranges still cover the span.
40
- SLACK_LINK_REGEX,
41
- ANGLE_AUTOLINK_REGEX,
42
- BARE_URL_REGEX,
43
- ]) {
44
- for (const m of text.matchAll(re)) {
45
- const start = m.index ?? 0;
46
- ranges.push({ start, end: start + m[0].length });
47
- }
48
- }
49
- return ranges;
50
- }
51
- function isProtected(pos, ranges) {
52
- for (const r of ranges) {
53
- if (pos >= r.start && pos < r.end) {
54
- return true;
55
- }
56
- }
57
- return false;
58
- }
5
+ import { findProtectedRanges, IDENTIFIER_REGEX, isProtected, } from "./protected-ranges.js";
59
6
  function buildIssueUrl(identifier, workspaceUrlKey) {
60
7
  return `https://linear.app/${workspaceUrlKey}/issue/${identifier}/`;
61
8
  }
@@ -21,12 +21,16 @@ export declare class LinearService {
21
21
  resolveIssueId(issueId: string): Promise<string>;
22
22
  getTeams(limit?: number): Promise<LinearTeam[]>;
23
23
  resolveUserId(nameOrEmailOrId: string): Promise<string>;
24
- getUsers(activeOnly?: boolean, limit?: number): Promise<LinearUser[]>;
25
- getProjects(limit?: number): Promise<LinearProject[]>;
24
+ getUsers(activeOnly?: boolean, limit?: number, nameFilter?: string): Promise<LinearUser[]>;
25
+ getProjects(limit?: number, options?: {
26
+ nameFilter?: string;
27
+ states?: string[];
28
+ excludeStates?: string[];
29
+ }): Promise<LinearProject[]>;
26
30
  resolveTeamId(teamKeyOrNameOrId: string): Promise<string>;
27
31
  resolveStatusId(statusName: string, teamId?: string): Promise<string>;
28
32
  private buildLabelData;
29
- getLabels(teamFilter?: string, limit?: number): Promise<{
33
+ getLabels(teamFilter?: string, limit?: number, nameFilter?: string): Promise<{
30
34
  labels: LinearLabel[];
31
35
  }>;
32
36
  createComment(args: {
@@ -101,11 +101,14 @@ export class LinearService {
101
101
  }
102
102
  throw notFoundError("User", nameOrEmailOrId);
103
103
  }
104
- async getUsers(activeOnly, limit = 100) {
104
+ async getUsers(activeOnly, limit = 100, nameFilter) {
105
105
  const filter = {};
106
106
  if (activeOnly) {
107
107
  filter.active = eqFilter(true);
108
108
  }
109
+ if (nameFilter) {
110
+ filter.name = { containsIgnoreCase: nameFilter };
111
+ }
109
112
  const usersConnection = await this.client.users({
110
113
  filter: nonEmptyFilter(filter),
111
114
  first: limit,
@@ -119,8 +122,19 @@ export class LinearService {
119
122
  }));
120
123
  return users.sort((a, b) => a.name.localeCompare(b.name));
121
124
  }
122
- async getProjects(limit = 100) {
125
+ async getProjects(limit = 100, options = {}) {
126
+ const filter = {};
127
+ if (options.nameFilter) {
128
+ filter.name = { containsIgnoreCase: options.nameFilter };
129
+ }
130
+ if (options.states && options.states.length > 0) {
131
+ filter.state = { in: options.states };
132
+ }
133
+ else if (options.excludeStates && options.excludeStates.length > 0) {
134
+ filter.state = { nin: options.excludeStates };
135
+ }
123
136
  const projects = await this.client.projects({
137
+ filter: nonEmptyFilter(filter),
124
138
  first: limit,
125
139
  orderBy: sdkOrderBy("updatedAt"),
126
140
  includeArchived: false,
@@ -235,13 +249,18 @@ export class LinearService {
235
249
  }
236
250
  return labelData;
237
251
  }
238
- async getLabels(teamFilter, limit = 100) {
252
+ async getLabels(teamFilter, limit = 100, nameFilter) {
239
253
  const labels = [];
254
+ const labelFilter = {};
255
+ if (nameFilter) {
256
+ labelFilter.name = { containsIgnoreCase: nameFilter };
257
+ }
240
258
  if (teamFilter) {
241
259
  const teamId = await this.resolveTeamId(teamFilter);
242
260
  const team = await this.client.team(teamId);
261
+ labelFilter.team = teamIdFilter(teamId);
243
262
  const teamLabels = await this.client.issueLabels({
244
- filter: { team: teamIdFilter(teamId) },
263
+ filter: labelFilter,
245
264
  first: limit,
246
265
  });
247
266
  const teamRef = { id: team.id, key: team.key, name: team.name };
@@ -253,7 +272,10 @@ export class LinearService {
253
272
  }
254
273
  }
255
274
  else {
256
- const allLabels = await this.client.issueLabels({ first: limit });
275
+ const allLabels = await this.client.issueLabels({
276
+ filter: nonEmptyFilter(labelFilter),
277
+ first: limit,
278
+ });
257
279
  for (const label of allLabels.nodes) {
258
280
  if (label.isGroup) {
259
281
  continue;
@@ -20,6 +20,22 @@ const INLINE_CODE_RE = /`([^`]+)`/;
20
20
  const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/;
21
21
  const INLINE_BOLD_RE = /\*\*(.+?)\*\*|__(.+?)__/;
22
22
  const INLINE_ITALIC_RE = /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/;
23
+ /**
24
+ * Allowlist of URL schemes accepted on link marks. Anything else
25
+ * (`javascript:`, `data:`, `vbscript:`, `file:`) is silently dropped
26
+ * so we don't ship XSS-shaped ProseMirror docs into Linear comments
27
+ * or issue descriptions. Schemeless / relative links pass through —
28
+ * those resolve against the document base.
29
+ */
30
+ const SAFE_LINK_SCHEMES = new Set(["http:", "https:", "mailto:", "linear:"]);
31
+ function isSafeLinkScheme(href) {
32
+ const trimmed = href.trim();
33
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed))
34
+ return true;
35
+ const colon = trimmed.indexOf(":");
36
+ const scheme = trimmed.slice(0, colon + 1).toLowerCase();
37
+ return SAFE_LINK_SCHEMES.has(scheme);
38
+ }
23
39
  export function markdownToProseMirror(text) {
24
40
  const state = { lines: text.split("\n"), content: [], i: 0 };
25
41
  while (state.i < state.lines.length) {
@@ -297,7 +313,7 @@ function findEarliestInlineMatch(text) {
297
313
  });
298
314
  }
299
315
  const linkMatch = text.match(INLINE_LINK_RE);
300
- if (linkMatch) {
316
+ if (linkMatch && isSafeLinkScheme(linkMatch[2])) {
301
317
  candidates.push({
302
318
  index: linkMatch.index ?? 0,
303
319
  length: linkMatch[0].length,
@@ -2,7 +2,10 @@ import { loadConfig } from "../config/config.js";
2
2
  import { resolveMember } from "../config/resolver.js";
3
3
  import { markdownToProseMirror } from "./markdown-prosemirror.js";
4
4
  import { isUuid } from "./uuid.js";
5
- const EXPLICIT_MENTION_REGEX = /@(\w+)/g;
5
+ // Unicode-aware mention regex: ASCII-only `\w` would silently fail to
6
+ // match Cyrillic / accented Latin / CJK names (`@Юрий`, `@Niño`).
7
+ // `\p{L}` covers all Unicode letters; `\p{N}` covers all numerics.
8
+ const EXPLICIT_MENTION_REGEX = /@([\p{L}\p{N}_]+)/gu;
6
9
  const WHITESPACE_SPLIT_REGEX = /\s+/;
7
10
  const FENCED_CODE_REGEX = /```[\s\S]*?```/g;
8
11
  const INLINE_CODE_REGEX = /`[^`]+`/g;
@@ -40,7 +43,11 @@ export async function resolveMentions(body, linearService, options = {}) {
40
43
  if (autoMention) {
41
44
  const stripped = stripCodeAndLinks(body);
42
45
  for (const candidate of getBareMentionCandidates()) {
43
- const pattern = new RegExp(`\\b${escapeRegex(candidate)}\\b`);
46
+ // Unicode-aware "word boundary": ASCII `\b` doesn't fire on
47
+ // Cyrillic / accented Latin / CJK chars, so a candidate like
48
+ // `Юрий` would silently fail to match. Lookarounds against
49
+ // the same letter+number+underscore class behave correctly.
50
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(candidate)}(?![\\p{L}\\p{N}_])`, "u");
44
51
  if (!pattern.test(stripped)) {
45
52
  continue;
46
53
  }
@@ -215,19 +222,24 @@ function splitMentions(node, explicit, bare) {
215
222
  function buildCombinedRegex(explicit, bare) {
216
223
  const parts = [];
217
224
  if (explicit.size > 0) {
218
- parts.push("@\\w+");
225
+ // Same Unicode-aware character class as EXPLICIT_MENTION_REGEX
226
+ // at module top so the splitter and the resolver agree on
227
+ // what counts as a name char.
228
+ parts.push("@[\\p{L}\\p{N}_]+");
219
229
  }
220
230
  if (bare.size > 0) {
221
231
  const alternation = [...bare.keys()]
222
232
  .sort((a, b) => b.length - a.length)
223
233
  .map(escapeRegex)
224
234
  .join("|");
225
- parts.push(`\\b(?:${alternation})\\b`);
235
+ // Unicode-aware "word boundary": no `\b` (ASCII-only); use
236
+ // lookarounds against the same letter+number+underscore class.
237
+ parts.push(`(?<![\\p{L}\\p{N}_])(?:${alternation})(?![\\p{L}\\p{N}_])`);
226
238
  }
227
239
  if (parts.length === 0) {
228
240
  return null;
229
241
  }
230
- return new RegExp(parts.join("|"), "g");
242
+ return new RegExp(parts.join("|"), "gu");
231
243
  }
232
244
  function escapeRegex(s) {
233
245
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1,7 +1,13 @@
1
+ export type OutputFormat = "json" | "summary";
1
2
  export declare function setRawMode(enabled: boolean): void;
2
3
  export declare function setJqFilter(filter: string | null): void;
3
4
  export declare function setFieldsFilter(fields: string[] | null): void;
5
+ export declare function setOutputFormat(format: OutputFormat): void;
6
+ /** @internal Test seam — consumers should not depend on the format state. */
7
+ export declare function getOutputFormat(): OutputFormat;
4
8
  export declare function outputSuccess(data: unknown): void;
5
- export declare function outputWarning(message: string | string[], _type?: string): void;
9
+ export declare function outputWarning(message: string | string[]): void;
6
10
  export declare function resetWarnings(): void;
11
+ /** @internal Test seam — call between tests that toggle `setOutputFormat`. */
12
+ export declare function resetOutputFormat(): void;
7
13
  export declare function handleAsyncCommand<TArgs extends unknown[]>(asyncFn: (...args: TArgs) => Promise<void>): (...args: TArgs) => Promise<void>;
@@ -1,9 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { dispatch as dispatchSummary, inferKindFromPayload, } from "./formatters/summary.js";
2
3
  import { logger } from "./logger.js";
3
4
  const warningBuffer = [];
4
5
  let rawMode = false;
5
6
  let jqFilter = null;
6
7
  let fieldsFilter = null;
8
+ let outputFormat = "json";
7
9
  export function setRawMode(enabled) {
8
10
  rawMode = enabled;
9
11
  }
@@ -13,6 +15,13 @@ export function setJqFilter(filter) {
13
15
  export function setFieldsFilter(fields) {
14
16
  fieldsFilter = fields;
15
17
  }
18
+ export function setOutputFormat(format) {
19
+ outputFormat = format;
20
+ }
21
+ /** @internal Test seam — consumers should not depend on the format state. */
22
+ export function getOutputFormat() {
23
+ return outputFormat;
24
+ }
16
25
  function filterFields(obj, fields) {
17
26
  if (Array.isArray(obj)) {
18
27
  return obj.map((item) => filterFields(item, fields));
@@ -29,6 +38,18 @@ function filterFields(obj, fields) {
29
38
  }
30
39
  return obj;
31
40
  }
41
+ /**
42
+ * Emit a summary-format render to stdout for the given payload.
43
+ *
44
+ * `kind` is captured upstream from the **pre-filter** payload — if we
45
+ * inferred here, `--fields identifier,url` would strip `title` and
46
+ * the heuristic would fall through to "generic", silently breaking
47
+ * the issue-list table. Caching the pre-filter shape keeps the
48
+ * formatter accurate regardless of how the user pared the JSON.
49
+ */
50
+ function emitSummary(payload, kind) {
51
+ logger.info(dispatchSummary(kind, payload));
52
+ }
32
53
  export function outputSuccess(data) {
33
54
  const warnings = drainWarnings();
34
55
  let output;
@@ -41,6 +62,11 @@ export function outputSuccess(data) {
41
62
  else {
42
63
  output = data;
43
64
  }
65
+ // Capture the kind from the original envelope shape BEFORE --raw /
66
+ // --fields stripping. Otherwise filtering away signature fields (e.g.
67
+ // `title` on an issue) breaks shape inference and the summary
68
+ // formatter falls back to the generic key-value dump.
69
+ const inferredKind = outputFormat === "summary" ? inferKindFromPayload(output) : "generic";
44
70
  // --raw: unwrap { data: [...] } to just the array
45
71
  if (rawMode &&
46
72
  output !== null &&
@@ -66,6 +92,13 @@ export function outputSuccess(data) {
66
92
  }
67
93
  }
68
94
  }
95
+ // summary format takes the post-raw / post-fields value and renders it
96
+ // as a human-readable block. We bypass the jq path because jq is a
97
+ // JSON-shape filter — it doesn't compose with text output.
98
+ if (outputFormat === "summary") {
99
+ emitSummary(output, inferredKind);
100
+ return;
101
+ }
69
102
  if (jqFilter) {
70
103
  const json = JSON.stringify(output);
71
104
  // Normalize common shell-escape artifacts (zsh history expansion)
@@ -87,7 +120,7 @@ export function outputSuccess(data) {
87
120
  logger.info(JSON.stringify(output, null, 2));
88
121
  }
89
122
  }
90
- export function outputWarning(message, _type) {
123
+ export function outputWarning(message) {
91
124
  const messages = Array.isArray(message) ? message : [message];
92
125
  for (const msg of messages) {
93
126
  warningBuffer.push(msg);
@@ -103,6 +136,10 @@ function drainWarnings() {
103
136
  export function resetWarnings() {
104
137
  warningBuffer.length = 0;
105
138
  }
139
+ /** @internal Test seam — call between tests that toggle `setOutputFormat`. */
140
+ export function resetOutputFormat() {
141
+ outputFormat = "json";
142
+ }
106
143
  function outputError(error) {
107
144
  const payload = JSON.stringify({ error: error.message }, null, 2);
108
145
  // Write to stdout (same channel as success) so machine callers always
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared "protected ranges" scanner — used by both
3
+ * `issue-reference-wrapper` and `issue-reference-extractor` so they
4
+ * agree on which spans of input text should be excluded from
5
+ * identifier processing.
6
+ *
7
+ * Why this exists: a transform/consumer pair that disagree about what
8
+ * counts as "protected" silently produces phantom relations or
9
+ * double-wrapped links. Concretely, the wrapper protects fenced code,
10
+ * inline code, markdown links, Slack links, angle-bracket autolinks,
11
+ * AND bare URLs; the extractor used to only strip fenced code, so
12
+ * `[click](https://x/DEV-100)` would wrap correctly but the auto-link
13
+ * extractor would still detect DEV-100 inside the URL and create a
14
+ * phantom relation. Same for `https://github.com/org/repo/DEV-100.md`.
15
+ *
16
+ * Centralising the scanner makes both consumers agree by construction.
17
+ */
18
+ /** Linear identifier shape: ABC-123, EMW-1, DEV-3592. */
19
+ export declare const IDENTIFIER_REGEX: RegExp;
20
+ export interface ProtectedRange {
21
+ end: number;
22
+ start: number;
23
+ }
24
+ /**
25
+ * Find ranges of `text` that should NOT have identifiers processed.
26
+ * Covered: fenced code, inline backticks, existing markdown links,
27
+ * Slack links, angle-bracket autolinks, bare URLs.
28
+ *
29
+ * The returned ranges may overlap; callers only test "is this position
30
+ * inside any protected range" so overlap is harmless.
31
+ */
32
+ export declare function findProtectedRanges(text: string): ProtectedRange[];
33
+ export declare function isProtected(pos: number, ranges: ProtectedRange[]): boolean;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared "protected ranges" scanner — used by both
3
+ * `issue-reference-wrapper` and `issue-reference-extractor` so they
4
+ * agree on which spans of input text should be excluded from
5
+ * identifier processing.
6
+ *
7
+ * Why this exists: a transform/consumer pair that disagree about what
8
+ * counts as "protected" silently produces phantom relations or
9
+ * double-wrapped links. Concretely, the wrapper protects fenced code,
10
+ * inline code, markdown links, Slack links, angle-bracket autolinks,
11
+ * AND bare URLs; the extractor used to only strip fenced code, so
12
+ * `[click](https://x/DEV-100)` would wrap correctly but the auto-link
13
+ * extractor would still detect DEV-100 inside the URL and create a
14
+ * phantom relation. Same for `https://github.com/org/repo/DEV-100.md`.
15
+ *
16
+ * Centralising the scanner makes both consumers agree by construction.
17
+ */
18
+ /** Linear identifier shape: ABC-123, EMW-1, DEV-3592. */
19
+ export const IDENTIFIER_REGEX = /\b([A-Z][A-Z0-9]*-\d+)\b/g;
20
+ const FENCED_CODE_BLOCK_REGEX = /(?:^|\n)([ \t]*)(?:```|~~~)[^\n]*\n[\s\S]*?\n\1?(?:```|~~~)(?=\n|$)/g;
21
+ // Inline backtick spans — `EMW-258`. Markdown allows multiple backticks
22
+ // for spans containing backticks; we keep it simple and match
23
+ // single-backtick spans, which covers the common case.
24
+ const INLINE_CODE_REGEX = /`[^`\n]+?`/g;
25
+ // Existing markdown links: [text](url). Both halves protected so
26
+ // identifiers inside the URL or the text aren't reprocessed.
27
+ const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]*)\)/g;
28
+ // Existing Slack mrkdwn links: <url|text>. Both halves protected for
29
+ // the same reason as markdown links.
30
+ const SLACK_LINK_REGEX = /<https?:\/\/[^|>\s]+\|[^>]*>/g;
31
+ // Angle-bracket autolinks: <https://...>
32
+ const ANGLE_AUTOLINK_REGEX = /<[^>\s]+>/g;
33
+ // Bare URLs in prose. We protect these so identifiers inside path
34
+ // components (e.g. "https://github.com/foo/DEV-100") don't get
35
+ // processed.
36
+ const BARE_URL_REGEX = /https?:\/\/\S+/g;
37
+ /**
38
+ * Find ranges of `text` that should NOT have identifiers processed.
39
+ * Covered: fenced code, inline backticks, existing markdown links,
40
+ * Slack links, angle-bracket autolinks, bare URLs.
41
+ *
42
+ * The returned ranges may overlap; callers only test "is this position
43
+ * inside any protected range" so overlap is harmless.
44
+ */
45
+ export function findProtectedRanges(text) {
46
+ const ranges = [];
47
+ for (const re of [
48
+ FENCED_CODE_BLOCK_REGEX,
49
+ INLINE_CODE_REGEX,
50
+ MARKDOWN_LINK_REGEX,
51
+ // Slack links must be considered before generic angle-bracket
52
+ // autolinks — the autolink regex doesn't know about the `|label`
53
+ // boundary, but matchAll resets per regex so order is just for
54
+ // clarity here.
55
+ SLACK_LINK_REGEX,
56
+ ANGLE_AUTOLINK_REGEX,
57
+ BARE_URL_REGEX,
58
+ ]) {
59
+ for (const m of text.matchAll(re)) {
60
+ const start = m.index ?? 0;
61
+ ranges.push({ start, end: start + m[0].length });
62
+ }
63
+ }
64
+ return ranges;
65
+ }
66
+ export function isProtected(pos, ranges) {
67
+ for (const r of ranges) {
68
+ if (pos >= r.start && pos < r.end) {
69
+ return true;
70
+ }
71
+ }
72
+ return false;
73
+ }
@@ -1,4 +1,40 @@
1
1
  import type { LinearIssue } from "../types/linear.js";
2
+ /**
3
+ * Column definition for the fixed-width text and CSV renderers.
4
+ * Generic over the row type `T` so the same renderers serve issues,
5
+ * projects, and any future resource that wants table output. The
6
+ * `colorize` hook receives the row so it can branch on entity-
7
+ * specific fields (e.g. the issue's priority or status); pass
8
+ * undefined to skip color.
9
+ */
10
+ export interface ColumnDef<T> {
11
+ colorize?: (value: string, row: T) => string;
12
+ extract: (row: T) => string;
13
+ header: string;
14
+ key: string;
15
+ width: number;
16
+ }
17
+ /**
18
+ * Render `rows` as a fixed-width text table using `columns`. Generic
19
+ * over the row type `T` so issues, projects, and other resources
20
+ * share the same width-padding + colorize machinery.
21
+ */
22
+ export declare function renderFixedWidthTable<T>(rows: T[], columns: ColumnDef<T>[]): string;
23
+ /**
24
+ * Render `rows` as CSV using `columns`. Quotes any value containing
25
+ * `,` or `"` (RFC 4180-style escaping). Generic over the row type.
26
+ */
27
+ export declare function renderCsv<T>(rows: T[], columns: ColumnDef<T>[]): string;
2
28
  export declare function formatTable(issues: LinearIssue[], fieldNames?: string[]): string;
3
29
  export declare function formatCsv(issues: LinearIssue[], fieldNames?: string[]): string;
30
+ export interface MarkdownColumnDef<T> {
31
+ align?: "left" | "right";
32
+ extract: (row: T) => string;
33
+ header: string;
34
+ }
35
+ /**
36
+ * Render `rows` as a markdown table using `columns`. Generic over
37
+ * the row type. Escapes `|` and newlines in cell values.
38
+ */
39
+ export declare function renderMarkdownTable<T>(rows: T[], columns: MarkdownColumnDef<T>[]): string;
4
40
  export declare function formatMarkdown(issues: LinearIssue[], fieldNames?: string[]): string;