@enrichlayer/el-linear 1.7.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 (62) hide show
  1. package/README.md +69 -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/init/index.js +2 -0
  13. package/dist/commands/init/oauth.d.ts +6 -0
  14. package/dist/commands/init/oauth.js +8 -2
  15. package/dist/commands/init/token.d.ts +0 -8
  16. package/dist/commands/init/token.js +13 -1
  17. package/dist/commands/issues/branch.d.ts +17 -0
  18. package/dist/commands/issues/branch.js +40 -0
  19. package/dist/commands/issues/description.d.ts +89 -0
  20. package/dist/commands/issues/description.js +187 -0
  21. package/dist/commands/issues.js +34 -215
  22. package/dist/commands/labels.js +7 -5
  23. package/dist/commands/profile/migrate-legacy.js +10 -33
  24. package/dist/commands/profile.js +1 -10
  25. package/dist/commands/projects.d.ts +5 -1
  26. package/dist/commands/projects.js +135 -64
  27. package/dist/commands/read-shortcut.d.ts +6 -0
  28. package/dist/commands/read-shortcut.js +6 -1
  29. package/dist/commands/refs.js +2 -1
  30. package/dist/commands/templates.js +127 -1
  31. package/dist/commands/users.js +2 -1
  32. package/dist/config/config.js +31 -9
  33. package/dist/config/issue-validation.js +1 -1
  34. package/dist/config/paths.d.ts +11 -0
  35. package/dist/config/paths.js +44 -6
  36. package/dist/config/term-enforcer.js +1 -1
  37. package/dist/main.js +37 -2
  38. package/dist/queries/templates.d.ts +3 -0
  39. package/dist/queries/templates.js +43 -0
  40. package/dist/utils/auth.js +7 -9
  41. package/dist/utils/auto-link-references.js +15 -1
  42. package/dist/utils/disk-cache.js +2 -1
  43. package/dist/utils/formatters/summary.d.ts +62 -0
  44. package/dist/utils/formatters/summary.js +755 -0
  45. package/dist/utils/graphql-issues-service.d.ts +106 -3
  46. package/dist/utils/graphql-issues-service.js +51 -37
  47. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  48. package/dist/utils/issue-reference-extractor.js +16 -9
  49. package/dist/utils/issue-reference-wrapper.js +1 -54
  50. package/dist/utils/linear-service.d.ts +7 -3
  51. package/dist/utils/linear-service.js +27 -5
  52. package/dist/utils/markdown-prosemirror.js +17 -1
  53. package/dist/utils/mention-resolver.js +17 -5
  54. package/dist/utils/output.d.ts +5 -1
  55. package/dist/utils/output.js +28 -1
  56. package/dist/utils/protected-ranges.d.ts +33 -0
  57. package/dist/utils/protected-ranges.js +73 -0
  58. package/dist/utils/table-formatter.d.ts +36 -0
  59. package/dist/utils/table-formatter.js +46 -24
  60. package/dist/utils/validators.d.ts +9 -1
  61. package/dist/utils/validators.js +10 -0
  62. package/package.json +1 -1
@@ -1,19 +1,122 @@
1
1
  import type { GraphQLResponseData, LinearIssue } from "../types/linear.js";
2
2
  import type { GraphQLService } from "./graphql-service.js";
3
3
  import type { LinearService } from "./linear-service.js";
