@enrichlayer/el-linear 1.37.0 → 1.37.2

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
@@ -145,6 +145,16 @@ EL_LINEAR_PROFILE=forage el-linear teams list
145
145
  el-linear profile remove old-profile
146
146
  ```
147
147
 
148
+ Fix or clean up a single member's aliases/handles without hand-editing
149
+ `config.json` — `profile members` operates directly on the active profile's
150
+ on-disk config (no Linear API round-trip):
151
+
152
+ ```bash
153
+ el-linear profile members list # show configured members
154
+ el-linear profile members clear "linear" # remove a mistaken/stale entry
155
+ el-linear profile members set "Alice Anderson" --aliases ali,al,alex
156
+ ```
157
+
148
158
  Each profile lives at `~/.config/el-linear/profiles/<name>/` and owns:
149
159
 
150
160
  - `token` — its Linear API token (mode 0600)
@@ -160,6 +170,18 @@ The active profile is selected by, in priority:
160
170
  The legacy fallback means **existing single-profile users see no
161
171
  behavior change** — profiles are purely opt-in.
162
172
 
173
+ **Automation and concurrent sessions: prefer `--profile`/`$EL_LINEAR_PROFILE`
174
+ over `profile use`.** `~/.config/el-linear/active-profile` is a single,
175
+ machine-global marker file — `profile use <name>` mutates it for *every*
176
+ el-linear invocation on the machine, including other terminals, scripts, and
177
+ agent sessions that don't set an explicit override. Two processes wanting
178
+ different workspaces at the same time will silently clobber each other. The
179
+ flag and env var are per-invocation and never touch the marker file, so they
180
+ compose safely with any number of concurrent sessions each pinned to their
181
+ own workspace. Reserve `profile use` for a human's own interactive default
182
+ switch; `profile use`/`profile add` print a loud warning when they change the
183
+ global marker so the blast radius is visible instead of silent.
184
+
163
185
  ## Migrating from v1.0–1.3
164
186
 
165
187
  Versions 1.0–1.3 stored everything in the single-file layout
@@ -454,7 +476,7 @@ el-linear <command> --help # detailed help for one command
454
476
  | Search | `search <query>` (semantic, cross-resource) |
455
477
  | Refs | `refs wrap` (rewrite issue identifiers in arbitrary text as links) |
456
478
  | Escape hatch | `graphql [query]` (with `--introspect`) |
457
- | Config | `config show`, `users list`, `teams list`, `templates list` |
479
+ | Config | `config show`, `users {list, read}`, `teams list`, `templates list` |
458
480
 
459
481
  All `list` subcommands support `-l, --limit <n>`. All commands accept the
460
482
  top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
@@ -241,11 +241,10 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
241
241
 
242
242
  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**.
243
243
 
244
- - `<cli> <subcommand> --help` (the flag may be a global option absent from the subcommand's help — check `<cli> --help` too).
245
- - `el-catalog commands --search "<intent>"` / `el-catalog clis --search` (the snapshot likely already lists it).
246
- - For a hook/guard, grep the source — e.g. `cli/el-hook/src/checks`.
244
+ - **`el-catalog commands --search "<intent>"`** / `el-catalog clis --search` — the **authoritative** check for a command/subcommand; it deterministically indexes every `<cli> <subcommand>`. Confirm with `<cli> <subcommand> --help` (and `<cli> --help` — a flag may be a global option absent from the subcommand's help).
245
+ - For a hook/guard, grep the source (e.g. `cli/el-hook/src/checks`) — but **empty grep output is inconclusive, not proof of absence**. A mis-quoted glob or bad flag makes grep exit silently with zero matches (`grep --include=*.ts …` errors under zsh, returning nothing for a symbol that exists). Never read a raw grep's silence as "doesn't exist" for the command check — that is exactly what `el-catalog` is for.
247
246
 
248
- 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.
247
+ Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields` flags filed as "features" though both already existed and worked, and a near-duplicate `el-git` subcommand built after a raw grep silently misfired (the `el-catalog` search would have found the existing one) — every premise caught only at implementation.
249
248
 
250
249
  When `el-linear issues search` (or the cross-resource `search`) returns rows
251
250
  carrying issue identifiers, the JSON envelope embeds a `_warnings` line
