@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.
- package/README.md +69 -1
- package/claude-skills/linear-operations/SKILL.md +55 -0
- package/dist/auth/oauth-callback.js +14 -7
- package/dist/auth/oauth-fs.d.ts +22 -0
- package/dist/auth/oauth-fs.js +77 -6
- package/dist/auth/oauth-headless.d.ts +13 -8
- package/dist/auth/oauth-headless.js +18 -11
- package/dist/auth/oauth-storage.d.ts +11 -3
- package/dist/auth/oauth-storage.js +15 -8
- package/dist/auth/token-resolver.d.ts +9 -0
- package/dist/auth/token-resolver.js +57 -37
- package/dist/commands/init/index.js +2 -0
- package/dist/commands/init/oauth.d.ts +6 -0
- package/dist/commands/init/oauth.js +8 -2
- package/dist/commands/init/token.d.ts +0 -8
- package/dist/commands/init/token.js +13 -1
- package/dist/commands/issues/branch.d.ts +17 -0
- package/dist/commands/issues/branch.js +40 -0
- package/dist/commands/issues/description.d.ts +89 -0
- package/dist/commands/issues/description.js +187 -0
- package/dist/commands/issues.js +34 -215
- package/dist/commands/labels.js +7 -5
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +135 -64
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +2 -1
- package/dist/commands/templates.js +127 -1
- package/dist/commands/users.js +2 -1
- package/dist/config/config.js +31 -9
- package/dist/config/issue-validation.js +1 -1
- package/dist/config/paths.d.ts +11 -0
- package/dist/config/paths.js +44 -6
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +37 -2
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +15 -1
- package/dist/utils/disk-cache.js +2 -1
- package/dist/utils/formatters/summary.d.ts +62 -0
- package/dist/utils/formatters/summary.js +755 -0
- package/dist/utils/graphql-issues-service.d.ts +106 -3
- package/dist/utils/graphql-issues-service.js +51 -37
- package/dist/utils/issue-reference-extractor.d.ts +9 -1
- package/dist/utils/issue-reference-extractor.js +16 -9
- package/dist/utils/issue-reference-wrapper.js +1 -54
- package/dist/utils/linear-service.d.ts +7 -3
- package/dist/utils/linear-service.js +27 -5
- package/dist/utils/markdown-prosemirror.js +17 -1
- package/dist/utils/mention-resolver.js +17 -5
- package/dist/utils/output.d.ts +5 -1
- package/dist/utils/output.js +28 -1
- package/dist/utils/protected-ranges.d.ts +33 -0
- package/dist/utils/protected-ranges.js +73 -0
- package/dist/utils/table-formatter.d.ts +36 -0
- package/dist/utils/table-formatter.js +46 -24
- package/dist/utils/validators.d.ts +9 -1
- package/dist/utils/validators.js +10 -0
- 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:
|
|
113
|
+
updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
|
|
11
114
|
private extractMilestoneNodes;
|
|
12
115
|
private executeUpdateMutation;
|
|
13
|
-
createIssue(args:
|
|
116
|
+
createIssue(args: CreateIssueArgs): Promise<LinearIssue>;
|
|
14
117
|
private resolveCreateFields;
|
|
15
118
|
private executeCreateMutation;
|
|
16
|
-
searchIssues(args:
|
|
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
|
-
:
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
:
|
|
170
|
+
: undefined;
|
|
163
171
|
const projectId = args.projectId
|
|
164
172
|
? this.resolveProjectId(args.projectId, resolveResult)
|
|
165
|
-
:
|
|
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 (
|
|
175
|
-
|
|
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 (
|
|
181
|
-
statusId = await this.linearService.resolveStatusId(
|
|
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
|
-
:
|
|
238
|
+
: undefined;
|
|
233
239
|
const finalProjectId = args.projectId
|
|
234
240
|
? this.resolveProjectId(args.projectId, resolveResult)
|
|
235
|
-
:
|
|
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:
|
|
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:
|
|
273
|
+
first: limit,
|
|
267
274
|
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
|
268
|
-
orderBy: args.orderBy
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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 &&
|
|
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 (
|
|
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 &&
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
82
|
+
const ranges = findProtectedRanges(text);
|
|
79
83
|
const byIdentifier = new Map();
|
|
80
|
-
for (const match of
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("|"), "
|
|
242
|
+
return new RegExp(parts.join("|"), "gu");
|
|
231
243
|
}
|
|
232
244
|
function escapeRegex(s) {
|
|
233
245
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
package/dist/utils/output.d.ts
CHANGED
|
@@ -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[]
|
|
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>;
|