4
+ /**
5
+ * Arguments accepted by `GraphQLIssuesService.searchIssues`. Two
6
+ * modes:
7
+ * - When `query` is set, runs Linear's full-text search and
8
+ * post-filters the results with the structured filters below.
9
+ * - When `query` is unset, runs a structured filter directly.
10
+ */
11
+ export interface SearchIssueArgs {
12
+ /** Free-text search term. When set, `searchIssues` uses Linear's
13
+ * native full-text search. */
14
+ query?: string;
15
+ /** Team key, name, or UUID. */
16
+ teamId?: string;
17
+ /** Project name or UUID. */
18
+ projectId?: string;
19
+ /** Set true to match only issues with NO project. Mutually
20
+ * exclusive with `projectId`. */
21
+ noProject?: boolean;
22
+ /** Assignee name, email, alias, or UUID. */
23
+ assigneeId?: string;
24
+ /** Issue states to include (e.g. `["Todo", "In Progress"]`). */
25
+ status?: string[];
26
+ /** Label names to require (intersection). */
27
+ labelNames?: string[];
28
+ /** Priority values (0-4) to include. */
29
+ priority?: number[];
30
+ /** Result cap; defaults to 10 when not set. */
31
+ limit?: number;
32
+ /** Linear orderBy enum (`createdAt` / `updatedAt`). */
33
+ orderBy?: string;
34
+ }
35
+ /**
36
+ * Arguments accepted by `GraphQLIssuesService.updateIssue`. Same
37
+ * field shapes as `CreateIssueArgs` (UUID-or-name where applicable),
38
+ * but with `id` required (the issue to update) and `subscriberIds` /
39
+ * `templateId` omitted (not valid on update).
40
+ */
41
+ export interface UpdateIssueArgs {
42
+ /** Issue identifier (e.g. `DEV-3592`) or UUID. Required. */
43
+ id: string;
44
+ title?: string;
45
+ description?: string;
46
+ /** Assignee name, email, alias, or UUID. */
47
+ assigneeId?: string;
48
+ priority?: number;
49
+ projectId?: string;
50
+ statusId?: string;
51
+ /**
52
+ * Hint for the team-aware label resolver when the issue is being
53
+ * moved between teams.
54
+ */
55
+ teamId?: string;
56
+ labelIds?: string[];
57
+ parentId?: string;
58
+ milestoneId?: string;
59
+ cycleId?: string | null;
60
+ dueDate?: string;
61
+ estimate?: number;
62
+ }
63
+ /**
64
+ * Arguments accepted by `GraphQLIssuesService.createIssue`. Most fields
65
+ * may be passed as a Linear UUID OR a name/identifier — the service
66
+ * resolves names to UUIDs against the workspace before issuing the
67
+ * mutation. The few fields that must already be UUIDs are noted on
68
+ * the property docstring.
69
+ *
70
+ * Pre-fix this was `Record<string, unknown>` and every command-side
71
+ * caller built up a free-form object. A typo in `assigeeId` vs
72
+ * `assigneeId` compiled cleanly and silently dropped the field. With
73
+ * the typed shape, `tsc` catches the typo.
74
+ */
75
+ export interface CreateIssueArgs {
76
+ title?: string;
77
+ /** Team key, name, or UUID. Required unless `templateId` is set. */
78
+ teamId?: string;
79
+ /** Original team token (key or name) — used in label resolution error messages. */
80
+ teamInput?: string;
81
+ description?: string;
82
+ /** Assignee name, email, alias, or UUID. */
83
+ assigneeId?: string;
84
+ /** Linear priority: 0 (none), 1 (urgent), 2 (high), 3 (medium), 4 (low). */
85
+ priority?: number;
86
+ /** Project name or UUID. */
87
+ projectId?: string;
88
+ /** Status name or UUID — resolved per team. */
89
+ statusId?: string;
90
+ /** Label names or UUIDs — auto-created on the team if missing. */
91
+ labelIds?: string[];
92
+ /** Parent issue identifier or UUID. */
93
+ parentId?: string;
94
+ /** Project milestone name or UUID — resolved within the project. */
95
+ milestoneId?: string;
96
+ /** Cycle number, name, or UUID — resolved per team. */
97
+ cycleId?: string;
98
+ /** Subscriber names, emails, aliases, or UUIDs. */
99
+ subscriberIds?: string[];
100
+ /** Due date, ISO 8601. */
101
+ dueDate?: string;
102
+ /** Story-points / estimate. */
103
+ estimate?: number;
104
+ /** Linear server-side template UUID for `--from-template`. */
105
+ templateId?: string;
106
+ }
4
107
  export declare class GraphQLIssuesService {
5
108
  private readonly graphQLService;
6
109
  private readonly linearService;
7
110
  constructor(graphQLService: GraphQLService, linearService: LinearService);
8
111
  getIssues(limit?: number): Promise<LinearIssue[]>;
9
112
  getIssueById(issueId: string): Promise<LinearIssue>;
10
- updateIssue(args: Record<string, unknown>, labelMode?: string): Promise<LinearIssue>;
113
+ updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
11
114
  private extractMilestoneNodes;
12
115
  private executeUpdateMutation;
13
- createIssue(args: Record<string, unknown>): Promise<LinearIssue>;
116
+ createIssue(args: CreateIssueArgs): Promise<LinearIssue>;
14
117
  private resolveCreateFields;
15
118
  private executeCreateMutation;
16
- searchIssues(args: Record<string, unknown>): Promise<LinearIssue[]>;
119
+ searchIssues(args: SearchIssueArgs): Promise<LinearIssue[]>;
17
120
  private resolveTeamId;
18
121
  private resolveProjectId;
19
122
  /**
@@ -5,6 +5,7 @@ import { toISOStringOrNow } from "./date-format.js";
5
5
  import { extractEmbeds } from "./embed-parser.js";
6
6
  import { notFoundError } from "./error-messages.js";
7
7
  import { parseIssueIdentifier, tryParseIssueIdentifier, } from "./identifier-parser.js";
8
+ import { logger } from "./logger.js";
8
9
  import { isUuid } from "./uuid.js";
9
10
  const TEAM_KEY_REGEX = /^[A-Z0-9]+$/i;
10
11
  function extractSummaryText(summary) {
@@ -76,15 +77,18 @@ export class GraphQLIssuesService {
76
77
  const finalLabelIds = await this.resolveLabelsWithMode(args.labelIds, resolveResult, labelMode, currentIssueLabels, teamIdForLabels, args.teamId);
77
78
  const finalProjectId = args.projectId
78
79
  ? this.resolveProjectId(args.projectId, resolveResult)
79
- : args.projectId;
80
+ : undefined;
80
81
  const { projectMilestoneNodes, issueProjectMilestoneNodes } = this.extractMilestoneNodes(args, resolveResult);
81
82
  const finalMilestoneId = this.resolveMilestoneId(args.milestoneId, resolveResult, projectMilestoneNodes, issueProjectMilestoneNodes);
82
83
  const finalCycleId = await this.resolveCycleIdForUpdate(args, resolveResult, resolvedIssueId);
83
84
  const resolvedStatusId = await this.resolveStatusIdForUpdate(args, resolvedIssueId);
84
- if (args.assigneeId && !isUuid(args.assigneeId)) {
85
- args.assigneeId = await this.linearService.resolveUserId(args.assigneeId);
85
+ // Resolve assignee in place — `buildUpdateInput` reads it
86
+ // directly. Match the createIssue pattern.
87
+ let assigneeId = args.assigneeId;
88
+ if (assigneeId && !isUuid(assigneeId)) {
89
+ assigneeId = await this.linearService.resolveUserId(assigneeId);
86
90
  }
87
- const updateInput = this.buildUpdateInput(args, {
91
+ const updateInput = this.buildUpdateInput({ ...args, assigneeId }, {
88
92
  statusId: resolvedStatusId,
89
93
  projectId: finalProjectId,
90
94
  labelIds: finalLabelIds,
@@ -150,35 +154,37 @@ export class GraphQLIssuesService {
150
154
  }
151
155
  }
152
156
  const resolved = await this.resolveCreateFields(args, resolveResult);
153
- if (args.assigneeId && !isUuid(args.assigneeId)) {
154
- args.assigneeId = await this.linearService.resolveUserId(args.assigneeId);
155
- }
156
- const createInput = this.buildCreateInput(args, resolved);
157
+ // Mutate-the-input feels gross but matches the existing pattern
158
+ // — `resolved` doesn't carry assigneeId, so the buildCreateInput
159
+ // step reads `args.assigneeId` directly. Resolve in place.
160
+ let assigneeId = args.assigneeId;
161
+ if (assigneeId && !isUuid(assigneeId)) {
162
+ assigneeId = await this.linearService.resolveUserId(assigneeId);
163
+ }
164
+ const createInput = this.buildCreateInput({ ...args, assigneeId }, resolved);
157
165
  return this.executeCreateMutation(createInput);
158
166
  }
159
167
  async resolveCreateFields(args, resolveResult) {
160
168
  let teamId = args.teamId
161
169
  ? await this.resolveTeamId(args.teamId, resolveResult)
162
- : args.teamId;
170
+ : undefined;
163
171
  const projectId = args.projectId
164
172
  ? this.resolveProjectId(args.projectId, resolveResult)
165
- : args.projectId;
173
+ : undefined;
166
174
  // Validate team-project compatibility and auto-correct when possible
167
- if (projectId && teamId) {
175
+ if (projectId && teamId && args.projectId) {
168
176
  teamId = this.validateProjectTeam(teamId, args.projectId, resolveResult);
169
177
  }
170
178
  const labelIds = await this.resolveLabels(args.labelIds, resolveResult, teamId, args.teamInput);
171
179
  const parentId = this.resolveParentId(args.parentId, resolveResult);
172
180
  const milestoneId = this.resolveMilestoneIdForCreate(args.milestoneId, resolveResult, projectId);
173
181
  let cycleId = args.cycleId;
174
- if (args.cycleId &&
175
- typeof args.cycleId === "string" &&
176
- !isUuid(args.cycleId)) {
177
- cycleId = await this.linearService.resolveCycleId(args.cycleId, teamId);
182
+ if (cycleId && !isUuid(cycleId)) {
183
+ cycleId = await this.linearService.resolveCycleId(cycleId, teamId);
178
184
  }
179
185
  let statusId = args.statusId;
180
- if (args.statusId && !isUuid(args.statusId)) {
181
- statusId = await this.linearService.resolveStatusId(args.statusId, teamId);
186
+ if (statusId && !isUuid(statusId)) {
187
+ statusId = await this.linearService.resolveStatusId(statusId, teamId);
182
188
  }
183
189
  return {
184
190
  teamId,
@@ -229,15 +235,16 @@ export class GraphQLIssuesService {
229
235
  }
230
236
  const finalTeamId = args.teamId
231
237
  ? await this.resolveTeamId(args.teamId, resolveResult)
232
- : args.teamId;
238
+ : undefined;
233
239
  const finalProjectId = args.projectId
234
240
  ? this.resolveProjectId(args.projectId, resolveResult)
235
- : args.projectId;
241
+ : undefined;
236
242
  const finalAssigneeId = this.resolveAssigneeId(args.assigneeId, resolveResult);
243
+ const limit = args.limit ?? 10;
237
244
  if (args.query) {
238
245
  const searchResult = await this.graphQLService.rawRequest(SEARCH_ISSUES_QUERY, {
239
246
  term: args.query,
240
- first: args.limit || 10,
247
+ first: limit,
241
248
  });
242
249
  const searchIssues = searchResult.searchIssues;
243
250
  if (!searchIssues?.nodes) {
@@ -263,9 +270,9 @@ export class GraphQLIssuesService {
263
270
  priority: args.priority,
264
271
  });
265
272
  const searchResult = await this.graphQLService.rawRequest(FILTERED_SEARCH_ISSUES_QUERY, {
266
- first: args.limit || 10,
273
+ first: limit,
267
274
  filter: Object.keys(filter).length > 0 ? filter : undefined,
268
- orderBy: args.orderBy || "updatedAt",
275
+ orderBy: args.orderBy ?? "updatedAt",
269
276
  });
270
277
  const filteredIssues = searchResult.issues;
271
278
  if (!filteredIssues?.nodes) {
@@ -324,7 +331,7 @@ export class GraphQLIssuesService {
324
331
  if (teamNodes.length === 1) {
325
332
  const correctTeamId = teamNodes[0].id;
326
333
  const correctKey = teamKeys[0];
327
- process.stderr.write(`Auto-switched team to ${correctKey} (the only team associated with project "${projectName}").\n`);
334
+ logger.error(`Auto-switched team to ${correctKey} (the only team associated with project "${projectName}").`);
328
335
  return correctTeamId;
329
336
  }
330
337
  throw new Error(`Project "${projectName}" is not associated with the specified team.\n` +
@@ -399,7 +406,7 @@ export class GraphQLIssuesService {
399
406
  const labelData = created.issueLabel;
400
407
  const teamInfo = labelData.team;
401
408
  const teamKey = teamInfo?.key ?? "";
402
- process.stderr.write(`Auto-created label "${name}" on team ${teamKey}\n`);
409
+ logger.error(`Auto-created label "${name}" on team ${teamKey}`);
403
410
  return labelData.id;
404
411
  }
405
412
  return null;
@@ -529,7 +536,14 @@ export class GraphQLIssuesService {
529
536
  return fetchedTeam?.id;
530
537
  }
531
538
  buildCreateInput(args, resolved) {
532
- const input = { title: args.title };
539
+ const input = {};
540
+ // Title is required by Linear's API unless --from-template is set,
541
+ // in which case Linear copies the template's title. We only include
542
+ // the field when explicitly set so a missing title with templateId
543
+ // works as the API documents.
544
+ if (args.title) {
545
+ input.title = args.title;
546
+ }
533
547
  if (resolved.teamId) {
534
548
  input.teamId = resolved.teamId;
535
549
  }
@@ -548,9 +562,7 @@ export class GraphQLIssuesService {
548
562
  if (resolved.statusId) {
549
563
  input.stateId = resolved.statusId;
550
564
  }
551
- if (resolved.labelIds &&
552
- Array.isArray(resolved.labelIds) &&
553
- resolved.labelIds.length > 0) {
565
+ if (resolved.labelIds && resolved.labelIds.length > 0) {
554
566
  input.labelIds = resolved.labelIds;
555
567
  }
556
568
  if (args.estimate !== undefined) {
@@ -565,14 +577,18 @@ export class GraphQLIssuesService {
565
577
  if (resolved.cycleId) {
566
578
  input.cycleId = resolved.cycleId;
567
579
  }
568
- if (args.subscriberIds &&
569
- Array.isArray(args.subscriberIds) &&
570
- args.subscriberIds.length > 0) {
580
+ if (args.subscriberIds && args.subscriberIds.length > 0) {
571
581
  input.subscriberIds = args.subscriberIds;
572
582
  }
573
583
  if (args.dueDate !== undefined) {
574
584
  input.dueDate = args.dueDate;
575
585
  }
586
+ if (args.templateId !== undefined && args.templateId !== null) {
587
+ // Server-side template instantiation: Linear copies the
588
+ // template's title/description/labels/priority onto the
589
+ // new issue. Any explicit field above wins by override.
590
+ input.templateId = args.templateId;
591
+ }
576
592
  return input;
577
593
  }
578
594
  buildUpdateInput(args, resolved) {
@@ -705,7 +721,7 @@ export class GraphQLIssuesService {
705
721
  resolveVariables.teamKey = teamKey;
706
722
  resolveVariables.issueNumber = issueNumber;
707
723
  }
708
- if (args.labelIds && Array.isArray(args.labelIds)) {
724
+ if (args.labelIds && args.labelIds.length > 0) {
709
725
  const nonUuidLabels = args.labelIds.filter((id) => !isUuid(id));
710
726
  if (nonUuidLabels.length > 0) {
711
727
  resolveVariables.__labelNames = nonUuidLabels;
@@ -714,9 +730,7 @@ export class GraphQLIssuesService {
714
730
  if (args.projectId && !isUuid(args.projectId)) {
715
731
  resolveVariables.projectName = args.projectId;
716
732
  }
717
- if (args.milestoneId &&
718
- typeof args.milestoneId === "string" &&
719
- !isUuid(args.milestoneId)) {
733
+ if (args.milestoneId && !isUuid(args.milestoneId)) {
720
734
  resolveVariables.milestoneName = args.milestoneId;
721
735
  }
722
736
  const { __labelNames, ...updateQueryVars } = resolveVariables;
@@ -750,7 +764,7 @@ export class GraphQLIssuesService {
750
764
  if (args.cycleId === undefined || args.cycleId === null) {
751
765
  return args.cycleId;
752
766
  }
753
- if (typeof args.cycleId !== "string" || isUuid(args.cycleId)) {
767
+ if (isUuid(args.cycleId)) {
754
768
  return args.cycleId;
755
769
  }
756
770
  let teamIdForCycle = resolveResult.issues?.nodes?.[0]?.team?.id;
@@ -780,7 +794,7 @@ export class GraphQLIssuesService {
780
794
  if (args.milestoneId && !isUuid(args.milestoneId)) {
781
795
  resolveVariables.milestoneName = args.milestoneId;
782
796
  }
783
- if (args.labelIds && Array.isArray(args.labelIds)) {
797
+ if (args.labelIds && args.labelIds.length > 0) {
784
798
  const nonUuidLabels = args.labelIds.filter((id) => !isUuid(id));
785
799
  if (nonUuidLabels.length > 0) {
786
800
  resolveVariables.__labelNames = nonUuidLabels;
@@ -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,11 @@
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
+ export declare function getOutputFormat(): OutputFormat;
4
7
  export declare function outputSuccess(data: unknown): void;
5
- export declare function outputWarning(message: string | string[], _type?: string): void;
8
+ export declare function outputWarning(message: string | string[]): void;
6
9
  export declare function resetWarnings(): void;
10
+ export declare function resetOutputFormat(): void;
7
11
  export declare function handleAsyncCommand<TArgs extends unknown[]>(asyncFn: (...args: TArgs) => Promise<void>): (...args: TArgs) => Promise<void>;