@@ -17,6 +17,17 @@ export interface User {
17
17
  displayName: string;
18
18
  }
19
19
  export declare function fetchAllUsers(token: string): Promise<User[]>;
20
+ /**
21
+ * Detect the synthetic Linear-internal "system actor" surfaced by the Users
22
+ * API alongside real people — DEV-5612. Its email is a
23
+ * `<uuid>@linear.linear.app` address (the documented pattern for Linear's own
24
+ * automation account); it isn't a person and should never be offered as an
25
+ * assignable alias target. A user setting up the alias wizard for the first
26
+ * time mistook it for themselves (`linear <linear-…@linear.linear.app>`
27
+ * presented as an ordinary numbered member) and typed their own nicknames as
28
+ * its aliases.
29
+ */
30
+ export declare function isLinearSystemActor(user: User): boolean;
20
31
  /**
21
32
  * Find groups of users sharing the same display name. The current alias
22
33
  * config keys aliases by display name, so colliding users would silently
@@ -25,11 +36,29 @@ export declare function fetchAllUsers(token: string): Promise<User[]>;
25
36
  * schema to UUID-keyed maps.
26
37
  */
27
38
  export declare function findDisplayNameCollisions(users: User[]): Map<string, User[]>;
39
+ /** The alias/handle-mutation slice of {@link AliasUpdate}, without the user
40
+ * identity fields — shared by the UUID-keyed wizard merge and the by-name
41
+ * direct-edit path (DEV-5612), which has no UUID to record. */
42
+ export type MemberAliasMutation = Pick<AliasUpdate, "mode" | "aliases" | "github" | "gitlab">;
28
43
  /**
29
44
  * Merge new alias entries into existing config without dropping unrelated
30
45
  * fields. Pure function — no I/O. Exported for testing.
31
46
  */
32
47
  export declare function mergeAliasesIntoConfig(existing: WizardConfig, updates: Map<string, AliasUpdate>): WizardConfig;
