@enrichlayer/el-linear 1.37.1 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/claude-skills/linear-operations/SKILL.md +31 -30
- package/dist/commands/comments.js +2 -1
- package/dist/commands/init/aliases.d.ts +29 -0
- package/dist/commands/init/aliases.js +86 -32
- package/dist/commands/issues/description.js +2 -1
- package/dist/commands/issues.js +55 -14
- package/dist/commands/labels.js +2 -1
- 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/projects.js +53 -1
- package/dist/commands/users.js +9 -0
- package/dist/config/config.d.ts +11 -0
- package/dist/queries/projects-types.d.ts +10 -0
- package/dist/queries/projects.d.ts +1 -0
- package/dist/queries/projects.js +20 -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 +28 -2
- package/dist/utils/gate-telemetry.js +26 -1
- package/dist/utils/inline-text-input.d.ts +7 -0
- package/dist/utils/inline-text-input.js +12 -0
- package/dist/utils/linear-service.d.ts +8 -0
- package/dist/utils/linear-service.js +18 -0
- package/dist/utils/relation-candidate-prompt.d.ts +12 -5
- package/dist/utils/relation-candidate-prompt.js +23 -9
- package/package.json +3 -2
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)
|
|
@@ -267,8 +277,9 @@ el-linear can record each gate's fire/override decision to a local JSONL file so
|
|
|
267
277
|
you can measure its **override-rate** and tell whether it's too aggressive. It is
|
|
268
278
|
**off by default** and writes nothing unless you opt in (e.g.
|
|
269
279
|
`export EL_TELEMETRY_DIR=<path>`); there is no server or database, and
|
|
270
|
-
`EL_TELEMETRY_DISABLED=1` forces it off.
|
|
271
|
-
|
|
280
|
+
`EL_TELEMETRY_DISABLED=1` forces it off. The active ledger rotates to one `.old`
|
|
281
|
+
backup before append when it exceeds 2 MiB. Full opt-in rules, the event schema,
|
|
282
|
+
and a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
|
|
272
283
|
|
|
273
284
|
### Networking (IPv4 preference)
|
|
274
285
|
|
|
@@ -466,7 +477,7 @@ el-linear <command> --help # detailed help for one command
|
|
|
466
477
|
| Search | `search <query>` (semantic, cross-resource) |
|
|
467
478
|
| Refs | `refs wrap` (rewrite issue identifiers in arbitrary text as links) |
|
|
468
479
|
| Escape hatch | `graphql [query]` (with `--introspect`) |
|
|
469
|
-
| Config | `config show`, `users list`, `teams list`, `templates list` |
|
|
480
|
+
| Config | `config show`, `users {list, read}`, `teams list`, `templates list` |
|
|
470
481
|
|
|
471
482
|
All `list` subcommands support `-l, --limit <n>`. All commands accept the
|
|
472
483
|
top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
|
|
@@ -237,6 +237,16 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
|
|
|
237
237
|
el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
+
### Cross-linking: be generous, link proactively, don't wait ([DEV-5853](https://linear.app/verticalint/issue/DEV-5853/))
|
|
241
|
+
|
|
242
|
+
**The default is to link, not to ask.** A cross-link is cheap and reversible; a *missing* link is invisible and costs reviewers and SOP tooling the context they need. When you identify an issue that is **related** / a **blocker** / a **follow-up** / the **origin** of the work in front of you, create the relation yourself — don't stage the candidates and wait for the human to name them, and don't hedge with "tell me if you want these linked."
|
|
243
|
+
|
|
244
|
+
- **At create time**, pass `--related-to "<ids>"` / `--parent <id>` / `--blocked-by <id>` directly on `issues create` — this path typically passes the classifier, so link generously the moment you file.
|
|
245
|
+
- **After the fact**, call `el-linear issues relate <source> --related-to "<ids>"` (or `--blocked-by` / `--blocks` / `--duplicate-of`) proactively, the same way.
|
|
246
|
+
- Over-linking is self-correcting (a wrong relation is one command to remove); under-linking is not. When in doubt, link.
|
|
247
|
+
|
|
248
|
+
The one exception is below: if the auto-mode permission classifier actually blocks a specific post-hoc `relate` call, that block — not caution — is your signal to surface the candidates for a one-word confirm.
|
|
249
|
+
|
|
240
250
|
### Existence check — before an "add capability X" issue ([DEV-5097](https://linear.app/verticalint/issue/DEV-5097/))
|
|
241
251
|
|
|
242
252
|
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**.
|
|
@@ -248,38 +258,29 @@ Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields`
|
|
|
248
258
|
|
|
249
259
|
When `el-linear issues search` (or the cross-resource `search`) returns rows
|
|
250
260
|
carrying issue identifiers, the JSON envelope embeds a `_warnings` line
|
|
251
|
-
starting with `relation_candidates:`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
- **Calling `issues relate` directly off your own search output** — even if
|
|
273
|
-
the IDs are real and the candidates look obvious, this is the exact path
|
|
274
|
-
the auto-mode guard refuses.
|
|
275
|
-
- **Splitting one relate call into N single-ID calls** to "look smaller" —
|
|
276
|
-
same provenance problem, same block, just multiplied.
|
|
277
|
-
- **Asking the user a yes/no question** ("Should I link these?") instead of
|
|
278
|
-
having them name the IDs — yes answers stay agent-inferred, the reply
|
|
279
|
-
must carry the IDs to convert them to user-specified.
|
|
261
|
+
starting with `relation_candidates:` enumerating the candidate IDs. Treat it
|
|
262
|
+
as a **convenience list of link candidates, not a stop sign** — under the
|
|
263
|
+
proactive default above, relate the ones that are genuinely related/blocking
|
|
264
|
+
without waiting to be told. Prefer create-time `--related-to` when the search
|
|
265
|
+
ran as part of filing a new issue (create-time relations typically pass the
|
|
266
|
+
classifier); otherwise call `issues relate` directly.
|
|
267
|
+
|
|
268
|
+
**Residual auto-mode constraint (the only reason to pause).** Claude Code's
|
|
269
|
+
auto-mode permission classifier *may* block a standalone `issues relate
|
|
270
|
+
--related-to "<ids>"` when it judges the IDs *agent-inferred* (surfaced by
|
|
271
|
+
your own search) rather than *user-specified*, because each peer is a write
|
|
272
|
+
target. A standing user instruction to cross-link generously is itself
|
|
273
|
+
authorization — so proceed by default. But if a specific `relate` call is
|
|
274
|
+
**actually blocked** by the classifier, that block is your cue: surface the
|
|
275
|
+
candidate IDs to the user for a one-word confirm, then re-run with the
|
|
276
|
+
user-named IDs (which pass). This is the **exception path**, not the default —
|
|
277
|
+
do not pre-emptively withhold links the classifier would have allowed. Two
|
|
278
|
+
ways to keep it frictionless: file relations at create time (`--related-to`),
|
|
279
|
+
or the operator adds a permission rule / runs non-auto-mode so post-hoc
|
|
280
|
+
`relate` never trips.
|
|
280
281
|
|
|
281
282
|
If `--include-closed` search returns no matches, no `relation_candidates:`
|
|
282
|
-
warning is emitted
|
|
283
|
+
warning is emitted and the flow proceeds normally.
|
|
283
284
|
|
|
284
285
|
### Viewing existing relations
|
|
285
286
|
|
|
@@ -4,6 +4,7 @@ import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LI
|
|
|
4
4
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
5
5
|
import { applyFooter } from "../utils/footer.js";
|
|
6
6
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
7
|
+
import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
|
|
7
8
|
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
8
9
|
import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
|
|
9
10
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
@@ -79,7 +80,7 @@ function readBody(options) {
|
|
|
79
80
|
return readFileSync(options.bodyFile, "utf-8");
|
|
80
81
|
}
|
|
81
82
|
if (options.body) {
|
|
82
|
-
return options.body;
|
|
83
|
+
return normalizeInlineTextInput(options.body);
|
|
83
84
|
}
|
|
84
85
|
throw new Error("Either --body or --body-file is required");
|
|
85
86
|
}
|
|
@@ -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
|
|
@@ -20,6 +20,7 @@ import fs from "node:fs";
|
|
|
20
20
|
import { loadConfig } from "../../config/config.js";
|
|
21
21
|
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
22
22
|
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
23
|
+
import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
|
|
23
24
|
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
24
25
|
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
26
|
import { validateReferences } from "../../utils/validate-references.js";
|
|
@@ -59,7 +60,7 @@ export function resolveDescription(options) {
|
|
|
59
60
|
return readDescriptionFile(options.descriptionFile);
|
|
60
61
|
}
|
|
61
62
|
if (hasInline) {
|
|
62
|
-
return options.description;
|
|
63
|
+
return normalizeInlineTextInput(options.description);
|
|
63
64
|
}
|
|
64
65
|
if (hasTemplate) {
|
|
65
66
|
const templates = loadConfig().descriptionTemplates ?? {};
|
package/dist/commands/issues.js
CHANGED
|
@@ -7,12 +7,13 @@ 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";
|
|
14
14
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
15
15
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
16
|
+
import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
|
|
16
17
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
17
18
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
18
19
|
import { logger } from "../utils/logger.js";
|
|
@@ -205,7 +206,8 @@ async function handleListIssues(options, command) {
|
|
|
205
206
|
// user passes --include-closed OR explicit --status. Explicit status
|
|
206
207
|
// wins because the user already named the workflow states they want.
|
|
207
208
|
const excludeTerminalStates = !options.includeClosed && explicitStatus === undefined;
|
|
208
|
-
const hasOtherFilters = options.
|
|
209
|
+
const hasOtherFilters = options.search ||
|
|
210
|
+
options.team ||
|
|
209
211
|
options.labels ||
|
|
210
212
|
options.status ||
|
|
211
213
|
options.assignee ||
|
|
@@ -223,6 +225,7 @@ async function handleListIssues(options, command) {
|
|
|
223
225
|
// (DEV-4478 cycle-1.)
|
|
224
226
|
if (hasOtherFilters || excludeTerminalStates || options.includeClosed) {
|
|
225
227
|
const searchArgs = {
|
|
228
|
+
query: options.search,
|
|
226
229
|
teamId: options.team ? resolveTeam(options.team) : undefined,
|
|
227
230
|
assigneeId: options.assignee
|
|
228
231
|
? await resolveAssignee(options.assignee, rootOpts)
|
|
@@ -244,8 +247,15 @@ async function handleListIssues(options, command) {
|
|
|
244
247
|
if (excludeTerminalStates) {
|
|
245
248
|
outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
|
|
246
249
|
}
|
|
250
|
+
if (options.search) {
|
|
251
|
+
const relationPrompt = buildRelationCandidatePrompt(result);
|
|
252
|
+
if (relationPrompt) {
|
|
253
|
+
outputWarning(relationPrompt);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
247
256
|
warnIfTruncated(result.length, limit);
|
|
248
257
|
outputIssues(result, command, {
|
|
258
|
+
query: options.search,
|
|
249
259
|
team: options.team,
|
|
250
260
|
});
|
|
251
261
|
}
|
|
@@ -477,18 +487,28 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
477
487
|
/**
|
|
478
488
|
* DEV-4823: create-time duplicate-detection gate. Searches the title's
|
|
479
489
|
* salient keywords (including closed issues), scores candidates by Jaccard
|
|
480
|
-
* title-overlap, and
|
|
481
|
-
*
|
|
490
|
+
* title-overlap, and — for a candidate at/above the hard-block threshold —
|
|
491
|
+
* throws, listing the matches.
|
|
492
|
+
*
|
|
493
|
+
* DEV-5590: two-tier since the single-threshold gate measured a 52.2%
|
|
494
|
+
* override rate (score alone didn't separate genuine duplicates from
|
|
495
|
+
* legitimate distinct issues — see {@link DEFAULT_HARD_BLOCK_THRESHOLD} for
|
|
496
|
+
* the data). A candidate at/above `duplicateThreshold` (detection floor) but
|
|
497
|
+
* below `duplicateHardBlockThreshold` (hard-block floor) is ADVISORY: printed
|
|
498
|
+
* as a warning, creation proceeds, and the fire is recorded as `advisory` —
|
|
499
|
+
* excluded from the override-rate denominator (only `blocked`/`overridden`
|
|
500
|
+
* count). At/above the hard-block floor, behavior is unchanged from DEV-4823.
|
|
482
501
|
*
|
|
483
502
|
* Bypassed by `--skip-validation` and
|
|
484
503
|
* `config.validation.duplicateDetection: false` / `validation.enabled: false`.
|
|
485
504
|
* `--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
|
|
505
|
+
* would-hard-block, records an `overridden` telemetry event (DEV-4834) before
|
|
506
|
+
* proceeding, so `el-telemetry gates` can measure the override-rate. (A
|
|
507
|
+
* hard-blocked fire records `blocked`.) `--skip-validation` is a blanket
|
|
508
|
+
* bypass and emits nothing — it's not a gate-specific override, so counting
|
|
509
|
+
* it would dilute the signal. The search itself is best-effort: a network/API
|
|
510
|
+
* failure warns and proceeds rather than blocking legitimate issue creation
|
|
511
|
+
* on infra trouble.
|
|
492
512
|
*/
|
|
493
513
|
async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
494
514
|
if (options.skipValidation) {
|
|
@@ -509,6 +529,9 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
509
529
|
const threshold = typeof validation?.duplicateThreshold === "number"
|
|
510
530
|
? validation.duplicateThreshold
|
|
511
531
|
: DEFAULT_DUPLICATE_THRESHOLD;
|
|
532
|
+
const hardBlockThreshold = typeof validation?.duplicateHardBlockThreshold === "number"
|
|
533
|
+
? validation.duplicateHardBlockThreshold
|
|
534
|
+
: DEFAULT_HARD_BLOCK_THRESHOLD;
|
|
512
535
|
let candidates;
|
|
513
536
|
try {
|
|
514
537
|
candidates = await issuesService.searchIssues({
|
|
@@ -541,14 +564,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
541
564
|
if (matches.length === 0) {
|
|
542
565
|
return;
|
|
543
566
|
}
|
|
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
567
|
const gateEvent = {
|
|
548
568
|
gate: "issues-create-dup",
|
|
549
569
|
topScore: matches[0].score,
|
|
550
570
|
candidateCount: matches.length,
|
|
551
571
|
};
|
|
572
|
+
// DEV-5590: below the hard-block floor, warn and proceed — never forces a
|
|
573
|
+
// stop, so there's nothing to "override". Recorded as `advisory` (visible
|
|
574
|
+
// in the raw ledger, excluded from the override-rate metric).
|
|
575
|
+
if (matches[0].score < hardBlockThreshold) {
|
|
576
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
577
|
+
...gateEvent,
|
|
578
|
+
outcome: "advisory",
|
|
579
|
+
});
|
|
580
|
+
outputWarning(formatDuplicateBlock(matches, "advisory"));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// The gate would hard-block. Record the decision so `el-telemetry gates`
|
|
584
|
+
// can compute override-rate (DEV-4834): `overridden` when the user passed
|
|
585
|
+
// --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
|
|
552
586
|
if (options.allowDuplicate) {
|
|
553
587
|
await emitGateEvent("el-linear", "issues create", {
|
|
554
588
|
...gateEvent,
|
|
@@ -923,6 +957,12 @@ async function handleUpdateIssue(issueId, options, command) {
|
|
|
923
957
|
if (options.descriptionFile) {
|
|
924
958
|
options.description = readDescriptionFile(options.descriptionFile);
|
|
925
959
|
}
|
|
960
|
+
else if (typeof options.description === "string") {
|
|
961
|
+
options.description = normalizeInlineTextInput(options.description);
|
|
962
|
+
}
|
|
963
|
+
if (typeof options.appendDescription === "string") {
|
|
964
|
+
options.appendDescription = normalizeInlineTextInput(options.appendDescription);
|
|
965
|
+
}
|
|
926
966
|
validateUpdateOptions(options);
|
|
927
967
|
const rootOpts = getRootOpts(command);
|
|
928
968
|
const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
|
|
@@ -1204,6 +1244,7 @@ export function setupIssuesCommands(program) {
|
|
|
1204
1244
|
.command("list")
|
|
1205
1245
|
.description("List issues.")
|
|
1206
1246
|
.option("-l, --limit <number>", "limit results", "25")
|
|
1247
|
+
.option("--search <query>", "full-text search term; composes with list filters")
|
|
1207
1248
|
.option("--team <team>", "filter by team key (EL: resolves names)")
|
|
1208
1249
|
.option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
|
|
1209
1250
|
.option("--delegate <delegate>", "filter by delegated agent (name, alias, or ID)")
|
|
@@ -1268,7 +1309,7 @@ export function setupIssuesCommands(program) {
|
|
|
1268
1309
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1269
1310
|
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1270
1311
|
.option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate)")
|
|
1271
|
-
.option("--allow-duplicate", "
|
|
1312
|
+
.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
1313
|
.option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
|
|
1273
1314
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1274
1315
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
package/dist/commands/labels.js
CHANGED
|
@@ -9,8 +9,9 @@ import { getRootOpts } from "../utils/root-opts.js";
|
|
|
9
9
|
import { parsePositiveInt, validateHexColor } from "../utils/validators.js";
|
|
10
10
|
async function handleCreateLabel(name, options, command) {
|
|
11
11
|
const rootOpts = getRootOpts(command);
|
|
12
|
-
const teamId = resolveTeam(options.team);
|
|
13
12
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
13
|
+
const linearService = await createLinearService(rootOpts);
|
|
14
|
+
const teamId = await linearService.resolveTeamId(resolveTeam(options.team));
|
|
14
15
|
const input = { name, teamId };
|
|
15
16
|
if (options.color) {
|
|
16
17
|
input.color = validateHexColor(options.color);
|
|
@@ -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;
|