@enrichlayer/el-linear 1.37.1 → 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 +11 -1
- package/dist/commands/init/aliases.d.ts +29 -0
- package/dist/commands/init/aliases.js +86 -32
- package/dist/commands/issues.js +37 -13
- package/dist/commands/profile/members.d.ts +50 -0
- package/dist/commands/profile/members.js +132 -0
- package/dist/commands/profile.js +4 -0
- package/dist/commands/users.js +9 -0
- package/dist/config/config.d.ts +11 -0
- package/dist/utils/duplicate-detection.d.ts +46 -2
- package/dist/utils/duplicate-detection.js +54 -4
- package/dist/utils/gate-telemetry.d.ts +8 -2
- package/dist/utils/linear-service.d.ts +8 -0
- package/dist/utils/linear-service.js +18 -0
- package/package.json +1 -1
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)
|
|
@@ -466,7 +476,7 @@ el-linear <command> --help # detailed help for one command
|
|
|
466
476
|
| Search | `search <query>` (semantic, cross-resource) |
|
|
467
477
|
| Refs | `refs wrap` (rewrite issue identifiers in arbitrary text as links) |
|
|
468
478
|
| Escape hatch | `graphql [query]` (with `--introspect`) |
|
|
469
|
-
| Config | `config show`, `users list`, `teams list`, `templates list` |
|
|
479
|
+
| Config | `config show`, `users {list, read}`, `teams list`, `templates list` |
|
|
470
480
|
|
|
471
481
|
All `list` subcommands support `-l, --limit <n>`. All commands accept the
|
|
472
482
|
top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
|
|
@@ -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
|
-
|
|
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
|
|
166
|
-
if (
|
|
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
|
package/dist/commands/issues.js
CHANGED
|
@@ -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";
|
|
@@ -477,18 +477,28 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
477
477
|
/**
|
|
478
478
|
* DEV-4823: create-time duplicate-detection gate. Searches the title's
|
|
479
479
|
* salient keywords (including closed issues), scores candidates by Jaccard
|
|
480
|
-
* title-overlap, and
|
|
481
|
-
*
|
|
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.
|
|
482
491
|
*
|
|
483
492
|
* Bypassed by `--skip-validation` and
|
|
484
493
|
* `config.validation.duplicateDetection: false` / `validation.enabled: false`.
|
|
485
494
|
* `--allow-duplicate` does NOT skip the search — the gate still runs and, on a
|
|
486
|
-
* would-
|
|
487
|
-
* proceeding, so `el-telemetry gates` can measure the override-rate. (A
|
|
488
|
-
* fire records `blocked`.) `--skip-validation` is a blanket
|
|
489
|
-
* nothing — it's not a gate-specific override, so counting
|
|
490
|
-
* signal. The search itself is best-effort: a network/API
|
|
491
|
-
* proceeds rather than blocking legitimate issue creation
|
|
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.
|
|
492
502
|
*/
|
|
493
503
|
async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
494
504
|
if (options.skipValidation) {
|
|
@@ -509,6 +519,9 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
509
519
|
const threshold = typeof validation?.duplicateThreshold === "number"
|
|
510
520
|
? validation.duplicateThreshold
|
|
511
521
|
: DEFAULT_DUPLICATE_THRESHOLD;
|
|
522
|
+
const hardBlockThreshold = typeof validation?.duplicateHardBlockThreshold === "number"
|
|
523
|
+
? validation.duplicateHardBlockThreshold
|
|
524
|
+
: DEFAULT_HARD_BLOCK_THRESHOLD;
|
|
512
525
|
let candidates;
|
|
513
526
|
try {
|
|
514
527
|
candidates = await issuesService.searchIssues({
|
|
@@ -541,14 +554,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
541
554
|
if (matches.length === 0) {
|
|
542
555
|
return;
|
|
543
556
|
}
|
|
544
|
-
// The gate would fire. Record the decision so `el-telemetry gates` can
|
|
545
|
-
// compute override-rate (DEV-4834): `overridden` when the user passed
|
|
546
|
-
// --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
|
|
547
557
|
const gateEvent = {
|
|
548
558
|
gate: "issues-create-dup",
|
|
549
559
|
topScore: matches[0].score,
|
|
550
560
|
candidateCount: matches.length,
|
|
551
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.
|
|
552
576
|
if (options.allowDuplicate) {
|
|
553
577
|
await emitGateEvent("el-linear", "issues create", {
|
|
554
578
|
...gateEvent,
|
|
@@ -1268,7 +1292,7 @@ export function setupIssuesCommands(program) {
|
|
|
1268
1292
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1269
1293
|
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1270
1294
|
.option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate)")
|
|
1271
|
-
.option("--allow-duplicate", "
|
|
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)")
|
|
1272
1296
|
.option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
|
|
1273
1297
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1274
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
|
+
}
|
package/dist/commands/profile.js
CHANGED
|
@@ -26,6 +26,7 @@ import { confirm } from "@inquirer/prompts";
|
|
|
26
26
|
import { ACTIVE_PROFILE_FILE, CONFIG_DIR, CONFIG_PATH, isSafeProfileName as isSafeName, PROFILES_DIR, profilePaths, readActiveProfileMarker, resolveActiveProfile, setActiveProfileForSession, TOKEN_PATH, } from "../config/paths.js";
|
|
27
27
|
import { outputSuccess, outputWarning } from "../utils/output.js";
|
|
28
28
|
import { runFullWizard } from "./init/index.js";
|
|
29
|
+
import { registerMembersCommands } from "./profile/members.js";
|
|
29
30
|
import { registerMigrateLegacy } from "./profile/migrate-legacy.js";
|
|
30
31
|
export function setupProfileCommands(program) {
|
|
31
32
|
const profile = program
|
|
@@ -79,6 +80,9 @@ export function setupProfileCommands(program) {
|
|
|
79
80
|
// self-contained and unit-testable without dragging in the full
|
|
80
81
|
// profile-management surface.
|
|
81
82
|
registerMigrateLegacy(profile);
|
|
83
|
+
// `el-linear profile members {list,clear,set}` — DEV-5612: direct,
|
|
84
|
+
// non-interactive alias/handle edits without hand-editing config.json.
|
|
85
|
+
registerMembersCommands(profile);
|
|
82
86
|
}
|
|
83
87
|
export async function runProfileList() {
|
|
84
88
|
const active = resolveActiveProfile();
|
package/dist/commands/users.js
CHANGED
|
@@ -5,6 +5,15 @@ import { parsePositiveInt } from "../utils/validators.js";
|
|
|
5
5
|
export function setupUsersCommands(program) {
|
|
6
6
|
const users = program.command("users").description("User operations");
|
|
7
7
|
users.action(() => users.help());
|
|
8
|
+
users
|
|
9
|
+
.command("read <id>")
|
|
10
|
+
.description("Look up a single user by UUID, email, or name (resolves ambiguity the same way as other --assignee-style lookups).")
|
|
11
|
+
.action(handleAsyncCommand(async (id, _options, command) => {
|
|
12
|
+
const rootOpts = getRootOpts(command);
|
|
13
|
+
const service = await createLinearService(rootOpts);
|
|
14
|
+
const result = await service.getUser(id);
|
|
15
|
+
outputSuccess({ data: result });
|
|
16
|
+
}));
|
|
8
17
|
users
|
|
9
18
|
.command("list")
|
|
10
19
|
.description("List all users")
|
package/dist/config/config.d.ts
CHANGED
|
@@ -69,6 +69,17 @@ export interface ElLinearConfig {
|
|
|
69
69
|
* `DEFAULT_DUPLICATE_THRESHOLD` (0.35). Lower = more aggressive.
|
|
70
70
|
*/
|
|
71
71
|
duplicateThreshold?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Jaccard title-similarity threshold (0-1) above which a duplicate
|
|
74
|
+
* candidate HARD blocks creation (throws, requires `--allow-duplicate`).
|
|
75
|
+
* A candidate scoring at/above `duplicateThreshold` but below this is
|
|
76
|
+
* advisory only — printed, creation proceeds. Defaults to
|
|
77
|
+
* `DEFAULT_HARD_BLOCK_THRESHOLD` (0.6). See DEV-5590 for why the gate
|
|
78
|
+
* is two-tier: single-threshold override-rate telemetry showed score
|
|
79
|
+
* alone doesn't separate genuine duplicates from legitimate distinct
|
|
80
|
+
* issues in the 0.35-0.6 range.
|
|
81
|
+
*/
|
|
82
|
+
duplicateHardBlockThreshold?: number;
|
|
72
83
|
/**
|
|
73
84
|
* OPT-IN SOP-label parent gate (DEV-5378). When `true`, `issues create`
|
|
74
85
|
* requires an issue carrying an SOP-type label (see `sopLabels`) to point
|
|
@@ -32,6 +32,44 @@ import type { LinearIssue } from "../types/linear.js";
|
|
|
32
32
|
* Overridable via `config.validation.duplicateThreshold`.
|
|
33
33
|
*/
|
|
34
34
|
export declare const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
|
|
35
|
+
/**
|
|
36
|
+
* Threshold above which a candidate is a HARD block (creation refuses without
|
|
37
|
+
* `--allow-duplicate`). Below this (but at/above {@link DEFAULT_DUPLICATE_THRESHOLD})
|
|
38
|
+
* a candidate is ADVISORY ONLY — printed, but creation proceeds — DEV-5590.
|
|
39
|
+
*
|
|
40
|
+
* Why a second threshold instead of just retuning the first: `el-telemetry
|
|
41
|
+
* gates` measured a 52.2% override rate on the single-threshold (0.35) gate
|
|
42
|
+
* over 144 real fires. Analyzing the real ledger (`top_score` + `outcome` per
|
|
43
|
+
* fire, no title text is ever recorded — el-linear collects nothing by
|
|
44
|
+
* default) falsifies the obvious fix of "just raise the threshold": mean/
|
|
45
|
+
* median `top_score` for `blocked` fires (0.414 / 0.40) and `overridden`
|
|
46
|
+
* fires (0.421 / 0.40) are statistically indistinguishable, and simulating
|
|
47
|
+
* every cutoff from 0.35 to 0.56 against the real corpus held the override
|
|
48
|
+
* rate flat at 52–63% — *increasing* at some higher cutoffs. Score alone does
|
|
49
|
+
* not separate genuine duplicates from legitimate distinct issues in this
|
|
50
|
+
* workspace's real usage; no single threshold in the observed range is
|
|
51
|
+
* better than a coin flip.
|
|
52
|
+
*
|
|
53
|
+
* Given that, blocking hard on a weak signal is worse than not blocking at
|
|
54
|
+
* all: over half the stops were wrong. 0.6 sits above the entire analyzed
|
|
55
|
+
* range (only 1/144 historical fires scored this high) and is reserved for
|
|
56
|
+
* near-verbatim title overlap — at Jaccard >= 0.6, more than 6 of every 10
|
|
57
|
+
* combined salient tokens are shared, which is a qualitatively different
|
|
58
|
+
* (and much rarer) signal than the "shares a few topical words" fires that
|
|
59
|
+
* dominate the false-positive population. This preserves a real backstop for
|
|
60
|
+
* the obvious copy-paste case while no longer forcing a stop-and-override
|
|
61
|
+
* ritual on the ambiguous 0.35-0.6 band, where the data shows we're wrong
|
|
62
|
+
* about as often as we're right.
|
|
63
|
+
*
|
|
64
|
+
* Caveat for future tuning: this corpus has no title text, so it cannot
|
|
65
|
+
* validate a *tokenization* fix (further stopwording per DEV-4830) — only a
|
|
66
|
+
* threshold-shape fix. A real precision improvement (distinguishing WHICH
|
|
67
|
+
* 0.35-0.6 fires are genuine) needs the title corpus, which is intentionally
|
|
68
|
+
* not collected. Revisit if `el-telemetry gates` after this ships still shows
|
|
69
|
+
* an unhealthy override rate on the >=0.6 hard-block tier specifically.
|
|
70
|
+
* Overridable via `config.validation.duplicateHardBlockThreshold`.
|
|
71
|
+
*/
|
|
72
|
+
export declare const DEFAULT_HARD_BLOCK_THRESHOLD = 0.6;
|
|
35
73
|
/** A scored duplicate candidate, ready to print in the block. */
|
|
36
74
|
export interface DuplicateCandidate {
|
|
37
75
|
identifier: string;
|
|
@@ -71,6 +109,12 @@ export declare function scoreDuplicateCandidates(title: string, candidates: Line
|
|
|
71
109
|
/**
|
|
72
110
|
* Render the human/agent-facing block listing duplicate candidates, matching
|
|
73
111
|
* the shape of the validation "Suggestions:" blocks (id · title · state ·
|
|
74
|
-
* assignee).
|
|
112
|
+
* assignee).
|
|
113
|
+
*
|
|
114
|
+
* `mode: "block"` (default) is the body of the thrown error when the gate
|
|
115
|
+
* hard-blocks (score >= the hard-block threshold — DEV-5590). `mode:
|
|
116
|
+
* "advisory"` is printed as a warning when the score is below the hard-block
|
|
117
|
+
* threshold: creation already proceeded, so the trailing hint differs (no
|
|
118
|
+
* "re-run" — there's nothing to re-run).
|
|
75
119
|
*/
|
|
76
|
-
export declare function formatDuplicateBlock(candidates: DuplicateCandidate[]): string;
|
|
120
|
+
export declare function formatDuplicateBlock(candidates: DuplicateCandidate[], mode?: "block" | "advisory"): string;
|
|
@@ -31,6 +31,44 @@
|
|
|
31
31
|
* Overridable via `config.validation.duplicateThreshold`.
|
|
32
32
|
*/
|
|
33
33
|
export const DEFAULT_DUPLICATE_THRESHOLD = 0.35;
|
|
34
|
+
/**
|
|
35
|
+
* Threshold above which a candidate is a HARD block (creation refuses without
|
|
36
|
+
* `--allow-duplicate`). Below this (but at/above {@link DEFAULT_DUPLICATE_THRESHOLD})
|
|
37
|
+
* a candidate is ADVISORY ONLY — printed, but creation proceeds — DEV-5590.
|
|
38
|
+
*
|
|
39
|
+
* Why a second threshold instead of just retuning the first: `el-telemetry
|
|
40
|
+
* gates` measured a 52.2% override rate on the single-threshold (0.35) gate
|
|
41
|
+
* over 144 real fires. Analyzing the real ledger (`top_score` + `outcome` per
|
|
42
|
+
* fire, no title text is ever recorded — el-linear collects nothing by
|
|
43
|
+
* default) falsifies the obvious fix of "just raise the threshold": mean/
|
|
44
|
+
* median `top_score` for `blocked` fires (0.414 / 0.40) and `overridden`
|
|
45
|
+
* fires (0.421 / 0.40) are statistically indistinguishable, and simulating
|
|
46
|
+
* every cutoff from 0.35 to 0.56 against the real corpus held the override
|
|
47
|
+
* rate flat at 52–63% — *increasing* at some higher cutoffs. Score alone does
|
|
48
|
+
* not separate genuine duplicates from legitimate distinct issues in this
|
|
49
|
+
* workspace's real usage; no single threshold in the observed range is
|
|
50
|
+
* better than a coin flip.
|
|
51
|
+
*
|
|
52
|
+
* Given that, blocking hard on a weak signal is worse than not blocking at
|
|
53
|
+
* all: over half the stops were wrong. 0.6 sits above the entire analyzed
|
|
54
|
+
* range (only 1/144 historical fires scored this high) and is reserved for
|
|
55
|
+
* near-verbatim title overlap — at Jaccard >= 0.6, more than 6 of every 10
|
|
56
|
+
* combined salient tokens are shared, which is a qualitatively different
|
|
57
|
+
* (and much rarer) signal than the "shares a few topical words" fires that
|
|
58
|
+
* dominate the false-positive population. This preserves a real backstop for
|
|
59
|
+
* the obvious copy-paste case while no longer forcing a stop-and-override
|
|
60
|
+
* ritual on the ambiguous 0.35-0.6 band, where the data shows we're wrong
|
|
61
|
+
* about as often as we're right.
|
|
62
|
+
*
|
|
63
|
+
* Caveat for future tuning: this corpus has no title text, so it cannot
|
|
64
|
+
* validate a *tokenization* fix (further stopwording per DEV-4830) — only a
|
|
65
|
+
* threshold-shape fix. A real precision improvement (distinguishing WHICH
|
|
66
|
+
* 0.35-0.6 fires are genuine) needs the title corpus, which is intentionally
|
|
67
|
+
* not collected. Revisit if `el-telemetry gates` after this ships still shows
|
|
68
|
+
* an unhealthy override rate on the >=0.6 hard-block tier specifically.
|
|
69
|
+
* Overridable via `config.validation.duplicateHardBlockThreshold`.
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_HARD_BLOCK_THRESHOLD = 0.6;
|
|
34
72
|
/**
|
|
35
73
|
* Function words and issue-boilerplate tokens that carry no topical signal.
|
|
36
74
|
* Dropping them keeps the Jaccard score driven by the distinctive nouns
|
|
@@ -174,14 +212,26 @@ export function scoreDuplicateCandidates(title, candidates, threshold = DEFAULT_
|
|
|
174
212
|
/**
|
|
175
213
|
* Render the human/agent-facing block listing duplicate candidates, matching
|
|
176
214
|
* the shape of the validation "Suggestions:" blocks (id · title · state ·
|
|
177
|
-
* assignee).
|
|
215
|
+
* assignee).
|
|
216
|
+
*
|
|
217
|
+
* `mode: "block"` (default) is the body of the thrown error when the gate
|
|
218
|
+
* hard-blocks (score >= the hard-block threshold — DEV-5590). `mode:
|
|
219
|
+
* "advisory"` is printed as a warning when the score is below the hard-block
|
|
220
|
+
* threshold: creation already proceeded, so the trailing hint differs (no
|
|
221
|
+
* "re-run" — there's nothing to re-run).
|
|
178
222
|
*/
|
|
179
|
-
export function formatDuplicateBlock(candidates) {
|
|
223
|
+
export function formatDuplicateBlock(candidates, mode = "block") {
|
|
180
224
|
const lines = candidates.map((c) => ` ${c.identifier} · ${c.title} · ${c.state} · ${c.assignee} (similarity ${c.score})`);
|
|
181
|
-
|
|
225
|
+
const header = `Possible duplicate issue${candidates.length > 1 ? "s" : ""} found ` +
|
|
182
226
|
"(by title-keyword overlap):\n" +
|
|
183
227
|
`${lines.join("\n")}\n\n` +
|
|
184
|
-
" If one of these is the same work, comment on it instead of creating a new issue.\n"
|
|
228
|
+
" If one of these is the same work, comment on it instead of creating a new issue.\n";
|
|
229
|
+
if (mode === "advisory") {
|
|
230
|
+
return (header +
|
|
231
|
+
" This is advisory only (DEV-5590) — creation is proceeding. Pass " +
|
|
232
|
+
"--allow-duplicate to silence this notice next time.");
|
|
233
|
+
}
|
|
234
|
+
return (header +
|
|
185
235
|
" If this is genuinely distinct, re-run with --allow-duplicate to proceed " +
|
|
186
236
|
"(and consider --related-to to link the related issue).");
|
|
187
237
|
}
|
|
@@ -29,9 +29,15 @@ export interface GateEvent {
|
|
|
29
29
|
* `blocked` — the gate stopped creation. `overridden` — a gate-specific
|
|
30
30
|
* override flag let a would-block proceed. `fail-open` — the gate could not
|
|
31
31
|
* evaluate (infra/service error) and let creation proceed rather than block
|
|
32
|
-
* on trouble; tracked so degradation is measurable (DEV-5378).
|
|
32
|
+
* on trouble; tracked so degradation is measurable (DEV-5378). `advisory` —
|
|
33
|
+
* the gate fired below its hard-block threshold: printed, but never forced
|
|
34
|
+
* a stop (DEV-5590). Like `fail-open`, the tools-repo reader
|
|
35
|
+
* (`el-telemetry gates`) only recognizes `blocked`/`overridden` for the
|
|
36
|
+
* override-rate denominator — an unrecognized outcome is skipped rather
|
|
37
|
+
* than counted, so `advisory` fires are visible in the raw ledger but
|
|
38
|
+
* intentionally excluded from the metric.
|
|
33
39
|
*/
|
|
34
|
-
outcome: "blocked" | "overridden" | "fail-open";
|
|
40
|
+
outcome: "blocked" | "overridden" | "fail-open" | "advisory";
|
|
35
41
|
/** Highest candidate similarity that triggered the gate (0–1). */
|
|
36
42
|
topScore?: number;
|
|
37
43
|
/** How many candidates crossed the threshold. */
|
|
@@ -14,6 +14,14 @@ export declare class LinearService {
|
|
|
14
14
|
resolveIssueId(issueId: string): Promise<string>;
|
|
15
15
|
getTeams(limit?: number): Promise<LinearTeam[]>;
|
|
16
16
|
resolveUserId(nameOrEmailOrId: string): Promise<string>;
|
|
17
|
+
/**
|
|
18
|
+
* Single-user lookup by UUID, email, or name (DEV-5612) — the natural
|
|
19
|
+
* complement to `getUsers`/`users list`, so identifying one actor (e.g. an
|
|
20
|
+
* unrecognized member surfaced by the alias wizard) doesn't require
|
|
21
|
+
* dumping the whole workspace. Resolution/ambiguity semantics match every
|
|
22
|
+
* other `--assignee`-style lookup in the CLI via {@link resolveUserId}.
|
|
23
|
+
*/
|
|
24
|
+
getUser(nameOrEmailOrId: string): Promise<LinearUser>;
|
|
17
25
|
getUsers(activeOnly?: boolean, limit?: number, nameFilter?: string): Promise<LinearUser[]>;
|
|
18
26
|
getProjects(limit?: number, options?: {
|
|
19
27
|
nameFilter?: string;
|
|
@@ -99,6 +99,24 @@ export class LinearService {
|
|
|
99
99
|
}
|
|
100
100
|
throw notFoundError("User", nameOrEmailOrId);
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Single-user lookup by UUID, email, or name (DEV-5612) — the natural
|
|
104
|
+
* complement to `getUsers`/`users list`, so identifying one actor (e.g. an
|
|
105
|
+
* unrecognized member surfaced by the alias wizard) doesn't require
|
|
106
|
+
* dumping the whole workspace. Resolution/ambiguity semantics match every
|
|
107
|
+
* other `--assignee`-style lookup in the CLI via {@link resolveUserId}.
|
|
108
|
+
*/
|
|
109
|
+
async getUser(nameOrEmailOrId) {
|
|
110
|
+
const id = await this.resolveUserId(nameOrEmailOrId);
|
|
111
|
+
const user = await this.client.user(id);
|
|
112
|
+
return {
|
|
113
|
+
id: user.id,
|
|
114
|
+
name: user.name,
|
|
115
|
+
displayName: user.displayName,
|
|
116
|
+
email: user.email,
|
|
117
|
+
active: user.active,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
102
120
|
async getUsers(activeOnly, limit = 100, nameFilter) {
|
|
103
121
|
const filter = {};
|
|
104
122
|
if (activeOnly) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.37.
|
|
3
|
+
"version": "1.37.2",
|
|
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",
|