48
+ /**
49
+ * Apply a single member alias/handle mutation directly by display name — the
50
+ * non-interactive counterpart to the `init aliases` walk (DEV-5612). Used by
51
+ * `el-linear profile members clear <name>` / `members set <name>` so a
52
+ * mistaken or unwanted entry (e.g. the Linear system actor, or a stale
53
+ * alias) can be fixed without hand-editing config.json or re-walking every
54
+ * user interactively.
55
+ *
56
+ * Unlike {@link mergeAliasesIntoConfig} (keyed by Linear user UUID, populated
57
+ * by the `init aliases` walk against the live Users API), this operates
58
+ * purely on the on-disk config by display name — the `uuids`/`fullNames`
59
+ * maps are left untouched since there's no fresh UUID to record here.
60
+ */
61
+ export declare function applyMemberAliasUpdate(existing: WizardConfig, displayName: string, update: MemberAliasMutation): WizardConfig;
33
62
  /**
34
63
  * What to do with a single platform handle (github/gitlab/...) when applying
35
64
  * an AliasUpdate. Discriminated union; no sentinel strings.
@@ -46,6 +46,19 @@ export async function fetchAllUsers(token) {
46
46
  }
47
47
  return out;
48
48
  }
49
+ /**
50
+ * Detect the synthetic Linear-internal "system actor" surfaced by the Users
51
+ * API alongside real people — DEV-5612. Its email is a
52
+ * `<uuid>@linear.linear.app` address (the documented pattern for Linear's own
53
+ * automation account); it isn't a person and should never be offered as an
54
+ * assignable alias target. A user setting up the alias wizard for the first
55
+ * time mistook it for themselves (`linear <linear-…@linear.linear.app>`
56
+ * presented as an ordinary numbered member) and typed their own nicknames as
57
+ * its aliases.
58
+ */
59
+ export function isLinearSystemActor(user) {
60
+ return Boolean(user.email && /@linear\.linear\.app$/i.test(user.email));
61
+ }
49
62
  /**
50
63
  * Find groups of users sharing the same display name. The current alias
51
64
  * config keys aliases by display name, so colliding users would silently
@@ -82,6 +95,45 @@ function applyHandleAction(handlesMap, fullName, action) {
82
95
  handlesMap[action.value.trim()] = fullName;
83
96
  }
84
97
  }
98
+ /**
99
+ * Apply one member's alias/handle mutation into `next` (mutated in place).
100
+ * Ensures the `members.aliases`/`members.handles.{github,gitlab}` sub-trees
101
+ * exist first. Pure with respect to `fullName`/`update` — the only side
102
+ * effect is on `next`.
103
+ */
104
+ function applyAliasMutation(next, fullName, update) {
105
+ next.members = next.members ?? {};
106
+ next.members.aliases = next.members.aliases ?? {};
107
+ next.members.handles = next.members.handles ?? {};
108
+ next.members.handles.github = next.members.handles.github ?? {};
109
+ next.members.handles.gitlab = next.members.handles.gitlab ?? {};
110
+ // Aliases: replace this user's entries based on the update mode.
111
+ // We track aliases as { aliasKey: fullName }, so removing means deleting
112
+ // any keys whose value is this fullName.
113
+ const currentAliasKeys = Object.entries(next.members.aliases)
114
+ .filter(([_, v]) => v === fullName)
115
+ .map(([k]) => k);
116
+ if (update.mode === "clear") {
117
+ for (const k of currentAliasKeys)
118
+ delete next.members.aliases[k];
119
+ }
120
+ else if (update.mode === "edit") {
121
+ for (const k of currentAliasKeys)
122
+ delete next.members.aliases[k];
123
+ for (const a of update.aliases)
124
+ next.members.aliases[a] = fullName;
125
+ }
126
+ else if (update.mode === "append") {
127
+ for (const a of update.aliases)
128
+ next.members.aliases[a] = fullName;
129
+ }
130
+ // "keep" → no changes.
131
+ // GitHub / GitLab handles use the same edit/append/clear semantics,
132
+ // driven by the discriminated HandleAction per platform.
133
+ for (const platform of HANDLE_PLATFORMS) {
134
+ applyHandleAction(next.members.handles[platform], fullName, update[platform]);
135
+ }
136
+ }
85
137
  /**
86
138
  * Merge new alias entries into existing config without dropping unrelated
87
139
  * fields. Pure function — no I/O. Exported for testing.
@@ -89,45 +141,34 @@ function applyHandleAction(handlesMap, fullName, action) {
89
141
  export function mergeAliasesIntoConfig(existing, updates) {
90
142
  const next = JSON.parse(JSON.stringify(existing));
91
143
  next.members = next.members ?? {};
92
- next.members.aliases = next.members.aliases ?? {};
93
144
  next.members.fullNames = next.members.fullNames ?? {};
94
- next.members.handles = next.members.handles ?? {};
95
145
  next.members.uuids = next.members.uuids ?? {};
96
- next.members.handles.github = next.members.handles.github ?? {};
97
- next.members.handles.gitlab = next.members.handles.gitlab ?? {};
98
146
  for (const [userId, update] of updates) {
99
147
  const fullName = update.displayName;
100
148
  next.members.uuids[fullName] = userId;
101
149
  next.members.fullNames[userId] = fullName;
102
- // Aliases: replace this user's entries based on the update mode.
103
- // We track aliases as { aliasKey: fullName }, so removing means deleting
104
- // any keys whose value is this fullName.
105
- const currentAliasKeys = Object.entries(next.members.aliases)
106
- .filter(([_, v]) => v === fullName)
107
- .map(([k]) => k);
108
- if (update.mode === "clear") {
109
- for (const k of currentAliasKeys)
110
- delete next.members.aliases[k];
111
- }
112
- else if (update.mode === "edit") {
113
- for (const k of currentAliasKeys)
114
- delete next.members.aliases[k];
115
- for (const a of update.aliases)
116
- next.members.aliases[a] = fullName;
117
- }
118
- else if (update.mode === "append") {
119
- for (const a of update.aliases)
120
- next.members.aliases[a] = fullName;
121
- }
122
- // "keep" → no changes.
123
- // GitHub / GitLab handles use the same edit/append/clear semantics,
124
- // driven by the discriminated HandleAction per platform.
125
- for (const platform of HANDLE_PLATFORMS) {
126
- applyHandleAction(next.members.handles[platform], fullName, update[platform]);
127
- }
150
+ applyAliasMutation(next, fullName, update);
128
151
  }
129
152
  return next;
130
153
  }
154
+ /**
155
+ * Apply a single member alias/handle mutation directly by display name — the
156
+ * non-interactive counterpart to the `init aliases` walk (DEV-5612). Used by
157
+ * `el-linear profile members clear <name>` / `members set <name>` so a
158
+ * mistaken or unwanted entry (e.g. the Linear system actor, or a stale
159
+ * alias) can be fixed without hand-editing config.json or re-walking every
160
+ * user interactively.
161
+ *
162
+ * Unlike {@link mergeAliasesIntoConfig} (keyed by Linear user UUID, populated
163
+ * by the `init aliases` walk against the live Users API), this operates
164
+ * purely on the on-disk config by display name — the `uuids`/`fullNames`
165
+ * maps are left untouched since there's no fresh UUID to record here.
166
+ */
167
+ export function applyMemberAliasUpdate(existing, displayName, update) {
168
+ const next = JSON.parse(JSON.stringify(existing));
169
+ applyAliasMutation(next, displayName, update);
170
+ return next;
171
+ }
131
172
  /** Set of supported platforms for member handle keys. */
