@enrichlayer/el-linear 1.37.2 → 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 +8 -4
- package/claude-skills/linear-operations/SKILL.md +45 -30
- package/dist/commands/attachments.js +60 -0
- package/dist/commands/comments.js +2 -1
- package/dist/commands/issues/description.js +2 -1
- package/dist/commands/issues.js +118 -7
- package/dist/commands/labels.js +2 -1
- package/dist/commands/projects.js +53 -1
- 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/queries/projects-types.d.ts +10 -0
- package/dist/queries/projects.d.ts +1 -0
- package/dist/queries/projects.js +20 -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/gate-telemetry.d.ts +20 -0
- package/dist/utils/gate-telemetry.js +26 -1
- package/dist/utils/graphql-issues-service.d.ts +2 -0
- package/dist/utils/graphql-issues-service.js +41 -0
- package/dist/utils/inline-text-input.d.ts +7 -0
- package/dist/utils/inline-text-input.js +12 -0
- package/dist/utils/relation-candidate-prompt.d.ts +12 -5
- package/dist/utils/relation-candidate-prompt.js +23 -9
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -271,14 +271,18 @@ 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.
|
|
279
282
|
`export EL_TELEMETRY_DIR=<path>`); there is no server or database, and
|
|
280
|
-
`EL_TELEMETRY_DISABLED=1` forces it off.
|
|
281
|
-
|
|
283
|
+
`EL_TELEMETRY_DISABLED=1` forces it off. The active ledger rotates to one `.old`
|
|
284
|
+
backup before append when it exceeds 2 MiB. Full opt-in rules, the event schema,
|
|
285
|
+
and a `jq` reader are in [docs/telemetry.md](./docs/telemetry.md).
|
|
282
286
|
|
|
283
287
|
### Networking (IPv4 preference)
|
|
284
288
|
|
|
@@ -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:
|
|
@@ -237,6 +251,16 @@ el-linear issues search "keywords from proposed title" --include-closed 2>&1
|
|
|
237
251
|
el-linear issues create "Title" --team ENG --related-to "ENG-456,ENG-789" ... 2>&1
|
|
238
252
|
```
|
|
239
253
|
|
|
254
|
+
### Cross-linking: be generous, link proactively, don't wait ([DEV-5853](https://linear.app/verticalint/issue/DEV-5853/))
|
|
255
|
+
|
|
256
|
+
**The default is to link, not to ask.** A cross-link is cheap and reversible; a *missing* link is invisible and costs reviewers and SOP tooling the context they need. When you identify an issue that is **related** / a **blocker** / a **follow-up** / the **origin** of the work in front of you, create the relation yourself — don't stage the candidates and wait for the human to name them, and don't hedge with "tell me if you want these linked."
|
|
257
|
+
|
|
258
|
+
- **At create time**, pass `--related-to "<ids>"` / `--parent <id>` / `--blocked-by <id>` directly on `issues create` — this path typically passes the classifier, so link generously the moment you file.
|
|
259
|
+
- **After the fact**, call `el-linear issues relate <source> --related-to "<ids>"` (or `--blocked-by` / `--blocks` / `--duplicate-of`) proactively, the same way.
|
|
260
|
+
- Over-linking is self-correcting (a wrong relation is one command to remove); under-linking is not. When in doubt, link.
|
|
261
|
+
|
|
262
|
+
The one exception is below: if the auto-mode permission classifier actually blocks a specific post-hoc `relate` call, that block — not caution — is your signal to surface the candidates for a one-word confirm.
|
|
263
|
+
|
|
240
264
|
### Existence check — before an "add capability X" issue ([DEV-5097](https://linear.app/verticalint/issue/DEV-5097/))
|
|
241
265
|
|
|
242
266
|
The dup-check above guards against duplicating an *issue*. This guards against duplicating *reality*: before filing an issue to **add** a flag / guard / command / subcommand, confirm it doesn't **already exist**.
|
|
@@ -248,38 +272,29 @@ Skipping it cost real rework across sessions: a hook guard and `--jq`/`--fields`
|
|
|
248
272
|
|
|
249
273
|
When `el-linear issues search` (or the cross-resource `search`) returns rows
|
|
250
274
|
carrying issue identifiers, the JSON envelope embeds a `_warnings` line
|
|
251
|
-
starting with `relation_candidates:`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
- **Calling `issues relate` directly off your own search output** — even if
|
|
273
|
-
the IDs are real and the candidates look obvious, this is the exact path
|
|
274
|
-
the auto-mode guard refuses.
|
|
275
|
-
- **Splitting one relate call into N single-ID calls** to "look smaller" —
|
|
276
|
-
same provenance problem, same block, just multiplied.
|
|
277
|
-
- **Asking the user a yes/no question** ("Should I link these?") instead of
|
|
278
|
-
having them name the IDs — yes answers stay agent-inferred, the reply
|
|
279
|
-
must carry the IDs to convert them to user-specified.
|
|
275
|
+
starting with `relation_candidates:` enumerating the candidate IDs. Treat it
|
|
276
|
+
as a **convenience list of link candidates, not a stop sign** — under the
|
|
277
|
+
proactive default above, relate the ones that are genuinely related/blocking
|
|
278
|
+
without waiting to be told. Prefer create-time `--related-to` when the search
|
|
279
|
+
ran as part of filing a new issue (create-time relations typically pass the
|
|
280
|
+
classifier); otherwise call `issues relate` directly.
|
|
281
|
+
|
|
282
|
+
**Residual auto-mode constraint (the only reason to pause).** Claude Code's
|
|
283
|
+
auto-mode permission classifier *may* block a standalone `issues relate
|
|
284
|
+
--related-to "<ids>"` when it judges the IDs *agent-inferred* (surfaced by
|
|
285
|
+
your own search) rather than *user-specified*, because each peer is a write
|
|
286
|
+
target. A standing user instruction to cross-link generously is itself
|
|
287
|
+
authorization — so proceed by default. But if a specific `relate` call is
|
|
288
|
+
**actually blocked** by the classifier, that block is your cue: surface the
|
|
289
|
+
candidate IDs to the user for a one-word confirm, then re-run with the
|
|
290
|
+
user-named IDs (which pass). This is the **exception path**, not the default —
|
|
291
|
+
do not pre-emptively withhold links the classifier would have allowed. Two
|
|
292
|
+
ways to keep it frictionless: file relations at create time (`--related-to`),
|
|
293
|
+
or the operator adds a permission rule / runs non-auto-mode so post-hoc
|
|
294
|
+
`relate` never trips.
|
|
280
295
|
|
|
281
296
|
If `--include-closed` search returns no matches, no `relation_candidates:`
|
|
282
|
-
warning is emitted
|
|
297
|
+
warning is emitted and the flow proceeds normally.
|
|
283
298
|
|
|
284
299
|
### Viewing existing relations
|
|
285
300
|
|
|
@@ -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.")
|
|
@@ -4,6 +4,7 @@ import { CREATE_COMMENT_MUTATION, DELETE_COMMENT_MUTATION, GET_COMMENT_QUERY, LI
|
|
|
4
4
|
import { autoLinkReferences, } from "../utils/auto-link-references.js";
|
|
5
5
|
import { applyFooter } from "../utils/footer.js";
|
|
6
6
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
7
|
+
import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
|
|
7
8
|
import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
|
|
8
9
|
import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
|
|
9
10
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
@@ -79,7 +80,7 @@ function readBody(options) {
|
|
|
79
80
|
return readFileSync(options.bodyFile, "utf-8");
|
|
80
81
|
}
|
|
81
82
|
if (options.body) {
|
|
82
|
-
return options.body;
|
|
83
|
+
return normalizeInlineTextInput(options.body);
|
|
83
84
|
}
|
|
84
85
|
throw new Error("Either --body or --body-file is required");
|
|
85
86
|
}
|
|
@@ -20,6 +20,7 @@ import fs from "node:fs";
|
|
|
20
20
|
import { loadConfig } from "../../config/config.js";
|
|
21
21
|
import { UPDATE_ISSUE_MUTATION } from "../../queries/issues.js";
|
|
22
22
|
import { autoLinkReferences, } from "../../utils/auto-link-references.js";
|
|
23
|
+
import { normalizeInlineTextInput } from "../../utils/inline-text-input.js";
|
|
23
24
|
import { extractIssueReferences } from "../../utils/issue-reference-extractor.js";
|
|
24
25
|
import { wrapIssueReferencesAsLinks } from "../../utils/issue-reference-wrapper.js";
|
|
25
26
|
import { validateReferences } from "../../utils/validate-references.js";
|
|
@@ -59,7 +60,7 @@ export function resolveDescription(options) {
|
|
|
59
60
|
return readDescriptionFile(options.descriptionFile);
|
|
60
61
|
}
|
|
61
62
|
if (hasInline) {
|
|
62
|
-
return options.description;
|
|
63
|
+
return normalizeInlineTextInput(options.description);
|
|
63
64
|
}
|
|
64
65
|
if (hasTemplate) {
|
|
65
66
|
const templates = loadConfig().descriptionTemplates ?? {};
|
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";
|
|
@@ -13,6 +14,7 @@ import { applyFooter } from "../utils/footer.js";
|
|
|
13
14
|
import { emitGateEvent } from "../utils/gate-telemetry.js";
|
|
14
15
|
import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
|
|
15
16
|
import { createGraphQLService, } from "../utils/graphql-service.js";
|
|
17
|
+
import { normalizeInlineTextInput } from "../utils/inline-text-input.js";
|
|
16
18
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
17
19
|
import { createLinearService, } from "../utils/linear-service.js";
|
|
18
20
|
import { logger } from "../utils/logger.js";
|
|
@@ -205,7 +207,8 @@ async function handleListIssues(options, command) {
|
|
|
205
207
|
// user passes --include-closed OR explicit --status. Explicit status
|
|
206
208
|
// wins because the user already named the workflow states they want.
|
|
207
209
|
const excludeTerminalStates = !options.includeClosed && explicitStatus === undefined;
|
|
208
|
-
const hasOtherFilters = options.
|
|
210
|
+
const hasOtherFilters = options.search ||
|
|
211
|
+
options.team ||
|
|
209
212
|
options.labels ||
|
|
210
213
|
options.status ||
|
|
211
214
|
options.assignee ||
|
|
@@ -223,6 +226,7 @@ async function handleListIssues(options, command) {
|
|
|
223
226
|
// (DEV-4478 cycle-1.)
|
|
224
227
|
if (hasOtherFilters || excludeTerminalStates || options.includeClosed) {
|
|
225
228
|
const searchArgs = {
|
|
229
|
+
query: options.search,
|
|
226
230
|
teamId: options.team ? resolveTeam(options.team) : undefined,
|
|
227
231
|
assigneeId: options.assignee
|
|
228
232
|
? await resolveAssignee(options.assignee, rootOpts)
|
|
@@ -244,8 +248,15 @@ async function handleListIssues(options, command) {
|
|
|
244
248
|
if (excludeTerminalStates) {
|
|
245
249
|
outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
|
|
246
250
|
}
|
|
251
|
+
if (options.search) {
|
|
252
|
+
const relationPrompt = buildRelationCandidatePrompt(result);
|
|
253
|
+
if (relationPrompt) {
|
|
254
|
+
outputWarning(relationPrompt);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
247
257
|
warnIfTruncated(result.length, limit);
|
|
248
258
|
outputIssues(result, command, {
|
|
259
|
+
query: options.search,
|
|
249
260
|
team: options.team,
|
|
250
261
|
});
|
|
251
262
|
}
|
|
@@ -330,7 +341,12 @@ async function withProjectResolverEnrichment(fn, options, services) {
|
|
|
330
341
|
throw err;
|
|
331
342
|
}
|
|
332
343
|
}
|
|
333
|
-
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) {
|
|
334
350
|
const config = loadConfig();
|
|
335
351
|
enforceTerms([title, options.description], { strict: options.strict });
|
|
336
352
|
// Effective assignee: explicit --assignee wins; --no-assignee (commander
|
|
@@ -359,10 +375,9 @@ async function resolveCreateInputs(title, options, rootOpts) {
|
|
|
359
375
|
const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
|
|
360
376
|
if (!options.skipValidation && !hasFromTemplate) {
|
|
361
377
|
const rawLabels = options.labels ? splitList(options.labels) : null;
|
|
362
|
-
const description = resolveDescription(options);
|
|
363
378
|
const validationResult = validateIssueCreation({
|
|
364
379
|
labels: rawLabels,
|
|
365
|
-
description:
|
|
380
|
+
description: resolvedDescription || undefined,
|
|
366
381
|
title,
|
|
367
382
|
assignee: effectiveAssignee,
|
|
368
383
|
project: options.project,
|
|
@@ -689,11 +704,84 @@ async function enforceSopLabelParent(labels, options, issuesService) {
|
|
|
689
704
|
// any unresolvable refs so a typo reads differently from a real non-SOP parent.
|
|
690
705
|
await decide("no-sop-parent", unresolvableRefs.length > 0 ? unresolvableRefs : undefined);
|
|
691
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
|
+
}
|
|
692
765
|
async function handleCreateIssue(title, options, command) {
|
|
693
766
|
const rootOpts = getRootOpts(command);
|
|
694
|
-
|
|
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);
|
|
695
783
|
const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
|
|
696
|
-
const descriptionWithAttachments = buildDescriptionWithAttachments(
|
|
784
|
+
const descriptionWithAttachments = buildDescriptionWithAttachments(rawDescription ?? "", uploadResults);
|
|
697
785
|
// Append messageFooter (config or --footer flag) so auto-link picks up any
|
|
698
786
|
// issue refs in the footer too. --no-footer skips both flag and config.
|
|
699
787
|
// Commander parses --no-footer as `options.footer === false`.
|
|
@@ -703,6 +791,21 @@ async function handleCreateIssue(title, options, command) {
|
|
|
703
791
|
footer: explicitFooter,
|
|
704
792
|
noFooter,
|
|
705
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
|
+
}
|
|
706
809
|
const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
|
|
707
810
|
// DEV-4823: deterministic duplicate-detection gate. Runs before the create
|
|
708
811
|
// POST, searches the title's salient keywords (including closed issues),
|
|
@@ -947,6 +1050,12 @@ async function handleUpdateIssue(issueId, options, command) {
|
|
|
947
1050
|
if (options.descriptionFile) {
|
|
948
1051
|
options.description = readDescriptionFile(options.descriptionFile);
|
|
949
1052
|
}
|
|
1053
|
+
else if (typeof options.description === "string") {
|
|
1054
|
+
options.description = normalizeInlineTextInput(options.description);
|
|
1055
|
+
}
|
|
1056
|
+
if (typeof options.appendDescription === "string") {
|
|
1057
|
+
options.appendDescription = normalizeInlineTextInput(options.appendDescription);
|
|
1058
|
+
}
|
|
950
1059
|
validateUpdateOptions(options);
|
|
951
1060
|
const rootOpts = getRootOpts(command);
|
|
952
1061
|
const { graphQLService, linearService, issuesService } = await createIssuesService(rootOpts);
|
|
@@ -1228,6 +1337,7 @@ export function setupIssuesCommands(program) {
|
|
|
1228
1337
|
.command("list")
|
|
1229
1338
|
.description("List issues.")
|
|
1230
1339
|
.option("-l, --limit <number>", "limit results", "25")
|
|
1340
|
+
.option("--search <query>", "full-text search term; composes with list filters")
|
|
1231
1341
|
.option("--team <team>", "filter by team key (EL: resolves names)")
|
|
1232
1342
|
.option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
|
|
1233
1343
|
.option("--delegate <delegate>", "filter by delegated agent (name, alias, or ID)")
|
|
@@ -1291,9 +1401,10 @@ export function setupIssuesCommands(program) {
|
|
|
1291
1401
|
.option("--due-date <date>", "due date (YYYY-MM-DD)")
|
|
1292
1402
|
.option("--checkout", "create and checkout a git branch named after the issue")
|
|
1293
1403
|
.option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
|
|
1294
|
-
.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)")
|
|
1295
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)")
|
|
1296
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')
|
|
1297
1408
|
.option("--no-auto-link", "skip auto-linking issue references found in the description")
|
|
1298
1409
|
.option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
|
|
1299
1410
|
.option("--no-footer", "skip the configured messageFooter for this issue")
|
package/dist/commands/labels.js
CHANGED
|
@@ -9,8 +9,9 @@ import { getRootOpts } from "../utils/root-opts.js";
|
|
|
9
9
|
import { parsePositiveInt, validateHexColor } from "../utils/validators.js";
|
|
10
10
|
async function handleCreateLabel(name, options, command) {
|
|
11
11
|
const rootOpts = getRootOpts(command);
|
|
12
|
-
const teamId = resolveTeam(options.team);
|
|
13
12
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
13
|
+
const linearService = await createLinearService(rootOpts);
|
|
14
|
+
const teamId = await linearService.resolveTeamId(resolveTeam(options.team));
|
|
14
15
|
const input = { name, teamId };
|
|
15
16
|
if (options.color) {
|
|
16
17
|
input.color = validateHexColor(options.color);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfig } from "../config/config.js";
|
|
2
2
|
import { resolveTeam } from "../config/resolver.js";
|
|
3
|
-
import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
3
|
+
import { ARCHIVE_PROJECT_MUTATION, CREATE_PROJECT_MUTATION, DELETE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, PROJECT_READ_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_FIELDS_MUTATION, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
|
|
4
4
|
import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
|
|
5
5
|
import { createGraphQLService } from "../utils/graphql-service.js";
|
|
6
6
|
import { createLinearService } from "../utils/linear-service.js";
|
|
@@ -229,6 +229,26 @@ function formatTeamsOutput(projectUpdate) {
|
|
|
229
229
|
})),
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
|
+
function hasOption(options, key) {
|
|
233
|
+
return options[key] !== undefined;
|
|
234
|
+
}
|
|
235
|
+
function flattenProjectUpdate(projectUpdate) {
|
|
236
|
+
if (!projectUpdate.project) {
|
|
237
|
+
throw new Error("Failed to update project");
|
|
238
|
+
}
|
|
239
|
+
const updatedProject = projectUpdate.project;
|
|
240
|
+
return {
|
|
241
|
+
id: updatedProject.id,
|
|
242
|
+
name: updatedProject.name,
|
|
243
|
+
description: updatedProject.description ?? undefined,
|
|
244
|
+
content: updatedProject.content ?? undefined,
|
|
245
|
+
teams: updatedProject.teams.nodes.map((t) => ({
|
|
246
|
+
id: t.id,
|
|
247
|
+
key: t.key,
|
|
248
|
+
name: t.name,
|
|
249
|
+
})),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
232
252
|
async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
|
|
233
253
|
const rootOpts = getRootOpts(command);
|
|
234
254
|
const graphQLService = await createGraphQLService(rootOpts);
|
|
@@ -437,6 +457,31 @@ async function handleReadProject(projectNameOrId, _options, command) {
|
|
|
437
457
|
teams: teams.nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
|
|
438
458
|
});
|
|
439
459
|
}
|
|
460
|
+
async function handleUpdateProject(projectNameOrId, options, command) {
|
|
461
|
+
const input = {};
|
|
462
|
+
if (hasOption(options, "name")) {
|
|
463
|
+
input.name = options.name;
|
|
464
|
+
}
|
|
465
|
+
if (hasOption(options, "description")) {
|
|
466
|
+
input.description = options.description;
|
|
467
|
+
}
|
|
468
|
+
if (hasOption(options, "content")) {
|
|
469
|
+
input.content = options.content;
|
|
470
|
+
}
|
|
471
|
+
if (Object.keys(input).length === 0) {
|
|
472
|
+
throw new Error("Nothing to update. Pass at least one of --name, --description, or --content.");
|
|
473
|
+
}
|
|
474
|
+
const rootOpts = getRootOpts(command);
|
|
475
|
+
const graphQLService = await createGraphQLService(rootOpts);
|
|
476
|
+
const linearService = await createLinearService(rootOpts);
|
|
477
|
+
const projectId = await linearService.resolveProjectId(projectNameOrId);
|
|
478
|
+
const updateResult = await graphQLService.rawRequest(UPDATE_PROJECT_FIELDS_MUTATION, { id: projectId, input });
|
|
479
|
+
const projectUpdate = updateResult.projectUpdate;
|
|
480
|
+
if (!projectUpdate.success) {
|
|
481
|
+
throw new Error(`Failed to update project "${projectNameOrId}"`);
|
|
482
|
+
}
|
|
483
|
+
outputSuccess(flattenProjectUpdate(projectUpdate));
|
|
484
|
+
}
|
|
440
485
|
export function setupProjectsCommands(program) {
|
|
441
486
|
const projects = program
|
|
442
487
|
.command("projects")
|
|
@@ -463,6 +508,13 @@ export function setupProjectsCommands(program) {
|
|
|
463
508
|
.command("read <project>")
|
|
464
509
|
.description("Read one project's full details (resolves name/slug/URL/ID). `--format summary` shows state, lead, teams, target, progress, url; JSON includes description/content.")
|
|
465
510
|
.action(handleAsyncCommand(handleReadProject));
|
|
511
|
+
projects
|
|
512
|
+
.command("update <project>")
|
|
513
|
+
.description("Update project name, short description, or markdown content")
|
|
514
|
+
.option("--name <name>", "project name")
|
|
515
|
+
.option("-d, --description <text>", "short summary (max 255 chars, shown in lists)")
|
|
516
|
+
.option("--content <markdown>", "full markdown body (shown in project panel)")
|
|
517
|
+
.action(handleAsyncCommand(handleUpdateProject));
|
|
466
518
|
projects
|
|
467
519
|
.command("list")
|
|
468
520
|
.description("List projects")
|
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
|