@enrichlayer/el-linear 1.27.0 → 1.29.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 +10 -0
- package/dist/commands/batch.js +2 -2
- package/dist/commands/issues.js +45 -11
- package/dist/config/registry-resolve.d.ts +23 -0
- package/dist/config/registry-resolve.js +59 -0
- package/dist/config/resolver.d.ts +11 -0
- package/dist/config/resolver.js +20 -0
- package/dist/utils/duplicate-detection.d.ts +4 -3
- package/dist/utils/duplicate-detection.js +43 -4
- package/dist/utils/gate-telemetry.d.ts +40 -0
- package/dist/utils/gate-telemetry.js +100 -0
- package/dist/utils/graphql-issues-service.js +18 -0
- package/package.json +1 -1
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/batch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveAssignee, resolveLabels,
|
|
1
|
+
import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
|
|
2
2
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
3
3
|
import { logger } from "../utils/logger.js";
|
|
4
4
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
@@ -45,7 +45,7 @@ async function resolveTargetIssues(options, rootOpts) {
|
|
|
45
45
|
? await resolveAssignee(filters.assignee, rootOpts)
|
|
46
46
|
: undefined,
|
|
47
47
|
delegateId: filters.delegate
|
|
48
|
-
?
|
|
48
|
+
? await resolveMemberWithRegistry(filters.delegate)
|
|
49
49
|
: undefined,
|
|
50
50
|
project: filters.project
|
|
51
51
|
? { kind: "id", id: filters.project }
|
package/dist/commands/issues.js
CHANGED
|
@@ -2,13 +2,14 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { loadConfig } from "../config/config.js";
|
|
3
3
|
import { enrichProjectResolverError, enrichValidationErrors, } from "../config/error-enrichment.js";
|
|
4
4
|
import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
|
|
5
|
-
import { resolveAssignee, resolveLabels,
|
|
5
|
+
import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
|
|
6
6
|
import { resolveDefaultStatus } from "../config/status-defaults.js";
|
|
7
7
|
import { enforceTerms } from "../config/term-enforcer.js";
|
|
8
8
|
import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
|
|
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";
|
|
@@ -209,7 +210,7 @@ async function handleListIssues(options, command) {
|
|
|
209
210
|
? await resolveAssignee(options.assignee, rootOpts)
|
|
210
211
|
: undefined,
|
|
211
212
|
delegateId: options.delegate
|
|
212
|
-
?
|
|
213
|
+
? await resolveMemberWithRegistry(options.delegate)
|
|
213
214
|
: undefined,
|
|
214
215
|
project: resolveProjectFlag(options.project),
|
|
215
216
|
labelNames: options.labels ? splitList(options.labels) : undefined,
|
|
@@ -255,7 +256,7 @@ async function handleSearchIssues(query, options, command) {
|
|
|
255
256
|
? await resolveAssignee(options.assignee, rootOpts)
|
|
256
257
|
: undefined,
|
|
257
258
|
delegateId: options.delegate
|
|
258
|
-
?
|
|
259
|
+
? await resolveMemberWithRegistry(options.delegate)
|
|
259
260
|
: undefined,
|
|
260
261
|
project: resolveProjectFlag(options.project),
|
|
261
262
|
status: explicitStatus,
|
|
@@ -383,7 +384,7 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
383
384
|
? await resolveAssignee(effectiveAssignee, rootOpts)
|
|
384
385
|
: undefined;
|
|
385
386
|
const delegateId = options.delegate
|
|
386
|
-
?
|
|
387
|
+
? await resolveMemberWithRegistry(options.delegate)
|
|
387
388
|
: undefined;
|
|
388
389
|
let labelIds = [];
|
|
389
390
|
if (options.labels) {
|
|
@@ -406,7 +407,10 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
406
407
|
});
|
|
407
408
|
let subscriberIds;
|
|
408
409
|
if (options.subscriber) {
|
|
409
|
-
|
|
410
|
+
// --subscriber resolves through the opt-in registry too (DEV-4880),
|
|
411
|
+
// matching --assignee / --delegate. Each entry falls back to config on a
|
|
412
|
+
// miss; resolveMemberWithRegistry is fail-open and dormant when unconfigured.
|
|
413
|
+
subscriberIds = await Promise.all(splitList(options.subscriber).map((s) => resolveMemberWithRegistry(s)));
|
|
410
414
|
}
|
|
411
415
|
const priority = effectivePriorityInput
|
|
412
416
|
? validatePriority(effectivePriorityInput)
|
|
@@ -458,13 +462,18 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
|
|
|
458
462
|
* title-overlap, and throws — listing the matches — when one is at/above the
|
|
459
463
|
* configured similarity threshold.
|
|
460
464
|
*
|
|
461
|
-
* Bypassed by `--
|
|
465
|
+
* Bypassed by `--skip-validation` and
|
|
462
466
|
* `config.validation.duplicateDetection: false` / `validation.enabled: false`.
|
|
463
|
-
*
|
|
464
|
-
*
|
|
467
|
+
* `--allow-duplicate` does NOT skip the search — the gate still runs and, on a
|
|
468
|
+
* would-fire, records an `overridden` telemetry event (DEV-4834) before
|
|
469
|
+
* proceeding, so `el-telemetry gates` can measure the override-rate. (A blocked
|
|
470
|
+
* fire records `blocked`.) `--skip-validation` is a blanket bypass and emits
|
|
471
|
+
* nothing — it's not a gate-specific override, so counting it would dilute the
|
|
472
|
+
* signal. The search itself is best-effort: a network/API failure warns and
|
|
473
|
+
* proceeds rather than blocking legitimate issue creation on infra trouble.
|
|
465
474
|
*/
|
|
466
475
|
async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
467
|
-
if (options.
|
|
476
|
+
if (options.skipValidation) {
|
|
468
477
|
return;
|
|
469
478
|
}
|
|
470
479
|
const validation = loadConfig().validation;
|
|
@@ -498,7 +507,13 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
498
507
|
});
|
|
499
508
|
}
|
|
500
509
|
catch (err) {
|
|
501
|
-
|
|
510
|
+
// The "pass --allow-duplicate" hint only makes sense when the caller
|
|
511
|
+
// hasn't already passed it (under --allow-duplicate we ran the search
|
|
512
|
+
// only to detect a would-fire for telemetry, and we proceed regardless).
|
|
513
|
+
const silenceHint = options.allowDuplicate
|
|
514
|
+
? ""
|
|
515
|
+
: " Pass --allow-duplicate to silence.";
|
|
516
|
+
outputWarning(`Duplicate-detection search failed (${err instanceof Error ? err.message : String(err)}); proceeding without the dup check.${silenceHint}`);
|
|
502
517
|
return;
|
|
503
518
|
}
|
|
504
519
|
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
@@ -508,6 +523,25 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
508
523
|
if (matches.length === 0) {
|
|
509
524
|
return;
|
|
510
525
|
}
|
|
526
|
+
// The gate would fire. Record the decision so `el-telemetry gates` can
|
|
527
|
+
// compute override-rate (DEV-4834): `overridden` when the user passed
|
|
528
|
+
// --allow-duplicate and we proceed anyway, `blocked` when we stop creation.
|
|
529
|
+
const gateEvent = {
|
|
530
|
+
gate: "issues-create-dup",
|
|
531
|
+
topScore: matches[0].score,
|
|
532
|
+
candidateCount: matches.length,
|
|
533
|
+
};
|
|
534
|
+
if (options.allowDuplicate) {
|
|
535
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
536
|
+
...gateEvent,
|
|
537
|
+
outcome: "overridden",
|
|
538
|
+
});
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
542
|
+
...gateEvent,
|
|
543
|
+
outcome: "blocked",
|
|
544
|
+
});
|
|
511
545
|
throw new Error(`Issue creation blocked: ${formatDuplicateBlock(matches)}`);
|
|
512
546
|
}
|
|
513
547
|
async function handleCreateIssue(title, options, command) {
|
|
@@ -812,7 +846,7 @@ async function handleUpdateIssue(issueId, options, command) {
|
|
|
812
846
|
const delegateId = options.clearDelegate
|
|
813
847
|
? null
|
|
814
848
|
: options.delegate
|
|
815
|
-
?
|
|
849
|
+
? await resolveMemberWithRegistry(options.delegate)
|
|
816
850
|
: undefined;
|
|
817
851
|
const updateArgs = buildUpdateArgs(issueId, options, assigneeId, delegateId);
|
|
818
852
|
const result = await withProjectResolverEnrichment(() => issuesService.updateIssue(updateArgs, options.labelBy || "adding"), {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional, opt-in resolution against the company-wide identity registry
|
|
3
|
+
* (DEV-4827 / DEV-4871). EL-only: this activates **solely** when
|
|
4
|
+
* `EL_IDENTITY_URL` is set. el-linear is MIT/open-source — a non-EL install
|
|
5
|
+
* leaves this dormant (no network, no config, nothing written), and the package
|
|
6
|
+
* never takes a dependency on EL-internal infrastructure. It mirrors the
|
|
7
|
+
* env-gated CF-Access pattern of the tools `@enrichlayer/el-identity` client but
|
|
8
|
+
* is kept self-contained so the OSS package carries no private dependency.
|
|
9
|
+
*
|
|
10
|
+
* The registry is an *enhancement* for EL users: callers try it first, then fall
|
|
11
|
+
* back to the bundled config (`resolveMember`). It must never throw or break a
|
|
12
|
+
* command because the registry is unreachable.
|
|
13
|
+
*/
|
|
14
|
+
/** True only when the registry is explicitly configured (opt-in). */
|
|
15
|
+
export declare function isRegistryConfigured(env?: NodeJS.ProcessEnv): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve any identifier (alias, handle, email, name, Linear UUID) to a Linear
|
|
18
|
+
* UUID via the registry's `GET /api/people/resolve`. Returns `null` when the
|
|
19
|
+
* registry is not configured, when nothing matches, or on **any** failure
|
|
20
|
+
* (unreachable / timeout / CF-Access challenge / malformed response) — the
|
|
21
|
+
* caller falls back to the config-based resolver. Never throws.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveViaRegistry(identifier: string, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): Promise<string | null>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional, opt-in resolution against the company-wide identity registry
|
|
3
|
+
* (DEV-4827 / DEV-4871). EL-only: this activates **solely** when
|
|
4
|
+
* `EL_IDENTITY_URL` is set. el-linear is MIT/open-source — a non-EL install
|
|
5
|
+
* leaves this dormant (no network, no config, nothing written), and the package
|
|
6
|
+
* never takes a dependency on EL-internal infrastructure. It mirrors the
|
|
7
|
+
* env-gated CF-Access pattern of the tools `@enrichlayer/el-identity` client but
|
|
8
|
+
* is kept self-contained so the OSS package carries no private dependency.
|
|
9
|
+
*
|
|
10
|
+
* The registry is an *enhancement* for EL users: callers try it first, then fall
|
|
11
|
+
* back to the bundled config (`resolveMember`). It must never throw or break a
|
|
12
|
+
* command because the registry is unreachable.
|
|
13
|
+
*/
|
|
14
|
+
const URL_ENV = "EL_IDENTITY_URL";
|
|
15
|
+
const CF_ID_ENV = "EL_IDENTITY_CF_ACCESS_CLIENT_ID";
|
|
16
|
+
const CF_SECRET_ENV = "EL_IDENTITY_CF_ACCESS_CLIENT_SECRET";
|
|
17
|
+
const TIMEOUT_MS = 8000;
|
|
18
|
+
/** True only when the registry is explicitly configured (opt-in). */
|
|
19
|
+
export function isRegistryConfigured(env = process.env) {
|
|
20
|
+
return Boolean(env[URL_ENV]?.trim());
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve any identifier (alias, handle, email, name, Linear UUID) to a Linear
|
|
24
|
+
* UUID via the registry's `GET /api/people/resolve`. Returns `null` when the
|
|
25
|
+
* registry is not configured, when nothing matches, or on **any** failure
|
|
26
|
+
* (unreachable / timeout / CF-Access challenge / malformed response) — the
|
|
27
|
+
* caller falls back to the config-based resolver. Never throws.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveViaRegistry(identifier, env = process.env, fetchImpl = fetch) {
|
|
30
|
+
const base = env[URL_ENV]?.trim();
|
|
31
|
+
if (!base) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const headers = { Accept: "application/json" };
|
|
35
|
+
const cfId = env[CF_ID_ENV];
|
|
36
|
+
const cfSecret = env[CF_SECRET_ENV];
|
|
37
|
+
if (cfId && cfSecret) {
|
|
38
|
+
headers["CF-Access-Client-Id"] = cfId;
|
|
39
|
+
headers["CF-Access-Client-Secret"] = cfSecret;
|
|
40
|
+
}
|
|
41
|
+
const url = `${base.replace(/\/+$/, "")}/api/people/resolve?identifier=${encodeURIComponent(identifier)}`;
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetchImpl(url, {
|
|
44
|
+
headers,
|
|
45
|
+
// A 3xx is a Cloudflare Access SSO bounce, not a result — `redirect:
|
|
46
|
+
// "manual"` surfaces it as a non-ok status we treat as a miss.
|
|
47
|
+
redirect: "manual",
|
|
48
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const record = (await res.json());
|
|
54
|
+
return record?.linearId ?? null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -13,6 +13,17 @@ export declare function resolveMember(input: string): string;
|
|
|
13
13
|
* Falls back to resolveMember for all other inputs.
|
|
14
14
|
*/
|
|
15
15
|
export declare function resolveAssignee(input: string, rootOpts: Record<string, unknown>): Promise<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a member identifier (alias / handle / name) to a UUID, consulting the
|
|
18
|
+
* opt-in identity registry first when configured, then the bundled config
|
|
19
|
+
* (`resolveMember`). The shared async resolution path behind `--assignee` and
|
|
20
|
+
* `--delegate` (DEV-4871 / DEV-4872).
|
|
21
|
+
*
|
|
22
|
+
* EL-only and env-gated on `EL_IDENTITY_URL`; fail-open — when unconfigured, on
|
|
23
|
+
* a miss, or on any failure it falls back to config, so a non-EL install and an
|
|
24
|
+
* unreachable registry both behave exactly as before. Never throws.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveMemberWithRegistry(input: string): Promise<string>;
|
|
16
27
|
/**
|
|
17
28
|
* Resolve a user's display name from their UUID via config fullNames map.
|
|
18
29
|
* Returns the full name if found, otherwise returns the original name.
|
package/dist/config/resolver.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createGraphQLService } from "../utils/graphql-service.js";
|
|
|
2
2
|
import { outputWarning } from "../utils/output.js";
|
|
3
3
|
import { isUuid, isUuidPrefix } from "../utils/uuid.js";
|
|
4
4
|
import { loadConfig } from "./config.js";
|
|
5
|
+
import { isRegistryConfigured, resolveViaRegistry, } from "./registry-resolve.js";
|
|
5
6
|
/**
|
|
6
7
|
* Resolve a team key/name/alias to its UUID.
|
|
7
8
|
* Case-insensitive: "fe" → FE UUID, "frontend" → FE UUID via alias.
|
|
@@ -98,6 +99,25 @@ export async function resolveAssignee(input, rootOpts) {
|
|
|
98
99
|
}
|
|
99
100
|
return result.viewer.id;
|
|
100
101
|
}
|
|
102
|
+
return resolveMemberWithRegistry(input);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a member identifier (alias / handle / name) to a UUID, consulting the
|
|
106
|
+
* opt-in identity registry first when configured, then the bundled config
|
|
107
|
+
* (`resolveMember`). The shared async resolution path behind `--assignee` and
|
|
108
|
+
* `--delegate` (DEV-4871 / DEV-4872).
|
|
109
|
+
*
|
|
110
|
+
* EL-only and env-gated on `EL_IDENTITY_URL`; fail-open — when unconfigured, on
|
|
111
|
+
* a miss, or on any failure it falls back to config, so a non-EL install and an
|
|
112
|
+
* unreachable registry both behave exactly as before. Never throws.
|
|
113
|
+
*/
|
|
114
|
+
export async function resolveMemberWithRegistry(input) {
|
|
115
|
+
if (isRegistryConfigured()) {
|
|
116
|
+
const viaRegistry = await resolveViaRegistry(input);
|
|
117
|
+
if (viaRegistry) {
|
|
118
|
+
return viaRegistry;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
101
121
|
return resolveMember(input);
|
|
102
122
|
}
|
|
103
123
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRegistryConfigured, resolveViaRegistry, } from "../config/registry-resolve.js";
|
|
1
2
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
2
3
|
import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
|
|
3
4
|
import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
|
|
@@ -1048,6 +1049,15 @@ export class GraphQLIssuesService {
|
|
|
1048
1049
|
if (!assigneeId || isUuid(assigneeId)) {
|
|
1049
1050
|
return assigneeId;
|
|
1050
1051
|
}
|
|
1052
|
+
// Opt-in registry resolution (DEV-4872): try the identity registry first,
|
|
1053
|
+
// falling back to the Linear-API user lookup below on a miss. EL-only,
|
|
1054
|
+
// env-gated, fail-open — a non-EL install never reaches the network here.
|
|
1055
|
+
if (isRegistryConfigured()) {
|
|
1056
|
+
const viaRegistry = await resolveViaRegistry(assigneeId);
|
|
1057
|
+
if (viaRegistry) {
|
|
1058
|
+
return viaRegistry;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1051
1061
|
// A plain name (no `@`) resolves via the user lookup — same as
|
|
1052
1062
|
// `resolveDelegateId`. Without this, a non-config full name like
|
|
1053
1063
|
// "Yury Tsukerman" fell through unchanged and was sent to the
|
|
@@ -1068,6 +1078,14 @@ export class GraphQLIssuesService {
|
|
|
1068
1078
|
if (!delegateId || isUuid(delegateId)) {
|
|
1069
1079
|
return delegateId;
|
|
1070
1080
|
}
|
|
1081
|
+
// Opt-in registry resolution (DEV-4872): registry-first, Linear-API
|
|
1082
|
+
// fallback. EL-only, env-gated, fail-open.
|
|
1083
|
+
if (isRegistryConfigured()) {
|
|
1084
|
+
const viaRegistry = await resolveViaRegistry(delegateId);
|
|
1085
|
+
if (viaRegistry) {
|
|
1086
|
+
return viaRegistry;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1071
1089
|
if (!delegateId.includes("@")) {
|
|
1072
1090
|
return this.linearService.resolveUserId(delegateId);
|
|
1073
1091
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.29.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",
|