@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 +23 -1
- package/claude-skills/linear-operations/SKILL.md +3 -4
- package/dist/commands/init/aliases.d.ts +29 -0
- package/dist/commands/init/aliases.js +86 -32
- package/dist/commands/issues.js +59 -18
- package/dist/commands/profile/members.d.ts +50 -0
- package/dist/commands/profile/members.js +132 -0
- package/dist/commands/profile.js +24 -1
- package/dist/commands/projects.js +8 -5
- package/dist/commands/users.js +9 -0
- package/dist/config/config.d.ts +11 -0
- package/dist/config/paths.d.ts +9 -0
- package/dist/config/paths.js +9 -1
- package/dist/main.js +3 -1
- package/dist/queries/issues-types.d.ts +11 -0
- package/dist/queries/issues.d.ts +19 -0
- package/dist/queries/issues.js +39 -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/graphql-issues-service.js +29 -7
- package/dist/utils/linear-service.d.ts +8 -0
- package/dist/utils/linear-service.js +18 -0
- package/dist/utils/output.js +41 -1
- package/dist/utils/root-opts.d.ts +13 -0
- package/dist/utils/root-opts.js +23 -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)
|
|
@@ -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` (
|
|
245
|
-
- `el-
|
|
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
|
|
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
|
-
|
|
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";
|
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
464
|
-
*
|
|
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-
|
|
470
|
-
* proceeding, so `el-telemetry gates` can measure the override-rate. (A
|
|
471
|
-
* fire records `blocked`.) `--skip-validation` is a blanket
|
|
472
|
-
* nothing — it's not a gate-specific override, so counting
|
|
473
|
-
* signal. The search itself is best-effort: a network/API
|
|
474
|
-
* 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.
|
|
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", "
|
|
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
|
+
}
|