@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.
- package/README.md +82 -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/attachments.js +2 -1
- package/dist/commands/comments.js +17 -28
- package/dist/commands/cycles.js +2 -1
- package/dist/commands/documents.js +2 -1
- package/dist/commands/graphql.js +4 -6
- 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/issue-id.js +1 -3
- 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 +183 -0
- package/dist/commands/issues/link-references.d.ts +21 -0
- package/dist/commands/issues/link-references.js +171 -0
- package/dist/commands/issues/relations.d.ts +55 -0
- package/dist/commands/issues/relations.js +132 -0
- package/dist/commands/issues.js +97 -497
- package/dist/commands/labels.js +20 -28
- package/dist/commands/profile/migrate-legacy.js +10 -33
- package/dist/commands/profile.js +1 -10
- package/dist/commands/project-milestones.js +13 -20
- package/dist/commands/projects.d.ts +5 -1
- package/dist/commands/projects.js +202 -102
- package/dist/commands/read-shortcut.d.ts +6 -0
- package/dist/commands/read-shortcut.js +6 -1
- package/dist/commands/refs.js +10 -1
- package/dist/commands/releases.js +26 -30
- package/dist/commands/search.js +21 -30
- package/dist/commands/teams.js +2 -1
- package/dist/commands/templates.js +128 -7
- package/dist/commands/users.js +3 -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/resolver.js +2 -3
- package/dist/config/term-enforcer.js +1 -1
- package/dist/main.js +37 -2
- package/dist/queries/attachments-types.d.ts +30 -0
- package/dist/queries/attachments-types.js +5 -0
- package/dist/queries/comments-types.d.ts +49 -0
- package/dist/queries/comments-types.js +5 -0
- package/dist/queries/documents-types.d.ts +61 -0
- package/dist/queries/documents-types.js +9 -0
- package/dist/queries/introspect-types.d.ts +57 -0
- package/dist/queries/introspect-types.js +10 -0
- package/dist/queries/issues-types.d.ts +416 -0
- package/dist/queries/issues-types.js +23 -0
- package/dist/queries/issues.d.ts +2 -0
- package/dist/queries/issues.js +22 -0
- package/dist/queries/labels-types.d.ts +64 -0
- package/dist/queries/labels-types.js +5 -0
- package/dist/queries/project-milestones-types.d.ts +91 -0
- package/dist/queries/project-milestones-types.js +10 -0
- package/dist/queries/projects-types.d.ts +75 -0
- package/dist/queries/projects-types.js +5 -0
- package/dist/queries/projects.d.ts +2 -0
- package/dist/queries/projects.js +22 -0
- package/dist/queries/releases-types.d.ts +84 -0
- package/dist/queries/releases-types.js +5 -0
- package/dist/queries/search-types.d.ts +86 -0
- package/dist/queries/search-types.js +6 -0
- package/dist/queries/templates-types.d.ts +61 -0
- package/dist/queries/templates-types.js +9 -0
- package/dist/queries/templates.d.ts +3 -0
- package/dist/queries/templates.js +43 -0
- package/dist/types/linear.d.ts +8 -2
- package/dist/utils/auth.js +7 -9
- package/dist/utils/auto-link-references.js +44 -25
- 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-attachments-service.js +6 -9
- package/dist/utils/graphql-documents-service.js +19 -25
- package/dist/utils/graphql-issues-service.d.ts +118 -5
- package/dist/utils/graphql-issues-service.js +202 -209
- 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 +7 -1
- package/dist/utils/output.js +38 -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/dist/utils/workspace-url.d.ts +5 -1
- package/dist/utils/workspace-url.js +35 -5
- 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
|
-
*
|
|
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,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[]
|
|
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>;
|
package/dist/utils/output.js
CHANGED
|
@@ -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
|
|
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;
|