@enrichlayer/el-linear 1.29.0 → 1.30.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/claude-skills/linear-operations/SKILL.md +21 -0
- package/dist/commands/comments.js +73 -6
- package/dist/commands/issues/tree.js +7 -6
- package/dist/types/linear.d.ts +11 -0
- package/dist/types/linear.js +15 -1
- package/dist/utils/format-tree.js +2 -1
- package/dist/utils/graphql-issues-service.d.ts +2 -1
- package/dist/utils/graphql-issues-service.js +3 -2
- package/dist/utils/mention-resolver.d.ts +26 -5
- package/dist/utils/mention-resolver.js +34 -6
- package/dist/utils/output.d.ts +6 -0
- package/dist/utils/output.js +8 -0
- package/package.json +1 -1
|
@@ -352,6 +352,27 @@ el-linear comments create ENG-123 --body "cc @alice — Bob can you review?" 2>&
|
|
|
352
352
|
|
|
353
353
|
Works in comments only (not descriptions).
|
|
354
354
|
|
|
355
|
+
### Confirming a mention fired (the `mentions` output field)
|
|
356
|
+
|
|
357
|
+
A real mention lives in the comment's structured `bodyData`, not in the markdown — so a comment whose body reads `@alice` may or may not have actually pinged anyone. **Don't guess: read the `mentions` field** that `comments create|update` attach to their output (the sibling of `autoLinked`):
|
|
358
|
+
|
|
359
|
+
```jsonc
|
|
360
|
+
{
|
|
361
|
+
"id": "…",
|
|
362
|
+
"mentions": {
|
|
363
|
+
"resolved": [{ "label": "alice", "userId": "…" }], // real notifications sent
|
|
364
|
+
"unresolved": ["bobby"], // explicit @names that matched nobody
|
|
365
|
+
"delivered": true // false ⇒ Linear rejected bodyData, fell back to plain text
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
- An **unresolved** explicit `@name` (typo or unknown handle) is left as plain text — **no notification** — and el-linear prints a loud `⚠ @name did not resolve …` warning to **stderr**. Fix the name and re-comment; never assume the ping landed.
|
|
371
|
+
- `delivered: false` means the structured body was rejected and the comment shipped as plain markdown, so the resolved mentions did **not** fire — also warned on stderr.
|
|
372
|
+
- Under `--quiet`, the stdout stays the one-line `comment <id>`; the `mentions: resolved=[…] unresolved=[…]` confirmation is echoed to **stderr**.
|
|
373
|
+
|
|
374
|
+
This makes "a real @mention" a verifiable, deterministic convention rather than a hope ([DEV-4987](https://linear.app/verticalint/issue/DEV-4987/)).
|
|
375
|
+
|
|
355
376
|
---
|
|
356
377
|
|
|
357
378
|
## CLI Syntax Rules
|
|
@@ -7,8 +7,9 @@ import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
|
7
7
|
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
8
8
|
import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
|
|
9
9
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { logger } from "../utils/logger.js";
|
|
11
|
+
import { resolveMentions, } from "../utils/mention-resolver.js";
|
|
12
|
+
import { getQuietMode, handleAsyncCommand, outputSuccess, } from "../utils/output.js";
|
|
12
13
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
13
14
|
import { validateReferences } from "../utils/validate-references.js";
|
|
14
15
|
import { parsePositiveInt } from "../utils/validators.js";
|
|
@@ -17,6 +18,54 @@ import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
|
|
|
17
18
|
// tweak on their side doesn't silently regress the fallback path.
|
|
18
19
|
const BODY_DATA_ERROR_RE = /prosemirror|bodydata|invalid.*body/i;
|
|
19
20
|
const ISSUE_IDENTIFIER_REGEX = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
21
|
+
/**
|
|
22
|
+
* Turn a {@link MentionReport} into the `mentions` output field, or `undefined`
|
|
23
|
+
* when there is nothing to report. `delivered=false` records a bodyData→plain
|
|
24
|
+
* fallback so the JSON output never falsely claims a ping was sent.
|
|
25
|
+
*/
|
|
26
|
+
function buildMentionsOutput(report, delivered) {
|
|
27
|
+
if (!report) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (report.resolved.length === 0 && report.unresolvedExplicit.length === 0) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
resolved: report.resolved.map((m) => ({
|
|
35
|
+
label: m.label,
|
|
36
|
+
userId: m.userId,
|
|
37
|
+
})),
|
|
38
|
+
unresolved: report.unresolvedExplicit,
|
|
39
|
+
delivered,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Emit the human-facing side of mention resolution to **stderr** (so it never
|
|
44
|
+
* pollutes the machine-stable stdout line, and survives `--quiet`):
|
|
45
|
+
* - a loud warning for each explicit `@name` that resolved to nobody,
|
|
46
|
+
* - a warning when a bodyData rejection dropped resolved mentions,
|
|
47
|
+
* - under `--quiet`, a one-line confirmation of what resolved (since the
|
|
48
|
+
* quiet stdout line can't carry the `mentions` field).
|
|
49
|
+
*/
|
|
50
|
+
function reportMentions(mentions) {
|
|
51
|
+
if (!mentions) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const name of mentions.unresolved) {
|
|
55
|
+
logger.error(`⚠ @${name} did not resolve to a team member — left as plain text (no notification sent)`);
|
|
56
|
+
}
|
|
57
|
+
if (!mentions.delivered && mentions.resolved.length > 0) {
|
|
58
|
+
logger.error(`⚠ Linear rejected the structured comment body; fell back to plain text — ${mentions.resolved.length} mention(s) were NOT delivered as notifications`);
|
|
59
|
+
}
|
|
60
|
+
// In quiet mode stdout is a single `comment <id>` line with no room for the
|
|
61
|
+
// `mentions` field, so echo a concise confirmation to stderr.
|
|
62
|
+
if (getQuietMode()) {
|
|
63
|
+
const resolved = mentions.delivered
|
|
64
|
+
? mentions.resolved.map((m) => `${m.label}→${m.userId}`).join(", ")
|
|
65
|
+
: "(none delivered)";
|
|
66
|
+
logger.error(`mentions: resolved=[${resolved}] unresolved=[${mentions.unresolved.join(", ")}]`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
20
69
|
function readBody(options) {
|
|
21
70
|
// --body and --body-file are two sources for the same field; accepting both
|
|
22
71
|
// would silently drop one. Reject up front (DEV-4450) — the same mutual-
|
|
@@ -130,12 +179,15 @@ async function handleCreateComment(issueId, options, command) {
|
|
|
130
179
|
selfUserId,
|
|
131
180
|
});
|
|
132
181
|
const input = { issueId: resolvedIssueId };
|
|
133
|
-
if (mentionResult) {
|
|
182
|
+
if (mentionResult?.bodyData) {
|
|
134
183
|
input.bodyData = mentionResult.bodyData;
|
|
135
184
|
}
|
|
136
185
|
else {
|
|
137
186
|
input.body = body;
|
|
138
187
|
}
|
|
188
|
+
// Tracks whether resolved mentions actually shipped as structured nodes;
|
|
189
|
+
// flipped to false if the bodyData fallback below sends plain text instead.
|
|
190
|
+
let mentionsDelivered = true;
|
|
139
191
|
let result;
|
|
140
192
|
try {
|
|
141
193
|
result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, { input });
|
|
@@ -146,6 +198,7 @@ async function handleCreateComment(issueId, options, command) {
|
|
|
146
198
|
// Linear handle the conversion server-side.
|
|
147
199
|
const msg = err instanceof Error ? err.message : String(err);
|
|
148
200
|
if (input.bodyData && BODY_DATA_ERROR_RE.test(msg)) {
|
|
201
|
+
mentionsDelivered = false;
|
|
149
202
|
const fallbackInput = {
|
|
150
203
|
issueId: resolvedIssueId,
|
|
151
204
|
body,
|
|
@@ -179,7 +232,13 @@ async function handleCreateComment(issueId, options, command) {
|
|
|
179
232
|
linearService,
|
|
180
233
|
});
|
|
181
234
|
const output = transformComment(mutation.comment);
|
|
182
|
-
|
|
235
|
+
const mentions = buildMentionsOutput(mentionResult, mentionsDelivered);
|
|
236
|
+
reportMentions(mentions);
|
|
237
|
+
outputSuccess({
|
|
238
|
+
...output,
|
|
239
|
+
...(autoLinked ? { autoLinked } : {}),
|
|
240
|
+
...(mentions ? { mentions } : {}),
|
|
241
|
+
});
|
|
183
242
|
}
|
|
184
243
|
async function handleUpdateComment(commentId, options, command) {
|
|
185
244
|
const rootOpts = getRootOpts(command);
|
|
@@ -196,12 +255,13 @@ async function handleUpdateComment(commentId, options, command) {
|
|
|
196
255
|
selfUserId,
|
|
197
256
|
});
|
|
198
257
|
const input = {};
|
|
199
|
-
if (mentionResult) {
|
|
258
|
+
if (mentionResult?.bodyData) {
|
|
200
259
|
input.bodyData = mentionResult.bodyData;
|
|
201
260
|
}
|
|
202
261
|
else {
|
|
203
262
|
input.body = body;
|
|
204
263
|
}
|
|
264
|
+
let mentionsDelivered = true;
|
|
205
265
|
let result;
|
|
206
266
|
try {
|
|
207
267
|
result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, { id: commentId, input });
|
|
@@ -217,6 +277,7 @@ async function handleUpdateComment(commentId, options, command) {
|
|
|
217
277
|
// extraction; copy-then-refactor on the third per the repo convention.
|
|
218
278
|
const msg = err instanceof Error ? err.message : String(err);
|
|
219
279
|
if (input.bodyData && BODY_DATA_ERROR_RE.test(msg)) {
|
|
280
|
+
mentionsDelivered = false;
|
|
220
281
|
const fallbackInput = { body };
|
|
221
282
|
result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, { id: commentId, input: fallbackInput });
|
|
222
283
|
}
|
|
@@ -243,7 +304,13 @@ async function handleUpdateComment(commentId, options, command) {
|
|
|
243
304
|
});
|
|
244
305
|
}
|
|
245
306
|
const output = transformComment(comment);
|
|
246
|
-
|
|
307
|
+
const mentions = buildMentionsOutput(mentionResult, mentionsDelivered);
|
|
308
|
+
reportMentions(mentions);
|
|
309
|
+
outputSuccess({
|
|
310
|
+
...output,
|
|
311
|
+
...(autoLinked ? { autoLinked } : {}),
|
|
312
|
+
...(mentions ? { mentions } : {}),
|
|
313
|
+
});
|
|
247
314
|
}
|
|
248
315
|
async function handleListComments(issueId, options, command) {
|
|
249
316
|
const rootOpts = getRootOpts(command);
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* connection has no top-level state filter).
|
|
19
19
|
*/
|
|
20
20
|
import { buildIssueTreeQuery, DEFAULT_TREE_DEPTH, MAX_TREE_DEPTH, } from "../../queries/issue-tree.js";
|
|
21
|
+
import { TERMINAL_STATE_TYPES } from "../../types/linear.js";
|
|
21
22
|
import { formatTree } from "../../utils/format-tree.js";
|
|
22
23
|
import { createIssuesService } from "../../utils/issues-service-bootstrap.js";
|
|
23
24
|
import { handleAsyncCommand, outputSuccess } from "../../utils/output.js";
|
|
@@ -74,10 +75,11 @@ function parseDepth(raw) {
|
|
|
74
75
|
return n;
|
|
75
76
|
}
|
|
76
77
|
/**
|
|
77
|
-
* Walk the tree depth-first and drop any node whose `state.type` is
|
|
78
|
-
* `completed`
|
|
79
|
-
* A pruned child takes its entire
|
|
80
|
-
* `issues list --no-include-closed`
|
|
78
|
+
* Walk the tree depth-first and drop any node whose `state.type` is terminal
|
|
79
|
+
* (`completed` / `canceled` / `duplicate` — `TERMINAL_STATE_TYPES`, DEV-4879).
|
|
80
|
+
* Pure function — does not mutate the input. A pruned child takes its entire
|
|
81
|
+
* subtree with it (consistent with how `issues list --no-include-closed`
|
|
82
|
+
* excludes closed work entirely — same shared constant).
|
|
81
83
|
*
|
|
82
84
|
* Assumes Linear's parent → children graph stays single-parent (a tree,
|
|
83
85
|
* not a DAG). If Linear ever ships multi-parent issues, this recursion
|
|
@@ -97,6 +99,5 @@ function pruneTerminalStates(root) {
|
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
101
|
function isTerminalState(node) {
|
|
100
|
-
|
|
101
|
-
return t === "completed" || t === "canceled";
|
|
102
|
+
return TERMINAL_STATE_TYPES.includes(node.state?.type ?? "");
|
|
102
103
|
}
|
package/dist/types/linear.d.ts
CHANGED
|
@@ -35,6 +35,17 @@ interface StateRef {
|
|
|
35
35
|
*/
|
|
36
36
|
type?: string;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Workflow-state types treated as terminal / closed for default filtering.
|
|
40
|
+
* `issues list` / `issues search` exclude these unless `--include-closed` (or
|
|
41
|
+
* an explicit `--status`) is passed (DEV-4478).
|
|
42
|
+
*
|
|
43
|
+
* `duplicate` is included (DEV-4879) because this workspace parks resolved
|
|
44
|
+
* duplicates in a `duplicate`-typed state — they carry a `duplicate-of`
|
|
45
|
+
* relation and are semantically closed, so they must not leak into the default
|
|
46
|
+
* open-issues view the way `completed` / `canceled` don't.
|
|
47
|
+
*/
|
|
48
|
+
export declare const TERMINAL_STATE_TYPES: readonly string[];
|
|
38
49
|
interface ProjectRef {
|
|
39
50
|
id: string;
|
|
40
51
|
name: string;
|
package/dist/types/linear.js
CHANGED
|
@@ -3,4 +3,18 @@
|
|
|
3
3
|
* These represent the public API surface — GraphQL responses are
|
|
4
4
|
* transformed into these shapes at the service boundary.
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Workflow-state types treated as terminal / closed for default filtering.
|
|
8
|
+
* `issues list` / `issues search` exclude these unless `--include-closed` (or
|
|
9
|
+
* an explicit `--status`) is passed (DEV-4478).
|
|
10
|
+
*
|
|
11
|
+
* `duplicate` is included (DEV-4879) because this workspace parks resolved
|
|
12
|
+
* duplicates in a `duplicate`-typed state — they carry a `duplicate-of`
|
|
13
|
+
* relation and are semantically closed, so they must not leak into the default
|
|
14
|
+
* open-issues view the way `completed` / `canceled` don't.
|
|
15
|
+
*/
|
|
16
|
+
export const TERMINAL_STATE_TYPES = [
|
|
17
|
+
"completed",
|
|
18
|
+
"canceled",
|
|
19
|
+
"duplicate",
|
|
20
|
+
];
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Pure rendering — does not fetch or filter. The depth bound and the
|
|
11
11
|
* terminal-state exclusion are enforced by the caller before this runs.
|
|
12
12
|
*/
|
|
13
|
+
import { TERMINAL_STATE_TYPES } from "../types/linear.js";
|
|
13
14
|
const BRANCH_TEE = "├── ";
|
|
14
15
|
const BRANCH_END = "└── ";
|
|
15
16
|
const VERTICAL = "│ ";
|
|
@@ -40,7 +41,7 @@ function formatStateSuffix(state) {
|
|
|
40
41
|
// Only annotate terminal-typed states; the renderer is otherwise
|
|
41
42
|
// state-name-agnostic so workspace-custom workflow states (e.g. "In
|
|
42
43
|
// Review") don't get a noisy suffix.
|
|
43
|
-
if (
|
|
44
|
+
if (TERMINAL_STATE_TYPES.includes(state.type)) {
|
|
44
45
|
return ` [${state.name}]`;
|
|
45
46
|
}
|
|
46
47
|
return "";
|
|
@@ -42,7 +42,8 @@ export interface SearchIssueArgs {
|
|
|
42
42
|
/** Issue states to include (e.g. `["Todo", "In Progress"]`). */
|
|
43
43
|
status?: string[];
|
|
44
44
|
/**
|
|
45
|
-
* Exclude terminal states (workflow type `completed` or
|
|
45
|
+
* Exclude terminal states (workflow type `completed`, `canceled`, or
|
|
46
|
+
* `duplicate` — see `TERMINAL_STATE_TYPES`).
|
|
46
47
|
* Default `false` for back-compat at the service layer; the CLI's
|
|
47
48
|
* `issues list` / `issues search` flip this to `true` by default
|
|
48
49
|
* (DEV-4478) and accept `--include-closed` to opt back in. When
|
|
@@ -2,6 +2,7 @@ import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-re
|
|
|
2
2
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
3
3
|
import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
4
4
|
import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
|
|
5
|
+
import { TERMINAL_STATE_TYPES } from "../types/linear.js";
|
|
5
6
|
import { toISOStringOrNow } from "./date-format.js";
|
|
6
7
|
import { extractEmbeds } from "./embed-parser.js";
|
|
7
8
|
import { multipleMatchesError, notFoundError } from "./error-messages.js";
|
|
@@ -981,7 +982,7 @@ export class GraphQLIssuesService {
|
|
|
981
982
|
else if (filters.excludeTerminalStates) {
|
|
982
983
|
// Implicit terminal-state exclusion (DEV-4478). Explicit `status`
|
|
983
984
|
// always wins — the user already named the states they want.
|
|
984
|
-
filtered = filtered.filter((issue) =>
|
|
985
|
+
filtered = filtered.filter((issue) => !TERMINAL_STATE_TYPES.includes(issue.state?.type ?? ""));
|
|
985
986
|
}
|
|
986
987
|
if (filters.labelNames && filters.labelNames.length > 0) {
|
|
987
988
|
const lowerNames = filters.labelNames.map((n) => n.toLowerCase());
|
|
@@ -1024,7 +1025,7 @@ export class GraphQLIssuesService {
|
|
|
1024
1025
|
else if (filters.excludeTerminalStates) {
|
|
1025
1026
|
// Implicit terminal-state exclusion (DEV-4478). Explicit `status`
|
|
1026
1027
|
// always wins — the user already named the states they want.
|
|
1027
|
-
filter.state = { type: { nin: [
|
|
1028
|
+
filter.state = { type: { nin: [...TERMINAL_STATE_TYPES] } };
|
|
1028
1029
|
}
|
|
1029
1030
|
if (filters.labelNames && filters.labelNames.length > 0) {
|
|
1030
1031
|
if (filters.labelNames.length === 1) {
|
|
@@ -12,6 +12,27 @@ export interface ResolveMentionsOptions {
|
|
|
12
12
|
*/
|
|
13
13
|
selfUserId?: string;
|
|
14
14
|
}
|
|
15
|
+
interface ResolvedMention {
|
|
16
|
+
label: string;
|
|
17
|
+
userId: string;
|
|
18
|
+
}
|
|
19
|
+
export interface MentionReport {
|
|
20
|
+
/**
|
|
21
|
+
* ProseMirror doc carrying `suggestion_userMentions` nodes — the structured
|
|
22
|
+
* body that makes Linear fire real notifications. `null` when nothing
|
|
23
|
+
* resolved (the caller should send the plain markdown `body` instead).
|
|
24
|
+
*/
|
|
25
|
+
bodyData: ProseMirrorNode | null;
|
|
26
|
+
/** Mentions that resolved to a real user (explicit `@name` + bare names). */
|
|
27
|
+
resolved: ResolvedMention[];
|
|
28
|
+
/**
|
|
29
|
+
* Explicit `@name` tokens that did NOT resolve to any team member — almost
|
|
30
|
+
* always a typo or an unknown handle. Left as plain text (no notification),
|
|
31
|
+
* so the caller surfaces them as a warning. Bare-name non-matches are not
|
|
32
|
+
* tracked here: they were never an intended ping.
|
|
33
|
+
*/
|
|
34
|
+
unresolvedExplicit: string[];
|
|
35
|
+
}
|
|
15
36
|
/**
|
|
16
37
|
* Resolve mentions in markdown body text to Linear user IDs.
|
|
17
38
|
*
|
|
@@ -23,9 +44,9 @@ export interface ResolveMentionsOptions {
|
|
|
23
44
|
* are also converted to mentions — without requiring the `@` prefix. This
|
|
24
45
|
* catches cases like "Bob owns the design" → "@bob owns the design".
|
|
25
46
|
*
|
|
26
|
-
* Returns a
|
|
27
|
-
*
|
|
47
|
+
* Returns a {@link MentionReport} describing which names resolved, which
|
|
48
|
+
* explicit `@names` did not, and the `bodyData` doc to send — or `null` when
|
|
49
|
+
* there was nothing to resolve and nothing to warn about.
|
|
28
50
|
*/
|
|
29
|
-
export declare function resolveMentions(body: string, linearService: LinearService, options?: ResolveMentionsOptions): Promise<
|
|
30
|
-
|
|
31
|
-
} | null>;
|
|
51
|
+
export declare function resolveMentions(body: string, linearService: LinearService, options?: ResolveMentionsOptions): Promise<MentionReport | null>;
|
|
52
|
+
export {};
|
|
@@ -21,20 +21,27 @@ const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
|
21
21
|
* are also converted to mentions — without requiring the `@` prefix. This
|
|
22
22
|
* catches cases like "Bob owns the design" → "@bob owns the design".
|
|
23
23
|
*
|
|
24
|
-
* Returns a
|
|
25
|
-
*
|
|
24
|
+
* Returns a {@link MentionReport} describing which names resolved, which
|
|
25
|
+
* explicit `@names` did not, and the `bodyData` doc to send — or `null` when
|
|
26
|
+
* there was nothing to resolve and nothing to warn about.
|
|
26
27
|
*/
|
|
27
28
|
export async function resolveMentions(body, linearService, options = {}) {
|
|
28
29
|
const autoMention = options.autoMention !== false;
|
|
29
30
|
const selfUserId = options.selfUserId;
|
|
30
31
|
// 1. Resolve explicit @name mentions (existing behavior).
|
|
31
32
|
const explicit = new Map();
|
|
33
|
+
const unresolvedExplicit = [];
|
|
32
34
|
const explicitNames = [...body.matchAll(EXPLICIT_MENTION_REGEX)].map((m) => m[1]);
|
|
33
35
|
for (const name of new Set(explicitNames)) {
|
|
34
36
|
const userId = await resolveUserByName(name, linearService);
|
|
35
37
|
if (userId) {
|
|
36
38
|
explicit.set(name, { userId, label: name });
|
|
37
39
|
}
|
|
40
|
+
else {
|
|
41
|
+
// An explicit `@name` the author clearly meant as a ping, but it
|
|
42
|
+
// matched no team member — surface it so the ping never dies silently.
|
|
43
|
+
unresolvedExplicit.push(name);
|
|
44
|
+
}
|
|
38
45
|
}
|
|
39
46
|
// 2. Bare-name mentions: scan for candidate names that actually appear as
|
|
40
47
|
// standalone words in the body (outside code / link contexts).
|
|
@@ -61,12 +68,33 @@ export async function resolveMentions(body, linearService, options = {}) {
|
|
|
61
68
|
bare.set(candidate, { userId, label: candidate });
|
|
62
69
|
}
|
|
63
70
|
}
|
|
64
|
-
|
|
71
|
+
// Nothing resolved and no failed explicit ping → caller has nothing to do.
|
|
72
|
+
if (explicit.size === 0 &&
|
|
73
|
+
bare.size === 0 &&
|
|
74
|
+
unresolvedExplicit.length === 0) {
|
|
65
75
|
return null;
|
|
66
76
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
// De-dup by userId for the reported set: a capitalized explicit `@Bob` also
|
|
78
|
+
// matches the bare candidate "Bob" (the `@` isn't a word char, so the bare
|
|
79
|
+
// lookbehind passes), landing the same user in both maps. The injected
|
|
80
|
+
// bodyData is unaffected (the `@name` alternation consumes the span first,
|
|
81
|
+
// so only one mention node is emitted), but `resolved` would over-count and
|
|
82
|
+
// `--quiet` would print the user twice — which undercuts the whole point of
|
|
83
|
+
// a trustworthy mention report. Explicit-first so the label is the typed
|
|
84
|
+
// `@name`, not the capitalized bare candidate.
|
|
85
|
+
const resolvedByUser = new Map();
|
|
86
|
+
for (const m of [...explicit.values(), ...bare.values()]) {
|
|
87
|
+
if (!resolvedByUser.has(m.userId)) {
|
|
88
|
+
resolvedByUser.set(m.userId, m);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const resolved = [...resolvedByUser.values()];
|
|
92
|
+
// Only build the structured doc when something actually resolved; a body
|
|
93
|
+
// whose only mention was an unresolved `@typo` is sent as plain markdown.
|
|
94
|
+
const bodyData = explicit.size > 0 || bare.size > 0
|
|
95
|
+
? injectMentions(markdownToProseMirror(body), explicit, bare)
|
|
96
|
+
: null;
|
|
97
|
+
return { bodyData, resolved, unresolvedExplicit };
|
|
70
98
|
}
|
|
71
99
|
async function resolveUserByName(name, linearService) {
|
|
72
100
|
const configResult = resolveMember(name);
|
package/dist/utils/output.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export declare function setRawMode(enabled: boolean): void;
|
|
|
6
6
|
* the summary block. Set in main.ts's preAction when the flag is present.
|
|
7
7
|
*/
|
|
8
8
|
export declare function setQuietMode(enabled: boolean): void;
|
|
9
|
+
/**
|
|
10
|
+
* Whether `--quiet` is active. Lets a command decide to route extra
|
|
11
|
+
* human-facing detail (e.g. a mention-resolution confirmation) to stderr,
|
|
12
|
+
* keeping the single machine-stable stdout line intact.
|
|
13
|
+
*/
|
|
14
|
+
export declare function getQuietMode(): boolean;
|
|
9
15
|
export declare function setJqFilter(filter: string | null): void;
|
|
10
16
|
export declare function setFieldsFilter(fields: string[] | null): void;
|
|
11
17
|
export declare function setOutputFormat(format: OutputFormat): void;
|
package/dist/utils/output.js
CHANGED
|
@@ -19,6 +19,14 @@ export function setRawMode(enabled) {
|
|
|
19
19
|
export function setQuietMode(enabled) {
|
|
20
20
|
quietMode = enabled;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether `--quiet` is active. Lets a command decide to route extra
|
|
24
|
+
* human-facing detail (e.g. a mention-resolution confirmation) to stderr,
|
|
25
|
+
* keeping the single machine-stable stdout line intact.
|
|
26
|
+
*/
|
|
27
|
+
export function getQuietMode() {
|
|
28
|
+
return quietMode;
|
|
29
|
+
}
|
|
22
30
|
export function setJqFilter(filter) {
|
|
23
31
|
jqFilter = filter;
|
|
24
32
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|