@enrichlayer/el-linear 1.20.0 → 1.22.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 CHANGED
@@ -553,6 +553,38 @@ el-linear read ADM-652 --field "Out of scope"
553
553
  Single-issue only — pair it with `--jq` on full JSON for batch
554
554
  extraction across many issues.
555
555
 
556
+ ### Print the whole description as raw text: `--body`
557
+
558
+ `issues read --body` prints the issue's **entire** description as raw
559
+ markdown — real newlines, no JSON envelope — instead of one named
560
+ section. Single-issue only; exits 1 with a stderr hint when the issue has
561
+ no description. This is the canonical replacement for
562
+ `... --format json | python3 -c "...['description']"`.
563
+
564
+ ```bash
565
+ el-linear issues read DEV-123 --body
566
+ # ## Why we need this
567
+ # ...full description body...
568
+ ```
569
+
570
+ Mutually exclusive with `--field` / `--sections` / `--with`.
571
+
572
+ ### One-line write confirmations: `-q, --quiet`
573
+
574
+ `issues create|update` and `comments create|update` accept `-q, --quiet`,
575
+ printing a single confirmation line instead of the full JSON envelope so
576
+ you don't have to `grep` the result:
577
+
578
+ ```bash
579
+ el-linear issues update DEV-123 --status "In Review" --quiet
580
+ # DEV-123 In Review https://linear.app/acme/issue/DEV-123/...
581
+
582
+ el-linear comments create DEV-123 --body "..." --quiet
583
+ # comment 6f1c…
584
+ ```
585
+
586
+ `--quiet` overrides `--format` and is independent of `--fields` / `--jq`.
587
+
556
588
  ### Render an issue's tree: `issues tree`
557
589
 
558
590
  `issues tree <ID>` walks the parent → children graph for an issue in a
@@ -599,6 +631,30 @@ with `--field`. (Named `--sections` rather than the seemingly-obvious
599
631
  `--fields` because `--fields` is already taken at the program level for
600
632
  output-key filtering — `el-linear` is the namespace owner.)
601
633
 
634
+ ### Opt-in includes: `--with`
635
+
636
+ `issues read --with <names>` adds extra blocks of related data to the
637
+ JSON envelope. Comma-separated; unknown values are rejected with the
638
+ candidate list.
639
+
640
+ Currently supported:
641
+
642
+ | Include | What it adds |
643
+ |---------|--------------|
644
+ | `relations` | A top-level `relations` array (outgoing + incoming cross-issue links), built from the same data as `issues related`. |
645
+
646
+ ```bash
647
+ # Issue + its sidebar relations in one call:
648
+ el-linear issues read DEV-123 --with relations
649
+
650
+ # Across multiple issues — relations fetched per issue in parallel:
651
+ el-linear read DEV-1 DEV-2 --with relations | jq '.data[].relations'
652
+ ```
653
+
654
+ `--with` is JSON-only — it composes with `--jq` / `--fields` / `--raw`,
655
+ and is mutually exclusive with `--field` (which prints raw section text,
656
+ no envelope).
657
+
602
658
  ## Wrapping Linear references in arbitrary text
603
659
 
604
660
  `el-linear refs wrap` takes plain text on stdin (or via `--file`) and rewrites
@@ -50,10 +50,20 @@ el-linear projects list --limit 50 2>&1 | head -100
50
50
  # ❌ Don't reach for jq just to print title + state
51
51
  el-linear issues read DEV-123 --jq '.title + " " + .state.name' 2>&1
52
52
 
53
+ # ❌ Don't pipe a read through python just to get the whole description body
54
+ el-linear issues read DEV-123 --format json 2>&1 | python3 -c "import json,sys; print(json.load(sys.stdin)['description'])"
55
+
56
+ # ❌ Don't grep a write's JSON for the new state / url
57
+ el-linear issues update DEV-123 --status Done 2>&1 | grep -iE 'state|url'
58
+
53
59
  # ✅ Just use --format summary
54
60
  el-linear issues search "..." --limit 10 --format summary 2>&1
55
61
  el-linear projects list --limit 50 --format summary 2>&1
56
62
  el-linear issues read DEV-123 --format summary 2>&1
