@enrichlayer/el-linear 1.30.0 → 1.32.1

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 CHANGED
@@ -662,9 +662,9 @@ Mutually exclusive with `--field` / `--sections` / `--with`.
662
662
 
663
663
  ### One-line write confirmations: `-q, --quiet`
664
664
 
665
- `issues create|update` and `comments create|update` accept `-q, --quiet`,
666
- printing a single confirmation line instead of the full JSON envelope so
667
- you don't have to `grep` the result:
665
+ `issues create|update`, `issues relate`, and `comments create|update` accept
666
+ `-q, --quiet`, printing a single confirmation line instead of the full JSON
667
+ envelope so you don't have to `grep` the result:
668
668
 
669
669
  ```bash
670
670
  el-linear issues update DEV-123 --status "In Review" --quiet
@@ -672,6 +672,24 @@ el-linear issues update DEV-123 --status "In Review" --quiet
672
672
 
673
673
  el-linear comments create DEV-123 --body "..." --quiet
674
674
  # comment 6f1c…
675
+
676
+ el-linear issues relate DEV-123 --related-to "DEV-456,DEV-789" --quiet
677
+ # DEV-123 related DEV-456,DEV-789 (2)
678
+ ```
679
+
680
+ For `issues relate` the line is source-oriented — relations are grouped by
681
+ type from the source issue's perspective, so a `--blocked-by` reads back as
682
+ `blockedBy`. `--format summary` renders the same source-oriented view as a
683
+ `TYPE / TARGET / TITLE` table (TARGET is the peer issue; the source isn't
684
+ repeated as a column):
685
+
686
+ ```bash
687
+ el-linear issues relate DEV-1 --blocked-by DEV-9 --format summary
688
+ # TYPE TARGET TITLE
689
+ # ------------------------------------
690
+ # blockedBy DEV-9 Add IP ban system
691
+ #
692
+ # 1 relation
675
693
  ```
676
694
 
677
695
  `--quiet` overrides `--format` and is independent of `--fields` / `--jq`.
@@ -197,7 +197,15 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
197
197
  el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
