@enrichlayer/el-linear 1.38.0 → 1.38.1
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 -2
- package/claude-skills/linear-operations/SKILL.md +14 -0
- package/dist/commands/attachments.js +60 -0
- package/dist/commands/issues.js +100 -6
- package/dist/config/config.d.ts +20 -0
- package/dist/config/goal-completion-validation.d.ts +92 -0
- package/dist/config/goal-completion-validation.js +158 -0
- package/dist/types/linear.d.ts +9 -0
- package/dist/utils/file-service.d.ts +3 -1
- package/dist/utils/file-service.js +53 -7
- package/dist/utils/graphql-issues-service.d.ts +2 -0
- package/dist/utils/graphql-issues-service.js +41 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -271,8 +271,11 @@ itself: `el-linear teams list --raw | jq '.[] | {key, id}'`, etc.
|
|
|
271
271
|
|
|
272
272
|
### Gate telemetry (optional)
|
|
273
273
|
|
|
274
|
-
`issues create` has a duplicate-detection gate (on by default)
|
|
275
|
-
[SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate)
|
|
274
|
+
`issues create` has a duplicate-detection gate (on by default), an opt-in
|
|
275
|
+
[SOP-label parent gate](./docs/configuration.md#sop-label-parent-gate-validationsoplabelparentgate),
|
|
276
|
+
and an opt-in
|
|
277
|
+
[goal-completion gate](./docs/configuration.md#goal-completion-gate-validationgoalcompletiongate)
|
|
278
|
+
(requires a falsifiable "Done when" / acceptance-criteria section).
|
|
276
279
|
el-linear can record each gate's fire/override decision to a local JSONL file so
|
|
277
280
|
you can measure its **override-rate** and tell whether it's too aggressive. It is
|
|
278
281
|
**off by default** and writes nothing unless you opt in (e.g.
|
|
@@ -121,6 +121,20 @@ el-linear comments list DEV-123 --format json 2>&1 | python3 -c "import json,sys
|
|
|
121
121
|
`#comment-<hash>`. `comments list --format summary` includes each comment id
|
|
122
122
|
so you can copy it straight into `comments read`.
|
|
123
123
|
|
|
124
|
+
### Attachment reads and downloads
|
|
125
|
+
|
|
126
|
+
List attachments first, then use the attachment ID or exact title. Text files
|
|
127
|
+
can be streamed directly; binary files require an explicit download path.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
el-linear attachments list DEV-123 --format summary
|
|
131
|
+
el-linear attachments read DEV-123 <attachment-id>
|
|
132
|
+
el-linear attachments download DEV-123 <attachment-id> --output /tmp/report.pdf
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Do not reconstruct authenticated `curl` commands from attachment URLs. The
|
|
136
|
+
attachment commands use the active Linear profile and reject binary stdout.
|
|
137
|
+
|
|
124
138
|
### Terse write confirmations: `-q, --quiet`
|
|
125
139
|
|
|
126
140
|
`issues create|update` and `comments create|update` accept `-q, --quiet`, which prints a single machine-stable confirmation line instead of the full JSON envelope — no need to `grep` the result for the identifier / state / url:
|
|
@@ -1,9 +1,28 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
1
2
|
import { createFileService } from "../utils/file-service.js";
|
|
2
3
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
3
4
|
import { createLinearService } from "../utils/linear-service.js";
|
|
4
5
|
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
6
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
6
7
|
import { parsePositiveInt } from "../utils/validators.js";
|
|
8
|
+
function selectAttachment(attachments, selector) {
|
|
9
|
+
const idMatch = attachments.find((attachment) => attachment.id === selector);
|
|
10
|
+
if (idMatch)
|
|
11
|
+
return idMatch;
|
|
12
|
+
const matches = attachments.filter((attachment) => attachment.title === selector || attachment.url === selector);
|
|
13
|
+
if (matches.length === 1)
|
|
14
|
+
return matches[0];
|
|
15
|
+
if (matches.length > 1) {
|
|
16
|
+
throw new Error(`Multiple attachments are titled "${selector}"; select one by attachment ID.`);
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Attachment "${selector}" was not found. Run attachments list <issueId> to see attachment IDs and titles.`);
|
|
19
|
+
}
|
|
20
|
+
async function resolveAttachment(issueId, selector, rootOpts) {
|
|
21
|
+
const linearService = await createLinearService(rootOpts);
|
|
22
|
+
const resolvedIssueId = await linearService.resolveIssueId(issueId);
|
|
23
|
+
const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
|
|
24
|
+
return selectAttachment(await attachmentsService.listAttachments(resolvedIssueId), selector);
|
|
25
|
+
}
|
|
7
26
|
export function setupAttachmentsCommands(program) {
|
|
8
27
|
const attachments = program
|
|
9
28
|
.command("attachments")
|
|
@@ -45,6 +64,47 @@ export function setupAttachmentsCommands(program) {
|
|
|
45
64
|
const data = allAttachments.slice(0, limit);
|
|
46
65
|
outputSuccess({ data, meta: { count: data.length } });
|
|
47
66
|
}));
|
|
67
|
+
attachments
|
|
68
|
+
.command("read <issueId> <attachment>")
|
|
69
|
+
.description("Write a text attachment to stdout by ID, title, or URL.")
|
|
70
|
+
.action(handleAsyncCommand(async (issueId, selector, _options, command) => {
|
|
71
|
+
const rootOpts = getRootOpts(command);
|
|
72
|
+
const attachment = await resolveAttachment(issueId, selector, rootOpts);
|
|
73
|
+
const fileService = await createFileService(rootOpts);
|
|
74
|
+
const result = await fileService.readTextFile(attachment.url);
|
|
75
|
+
if (!result.success)
|
|
76
|
+
throw new Error(result.error);
|
|
77
|
+
process.stdout.write(result.content);
|
|
78
|
+
if (!result.content.endsWith("\n"))
|
|
79
|
+
process.stdout.write("\n");
|
|
80
|
+
}));
|
|
81
|
+
attachments
|
|
82
|
+
.command("download <issueId> <attachment>")
|
|
83
|
+
.description("Download an attachment by ID, title, or URL.")
|
|
84
|
+
.option("--output <path>", "output file path")
|
|
85
|
+
.option("--overwrite", "overwrite existing file", false)
|
|
86
|
+
.action(handleAsyncCommand(async (issueId, selector, options, command) => {
|
|
87
|
+
const rootOpts = getRootOpts(command);
|
|
88
|
+
const attachment = await resolveAttachment(issueId, selector, rootOpts);
|
|
89
|
+
const fileService = await createFileService(rootOpts);
|
|
90
|
+
const titleBasename = attachment.title
|
|
91
|
+
? basename(attachment.title)
|
|
92
|
+
: undefined;
|
|
93
|
+
const defaultOutput = titleBasename && ![".", ".."].includes(titleBasename)
|
|
94
|
+
? titleBasename
|
|
95
|
+
: undefined;
|
|
96
|
+
const result = await fileService.downloadFile(attachment.url, {
|
|
97
|
+
output: options.output ?? defaultOutput,
|
|
98
|
+
overwrite: options.overwrite,
|
|
99
|
+
});
|
|
100
|
+
if (!result.success)
|
|
101
|
+
throw new Error(result.error);
|
|
102
|
+
outputSuccess({
|
|
103
|
+
success: true,
|
|
104
|
+
filePath: result.filePath,
|
|
105
|
+
message: `Attachment downloaded to ${result.filePath}`,
|
|
106
|
+
});
|
|
107
|
+
}));
|
|
48
108
|
attachments
|
|
49
109
|
.command("delete <attachmentId>")
|
|
50
110
|
.description("Delete an attachment.")
|
package/dist/commands/issues.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { evaluateGoalCompletion, formatGoalCompletionBlock, getGoalCompletionGateConfig, } from "../config/goal-completion-validation.js";
|
|
4
5
|
import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
|
|
5
6
|
import { resolveAssignee, resolveLabels, resolveMemberWithRegistry, resolveTeam, } from "../config/resolver.js";
|
|
6
7
|
import { formatSopParentBlock, getSopLabelGateConfig, hasSopLabel, isUnresolvableReferenceError, } from "../config/sop-label-validation.js";
|
|
@@ -340,7 +341,12 @@ async function withProjectResolverEnrichment(fn, options, services) {
|
|
|
340
341
|
throw err;
|
|
341
342
|
}
|
|
342
343
|
}
|
|
343
|
-
async function resolveCreateInputs(title, options, rootOpts
|
|
344
|
+
async function resolveCreateInputs(title, options, rootOpts,
|
|
345
|
+
// Pre-resolved description, threaded in so this validation path does NOT
|
|
346
|
+
// call resolveDescription() itself — on the `--description-file -` (stdin)
|
|
347
|
+
// path a second read drains the pipe to "" (DEV-5920 cycle-2). Resolve
|
|
348
|
+
// happens exactly once in handleCreateIssue and the value flows here.
|
|
349
|
+
resolvedDescription) {
|
|
344
350
|
const config = loadConfig();
|
|
345
351
|
enforceTerms([title, options.description], { strict: options.strict });
|
|
346
352
|
// Effective assignee: explicit --assignee wins; --no-assignee (commander
|
|
@@ -369,10 +375,9 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
369
375
|
const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
|
|
370
376
|
if (!options.skipValidation && !hasFromTemplate) {
|
|
371
377
|
const rawLabels = options.labels ? splitList(options.labels) : null;
|
|
372
|
-
const description = resolveDescription(options);
|
|
373
378
|
const validationResult = validateIssueCreation({
|
|
374
379
|
labels: rawLabels,
|
|
375
|
-
description:
|
|
380
|
+
description: resolvedDescription || undefined,
|
|
376
381
|
title,
|
|
377
382
|
assignee: effectiveAssignee,
|
|
378
383
|
project: options.project,
|
|
@@ -699,11 +704,84 @@ async function enforceSopLabelParent(labels, options, issuesService) {
|
|
|
699
704
|
// any unresolvable refs so a typo reads differently from a real non-SOP parent.
|
|
700
705
|
await decide("no-sop-parent", unresolvableRefs.length > 0 ? unresolvableRefs : undefined);
|
|
701
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* DEV-5920: create-time goal-completion gate. Checks the description for a
|
|
709
|
+
* "Done when" (or equivalent) section containing at least one falsifiable
|
|
710
|
+
* criterion — the deterministic form of the concrete-goals rule (RFC-0027
|
|
711
|
+
* discussion): a goal a later session can't mechanically verify has no
|
|
712
|
+
* terminal state to converge on.
|
|
713
|
+
*
|
|
714
|
+
* OPT-IN and two-mode: dormant unless `validation.goalCompletionGate` is
|
|
715
|
+
* `"warn"` (stderr warning, creation proceeds — recorded as `advisory`) or
|
|
716
|
+
* `"block"` (throws — recorded as `blocked`), mirroring the DEV-5378 SOP gate
|
|
717
|
+
* on config plumbing and the DEV-5590 advisory tier on outcomes. Bypassed by
|
|
718
|
+
* `--skip-validation` (blanket, emits nothing) and the narrow
|
|
719
|
+
* `--allow-vague-goal` (which records an `overridden` gate event on a
|
|
720
|
+
* would-fire, mirroring `--allow-duplicate`). Purely local — no network, so it
|
|
721
|
+
* runs before the service-backed gates and has no fail-open branch.
|
|
722
|
+
*/
|
|
723
|
+
async function enforceGoalCompletion(description, options) {
|
|
724
|
+
if (options.skipValidation) {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const { mode, headers } = getGoalCompletionGateConfig();
|
|
728
|
+
if (mode === "off") {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const evaluation = evaluateGoalCompletion(description, headers);
|
|
732
|
+
if (evaluation.ok) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// Record the decision so `el-telemetry gates` can compute override-rate,
|
|
736
|
+
// mirroring the dup gate (DEV-4834): `overridden` when the caller passed
|
|
737
|
+
// --allow-vague-goal, `advisory` in warn mode, `blocked` when we stop.
|
|
738
|
+
const gateEvent = { gate: "issues-create-goal-completion" };
|
|
739
|
+
if (options.allowVagueGoal) {
|
|
740
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
741
|
+
...gateEvent,
|
|
742
|
+
outcome: "overridden",
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const message = formatGoalCompletionBlock({
|
|
747
|
+
reason: evaluation.reason,
|
|
748
|
+
headers,
|
|
749
|
+
sectionHeader: evaluation.reason === "vague-section" ? evaluation.header : undefined,
|
|
750
|
+
});
|
|
751
|
+
if (mode === "warn") {
|
|
752
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
753
|
+
...gateEvent,
|
|
754
|
+
outcome: "advisory",
|
|
755
|
+
});
|
|
756
|
+
outputWarning(message);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
await emitGateEvent("el-linear", "issues create", {
|
|
760
|
+
...gateEvent,
|
|
761
|
+
outcome: "blocked",
|
|
762
|
+
});
|
|
763
|
+
throw new Error(`Issue creation blocked: ${message}`);
|
|
764
|
+
}
|
|
702
765
|
async function handleCreateIssue(title, options, command) {
|
|
703
766
|
const rootOpts = getRootOpts(command);
|
|
704
|
-
|
|
767
|
+
// DEV-5920 (cycle-2): resolve the description exactly ONCE, before anything
|
|
768
|
+
// else. On create, resolveDescription would otherwise run three times —
|
|
769
|
+
// field validation (inside resolveCreateInputs), the body build, and the
|
|
770
|
+
// goal-completion gate. With `--description-file -` each call does
|
|
771
|
+
// fs.readFileSync(fd 0), and a stdin pipe only yields data on the FIRST
|
|
772
|
+
// read — later reads drain to "". That silently dropped a piped body and
|
|
773
|
+
// made the gate see an empty description (spurious block/warn). Resolving
|
|
774
|
+
// once here and threading the value through fixes all three reads. This
|
|
775
|
+
// single resolve also enforces the --template / --description-file
|
|
776
|
+
// mutual-exclusivity guard (it lives in resolveDescription). Kept as a lone
|
|
777
|
+
// resolve rather than round-tripping through options.description because
|
|
778
|
+
// resolveDescription returns file bodies verbatim (no inline-escape
|
|
779
|
+
// normalization) — re-resolving an inline copy would corrupt a file that
|
|
780
|
+
// intentionally contains backslash sequences.
|
|
781
|
+
const rawDescription = resolveDescription(options);
|
|
782
|
+
const { teamInput, teamId, assigneeId, delegateId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts, rawDescription);
|
|
705
783
|
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
706
|
-
const descriptionWithAttachments = buildDescriptionWithAttachments(
|
|
784
|
+
const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
|
|
707
785
|
// Append messageFooter (config or --footer flag) so auto-link picks up any
|
|
708
786
|
// issue refs in the footer too. --no-footer skips both flag and config.
|
|
709
787
|
// Commander parses --no-footer as `options.footer === false`.
|
|
@@ -713,6 +791,21 @@ async function handleCreateIssue(title, options, command) {
|
|
|
713
791
|
footer: explicitFooter,
|
|
714
792
|
noFooter,
|
|
715
793
|
}) ?? "";
|
|
794
|
+
// DEV-5920: goal-completion gate. Opt-in (dormant unless
|
|
795
|
+
// validation.goalCompletionGate is "warn"/"block"). Runs on the raw resolved
|
|
796
|
+
// description — not the composed one — so an attachment link or a configured
|
|
797
|
+
// messageFooter (which routinely carries digits/URLs) can't satisfy the
|
|
798
|
+
// falsifiability check on the author's behalf. Purely local, so it runs
|
|
799
|
+
// before the service-backed gates. Skipped on the --from-template path when
|
|
800
|
+
// no local description override is present: the description is instantiated
|
|
801
|
+
// server-side from the template, invisible to a client-side check (same gap
|
|
802
|
+
// as the dup gate on a template-resolved title).
|
|
803
|
+
{
|
|
804
|
+
const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
|
|
805
|
+
if (!hasFromTemplate || rawDescription) {
|
|
806
|
+
await enforceGoalCompletion(rawDescription ?? "", options);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
716
809
|
const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
|
|
717
810
|
// DEV-4823: deterministic duplicate-detection gate. Runs before the create
|
|
718
811
|
// POST, searches the title's salient keywords (including closed issues),
|
|
@@ -1308,9 +1401,10 @@ export function setupIssuesCommands(program) {
|
|
|
1308
1401
|
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1309
1402
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1310
1403
|
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1311
|
-
.option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate)")
|
|
1404
|
+
.option("--skip-validation", "skip all validation (labels, description, assignee, project, duplicate detection, SOP-parent gate, goal-completion gate)")
|
|
1312
1405
|
.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)")
|
|
1313
1406
|
.option("--allow-unparented-sop", "skip the SOP-label parent gate and create an SOP-labeled issue without a parent SOP")
|
|
1407
|
+
.option("--allow-vague-goal", 'skip the goal-completion gate and create even without a falsifiable "Done when" / acceptance-criteria section')
|
|
1314
1408
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1315
1409
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
1316
1410
|
.option("--no-footer", "skip the configured messageFooter for this issue")
|
package/dist/config/config.d.ts
CHANGED
|
@@ -95,6 +95,26 @@ export interface ElLinearConfig {
|
|
|
95
95
|
* for the `sopLabelParentGate` check. Defaults to `["SOP"]`.
|
|
96
96
|
*/
|
|
97
97
|
sopLabels?: string[];
|
|
98
|
+
/**
|
|
99
|
+
* OPT-IN goal-completion gate (DEV-5920). When `"warn"` or `"block"`,
|
|
100
|
+
* `issues create` checks the description for a goal-completion section
|
|
101
|
+
* ("Done when" / "Acceptance criteria" / … — see `goalSectionHeaders`)
|
|
102
|
+
* containing at least one falsifiable criterion (a command, a threshold
|
|
103
|
+
* number, an artifact path, an exit-code assertion, or a "verifiable
|
|
104
|
+
* via X" phrase). `"warn"` prints a stderr warning; `"block"` stops
|
|
105
|
+
* creation. Defaults to off (absent/`false`) — el-linear is
|
|
106
|
+
* MIT/open-source and a fresh install must not be surprised by a new
|
|
107
|
+
* refusal (the EL shared team config opts in). Bypass a single create
|
|
108
|
+
* with `--allow-vague-goal`.
|
|
109
|
+
*/
|
|
110
|
+
goalCompletionGate?: false | "warn" | "block";
|
|
111
|
+
/**
|
|
112
|
+
* Section header names (matched case-insensitively, `##`/`###`/bold
|
|
113
|
+
* pseudo-header forms) accepted as the goal-completion section for the
|
|
114
|
+
* `goalCompletionGate` check. Defaults to `["Done when", "Done-when",
|
|
115
|
+
* "Acceptance criteria", "Success criteria"]`.
|
|
116
|
+
*/
|
|
117
|
+
goalSectionHeaders?: string[];
|
|
98
118
|
};
|
|
99
119
|
/**
|
|
100
120
|
* Optional override for the Linear workspace URL key (the part after
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal-completion ("Done when") validation — DEV-5920.
|
|
3
|
+
*
|
|
4
|
+
* A create-time gate that checks the issue description for a goal-completion
|
|
5
|
+
* section ("Done when", "Acceptance criteria", "Success criteria", …) that
|
|
6
|
+
* contains at least one FALSIFIABLE criterion — something a later session can
|
|
7
|
+
* mechanically verify (a command with an expected result, a threshold number,
|
|
8
|
+
* a named artifact path, an exit-code/status assertion, or an explicit
|
|
9
|
+
* "verifiable via X" phrase). A section made only of bare quality adjectives
|
|
10
|
+
* ("improved", "better", "cleaner", "faster") gives the implementing agent no
|
|
11
|
+
* terminal state to converge on, which is the concrete-goals failure mode this
|
|
12
|
+
* gate encodes (RFC-0027 discussion). Mirrors the DEV-4823 duplicate-detection
|
|
13
|
+
* gate and the DEV-5378 SOP-label parent gate.
|
|
14
|
+
*
|
|
15
|
+
* OPT-IN by design. el-linear is MIT and published on npm; most installs are
|
|
16
|
+
* not Enrich Layer and must not be surprised by a new refusal. The gate is
|
|
17
|
+
* dormant unless `validation.goalCompletionGate` is set to `"warn"` or
|
|
18
|
+
* `"block"` (the EL workspace flips it on in its shared team config). Section
|
|
19
|
+
* matching reuses the `extractField` header semantics (`##`/`###` ATX headers
|
|
20
|
+
* and `**bold**` pseudo-headers, case-insensitive, trailing colons stripped)
|
|
21
|
+
* so the gate accepts exactly what `issues read --field "Done when"` can later
|
|
22
|
+
* extract.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Default section headers accepted as the goal-completion section, matched
|
|
26
|
+
* with `extractField` semantics (case-insensitive, `##`/`###`/`**bold**`
|
|
27
|
+
* forms, trailing colons stripped). Overridable via
|
|
28
|
+
* `config.validation.goalSectionHeaders`.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_GOAL_SECTION_HEADERS: string[];
|
|
31
|
+
/** Gate mode: dormant, stderr warning, or hard block. */
|
|
32
|
+
export type GoalCompletionGateMode = "off" | "warn" | "block";
|
|
33
|
+
export interface GoalCompletionGateConfig {
|
|
34
|
+
/**
|
|
35
|
+
* The mode in effect. OPT-IN: `"off"` unless validation is not turned off
|
|
36
|
+
* AND `goalCompletionGate` is explicitly `"warn"` or `"block"`.
|
|
37
|
+
*/
|
|
38
|
+
mode: GoalCompletionGateMode;
|
|
39
|
+
/** The section headers in effect (config override or {@link DEFAULT_GOAL_SECTION_HEADERS}). */
|
|
40
|
+
headers: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the goal-completion-gate config from the merged el-linear config.
|
|
44
|
+
*
|
|
45
|
+
* The gate is dormant by default. It activates only when validation isn't
|
|
46
|
+
* disabled (`validation.enabled !== false`) AND the operator has explicitly
|
|
47
|
+
* set `validation.goalCompletionGate` to `"warn"` or `"block"`. Any other
|
|
48
|
+
* value (absent, `false`, a typo) resolves to `"off"` — a misconfigured gate
|
|
49
|
+
* must fail dormant, never blocking. An absent or empty `goalSectionHeaders`
|
|
50
|
+
* falls back to {@link DEFAULT_GOAL_SECTION_HEADERS}.
|
|
51
|
+
*/
|
|
52
|
+
export declare function getGoalCompletionGateConfig(): GoalCompletionGateConfig;
|
|
53
|
+
/**
|
|
54
|
+
* Does the section text contain at least one falsifiable criterion?
|
|
55
|
+
* See {@link FALSIFIABLE_PROXY_RES} for what counts. An empty/whitespace
|
|
56
|
+
* section trivially fails — a bare header is not a criterion.
|
|
57
|
+
*/
|
|
58
|
+
export declare function hasFalsifiableCriterion(sectionText: string): boolean;
|
|
59
|
+
export type GoalCompletionEvaluation = {
|
|
60
|
+
ok: true;
|
|
61
|
+
header: string;
|
|
62
|
+
} | {
|
|
63
|
+
ok: false;
|
|
64
|
+
reason: "no-section";
|
|
65
|
+
} | {
|
|
66
|
+
ok: false;
|
|
67
|
+
reason: "vague-section";
|
|
68
|
+
header: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Evaluate a description against the goal-completion rule. Headers are tried
|
|
72
|
+
* in **configured list order**, not document order — the first header in
|
|
73
|
+
* `headers` that is present in the description decides the outcome, even if a
|
|
74
|
+
* later-listed header appears earlier in the body. This is intentional: the
|
|
75
|
+
* list order encodes the operator's preferred canonical header, so the block
|
|
76
|
+
* message names the header they'd rather authors use. A present-but-vague
|
|
77
|
+
* section is reported as `vague-section` with the header that matched, so the
|
|
78
|
+
* error can point at the exact section rather than a generic "missing".
|
|
79
|
+
*/
|
|
80
|
+
export declare function evaluateGoalCompletion(description: string, headers?: string[]): GoalCompletionEvaluation;
|
|
81
|
+
/**
|
|
82
|
+
* Render the human/agent-facing block emitted when the gate fires. `reason`
|
|
83
|
+
* distinguishes "no goal-completion section at all" from "section present but
|
|
84
|
+
* nothing falsifiable in it", so the message points at the exact fix. Names
|
|
85
|
+
* the rule and the `--allow-vague-goal` escape hatch.
|
|
86
|
+
*/
|
|
87
|
+
export declare function formatGoalCompletionBlock(opts: {
|
|
88
|
+
reason: "no-section" | "vague-section";
|
|
89
|
+
headers: string[];
|
|
90
|
+
/** The header that matched, when reason is `vague-section`. */
|
|
91
|
+
sectionHeader?: string;
|
|
92
|
+
}): string;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal-completion ("Done when") validation — DEV-5920.
|
|
3
|
+
*
|
|
4
|
+
* A create-time gate that checks the issue description for a goal-completion
|
|
5
|
+
* section ("Done when", "Acceptance criteria", "Success criteria", …) that
|
|
6
|
+
* contains at least one FALSIFIABLE criterion — something a later session can
|
|
7
|
+
* mechanically verify (a command with an expected result, a threshold number,
|
|
8
|
+
* a named artifact path, an exit-code/status assertion, or an explicit
|
|
9
|
+
* "verifiable via X" phrase). A section made only of bare quality adjectives
|
|
10
|
+
* ("improved", "better", "cleaner", "faster") gives the implementing agent no
|
|
11
|
+
* terminal state to converge on, which is the concrete-goals failure mode this
|
|
12
|
+
* gate encodes (RFC-0027 discussion). Mirrors the DEV-4823 duplicate-detection
|
|
13
|
+
* gate and the DEV-5378 SOP-label parent gate.
|
|
14
|
+
*
|
|
15
|
+
* OPT-IN by design. el-linear is MIT and published on npm; most installs are
|
|
16
|
+
* not Enrich Layer and must not be surprised by a new refusal. The gate is
|
|
17
|
+
* dormant unless `validation.goalCompletionGate` is set to `"warn"` or
|
|
18
|
+
* `"block"` (the EL workspace flips it on in its shared team config). Section
|
|
19
|
+
* matching reuses the `extractField` header semantics (`##`/`###` ATX headers
|
|
20
|
+
* and `**bold**` pseudo-headers, case-insensitive, trailing colons stripped)
|
|
21
|
+
* so the gate accepts exactly what `issues read --field "Done when"` can later
|
|
22
|
+
* extract.
|
|
23
|
+
*/
|
|
24
|
+
import { extractField } from "../utils/extract-field.js";
|
|
25
|
+
import { loadConfig } from "./config.js";
|
|
26
|
+
/**
|
|
27
|
+
* Default section headers accepted as the goal-completion section, matched
|
|
28
|
+
* with `extractField` semantics (case-insensitive, `##`/`###`/`**bold**`
|
|
29
|
+
* forms, trailing colons stripped). Overridable via
|
|
30
|
+
* `config.validation.goalSectionHeaders`.
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_GOAL_SECTION_HEADERS = [
|
|
33
|
+
"Done when",
|
|
34
|
+
"Done-when",
|
|
35
|
+
"Acceptance criteria",
|
|
36
|
+
"Success criteria",
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the goal-completion-gate config from the merged el-linear config.
|
|
40
|
+
*
|
|
41
|
+
* The gate is dormant by default. It activates only when validation isn't
|
|
42
|
+
* disabled (`validation.enabled !== false`) AND the operator has explicitly
|
|
43
|
+
* set `validation.goalCompletionGate` to `"warn"` or `"block"`. Any other
|
|
44
|
+
* value (absent, `false`, a typo) resolves to `"off"` — a misconfigured gate
|
|
45
|
+
* must fail dormant, never blocking. An absent or empty `goalSectionHeaders`
|
|
46
|
+
* falls back to {@link DEFAULT_GOAL_SECTION_HEADERS}.
|
|
47
|
+
*/
|
|
48
|
+
export function getGoalCompletionGateConfig() {
|
|
49
|
+
const validation = loadConfig().validation;
|
|
50
|
+
const raw = validation?.goalCompletionGate;
|
|
51
|
+
const mode = validation?.enabled !== false && (raw === "warn" || raw === "block")
|
|
52
|
+
? raw
|
|
53
|
+
: "off";
|
|
54
|
+
const headers = validation?.goalSectionHeaders && validation.goalSectionHeaders.length > 0
|
|
55
|
+
? validation.goalSectionHeaders
|
|
56
|
+
: DEFAULT_GOAL_SECTION_HEADERS;
|
|
57
|
+
return { mode, headers };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Falsifiability proxies — any single match makes the section pass. Each is a
|
|
61
|
+
* cheap textual stand-in for "a later session can mechanically check this":
|
|
62
|
+
*
|
|
63
|
+
* - **command** — inline code or a fenced block (`` `pnpm test` `` and its
|
|
64
|
+
* expected output live in code spans by Markdown convention).
|
|
65
|
+
* - **number** — a digit anywhere in the section: thresholds ("under 200ms",
|
|
66
|
+
* "95%"), counts ("all 12 tests"), issue/artifact ids. Deliberately
|
|
67
|
+
* permissive — the failure mode this gate targets is a section with NO
|
|
68
|
+
* number/command/artifact at all, not a weak number.
|
|
69
|
+
* - **artifact path** — a bare filename with a code-adjacent extension
|
|
70
|
+
* (`foo.ts`, `report.json` — this also matches the file segment inside a
|
|
71
|
+
* longer path like `src/utils/foo.ts`), or an ANCHORED slash-path (leading
|
|
72
|
+
* `./`, `../`, `/`, or `~/`) for extension-less dirs like `./scripts/run`.
|
|
73
|
+
* The anchor requirement is deliberate: an un-anchored two-segment
|
|
74
|
+
* `a/b` pattern over-fires on English prose ("and/or", "read/write",
|
|
75
|
+
* "client/server"), so a real relative artifact path must either carry a
|
|
76
|
+
* file extension or start with a path anchor to count.
|
|
77
|
+
* - **exit/status assertion** — "exits non-zero", "exit code 0", "tests
|
|
78
|
+
* pass", "CI green", "returns nonzero".
|
|
79
|
+
* - **verifiable-via phrase** — "verifiable via/by/with/through X".
|
|
80
|
+
*/
|
|
81
|
+
const FALSIFIABLE_PROXY_RES = [
|
|
82
|
+
// Inline code span or a fenced code block opener.
|
|
83
|
+
/`[^`\n]+`/,
|
|
84
|
+
/^ {0,3}(?:`{3,}|~{3,})/m,
|
|
85
|
+
// Threshold number / percentage / count.
|
|
86
|
+
/\d/,
|
|
87
|
+
// Artifact path — a filename with a code-adjacent extension (also fires on
|
|
88
|
+
// the file segment of a longer path), OR an anchored slash-path for
|
|
89
|
+
// extension-less dirs. The anchor (`./` `../` `/` `~/`) is required so a
|
|
90
|
+
// bare `a/b` doesn't over-fire on prose like "and/or" / "read/write".
|
|
91
|
+
/\b[\w-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|mdx|ya?ml|sh|css|html|txt|csv|toml|sql|py|go|rs|lock)\b/i,
|
|
92
|
+
/(?:^|[\s("'[])(?:\.{1,2}\/|~\/|\/)[\w.-]+(?:\/[\w.-]+)*/m,
|
|
93
|
+
// Exit-code / status assertion.
|
|
94
|
+
/\bexit(?:s|ed)?\s+(?:code\s+|status\s+)?(?:non-?zero|zero|\d+)\b/i,
|
|
95
|
+
/\bexit\s+(?:code|status)\b/i,
|
|
96
|
+
/\breturns?\s+non-?zero\b/i,
|
|
97
|
+
/\b(?:test|tests|suite|ci|pipeline|lint|typecheck|build|check|checks)\s+(?:is\s+|are\s+|stays?\s+|go(?:es)?\s+)?(?:pass(?:es|ing)?|green|fail(?:s|ing)?|red)\b/i,
|
|
98
|
+
// Explicit "verifiable via X" escape phrase.
|
|
99
|
+
/\bverifi(?:able|ed)\s+(?:via|by|with|through)\b/i,
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* Does the section text contain at least one falsifiable criterion?
|
|
103
|
+
* See {@link FALSIFIABLE_PROXY_RES} for what counts. An empty/whitespace
|
|
104
|
+
* section trivially fails — a bare header is not a criterion.
|
|
105
|
+
*/
|
|
106
|
+
export function hasFalsifiableCriterion(sectionText) {
|
|
107
|
+
if (!sectionText || sectionText.trim().length === 0) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return FALSIFIABLE_PROXY_RES.some((re) => re.test(sectionText));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Evaluate a description against the goal-completion rule. Headers are tried
|
|
114
|
+
* in **configured list order**, not document order — the first header in
|
|
115
|
+
* `headers` that is present in the description decides the outcome, even if a
|
|
116
|
+
* later-listed header appears earlier in the body. This is intentional: the
|
|
117
|
+
* list order encodes the operator's preferred canonical header, so the block
|
|
118
|
+
* message names the header they'd rather authors use. A present-but-vague
|
|
119
|
+
* section is reported as `vague-section` with the header that matched, so the
|
|
120
|
+
* error can point at the exact section rather than a generic "missing".
|
|
121
|
+
*/
|
|
122
|
+
export function evaluateGoalCompletion(description, headers = DEFAULT_GOAL_SECTION_HEADERS) {
|
|
123
|
+
for (const header of headers) {
|
|
124
|
+
const section = extractField(description, header);
|
|
125
|
+
if (section !== null) {
|
|
126
|
+
return hasFalsifiableCriterion(section)
|
|
127
|
+
? { ok: true, header }
|
|
128
|
+
: { ok: false, reason: "vague-section", header };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, reason: "no-section" };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Render the human/agent-facing block emitted when the gate fires. `reason`
|
|
135
|
+
* distinguishes "no goal-completion section at all" from "section present but
|
|
136
|
+
* nothing falsifiable in it", so the message points at the exact fix. Names
|
|
137
|
+
* the rule and the `--allow-vague-goal` escape hatch.
|
|
138
|
+
*/
|
|
139
|
+
export function formatGoalCompletionBlock(opts) {
|
|
140
|
+
const headerList = opts.headers.join(", ");
|
|
141
|
+
let head;
|
|
142
|
+
if (opts.reason === "no-section") {
|
|
143
|
+
head =
|
|
144
|
+
`Issue description has no goal-completion section (looked for: ${headerList}).\n` +
|
|
145
|
+
` Add a "${opts.headers[0]}" section stating how completion will be verified.\n`;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
head =
|
|
149
|
+
`The "${opts.sectionHeader}" section contains no falsifiable criterion.\n` +
|
|
150
|
+
' Bare quality adjectives ("improved", "better", "cleaner", "faster") give the\n' +
|
|
151
|
+
" implementing session no terminal state to converge on.\n";
|
|
152
|
+
}
|
|
153
|
+
const criteria = " At least one criterion must be mechanically checkable: a command with its\n" +
|
|
154
|
+
" expected exit/output, a threshold number or percentage, a named artifact path,\n" +
|
|
155
|
+
' an exit-code/status assertion, or a "verifiable via X" phrase.\n';
|
|
156
|
+
const hatch = " If the goal is intentionally open-ended, re-run with --allow-vague-goal.";
|
|
157
|
+
return head + criteria + hatch;
|
|
158
|
+
}
|
package/dist/types/linear.d.ts
CHANGED
|
@@ -223,6 +223,15 @@ export type FileDownloadResult = {
|
|
|
223
223
|
error: string;
|
|
224
224
|
statusCode?: number;
|
|
225
225
|
};
|
|
226
|
+
export type FileReadResult = {
|
|
227
|
+
success: true;
|
|
228
|
+
content: string;
|
|
229
|
+
contentType: string;
|
|
230
|
+
} | {
|
|
231
|
+
success: false;
|
|
232
|
+
error: string;
|
|
233
|
+
statusCode?: number;
|
|
234
|
+
};
|
|
226
235
|
export type FileUploadResult = {
|
|
227
236
|
success: true;
|
|
228
237
|
assetUrl: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LinearCredential } from "../auth/linear-credential.js";
|
|
2
|
-
import type { FileDownloadResult, FileUploadResult } from "../types/linear.js";
|
|
2
|
+
import type { FileDownloadResult, FileReadResult, FileUploadResult } from "../types/linear.js";
|
|
3
3
|
/**
|
|
4
4
|
* Constructor arg for `FileService`. Re-exported alias of the shared
|
|
5
5
|
* `LinearCredential` union (`{ apiKey } | { oauthToken }`). See
|
|
@@ -10,6 +10,8 @@ export type FileServiceAuth = LinearCredential;
|
|
|
10
10
|
export declare class FileService {
|
|
11
11
|
private readonly authHeader;
|
|
12
12
|
constructor(auth: FileServiceAuth);
|
|
13
|
+
private fetchUpload;
|
|
14
|
+
readTextFile(url: string): Promise<FileReadResult>;
|
|
13
15
|
downloadFile(url: string, options?: {
|
|
14
16
|
output?: string;
|
|
15
17
|
overwrite?: boolean;
|
|
@@ -47,6 +47,58 @@ export class FileService {
|
|
|
47
47
|
constructor(auth) {
|
|
48
48
|
this.authHeader = buildAuthHeader(auth);
|
|
49
49
|
}
|
|
50
|
+
async fetchUpload(url) {
|
|
51
|
+
const urlObj = new URL(url);
|
|
52
|
+
const headers = {};
|
|
53
|
+
if (!urlObj.searchParams.has("signature")) {
|
|
54
|
+
headers.Authorization = this.authHeader;
|
|
55
|
+
}
|
|
56
|
+
return fetch(url, { method: "GET", headers });
|
|
57
|
+
}
|
|
58
|
+
async readTextFile(url) {
|
|
59
|
+
if (!isLinearUploadUrl(url)) {
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
error: "URL must be from uploads.linear.app domain",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const response = await this.fetchUpload(url);
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
71
|
+
statusCode: response.status,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const contentType = (response.headers.get("content-type") ?? "")
|
|
75
|
+
.split(";", 1)[0]
|
|
76
|
+
.toLowerCase();
|
|
77
|
+
const isText = contentType.startsWith("text/") ||
|
|
78
|
+
[
|
|
79
|
+
"application/json",
|
|
80
|
+
"application/xml",
|
|
81
|
+
"application/javascript",
|
|
82
|
+
].includes(contentType);
|
|
83
|
+
if (!isText) {
|
|
84
|
+
return {
|
|
85
|
+
success: false,
|
|
86
|
+
error: `Attachment is not text (${contentType || "unknown content type"}); use attachments download instead.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
success: true,
|
|
91
|
+
content: await response.text(),
|
|
92
|
+
contentType,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return {
|
|
97
|
+
success: false,
|
|
98
|
+
error: error instanceof Error ? error.message : String(error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
50
102
|
async downloadFile(url, options = {}) {
|
|
51
103
|
if (!isLinearUploadUrl(url)) {
|
|
52
104
|
return {
|
|
@@ -68,13 +120,7 @@ export class FileService {
|
|
|
68
120
|
}
|
|
69
121
|
}
|
|
70
122
|
try {
|
|
71
|
-
const
|
|
72
|
-
const isSignedUrl = urlObj.searchParams.has("signature");
|
|
73
|
-
const headers = {};
|
|
74
|
-
if (!isSignedUrl) {
|
|
75
|
-
headers.Authorization = this.authHeader;
|
|
76
|
-
}
|
|
77
|
-
const response = await fetch(url, { method: "GET", headers });
|
|
123
|
+
const response = await this.fetchUpload(url);
|
|
78
124
|
if (!response.ok) {
|
|
79
125
|
return {
|
|
80
126
|
success: false,
|
|
@@ -209,6 +209,7 @@ export declare class GraphQLIssuesService {
|
|
|
209
209
|
startIssue(issueId: string): Promise<StartIssueResult>;
|
|
210
210
|
claimIssue(issueId: string): Promise<ClaimIssueResult>;
|
|
211
211
|
updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
|
|
212
|
+
private updateIssueImpl;
|
|
212
213
|
archiveIssue(issueId: string): Promise<IssueArchiveOperationResult>;
|
|
213
214
|
deleteIssue(issueId: string, options?: {
|
|
214
215
|
permanentlyDelete?: boolean;
|
|
@@ -216,6 +217,7 @@ export declare class GraphQLIssuesService {
|
|
|
216
217
|
private extractMilestoneNodes;
|
|
217
218
|
private executeUpdateMutation;
|
|
218
219
|
createIssue(args: CreateIssueArgs): Promise<LinearIssue>;
|
|
220
|
+
private createIssueImpl;
|
|
219
221
|
/**
|
|
220
222
|
* Folds the `@include`-gated `projectsByName` / `projectsById` aliases
|
|
221
223
|
* from a create batch response into the single `projects` field the
|
|
@@ -28,6 +28,31 @@ function extractSummaryText(summary) {
|
|
|
28
28
|
walk(summary.content);
|
|
29
29
|
return parts.length > 0 ? parts.join("") : undefined;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* FE-926: Linear's description-content store can end up in a corrupted state
|
|
33
|
+
* for a specific issue — observed on FE-921, where a description saved
|
|
34
|
+
* cleanly at creation was empty ~1 minute later with no issue-history trace,
|
|
35
|
+
* and every subsequent write attempt raised this same raw GraphQL error
|
|
36
|
+
* against a *different* DocumentContent id each time. It can surface from
|
|
37
|
+
* any call that touches the issue's description, including a plain
|
|
38
|
+
* batch-resolve read (not just the create/update mutation itself), so
|
|
39
|
+
* `updateIssue`/`createIssue` wrap their whole body rather than a single
|
|
40
|
+
* call site. Retrying does not self-heal — the raw backend message is
|
|
41
|
+
* replaced with the known workaround.
|
|
42
|
+
*/
|
|
43
|
+
function rethrowWithDocumentContentHint(error, issueId) {
|
|
44
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
45
|
+
if (msg.includes("Conflict on insert of DocumentContent")) {
|
|
46
|
+
const detail = issueId
|
|
47
|
+
? `for ${issueId}. This does not self-heal by retrying — each attempt fails against a new conflicting id. Workaround: create a replacement issue with the same content, relate it back with --duplicate-of ${issueId}, and cancel the original.`
|
|
48
|
+
: "for this issue. This does not self-heal by retrying — each attempt fails against a new conflicting id. If it recurs on the same issue after creation, recreate it under a fresh id rather than repairing via update.";
|
|
49
|
+
throw new Error(`Linear reports its description store is in conflict ${detail}`);
|
|
50
|
+
}
|
|
51
|
+
if (error instanceof Error) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(msg);
|
|
55
|
+
}
|
|
31
56
|
export class GraphQLIssuesService {
|
|
32
57
|
graphQLService;
|
|
33
58
|
linearService;
|
|
@@ -262,6 +287,14 @@ export class GraphQLIssuesService {
|
|
|
262
287
|
};
|
|
263
288
|
}
|
|
264
289
|
async updateIssue(args, labelMode = "overwriting") {
|
|
290
|
+
try {
|
|
291
|
+
return await this.updateIssueImpl(args, labelMode);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
rethrowWithDocumentContentHint(error, args.id);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async updateIssueImpl(args, labelMode = "overwriting") {
|
|
265
298
|
// Normalize URL/slug-id --project inputs to UUIDs before the batch
|
|
266
299
|
// resolver runs; see comment on `withNormalizedProjectId`.
|
|
267
300
|
const normalizedArgs = await this.withNormalizedProjectId(args);
|
|
@@ -368,6 +401,14 @@ export class GraphQLIssuesService {
|
|
|
368
401
|
return this.transformIssueData(issueUpdate.issue);
|
|
369
402
|
}
|
|
370
403
|
async createIssue(args) {
|
|
404
|
+
try {
|
|
405
|
+
return await this.createIssueImpl(args);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
rethrowWithDocumentContentHint(error);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async createIssueImpl(args) {
|
|
371
412
|
// Pre-resolve URL/slug-id forms of --project to a UUID so the batch
|
|
372
413
|
// resolver below (which uses a `name eqIgnoreCase` filter) can skip
|
|
373
414
|
// the project lookup entirely. Plain name inputs flow through unchanged
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.38.
|
|
3
|
+
"version": "1.38.1",
|
|
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",
|