63
+
64
+ # ✅ Whole description as raw text → --body. Terse write confirmation → --quiet
65
+ el-linear issues read DEV-123 --body 2>&1
66
+ el-linear issues update DEV-123 --status Done --quiet 2>&1
57
67
  ```
58
68
 
59
69
  The summary formatter exists exactly because every consumer (humans and LLMs) was reinventing the same `python -c` / `jq` extraction in shell. Pick the canonical path; the per-resource format is a stable contract.
@@ -72,6 +82,34 @@ el-linear issues read DEV-123 2>&1 | python3 -c "import json,sys; print(json.loa
72
82
 
73
83
  `--field` is single-issue only — for batch extraction, fall back to `--jq` on the full JSON.
74
84
 
85
+ ### The whole description as raw text: `--body`
86
+
87
+ When you want the **entire** description (not one section) as plain markdown — to read it, diff it, or pipe it to a file — use `issues read --body`. It prints the raw description with real newlines and no JSON envelope, single-issue only, and exits non-zero when the issue has no description. This is the canonical replacement for `... --format json | python3 -c "...['description']"` and the `sed 's/\\n/\n/g'` newline-unescaping hack.
88
+
89
+ ```bash
90
+ # ✅ Full description, raw markdown, scriptable
91
+ el-linear issues read DEV-123 --body 2>&1
92
+
93
+ # ❌ Don't do this
94
+ el-linear issues read DEV-123 --format json 2>&1 | python3 -c "import json,sys; print(json.load(sys.stdin)['description'])"
95
+ ```
96
+
97
+ `--body` is mutually exclusive with `--field` / `--sections` / `--with` (those extract named parts or extend the JSON envelope; `--body` is the whole thing as text).
98
+
99
+ ### Terse write confirmations: `-q, --quiet`
100
+
101
+ `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:
102
+
103
+ ```bash
104
+ el-linear issues update DEV-123 --status "In Review" --quiet 2>&1
105
+ # DEV-123 In Review https://linear.app/acme/issue/DEV-123/...
106
+
107
+ el-linear comments create DEV-123 --body "..." --quiet 2>&1
108
+ # comment <id>
109
+ ```
110
+
111
+ `--quiet` overrides `--format` (it's the whole point) and is independent of `--fields`/`--jq`.
112
+
75
113
  ### When you must reach outside el-linear: prefer `jq` over `python3 -c`
76
114
 
77
115
  For tools that aren't `el-linear` (e.g. `gh`, `glab`, `kubectl`), prefer `jq` for JSON extraction in one-shot shell commands. `python3 -c "import json,sys; ..."` produces longer, harder-to-read pipelines, and tends to attract incremental complexity (try/except, fallbacks) that `jq` handles inline. Reach for python only when the transformation genuinely needs control flow that's painful in `jq` (e.g. multi-step assembly with intermediate state).
@@ -201,11 +239,11 @@ el-linear issues link-references ENG-123 --dry-run # preview
201
239
 
202
240
  When starting work on an existing issue (`el-linear issues read ENG-123`):
203
241
 
204
- - [ ] **Assignee set** — if missing, ask user and update: `el-linear issues update ENG-123 --assignee <name>`.
242
+ - [ ] **Branch claim path used** — `el-linear issues create --checkout` and `el-linear issues mark-branch ENG-123` automatically claim the issue by assigning it to the current Linear user and moving it to the team's first started state. Use `--no-claim` only when intentionally creating/marking a branch on someone else's behalf.
205
243
  - [ ] **Project set** — if missing, ask user and update: `el-linear issues update ENG-123 --project "<name>"`.
206
- - [ ] **Status appropriate** — move to "In Progress" or your team's equivalent.
244
+ - [ ] **Status appropriate** — if you did not use one of the branch claim paths, move the issue to "In Progress" or your team's equivalent.
207
245
 
208
- Don't start implementation work on an unassigned issue. The assignee is the person accountable.
246
+ Don't start implementation work on an unassigned issue — the assignee is the person accountable. If you did not use one of the branch claim paths (or used `--no-claim`), assign the issue before proceeding.
209
247
 
210
248
  ---
211
249
 
@@ -291,6 +291,7 @@ export function setupCommentsCommands(program) {
291
291
  .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
292
292
  .option("--footer <text>", "text appended to the comment body (overrides config.messageFooter)")
293
293
  .option("--no-footer", "skip the configured messageFooter for this comment")
294
+ .option("-q, --quiet", "print one confirmation line (comment <id>) instead of the full JSON")
294
295
  .action(handleAsyncCommand(handleCreateComment));
295
296
  comments
296
297
  .command("update <commentId>")
@@ -299,6 +300,7 @@ export function setupCommentsCommands(program) {
299
300
  .option("--body-file <path>", "read new comment body from file")
300
301
  .option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
301
302
  .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
303
+ .option("-q, --quiet", "print one confirmation line (comment <id>) instead of the full JSON")
302
304
  .action(handleAsyncCommand(handleUpdateComment));
303
305
  comments
304
306
  .command("list <issueId>")
@@ -37,6 +37,24 @@ function isImageFile(filename) {
37
37
  const ext = filename.lastIndexOf(".");
38
38
  return ext !== -1 && IMAGE_EXTENSIONS.has(filename.slice(ext).toLowerCase());
39
39
  }
40
+ function errorMessage(error) {
41
+ return error instanceof Error ? error.message : String(error);
42
+ }
43
+ function warnClaimFailure(issueId, error) {
44
+ outputWarning(`Branch operation succeeded for ${issueId}, but auto-claim failed: ${errorMessage(error)}`);
45
+ }
46
+ async function maybeClaimBranchIssue(issueId, options, issuesService) {
47
+ if (options.claim === false) {
48
+ return undefined;
49
+ }
50
+ try {
51
+ return await issuesService.claimIssue(issueId);
52
+ }
53
+ catch (error) {
54
+ warnClaimFailure(issueId, error);
55
+ return undefined;
56
+ }
57
+ }
40
58
  function validateUpdateOptions(options) {
41
59
  if (options.delegate && options.clearDelegate) {
42
60
  throw new Error("Cannot use --delegate and --clear-delegate together");
@@ -497,6 +515,7 @@ async function handleCreateIssue(title, options, command) {
497
515
  }
498
516
  }
499
517
  let branch;
518
+ let claim;
500
519
  if (options.checkout && result.branchName) {
501
520
  const branchName = toBranchName(result.branchName);
502
521
  // Only treat the branch as created (and surface it in the output) when
@@ -516,11 +535,13 @@ async function handleCreateIssue(title, options, command) {
516
535
  outputWarning(`Created branch ${branch} but could not record its Linear issue marker. ` +
517
536
  `Run 'el-linear issues mark-branch ${result.identifier}' from the branch to set it.`);
518
537
  }
538
+ claim = await maybeClaimBranchIssue(result.identifier, options, issuesService);
519
539
  }
520
540
  }
521
541
  const output = {
522
542
  ...result,
523
543
  ...(branch ? { branch } : {}),
544
+ ...(claim ? { claim } : {}),
524
545
  ...(relations.length > 0 ? { relations } : {}),
525
546
  ...(autoLinked ? { autoLinked } : {}),
526
547
  ...(attachments.length === 1
@@ -852,7 +873,7 @@ async function handleRetrolink(options, command) {
852
873
  ...(newBranch ? { branch: newBranch } : {}),
853
874
  });
854
875
  }
855
- async function handleMarkBranch(issueId, _options, _command) {
876
+ async function handleMarkBranch(issueId, options, command) {
856
877
  const branch = currentGitBranch();
857
878
  if (!branch) {
858
879
  throw new Error("Not on a named git branch (detached HEAD or not in a repository).");
@@ -882,10 +903,22 @@ async function handleMarkBranch(issueId, _options, _command) {
882
903
  }
883
904
  const previous = getBranchLinearIssue(branch);
884
905
  setBranchLinearIssue(branch, identifier);
906
+ let claim;
907
+ if (options.claim !== false) {
908
+ try {
909
+ const rootOpts = getRootOpts(command);
910
+ const { issuesService } = await createIssuesService(rootOpts);
911
+ claim = await issuesService.claimIssue(identifier);
912
+ }
913
+ catch (error) {
914
+ warnClaimFailure(identifier, error);
915
+ }
916
+ }
885
917
  outputSuccess({
886
918
  branch,
887
919
  linearIssue: identifier,
888
920
  ...(previous && previous !== identifier ? { previous } : {}),
921
+ ...(claim ? { claim } : {}),
889
922
  marked: true,
890
923
  });
891
924
  }
@@ -965,10 +998,12 @@ export function setupIssuesCommands(program) {
965
998
  .option("--attachment <path>", "attach a file (image, PDF, etc.) to the created issue (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
966
999
  .option("--due-date <date>", "due date (YYYY-MM-DD)")
967
1000
  .option("--checkout", "create and checkout a git branch named after the issue")
1001
+ .option("--no-claim", "with --checkout, skip assigning the issue to the current Linear user and moving it to the first started state")
968
1002
  .option("--skip-validation", "skip all validation (labels, description, assignee, project)")
969
1003
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
970
1004
  .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
971
1005
  .option("--no-footer", "skip the configured messageFooter for this issue")
1006
+ .option("-q, --quiet", "print one confirmation line (IDENTIFIER STATE URL) instead of the full JSON")
972
1007
  .action(handleAsyncCommand((titleArg, options, command) => {
973
1008
  // Normalize --label alias to --labels
974
1009
  if (options.label && !options.labels) {
@@ -994,13 +1029,20 @@ export function setupIssuesCommands(program) {
994
1029
  .alias("get")
995
1030
  .alias("show")
996
1031
  .description("Get issue details. Accepts multiple IDs for batch retrieval.")
1032
+ .option("--body", "Print the issue's full description as raw markdown text — no JSON " +
1033
+ "envelope, single-issue only. Exits non-zero if the issue has no " +
1034
+ "description. The whole-body sibling of --field; mutually exclusive " +
1035
+ "with --field / --sections / --with.")
997
1036
  .option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
998
1037
  "Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
999
1038
  "Outputs the section text only — no JSON envelope. Single-issue only.")
1000
1039
  .option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope"). ' +
1001
1040
  "Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
1002
1041
  "Sibling of --field (singular). Named --sections rather than --fields because the program already has a global --fields for output-key filtering.")
1003
- .addHelpText("after", '\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789\nExtract a section: el-linear issue read DEV-123 --field "Done when"\nMulti-section: el-linear issue read DEV-123 --sections "Done when,Out of scope"')
1042
+ .option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
1043
+ 'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
1044
+ "(adds an array of cross-issue relations under a top-level `relations` key).")
1045
+ .addHelpText("after", '\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789\nFull description: el-linear issue read DEV-123 --body\nExtract a section: el-linear issue read DEV-123 --field "Done when"\nMulti-section: el-linear issue read DEV-123 --sections "Done when,Out of scope"\nWith relations: el-linear issue read DEV-123 --with relations')
1004
1046
  .action(handleAsyncCommand(readIssues));
1005
1047
  issues
1006
1048
  .command("update <issueId>")
@@ -1038,6 +1080,7 @@ export function setupIssuesCommands(program) {
1038
1080
  .option("--blocks <issues>", "issues this blocks (comma-separated)")
1039
1081
  .option("--blocked-by <issues>", "issues blocking this (comma-separated)")
1040
1082
  .option("--duplicate-of <issue>", "mark as duplicate of another issue")
1083
+ .option("-q, --quiet", "print one confirmation line (IDENTIFIER STATE URL) instead of the full JSON")
1041
1084
  .action(handleAsyncCommand(handleUpdateIssue));
1042
1085
  issues
1043
1086
  .command("archive <issueId>")
@@ -1103,6 +1146,8 @@ export function setupIssuesCommands(program) {
1103
1146
  .description("Record the current branch's Linear issue as git metadata (branch.<branch>.linearIssue).")
1104
1147
  .addHelpText("after", "\nManual recovery for branches not created via 'issues create --checkout' / 'retrolink'.\n" +
1105
1148
  "With no argument, infers the identifier from the branch name (e.g. dev-4293-slug -> DEV-4293).\n" +
1106
- "Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.")
1149
+ "Accepts a bare identifier (DEV-123), a branch-style token, or a Linear URL.\n" +
1150
+ "By default, also claims the issue (assignee = current Linear user; status = first started state). Pass --no-claim to only write git metadata.")
1151
+ .option("--no-claim", "only record git metadata; skip assigning the issue to the current Linear user and moving it to the first started state")
1107
1152
  .action(handleAsyncCommand(handleMarkBranch));
1108
1153
  }
@@ -1,9 +1,12 @@
1
+ import { GET_ISSUE_RELATIONS_QUERY } from "../queries/issues.js";
1
2
  import { downloadLinearUploads } from "../utils/download-uploads.js";
2
3
  import { extractField, extractFields } from "../utils/extract-field.js";
3
4
  import { createFileService } from "../utils/file-service.js";
4
5
  import { createIssuesService } from "../utils/issues-service-bootstrap.js";
5
6
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
6
7
  import { getRootOpts } from "../utils/root-opts.js";
8
+ import { parseWithIncludes, } from "../utils/with-includes.js";
9
+ import { buildIncomingRelationEntries, buildOutgoingRelationEntries, } from "./issues/relations.js";
7
10
  /**
8
11
  * Issue ID pattern: 1-5 uppercase letters, dash, 1+ digits (e.g. ADM-652, DEV-12).
9
12
  */
@@ -20,13 +23,20 @@ export function setupReadShortcut(program) {
20
23
  .alias("view")
21
24
  .alias("show")
22
25
  .description("Shortcut for `issues read`. Get issue details by identifier.")
26
+ .option("--body", "Print the issue's full description as raw markdown text — no JSON " +
27
+ "envelope, single-issue only. Exits non-zero if the issue has no " +
28
+ "description. The whole-body sibling of --field; mutually exclusive " +
29
+ "with --field / --sections / --with.")
23
30
  .option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
24
31
  "Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
25
32
  "Outputs the section text only — no JSON envelope.")
26
33
  .option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope,Steps"). ' +
27
34
  "Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
28
35
  "Sibling of --field (singular). Named --sections rather than --fields because the program already has a global --fields for output-key filtering.")
29
- .addHelpText("after", '\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)\n el-linear read DEV-123 --field "Done when" (just that section)\n el-linear read DEV-123 --sections "Done when,Out of scope"')
36
+ .option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
37
+ 'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
38
+ "(adds an array of cross-issue relations under a top-level `relations` key).")
39
+ .addHelpText("after", '\nExamples:\n el-linear read ADM-652\n el-linear get DEV-123 DEV-456\n el-linear ADM-652 (auto-detected)\n el-linear read DEV-123 --body (full description, raw text)\n el-linear read DEV-123 --field "Done when" (just that section)\n el-linear read DEV-123 --sections "Done when,Out of scope"\n el-linear read DEV-123 --with relations (issue + cross-issue links)')
30
40
  .action(handleAsyncCommand(readIssues));
31
41
  // Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
32
42
  const originalParse = program.parse.bind(program);
@@ -61,19 +71,33 @@ export function setupReadShortcut(program) {
61
71
  */
62
72
  export async function readIssues(issueIds, options, command) {
63
73
  const rootOpts = getRootOpts(command);
64
- const { issuesService } = await createIssuesService(rootOpts);
74
+ const { graphQLService, issuesService } = await createIssuesService(rootOpts);
65
75
  const fileService = await createFileService(rootOpts);
66
76
  const fieldName = typeof options.field === "string" ? options.field : null;
77
+ const bodyOnly = options.body === true;
67
78
  const sectionsRaw = typeof options.sections === "string" ? options.sections : null;
79
+ // DEV-4476: --with opt-in includes (currently `relations`). Throws on
80
+ // unknown values via parseWithIncludes — fail fast in the CLI per the
81
+ // deterministic-CLI doctrine.
82
+ const includes = typeof options.with === "string"
83
+ ? parseWithIncludes(options.with)
84
+ : { relations: false };
68
85
  if (fieldName && sectionsRaw) {
69
86
  throw new Error("--field and --sections are mutually exclusive. Use --field for a single section (plain-text output) or --sections for multiple (JSON map).");
70
87
  }
71
- // Both --field and --sections are single-issue only — section extraction
72
- // can't sensibly fan out to N different bodies, the caller almost always
73
- // wants the named sections of one issue.
74
- if ((fieldName || sectionsRaw) && issueIds.length > 1) {
75
- throw new Error("--field / --sections are single-issue only; pass exactly one issueId. " +
76
- "For multiple issues, drop the section flags and use --jq or --format summary.");
88
+ // --body prints the WHOLE description as raw text (no envelope) — it's the
89
+ // "all sections" sibling of --field. Pairing it with section extraction or
90
+ // --with would produce two conflicting output shapes, so reject up front.
91
+ if (bodyOnly && (fieldName || sectionsRaw || includes.relations)) {
92
+ throw new Error("--body is mutually exclusive with --field / --sections / --with. " +
93
+ "Use --body for the entire description, --field/--sections for named parts.");
94
+ }
95
+ // --field / --sections / --body are single-issue only — raw text extraction
96
+ // can't sensibly fan out to N different bodies; the caller almost always
97
+ // wants the named sections (or full body) of one issue.
98
+ if ((fieldName || sectionsRaw || bodyOnly) && issueIds.length > 1) {
99
+ throw new Error("--field / --sections / --body are single-issue only; pass exactly one issueId. " +
100
+ "For multiple issues, drop those flags and use --jq or --format summary.");
77
101
  }
78
102
  // Parse the comma-separated --sections list once. Preserve the caller's
79
103
  // order, trim each entry, and drop empties so trailing commas don't
@@ -87,9 +111,26 @@ export async function readIssues(issueIds, options, command) {
87
111
  if (sectionNames !== null && sectionNames.length === 0) {
88
112
  throw new Error("--sections was empty after trimming. Pass a comma-separated list of section names.");
89
113
  }
114
+ // --field is also an extract-this-section operation; pairing it with
115
+ // --with would produce ambiguous output (section text vs. JSON envelope).
116
+ // Reject up front rather than silently dropping one.
117
+ if (fieldName && includes.relations) {
118
+ throw new Error("--field and --with are mutually exclusive (--field outputs raw section text; --with extends the JSON envelope).");
119
+ }
90
120
  if (issueIds.length === 1) {
91
121
  const issue = await issuesService.getIssueById(issueIds[0]);
92
122
  const resolved = await downloadLinearUploads(issue, fileService);
123
+ if (bodyOnly) {
124
+ const description = resolved.description ?? "";
125
+ if (description.trim() === "") {
126
+ // Mirror --field's "not found" contract: nothing on stdout,
127
+ // exit non-zero with a stderr hint so scripts can branch.
128
+ process.stderr.write(`el-linear: ${resolved.identifier} has no description\n`);
129
+ process.exit(1);
130
+ }
131
+ process.stdout.write(`${description}\n`);
132
+ return;
133
+ }
93
134
  if (fieldName) {
94
135
  const section = extractField(resolved.description ?? "", fieldName);
95
136
  if (section === null) {
@@ -124,7 +165,15 @@ export async function readIssues(issueIds, options, command) {
124
165
  });
125
166
  return;
126
167
  }
127
- outputSuccess(resolved);
168
+ // DEV-4476: --with relations (mutually exclusive with --sections, which
169
+ // returns above). Enrich the single-issue envelope when requested.
170
+ const envelope = includes.relations
171
+ ? {
172
+ ...resolved,
173
+ relations: await fetchRelations(graphQLService, resolved.id),
174
+ }
175
+ : resolved;
176
+ outputSuccess(envelope);
128
177
  }
129
178
  else {
130
179
  // DEV-4477: one batched GraphQL call instead of N parallel single-issue
@@ -133,7 +182,45 @@ export async function readIssues(issueIds, options, command) {
133
182
  // that's HTTP, not GraphQL, and downloadLinearUploads is a no-op when
134
183
  // there's nothing to download.
135
184
  const issues = await issuesService.getIssuesByRefs(issueIds);
136
- const results = await Promise.all(issues.map((issue) => downloadLinearUploads(issue, fileService)));
185
+ const results = await Promise.all(
186
+ // Apply --with relations over the batch-fetched issues (DEV-4476),
187
+ // preserving DEV-4477's single batched GraphQL fetch above — don't
188
+ // re-fetch per id, which would defeat the batch optimization.
189
+ issues.map(async (issue) => {
190
+ const resolved = await downloadLinearUploads(issue, fileService);
191
+ if (!includes.relations) {
192
+ return resolved;
193
+ }
194
+ return {
195
+ ...resolved,
196
+ relations: await fetchRelations(graphQLService, resolved.id),
197
+ };
198
+ }));
137
199
  outputSuccess(results);
138
200
  }
139
201
  }
202
+ /**
203
+ * Fetch relations for a known-UUID issue and flatten outgoing + incoming
204
+ * via the shared relations builders. `relatedIssue` / `issue` peers that
205
+ * Linear omits (rare — deleted-relation edge case) are skipped, matching
206
+ * the `issues related` command's behavior.
207
+ *
208
+ * Race semantics: when `result.issue` is null (the issue existed at the
209
+ * `getIssueById` call upstream but is missing here — Linear deleted /
210
+ * unarchived it between the two calls), returns `[]` rather than
211
+ * throwing. The base issue is still emitted, and the caller sees an
212
+ * empty `relations` array — preferable to failing the whole envelope
213
+ * for a rare race. This intentionally diverges from `handleRelatedIssues`
214
+ * (which throws), because that command's *primary* output is relations,
215
+ * whereas here relations are an opt-in side dish.
216
+ */
217
+ async function fetchRelations(graphQLService, issueId) {
218
+ const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, { id: issueId });
219
+ if (!result.issue) {
220
+ return [];
221
+ }
222
+ return [
223
+ ...buildOutgoingRelationEntries(result.issue.relations.nodes),
224
+ ...buildIncomingRelationEntries(result.issue.inverseRelations.nodes),
225
+ ];
226
+ }
package/dist/main.js CHANGED
@@ -31,7 +31,7 @@ import { setActiveProfileForSession } from "./config/paths.js";
31
31
  import { initCliSentry } from "./sentry.js";
32
32
  import { logger } from "./utils/logger.js";
33
33
  import { applyIpv4Preference } from "./utils/network-preference.js";
34
- import { setFieldsFilter, setJqFilter, setOutputFormat, setRawMode, } from "./utils/output.js";
34
+ import { setFieldsFilter, setJqFilter, setOutputFormat, setQuietMode, setRawMode, } from "./utils/output.js";
35
35
  import { outputUsageInfo } from "./utils/usage.js";
36
36
  import { splitList } from "./utils/validators.js";
37
37
  // Prefer IPv4 for outbound API calls before any network I/O (Sentry init or a
@@ -79,6 +79,12 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
79
79
  if (rootOpts.fields) {
80
80
  setFieldsFilter(splitList(rootOpts.fields));
81
81
  }
82
+ // --quiet is registered only on the write commands (issues/comments
83
+ // create|update); it surfaces here via optsWithGlobals. One confirmation
84
+ // line instead of the full envelope — see setQuietMode.
85
+ if (rootOpts.quiet) {
86
+ setQuietMode(true);
87
+ }
82
88
  // Subcommand --format wins over the global flag (commander resolves
83
89
  // options closest to the action), so `optsWithGlobals` returns the
84
90
  // subcommand value when one is set. We accept any of the per-command
@@ -214,6 +214,29 @@ export interface IssueStartContextResponse {
214
214
  delegate: AssigneeNode | null;
215
215
  } | null;
216
216
  }
217
+ export interface IssueClaimContextResponse {
218
+ viewer: {
219
+ id: string;
220
+ name: string;
221
+ displayName: string;
222
+ email: string;
223
+ } | null;
224
+ issue: {
225
+ id: string;
226
+ identifier: string;
227
+ state: {
228
+ id: string;
229
+ name: string;
230
+ type: WorkflowStateType;
231
+ } | null;
232
+ assignee: AssigneeNode | null;
233
+ team: {
234
+ id: string;
235
+ key: string;
236
+ name: string;
237
+ } | null;
238
+ } | null;
239
+ }
217
240
  export interface TeamStartedStatusesResponse {
218
241
  team: {
219
242
  states: {
@@ -80,6 +80,12 @@ export declare function buildResolveLabelsByNameQuery(labelNames: string[]): {
80
80
  export declare const GET_ISSUE_STATE_HISTORY_QUERY = "\n query GetIssueStateHistory($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n stateHistory {\n nodes {\n state { id name type }\n startedAt\n endedAt\n }\n }\n }\n }\n";
81
81
  export declare const GET_ISSUE_TEAM_QUERY = "\n query GetIssueTeam($issueId: String!) {\n issue(id: $issueId) {\n team { id }\n }\n }\n";
82
82
  export declare const GET_ISSUE_START_CONTEXT_QUERY = "\n query GetIssueStartContext($id: String!) {\n issue(id: $id) {\n id\n identifier\n state { id name type }\n team { id key name }\n delegate { id name url }\n }\n }\n";
83
+ /**
84
+ * Batch issue lifecycle/assignee context with the authenticated viewer for the
85
+ * branch auto-claim flow. Raw GraphQL is used because this needs `viewer` plus
86
+ * issue fields in one round-trip; the SDK would make separate calls.
87
+ */
88
+ export declare const GET_ISSUE_CLAIM_CONTEXT_QUERY = "\n query GetIssueClaimContext($id: String!) {\n viewer {\n id\n name\n displayName\n email\n }\n issue(id: $id) {\n id\n identifier\n state { id name type }\n assignee { id name url }\n team { id key name }\n }\n }\n";
83
89
  export declare const TEAM_STARTED_STATUSES_QUERY = "\n query TeamStartedStatuses($teamId: String!) {\n team(id: $teamId) {\n states(filter: { type: { eq: \"started\" } }) {\n nodes {\n id\n name\n position\n }\n }\n }\n }\n";
84
90
  export declare const ISSUE_RELATION_CREATE_MUTATION = "\n mutation IssueRelationCreate($input: IssueRelationCreateInput!) {\n issueRelationCreate(input: $input) {\n success\n issueRelation {\n id\n type\n issue {\n id\n identifier\n title\n }\n relatedIssue {\n id\n identifier\n title\n }\n }\n }\n }\n";
85
91
  export declare const GET_ISSUE_RELATIONS_QUERY = "\n query GetIssueRelations($id: String!) {\n issue(id: $id) {\n id\n identifier\n title\n description\n relations {\n nodes {\n id\n type\n relatedIssue {\n id\n identifier\n title\n state { id name }\n priority\n assignee { id name }\n team { id key name }\n }\n }\n }\n inverseRelations {\n nodes {\n id\n type\n issue {\n id\n identifier\n title\n state { id name }\n priority\n assignee { id name }\n team { id key name }\n }\n }\n }\n }\n }\n";
@@ -472,6 +472,28 @@ export const GET_ISSUE_START_CONTEXT_QUERY = `
472
472
  }
473
473
  }
474
474
  `;
475
+ /**
476
+ * Batch issue lifecycle/assignee context with the authenticated viewer for the
477
+ * branch auto-claim flow. Raw GraphQL is used because this needs `viewer` plus
478
+ * issue fields in one round-trip; the SDK would make separate calls.
479
+ */
480
+ export const GET_ISSUE_CLAIM_CONTEXT_QUERY = `
481
+ query GetIssueClaimContext($id: String!) {
482
+ viewer {
483
+ id
484
+ name
485
+ displayName
486
+ email
487
+ }
488
+ issue(id: $id) {
489
+ id
490
+ identifier
491
+ state { id name type }
492
+ assignee { id name url }
493
+ team { id key name }
494
+ }
495
+ }
496
+ `;
475
497
  export const TEAM_STARTED_STATUSES_QUERY = `
476
498
  query TeamStartedStatuses($teamId: String!) {
477
499
  team(id: $teamId) {
@@ -60,3 +60,21 @@ export declare function inferKindFromPayload(value: unknown): ResourceKind;
60
60
  * array or a `{ data: [...] }` envelope — both are handled.
61
61
  */
62
62
  export declare function dispatch(kind: ResourceKind, payload: unknown): string;
63
+ /**
64
+ * One-line confirmation render for the `--quiet` write path.
65
+ *
66
+ * Writes (`issues create|update`, `comments create|update`) otherwise emit
67
+ * the full JSON envelope; agents then `grep` it for the identifier / state /
68
+ * url. `--quiet` routes the same payload here so the caller gets exactly one
69
+ * machine-stable line and nothing else.
70
+ *
71
+ * - issue → `IDENTIFIER STATE URL` (two-space separated, matching the
72
+ * summary header style). Empty fields collapse to `-`.
73
+ * - comment → `comment <id>` (the create/update mutation doesn't fetch a url
74
+ * or the parent identifier, so the id — what you need to edit or
75
+ * reference the comment — is the stable handle).
76
+ * - anything else → compact single-line JSON, so the contract ("one line,
77
+ * always parseable") holds even for payloads with no dedicated
78
+ * shape.
79
+ */
80
+ export declare function formatLine(payload: unknown): string;
@@ -758,3 +758,33 @@ export function dispatch(kind, payload) {
758
758
  return formatGenericSummary(payload);
759
759
  }
760
760
  }
761
+ /**
762
+ * One-line confirmation render for the `--quiet` write path.
763
+ *
764
+ * Writes (`issues create|update`, `comments create|update`) otherwise emit
765
+ * the full JSON envelope; agents then `grep` it for the identifier / state /
766
+ * url. `--quiet` routes the same payload here so the caller gets exactly one
767
+ * machine-stable line and nothing else.
768
+ *
769
+ * - issue → `IDENTIFIER STATE URL` (two-space separated, matching the
770
+ * summary header style). Empty fields collapse to `-`.
771
+ * - comment → `comment <id>` (the create/update mutation doesn't fetch a url
772
+ * or the parent identifier, so the id — what you need to edit or
773
+ * reference the comment — is the stable handle).
774
+ * - anything else → compact single-line JSON, so the contract ("one line,
775
+ * always parseable") holds even for payloads with no dedicated
776
+ * shape.
777
+ */
778
+ export function formatLine(payload) {
779
+ const kind = inferKindFromPayload(payload);
780
+ const obj = asObj(payload);
781
+ if (kind === "issue" && obj) {
782
+ // s()/getName() already render missing values as the em-dash placeholder
783
+ // used throughout the summary formatter, keeping --quiet consistent.
784
+ return `${s(obj.identifier)} ${getName(obj.state)} ${s(obj.url)}`;
785
+ }
786
+ if (kind === "comment" && obj) {
787
+ return `comment ${s(obj.id)}`;
788
+ }
789
+ return JSON.stringify(payload);
790
+ }
@@ -151,6 +151,33 @@ export interface StartIssueResult {
151
151
  name: string;
152
152
  };
153
153
  }
154
+ export interface ClaimIssueResult {
155
+ issue: LinearIssue;
156
+ claimed: boolean;
157
+ /** True when the issue was already started and assigned to the viewer, so the claim was a no-op. */
158
+ alreadyClaimed: boolean;
159
+ assigned: boolean;
160
+ started: boolean;
161
+ assignee: {
162
+ id: string;
163
+ name: string;
164
+ displayName: string;
165
+ email: string;
166
+ };
167
+ previousAssignee?: {
168
+ id: string;
169
+ name: string;
170
+ };
171
+ previousState?: {
172
+ id: string;
173
+ name: string;
174
+ type: string;
175
+ };
176
+ targetState?: {
177
+ id: string;
178
+ name: string;
179
+ };
180
+ }
154
181
  export declare class GraphQLIssuesService {
155
182
  private readonly graphQLService;
156
183
  private readonly linearService;
@@ -175,7 +202,9 @@ export declare class GraphQLIssuesService {
175
202
  * for the read handler — the JSON envelope is a positional array.
176
203
  */
177
204
  getIssuesByRefs(refs: string[]): Promise<LinearIssue[]>;
205
+ private getFirstStartedStatus;
178
206
  startIssue(issueId: string): Promise<StartIssueResult>;
207
+ claimIssue(issueId: string): Promise<ClaimIssueResult>;
179
208
  updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
180
209
  archiveIssue(issueId: string): Promise<IssueArchiveOperationResult>;
181
210
  deleteIssue(issueId: string, options?: {
@@ -1,5 +1,5 @@
1
1
  import { resolveUserDisplayName } from "../config/resolver.js";
2
- import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
2
+ import { ARCHIVE_ISSUE_MUTATION, BATCH_GET_ISSUES_QUERY, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_CLAIM_CONTEXT_QUERY, GET_ISSUE_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
3
3
  import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
4
4
  import { toISOStringOrNow } from "./date-format.js";
5
5
  import { extractEmbeds } from "./embed-parser.js";
@@ -150,6 +150,16 @@ export class GraphQLIssuesService {
150
150
  }
151
151
  return ordered.map((issue) => this.transformIssueData(issue));
152
152
  }
153
+ async getFirstStartedStatus(team) {
154
+ const statuses = await this.graphQLService.rawRequest(TEAM_STARTED_STATUSES_QUERY, { teamId: team.id });
155
+ const startedStatus = statuses.team?.states.nodes
156
+ .slice()
157
+ .sort((a, b) => a.position - b.position)[0];
158
+ if (!startedStatus) {
159
+ throw new Error(`Team ${team.key} has no workflow status of type "started".`);
160
+ }
161
+ return { id: startedStatus.id, name: startedStatus.name };
162
+ }
153
163
  async startIssue(issueId) {
154
164
  const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
155
165
  const context = await this.graphQLService.rawRequest(GET_ISSUE_START_CONTEXT_QUERY, { id: resolvedIssueId });
@@ -175,13 +185,7 @@ export class GraphQLIssuesService {
175
185
  if (!issue.team?.id) {
176
186
  throw new Error(`Issue ${issue.identifier} has no team; cannot start it.`);
177
187
  }
178
- const statuses = await this.graphQLService.rawRequest(TEAM_STARTED_STATUSES_QUERY, { teamId: issue.team.id });
179
- const startedStatus = statuses.team?.states.nodes
180
- .slice()
181
- .sort((a, b) => a.position - b.position)[0];
182
- if (!startedStatus) {
183
- throw new Error(`Team ${issue.team.key} has no workflow status of type "started".`);
184
- }
188
+ const startedStatus = await this.getFirstStartedStatus(issue.team);
185
189
  const updated = await this.updateIssue({ id: resolvedIssueId, statusId: startedStatus.id }, "adding");
186
190
  return {
187
191
  issue: updated,
@@ -190,6 +194,68 @@ export class GraphQLIssuesService {
190
194
  targetState: { id: startedStatus.id, name: startedStatus.name },
191
195
  };
192
196
  }
197
+ async claimIssue(issueId) {
198
+ const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
199
+ const context = await this.graphQLService.rawRequest(GET_ISSUE_CLAIM_CONTEXT_QUERY, { id: resolvedIssueId });
200
+ const issue = context.issue;
201
+ if (!issue) {
202
+ throw notFoundError("Issue", issueId);
203
+ }
204
+ const viewer = context.viewer;
205
+ if (!viewer?.id) {
206
+ throw new Error("Could not resolve the authenticated Linear viewer.");
207
+ }
208
+ const previousState = issue.state
209
+ ? {
210
+ id: issue.state.id,
211
+ name: issue.state.name,
212
+ type: issue.state.type,
213
+ }
214
+ : undefined;
215
+ const previousAssignee = issue.assignee
216
+ ? { id: issue.assignee.id, name: issue.assignee.name }
217
+ : undefined;
218
+ const terminalState = previousState && ["completed", "canceled"].includes(previousState.type);
219
+ const needsStartedState = !terminalState && previousState?.type !== "started";
220
+ const needsAssignee = issue.assignee?.id !== viewer.id;
221
+ if (!needsStartedState && !needsAssignee) {
222
+ // This path is only reachable when the viewer is already the assignee
223
+ // (`needsAssignee` is false), so `assignee: viewer` is the current assignee.
224
+ return {
225
+ issue: await this.getIssueById(resolvedIssueId),
226
+ previousState,
227
+ previousAssignee,
228
+ claimed: false,
229
+ alreadyClaimed: true,
230
+ assigned: false,
231
+ started: false,
232
+ assignee: viewer,
233
+ };
234
+ }
235
+ let targetState;
236
+ if (needsStartedState) {
237
+ if (!issue.team?.id) {
238
+ throw new Error(`Issue ${issue.identifier} has no team; cannot start it.`);
239
+ }
240
+ targetState = await this.getFirstStartedStatus(issue.team);
241
+ }
242
+ const updated = await this.updateIssue({
243
+ id: resolvedIssueId,
244
+ ...(targetState ? { statusId: targetState.id } : {}),
245
+ ...(needsAssignee ? { assigneeId: viewer.id } : {}),
246
+ }, "adding");
247
+ return {
248
+ issue: updated,
249
+ previousState,
250
+ previousAssignee,
251
+ ...(targetState ? { targetState } : {}),
252
+ claimed: true,
253
+ alreadyClaimed: false,
254
+ assigned: needsAssignee,
255
+ started: Boolean(targetState),
256
+ assignee: viewer,
257
+ };
258
+ }
193
259
  async updateIssue(args, labelMode = "overwriting") {
194
260
  // Normalize URL/slug-id --project inputs to UUIDs before the batch
195
261
  // resolver runs; see comment on `withNormalizedProjectId`.
@@ -1,5 +1,11 @@
1
1
  type OutputFormat = "json" | "summary";
2
2
  export declare function setRawMode(enabled: boolean): void;
3
+ /**
4
+ * `--quiet` (write commands only): collapse the success payload to a single
5
+ * confirmation line via `formatLine`, bypassing both the JSON envelope and
6
+ * the summary block. Set in main.ts's preAction when the flag is present.
7
+ */
8
+ export declare function setQuietMode(enabled: boolean): void;
3
9
  export declare function setJqFilter(filter: string | null): void;
4
10
  export declare function setFieldsFilter(fields: string[] | null): void;
5
11
  export declare function setOutputFormat(format: OutputFormat): void;
@@ -1,15 +1,24 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { dispatch as dispatchSummary, inferKindFromPayload, } from "./formatters/summary.js";
2
+ import { dispatch as dispatchSummary, formatLine, inferKindFromPayload, } from "./formatters/summary.js";
3
3
  import { logger } from "./logger.js";
4
4
  import { sanitizeForLog } from "./sanitize-for-log.js";
5
5
  const warningBuffer = [];
6
6
  let rawMode = false;
7
+ let quietMode = false;
7
8
  let jqFilter = null;
8
9
  let fieldsFilter = null;
9
10
  let outputFormat = "json";
10
11
  export function setRawMode(enabled) {
11
12
  rawMode = enabled;
12
13
  }
14
+ /**
15
+ * `--quiet` (write commands only): collapse the success payload to a single
16
+ * confirmation line via `formatLine`, bypassing both the JSON envelope and
17
+ * the summary block. Set in main.ts's preAction when the flag is present.
18
+ */
19
+ export function setQuietMode(enabled) {
20
+ quietMode = enabled;
21
+ }
13
22
  export function setJqFilter(filter) {
14
23
  jqFilter = filter;
15
24
  }
@@ -102,6 +111,15 @@ export function outputSuccess(data) {
102
111
  // `title` on an issue) breaks shape inference and the summary
103
112
  // formatter falls back to the generic key-value dump.
104
113
  const inferredKind = outputFormat === "summary" ? inferKindFromPayload(output) : "generic";
114
+ // --quiet: one machine-stable confirmation line, nothing else. Highest
115
+ // precedence on the write path and independent of --raw / --fields / --jq
116
+ // (those reshape the payload the flag exists to avoid) — so we emit from
117
+ // the full pre-filter object, otherwise `--fields identifier` would strip
118
+ // the state/url formatLine needs and break shape inference.
119
+ if (quietMode) {
120
+ logger.info(formatLine(output));
121
+ return;
122
+ }
105
123
  // --raw: unwrap { data: [...] } to just the array
106
124
  if (rawMode &&
107
125
  output !== null &&
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `issues read --with` parser (DEV-4476).
3
+ *
4
+ * Opt-in includes for `issues read`. Each value names an additional block
5
+ * of data to fetch alongside the base issue and inject into the JSON
6
+ * envelope. Comma-separated; whitespace tolerated; unknown values rejected
7
+ * with a structured error naming the candidates (deterministic-CLI
8
+ * doctrine — fail fast in the CLI, not in the consumer's script).
9
+ *
10
+ * Currently supported:
11
+ * - `relations` — fetches `Issue.relations` + `Issue.inverseRelations`
12
+ * and adds a `relations` array to the envelope.
13
+ *
14
+ * Reserved for future MRs (the value space is a closed set so adding more
15
+ * later is back-compat):
16
+ * - `children` — refetch with expanded sub-issue fragment (state,
17
+ * assignee, priority — beyond the default id/identifier/
18
+ * title trio that's already in the envelope).
19
+ * - `comments` — no-op for fetching (comments are already in the
20
+ * default envelope via `_WITH_COMMENTS` fragment) but
21
+ * would gate explicit summary-format rendering.
22
+ *
23
+ * Doctrine note: comments are intentionally NOT included today because
24
+ * adding `--with comments` would imply that comments are *off* by default,
25
+ * which would be a breaking JSON-shape change.
26
+ */
27
+ export declare const WITH_INCLUDE_VALUES: readonly ["relations"];
28
+ export type WithInclude = (typeof WITH_INCLUDE_VALUES)[number];
29
+ export interface ParsedWithIncludes {
30
+ relations: boolean;
31
+ }
32
+ /**
33
+ * Parse a `--with <names>` argument. Returns a flag object so call sites
34
+ * read `if (includes.relations)` instead of `Set.has("relations")`.
35
+ *
36
+ * Empty / whitespace-only values are caller errors (commander allows
37
+ * `--with ""` through) — reject with the same message as unknown values
38
+ * so the user gets one consistent failure mode.
39
+ */
40
+ export declare function parseWithIncludes(raw: string): ParsedWithIncludes;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `issues read --with` parser (DEV-4476).
3
+ *
4
+ * Opt-in includes for `issues read`. Each value names an additional block
5
+ * of data to fetch alongside the base issue and inject into the JSON
6
+ * envelope. Comma-separated; whitespace tolerated; unknown values rejected
7
+ * with a structured error naming the candidates (deterministic-CLI
8
+ * doctrine — fail fast in the CLI, not in the consumer's script).
9
+ *
10
+ * Currently supported:
11
+ * - `relations` — fetches `Issue.relations` + `Issue.inverseRelations`
12
+ * and adds a `relations` array to the envelope.
13
+ *
14
+ * Reserved for future MRs (the value space is a closed set so adding more
15
+ * later is back-compat):
16
+ * - `children` — refetch with expanded sub-issue fragment (state,
17
+ * assignee, priority — beyond the default id/identifier/
18
+ * title trio that's already in the envelope).
19
+ * - `comments` — no-op for fetching (comments are already in the
20
+ * default envelope via `_WITH_COMMENTS` fragment) but
21
+ * would gate explicit summary-format rendering.
22
+ *
23
+ * Doctrine note: comments are intentionally NOT included today because
24
+ * adding `--with comments` would imply that comments are *off* by default,
25
+ * which would be a breaking JSON-shape change.
26
+ */
27
+ export const WITH_INCLUDE_VALUES = ["relations"];
28
+ /**
29
+ * Parse a `--with <names>` argument. Returns a flag object so call sites
30
+ * read `if (includes.relations)` instead of `Set.has("relations")`.
31
+ *
32
+ * Empty / whitespace-only values are caller errors (commander allows
33
+ * `--with ""` through) — reject with the same message as unknown values
34
+ * so the user gets one consistent failure mode.
35
+ */
36
+ export function parseWithIncludes(raw) {
37
+ const names = raw
38
+ .split(",")
39
+ .map((s) => s.trim())
40
+ .filter((s) => s.length > 0);
41
+ if (names.length === 0) {
42
+ throw new Error(`--with requires at least one include name. Supported: ${WITH_INCLUDE_VALUES.join(", ")}`);
43
+ }
44
+ const includes = { relations: false };
45
+ for (const name of names) {
46
+ if (!WITH_INCLUDE_VALUES.includes(name)) {
47
+ throw new Error(`--with: unknown include "${name}". Supported: ${WITH_INCLUDE_VALUES.join(", ")}`);
48
+ }
49
+ // Narrowing: only assignable members of ParsedWithIncludes.
50
+ if (name === "relations") {
51
+ includes.relations = true;
52
+ }
53
+ }
54
+ return includes;
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enrichlayer/el-linear",
3
- "version": "1.20.0",
3
+ "version": "1.22.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",