@enrichlayer/el-linear 1.27.0 → 1.28.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
CHANGED
|
@@ -247,6 +247,16 @@ A full reference with every key documented lives in [config.example.json](./conf
|
|
|
247
247
|
UUIDs come from the Linear UI (URL bars, settings pages) or via el-linear
|
|
248
248
|
itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
249
249
|
|
|
250
|
+
### Gate telemetry (optional)
|
|
251
|
+
|
|
252
|
+
`issues create` has a duplicate-detection gate. el-linear can record each
|
|
253
|
+
fire/override decision to a local JSONL file so you can measure the gate's
|
|
254
|
+
**override-rate** and tell whether it's too aggressive. It is **off by default**
|
|
255
|
+
and writes nothing unless you opt in (e.g. `export EL_TELEMETRY_DIR=<path>`);
|
|
256
|
+
there is no server or database, and `EL_TELEMETRY_DISABLED=1` forces it off.
|
|
257
|
+
Full opt-in rules, the event schema, and a `jq` reader are in
|
|
258
|
+
[docs/telemetry.md](./docs/telemetry.md).
|
|
259
|
+
|
|
250
260
|
### Networking (IPv4 preference)
|
|
251
261
|
|
|
252
262
|
el-linear talks only to `api.linear.app` (Cloudflare, dual-stack). On a network
|
package/dist/commands/issues.js
CHANGED
|
@@ -9,6 +9,7 @@ import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../qu
|
|
|
9
9
|
import { DEFAULT_DUPLICATE_THRESHOLD, formatDuplicateBlock, scoreDuplicateCandidates, tokenizeTitle, } from "../utils/duplicate-detection.js";
|
|
10
10
|
import { createFileService } from "../utils/file-service.js";
|
|
11
11
|
import { applyFooter } from "../utils/footer.js";
|
|
12
|
+
import { emitGateEvent } from "../utils/gate-telemetry.js";
|
|
12
13
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
13
14
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
14
15
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
@@ -458,13 +459,18 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
458
459
|
* title-overlap, and throws — listing the matches — when one is at/above the
|
|
459
460
|
* configured similarity threshold.
|
|
460
461
|
*
|
|
461
|
-
* Bypassed by `--
|
|
462
|
+
* Bypassed by `--skip-validation` and
|
|
462
463
|
* `config.validation.duplicateDetection: false` / `validation.enabled: false`.
|
|
463
|
-
*
|
|
464
|
-
*
|
|
464
|
+
* `--allow-duplicate` does NOT skip the search — the gate still runs and, on a
|
|
465
|
+
* would-fire, records an `overridden` telemetry event (DEV-4834) before
|
|
466
|
+
* proceeding, so `el-telemetry gates` can measure the override-rate. (A blocked
|
|
467
|
+
* fire records `blocked`.) `--skip-validation` is a blanket bypass and emits
|
|
468
|
+
* nothing — it's not a gate-specific override, so counting it would dilute the
|
|
469
|
+
* signal. The search itself is best-effort: a network/API failure warns and
|
|
470
|
+
* proceeds rather than blocking legitimate issue creation on infra trouble.
|
|
465
471
|
*/
|
|
466
472
|
async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
467
|
-
if (options.
|
|
473
|
+
if (options.skipValidation) {
|
|
468
474
|
return;
|
|
469
475
|
}
|
|
470
476
|
const validation = loadConfig().validation;
|
|
@@ -498,7 +504,13 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
498
504
|
});
|
|
499
505
|
}
|
|
500
506
|
catch (err) {
|
|
501
|
-
|
|
507
|
+
// The "pass --allow-duplicate" hint only makes sense when the caller
|
|
508
|
+
// hasn't already passed it (under --allow-duplicate we ran the search
|
|
509
|
+
// only to detect a would-fire for telemetry, and we proceed regardless).
|
|
510
|
+
const silenceHint = options.allowDuplicate
|
|
511
|
+
? ""
|
|
512
|
+
: " Pass --allow-duplicate to silence.";
|
|
513
|
+
outputWarning(`Duplicate-detection search failed (${err instanceof Error ? err.message : String(err)}); proceeding without the dup check.${silenceHint}`);
|
|
502
514
|
return;
|
|
503
515
|
}
|
|
504
516
|
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
@@ -508,6 +520,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
508
520
|
if (matches.length === 0) {
|
|
509
521
|
return;
|
|
510
522
|
}
|
|
523
|
+
// The gate would fire. Record the decision so `el-telemetry gates` can
|
|
524
|
+
// compute override-rate (DEV-4834): `overridden` when the user passed
|
|
525
|
+
// --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
|
|
526
|
+
const gateEvent = {
|
|
527
|
+
gate: "issues-create-dup",
|
|
528
|
+
topScore: matches[0].score,
|
|
529
|
+
candidateCount: matches.length,
|
|
530
|
+
};
|
|
531
|
+
if (options.allowDuplicate) {
|
|
532
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
533
|
+
...gateEvent,
|
|
534
|
+
outcome: "overridden",
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
539
|
+
...gateEvent,
|
|
540
|
+
outcome: "blocked",
|
|
541
|
+
});
|
|
511
542
|
throw new Error(`Issue creation blocked: ${formatDuplicateBlock(matches)}`);
|
|
512
543
|
}
|
|
513
544
|
async function handleCreateIssue(title, options, command) {
|
|
@@ -45,9 +45,10 @@ export interface DuplicateCandidate {
|
|
|
45
45
|
* Tokenize a title into a set of salient lowercase keywords.
|
|
46
46
|
*
|
|
47
47
|
* Splits on any run of non-alphanumeric characters (so `scripts/*.mjs` →
|
|
48
|
-
* `scripts`, `mjs`), lowercases, then drops stopwords,
|
|
49
|
-
* (`52 files` → `files`),
|
|
50
|
-
* downstream set algebra is
|
|
48
|
+
* `scripts`, `mjs`), lowercases, then drops English stopwords, tool-name /
|
|
49
|
+
* CLI-scaffolding boilerplate (DEV-4830), pure numbers (`52 files` → `files`),
|
|
50
|
+
* and single-character tokens. Returns a Set so downstream set algebra is
|
|
51
|
+
* direct.
|
|
51
52
|
*
|
|
52
53
|
* Scope: ASCII `[a-z0-9]` only — a title written entirely in a non-Latin
|
|
53
54
|
* script (Cyrillic, CJK, …) tokenizes to the empty set, so the gate fails
|
|
@@ -68,13 +68,49 @@ const STOPWORDS = new Set([
|
|
|
68
68
|
"with",
|
|
69
69
|
"without",
|
|
70
70
|
]);
|
|
71
|
+
/**
|
|
72
|
+
* Tool-name and CLI-scaffolding boilerplate — DEV-4830. These tokens appear in
|
|
73
|
+
* a large fraction of this workspace's titles ("Add --X flag to el-linear
|
|
74
|
+
* issues create", "… el-git pipeline watch", …) regardless of topic, so they
|
|
75
|
+
* inflate Jaccard between genuinely-distinct issues that merely touch the same
|
|
76
|
+
* command surface. A retrospective precision sweep over 288 DEV titles showed
|
|
77
|
+
* the "Add --X flag to el-linear issues create" family scoring 0.45–0.60 (all
|
|
78
|
+
* false positives) purely on shared boilerplate; dropping these tokens pushes
|
|
79
|
+
* that family to 0.17–0.25 while every genuine duplicate stayed ≥ 0.35 (the
|
|
80
|
+
* motivating DEV-4816↔DEV-4818 pair holds at 0.40). Total fires 29 → 19.
|
|
81
|
+
*
|
|
82
|
+
* Note the el-tool prefixes split on `-` first, so `el-linear` arrives here as
|
|
83
|
+
* `el` + `linear`; both fragments are listed. Topical words that happen to be
|
|
84
|
+
* tool *suffixes* (`research`, `telemetry`, `audit`, …) are deliberately NOT
|
|
85
|
+
* listed — they carry real signal in non-tool issues.
|
|
86
|
+
*/
|
|
87
|
+
const BOILERPLATE_STOPWORDS = new Set([
|
|
88
|
+
"el",
|
|
89
|
+
"cli",
|
|
90
|
+
"command",
|
|
91
|
+
"commands",
|
|
92
|
+
"subcommand",
|
|
93
|
+
"flag",
|
|
94
|
+
"flags",
|
|
95
|
+
"option",
|
|
96
|
+
"options",
|
|
97
|
+
"arg",
|
|
98
|
+
"args",
|
|
99
|
+
"linear",
|
|
100
|
+
"git",
|
|
101
|
+
"issue",
|
|
102
|
+
"issues",
|
|
103
|
+
"create",
|
|
104
|
+
"update",
|
|
105
|
+
]);
|
|
71
106
|
/**
|
|
72
107
|
* Tokenize a title into a set of salient lowercase keywords.
|
|
73
108
|
*
|
|
74
109
|
* Splits on any run of non-alphanumeric characters (so `scripts/*.mjs` →
|
|
75
|
-
* `scripts`, `mjs`), lowercases, then drops stopwords,
|
|
76
|
-
* (`52 files` → `files`),
|
|
77
|
-
* downstream set algebra is
|
|
110
|
+
* `scripts`, `mjs`), lowercases, then drops English stopwords, tool-name /
|
|
111
|
+
* CLI-scaffolding boilerplate (DEV-4830), pure numbers (`52 files` → `files`),
|
|
112
|
+
* and single-character tokens. Returns a Set so downstream set algebra is
|
|
113
|
+
* direct.
|
|
78
114
|
*
|
|
79
115
|
* Scope: ASCII `[a-z0-9]` only — a title written entirely in a non-Latin
|
|
80
116
|
* script (Cyrillic, CJK, …) tokenizes to the empty set, so the gate fails
|
|
@@ -85,7 +121,10 @@ export function tokenizeTitle(title) {
|
|
|
85
121
|
const tokens = title
|
|
86
122
|
.toLowerCase()
|
|
87
123
|
.split(/[^a-z0-9]+/)
|
|
88
|
-
.filter((t) => t.length >= 2 &&
|
|
124
|
+
.filter((t) => t.length >= 2 &&
|
|
125
|
+
!STOPWORDS.has(t) &&
|
|
126
|
+
!BOILERPLATE_STOPWORDS.has(t) &&
|
|
127
|
+
!/^\d+$/.test(t));
|
|
89
128
|
return new Set(tokens);
|
|
90
129
|
}
|
|
91
130
|
/**
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Resolve where the ledger lives — for a *reader* locating the file (mirrors
|
|
2
|
+
* el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
|
|
3
|
+
* the opt-in policy, so never write through it — `emitGateEvent` goes through
|
|
4
|
+
* {@link decideGateLedger}, which may veto writing entirely. */
|
|
5
|
+
export declare function gateEventsPath(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Decide whether gate telemetry is enabled and, if so, the ledger path —
|
|
8
|
+
* returning `null` (no-op) otherwise. Pure (no env / fs reads) so the opt-in
|
|
9
|
+
* policy is exhaustively testable.
|
|
10
|
+
*
|
|
11
|
+
* Policy (open-source-safe):
|
|
12
|
+
* - `disabled` (`EL_TELEMETRY_DISABLED`) → off. Hard opt-out, wins over all.
|
|
13
|
+
* - `explicitDir` (`EL_TELEMETRY_DIR` set) → on. An explicit destination is an
|
|
14
|
+
* explicit opt-in; the dir is created on demand.
|
|
15
|
+
* - otherwise → on **only if the default dir already exists**, i.e. the user
|
|
16
|
+
* already runs the EL telemetry tooling that created it. A fresh open-source
|
|
17
|
+
* install has no such dir, so nothing is ever written for them.
|
|
18
|
+
*/
|
|
19
|
+
export declare function decideGateLedger(opts: {
|
|
20
|
+
disabled: boolean;
|
|
21
|
+
explicitDir?: string;
|
|
22
|
+
defaultDir: string;
|
|
23
|
+
defaultDirExists: boolean;
|
|
24
|
+
}): string | null;
|
|
25
|
+
export interface GateEvent {
|
|
26
|
+
/** Stable gate id, e.g. `issues-create-dup`. */
|
|
27
|
+
gate: string;
|
|
28
|
+
outcome: "blocked" | "overridden";
|
|
29
|
+
/** Highest candidate similarity that triggered the gate (0–1). */
|
|
30
|
+
topScore?: number;
|
|
31
|
+
/** How many candidates crossed the threshold. */
|
|
32
|
+
candidateCount?: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Best-effort append of a gate event to the local ledger — but only when
|
|
36
|
+
* telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
|
|
37
|
+
* no-op, so an open-source install with no telemetry writes nothing. **Never
|
|
38
|
+
* throws** — telemetry must not block issue creation.
|
|
39
|
+
*/
|
|
40
|
+
export declare function emitGateEvent(name: string, subcommand: string, event: GateEvent): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic-gate fire/override telemetry (DEV-4834, sub of DEV-4831).
|
|
7
|
+
*
|
|
8
|
+
* The `issues create` duplicate-detection gate (DEV-4823) records each decision
|
|
9
|
+
* it makes as a `gate` event so a reader (the Enrich Layer `el-telemetry gates`
|
|
10
|
+
* command, or any JSONL consumer) can compute the gate's override-rate
|
|
11
|
+
* (overridden / total) and tell whether the threshold is noisy.
|
|
12
|
+
*
|
|
13
|
+
* **Opt-in.** el-linear is open-source; most installs have no telemetry, and we
|
|
14
|
+
* must never write files a user didn't ask for. Emission is therefore OFF by
|
|
15
|
+
* default and turns on only when telemetry is actually configured — see
|
|
16
|
+
* {@link decideGateLedger}. The ledger is a plain local JSONL file
|
|
17
|
+
* (`gate-events.jsonl`); there is no server or database. el-linear can't import
|
|
18
|
+
* `el-telemetry` (separate package), so it writes by **path-contract** — the
|
|
19
|
+
* same approach `el-hook` uses. The path mirrors `el-telemetry`'s
|
|
20
|
+
* `GATE_EVENTS_PATH`; keep the two in sync. Format + reader are documented in
|
|
21
|
+
* `docs/telemetry.md`.
|
|
22
|
+
*/
|
|
23
|
+
/** The default ledger directory when `EL_TELEMETRY_DIR` is not set. */
|
|
24
|
+
function defaultTelemetryDir() {
|
|
25
|
+
return join(homedir(), ".cache", "el-telemetry");
|
|
26
|
+
}
|
|
27
|
+
/** Resolve where the ledger lives — for a *reader* locating the file (mirrors
|
|
28
|
+
* el-telemetry's `GATE_EVENTS_PATH`). This is NOT the emit decision: it ignores
|
|
29
|
+
* the opt-in policy, so never write through it — `emitGateEvent` goes through
|
|
30
|
+
* {@link decideGateLedger}, which may veto writing entirely. */
|
|
31
|
+
export function gateEventsPath() {
|
|
32
|
+
const dir = process.env.EL_TELEMETRY_DIR || defaultTelemetryDir();
|
|
33
|
+
return join(dir, "gate-events.jsonl");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether gate telemetry is enabled and, if so, the ledger path —
|
|
37
|
+
* returning `null` (no-op) otherwise. Pure (no env / fs reads) so the opt-in
|
|
38
|
+
* policy is exhaustively testable.
|
|
39
|
+
*
|
|
40
|
+
* Policy (open-source-safe):
|
|
41
|
+
* - `disabled` (`EL_TELEMETRY_DISABLED`) → off. Hard opt-out, wins over all.
|
|
42
|
+
* - `explicitDir` (`EL_TELEMETRY_DIR` set) → on. An explicit destination is an
|
|
43
|
+
* explicit opt-in; the dir is created on demand.
|
|
44
|
+
* - otherwise → on **only if the default dir already exists**, i.e. the user
|
|
45
|
+
* already runs the EL telemetry tooling that created it. A fresh open-source
|
|
46
|
+
* install has no such dir, so nothing is ever written for them.
|
|
47
|
+
*/
|
|
48
|
+
export function decideGateLedger(opts) {
|
|
49
|
+
if (opts.disabled) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (opts.explicitDir) {
|
|
53
|
+
return join(opts.explicitDir, "gate-events.jsonl");
|
|
54
|
+
}
|
|
55
|
+
if (!opts.defaultDirExists) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return join(opts.defaultDir, "gate-events.jsonl");
|
|
59
|
+
}
|
|
60
|
+
/** Wire {@link decideGateLedger} to the real environment + filesystem. */
|
|
61
|
+
function gateLedgerIfEnabled() {
|
|
62
|
+
const defaultDir = defaultTelemetryDir();
|
|
63
|
+
return decideGateLedger({
|
|
64
|
+
disabled: Boolean(process.env.EL_TELEMETRY_DISABLED),
|
|
65
|
+
explicitDir: process.env.EL_TELEMETRY_DIR,
|
|
66
|
+
defaultDir,
|
|
67
|
+
defaultDirExists: existsSync(defaultDir),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort append of a gate event to the local ledger — but only when
|
|
72
|
+
* telemetry is opted in ({@link gateLedgerIfEnabled}); otherwise a silent
|
|
73
|
+
* no-op, so an open-source install with no telemetry writes nothing. **Never
|
|
74
|
+
* throws** — telemetry must not block issue creation.
|
|
75
|
+
*/
|
|
76
|
+
export async function emitGateEvent(name, subcommand, event) {
|
|
77
|
+
const path = gateLedgerIfEnabled();
|
|
78
|
+
if (!path) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await mkdir(dirname(path), { recursive: true });
|
|
83
|
+
const record = {
|
|
84
|
+
ts: new Date().toISOString(),
|
|
85
|
+
kind: "gate",
|
|
86
|
+
name,
|
|
87
|
+
subcommand,
|
|
88
|
+
metadata: {
|
|
89
|
+
gate: event.gate,
|
|
90
|
+
outcome: event.outcome,
|
|
91
|
+
top_score: event.topScore,
|
|
92
|
+
candidate_count: event.candidateCount,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// best-effort — telemetry never blocks issue creation
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"description": "A pragmatic CLI for Linear.app — deterministic team/label/member resolution, structured issue validation, configurable term enforcement, and a GraphQL escape hatch.",
|
|
5
5
|
"main": "dist/main.js",
|
|
6
6
|
"types": "dist/main.d.ts",
|