132
173
  export const HANDLE_PLATFORMS = ["github", "gitlab"];
133
174
  function currentAliasesFor(config, fullName) {
@@ -162,11 +203,24 @@ export async function runAliasesStep(token, existing, options = {}) {
162
203
  console.log(" Skipped — run `el-linear init aliases` to add or edit aliases later.");
163
204
  return new Map();
164
205
  }
165
- const allUsers = await fetchAllUsers(token);
166
- if (allUsers.length === 0) {
206
+ const fetchedUsers = await fetchAllUsers(token);
207
+ if (fetchedUsers.length === 0) {
167
208
  console.log(" No active users visible to this token.");
168
209
  return new Map();
169
210
  }
211
+ // Skip the Linear system actor(s) — not real people, never alias targets
212
+ // (DEV-5612). Filtered before collision detection so it can't spuriously
213
+ // collide with (or mask) a real display-name collision.
214
+ const systemActors = fetchedUsers.filter(isLinearSystemActor);
215
+ if (systemActors.length > 0) {
216
+ console.log(` Skipping ${systemActors.length} Linear system actor(s) — not real people, never alias targets: ` +
217
+ `${systemActors.map((u) => u.email ?? u.id).join(", ")}`);
218
+ }
219
+ const allUsers = fetchedUsers.filter((u) => !isLinearSystemActor(u));
220
+ if (allUsers.length === 0) {
221
+ console.log(" No human users remain after filtering system actors.");
222
+ return new Map();
223
+ }
170
224
  // Skip users whose display name collides with another active user. The
171
225
  // current schema keys aliases by display name, so writing aliases for a
172
226
  // colliding user would corrupt the other user's entries. A schema migration
@@ -7,7 +7,7 @@ import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvabl
7
7
  import { resolveDefaultStatus } from "../config/status-defaults.js";
8
8
  import { enforceTerms } from "../config/term-enforcer.js";
9
9
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
10
- import { DEFAULT_DUPLICATE_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
10
+ import { DEFAULT_DUPLICATE_THRESHOLD, DEFAULT_HARD_BLOCK_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
11
11
  import { createFileService } from "../utils/file-service.js";
12
12
  import { applyFooter } from "../utils/footer.js";
13
13
  import { emitGateEvent } from "../utils/gate-telemetry.js";
@@ -18,7 +18,7 @@ import { createLinearService, } from "../utils/linear-service.js";
18
18
  import { logger } from "../utils/logger.js";
19
19
  import { handleAsyncCommand, outputSuccess, outputWarning, warnIfTruncated, } from "../utils/output.js";
20
20
  import { buildRelationCandidatePrompt } from "../utils/relation-candidate-prompt.js";
21
- import { getRootOpts } from "../utils/root-opts.js";
21
+ import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
22
22
  import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
23
23
  import { parsePositiveInt, parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
24
24
  import { currentGitBranch, extractIssueIdentifierFromBranch, getBranchLinearIssue, gitCheckoutBranch, setBranchLinearIssue, toBranchName, } from "./issues/branch.js";
@@ -121,7 +121,17 @@ function buildUpdateArgs(issueId, options, assigneeId, delegateId) {
121
121
  dueDate: options.dueDate || (options.clearDueDate ? null : undefined),
122
122
  };
123
123
  }
124
- function outputIssues(issues, format, fields, meta) {
124
+ /**
125
+ * `--format` / `--fields` are registered on both the root program and the
126
+ * list/search subcommands, and commander 15 assigns the CLI token to the
127
+ * root registration — so the values must be resolved via
128
+ * {@link effectiveOption} rather than read off the subcommand's own
129
+ * options (DEV-5376: `--format table --fields …` silently fell through to
130
+ * the JSON envelope because `options.format` stayed at its default).
131
+ */
132
+ function outputIssues(issues, command, meta) {
133
+ const format = effectiveOption(command, "format");
134
+ const fields = effectiveOption(command, "fields");
125
135
  const fieldList = fields ? splitList(fields) : undefined;
126
136
  if (format === "table") {
127
137
  logger.info(formatTable(issues, fieldList));
@@ -134,6 +144,13 @@ function outputIssues(issues, format, fields, meta) {
134
144
  logger.info(formatCsv(issues, fieldList));
135
145
  }
136
146
  else {
147
+ // JSON path: the `--fields` projection is applied by the global
148
+ // `fieldsFilter` inside `outputSuccess` (set in main.ts's preAction),
149
+ // NOT by `fieldList` here — `fieldList` only feeds the tabular
150
+ // formatters above. Keeping the projection in one place is what
151
+ // prevents double-application; a future refactor that routes
152
+ // table/csv/md through `outputSuccess` would need to drop the global
153
+ // filter or it would project twice.
137
154
  outputSuccess({ data: issues, meta: { count: issues.length, ...meta } });
138
155
  }
139
156
  }
@@ -228,14 +245,14 @@ async function handleListIssues(options, command) {
228
245
  outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
229
246
  }
230
247
  warnIfTruncated(result.length, limit);
231
- outputIssues(result, options.format, options.fields, {
248
+ outputIssues(result, command, {
232
249
  team: options.team,
233
250
  });
234
251
  }
235
252
  else {
236
253
  const result = sortIssues(await issuesService.getIssues(limit), options.sort);
237
254
  warnIfTruncated(result.length, limit);
238
- outputIssues(result, options.format, options.fields, {});
255
+ outputIssues(result, command, {});
239
256
  }
240
257
  }
241
258
  async function handleSearchIssues(query, options, command) {
@@ -282,7 +299,7 @@ async function handleSearchIssues(query, options, command) {
282
299
  if (relationPrompt) {
283
300
  outputWarning(relationPrompt);
284
301
  }
285
- outputIssues(result, options.format, options.fields, { query });
302
+ outputIssues(result, command, { query });
286
303
  }
287
304
  /**
288
305
  * Run an `issuesService.createIssue` / `.updateIssue` call and, if it
@@ -460,18 +477,28 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
460
477
  /**
461
478
  * DEV-4823: create-time duplicate-detection gate. Searches the title's
462
479
  * salient keywords (including closed issues), scores candidates by Jaccard
463
- * title-overlap, and throws — listing the matches — when one is at/above the
464
- * configured similarity threshold.
480
+ * title-overlap, and — for a candidate at/above the hard-block threshold —
481
+ * throws, listing the matches.
482
+ *
483
+ * DEV-5590: two-tier since the single-threshold gate measured a 52.2%
484
+ * override rate (score alone didn't separate genuine duplicates from
485
+ * legitimate distinct issues — see {@link DEFAULT_HARD_BLOCK_THRESHOLD} for
486
+ * the data). A candidate at/above `duplicateThreshold` (detection floor) but
487
+ * below `duplicateHardBlockThreshold` (hard-block floor) is ADVISORY: printed
488
+ * as a warning, creation proceeds, and the fire is recorded as `advisory` —
489
+ * excluded from the override-rate denominator (only `blocked`/`overridden`
490
+ * count). At/above the hard-block floor, behavior is unchanged from DEV-4823.
465
491
  *
466
492
  * Bypassed by `--skip-validation` and
467
493
  * `config.validation.duplicateDetection: false` / `validation.enabled: false`.
468
494
  * `--allow-duplicate` does NOT skip the search — the gate still runs and, on a
469
- * would-fire, records an `overridden` telemetry event (DEV-4834) before
470
- * proceeding, so `el-telemetry gates` can measure the override-rate. (A blocked
471
- * fire records `blocked`.) `--skip-validation` is a blanket bypass and emits
472
- * nothing — it's not a gate-specific override, so counting it would dilute the
473
- * signal. The search itself is best-effort: a network/API failure warns and
474
- * proceeds rather than blocking legitimate issue creation on infra trouble.
495
+ * would-hard-block, records an `overridden` telemetry event (DEV-4834) before
496
+ * proceeding, so `el-telemetry gates` can measure the override-rate. (A
497
+ * hard-blocked fire records `blocked`.) `--skip-validation` is a blanket
498
+ * bypass and emits nothing — it's not a gate-specific override, so counting
499
+ * it would dilute the signal. The search itself is best-effort: a network/API
500
+ * failure warns and proceeds rather than blocking legitimate issue creation
501
+ * on infra trouble.
475
502
  */
476
503
  async function enforceNoDuplicateIssue(title, options, issuesService) {
477
504
  if (options.skipValidation) {
@@ -492,6 +519,9 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
492
519
  const threshold = typeof validation?.duplicateThreshold === "number"
493
520
  ? validation.duplicateThreshold
494
521
  : DEFAULT_DUPLICATE_THRESHOLD;
522
+ const hardBlockThreshold = typeof validation?.duplicateHardBlockThreshold === "number"
523
+ ? validation.duplicateHardBlockThreshold
524
+ : DEFAULT_HARD_BLOCK_THRESHOLD;
495
525
  let candidates;
496
526
  try {
497
527
  candidates = await issuesService.searchIssues({
@@ -524,14 +554,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
524
554
  if (matches.length === 0) {
525
555
  return;
526
556
  }
527
- // The gate would fire. Record the decision so `el-telemetry gates` can
528
- // compute override-rate (DEV-4834): `overridden` when the user passed
529
- // --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
530
557
  const gateEvent = {
531
558
  gate: "issues-create-dup",
532
559
  topScore: matches[0].score,
533
560
  candidateCount: matches.length,
534
561
  };
562
+ // DEV-5590: below the hard-block floor, warn and proceed — never forces a
563
+ // stop, so there's nothing to "override". Recorded as `advisory` (visible
564
+ // in the raw ledger, excluded from the override-rate metric).
565
+ if (matches[0].score < hardBlockThreshold) {
566
+ await emitGateEvent("el-linear", "issues create", {
567
+ ...gateEvent,
568
+ outcome: "advisory",
569
+ });
570
+ outputWarning(formatDuplicateBlock(matches, "advisory"));
571
+ return;
572
+ }
573
+ // The gate would hard-block. Record the decision so `el-telemetry gates`
574
+ // can compute override-rate (DEV-4834): `overridden` when the user passed
575
+ // --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
535
576
  if (options.allowDuplicate) {
536
577
  await emitGateEvent("el-linear", "issues create", {
537
578
  ...gateEvent,
@@ -1251,7 +1292,7 @@ export function setupIssuesCommands(program) {
1251
1292
  .option("--checkout", "create and checkout a git branch named after the issue")
1252
1293
  .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
1253
1294
  .option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate)")
1254
- .option("--allow-duplicate", "skip the duplicate-detection gate and create even if a similar issue already exists")
1295
+ .option("--allow-duplicate", "silence the duplicate-detection hard block and create even if a near-identical issue already exists (DEV-5590: only matters at/above the hard-block threshold — below it the gate is advisory-only and never blocks)")
1255
1296
  .option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
1256
1297
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1257
1298
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `el-linear profile members` — direct, non-interactive alias/handle edits
3
+ * on the active profile's on-disk config — DEV-5612.
4
+ *
5
+ * `init aliases` (the interactive wizard walk) is the right tool for a
6
+ * first-time setup or a bulk pass over many users, but fixing ONE entry
7
+ * (e.g. clearing a mistaken alias, or the Linear system actor a user
8
+ * accidentally aliased before DEV-5612's wizard-side skip existed) meant
9
+ * either re-walking every user interactively or hand-editing
10
+ * `~/.config/el-linear/config.json`. These commands are the direct path:
11
+ *
12
+ * el-linear profile members list — show configured members
13
+ * el-linear profile members clear <name> — remove all aliases/handles for <name>
14
+ * el-linear profile members set <name> [options] — replace aliases/handles for <name>
15
+ *
16
+ * `<name>` is the exact Linear display name as it appears in
17
+ * `members.aliases`/`members.handles.*` values (see `profile members list`).
18
+ * These commands operate purely on the local config file — no Linear API
19
+ * call, no UUID resolution — so they also work to clean up a stale/mistaken
20
+ * entry for a user no longer in the workspace.
21
+ */
22
+ import type { Command } from "commander";
23
+ import { type WizardConfig } from "../init/shared.js";
24
+ export interface MemberListEntry {
25
+ displayName: string;
26
+ aliases: string[];
27
+ github?: string;
28
+ gitlab?: string;
29
+ }
30
+ /**
31
+ * Reconstruct a per-member view from the on-disk `members.aliases` /
32
+ * `members.handles.{github,gitlab}` maps (each `{ key: displayName }`).
33
+ * `WizardConfig`'s maps are `Record<string, string | undefined>` (DeepPartial
34
+ * over an index signature) — an `undefined` value can't happen in practice
35
+ * (JSON never round-trips one), but we guard it defensively rather than cast.
36
+ * Pure — exported for testing.
37
+ */
38
+ export declare function listMembers(config: WizardConfig): MemberListEntry[];
39
+ /** `el-linear profile members list` — exported for direct testing. */
40
+ export declare function runMembersList(): Promise<MemberListEntry[]>;
41
+ /** `el-linear profile members clear <name>` — exported for direct testing. */
42
+ export declare function runMembersClear(name: string): Promise<void>;
43
+ export interface MembersSetOptions {
44
+ aliases?: string;
45
+ github?: string;
46
+ gitlab?: string;
47
+ }
48
+ /** `el-linear profile members set <name>` — exported for direct testing. */
49
+ export declare function runMembersSet(name: string, opts: MembersSetOptions): Promise<void>;
50
+ export declare function registerMembersCommands(profile: Command): void;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `el-linear profile members` — direct, non-interactive alias/handle edits
3
+ * on the active profile's on-disk config — DEV-5612.
4
+ *
5
+ * `init aliases` (the interactive wizard walk) is the right tool for a
6
+ * first-time setup or a bulk pass over many users, but fixing ONE entry
7
+ * (e.g. clearing a mistaken alias, or the Linear system actor a user
8
+ * accidentally aliased before DEV-5612's wizard-side skip existed) meant
9
+ * either re-walking every user interactively or hand-editing
10
+ * `~/.config/el-linear/config.json`. These commands are the direct path:
11
+ *
12
+ * el-linear profile members list — show configured members
13
+ * el-linear profile members clear <name> — remove all aliases/handles for <name>
14
+ * el-linear profile members set <name> [options] — replace aliases/handles for <name>
15
+ *
16
+ * `<name>` is the exact Linear display name as it appears in
17
+ * `members.aliases`/`members.handles.*` values (see `profile members list`).
18
+ * These commands operate purely on the local config file — no Linear API
19
+ * call, no UUID resolution — so they also work to clean up a stale/mistaken
20
+ * entry for a user no longer in the workspace.
21
+ */
22
+ import { outputSuccess } from "../../utils/output.js";
23
+ import { applyMemberAliasUpdate } from "../init/aliases.js";
24
+ import { parseCsvList, readConfig, updateConfig, } from "../init/shared.js";
25
+ /**
26
+ * Reconstruct a per-member view from the on-disk `members.aliases` /
27
+ * `members.handles.{github,gitlab}` maps (each `{ key: displayName }`).
28
+ * `WizardConfig`'s maps are `Record<string, string | undefined>` (DeepPartial
29
+ * over an index signature) — an `undefined` value can't happen in practice
30
+ * (JSON never round-trips one), but we guard it defensively rather than cast.
31
+ * Pure — exported for testing.
32
+ */
33
+ export function listMembers(config) {
34
+ const aliases = config.members?.aliases ?? {};
35
+ const github = config.members?.handles?.github ?? {};
36
+ const gitlab = config.members?.handles?.gitlab ?? {};
37
+ const byName = new Map();
38
+ const ensure = (name) => {
39
+ let entry = byName.get(name);
40
+ if (!entry) {
41
+ entry = { displayName: name, aliases: [] };
42
+ byName.set(name, entry);
43
+ }
44
+ return entry;
45
+ };
46
+ for (const [alias, name] of Object.entries(aliases)) {
47
+ if (name === undefined)
48
+ continue;
49
+ ensure(name).aliases.push(alias);
50
+ }
51
+ for (const [handle, name] of Object.entries(github)) {
52
+ if (name === undefined)
53
+ continue;
54
+ ensure(name).github = handle;
55
+ }
56
+ for (const [handle, name] of Object.entries(gitlab)) {
57
+ if (name === undefined)
58
+ continue;
59
+ ensure(name).gitlab = handle;
60
+ }
61
+ return [...byName.values()].sort((a, b) => a.displayName.localeCompare(b.displayName));
62
+ }
63
+ /**
64
+ * Translate a `--github`/`--gitlab` flag value into a HandleAction:
65
+ * - flag absent → keep (leave any existing handle alone)
66
+ * - flag present, empty → clear
67
+ * - flag present, value → set
68
+ */
69
+ function handleActionFromFlag(raw) {
70
+ if (raw === undefined)
71
+ return { kind: "keep" };
72
+ const trimmed = raw.trim();
73
+ return trimmed ? { kind: "set", value: trimmed } : { kind: "clear" };
74
+ }
75
+ /** `el-linear profile members list` — exported for direct testing. */
76
+ export async function runMembersList() {
77
+ const config = await readConfig();
78
+ return listMembers(config);
79
+ }
80
+ /** `el-linear profile members clear <name>` — exported for direct testing. */
81
+ export async function runMembersClear(name) {
82
+ await updateConfig((current) => applyMemberAliasUpdate(current, name, {
83
+ mode: "clear",
84
+ aliases: [],
85
+ github: { kind: "clear" },
86
+ gitlab: { kind: "clear" },
87
+ }));
88
+ }
89
+ /** `el-linear profile members set <name>` — exported for direct testing. */
90
+ export async function runMembersSet(name, opts) {
91
+ if (opts.aliases === undefined &&
92
+ opts.github === undefined &&
93
+ opts.gitlab === undefined) {
94
+ throw new Error("Pass at least one of --aliases, --github, --gitlab.");
95
+ }
96
+ await updateConfig((current) => applyMemberAliasUpdate(current, name, {
97
+ mode: opts.aliases !== undefined ? "edit" : "keep",
98
+ aliases: opts.aliases !== undefined ? parseCsvList(opts.aliases) : [],
99
+ github: handleActionFromFlag(opts.github),
100
+ gitlab: handleActionFromFlag(opts.gitlab),
101
+ }));
102
+ }
103
+ export function registerMembersCommands(profile) {
104
+ const members = profile
105
+ .command("members")
106
+ .description("Directly edit or clear a member's aliases/handles without hand-editing config.json (DEV-5612).");
107
+ members.action(() => members.help());
108
+ members
109
+ .command("list")
110
+ .description("List members with configured aliases/GitHub/GitLab handles.")
111
+ .action(async () => {
112
+ const data = await runMembersList();
113
+ outputSuccess({ data, meta: { count: data.length } });
114
+ });
115
+ members
116
+ .command("clear <name>")
117
+ .description("Remove all aliases + GitHub/GitLab handles for <name> (exact display name, see `members list`).")
118
+ .action(async (name) => {
119
+ await runMembersClear(name);
120
+ outputSuccess({ data: { cleared: name } });
121
+ });
122
+ members
123
+ .command("set <name>")
124
+ .description("Replace aliases/handles for <name> (exact display name). Omit a flag to leave that field unchanged; pass an empty value to clear just that field.")
125
+ .option("--aliases <csv>", "comma-separated aliases; replaces the existing set for this member")
126
+ .option("--github <handle>", "GitHub handle; pass an empty string to clear")
127
+ .option("--gitlab <handle>", "GitLab handle; pass an empty string to clear")
128
+ .action(async (name, opts) => {
129
+ await runMembersSet(name, opts);
130
+ outputSuccess({ data: { updated: name } });
131
+ });
132
+ }