@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
|
@@ -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();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from "../config/config.js";
|
|
2
2
|
import { resolveTeam } from "../config/resolver.js";
|
|
3
|
-
import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
3
|
+
import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_FIELDS_MUTATION, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
4
4
|
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
5
5
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
6
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
@@ -229,6 +229,26 @@ function formatTeamsOutput(projectUpdate) {
|
|
|
229
229
|
})),
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
|
+
function hasOption(options, key) {
|
|
233
|
+
return options[key] !== undefined;
|
|
234
|
+
}
|
|
235
|
+
function flattenProjectUpdate(projectUpdate) {
|
|
236
|
+
if (!projectUpdate.project) {
|
|
237
|
+
throw new Error("Failed to update project");
|
|
238
|
+
}
|
|
239
|
+
const updatedProject = projectUpdate.project;
|
|
240
|
+
return {
|
|
241
|
+
id: updatedProject.id,
|
|
242
|
+
name: updatedProject.name,
|
|
243
|
+
description: updatedProject.description ?? undefined,
|
|
244
|
+
content: updatedProject.content ?? undefined,
|
|
245
|
+
teams: updatedProject.teams.nodes.map((t) => ({
|
|
246
|
+
id: t.id,
|
|
247
|
+
key: t.key,
|
|
248
|
+
name: t.name,
|
|
249
|
+
})),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
232
252
|
async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
|
|
233
253
|
const rootOpts = getRootOpts(command);
|
|
234
254
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
@@ -437,6 +457,31 @@ async function handleReadProject(projectNameOrId, _options, command) {
|
|
|
437
457
|
teams: teams.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
|
|
438
458
|
});
|
|
439
459
|
}
|
|
460
|
+
async function handleUpdateProject(projectNameOrId, options, command) {
|
|
461
|
+
const input = {};
|
|
462
|
+
if (hasOption(options, "name")) {
|
|
463
|
+
input.name = options.name;
|
|
464
|
+
}
|
|
465
|
+
if (hasOption(options, "description")) {
|
|
466
|
+
input.description = options.description;
|
|
467
|
+
}
|
|
468
|
+
if (hasOption(options, "content")) {
|
|
469
|
+
input.content = options.content;
|
|
470
|
+
}
|
|
471
|
+
if (Object.keys(input).length === 0) {
|
|
472
|
+
throw new Error("Nothing to update. Pass at least one of --name, --description, or --content.");
|
|
473
|
+
}
|
|
474
|
+
const rootOpts = getRootOpts(command);
|
|
475
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
476
|
+
const linearService = await createLinearService(rootOpts);
|
|
477
|
+
const projectId = await linearService.resolveProjectId(projectNameOrId);
|
|
478
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_FIELDS_MUTATION, { id: projectId, input });
|
|
479
|
+
const projectUpdate = updateResult.projectUpdate;
|
|
480
|
+
if (!projectUpdate.success) {
|
|
481
|
+
throw new Error(`Failed to update project "${projectNameOrId}"`);
|
|
482
|
+
}
|
|
483
|
+
outputSuccess(flattenProjectUpdate(projectUpdate));
|
|
484
|
+
}
|
|
440
485
|
export function setupProjectsCommands(program) {
|
|
441
486
|
const projects = program
|
|
442
487
|
.command("projects")
|
|
@@ -463,6 +508,13 @@ export function setupProjectsCommands(program) {
|
|
|
463
508
|
.command("read <project>")
|
|
464
509
|
.description("Read one project's full details (resolves name/slug/URL/ID). `--format summary` shows state, lead, teams, target, progress, url; JSON includes description/content.")
|
|
465
510
|
.action(handleAsyncCommand(handleReadProject));
|
|
511
|
+
projects
|
|
512
|
+
.command("update <project>")
|
|
513
|
+
.description("Update project name, short description, or markdown content")
|
|
514
|
+
.option("--name <name>", "project name")
|
|
515
|
+
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
516
|
+
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
517
|
+
.action(handleAsyncCommand(handleUpdateProject));
|
|
466
518
|
projects
|
|
467
519
|
.command("list")
|
|
468
520
|
.description("List projects")
|
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
|
|
@@ -77,6 +77,16 @@ export interface UpdateProjectResponse {
|
|
|
77
77
|
project: ProjectBaseNode | null;
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
interface ProjectUpdateFieldsNode extends ProjectBaseNode {
|
|
81
|
+
description: string | null;
|
|
82
|
+
content: string | null;
|
|
83
|
+
}
|
|
84
|
+
export interface UpdateProjectFieldsResponse {
|
|
85
|
+
projectUpdate: {
|
|
86
|
+
success: boolean;
|
|
87
|
+
project: ProjectUpdateFieldsNode | null;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
80
90
|
interface ProjectArchiveEntity {
|
|
81
91
|
id: string;
|
|
82
92
|
}
|
|
@@ -14,5 +14,6 @@ export declare const GET_PROJECT_TEAM_ISSUES_QUERY = "\n query GetProjectTeamIs
|
|
|
14
14
|
export declare const SEARCH_PROJECTS_BY_NAME_QUERY = "\n query SearchProjectsByName($name: String!) {\n projects(filter: { name: { containsIgnoreCase: $name } }, first: 10) {\n nodes {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
|
15
15
|
export declare const CREATE_PROJECT_MUTATION = "\n mutation CreateProject($input: ProjectCreateInput!) {\n projectCreate(input: $input) {\n success\n project {\n id\n name\n state\n teams {\n nodes { id key name }\n }\n }\n }\n }\n";
|
|
16
16
|
export declare const UPDATE_PROJECT_MUTATION = "\n mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
17
|
+
export declare const UPDATE_PROJECT_FIELDS_MUTATION = "\n mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {\n projectUpdate(id: $id, input: $input) {\n success\n project {\n id\n name\n description\n content\n teams {\n nodes {\n id\n key\n name\n }\n }\n }\n }\n }\n";
|
|
17
18
|
export declare const ARCHIVE_PROJECT_MUTATION = "\n mutation ArchiveProject($id: String!) {\n projectArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
|
18
19
|
export declare const DELETE_PROJECT_MUTATION = "\n mutation DeleteProject($id: String!) {\n projectDelete(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
package/dist/queries/projects.js
CHANGED
|
@@ -129,6 +129,26 @@ export const UPDATE_PROJECT_MUTATION = `
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
`;
|
|
132
|
+
export const UPDATE_PROJECT_FIELDS_MUTATION = `
|
|
133
|
+
mutation UpdateProjectFields($id: String!, $input: ProjectUpdateInput!) {
|
|
134
|
+
projectUpdate(id: $id, input: $input) {
|
|
135
|
+
success
|
|
136
|
+
project {
|
|
137
|
+
id
|
|
138
|
+
name
|
|
139
|
+
description
|
|
140
|
+
content
|
|
141
|
+
teams {
|
|
142
|
+
nodes {
|
|
143
|
+
id
|
|
144
|
+
key
|
|
145
|
+
name
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
132
152
|
export const ARCHIVE_PROJECT_MUTATION = `
|
|
133
153
|
mutation ArchiveProject($id: String!) {
|
|
134
154
|
projectArchive(id: $id) {
|
|
@@ -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
|
}
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic-gate fire/override telemetry (DEV-4834, sub of DEV-4831).
|
|
3
|
+
*
|
|
4
|
+
* The `issues create` duplicate-detection gate (DEV-4823) records each decision
|
|
5
|
+
* it makes as a `gate` event so a reader (the Enrich Layer `el-telemetry gates`
|
|
6
|
+
* command, or any JSONL consumer) can compute the gate's override-rate
|
|
7
|
+
* (overridden / total) and tell whether the threshold is noisy.
|
|
8
|
+
*
|
|
9
|
+
* **Opt-in.** el-linear is open-source; most installs have no telemetry, and we
|
|
10
|
+
* must never write files a user didn't ask for. Emission is therefore OFF by
|
|
11
|
+
* default and turns on only when telemetry is actually configured — see
|
|
12
|
+
* {@link decideGateLedger}. The ledger is a plain local JSONL file
|
|
13
|
+
* (`gate-events.jsonl`); there is no server or database. el-linear can't import
|
|
14
|
+
* `el-telemetry` (separate package), so it writes by **path-contract** — the
|
|
15
|
+
* same approach `el-hook` uses. The path mirrors `el-telemetry`'s
|
|
16
|
+
* `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
|
|
17
|
+
* `docs/telemetry.md`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const GATE_LEDGER_MAX_BYTES: number;
|
|
20
|
+
export declare const GATE_LEDGER_BACKUP_SUFFIX = ".old";
|
|
1
21
|
/** Resolve where the ledger lives — for a *reader* locating the file (mirrors
|
|
2
22
|
* el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
|
|
3
23
|
* the opt-in policy, so never write through it — `emitGateEvent` goes through
|
|
@@ -29,9 +49,15 @@ export interface GateEvent {
|
|
|
29
49
|
* `blocked` — the gate stopped creation. `overridden` — a gate-specific
|
|
30
50
|
* override flag let a would-block proceed. `fail-open` — the gate could not
|
|
31
51
|
* evaluate (infra/service error) and let creation proceed rather than block
|
|
32
|
-
* on trouble; tracked so degradation is measurable (DEV-5378).
|
|
52
|
+
* on trouble; tracked so degradation is measurable (DEV-5378). `advisory` —
|
|
53
|
+
* the gate fired below its hard-block threshold: printed, but never forced
|
|
54
|
+
* a stop (DEV-5590). Like `fail-open`, the tools-repo reader
|
|
55
|
+
* (`el-telemetry gates`) only recognizes `blocked`/`overridden` for the
|
|
56
|
+
* override-rate denominator — an unrecognized outcome is skipped rather
|
|
57
|
+
* than counted, so `advisory` fires are visible in the raw ledger but
|
|
58
|
+
* intentionally excluded from the metric.
|
|
33
59
|
*/
|
|
34
|
-
outcome: "blocked" | "overridden" | "fail-open";
|
|
60
|
+
outcome: "blocked" | "overridden" | "fail-open" | "advisory";
|
|
35
61
|
/** Highest candidate similarity that triggered the gate (0–1). */
|
|
36
62
|
topScore?: number;
|
|
37
63
|
/** How many candidates crossed the threshold. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { appendFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
/**
|
|
@@ -20,6 +20,8 @@ import { dirname, join } from "node:path";
|
|
|
20
20
|
* `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
|
|
21
21
|
* `docs/telemetry.md`.
|
|
22
22
|
*/
|
|
23
|
+
export const GATE_LEDGER_MAX_BYTES = 2 * 1024 * 1024;
|
|
24
|
+
export const GATE_LEDGER_BACKUP_SUFFIX = ".old";
|
|
23
25
|
/** The default ledger directory when `EL_TELEMETRY_DIR` is not set. */
|
|
24
26
|
function defaultTelemetryDir() {
|
|
25
27
|
return join(homedir(), ".cache", "el-telemetry");
|
|
@@ -67,6 +69,28 @@ function gateLedgerIfEnabled() {
|
|
|
67
69
|
defaultDirExists: existsSync(defaultDir),
|
|
68
70
|
});
|
|
69
71
|
}
|
|
72
|
+
async function rotateGateLedgerIfOverLimit(path) {
|
|
73
|
+
let size = 0;
|
|
74
|
+
try {
|
|
75
|
+
const s = await stat(path);
|
|
76
|
+
if (!s.isFile())
|
|
77
|
+
return;
|
|
78
|
+
size = s.size;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (size <= GATE_LEDGER_MAX_BYTES)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
const backupPath = `${path}${GATE_LEDGER_BACKUP_SUFFIX}`;
|
|
87
|
+
await rm(backupPath, { force: true });
|
|
88
|
+
await rename(path, backupPath);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// best-effort — telemetry never blocks issue creation
|
|
92
|
+
}
|
|
93
|
+
}
|
|
70
94
|
/**
|
|
71
95
|
* Best-effort append of a gate event to the local ledger — but only when
|
|
72
96
|
* telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
|
|
@@ -80,6 +104,7 @@ export async function emitGateEvent(name, subcommand, event) {
|
|
|
80
104
|
}
|
|
81
105
|
try {
|
|
82
106
|
await mkdir(dirname(path), { recursive: true });
|
|
107
|
+
await rotateGateLedgerIfOverLimit(path);
|
|
83
108
|
const record = {
|
|
84
109
|
ts: new Date().toISOString(),
|
|
85
110
|
kind: "gate",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize shell-literal newline escapes in inline CLI text fields.
|
|
3
|
+
*
|
|
4
|
+
* File inputs are intentionally excluded by call site: a file body is already
|
|
5
|
+
* explicit authored text and may intentionally contain backslash sequences.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeInlineTextInput(value: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize shell-literal newline escapes in inline CLI text fields.
|
|
3
|
+
*
|
|
4
|
+
* File inputs are intentionally excluded by call site: a file body is already
|
|
5
|
+
* explicit authored text and may intentionally contain backslash sequences.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeInlineTextInput(value) {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/\\r\\n/g, "\n")
|
|
10
|
+
.replace(/\\n/g, "\n")
|
|
11
|
+
.replace(/\\r/g, "\n");
|
|
12
|
+
}
|
|
@@ -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) {
|
|
@@ -41,16 +41,23 @@
|
|
|
41
41
|
*/
|
|
42
42
|
export declare function extractCandidateIdentifiers(rows: unknown[]): string[];
|
|
43
43
|
/**
|
|
44
|
-
* Build the relation-candidate
|
|
45
|
-
*
|
|
44
|
+
* Build the relation-candidate warning string, or `null` when the result set
|
|
45
|
+
* has no identifier-bearing rows (nothing to surface).
|
|
46
46
|
*
|
|
47
47
|
* Shape (single line, structured-prose so a skill can match on the prefix):
|
|
48
48
|
*
|
|
49
49
|
* relation_candidates: Found N candidate related issues (DEV-1, DEV-2, …).
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* Link the relevant ones now — at create time with --related-to, or
|
|
51
|
+
* `issues relate <id> --related-to "<ids>"`. If auto-mode blocks an
|
|
52
|
+
* agent-inferred relate, reply with the IDs you want linked
|
|
53
|
+
* (e.g. "link DEV-1 and DEV-2"), or "no links" to skip.
|
|
52
54
|
*
|
|
53
|
-
*
|
|
55
|
+
* DEV-5853: the primary framing is **proactive** — this is a convenience list
|
|
56
|
+
* of link candidates, not a stop sign. Relate the relevant ones directly
|
|
57
|
+
* rather than waiting to be told; the reply flow is the *fallback* for when
|
|
58
|
+
* the auto-mode classifier actually blocks an agent-inferred `issues relate`
|
|
59
|
+
* (create-time `--related-to` typically passes, so prefer it). The
|
|
60
|
+
* `relation_candidates:` prefix matches the existing `results_truncated:`
|
|
54
61
|
* convention in `outputWarning` callers — a stable token a skill / agent
|
|
55
62
|
* harness can grep for without parsing free-form prose.
|
|
56
63
|
*/
|