198
198
  ```
199
199
 
200
- ### Surfacing relation candidates — explicit user reply required ([DEV-4494](https://linear.app/verticalint/issue/DEV-4494/))
200
+ ### Existence check — before an "add capability X" issue ([DEV-5097](https://linear.app/verticalint/issue/DEV-5097/))
201
+
202
+ The dup-check above guards against duplicating an *issue*. This guards against duplicating *reality*: before filing an issue to **add** a flag / guard / command / subcommand, confirm it doesn't **already exist**.
203
+
204
+ - `<cli> <subcommand> --help` (the flag may be a global option absent from the subcommand's help — check `<cli> --help` too).
205
+ - `el-catalog commands --search "<intent>"` / `el-catalog clis --search` (the snapshot likely already lists it).
206
+ - For a hook/guard, grep the source — e.g. `cli/el-hook/src/checks`.
207
+
208
+ Skipping it cost two needless branch+MR cycles in one session: a "feature" issue to add a hook guard that already existed and was firing, and one to add `--jq`/`--fields` flags that already worked — both premises only caught at implementation.
201
209
 
202
210
  When `el-linear issues search` (or the cross-resource `search`) returns rows
203
211
  carrying issue identifiers, the JSON envelope embeds a `_warnings` line
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `el-linear branch validate [branch]` — validate that a branch name carries a
3
+ * **real** Linear team prefix, checked against the workspace's actual teams
4
+ * (cached `teams list`) rather than a hand-maintained allowlist.
5
+ *
6
+ * Built so the tools-repo pre-commit gate (`check-linear-branch.sh`) can
7
+ * delegate team-validity here instead of hardcoding a bash regex that drifts
8
+ * (it already did: phantom `CS`, missing `CUS/EMW/NIC/PYT`). Branch *parsing*
9
+ * already lives in `issue-id`/`parseBranchName`; this adds team *validity*.
10
+ *
11
+ * Exit codes (so a caller can distinguish "block" from "fall back"):
12
+ * 0 — valid: a real team, or an exempt branch (main/master/detached/empty)
13
+ * 1 — invalid: parsed a team that isn't a real workspace team, or no Linear ID
14
+ * 2 — indeterminate: the team set couldn't be loaded (offline + cold cache);
15
+ * lets the caller fall back to its own check rather than hard-block
16
+ *
17
+ * `--exit-zero` reports the JSON verdict but always exits 0 (report-only).
18
+ */
19
+ import type { Command } from "commander";
20
+ export declare function setupBranchCommands(program: Command): void;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `el-linear branch validate [branch]` — validate that a branch name carries a
3
+ * **real** Linear team prefix, checked against the workspace's actual teams
4
+ * (cached `teams list`) rather than a hand-maintained allowlist.
5
+ *
6
+ * Built so the tools-repo pre-commit gate (`check-linear-branch.sh`) can
7
+ * delegate team-validity here instead of hardcoding a bash regex that drifts
8
+ * (it already did: phantom `CS`, missing `CUS/EMW/NIC/PYT`). Branch *parsing*
9
+ * already lives in `issue-id`/`parseBranchName`; this adds team *validity*.
10
+ *
11
+ * Exit codes (so a caller can distinguish "block" from "fall back"):
12
+ * 0 — valid: a real team, or an exempt branch (main/master/detached/empty)
13
+ * 1 — invalid: parsed a team that isn't a real workspace team, or no Linear ID
14
+ * 2 — indeterminate: the team set couldn't be loaded (offline + cold cache);
15
+ * lets the caller fall back to its own check rather than hard-block
16
+ *
17
+ * `--exit-zero` reports the JSON verdict but always exits 0 (report-only).
18
+ */
19
+ import { loadConfig } from "../config/config.js";
20
+ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
21
+ import { createLinearService } from "../utils/linear-service.js";
22
+ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
23
+ import { getRootOpts } from "../utils/root-opts.js";
24
+ import { getCurrentBranch, parseBranchName } from "./issue-id.js";
25
+ import { TEAMS_LIST_DEFAULT_LIMIT } from "./teams.js";
26
+ // Branches that never carry a Linear ID and must not be blocked. Mirrors the
27
+ // exemptions in the tools-repo gate (main/master/detached HEAD/empty).
28
+ const EXEMPT_BRANCHES = new Set(["main", "master", "HEAD", ""]);
29
+ export function setupBranchCommands(program) {
30
+ const branch = program.command("branch").description("Branch-name helpers");
31
+ branch.action(() => branch.help());
32
+ branch
33
+ .command("validate [branch]")
34
+ .description("Validate a branch's team prefix against real workspace teams. Exit 0 valid, 1 invalid, 2 indeterminate.")
35
+ .option("--exit-zero", "report the verdict as JSON but always exit 0 (report-only)", false)
36
+ .action(handleAsyncCommand(async (branchArg, options, command) => {
37
+ const branchName = branchArg ?? getCurrentBranch();
38
+ if (EXEMPT_BRANCHES.has(branchName)) {
39
+ outputSuccess({
40
+ branch: branchName,
41
+ valid: true,
42
+ team: null,
43
+ issueId: null,
44
+ reason: "exempt",
45
+ });
46
+ return;
47
+ }
48
+ const parsed = parseBranchName(branchName);
49
+ if (!(parsed.issueId && parsed.team)) {
50
+ outputSuccess({
51
+ branch: branchName,
52
+ valid: false,
53
+ team: null,
54
+ issueId: null,
55
+ reason: "no-linear-id",
56
+ });
57
+ if (!options.exitZero)
58
+ process.exitCode = 1;
59
+ return;
60
+ }
61
+ let teamKeys;
62
+ try {
63
+ const rootOpts = getRootOpts(command);
64
+ const ttl = resolveCacheTTL({
65
+ configTTL: loadConfig().cacheTTLSeconds,
66
+ noCacheFlag: rootOpts.cache === false,
67
+ });
68
+ // Same cache key and limit as `teams list` default — share the entry.
69
+ const teams = await cached(`teams-list-limit:${TEAMS_LIST_DEFAULT_LIMIT}`, ttl, async () => {
70
+ const service = await createLinearService(rootOpts);
71
+ return service.getTeams(TEAMS_LIST_DEFAULT_LIMIT);
72
+ });
73
+ teamKeys = teams.map((t) => t.key);
74
+ }
75
+ catch {
76
+ // Couldn't load the team set (offline + cold cache). Signal
77
+ // indeterminate so the caller can fall back instead of blocking.
78
+ outputSuccess({
79
+ branch: branchName,
80
+ valid: null,
81
+ team: parsed.team,
82
+ issueId: parsed.issueId,
83
+ reason: "teams-unavailable",
84
+ });
85
+ if (!options.exitZero)
86
+ process.exitCode = 2;
87
+ return;
88
+ }
89
+ const valid = teamKeys.includes(parsed.team);
90
+ outputSuccess({
91
+ branch: branchName,
92
+ valid,
93
+ team: parsed.team,
94
+ issueId: parsed.issueId,
95
+ reason: valid ? "ok" : "unknown-team",
96
+ ...(valid ? {} : { knownTeams: teamKeys }),
97
+ });
98
+ if (!(valid || options.exitZero))
99
+ process.exitCode = 1;
100
+ }));
101
+ }
@@ -24,5 +24,6 @@ interface ParsedBranch {
24
24
  team: string | null;
25
25
  }
26
26
  export declare function parseBranchName(branch: string): ParsedBranch;
27
+ export declare function getCurrentBranch(): string;
27
28
  export declare function setupIssueIdCommand(program: Command): void;
28
29
  export {};
@@ -50,7 +50,7 @@ export function parseBranchName(branch) {
50
50
  slug: m[3] ?? null,
51
51
  };
52
52
  }
53
- function getCurrentBranch() {
53
+ export function getCurrentBranch() {
54
54
  const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
55
55
  encoding: "utf8",
56
56
  });
@@ -1233,6 +1233,7 @@ export function setupIssuesCommands(program) {
1233
1233
  .option("--blocks <issues>", "issues this blocks (comma-separated)")
1234
1234
  .option("--blocked-by <issues>", "issues blocking this (comma-separated)")
1235
1235
  .option("--duplicate-of <issue>", "mark as duplicate of another issue")
1236
+ .option("-q, --quiet", "print one confirmation line (SOURCE type targets (count)) instead of the full JSON")
1236
1237
  .action(handleAsyncCommand(handleRelateIssue));
1237
1238
  issues
1238
1239
  .command("related <issueId>")
@@ -1,2 +1,3 @@
1
1
  import type { Command } from "commander";
2
+ export declare const TEAMS_LIST_DEFAULT_LIMIT = 100;
2
3
  export declare function setupTeamsCommands(program: Command): void;
@@ -4,6 +4,7 @@ import { createLinearService } from "../utils/linear-service.js";
4
4
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
5
5
  import { getRootOpts } from "../utils/root-opts.js";
6
6
  import { parsePositiveInt } from "../utils/validators.js";
7
+ export const TEAMS_LIST_DEFAULT_LIMIT = 100;
7
8
  export function setupTeamsCommands(program) {
8
9
  const teams = program
9
10
  .command("teams")
@@ -13,7 +14,7 @@ export function setupTeamsCommands(program) {
13
14
  teams
14
15
  .command("list")
15
16
  .description("List all teams")
16
- .option("-l, --limit <number>", "limit results", "100")
17
+ .option("-l, --limit <number>", "limit results", String(TEAMS_LIST_DEFAULT_LIMIT))
17
18
  .action(handleAsyncCommand(async (options, command) => {
18
19
  const rootOpts = getRootOpts(command);
19
20
  const limit = parsePositiveInt(options.limit, "--limit");
package/dist/main.js CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { program } from "commander";
6
6
  import { setupAttachmentsCommands } from "./commands/attachments.js";
7
7
  import { setupBatchCommands } from "./commands/batch.js";
8
+ import { setupBranchCommands } from "./commands/branch.js";
8
9
  import { setupCommentsCommands } from "./commands/comments.js";
9
10
  import { setupConfigCommands } from "./commands/config.js";
10
11
  import { setupCyclesCommands } from "./commands/cycles.js";
@@ -132,6 +133,7 @@ program.action(() => {
132
133
  });
133
134
  setupAttachmentsCommands(program);
134
135
  setupBatchCommands(program);
136
+ setupBranchCommands(program);
135
137
  setupSearchCommands(program);
136
138
  setupIssuesCommands(program);
137
139
  setupIssueIdCommand(program);
@@ -32,6 +32,16 @@ export declare function formatMilestoneSummary(milestone: Record<string, unknown
32
32
  export declare function formatMilestoneList(milestones: unknown[]): string;
33
33
  export declare function formatTeamList(teams: unknown[]): string;
34
34
  export declare function formatLabelList(labels: unknown[]): string;
35
+ /**
36
+ * Table render for an `issues relate` result — the `{ data: IssueRelation[] }`
37
+ * envelope where each entry is `{ type, issue, relatedIssue }`. Rendered
38
+ * **source-oriented** (`TYPE TARGET TITLE`) to match the `--quiet` line: the
39
+ * type is normalized from the source's perspective (a `--blocked-by` reads as
40
+ * `blockedBy`) and TARGET is the peer (non-source) issue. `sourceRef` comes
41
+ * from the envelope's `meta.source` via `dispatch`; absent it (a bare array),
42
+ * the row falls back to the stored direction.
43
+ */
44
+ export declare function formatRelationList(relations: unknown[], sourceRef?: string): string;
35
45
  export declare function formatUserSummary(user: Record<string, unknown>): string;
36
46
  export declare function formatUserList(users: unknown[]): string;
37
47
  export declare function formatDocumentSummary(doc: Record<string, unknown>): string;
@@ -49,7 +59,7 @@ export declare function formatSearchResultList(results: unknown[]): string;
49
59
  * the full payload. Lists fall back to a simple bulleted list.
50
60
  */
51
61
  export declare function formatGenericSummary(value: unknown): string;
52
- export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "empty-list" | "generic";
62
+ export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "empty-list" | "generic";
53
63
  /**
54
64
  * Heuristic — used by the central `outputSuccess` path which doesn't know
55
65
  * which command produced the payload. Looks at the shape of the data to
@@ -689,6 +689,64 @@ export function formatLabelList(labels) {
689
689
  { header: "COLOR", minWidth: 5, extract: (l) => s(l.color) },
690
690
  ], { emptyText: "(no labels)", itemNoun: "label" });
691
691
  }
692
+ // ── relations ──────────────────────────────────────────────────
693
+ /**
694
+ * Re-frame a stored relation row from the source issue's perspective: returns
695
+ * the normalized relation `type` (a reverse `blocks` becomes `blockedBy`) and
696
+ * the `peer` — the non-source issue node. `createRelations` stores a
697
+ * `--blocked-by X` row reversed (`{ type:"blocks", issue:X, relatedIssue:source }`),
698
+ * so the source can sit on either side; we orient by `sourceRef`.
699
+ *
700
+ * `sourceRef` is the raw arg the user passed — a canonical `TEAM-NUM`
701
+ * identifier or a UUID, the only two forms `resolveIssueId` admits — matched
702
+ * against either node's `identifier` or `id`. (Coupling note: if that resolver
703
+ * is ever widened to accept Linear URLs / slug-ids, as `--project` already
704
+ * does, this match would need the resolved `sourceId` instead.) With no source
705
+ * (`"—"`, e.g. a bare array with no `meta`), nothing matches, so it falls back
706
+ * to the stored direction: peer = `relatedIssue`, type uninverted.
707
+ */
708
+ function relationFromSource(rel, sourceRef) {
709
+ const matchesSource = (node) => !!node &&
710
+ sourceRef !== "—" &&
711
+ (s(node.identifier) === sourceRef || s(node.id) === sourceRef);
712
+ const issue = asObj(rel.issue);
713
+ const related = asObj(rel.relatedIssue);
714
+ // Source on the relatedIssue side ⇒ this is the inverse direction.
715
+ const sourceIsRelated = matchesSource(related) && !matchesSource(issue);
716
+ const peer = sourceIsRelated ? issue : related;
717
+ const rawType = s(rel.type);
718
+ const type = sourceIsRelated && rawType === "blocks" ? "blockedBy" : rawType;
719
+ return { type, peer };
720
+ }
721
+ /**
722
+ * Table render for an `issues relate` result — the `{ data: IssueRelation[] }`
723
+ * envelope where each entry is `{ type, issue, relatedIssue }`. Rendered
724
+ * **source-oriented** (`TYPE TARGET TITLE`) to match the `--quiet` line: the
725
+ * type is normalized from the source's perspective (a `--blocked-by` reads as
726
+ * `blockedBy`) and TARGET is the peer (non-source) issue. `sourceRef` comes
727
+ * from the envelope's `meta.source` via `dispatch`; absent it (a bare array),
728
+ * the row falls back to the stored direction.
729
+ */
730
+ export function formatRelationList(relations, sourceRef = "—") {
731
+ const rows = relations.map((raw) => {
732
+ const { type, peer } = relationFromSource(asObj(raw) ?? {}, sourceRef);
733
+ return {
734
+ type,
735
+ target: s(peer?.identifier ?? peer?.id),
736
+ title: s(peer?.title),
737
+ };
738
+ });
739
+ return renderTable(rows, [
740
+ { header: "TYPE", minWidth: 4, extract: (r) => r.type },
741
+ { header: "TARGET", minWidth: 6, extract: (r) => r.target },
742
+ {
743
+ header: "TITLE",
744
+ minWidth: 5,
745
+ maxWidth: TITLE_TRUNC,
746
+ extract: (r) => r.title,
747
+ },
748
+ ], { emptyText: "(no relations)", itemNoun: "relation" });
749
+ }
692
750
  // ── users ──────────────────────────────────────────────────────
693
751
  export function formatUserSummary(user) {
694
752
  const name = s(user.name);
@@ -968,6 +1026,10 @@ function inferListKind(items) {
968
1026
  return "empty-list";
969
1027
  }
970
1028
  const sample = asObj(items[0]) ?? {};
1029
+ // IssueRelation rows ({ type, issue, relatedIssue }) carry no top-level
1030
+ // identifier/title, so this must precede the issue-list check below.
1031
+ if ("relatedIssue" in sample && "issue" in sample && "type" in sample)
1032
+ return "relation-list";
971
1033
  if ("identifier" in sample && "title" in sample) {
972
1034
  // could be issue or search result
973
1035
  if ("type" in sample && !("templateData" in sample)) {
@@ -1088,6 +1150,8 @@ export function dispatch(kind, payload, fields) {
1088
1150
  return formatReleaseList(list ?? []);
1089
1151
  case "search-result-list":
1090
1152
  return formatSearchResultList(list ?? []);
1153
+ case "relation-list":
1154
+ return formatRelationList(list ?? [], s(asObj(obj?.meta)?.source));
1091
1155
  case "empty-list":
1092
1156
  return "(no results)";
1093
1157
  case "generic":
@@ -1122,5 +1186,44 @@ export function formatLine(payload) {
1122
1186
  if (kind === "comment" && obj) {
1123
1187
  return `comment ${s(obj.id)}`;
1124
1188
  }
1189
+ if (kind === "relation-list") {
1190
+ return formatRelationLine(payload);
1191
+ }
1125
1192
  return JSON.stringify(payload);
1126
1193
  }
1194
+ /**
1195
+ * `--quiet` render for an `issues relate` result: one source-oriented line
1196
+ * grouping the created relations by type from the source issue's perspective.
1197
+ *
1198
+ * EMW-331 related EMW-339,EMW-340 (2)
1199
+ * EMW-331 blockedBy EMW-400; related EMW-339 (2)
1200
+ *
1201
+ * The per-row reframing (peer + type inversion for reverse `--blocked-by`)
1202
+ * is shared with the summary table via `relationFromSource`; see its doc for
1203
+ * the direction and source-matching rules.
1204
+ */
1205
+ function formatRelationLine(payload) {
1206
+ const obj = asObj(payload);
1207
+ const relations = Array.isArray(payload)
1208
+ ? payload
1209
+ : Array.isArray(obj?.data)
1210
+ ? obj.data
1211
+ : [];
1212
+ const sourceRef = s(asObj(obj?.meta)?.source);
1213
+ // Preserve first-seen type order for a stable line.
1214
+ const byType = new Map();
1215
+ for (const raw of relations) {
1216
+ const rel = asObj(raw);
1217
+ if (!rel)
1218
+ continue;
1219
+ const { type, peer } = relationFromSource(rel, sourceRef);
1220
+ const targetId = s(peer?.identifier ?? peer?.id);
1221
+ const bucket = byType.get(type);
1222
+ if (bucket)
1223
+ bucket.push(targetId);
1224
+ else
1225
+ byType.set(type, [targetId]);
1226
+ }
1227
+ const segments = [...byType.entries()].map(([type, ids]) => `${type} ${ids.join(",")}`);
1228
+ return `${sourceRef} ${segments.join("; ")} (${relations.length})`;
1229
+ }
@@ -188,8 +188,10 @@ export declare class GraphQLIssuesService {
188
188
  /**
189
189
  * Batch-fetches N issues in a single GraphQL round-trip (DEV-4477).
190
190
  *
191
- * Replaces `Promise.all(refs.map(getIssueById))` — that pattern issues N
192
- * concurrent queries; this one issues exactly one. Useful for
191
+ * Replaces `Promise.all(refs.map(getIssueById))` as the primary path. The
192
+ * batch response is still order-restored locally; if Linear omits an input
193
+ * ref from that response, the missing ref falls back to the single-issue
194
+ * lookup path so valid issues are not misreported as missing. Useful for
193
195
  * `el-linear issues read <id...>`, the only caller today.
194
196
  *
195
197
  * Identifier shapes accepted (mirrors `getIssueById`):
@@ -66,8 +66,10 @@ export class GraphQLIssuesService {
66
66
  /**
67
67
  * Batch-fetches N issues in a single GraphQL round-trip (DEV-4477).
68
68
  *
69
- * Replaces `Promise.all(refs.map(getIssueById))` — that pattern issues N
70
- * concurrent queries; this one issues exactly one. Useful for
69
+ * Replaces `Promise.all(refs.map(getIssueById))` as the primary path. The
70
+ * batch response is still order-restored locally; if Linear omits an input
71
+ * ref from that response, the missing ref falls back to the single-issue
72
+ * lookup path so valid issues are not misreported as missing. Useful for
71
73
  * `el-linear issues read <id...>`, the only caller today.
72
74
  *
73
75
  * Identifier shapes accepted (mirrors `getIssueById`):
@@ -146,11 +148,12 @@ export class GraphQLIssuesService {
146
148
  for (const ref of refs) {
147
149
  const node = isUuid(ref) ? byUuid.get(ref) : byIdentifier.get(ref);
148
150
  if (!node) {
149
- throw notFoundError("Issue", ref);
151
+ ordered.push(await this.getIssueById(ref));
152
+ continue;
150
153
  }
151
- ordered.push(node);
154
+ ordered.push(this.transformIssueData(node));
152
155
  }
153
- return ordered.map((issue) => this.transformIssueData(issue));
156
+ return ordered;
154
157
  }
155
158
  async getFirstStartedStatus(team) {
156
159
  const statuses = await this.graphQLService.rawRequest(TEAM_STARTED_STATUSES_QUERY, { teamId: team.id });
@@ -5,7 +5,8 @@ import { isUuid } from "./uuid.js";
5
5
  // Unicode-aware mention regex: ASCII-only `\w` would silently fail to
6
6
  // match Cyrillic / accented Latin / CJK names (`@Юрий`, `@Niño`).
7
7
  // `\p{L}` covers all Unicode letters; `\p{N}` covers all numerics.
8
- const EXPLICIT_MENTION_REGEX = /@([\p{L}\p{N}_]+)/gu;
8
+ const MENTION_NAME_CHARS = "\\p{L}\\p{N}_\\-";
9
+ const EXPLICIT_MENTION_REGEX = new RegExp(`(?<![${MENTION_NAME_CHARS}])@([${MENTION_NAME_CHARS}]+)(?![${MENTION_NAME_CHARS}/])`, "gu");
9
10
  const WHITESPACE_SPLIT_REGEX = /\s+/;
10
11
  const FENCED_CODE_REGEX = /```[\s\S]*?```/g;
11
12
  const INLINE_CODE_REGEX = /`[^`]+`/g;
@@ -31,7 +32,10 @@ export async function resolveMentions(body, linearService, options = {}) {
31
32
  // 1. Resolve explicit @name mentions (existing behavior).
32
33
  const explicit = new Map();
33
34
  const unresolvedExplicit = [];
34
- const explicitNames = [...body.matchAll(EXPLICIT_MENTION_REGEX)].map((m) => m[1]);
35
+ const explicitScanBody = stripCodeAndLinks(body);
36
+ const explicitNames = [
37
+ ...explicitScanBody.matchAll(EXPLICIT_MENTION_REGEX),
38
+ ].map((m) => m[1]);
35
39
  for (const name of new Set(explicitNames)) {
36
40
  const userId = await resolveUserByName(name, linearService);
37
41
  if (userId) {
@@ -253,7 +257,7 @@ function buildCombinedRegex(explicit, bare) {
253
257
  // Same Unicode-aware character class as EXPLICIT_MENTION_REGEX
254
258
  // at module top so the splitter and the resolver agree on
255
259
  // what counts as a name char.
256
- parts.push("@[\\p{L}\\p{N}_]+");
260
+ parts.push(`(?<![${MENTION_NAME_CHARS}])@[${MENTION_NAME_CHARS}]+(?![${MENTION_NAME_CHARS}/])`);
257
261
  }
258
262
  if (bare.size > 0) {
259
263
  const alternation = [...bare.keys()]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.30.0",
3
+ "version": "1.32.1",
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",
@@ -53,7 +53,7 @@
53
53
  "dependencies": {
54
54
  "@inquirer/prompts": "^8.4.2",
55
55
  "@linear/sdk": "^86.0.0",
56
- "commander": "^14.0.0",
56
+ "commander": "^15.0.0",
57
57
  "picocolors": "^1.1.1"
58
58
  },
59
59
  "devDependencies": {