@enrichlayer/el-linear 1.34.1 → 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 +10 -8
- package/claude-skills/linear-operations/SKILL.md +15 -0
- package/dist/commands/issue-id.js +5 -4
- package/dist/commands/issues.js +115 -1
- package/dist/commands/project-updates.d.ts +2 -0
- package/dist/commands/project-updates.js +116 -0
- 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/main.js +8 -2
- package/dist/queries/project-updates-types.d.ts +52 -0
- package/dist/queries/project-updates-types.js +8 -0
- package/dist/queries/project-updates.d.ts +3 -0
- package/dist/queries/project-updates.js +49 -0
- package/dist/utils/formatters/summary.d.ts +3 -1
- package/dist/utils/formatters/summary.js +46 -0
- package/dist/utils/gate-telemetry.d.ts +7 -1
- package/dist/utils/output.js +42 -17
- 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
|
|
|
@@ -445,6 +446,7 @@ el-linear <command> --help # detailed help for one command
|
|
|
445
446
|
| Comments | `comments {list, read, create, update, delete}` |
|
|
446
447
|
| Labels | `labels {list, create, retire, restore}` |
|
|
447
448
|
| Projects | `projects {list, add-team, remove-team}` |
|
|
449
|
+
| Project updates | `project-updates {create, list, read}` (post a status update to a project's Updates feed) |
|
|
448
450
|
| Cycles | `cycles {list, read}` |
|
|
449
451
|
| Documents | `documents {list, read, create, update, delete}` |
|
|
450
452
|
| Releases | `releases {list, read, create, pipelines}` |
|
|
@@ -578,7 +580,7 @@ el-linear projects list --format summary --fields name,state,progress,lead,teams
|
|
|
578
580
|
# ...
|
|
579
581
|
```
|
|
580
582
|
|
|
581
|
-
Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
|
|
583
|
+
Unrecognized field names are reported as a `_warnings:` line appended after the summary block (`fields_unprojectable: --format summary on issues list does not project foo, bar; ...`) — same signal scripts get on the JSON path. Resources whose summary formatter doesn't yet wire `--fields` (cycles, milestones, project updates, comments, teams, labels, users, documents, templates, attachments, releases, search results) emit the same warning and render their default summary.
|
|
582
584
|
|
|
583
585
|
### Windowed metadata (`WindowedMeta`)
|
|
584
586
|
|
|
@@ -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
|
|
@@ -22,11 +22,12 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
|
22
22
|
// elsewhere in the workspace. Any addition here is the single point of
|
|
23
23
|
// truth that all skills rely on.
|
|
24
24
|
//
|
|
25
|
-
// Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex;
|
|
26
|
-
// mirror tools-repo DEV-4417):
|
|
27
|
-
// feature | fix | chore | refactor | dev
|
|
25
|
+
// Accepted prefixes (DEV-4777 adds bug/spike; DEV-4660 added codex; DEV-5342
|
|
26
|
+
// adds the feat short-form alias; all mirror tools-repo DEV-4417/DEV-5334):
|
|
27
|
+
// feature | feat | fix | chore | refactor | dev — SOP-canonical + Linear-CLI direct
|
|
28
28
|
// bug | spike — sanctioned Linear type labels
|
|
29
29
|
// codex — Codex-authored branches (codex/<TEAM>-<N>-slug)
|
|
30
|
+
// feat — short-form alias for feature (DEV-5342)
|
|
30
31
|
// New authoring surfaces (Codex, future agent prefixes) and sanctioned Linear
|
|
31
32
|
// type labels (bug, spike) get first-class issue detection so commit guards,
|
|
32
33
|
// MR descriptions, and session handoff don't go dark on a branch a human
|
|
@@ -34,7 +35,7 @@ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
|
34
35
|
//
|
|
35
36
|
// Mirror of cli/el-git/src/commands/context.ts:BRANCH_RE in the
|
|
36
37
|
// vertical-int/tools repo. If you change one, change the other.
|
|
37
|
-
const BRANCH_RE = /^(?:feature|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
|
|
38
|
+
const BRANCH_RE = /^(?:feature|feat|fix|chore|refactor|bug|spike|dev|codex)[-/]([A-Z]{2,4})-(\d+)(?:[-/](.*))?$/i;
|
|
38
39
|
export function parseBranchName(branch) {
|
|
39
40
|
const m = branch.match(BRANCH_RE);
|
|
40
41
|
if (!m) {
|
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")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { CREATE_PROJECT_UPDATE_MUTATION, GET_PROJECT_UPDATE_BY_ID_QUERY, LIST_PROJECT_UPDATES_QUERY, } from "../queries/project-updates.js";
|
|
3
|
+
import { notFoundError } from "../utils/error-messages.js";
|
|
4
|
+
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
5
|
+
import { createLinearService } from "../utils/linear-service.js";
|
|
6
|
+
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
7
|
+
import { getRootOpts } from "../utils/root-opts.js";
|
|
8
|
+
import { parsePositiveInt } from "../utils/validators.js";
|
|
9
|
+
const VALID_HEALTH = [
|
|
10
|
+
"onTrack",
|
|
11
|
+
"atRisk",
|
|
12
|
+
"offTrack",
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the update body from `--body` / `--body-file` (mutually exclusive,
|
|
16
|
+
* exactly one required) — mirrors the comments-command contract so file-sourced
|
|
17
|
+
* bodies sidestep shell-quoting traps for markdown/tables/backticks.
|
|
18
|
+
*/
|
|
19
|
+
function resolveBody(options) {
|
|
20
|
+
if (options.body && options.bodyFile) {
|
|
21
|
+
throw new Error("--body and --body-file are mutually exclusive — pass one or the other");
|
|
22
|
+
}
|
|
23
|
+
if (options.bodyFile) {
|
|
24
|
+
return readFileSync(options.bodyFile, "utf-8");
|
|
25
|
+
}
|
|
26
|
+
if (options.body) {
|
|
27
|
+
return options.body;
|
|
28
|
+
}
|
|
29
|
+
throw new Error("Either --body or --body-file is required");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Validate an optional `--health` value against Linear's
|
|
33
|
+
* `ProjectUpdateHealthType` enum. Returns undefined when the flag is omitted
|
|
34
|
+
* (Linear then defaults the health), throws on an unknown value.
|
|
35
|
+
*/
|
|
36
|
+
function resolveHealth(value) {
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (!VALID_HEALTH.includes(value)) {
|
|
41
|
+
throw new Error(`Invalid --health "${value}" — expected one of: ${VALID_HEALTH.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
async function handleCreateProjectUpdate(options, command) {
|
|
46
|
+
// Validate local input first so bad --body/--health fails with zero network.
|
|
47
|
+
const body = resolveBody(options);
|
|
48
|
+
const health = resolveHealth(options.health);
|
|
49
|
+
const rootOpts = getRootOpts(command);
|
|
50
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
51
|
+
const linearService = await createLinearService(rootOpts);
|
|
52
|
+
const projectId = await linearService.resolveProjectId(options.project);
|
|
53
|
+
const input = { projectId, body };
|
|
54
|
+
if (health !== undefined) {
|
|
55
|
+
input.health = health;
|
|
56
|
+
}
|
|
57
|
+
if (options.diffHidden) {
|
|
58
|
+
input.isDiffHidden = true;
|
|
59
|
+
}
|
|
60
|
+
const result = await graphQLService.rawRequest(CREATE_PROJECT_UPDATE_MUTATION, { input });
|
|
61
|
+
if (!result.projectUpdateCreate.success ||
|
|
62
|
+
!result.projectUpdateCreate.projectUpdate) {
|
|
63
|
+
throw new Error(`Failed to create project update on project "${options.project}"`);
|
|
64
|
+
}
|
|
65
|
+
outputSuccess(result.projectUpdateCreate.projectUpdate);
|
|
66
|
+
}
|
|
67
|
+
async function handleListProjectUpdates(options, command) {
|
|
68
|
+
const rootOpts = getRootOpts(command);
|
|
69
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
70
|
+
const linearService = await createLinearService(rootOpts);
|
|
71
|
+
const projectId = await linearService.resolveProjectId(options.project);
|
|
72
|
+
const result = await graphQLService.rawRequest(LIST_PROJECT_UPDATES_QUERY, {
|
|
73
|
+
projectId,
|
|
74
|
+
first: parsePositiveInt(options.limit, "--limit"),
|
|
75
|
+
});
|
|
76
|
+
if (!result.project) {
|
|
77
|
+
throw notFoundError("Project", options.project);
|
|
78
|
+
}
|
|
79
|
+
const nodes = result.project.projectUpdates.nodes;
|
|
80
|
+
outputSuccess({ data: nodes, meta: { count: nodes.length } });
|
|
81
|
+
}
|
|
82
|
+
async function handleReadProjectUpdate(updateId, _options, command) {
|
|
83
|
+
const rootOpts = getRootOpts(command);
|
|
84
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
85
|
+
const result = await graphQLService.rawRequest(GET_PROJECT_UPDATE_BY_ID_QUERY, { id: updateId });
|
|
86
|
+
if (!result.projectUpdate) {
|
|
87
|
+
throw notFoundError("Project update", updateId);
|
|
88
|
+
}
|
|
89
|
+
outputSuccess(result.projectUpdate);
|
|
90
|
+
}
|
|
91
|
+
export function setupProjectUpdatesCommands(program) {
|
|
92
|
+
const projectUpdates = program
|
|
93
|
+
.command("project-updates")
|
|
94
|
+
.description("Project update (status post) operations");
|
|
95
|
+
projectUpdates.action(() => projectUpdates.help());
|
|
96
|
+
projectUpdates
|
|
97
|
+
.command("create")
|
|
98
|
+
.description("Post a status update to a project (appears in the project's Updates feed).")
|
|
99
|
+
.requiredOption("--project <project>", "project name or ID")
|
|
100
|
+
.option("--body <body>", "update body markdown (inline)")
|
|
101
|
+
.option("--body-file <path>", "read update body from file")
|
|
102
|
+
.option("--health <health>", "project health: onTrack | atRisk | offTrack (omit to leave unset)")
|
|
103
|
+
.option("--diff-hidden", "hide the progress diff on the update")
|
|
104
|
+
.option("-q, --quiet", "print one confirmation line (health url) instead of the full JSON")
|
|
105
|
+
.action(handleAsyncCommand(handleCreateProjectUpdate));
|
|
106
|
+
projectUpdates
|
|
107
|
+
.command("list")
|
|
108
|
+
.description("List status updates posted to a project (newest first).")
|
|
109
|
+
.requiredOption("--project <project>", "project name or ID")
|
|
110
|
+
.option("-l, --limit <number>", "limit results", "50")
|
|
111
|
+
.action(handleAsyncCommand(handleListProjectUpdates));
|
|
112
|
+
projectUpdates
|
|
113
|
+
.command("read <updateId>")
|
|
114
|
+
.description("Get a single project update by its ID.")
|
|
115
|
+
.action(handleAsyncCommand(handleReadProjectUpdate));
|
|
116
|
+
}
|
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
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -20,6 +20,7 @@ import { setupIssuesCommands } from "./commands/issues.js";
|
|
|
20
20
|
import { setupLabelsCommands } from "./commands/labels.js";
|
|
21
21
|
import { setupProfileCommands } from "./commands/profile.js";
|
|
22
22
|
import { setupProjectMilestonesCommands } from "./commands/project-milestones.js";
|
|
23
|
+
import { setupProjectUpdatesCommands } from "./commands/project-updates.js";
|
|
23
24
|
import { setupProjectsCommands } from "./commands/projects.js";
|
|
24
25
|
import { setupReadShortcut } from "./commands/read-shortcut.js";
|
|
25
26
|
import { setupRefsCommands } from "./commands/refs.js";
|
|
@@ -65,9 +66,13 @@ program
|
|
|
65
66
|
.option("--profile <name>", "named profile (under ~/.config/el-linear/profiles/<name>/) for this invocation. Overrides EL_LINEAR_PROFILE env + the on-disk active-profile marker.")
|
|
66
67
|
.option("--json", "output as JSON (default, accepted for compatibility)")
|
|
67
68
|
.option("--format <kind>", "output format: json (default, structured envelope) or summary (human-readable)", "json")
|
|
68
|
-
.option("--raw", "strip { data, meta } wrapper from list output — emit the array directly"
|
|
69
|
+
.option("--raw", "strip { data, meta } wrapper from list output — emit the array directly. " +
|
|
70
|
+
"Warnings (e.g. --fields fields_unresolved) can't ride on a bare array, so " +
|
|
71
|
+
"they are written to stderr prefixed `_warnings: `, keeping stdout a pure JSON array")
|
|
69
72
|
.option("--jq <filter>", "apply a jq filter to the JSON output")
|
|
70
|
-
.option("--fields <fields>", "filter output to specific fields (comma-separated)"
|
|
73
|
+
.option("--fields <fields>", "filter output to specific fields (comma-separated). Unresolved fields are " +
|
|
74
|
+
"emitted as null plus a `fields_unresolved:` warning — in the JSON " +
|
|
75
|
+
"envelope's `_warnings`, or on stderr when output is a bare array (with --raw)")
|
|
71
76
|
.option("--no-cache", "bypass the on-disk cache for `teams list` / `labels list` / `projects list`");
|
|
72
77
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
73
78
|
const rootOpts = actionCommand.optsWithGlobals();
|
|
@@ -143,6 +148,7 @@ setupReleasesCommands(program);
|
|
|
143
148
|
setupProjectsCommands(program);
|
|
144
149
|
setupCyclesCommands(program);
|
|
145
150
|
setupProjectMilestonesCommands(program);
|
|
151
|
+
setupProjectUpdatesCommands(program);
|
|
146
152
|
setupEmbedsCommands(program);
|
|
147
153
|
setupTeamsCommands(program);
|
|
148
154
|
setupTemplatesCommands(program);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed response shapes for the queries in `./project-updates.ts`.
|
|
3
|
+
* See `./issues-types.ts` for the rationale (ALL-937).
|
|
4
|
+
*
|
|
5
|
+
* All three queries (create / list / read) select the same
|
|
6
|
+
* `PROJECT_UPDATE_FRAGMENT`, so one node shape covers every consumer.
|
|
7
|
+
*/
|
|
8
|
+
export type ProjectUpdateHealth = "onTrack" | "atRisk" | "offTrack";
|
|
9
|
+
interface ProjectUpdateProjectRef {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
interface ProjectUpdateUserRef {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
displayName: string | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Mirrors `PROJECT_UPDATE_FRAGMENT` — the full selection set shared by the
|
|
20
|
+
* create mutation, the project-scoped list, and the by-id read.
|
|
21
|
+
*/
|
|
22
|
+
export interface ProjectUpdateNode {
|
|
23
|
+
id: string;
|
|
24
|
+
body: string | null;
|
|
25
|
+
health: ProjectUpdateHealth | null;
|
|
26
|
+
url: string | null;
|
|
27
|
+
slugId: string | null;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
editedAt: string | null;
|
|
31
|
+
user: ProjectUpdateUserRef | null;
|
|
32
|
+
project: ProjectUpdateProjectRef | null;
|
|
33
|
+
}
|
|
34
|
+
export interface CreateProjectUpdateResponse {
|
|
35
|
+
projectUpdateCreate: {
|
|
36
|
+
success: boolean;
|
|
37
|
+
projectUpdate: ProjectUpdateNode | null;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface ListProjectUpdatesResponse {
|
|
41
|
+
project: {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
projectUpdates: {
|
|
45
|
+
nodes: ProjectUpdateNode[];
|
|
46
|
+
};
|
|
47
|
+
} | null;
|
|
48
|
+
}
|
|
49
|
+
export interface GetProjectUpdateByIdResponse {
|
|
50
|
+
projectUpdate: ProjectUpdateNode | null;
|
|
51
|
+
}
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed response shapes for the queries in `./project-updates.ts`.
|
|
3
|
+
* See `./issues-types.ts` for the rationale (ALL-937).
|
|
4
|
+
*
|
|
5
|
+
* All three queries (create / list / read) select the same
|
|
6
|
+
* `PROJECT_UPDATE_FRAGMENT`, so one node shape covers every consumer.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const CREATE_PROJECT_UPDATE_MUTATION = "\n mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {\n projectUpdateCreate(input: $input) {\n success\n projectUpdate {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n }\n";
|
|
2
|
+
export declare const LIST_PROJECT_UPDATES_QUERY = "\n query ListProjectUpdates($projectId: String!, $first: Int!) {\n project(id: $projectId) {\n id\n name\n projectUpdates(first: $first, orderBy: createdAt) {\n nodes {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n }\n }\n";
|
|
3
|
+
export declare const GET_PROJECT_UPDATE_BY_ID_QUERY = "\n query GetProjectUpdate($id: String!) {\n projectUpdate(id: $id) {\n \n id\n body\n health\n url\n slugId\n createdAt\n updatedAt\n editedAt\n user {\n id\n name\n displayName\n }\n project {\n id\n name\n }\n\n }\n }\n";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const PROJECT_UPDATE_FRAGMENT = `
|
|
2
|
+
id
|
|
3
|
+
body
|
|
4
|
+
health
|
|
5
|
+
url
|
|
6
|
+
slugId
|
|
7
|
+
createdAt
|
|
8
|
+
updatedAt
|
|
9
|
+
editedAt
|
|
10
|
+
user {
|
|
11
|
+
id
|
|
12
|
+
name
|
|
13
|
+
displayName
|
|
14
|
+
}
|
|
15
|
+
project {
|
|
16
|
+
id
|
|
17
|
+
name
|
|
18
|
+
}
|
|
19
|
+
`;
|
|
20
|
+
export const CREATE_PROJECT_UPDATE_MUTATION = `
|
|
21
|
+
mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {
|
|
22
|
+
projectUpdateCreate(input: $input) {
|
|
23
|
+
success
|
|
24
|
+
projectUpdate {
|
|
25
|
+
${PROJECT_UPDATE_FRAGMENT}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
export const LIST_PROJECT_UPDATES_QUERY = `
|
|
31
|
+
query ListProjectUpdates($projectId: String!, $first: Int!) {
|
|
32
|
+
project(id: $projectId) {
|
|
33
|
+
id
|
|
34
|
+
name
|
|
35
|
+
projectUpdates(first: $first, orderBy: createdAt) {
|
|
36
|
+
nodes {
|
|
37
|
+
${PROJECT_UPDATE_FRAGMENT}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
export const GET_PROJECT_UPDATE_BY_ID_QUERY = `
|
|
44
|
+
query GetProjectUpdate($id: String!) {
|
|
45
|
+
projectUpdate(id: $id) {
|
|
46
|
+
${PROJECT_UPDATE_FRAGMENT}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
@@ -30,6 +30,8 @@ export declare function formatCycleSummary(cycle: Record<string, unknown>): stri
|
|
|
30
30
|
export declare function formatCycleList(cycles: unknown[]): string;
|
|
31
31
|
export declare function formatMilestoneSummary(milestone: Record<string, unknown>): string;
|
|
32
32
|
export declare function formatMilestoneList(milestones: unknown[]): string;
|
|
33
|
+
export declare function formatProjectUpdateSummary(update: Record<string, unknown>): string;
|
|
34
|
+
export declare function formatProjectUpdateList(updates: unknown[]): string;
|
|
33
35
|
export declare function formatTeamList(teams: unknown[]): string;
|
|
34
36
|
export declare function formatLabelList(labels: unknown[]): string;
|
|
35
37
|
/**
|
|
@@ -68,7 +70,7 @@ export declare function formatSearchResultList(results: unknown[]): string;
|
|
|
68
70
|
* the full payload. Lists fall back to a simple bulleted list.
|
|
69
71
|
*/
|
|
70
72
|
export declare function formatGenericSummary(value: unknown): string;
|
|
71
|
-
export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "issue-relation-list" | "empty-list" | "generic";
|
|
73
|
+
export type ResourceKind = "issue" | "issue-list" | "project" | "project-list" | "comment" | "comment-list" | "cycle" | "cycle-list" | "milestone" | "milestone-list" | "project-update" | "project-update-list" | "team-list" | "label-list" | "user" | "user-list" | "document" | "document-list" | "template" | "template-list" | "attachment-list" | "release" | "release-list" | "search-result-list" | "relation-list" | "issue-relation-list" | "empty-list" | "generic";
|
|
72
74
|
/**
|
|
73
75
|
* Heuristic — used by the central `outputSuccess` path which doesn't know
|
|
74
76
|
* which command produced the payload. Looks at the shape of the data to
|
|
@@ -655,6 +655,35 @@ export function formatMilestoneList(milestones) {
|
|
|
655
655
|
{ header: "PROJECT", minWidth: 7, extract: (m) => getName(m.project) },
|
|
656
656
|
], { emptyText: "(no milestones)", itemNoun: "milestone" });
|
|
657
657
|
}
|
|
658
|
+
// ── project updates ────────────────────────────────────────────
|
|
659
|
+
export function formatProjectUpdateSummary(update) {
|
|
660
|
+
const headerLine = `project-update ${s(update.id)}`;
|
|
661
|
+
const fields = [
|
|
662
|
+
{ label: "Health", value: s(update.health) },
|
|
663
|
+
{ label: "Project", value: getName(update.project) },
|
|
664
|
+
{ label: "Author", value: getName(update.user) },
|
|
665
|
+
{ label: "Created", value: s(update.createdAt) },
|
|
666
|
+
{ label: "URL", value: s(update.url) },
|
|
667
|
+
];
|
|
668
|
+
const header = renderHeader(fields);
|
|
669
|
+
const body = clipDescription(update.body);
|
|
670
|
+
const parts = [headerLine, header];
|
|
671
|
+
if (body)
|
|
672
|
+
parts.push("", body);
|
|
673
|
+
return parts.filter((p) => p !== "").join("\n");
|
|
674
|
+
}
|
|
675
|
+
export function formatProjectUpdateList(updates) {
|
|
676
|
+
return renderTable(updates.map((raw) => asObj(raw) ?? {}), [
|
|
677
|
+
{ header: "HEALTH", minWidth: 6, extract: (u) => s(u.health) },
|
|
678
|
+
{ header: "AUTHOR", minWidth: 6, extract: (u) => getName(u.user) },
|
|
679
|
+
{
|
|
680
|
+
header: "CREATED",
|
|
681
|
+
minWidth: 7,
|
|
682
|
+
extract: (u) => s(u.createdAt).slice(0, 10),
|
|
683
|
+
},
|
|
684
|
+
{ header: "URL", minWidth: 3, maxWidth: 60, extract: (u) => s(u.url) },
|
|
685
|
+
], { emptyText: "(no project updates)", itemNoun: "project update" });
|
|
686
|
+
}
|
|
658
687
|
// ── teams ──────────────────────────────────────────────────────
|
|
659
688
|
export function formatTeamList(teams) {
|
|
660
689
|
return renderTable(teams.map((raw) => asObj(raw) ?? {}), [
|
|
@@ -1036,6 +1065,10 @@ export function inferKindFromPayload(value) {
|
|
|
1036
1065
|
"progress" in obj &&
|
|
1037
1066
|
("state" in obj || "lead" in obj || "teams" in obj))
|
|
1038
1067
|
return "project";
|
|
1068
|
+
// Project updates carry body + createdAt + user like comments; `health` is
|
|
1069
|
+
// the distinguishing field, so this must precede the comment check.
|
|
1070
|
+
if ("body" in obj && "health" in obj)
|
|
1071
|
+
return "project-update";
|
|
1039
1072
|
if ("body" in obj && "createdAt" in obj && "user" in obj)
|
|
1040
1073
|
return "comment";
|
|
1041
1074
|
if (("number" in obj || "isActive" in obj) &&
|
|
@@ -1079,6 +1112,10 @@ function inferListKind(items) {
|
|
|
1079
1112
|
}
|
|
1080
1113
|
return "issue-list";
|
|
1081
1114
|
}
|
|
1115
|
+
// Project-update rows carry body + createdAt like comments; `health` is the
|
|
1116
|
+
// distinguishing field, so this must precede the comment-list check.
|
|
1117
|
+
if ("body" in sample && "health" in sample)
|
|
1118
|
+
return "project-update-list";
|
|
1082
1119
|
if ("body" in sample && "createdAt" in sample)
|
|
1083
1120
|
return "comment-list";
|
|
1084
1121
|
if ("progress" in sample && "name" in sample) {
|
|
@@ -1168,6 +1205,10 @@ export function dispatch(kind, payload, fields) {
|
|
|
1168
1205
|
return formatMilestoneSummary((obj ?? {}));
|
|
1169
1206
|
case "milestone-list":
|
|
1170
1207
|
return formatMilestoneList(list ?? []);
|
|
1208
|
+
case "project-update":
|
|
1209
|
+
return formatProjectUpdateSummary((obj ?? {}));
|
|
1210
|
+
case "project-update-list":
|
|
1211
|
+
return formatProjectUpdateList(list ?? []);
|
|
1171
1212
|
case "team-list":
|
|
1172
1213
|
return formatTeamList(list ?? []);
|
|
1173
1214
|
case "label-list":
|
|
@@ -1230,6 +1271,11 @@ export function formatLine(payload) {
|
|
|
1230
1271
|
if (kind === "comment" && obj) {
|
|
1231
1272
|
return `comment ${s(obj.id)}`;
|
|
1232
1273
|
}
|
|
1274
|
+
if (kind === "project-update" && obj) {
|
|
1275
|
+
// create doesn't carry an identifier/title — health + url are the
|
|
1276
|
+
// stable handles a caller needs (matches the summary header style).
|
|
1277
|
+
return `${s(obj.health)} ${s(obj.url)}`;
|
|
1278
|
+
}
|
|
1233
1279
|
if (kind === "relation-list") {
|
|
1234
1280
|
return formatRelationLine(payload);
|
|
1235
1281
|
}
|
|
@@ -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/dist/utils/output.js
CHANGED
|
@@ -78,6 +78,12 @@ function filterFields(obj, fields, unresolved) {
|
|
|
78
78
|
const perItem = new Set();
|
|
79
79
|
const projected = filterFields(item, fields, perItem);
|
|
80
80
|
for (const field of fields) {
|
|
81
|
+
// A primitive item short-circuits: filterFields returns it
|
|
82
|
+
// unchanged and records NOTHING in perItem, so every field counts
|
|
83
|
+
// as resolved here — a mixed array holding one primitive suppresses
|
|
84
|
+
// the unresolved warning for all fields. Acceptable: a primitive
|
|
85
|
+
// item can't meaningfully be projected, and heterogeneous lists
|
|
86
|
+
// legitimately have per-item gaps.
|
|
81
87
|
if (!perItem.has(field)) {
|
|
82
88
|
resolvedSomewhere.add(field);
|
|
83
89
|
}
|
|
@@ -208,20 +214,19 @@ export function outputSuccess(data) {
|
|
|
208
214
|
}
|
|
209
215
|
else if (output !== null && typeof output === "object") {
|
|
210
216
|
const obj = output;
|
|
211
|
-
if (
|
|
212
|
-
output = {
|
|
213
|
-
...obj,
|
|
214
|
-
data: filterFields(obj.data, fieldsFilter, unresolved),
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
else if (obj.data !== null &&
|
|
217
|
+
if (obj.data !== null &&
|
|
218
218
|
obj.data !== undefined &&
|
|
219
219
|
typeof obj.data === "object") {
|
|
220
|
-
// Envelope with
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
220
|
+
// Envelope with a `data` payload — project inside `data`,
|
|
221
|
+
// whether it's an array (list envelope) or an object (e.g.
|
|
222
|
+
// el-git context). Arrays ARE objects, so this single
|
|
223
|
+
// `typeof === "object"` check subsumes the former separate
|
|
224
|
+
// `Array.isArray(obj.data)` arm (DEV-5339 collapsed the two
|
|
225
|
+
// byte-identical branches); `filterFields` dispatches on
|
|
226
|
+
// array-vs-object internally. Paths are relative to `data` for
|
|
227
|
+
// every envelope shape. Before DEV-5323 the object case fell
|
|
228
|
+
// through to root filtering, so `--fields branch,issueId` on an
|
|
229
|
+
// envelope returned `{}`.
|
|
225
230
|
output = {
|
|
226
231
|
...obj,
|
|
227
232
|
data: filterFields(obj.data, fieldsFilter, unresolved),
|
|
@@ -261,11 +266,31 @@ export function outputSuccess(data) {
|
|
|
261
266
|
// fields_unprojectable surfaced from a prior dispatch) and embed
|
|
262
267
|
// them as `_warnings` on the envelope.
|
|
263
268
|
const warnings = [...drainWarnings(), ...drainSummaryFieldWarnings()];
|
|
264
|
-
if (warnings.length > 0
|
|
265
|
-
output !== null &&
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
+
if (warnings.length > 0) {
|
|
270
|
+
if (output !== null &&
|
|
271
|
+
typeof output === "object" &&
|
|
272
|
+
!Array.isArray(output)) {
|
|
273
|
+
output = { ...output, _warnings: warnings };
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Bare-array (or primitive / null) output has no envelope object to
|
|
277
|
+
// carry `_warnings`. This is the DEV-5339 fix: previously the buffer
|
|
278
|
+
// was drained above but only re-embedded for object output, so a
|
|
279
|
+
// warning was silently dropped on a top-level array payload AND on
|
|
280
|
+
// `--raw` (which unwraps { data: [...] } to a bare array *before*
|
|
281
|
+
// this point) — losing exactly the DEV-5323 `fields_unresolved:`
|
|
282
|
+
// fail-visible signal on the `--raw` form our own CLAUDE.md
|
|
283
|
+
// recommends to agents. Route each warning to STDERR prefixed
|
|
284
|
+
// `_warnings: ` so the signal always reaches the consumer while
|
|
285
|
+
// stdout stays a pure JSON array (safe to pipe to `jq`). Mirrors the
|
|
286
|
+
// summary path's `_warnings:` line convention — that path uses
|
|
287
|
+
// logger.info because its stdout is already human text; here stdout
|
|
288
|
+
// must remain machine-parseable JSON, so we use logger.error
|
|
289
|
+
// (stderr).
|
|
290
|
+
for (const w of warnings) {
|
|
291
|
+
logger.error(`_warnings: ${w}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
269
294
|
}
|
|
270
295
|
if (jqFilter) {
|
|
271
296
|
const json = JSON.stringify(output);
|
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",
|