@enrichlayer/el-linear 1.39.0 → 1.41.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 +5 -1
- package/claude-skills/linear-operations/SKILL.md +1 -1
- package/dist/commands/issues/description.d.ts +3 -0
- package/dist/commands/issues/description.js +5 -8
- package/dist/commands/issues.js +41 -1
- package/dist/commands/projects.d.ts +24 -0
- package/dist/commands/projects.js +54 -5
- package/dist/config/config.d.ts +12 -0
- package/dist/config/intake-decision-validation.d.ts +54 -0
- package/dist/config/intake-decision-validation.js +161 -0
- package/dist/utils/extract-field.d.ts +2 -0
- package/dist/utils/extract-field.js +63 -9
- package/dist/utils/text-input-file.d.ts +23 -0
- package/dist/utils/text-input-file.js +32 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -275,7 +275,11 @@ itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
|
275
275
|
[SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate),
|
|
276
276
|
and an opt-in
|
|
277
277
|
[goal-completion gate](./docs/configuration.md#goal-completion-gate-validationgoalcompletiongate)
|
|
278
|
-
(requires a falsifiable "Done when" / acceptance-criteria section)
|
|
278
|
+
(requires a falsifiable "Done when" / acceptance-criteria section), plus an
|
|
279
|
+
opt-in
|
|
280
|
+
[intake-decision gate](./docs/configuration.md#intake-decision-gate-validationintakedecisiongate)
|
|
281
|
+
that requires need, value, ownership, and placement to be decided before issue
|
|
282
|
+
creation.
|
|
279
283
|
el-linear can record each gate's fire/override decision to a local JSONL file so
|
|
280
284
|
you can measure its **override-rate** and tell whether it's too aggressive. It is
|
|
281
285
|
**off by default** and writes nothing unless you opt in (e.g.
|
|
@@ -447,7 +447,7 @@ Run `el-linear usage` for the full command reference. Non-obvious rules:
|
|
|
447
447
|
- **Subcommand aliases** — `read`/`view`/`get`/`show`, `update`/`edit`/`set`.
|
|
448
448
|
- **`--jq` for GraphQL filtering** — never pipe through `jq` directly (zsh escaping breaks `!=`).
|
|
449
449
|
- **`--raw` flag** strips the `{ data, meta }` wrapper — emits just the array.
|
|
450
|
-
- **Body/description from a file** — `issues create`/`update` take `--description-file <path>`; `comments create`/`update` take `--body-file <path
|
|
450
|
+
- **Body/description/content from a file** — `issues create`/`update` take `--description-file <path>`; `comments create`/`update` take `--body-file <path>`; **`projects create`/`update` take `--content-file <path>`** (the project's full markdown **content**, not its short `description` — see the two-field gotcha under *Project Management Gotchas*). Prefer the file form for any body with backticks, fenced code, or markdown tables — it sidesteps shell-quoting traps (the same reason `el-git mr comment --body-file` exists). The inline and file flags are **mutually exclusive** (passing both errors); file-sourced bodies get the same auto-link / auto-mention treatment as inline text.
|
|
451
451
|
|
|
452
452
|
### Output format
|
|
453
453
|
|
|
@@ -23,6 +23,9 @@ import type { LinearService } from "../../utils/linear-service.js";
|
|
|
23
23
|
/**
|
|
24
24
|
* Read description from a file path or stdin ("-").
|
|
25
25
|
* Avoids shell escaping issues when descriptions contain special characters.
|
|
26
|
+
*
|
|
27
|
+
* Delegates to the shared reader so `projects --content-file` (DEV-6033), which
|
|
28
|
+
* is specified to behave identically, cannot drift from this one.
|
|
26
29
|
*/
|
|
27
30
|
export declare function readDescriptionFile(filePath: string): string;
|
|
28
31
|
/**
|
|
@@ -16,27 +16,24 @@
|
|
|
16
16
|
* Extracted from `commands/issues.ts` (ALL-938) so that file can
|
|
17
17
|
* focus on commander wiring + handlers.
|
|
18
18
|
*/
|
|
19
|
-
import fs from "node:fs";
|
|
20
19
|
import { loadConfig } from "../../config/config.js";
|
|
21
20
|
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
22
21
|
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
23
22
|
import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
|
|
24
23
|
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
25
24
|
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
|
+
import { readTextInputFile } from "../../utils/text-input-file.js";
|
|
26
26
|
import { validateReferences } from "../../utils/validate-references.js";
|
|
27
27
|
import { getWorkspaceUrlKey } from "../../utils/workspace-url.js";
|
|
28
28
|
/**
|
|
29
29
|
* Read description from a file path or stdin ("-").
|
|
30
30
|
* Avoids shell escaping issues when descriptions contain special characters.
|
|
31
|
+
*
|
|
32
|
+
* Delegates to the shared reader so `projects --content-file` (DEV-6033), which
|
|
33
|
+
* is specified to behave identically, cannot drift from this one.
|
|
31
34
|
*/
|
|
32
35
|
export function readDescriptionFile(filePath) {
|
|
33
|
-
|
|
34
|
-
return fs.readFileSync(0, "utf8").trim();
|
|
35
|
-
}
|
|
36
|
-
if (!fs.existsSync(filePath)) {
|
|
37
|
-
throw new Error(`Description file not found: ${filePath}`);
|
|
38
|
-
}
|
|
39
|
-
return fs.readFileSync(filePath, "utf8").trim();
|
|
36
|
+
return readTextInputFile(filePath, "Description");
|
|
40
37
|
}
|
|
41
38
|
/**
|
|
42
39
|
* Resolve the description from --description, --description-file, or
|
package/dist/commands/issues.js
CHANGED
|
@@ -2,6 +2,7 @@ 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 { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
|
|
5
|
+
import { evaluateIntakeDecision, formatIntakeDecisionBlock, getIntakeDecisionGateConfig, } from "../config/intake-decision-validation.js";
|
|
5
6
|
import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
|
|
6
7
|
import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
|
|
7
8
|
import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
|
|
@@ -762,6 +763,43 @@ async function enforceGoalCompletion(description, options) {
|
|
|
762
763
|
});
|
|
763
764
|
throw new Error(`Issue creation blocked: ${message}`);
|
|
764
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* DEV-6163: require the intake judgment before any create-time resolution or
|
|
768
|
+
* mutation. Unlike general field validation, this gate is not silently
|
|
769
|
+
* bypassed by `--skip-validation`; only its narrow, recorded override applies.
|
|
770
|
+
*/
|
|
771
|
+
async function enforceIntakeDecision(description, options) {
|
|
772
|
+
const { mode, headers } = getIntakeDecisionGateConfig();
|
|
773
|
+
if (mode === "off") {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const evaluation = evaluateIntakeDecision(description, headers);
|
|
777
|
+
if (evaluation.ok) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const gateEvent = { gate: "issues-create-intake-decision" };
|
|
781
|
+
if (options.allowMissingIntakeDecision) {
|
|
782
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
783
|
+
...gateEvent,
|
|
784
|
+
outcome: "overridden",
|
|
785
|
+
});
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const message = formatIntakeDecisionBlock({ evaluation, headers });
|
|
789
|
+
if (mode === "warn") {
|
|
790
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
791
|
+
...gateEvent,
|
|
792
|
+
outcome: "advisory",
|
|
793
|
+
});
|
|
794
|
+
outputWarning(message);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
798
|
+
...gateEvent,
|
|
799
|
+
outcome: "blocked",
|
|
800
|
+
});
|
|
801
|
+
throw new Error(`Issue creation blocked: ${message}`);
|
|
802
|
+
}
|
|
765
803
|
async function handleCreateIssue(title, options, command) {
|
|
766
804
|
const rootOpts = getRootOpts(command);
|
|
767
805
|
// DEV-5920 (cycle-2): resolve the description exactly ONCE, before anything
|
|
@@ -779,6 +817,7 @@ async function handleCreateIssue(title, options, command) {
|
|
|
779
817
|
// normalization) — re-resolving an inline copy would corrupt a file that
|
|
780
818
|
// intentionally contains backslash sequences.
|
|
781
819
|
const rawDescription = resolveDescription(options);
|
|
820
|
+
await enforceIntakeDecision(rawDescription ?? "", options);
|
|
782
821
|
const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts, rawDescription);
|
|
783
822
|
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
784
823
|
const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
|
|
@@ -1401,10 +1440,11 @@ export function setupIssuesCommands(program) {
|
|
|
1401
1440
|
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1402
1441
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1403
1442
|
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1404
|
-
.option("--skip-validation", "skip
|
|
1443
|
+
.option("--skip-validation", "skip general validation (labels, description, assignee, project, duplicate detection, SOP-parent gate, goal-completion gate); the intake-decision gate still requires its narrow override")
|
|
1405
1444
|
.option("--allow-duplicate", "silence the duplicate-detection hard block and create even if a near-identical issue already exists (DEV-5590: only matters at/above the hard-block threshold — below it the gate is advisory-only and never blocks)")
|
|
1406
1445
|
.option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
|
|
1407
1446
|
.option("--allow-vague-goal", 'skip the goal-completion gate and create even without a falsifiable "Done when" / acceptance-criteria section')
|
|
1447
|
+
.option("--allow-missing-intake-decision", "create without a complete intake decision when an accountable human approved the exception (recorded as a gate override)")
|
|
1408
1448
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1409
1449
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
1410
1450
|
.option("--no-footer", "skip the configured messageFooter for this issue")
|
|
@@ -5,4 +5,28 @@ export declare function resolveProjectStateFilter(options: OptionValues): {
|
|
|
5
5
|
states?: string[];
|
|
6
6
|
excludeStates?: string[];
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the project body from `--content <markdown>` or `--content-file <path>`
|
|
10
|
+
* (DEV-6033). The two are sources for the same field, so accepting both would
|
|
11
|
+
* silently drop one — we REJECT up front instead.
|
|
12
|
+
*
|
|
13
|
+
* That matches `comments --body-file` and `project-updates --body-file`, and it
|
|
14
|
+
* deliberately DIVERGES from `issues --description-file`, which documents a
|
|
15
|
+
* precedence (`--description-file` > `--description`) and therefore lets the file
|
|
16
|
+
* silently win when both are passed. Rejecting is the better contract — a caller
|
|
17
|
+
* who passes both has a bug, and telling them beats guessing — but do not describe
|
|
18
|
+
* this as "mirroring" issues: it is not, and a false claim about a sibling's
|
|
19
|
+
* mechanism outlives the person who wrote it.
|
|
20
|
+
*
|
|
21
|
+
* Returns `undefined` only when NEITHER flag was passed, which is what lets
|
|
22
|
+
* `projects update` keep distinguishing "leave content alone" from "clear it"
|
|
23
|
+
* (`--content ""` yields `""`, and `"" !== undefined`, so it still reaches the
|
|
24
|
+
* mutation — the same distinction `hasOption` drew before).
|
|
25
|
+
*
|
|
26
|
+
* Inline `--content` is passed through verbatim, exactly as it was before this
|
|
27
|
+
* flag existed. It is deliberately NOT run through `normalizeInlineTextInput`:
|
|
28
|
+
* that would newly rewrite `\n` inside an existing caller's `--content` string,
|
|
29
|
+
* a behavior change to a shipped flag that this issue does not ask for.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveProjectContent(options: OptionValues): string | undefined;
|
|
8
32
|
export declare function setupProjectsCommands(program: Command): void;
|
|
@@ -8,6 +8,7 @@ import { logger } from "../utils/logger.js";
|
|
|
8
8
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
9
9
|
import { effectiveOption, getRootOpts } from "../utils/root-opts.js";
|
|
10
10
|
import { renderCsv, renderFixedWidthTable, renderMarkdownTable, } from "../utils/table-formatter.js";
|
|
11
|
+
import { readTextInputFile } from "../utils/text-input-file.js";
|
|
11
12
|
import { isUuid } from "../utils/uuid.js";
|
|
12
13
|
import { parsePositiveInt, splitList } from "../utils/validators.js";
|
|
13
14
|
const VALID_PROJECT_STATES = new Set([
|
|
@@ -232,6 +233,43 @@ function formatTeamsOutput(projectUpdate) {
|
|
|
232
233
|
function hasOption(options, key) {
|
|
233
234
|
return options[key] !== undefined;
|
|
234
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Resolve the project body from `--content <markdown>` or `--content-file <path>`
|
|
238
|
+
* (DEV-6033). The two are sources for the same field, so accepting both would
|
|
239
|
+
* silently drop one — we REJECT up front instead.
|
|
240
|
+
*
|
|
241
|
+
* That matches `comments --body-file` and `project-updates --body-file`, and it
|
|
242
|
+
* deliberately DIVERGES from `issues --description-file`, which documents a
|
|
243
|
+
* precedence (`--description-file` > `--description`) and therefore lets the file
|
|
244
|
+
* silently win when both are passed. Rejecting is the better contract — a caller
|
|
245
|
+
* who passes both has a bug, and telling them beats guessing — but do not describe
|
|
246
|
+
* this as "mirroring" issues: it is not, and a false claim about a sibling's
|
|
247
|
+
* mechanism outlives the person who wrote it.
|
|
248
|
+
*
|
|
249
|
+
* Returns `undefined` only when NEITHER flag was passed, which is what lets
|
|
250
|
+
* `projects update` keep distinguishing "leave content alone" from "clear it"
|
|
251
|
+
* (`--content ""` yields `""`, and `"" !== undefined`, so it still reaches the
|
|
252
|
+
* mutation — the same distinction `hasOption` drew before).
|
|
253
|
+
*
|
|
254
|
+
* Inline `--content` is passed through verbatim, exactly as it was before this
|
|
255
|
+
* flag existed. It is deliberately NOT run through `normalizeInlineTextInput`:
|
|
256
|
+
* that would newly rewrite `\n` inside an existing caller's `--content` string,
|
|
257
|
+
* a behavior change to a shipped flag that this issue does not ask for.
|
|
258
|
+
*/
|
|
259
|
+
export function resolveProjectContent(options) {
|
|
260
|
+
const hasInline = typeof options.content === "string";
|
|
261
|
+
const hasFile = typeof options.contentFile === "string";
|
|
262
|
+
if (hasInline && hasFile) {
|
|
263
|
+
throw new Error("--content and --content-file are mutually exclusive — pass one or the other");
|
|
264
|
+
}
|
|
265
|
+
if (hasFile) {
|
|
266
|
+
return readTextInputFile(options.contentFile, "Content");
|
|
267
|
+
}
|
|
268
|
+
if (hasInline) {
|
|
269
|
+
return options.content;
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
235
273
|
function flattenProjectUpdate(projectUpdate) {
|
|
236
274
|
if (!projectUpdate.project) {
|
|
237
275
|
throw new Error("Failed to update project");
|
|
@@ -345,6 +383,11 @@ async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
|
|
|
345
383
|
});
|
|
346
384
|
}
|
|
347
385
|
async function handleCreateProject(name, options, command) {
|
|
386
|
+
// Resolve the body BEFORE the create mutation. An unreadable --content-file
|
|
387
|
+
// (or --content alongside it) must fail while nothing has been created yet —
|
|
388
|
+
// resolving after the create would leave an orphan project behind and then
|
|
389
|
+
// throw, which is the worst of both outcomes.
|
|
390
|
+
const content = resolveProjectContent(options);
|
|
348
391
|
const rootOpts = getRootOpts(command);
|
|
349
392
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
350
393
|
// Step 1: Check for duplicate projects (case-insensitive)
|
|
@@ -376,10 +419,10 @@ async function handleCreateProject(name, options, command) {
|
|
|
376
419
|
}
|
|
377
420
|
const project = createResult.projectCreate.project;
|
|
378
421
|
// Step 4: Set content if provided (separate mutation — Linear API quirk)
|
|
379
|
-
if (
|
|
422
|
+
if (content) {
|
|
380
423
|
await graphQLService.rawRequest(UPDATE_PROJECT_MUTATION, {
|
|
381
424
|
id: project.id,
|
|
382
|
-
input: { content
|
|
425
|
+
input: { content },
|
|
383
426
|
});
|
|
384
427
|
}
|
|
385
428
|
const teamList = project.teams.nodes.map((t) => t.key).join(", ");
|
|
@@ -465,11 +508,15 @@ async function handleUpdateProject(projectNameOrId, options, command) {
|
|
|
465
508
|
if (hasOption(options, "description")) {
|
|
466
509
|
input.description = options.description;
|
|
467
510
|
}
|
|
468
|
-
|
|
469
|
-
|
|
511
|
+
// `undefined` means neither --content nor --content-file was passed. An
|
|
512
|
+
// explicit `--content ""` resolves to "" and still lands here, so clearing a
|
|
513
|
+
// project's body keeps working exactly as it did under `hasOption`.
|
|
514
|
+
const content = resolveProjectContent(options);
|
|
515
|
+
if (content !== undefined) {
|
|
516
|
+
input.content = content;
|
|
470
517
|
}
|
|
471
518
|
if (Object.keys(input).length === 0) {
|
|
472
|
-
throw new Error("Nothing to update. Pass at least one of --name, --description, or --content.");
|
|
519
|
+
throw new Error("Nothing to update. Pass at least one of --name, --description, --content, or --content-file.");
|
|
473
520
|
}
|
|
474
521
|
const rootOpts = getRootOpts(command);
|
|
475
522
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
@@ -494,6 +541,7 @@ export function setupProjectsCommands(program) {
|
|
|
494
541
|
.option("--team <teams>", "comma-separated team keys (e.g., FE,DEV)")
|
|
495
542
|
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
496
543
|
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
544
|
+
.option("--content-file <path>", "read the markdown body from a file (or '-' for stdin); mutually exclusive with --content")
|
|
497
545
|
.option("--force", "create even if a project with the same name exists")
|
|
498
546
|
.action(handleAsyncCommand(handleCreateProject));
|
|
499
547
|
projects
|
|
@@ -514,6 +562,7 @@ export function setupProjectsCommands(program) {
|
|
|
514
562
|
.option("--name <name>", "project name")
|
|
515
563
|
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
516
564
|
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
565
|
+
.option("--content-file <path>", "read the markdown body from a file (or '-' for stdin); mutually exclusive with --content")
|
|
517
566
|
.action(handleAsyncCommand(handleUpdateProject));
|
|
518
567
|
projects
|
|
519
568
|
.command("list")
|
package/dist/config/config.d.ts
CHANGED
|
@@ -115,6 +115,18 @@ export interface ElLinearConfig {
|
|
|
115
115
|
* "Acceptance criteria", "Success criteria"]`.
|
|
116
116
|
*/
|
|
117
117
|
goalSectionHeaders?: string[];
|
|
118
|
+
/**
|
|
119
|
+
* OPT-IN explicit intake gate (DEV-6163). When `"warn"` or `"block"`,
|
|
120
|
+
* `issues create` requires an ordered `Intake decision` section recording
|
|
121
|
+
* why the work is needed, why it is worth doing, the existing/duplicate
|
|
122
|
+
* work checked, its canonical owner, concrete placement, and a `PROCEED`
|
|
123
|
+
* decision. Defaults to off for the
|
|
124
|
+
* open-source package. A narrow, recorded override is available through
|
|
125
|
+
* `--allow-missing-intake-decision`.
|
|
126
|
+
*/
|
|
127
|
+
intakeDecisionGate?: false | "warn" | "block";
|
|
128
|
+
/** Headers accepted for the intake section. Defaults to `["Intake decision"]`. */
|
|
129
|
+
intakeSectionHeaders?: string[];
|
|
118
130
|
};
|
|
119
131
|
/**
|
|
120
132
|
* Optional override for the Linear workspace URL key (the part after
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit issue-intake validation — DEV-6163.
|
|
3
|
+
*
|
|
4
|
+
* This gate does not try to decide whether work is worthwhile or where it
|
|
5
|
+
* belongs. It requires the author to record those judgments, in order, before
|
|
6
|
+
* `issues create` can mutate Linear. The deterministic part is completeness:
|
|
7
|
+
* needed, worth doing, existing/duplicate work checked, canonical owner,
|
|
8
|
+
* concrete placement, then a proceed decision.
|
|
9
|
+
*
|
|
10
|
+
* OPT-IN by design. el-linear is open source, so the gate is dormant unless a
|
|
11
|
+
* workspace config sets `validation.intakeDecisionGate` to `"warn"` or
|
|
12
|
+
* `"block"`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_INTAKE_SECTION_HEADERS: string[];
|
|
15
|
+
export type IntakeDecisionGateMode = "off" | "warn" | "block";
|
|
16
|
+
export interface IntakeDecisionGateConfig {
|
|
17
|
+
mode: IntakeDecisionGateMode;
|
|
18
|
+
headers: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare function getIntakeDecisionGateConfig(): IntakeDecisionGateConfig;
|
|
21
|
+
export type IntakeDecisionEvaluation = {
|
|
22
|
+
ok: true;
|
|
23
|
+
header: string;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: "no-section";
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
reason: "missing-field";
|
|
30
|
+
field: string;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
reason: "duplicate-field";
|
|
34
|
+
field: string;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
reason: "out-of-order";
|
|
38
|
+
field: string;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
reason: "invalid-field";
|
|
42
|
+
field: string;
|
|
43
|
+
} | {
|
|
44
|
+
ok: false;
|
|
45
|
+
reason: "not-proceeding";
|
|
46
|
+
decision: string;
|
|
47
|
+
};
|
|
48
|
+
export declare function evaluateIntakeDecision(description: string, headers?: string[]): IntakeDecisionEvaluation;
|
|
49
|
+
export declare function formatIntakeDecisionBlock(opts: {
|
|
50
|
+
evaluation: Exclude<IntakeDecisionEvaluation, {
|
|
51
|
+
ok: true;
|
|
52
|
+
}>;
|
|
53
|
+
headers: string[];
|
|
54
|
+
}): string;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit issue-intake validation — DEV-6163.
|
|
3
|
+
*
|
|
4
|
+
* This gate does not try to decide whether work is worthwhile or where it
|
|
5
|
+
* belongs. It requires the author to record those judgments, in order, before
|
|
6
|
+
* `issues create` can mutate Linear. The deterministic part is completeness:
|
|
7
|
+
* needed, worth doing, existing/duplicate work checked, canonical owner,
|
|
8
|
+
* concrete placement, then a proceed decision.
|
|
9
|
+
*
|
|
10
|
+
* OPT-IN by design. el-linear is open source, so the gate is dormant unless a
|
|
11
|
+
* workspace config sets `validation.intakeDecisionGate` to `"warn"` or
|
|
12
|
+
* `"block"`.
|
|
13
|
+
*/
|
|
14
|
+
import { extractField, stripFencedCodeBlocks } from "../utils/extract-field.js";
|
|
15
|
+
import { loadConfig } from "./config.js";
|
|
16
|
+
export const DEFAULT_INTAKE_SECTION_HEADERS = ["Intake decision"];
|
|
17
|
+
export function getIntakeDecisionGateConfig() {
|
|
18
|
+
const validation = loadConfig().validation;
|
|
19
|
+
const raw = validation?.intakeDecisionGate;
|
|
20
|
+
const mode = validation?.enabled !== false && (raw === "warn" || raw === "block")
|
|
21
|
+
? raw
|
|
22
|
+
: "off";
|
|
23
|
+
const headers = validation?.intakeSectionHeaders &&
|
|
24
|
+
validation.intakeSectionHeaders.length > 0
|
|
25
|
+
? validation.intakeSectionHeaders
|
|
26
|
+
: DEFAULT_INTAKE_SECTION_HEADERS;
|
|
27
|
+
return { mode, headers };
|
|
28
|
+
}
|
|
29
|
+
const FIELD_DEFINITIONS = [
|
|
30
|
+
{ key: "needed", label: "Needed" },
|
|
31
|
+
{ key: "worth", label: "Worth doing" },
|
|
32
|
+
{ key: "existing", label: "Existing work" },
|
|
33
|
+
{ key: "owner", label: "Owner" },
|
|
34
|
+
{ key: "placement", label: "Placement" },
|
|
35
|
+
{ key: "decision", label: "Decision" },
|
|
36
|
+
];
|
|
37
|
+
const FIELD_LINE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)?(?:\*\*)?(Needed|Worth doing|Existing work|Owner|Placement|Decision)(?:\*\*)?\s*:\s*(.*?)\s*$/gim;
|
|
38
|
+
const PLACEHOLDER = /^(?:tbd|todo|unknown|n\/?a|none|unsure|not decided|-)\.?$/i;
|
|
39
|
+
const NON_SPECIFIC = /^(?:yes|no)$/i;
|
|
40
|
+
const AFFIRMATIVE_WITH_REASON = /^yes\s*(?:[-—:;,]|because)\s*(\S.{2,})$/i;
|
|
41
|
+
function normalizedKey(label) {
|
|
42
|
+
switch (label.toLowerCase()) {
|
|
43
|
+
case "needed":
|
|
44
|
+
return "needed";
|
|
45
|
+
case "worth doing":
|
|
46
|
+
return "worth";
|
|
47
|
+
case "existing work":
|
|
48
|
+
return "existing";
|
|
49
|
+
case "owner":
|
|
50
|
+
return "owner";
|
|
51
|
+
case "placement":
|
|
52
|
+
return "placement";
|
|
53
|
+
default:
|
|
54
|
+
return "decision";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function evaluateIntakeDecision(description, headers = DEFAULT_INTAKE_SECTION_HEADERS) {
|
|
58
|
+
let section = null;
|
|
59
|
+
let matchedHeader = "";
|
|
60
|
+
for (const header of headers) {
|
|
61
|
+
section = extractField(description, header);
|
|
62
|
+
if (section !== null) {
|
|
63
|
+
matchedHeader = header;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (section === null) {
|
|
68
|
+
return { ok: false, reason: "no-section" };
|
|
69
|
+
}
|
|
70
|
+
// A template/example fence is not an operative decision record. Remove all
|
|
71
|
+
// fenced examples before matching so copied guidance cannot satisfy intake.
|
|
72
|
+
const operativeSection = stripFencedCodeBlocks(section);
|
|
73
|
+
const values = new Map();
|
|
74
|
+
for (const match of operativeSection.matchAll(FIELD_LINE)) {
|
|
75
|
+
const key = normalizedKey(match[1] ?? "");
|
|
76
|
+
if (values.has(key)) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: "duplicate-field",
|
|
80
|
+
field: FIELD_DEFINITIONS.find((field) => field.key === key)?.label ?? key,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
values.set(key, { value: match[2]?.trim() ?? "", index: match.index });
|
|
84
|
+
}
|
|
85
|
+
let previousIndex = -1;
|
|
86
|
+
for (const definition of FIELD_DEFINITIONS) {
|
|
87
|
+
const entry = values.get(definition.key);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: "missing-field",
|
|
92
|
+
field: definition.label,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (entry.index < previousIndex) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: "out-of-order",
|
|
99
|
+
field: definition.label,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
previousIndex = entry.index;
|
|
103
|
+
}
|
|
104
|
+
for (const key of ["needed", "worth"]) {
|
|
105
|
+
const value = values.get(key)?.value ?? "";
|
|
106
|
+
const match = value.match(AFFIRMATIVE_WITH_REASON);
|
|
107
|
+
const reason = match?.[1]?.trim() ?? "";
|
|
108
|
+
if (!match || PLACEHOLDER.test(reason)) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: "invalid-field",
|
|
112
|
+
field: key === "needed" ? "Needed" : "Worth doing",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const key of ["existing", "owner", "placement"]) {
|
|
117
|
+
const value = values.get(key)?.value ?? "";
|
|
118
|
+
if (value.length < 3 ||
|
|
119
|
+
PLACEHOLDER.test(value) ||
|
|
120
|
+
NON_SPECIFIC.test(value)) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: "invalid-field",
|
|
124
|
+
field: key === "existing"
|
|
125
|
+
? "Existing work"
|
|
126
|
+
: key === "owner"
|
|
127
|
+
? "Owner"
|
|
128
|
+
: "Placement",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const decision = values.get("decision")?.value ?? "";
|
|
133
|
+
if (!/^proceed$/i.test(decision)) {
|
|
134
|
+
return { ok: false, reason: "not-proceeding", decision };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, header: matchedHeader };
|
|
137
|
+
}
|
|
138
|
+
export function formatIntakeDecisionBlock(opts) {
|
|
139
|
+
const { evaluation } = opts;
|
|
140
|
+
let reason;
|
|
141
|
+
switch (evaluation.reason) {
|
|
142
|
+
case "no-section":
|
|
143
|
+
reason = `Issue description has no intake section (looked for: ${opts.headers.join(", ")}).`;
|
|
144
|
+
break;
|
|
145
|
+
case "missing-field":
|
|
146
|
+
reason = `The intake decision is missing the "${evaluation.field}" field.`;
|
|
147
|
+
break;
|
|
148
|
+
case "duplicate-field":
|
|
149
|
+
reason = `The intake decision repeats the "${evaluation.field}" field; record one unambiguous value.`;
|
|
150
|
+
break;
|
|
151
|
+
case "out-of-order":
|
|
152
|
+
reason = `The intake fields are out of order at "${evaluation.field}".`;
|
|
153
|
+
break;
|
|
154
|
+
case "invalid-field":
|
|
155
|
+
reason = `The intake field "${evaluation.field}" is empty, a placeholder, or lacks an explicit yes-and-reason judgment.`;
|
|
156
|
+
break;
|
|
157
|
+
case "not-proceeding":
|
|
158
|
+
reason = `The intake decision is "${evaluation.decision || "empty"}", not PROCEED.`;
|
|
159
|
+
}
|
|
160
|
+
return `${reason}\n\nRecord the decision before creating the issue, in this exact order:\n\n## ${opts.headers[0]}\n- Needed: Yes — <why this is needed>\n- Worth doing: Yes — <why the value exceeds the cost>\n- Existing work: <duplicate/search result and evidence>\n- Owner: <canonical owner or source of truth>\n- Placement: <team/project/repository/document path>\n- Decision: PROCEED\n\nIf an accountable human has approved an exceptional create, re-run with --allow-missing-intake-decision; that override is recorded.`;
|
|
161
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* Returns `null` when the field isn't found, so callers can distinguish
|
|
17
17
|
* "missing" from "empty body".
|
|
18
18
|
*/
|
|
19
|
+
/** Remove operative text inside CommonMark fenced code blocks, including an unclosed block through EOF. */
|
|
20
|
+
export declare function stripFencedCodeBlocks(body: string): string;
|
|
19
21
|
/**
|
|
20
22
|
* Multi-section variant of `extractField`. Extracts each requested section
|
|
21
23
|
* by name in one call and returns a `{section -> text|null}` map preserving
|
|
@@ -28,7 +28,50 @@ const BOLD_PSEUDO_HEADER_RE = /^\*\*([^*\n]+?)(?::\s*)?\*\*\s*$/;
|
|
|
28
28
|
// permitted as fence indent per the CommonMark spec). We toggle an inFence
|
|
29
29
|
// flag while scanning so section headers inside code samples don't
|
|
30
30
|
// terminate the real section.
|
|
31
|
-
const
|
|
31
|
+
const FENCE_DELIMITER_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
32
|
+
function fenceDelimiter(line) {
|
|
33
|
+
const match = line.match(FENCE_DELIMITER_RE);
|
|
34
|
+
const run = match?.[1] ?? "";
|
|
35
|
+
if (!run || new Set(run).size !== 1)
|
|
36
|
+
return null;
|
|
37
|
+
return { run, rest: match?.[2] ?? "" };
|
|
38
|
+
}
|
|
39
|
+
function openFence(line) {
|
|
40
|
+
const delimiter = fenceDelimiter(line);
|
|
41
|
+
if (!delimiter)
|
|
42
|
+
return null;
|
|
43
|
+
const marker = delimiter.run[0];
|
|
44
|
+
// CommonMark forbids backticks in the info string of a backtick fence.
|
|
45
|
+
if (marker === "`" && delimiter.rest.includes("`"))
|
|
46
|
+
return null;
|
|
47
|
+
return { marker, length: delimiter.run.length };
|
|
48
|
+
}
|
|
49
|
+
function closesFence(line, state) {
|
|
50
|
+
const delimiter = fenceDelimiter(line);
|
|
51
|
+
return (delimiter !== null &&
|
|
52
|
+
delimiter.run[0] === state.marker &&
|
|
53
|
+
delimiter.run.length >= state.length &&
|
|
54
|
+
delimiter.rest.trim().length === 0);
|
|
55
|
+
}
|
|
56
|
+
/** Remove operative text inside CommonMark fenced code blocks, including an unclosed block through EOF. */
|
|
57
|
+
export function stripFencedCodeBlocks(body) {
|
|
58
|
+
const out = [];
|
|
59
|
+
let fence = null;
|
|
60
|
+
for (const line of body.split("\n")) {
|
|
61
|
+
if (fence) {
|
|
62
|
+
if (closesFence(line, fence))
|
|
63
|
+
fence = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const opened = openFence(line);
|
|
67
|
+
if (opened) {
|
|
68
|
+
fence = opened;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push(line);
|
|
72
|
+
}
|
|
73
|
+
return out.join("\n");
|
|
74
|
+
}
|
|
32
75
|
function normalize(s) {
|
|
33
76
|
return s.toLowerCase().trim().replace(/\s+/g, " ");
|
|
34
77
|
}
|
|
@@ -86,15 +129,19 @@ export function extractField(body, fieldName) {
|
|
|
86
129
|
return null;
|
|
87
130
|
const target = normalize(fieldName);
|
|
88
131
|
const lines = body.split("\n");
|
|
89
|
-
let
|
|
132
|
+
let fence = null;
|
|
90
133
|
let startIdx = -1;
|
|
91
134
|
for (let i = 0; i < lines.length; i++) {
|
|
92
|
-
if (
|
|
93
|
-
|
|
135
|
+
if (fence) {
|
|
136
|
+
if (closesFence(lines[i], fence))
|
|
137
|
+
fence = null;
|
|
94
138
|
continue;
|
|
95
139
|
}
|
|
96
|
-
|
|
140
|
+
const opened = openFence(lines[i]);
|
|
141
|
+
if (opened) {
|
|
142
|
+
fence = opened;
|
|
97
143
|
continue;
|
|
144
|
+
}
|
|
98
145
|
const match = matchHeader(lines[i]);
|
|
99
146
|
if (!match)
|
|
100
147
|
continue;
|
|
@@ -106,14 +153,21 @@ export function extractField(body, fieldName) {
|
|
|
106
153
|
if (startIdx === -1)
|
|
107
154
|
return null;
|
|
108
155
|
const out = [];
|
|
109
|
-
let
|
|
156
|
+
let sectionFence = null;
|
|
110
157
|
for (let i = startIdx; i < lines.length; i++) {
|
|
111
|
-
if (
|
|
112
|
-
|
|
158
|
+
if (sectionFence) {
|
|
159
|
+
if (closesFence(lines[i], sectionFence))
|
|
160
|
+
sectionFence = null;
|
|
161
|
+
out.push(lines[i]);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const opened = openFence(lines[i]);
|
|
165
|
+
if (opened) {
|
|
166
|
+
sectionFence = opened;
|
|
113
167
|
out.push(lines[i]);
|
|
114
168
|
continue;
|
|
115
169
|
}
|
|
116
|
-
if (
|
|
170
|
+
if (matchHeader(lines[i]) !== null)
|
|
117
171
|
break;
|
|
118
172
|
out.push(lines[i]);
|
|
119
173
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the body for a `--*-file` flag: a path on disk, or `-` for stdin.
|
|
3
|
+
*
|
|
4
|
+
* These flags exist to keep large markdown bodies away from the shell. The
|
|
5
|
+
* workaround they replace — `--content "$(cat body.md)"` — hands the file's
|
|
6
|
+
* bytes to the shell first, so backticks, `$`, and nested quotes inside the
|
|
7
|
+
* markdown get interpolated before the CLI ever sees them. Reading the path
|
|
8
|
+
* ourselves means the bytes arrive exactly as authored.
|
|
9
|
+
*
|
|
10
|
+
* For the same reason the content is used verbatim: no escape-sequence
|
|
11
|
+
* normalization. That is `normalizeInlineTextInput`'s job for the *inline*
|
|
12
|
+
* flags, where a user typing `\n` at a shell prompt means a newline. In a file,
|
|
13
|
+
* a literal `\n` inside a fenced code block is content, and rewriting it would
|
|
14
|
+
* corrupt the document.
|
|
15
|
+
*
|
|
16
|
+
* `label` names the subject in the not-found error, so each flag reports itself
|
|
17
|
+
* ("Description file not found: …", "Content file not found: …").
|
|
18
|
+
*
|
|
19
|
+
* Single source of truth for `issues --description-file` and `projects
|
|
20
|
+
* --content-file` (DEV-6033) — the two are specified to behave identically, so
|
|
21
|
+
* they share one implementation rather than two that drift.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readTextInputFile(filePath: string, label: string): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Read the body for a `--*-file` flag: a path on disk, or `-` for stdin.
|
|
4
|
+
*
|
|
5
|
+
* These flags exist to keep large markdown bodies away from the shell. The
|
|
6
|
+
* workaround they replace — `--content "$(cat body.md)"` — hands the file's
|
|
7
|
+
* bytes to the shell first, so backticks, `$`, and nested quotes inside the
|
|
8
|
+
* markdown get interpolated before the CLI ever sees them. Reading the path
|
|
9
|
+
* ourselves means the bytes arrive exactly as authored.
|
|
10
|
+
*
|
|
11
|
+
* For the same reason the content is used verbatim: no escape-sequence
|
|
12
|
+
* normalization. That is `normalizeInlineTextInput`'s job for the *inline*
|
|
13
|
+
* flags, where a user typing `\n` at a shell prompt means a newline. In a file,
|
|
14
|
+
* a literal `\n` inside a fenced code block is content, and rewriting it would
|
|
15
|
+
* corrupt the document.
|
|
16
|
+
*
|
|
17
|
+
* `label` names the subject in the not-found error, so each flag reports itself
|
|
18
|
+
* ("Description file not found: …", "Content file not found: …").
|
|
19
|
+
*
|
|
20
|
+
* Single source of truth for `issues --description-file` and `projects
|
|
21
|
+
* --content-file` (DEV-6033) — the two are specified to behave identically, so
|
|
22
|
+
* they share one implementation rather than two that drift.
|
|
23
|
+
*/
|
|
24
|
+
export function readTextInputFile(filePath, label) {
|
|
25
|
+
if (filePath === "-") {
|
|
26
|
+
return fs.readFileSync(0, "utf8").trim();
|
|
27
|
+
}
|
|
28
|
+
if (!fs.existsSync(filePath)) {
|
|
29
|
+
throw new Error(`${label} file not found: ${filePath}`);
|
|
30
|
+
}
|
|
31
|
+
return fs.readFileSync(filePath, "utf8").trim();
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.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",
|