@enrichlayer/el-linear 1.35.0 → 1.36.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 +8 -7
- package/claude-skills/linear-operations/SKILL.md +15 -0
- package/dist/commands/issues.js +115 -1
- package/dist/config/config.d.ts +15 -0
- package/dist/config/sop-label-validation.d.ts +82 -0
- package/dist/config/sop-label-validation.js +103 -0
- package/dist/utils/gate-telemetry.d.ts +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -249,13 +249,14 @@ itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
|
249
249
|
|
|
250
250
|
### Gate telemetry (optional)
|
|
251
251
|
|
|
252
|
-
`issues create` has a duplicate-detection gate
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
252
|
+
`issues create` has a duplicate-detection gate (on by default) and an opt-in
|
|
253
|
+
[SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate).
|
|
254
|
+
el-linear can record each gate's fire/override decision to a local JSONL file so
|
|
255
|
+
you can measure its **override-rate** and tell whether it's too aggressive. It is
|
|
256
|
+
**off by default** and writes nothing unless you opt in (e.g.
|
|
257
|
+
`export EL_TELEMETRY_DIR=<path>`); there is no server or database, and
|
|
258
|
+
`EL_TELEMETRY_DISABLED=1` forces it off. Full opt-in rules, the event schema, and
|
|
259
|
+
a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
|
|
259
260
|
|
|
260
261
|
### Networking (IPv4 preference)
|
|
261
262
|
|
|
@@ -200,6 +200,21 @@ and outreach tracked in one place.
|
|
|
200
200
|
> `validation.duplicateDetection: false` (field validation still runs);
|
|
201
201
|
> `validation.enabled: false` turns off all validation.
|
|
202
202
|
|
|
203
|
+
> **SOP-label parent gate ([DEV-5378](https://linear.app/verticalint/issue/DEV-5378/), opt-in).** When enabled, `el-linear issues create` requires an
|
|
204
|
+
> issue carrying an **SOP-type label** (any name in `validation.sopLabels`,
|
|
205
|
+
> default `["SOP"]`, case-insensitive) to point at a **parent SOP** — `--parent`
|
|
206
|
+
> or `--related-to` must resolve to another SOP-labeled issue — and **blocks
|
|
207
|
+
> (exit non-zero)** otherwise, naming the rule. An SOP with no parent SOP is
|
|
208
|
+
> unfindable by SOP tooling and breaks the catalog topology. It is **off by
|
|
209
|
+
> default** (`validation.sopLabelParentGate: true` turns it on; the Enrich Layer
|
|
210
|
+
> shared team config flips it on) because "SOP" is a workspace-specific
|
|
211
|
+
> taxonomy, not something an open-source install should assume. Escape hatch:
|
|
212
|
+
> `--allow-unparented-sop` (narrow, for an intentionally top-level SOP);
|
|
213
|
+
> `--skip-validation` also bypasses it but skips all field validation. A typo'd
|
|
214
|
+
> or nonexistent parent reference **blocks** (naming the ref) so a mistake can't
|
|
215
|
+
> orphan an SOP; a transport/service error **fails open** with a warning (and a
|
|
216
|
+
> `fail-open` gate event); a resolvable non-SOP parent hard-blocks.
|
|
217
|
+
|
|
203
218
|
```bash
|
|
204
219
|
# --include-closed is required so previously-completed duplicates surface.
|
|
205
220
|
# `issues search` defaults to open states (DEV-4478); the duplicate check
|
package/dist/commands/issues.js
CHANGED
|
@@ -3,6 +3,7 @@ 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
5
|
import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
|
|
6
|
+
import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
|
|
6
7
|
import { resolveDefaultStatus } from "../config/status-defaults.js";
|
|
7
8
|
import { enforceTerms } from "../config/term-enforcer.js";
|
|
8
9
|
import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
|
|
@@ -544,6 +545,109 @@ async function enforceNoDuplicateIssue(title, options, issuesService) {
|
|
|
544
545
|
});
|
|
545
546
|
throw new Error(`Issue creation blocked: ${formatDuplicateBlock(matches)}`);
|
|
546
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* DEV-5378: create-time SOP-label parent gate. When the issue being created
|
|
550
|
+
* carries an SOP-type label, require that `--parent` or `--related-to` resolves
|
|
551
|
+
* to another SOP-labeled issue, and throw otherwise — the deterministic form of
|
|
552
|
+
* the kaizen skill's Stage-4 rule (parent ALL-1028). An SOP with no parent SOP
|
|
553
|
+
* is unfindable by `el-sop landscape` and breaks the catalog topology.
|
|
554
|
+
*
|
|
555
|
+
* OPT-IN: dormant unless `validation.sopLabelParentGate: true` (see
|
|
556
|
+
* {@link getSopLabelGateConfig}) — el-linear is MIT/open-source and "SOP" is an
|
|
557
|
+
* Enrich-Layer-specific taxonomy. Bypassed by `--skip-validation` (blanket) and
|
|
558
|
+
* the narrow `--allow-unparented-sop` (which records an `overridden` gate event,
|
|
559
|
+
* mirroring `--allow-duplicate`). The parent-label fetch is best-effort: when a
|
|
560
|
+
* referenced issue can't be resolved (network / not-found) the gate fails open
|
|
561
|
+
* with a warning rather than blocking legitimate creation on infra trouble — a
|
|
562
|
+
* genuinely resolvable non-SOP parent still hard-blocks.
|
|
563
|
+
*/
|
|
564
|
+
async function enforceSopLabelParent(labels, options, issuesService) {
|
|
565
|
+
if (options.skipValidation) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const { enabled, sopLabels } = getSopLabelGateConfig();
|
|
569
|
+
if (!enabled) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (!hasSopLabel(labels, sopLabels)) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const parentRefs = [
|
|
576
|
+
...(typeof options.parentTicket === "string" && options.parentTicket
|
|
577
|
+
? [options.parentTicket]
|
|
578
|
+
: []),
|
|
579
|
+
...(options.relatedTo ? splitList(options.relatedTo) : []),
|
|
580
|
+
];
|
|
581
|
+
// Record the decision so `el-telemetry gates` can compute override-rate,
|
|
582
|
+
// mirroring the dup gate (DEV-4834): `overridden` when the caller passed
|
|
583
|
+
// --allow-unparented-sop and we proceed, `blocked` when we stop creation.
|
|
584
|
+
const decide = async (reason, unresolvableRefs) => {
|
|
585
|
+
const gateEvent = { gate: "issues-create-sop-parent" };
|
|
586
|
+
if (options.allowUnparentedSop) {
|
|
587
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
588
|
+
...gateEvent,
|
|
589
|
+
outcome: "overridden",
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
594
|
+
...gateEvent,
|
|
595
|
+
outcome: "blocked",
|
|
596
|
+
});
|
|
597
|
+
throw new Error(`Issue creation blocked: ${formatSopParentBlock({
|
|
598
|
+
sopLabels,
|
|
599
|
+
reason,
|
|
600
|
+
parentRefs,
|
|
601
|
+
unresolvableRefs,
|
|
602
|
+
})}`);
|
|
603
|
+
};
|
|
604
|
+
// No parent/related at all → deterministic block (no fetch needed).
|
|
605
|
+
if (parentRefs.length === 0) {
|
|
606
|
+
await decide("no-parent");
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
// A referenced issue that resolves AND carries an SOP label satisfies the
|
|
610
|
+
// gate. Fetch per-ref (not batched) so one unresolvable ref doesn't sink a
|
|
611
|
+
// sibling that would have passed. Classify each failure: a clean
|
|
612
|
+
// not-found/malformed ref is an unresolvable reference (it contributes to a
|
|
613
|
+
// block — a typo must not slip through), a transport/service error is infra
|
|
614
|
+
// trouble (fail open so it can't block legitimate creation).
|
|
615
|
+
const unresolvableRefs = [];
|
|
616
|
+
let transportError = false;
|
|
617
|
+
for (const ref of parentRefs) {
|
|
618
|
+
try {
|
|
619
|
+
const issue = await issuesService.getIssueById(ref);
|
|
620
|
+
if (hasSopLabel(issue.labels.map((l) => l.name), sopLabels)) {
|
|
621
|
+
return; // valid SOP parent — pass
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
if (isUnresolvableReferenceError(err)) {
|
|
626
|
+
unresolvableRefs.push(ref);
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
transportError = true;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// No SOP parent was confirmed. A transport failure may have hidden the real
|
|
634
|
+
// SOP parent → fail open (best-effort, like the dup gate on a search
|
|
635
|
+
// failure) and record it so the degradation is measurable (DEV-5378).
|
|
636
|
+
if (transportError) {
|
|
637
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
638
|
+
gate: "issues-create-sop-parent",
|
|
639
|
+
outcome: "fail-open",
|
|
640
|
+
});
|
|
641
|
+
outputWarning(`SOP-parent check could not verify a parent SOP for ${parentRefs.join(", ")} due to a service error; proceeding without blocking.${options.allowUnparentedSop
|
|
642
|
+
? ""
|
|
643
|
+
: " Pass --allow-unparented-sop to silence."}`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Every reference either resolved to a non-SOP issue or cleanly failed to
|
|
647
|
+
// resolve (typo / nonexistent) — none is a valid SOP parent → block, naming
|
|
648
|
+
// any unresolvable refs so a typo reads differently from a real non-SOP parent.
|
|
649
|
+
await decide("no-sop-parent", unresolvableRefs.length > 0 ? unresolvableRefs : undefined);
|
|
650
|
+
}
|
|
547
651
|
async function handleCreateIssue(title, options, command) {
|
|
548
652
|
const rootOpts = getRootOpts(command);
|
|
549
653
|
const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
|
|
@@ -572,6 +676,15 @@ async function handleCreateIssue(title, options, command) {
|
|
|
572
676
|
if (title) {
|
|
573
677
|
await enforceNoDuplicateIssue(title, options, issuesService);
|
|
574
678
|
}
|
|
679
|
+
// DEV-5378: deterministic SOP-label parent gate. Opt-in (dormant unless
|
|
680
|
+
// validation.sopLabelParentGate). Runs on the post-normalization labels
|
|
681
|
+
// (resolveCreateInputs rewrote options.labels to the canonical set) and,
|
|
682
|
+
// when the issue is SOP-labeled, blocks unless --parent/--related-to points
|
|
683
|
+
// at another SOP-labeled issue. Independent of title, so it runs even on the
|
|
684
|
+
// --from-template path — though a template that supplies labels server-side
|
|
685
|
+
// leaves options.labels undefined here, in which case the gate sees no SOP
|
|
686
|
+
// label and no-ops (same client-side-visibility gap as the dup gate).
|
|
687
|
+
await enforceSopLabelParent(options.labels ? splitList(options.labels) : [], options, issuesService);
|
|
575
688
|
// Wrap valid issue identifiers as markdown links before creating, so the description
|
|
576
689
|
// saved on Linear has clickable refs from the start. Self-reference can't apply here
|
|
577
690
|
// because the issue doesn't exist yet — pass undefined.
|
|
@@ -1137,8 +1250,9 @@ export function setupIssuesCommands(program) {
|
|
|
1137
1250
|
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1138
1251
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1139
1252
|
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1140
|
-
.option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection)")
|
|
1253
|
+
.option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate)")
|
|
1141
1254
|
.option("--allow-duplicate", "skip the duplicate-detection gate and create even if a similar issue already exists")
|
|
1255
|
+
.option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
|
|
1142
1256
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1143
1257
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
1144
1258
|
.option("--no-footer", "skip the configured messageFooter for this issue")
|
package/dist/config/config.d.ts
CHANGED
|
@@ -67,6 +67,21 @@ export interface ElLinearConfig {
|
|
|
67
67
|
* `DEFAULT_DUPLICATE_THRESHOLD` (0.35). Lower = more aggressive.
|
|
68
68
|
*/
|
|
69
69
|
duplicateThreshold?: number;
|
|
70
|
+
/**
|
|
71
|
+
* OPT-IN SOP-label parent gate (DEV-5378). When `true`, `issues create`
|
|
72
|
+
* requires an issue carrying an SOP-type label (see `sopLabels`) to point
|
|
73
|
+
* at a parent SOP via `--parent` or `--related-to`, blocking otherwise.
|
|
74
|
+
* Defaults to `false` (dormant) — el-linear is MIT/open-source and "SOP"
|
|
75
|
+
* is an Enrich-Layer-specific taxonomy, so a fresh install stays silent
|
|
76
|
+
* until a workspace opts in (the EL shared team config flips it on).
|
|
77
|
+
* Bypass a single create with `--allow-unparented-sop`.
|
|
78
|
+
*/
|
|
79
|
+
sopLabelParentGate?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Label names (matched case-insensitively) that mark an issue as an SOP
|
|
82
|
+
* for the `sopLabelParentGate` check. Defaults to `["SOP"]`.
|
|
83
|
+
*/
|
|
84
|
+
sopLabels?: string[];
|
|
70
85
|
};
|
|
71
86
|
/**
|
|
72
87
|
* Optional override for the Linear workspace URL key (the part after
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOP-label parent validation — DEV-5378.
|
|
3
|
+
*
|
|
4
|
+
* The el-linear side of the SOP-system enforcement (parent ALL-1028). When an
|
|
5
|
+
* issue carries an SOP-type label, it must point at a parent SOP so the
|
|
6
|
+
* `el-sop landscape` catalog topology stays connected — an SOP with no parent
|
|
7
|
+
* SOP is unfindable and breaks the tree. This turns the kaizen skill's Stage-4
|
|
8
|
+
* "always give an SOP issue a parent SOP" prose rule into a deterministic
|
|
9
|
+
* create-time gate, mirroring the DEV-4823 duplicate-detection gate.
|
|
10
|
+
*
|
|
11
|
+
* OPT-IN by design. el-linear is MIT and published on npm; most installs are
|
|
12
|
+
* not Enrich Layer and have no SOP taxonomy. The gate is dormant unless
|
|
13
|
+
* `validation.sopLabelParentGate: true` is set (the EL workspace flips it on in
|
|
14
|
+
* its shared team config). This is the opposite of the duplicate-detection gate,
|
|
15
|
+
* which defaults on — but the dup check is workspace-agnostic, whereas "SOP" is
|
|
16
|
+
* an Enrich-Layer-specific label taxonomy that a fresh OSS install must not be
|
|
17
|
+
* surprised by.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Default label names that mark an issue as an SOP. Overridable via
|
|
21
|
+
* `config.validation.sopLabels`. Matched case-insensitively against the
|
|
22
|
+
* issue's labels.
|
|
23
|
+
*/
|
|
24
|
+
export declare const DEFAULT_SOP_LABELS: string[];
|
|
25
|
+
export interface SopLabelGateConfig {
|
|
26
|
+
/**
|
|
27
|
+
* Whether the gate is active. OPT-IN: only true when validation is not
|
|
28
|
+
* turned off AND `sopLabelParentGate` is explicitly `true`.
|
|
29
|
+
*/
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** The SOP label names in effect (config override or {@link DEFAULT_SOP_LABELS}). */
|
|
32
|
+
sopLabels: string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the SOP-label-parent-gate config from the merged el-linear config.
|
|
36
|
+
*
|
|
37
|
+
* The gate is dormant by default. It activates only when validation isn't
|
|
38
|
+
* disabled (`validation.enabled !== false`) AND the operator has explicitly set
|
|
39
|
+
* `validation.sopLabelParentGate: true`. An absent or empty `sopLabels` falls
|
|
40
|
+
* back to {@link DEFAULT_SOP_LABELS}.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getSopLabelGateConfig(): SopLabelGateConfig;
|
|
43
|
+
/**
|
|
44
|
+
* Case-insensitive membership test: does `labels` contain any of the configured
|
|
45
|
+
* SOP label names? Returns false for an empty `labels` set.
|
|
46
|
+
*/
|
|
47
|
+
export declare function hasSopLabel(labels: string[], sopLabels: string[]): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Distinguish a reference that cleanly does NOT resolve to a real issue (a
|
|
50
|
+
* typo'd or nonexistent `--parent` / `--related-to`) from a transport/service
|
|
51
|
+
* failure (network, GraphQL 5xx, timeout).
|
|
52
|
+
*
|
|
53
|
+
* `getIssueById` throws a plain `Error` for both — this codebase has no error
|
|
54
|
+
* `code` or subclass to switch on — so we key on the two message families it
|
|
55
|
+
* produces for an *unresolvable reference*:
|
|
56
|
+
* - `notFoundError` → `… not found.` (well-formed ref, no such issue)
|
|
57
|
+
* - `parseIssueIdentifier` → `Invalid issue identifier format: …` /
|
|
58
|
+
* `Invalid issue number in identifier: …` (malformed ref)
|
|
59
|
+
* Everything else — notably `GraphQL request failed: …` / `GraphQL query
|
|
60
|
+
* failed` from `graphql-service` — is treated as transport and fails open.
|
|
61
|
+
*
|
|
62
|
+
* This is a correctness distinction, not just telemetry: an unresolvable
|
|
63
|
+
* reference must BLOCK. Otherwise a typo'd `--related-to` on an SOP issue fails
|
|
64
|
+
* open, the issue is created, and the *follow-up* `createRelations` throws —
|
|
65
|
+
* leaving an orphan SOP on the board (the exact gap DEV-5378 cycle-1 caught). A
|
|
66
|
+
* transport error must FAIL OPEN so infra trouble can't block legitimate work.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isUnresolvableReferenceError(err: unknown): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Render the human/agent-facing block thrown when an SOP-labeled issue has no
|
|
71
|
+
* SOP parent. `reason` distinguishes "no parent at all" from "one or more
|
|
72
|
+
* references present but none resolves to an SOP-labeled issue", so the message
|
|
73
|
+
* points at the exact fix. When some references couldn't be resolved at all,
|
|
74
|
+
* `unresolvableRefs` names them (a typo'd ref reads differently from a real but
|
|
75
|
+
* non-SOP parent). Names the rule and the `--allow-unparented-sop` escape hatch.
|
|
76
|
+
*/
|
|
77
|
+
export declare function formatSopParentBlock(opts: {
|
|
78
|
+
sopLabels: string[];
|
|
79
|
+
reason: "no-parent" | "no-sop-parent";
|
|
80
|
+
parentRefs: string[];
|
|
81
|
+
unresolvableRefs?: string[];
|
|
82
|
+
}): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SOP-label parent validation — DEV-5378.
|
|
3
|
+
*
|
|
4
|
+
* The el-linear side of the SOP-system enforcement (parent ALL-1028). When an
|
|
5
|
+
* issue carries an SOP-type label, it must point at a parent SOP so the
|
|
6
|
+
* `el-sop landscape` catalog topology stays connected — an SOP with no parent
|
|
7
|
+
* SOP is unfindable and breaks the tree. This turns the kaizen skill's Stage-4
|
|
8
|
+
* "always give an SOP issue a parent SOP" prose rule into a deterministic
|
|
9
|
+
* create-time gate, mirroring the DEV-4823 duplicate-detection gate.
|
|
10
|
+
*
|
|
11
|
+
* OPT-IN by design. el-linear is MIT and published on npm; most installs are
|
|
12
|
+
* not Enrich Layer and have no SOP taxonomy. The gate is dormant unless
|
|
13
|
+
* `validation.sopLabelParentGate: true` is set (the EL workspace flips it on in
|
|
14
|
+
* its shared team config). This is the opposite of the duplicate-detection gate,
|
|
15
|
+
* which defaults on — but the dup check is workspace-agnostic, whereas "SOP" is
|
|
16
|
+
* an Enrich-Layer-specific label taxonomy that a fresh OSS install must not be
|
|
17
|
+
* surprised by.
|
|
18
|
+
*/
|
|
19
|
+
import { loadConfig } from "./config.js";
|
|
20
|
+
/**
|
|
21
|
+
* Default label names that mark an issue as an SOP. Overridable via
|
|
22
|
+
* `config.validation.sopLabels`. Matched case-insensitively against the
|
|
23
|
+
* issue's labels.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_SOP_LABELS = ["SOP"];
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the SOP-label-parent-gate config from the merged el-linear config.
|
|
28
|
+
*
|
|
29
|
+
* The gate is dormant by default. It activates only when validation isn't
|
|
30
|
+
* disabled (`validation.enabled !== false`) AND the operator has explicitly set
|
|
31
|
+
* `validation.sopLabelParentGate: true`. An absent or empty `sopLabels` falls
|
|
32
|
+
* back to {@link DEFAULT_SOP_LABELS}.
|
|
33
|
+
*/
|
|
34
|
+
export function getSopLabelGateConfig() {
|
|
35
|
+
const validation = loadConfig().validation;
|
|
36
|
+
const enabled = validation?.enabled !== false && validation?.sopLabelParentGate === true;
|
|
37
|
+
const sopLabels = validation?.sopLabels && validation.sopLabels.length > 0
|
|
38
|
+
? validation.sopLabels
|
|
39
|
+
: DEFAULT_SOP_LABELS;
|
|
40
|
+
return { enabled, sopLabels };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Case-insensitive membership test: does `labels` contain any of the configured
|
|
44
|
+
* SOP label names? Returns false for an empty `labels` set.
|
|
45
|
+
*/
|
|
46
|
+
export function hasSopLabel(labels, sopLabels) {
|
|
47
|
+
const wanted = new Set(sopLabels.map((l) => l.toLowerCase()));
|
|
48
|
+
return labels.some((l) => wanted.has(l.toLowerCase()));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Distinguish a reference that cleanly does NOT resolve to a real issue (a
|
|
52
|
+
* typo'd or nonexistent `--parent` / `--related-to`) from a transport/service
|
|
53
|
+
* failure (network, GraphQL 5xx, timeout).
|
|
54
|
+
*
|
|
55
|
+
* `getIssueById` throws a plain `Error` for both — this codebase has no error
|
|
56
|
+
* `code` or subclass to switch on — so we key on the two message families it
|
|
57
|
+
* produces for an *unresolvable reference*:
|
|
58
|
+
* - `notFoundError` → `… not found.` (well-formed ref, no such issue)
|
|
59
|
+
* - `parseIssueIdentifier` → `Invalid issue identifier format: …` /
|
|
60
|
+
* `Invalid issue number in identifier: …` (malformed ref)
|
|
61
|
+
* Everything else — notably `GraphQL request failed: …` / `GraphQL query
|
|
62
|
+
* failed` from `graphql-service` — is treated as transport and fails open.
|
|
63
|
+
*
|
|
64
|
+
* This is a correctness distinction, not just telemetry: an unresolvable
|
|
65
|
+
* reference must BLOCK. Otherwise a typo'd `--related-to` on an SOP issue fails
|
|
66
|
+
* open, the issue is created, and the *follow-up* `createRelations` throws —
|
|
67
|
+
* leaving an orphan SOP on the board (the exact gap DEV-5378 cycle-1 caught). A
|
|
68
|
+
* transport error must FAIL OPEN so infra trouble can't block legitimate work.
|
|
69
|
+
*/
|
|
70
|
+
export function isUnresolvableReferenceError(err) {
|
|
71
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
72
|
+
return (message.includes("not found.") ||
|
|
73
|
+
message.includes("Invalid issue identifier format") ||
|
|
74
|
+
message.includes("Invalid issue number in identifier"));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Render the human/agent-facing block thrown when an SOP-labeled issue has no
|
|
78
|
+
* SOP parent. `reason` distinguishes "no parent at all" from "one or more
|
|
79
|
+
* references present but none resolves to an SOP-labeled issue", so the message
|
|
80
|
+
* points at the exact fix. When some references couldn't be resolved at all,
|
|
81
|
+
* `unresolvableRefs` names them (a typo'd ref reads differently from a real but
|
|
82
|
+
* non-SOP parent). Names the rule and the `--allow-unparented-sop` escape hatch.
|
|
83
|
+
*/
|
|
84
|
+
export function formatSopParentBlock(opts) {
|
|
85
|
+
const sopList = opts.sopLabels.join(", ");
|
|
86
|
+
const head = `SOP-labeled issue must point at a parent SOP (SOP label(s): ${sopList}).\n` +
|
|
87
|
+
" An SOP with no parent SOP is unfindable by `el-sop landscape` and breaks the catalog topology.\n";
|
|
88
|
+
let detail;
|
|
89
|
+
if (opts.reason === "no-parent") {
|
|
90
|
+
detail =
|
|
91
|
+
" This issue has no --parent or --related-to. Add one that points at an SOP-labeled issue.\n";
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
detail = ` No referenced issue resolves to an SOP-labeled issue (referenced: ${opts.parentRefs.join(", ")}).\n`;
|
|
95
|
+
if (opts.unresolvableRefs && opts.unresolvableRefs.length > 0) {
|
|
96
|
+
detail += ` Could not resolve: ${opts.unresolvableRefs.join(", ")} — check the identifier exists.\n`;
|
|
97
|
+
}
|
|
98
|
+
detail +=
|
|
99
|
+
" Point --parent or --related-to at an existing SOP-labeled issue.\n";
|
|
100
|
+
}
|
|
101
|
+
const hatch = " If this SOP is intentionally top-level, re-run with --allow-unparented-sop.";
|
|
102
|
+
return head + detail + hatch;
|
|
103
|
+
}
|
|
@@ -25,7 +25,13 @@ export declare function decideGateLedger(opts: {
|
|
|
25
25
|
export interface GateEvent {
|
|
26
26
|
/** Stable gate id, e.g. `issues-create-dup`. */
|
|
27
27
|
gate: string;
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* `blocked` — the gate stopped creation. `overridden` — a gate-specific
|
|
30
|
+
* override flag let a would-block proceed. `fail-open` — the gate could not
|
|
31
|
+
* evaluate (infra/service error) and let creation proceed rather than block
|
|
32
|
+
* on trouble; tracked so degradation is measurable (DEV-5378).
|
|
33
|
+
*/
|
|
34
|
+
outcome: "blocked" | "overridden" | "fail-open";
|
|
29
35
|
/** Highest candidate similarity that triggered the gate (0–1). */
|
|
30
36
|
topScore?: number;
|
|
31
37
|
/** How many candidates crossed the threshold. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.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",
|