@enrichlayer/el-linear 1.19.0 → 1.21.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
@@ -447,6 +447,24 @@ el-linear <command> --help # detailed help for one command
447
447
  All `list` subcommands support `-l, --limit <n>`. All commands accept the
448
448
  top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
449
449
 
450
+ ### Open by default — `issues list` and `issues search` skip terminal states
451
+
452
+ `el-linear issues list` and `el-linear issues search` **exclude issues in
453
+ terminal workflow states (`Done` / `Canceled`) by default** so triage and
454
+ survey runs return the open set without piping through `grep`. The implicit
455
+ filter is surfaced in `_warnings` on every invocation, so scripts notice it
456
+ deterministically rather than silently. Three ways to opt back in:
457
+
458
+ ```bash
459
+ el-linear issues list --include-closed # everything, including Done/Canceled
460
+ el-linear issues search "auth" --status "Done" # explicit --status wins
461
+ el-linear issues list --status "Todo,In Progress" # any explicit status disables the implicit filter
462
+ ```
463
+
464
+ `--include-closed` and explicit `--status` both bypass the implicit filter
465
+ (explicit choice always wins). The change is per-command and only affects
466
+ list-shaped reads — single-issue `issues read DEV-123` is unaffected.
467
+
450
468
  ## Output formats
451
469
 
452
470
  Every command accepts `--format <kind>` at the root:
@@ -535,6 +553,76 @@ el-linear read ADM-652 --field "Out of scope"
535
553
  Single-issue only — pair it with `--jq` on full JSON for batch
536
554
  extraction across many issues.
537
555
 
556
+ ### Render an issue's tree: `issues tree`
557
+
558
+ `issues tree <ID>` walks the parent → children graph for an issue in a
559
+ single GraphQL round-trip and returns either a nested JSON envelope
560
+ (default) or an ASCII tree (`--format summary`).
561
+
562
+ ```bash
563
+ el-linear issues tree DEV-100 --format summary
564
+ # DEV-100 Migrate auth middleware
565
+ # ├── DEV-101 Write design doc
566
+ # │ ├── DEV-104 Survey existing auth flows [Done]
567
+ # │ └── DEV-105 Draft RFC
568
+ # ├── DEV-102 Build new session store (@Alice)
569
+ # └── DEV-103 Cutover plan
570
+ ```
571
+
572
+ Depth defaults to **3** (max 5 — Linear has no native depth-N recursion,
573
+ so each level adds a `children { nodes { ... } }` block to the generated
574
+ query). Terminal-state branches (`Done` / `Canceled`) are **kept** by
575
+ default because the tree's value is *structural*; pass
576
+ `--no-include-closed` to prune them.
577
+
578
+ ### Extract several sections in one call: `--sections`
579
+
580
+ When you want multiple sections (e.g. `Done when`, `Out of scope`, and
581
+ `Steps`), don't issue N separate `--field` calls — pass a comma-separated
582
+ list to `--sections` instead:
583
+
584
+ ```bash
585
+ el-linear issues read DEV-123 --sections "Done when,Out of scope"
586
+ # {
587
+ # "identifier": "DEV-123",
588
+ # "sections": {
589
+ # "Done when": "...",
590
+ # "Out of scope": "..."
591
+ # }
592
+ # }
593
+ ```
594
+
595
+ Returns a JSON envelope `{ identifier, sections: { name → text|null } }`.
596
+ Missing sections map to `null` and surface in `_warnings` so scripts can
597
+ detect them deterministically. Single-issue only, mutually exclusive
598
+ with `--field`. (Named `--sections` rather than the seemingly-obvious
599
+ `--fields` because `--fields` is already taken at the program level for
600
+ output-key filtering — `el-linear` is the namespace owner.)
601
+
602
+ ### Opt-in includes: `--with`
603
+
604
+ `issues read --with <names>` adds extra blocks of related data to the
605
+ JSON envelope. Comma-separated; unknown values are rejected with the
606
+ candidate list.
607
+
608
+ Currently supported:
609
+
610
+ | Include | What it adds |
611
+ |---------|--------------|
612
+ | `relations` | A top-level `relations` array (outgoing + incoming cross-issue links), built from the same data as `issues related`. |
613
+
614
+ ```bash
615
+ # Issue + its sidebar relations in one call:
616
+ el-linear issues read DEV-123 --with relations
617
+
618
+ # Across multiple issues — relations fetched per issue in parallel:
619
+ el-linear read DEV-1 DEV-2 --with relations | jq '.data[].relations'
620
+ ```
621
+
622
+ `--with` is JSON-only — it composes with `--jq` / `--fields` / `--raw`,
623
+ and is mutually exclusive with `--field` (which prints raw section text,
624
+ no envelope).
625
+
538
626
  ## Wrapping Linear references in arbitrary text
539
627
 
540
628
  `el-linear refs wrap` takes plain text on stdin (or via `--file`) and rewrites
@@ -123,7 +123,11 @@ and outreach tracked in one place.
123
123
  **Search before creating. No exceptions.**
124
124
 
125
125
  ```bash
126
- el-linear issues search "keywords from proposed title" 2>&1
126
+ # --include-closed is required so previously-completed duplicates surface.
127
+ # `issues search` defaults to open states (DEV-4478); the duplicate check
128
+ # intentionally widens to Done/Canceled because a closed-out duplicate is
129
+ # still a duplicate.
130
+ el-linear issues search "keywords from proposed title" --include-closed 2>&1
127
131
  ```
128
132
 
129
133
  1. Extract 2–3 key terms from the proposed title (skip generic words).
@@ -269,6 +273,7 @@ Run `el-linear usage` for the full command reference. Non-obvious rules:
269
273
  - **Subcommand aliases** — `read`/`view`/`get`/`show`, `update`/`edit`/`set`.
270
274
  - **`--jq` for GraphQL filtering** — never pipe through `jq` directly (zsh escaping breaks `!=`).
271
275
  - **`--raw` flag** strips the `{ data, meta }` wrapper — emits just the array.
276
+ - **Body/description from a file** — `issues create`/`update` take `--description-file <path>`; `comments create`/`update` take `--body-file <path>`. Prefer the file form for any body with backticks, fenced code, or markdown tables — it sidesteps shell-quoting traps (the same reason `el-git mr comment --body-file` exists). `--body` and `--body-file` are **mutually exclusive** (passing both errors); file-sourced bodies get the same auto-link / auto-mention treatment as inline `--body`.
272
277
 
273
278
  ### Output format
274
279
 
@@ -18,6 +18,12 @@ import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
18
18
  const BODY_DATA_ERROR_RE = /prosemirror|bodydata|invalid.*body/i;
19
19
  const ISSUE_IDENTIFIER_REGEX = /^[A-Z][A-Z0-9]*-\d+$/;
20
20
  function readBody(options) {
21
+ // --body and --body-file are two sources for the same field; accepting both
22
+ // would silently drop one. Reject up front (DEV-4450) — the same mutual-
23
+ // exclusivity contract resolveDescription() enforces for --template.
24
+ if (options.body && options.bodyFile) {
25
+ throw new Error("--body and --body-file are mutually exclusive — pass one or the other");
26
+ }
21
27
  if (options.bodyFile) {
22
28
  return readFileSync(options.bodyFile, "utf-8");
23
29
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `el-linear issues tree <ID>` (DEV-4480).
3
+ *
4
+ * Renders the parent-and-children tree of an issue to depth N (default 3,
5
+ * max 5 — see `MAX_TREE_DEPTH`). One GraphQL round-trip per invocation;
6
+ * the query string is generated by `buildIssueTreeQuery(depth)` at call
7
+ * time because the children connection has no `@include`-style depth
8
+ * directive.
9
+ *
10
+ * Two output modes:
11
+ * - **JSON** (default): nested `IssueTreeNode` shape.
12
+ * - **Summary**: ASCII tree via `formatTree`.
13
+ *
14
+ * Closed-issue filtering: `--include-closed` is on by default for `tree`
15
+ * because the tree's value is *structural* — knowing that a child was
16
+ * canceled is part of the picture. Pass `--no-include-closed` to prune
17
+ * terminal-state branches client-side after the fetch (Linear's children
18
+ * connection has no top-level state filter).
19
+ */
20
+ import type { Command, OptionValues } from "commander";
21
+ export declare function setupTreeCommand(issues: Command): void;
22
+ export declare function handleTreeCommand(issueId: string, options: OptionValues, command: Command): Promise<void>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `el-linear issues tree <ID>` (DEV-4480).
3
+ *
4
+ * Renders the parent-and-children tree of an issue to depth N (default 3,
5
+ * max 5 — see `MAX_TREE_DEPTH`). One GraphQL round-trip per invocation;
6
+ * the query string is generated by `buildIssueTreeQuery(depth)` at call
7
+ * time because the children connection has no `@include`-style depth
8
+ * directive.
9
+ *
10
+ * Two output modes:
11
+ * - **JSON** (default): nested `IssueTreeNode` shape.
12
+ * - **Summary**: ASCII tree via `formatTree`.
13
+ *
14
+ * Closed-issue filtering: `--include-closed` is on by default for `tree`
15
+ * because the tree's value is *structural* — knowing that a child was
16
+ * canceled is part of the picture. Pass `--no-include-closed` to prune
17
+ * terminal-state branches client-side after the fetch (Linear's children
18
+ * connection has no top-level state filter).
19
+ */
20
+ import { buildIssueTreeQuery, DEFAULT_TREE_DEPTH, MAX_TREE_DEPTH, } from "../../queries/issue-tree.js";
21
+ import { formatTree } from "../../utils/format-tree.js";
22
+ import { createIssuesService } from "../../utils/issues-service-bootstrap.js";
23
+ import { handleAsyncCommand, outputSuccess } from "../../utils/output.js";
24
+ import { getRootOpts } from "../../utils/root-opts.js";
25
+ export function setupTreeCommand(issues) {
26
+ issues
27
+ .command("tree <issueId>")
28
+ .description("Render the parent-and-children tree of an issue (DEV-4480). " +
29
+ "One GraphQL call. Depth defaults to 3 (max 5).")
30
+ .option("--depth <n>", `Tree depth, integer in [1, ${MAX_TREE_DEPTH}] (default ${DEFAULT_TREE_DEPTH})`, String(DEFAULT_TREE_DEPTH))
31
+ .option("--no-include-closed", "Prune branches whose state is Done or Canceled. By default tree " +
32
+ "renders all states because the tree's value is structural.")
33
+ .addHelpText("after", "\nExamples:" +
34
+ "\n el-linear issues tree DEV-100" +
35
+ "\n el-linear issues tree DEV-100 --depth 5 --format summary" +
36
+ "\n el-linear issues tree DEV-100 --no-include-closed")
37
+ .action(handleAsyncCommand(handleTreeCommand));
38
+ }
39
+ export async function handleTreeCommand(issueId, options, command) {
40
+ const rootOpts = getRootOpts(command);
41
+ const { graphQLService, linearService } = await createIssuesService(rootOpts);
42
+ const treeOptions = options;
43
+ const depth = parseDepth(treeOptions.depth);
44
+ // commander turns `--no-include-closed` into `includeClosed: false`; the
45
+ // default is true (see option declaration).
46
+ const includeClosed = treeOptions.includeClosed !== false;
47
+ const resolvedId = await linearService.resolveIssueId(issueId);
48
+ const result = await graphQLService.rawRequest(buildIssueTreeQuery(depth), { id: resolvedId });
49
+ if (!result.issue) {
50
+ throw new Error(`Issue "${issueId}" not found`);
51
+ }
52
+ const filtered = includeClosed
53
+ ? result.issue
54
+ : pruneTerminalStates(result.issue);
55
+ // `--format` is a *root-program* option (see main.ts), so commander
56
+ // surfaces it via `command.parent.opts()` — NOT via the subcommand
57
+ // action's local `options` parameter. Reading from `rootOpts` mirrors
58
+ // the precedent in `read-shortcut.ts`'s `--field` handling.
59
+ // (DEV-4480 cycle-1 blocker.)
60
+ if (rootOpts.format === "summary") {
61
+ process.stdout.write(`${formatTree(filtered)}\n`);
62
+ return;
63
+ }
64
+ outputSuccess(filtered);
65
+ }
66
+ function parseDepth(raw) {
67
+ if (raw === undefined) {
68
+ return DEFAULT_TREE_DEPTH;
69
+ }
70
+ const n = Number.parseInt(raw, 10);
71
+ if (!Number.isInteger(n) || n < 1 || n > MAX_TREE_DEPTH) {
72
+ throw new Error(`--depth must be an integer in [1, ${MAX_TREE_DEPTH}]; got "${raw}".`);
73
+ }
74
+ return n;
75
+ }
76
+ /**
77
+ * Walk the tree depth-first and drop any node whose `state.type` is
78
+ * `completed` or `canceled`. Pure function — does not mutate the input.
79
+ * A pruned child takes its entire subtree with it (consistent with how
80
+ * `issues list --no-include-closed` excludes closed work entirely).
81
+ *
82
+ * Assumes Linear's parent → children graph stays single-parent (a tree,
83
+ * not a DAG). If Linear ever ships multi-parent issues, this recursion
84
+ * would re-emit nodes reachable via multiple paths — at that point add
85
+ * a `Set<string>` of seen `id`s to the walk. Today the assumption is
86
+ * safe. Bounded recursion: `MAX_TREE_DEPTH=5` caps the call stack at
87
+ * ≤6 frames (root + 5 children levels). (Cycle-1 nit.)
88
+ */
89
+ function pruneTerminalStates(root) {
90
+ const kids = root.children?.nodes ?? [];
91
+ const surviving = kids
92
+ .filter((c) => !isTerminalState(c))
93
+ .map((c) => pruneTerminalStates(c));
94
+ return {
95
+ ...root,
96
+ children: { nodes: surviving },
97
+ };
98
+ }
99
+ function isTerminalState(node) {
100
+ const t = node.state?.type;
101
+ return t === "completed" || t === "canceled";
102
+ }
@@ -21,6 +21,7 @@ import { currentGitBranch, extractIssueIdentifierFromBranch, getBranchLinearIssu
21
21
  import { maybeAutoLink, prepareAutoLinkedDescription, readDescriptionFile, resolveDescription, } from "./issues/description.js";
22
22
  import { handleLinkReferencesIssue } from "./issues/link-references.js";
23
23
  import { buildIncomingRelationEntries, buildOutgoingRelationEntries, createRelations, } from "./issues/relations.js";
24
+ import { setupTreeCommand } from "./issues/tree.js";
24
25
  import { readIssues } from "./read-shortcut.js";
25
26
  const IMAGE_EXTENSIONS = new Set([
26
27
  ".png",
@@ -160,7 +161,12 @@ async function handleListIssues(options, command) {
160
161
  }
161
162
  const rootOpts = getRootOpts(command);
162
163
  const { issuesService } = await createIssuesService(rootOpts);
163
- const hasFilters = options.team ||
164
+ const explicitStatus = options.status ? splitList(options.status) : undefined;
165
+ // DEV-4478: default-exclude terminal states (Done/Canceled) unless the
166
+ // user passes --include-closed OR explicit --status. Explicit status
167
+ // wins because the user already named the workflow states they want.
168
+ const excludeTerminalStates = !options.includeClosed && explicitStatus === undefined;
169
+ const hasOtherFilters = options.team ||
164
170
  options.labels ||
165
171
  options.status ||
166
172
  options.assignee ||
@@ -169,7 +175,14 @@ async function handleListIssues(options, command) {
169
175
  options.project === false ||
170
176
  options.priority;
171
177
  const limit = parsePositiveInt(options.limit, "--limit");
172
- if (hasFilters) {
178
+ // Route through searchIssues whenever the CLI needs to control the GraphQL
179
+ // state filter: any explicit filter, the default `excludeTerminalStates`,
180
+ // OR an explicit `--include-closed`. The last case is load-bearing —
181
+ // getIssues' query hard-codes `state: { type: { neq: "completed" } }`, so
182
+ // falling through to it on `--include-closed --no-other-filters` would
183
+ // silently drop Done issues, the exact opposite of the flag's intent.
184
+ // (DEV-4478 cycle-1.)
185
+ if (hasOtherFilters || excludeTerminalStates || options.includeClosed) {
173
186
  const searchArgs = {
174
187
  teamId: options.team ? resolveTeam(options.team) : undefined,
175
188
  assigneeId: options.assignee
@@ -180,7 +193,8 @@ async function handleListIssues(options, command) {
180
193
  : undefined,
181
194
  project: resolveProjectFlag(options.project),
182
195
  labelNames: options.labels ? splitList(options.labels) : undefined,
183
- status: options.status ? splitList(options.status) : undefined,
196
+ status: explicitStatus,
197
+ excludeTerminalStates,
184
198
  priority: options.priority
185
199
  ? parsePriorityFilter(options.priority)
186
200
  : undefined,
@@ -188,6 +202,9 @@ async function handleListIssues(options, command) {
188
202
  limit,
189
203
  };
190
204
  const result = sortIssues(await issuesService.searchIssues(searchArgs), options.sort);
205
+ if (excludeTerminalStates) {
206
+ outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
207
+ }
191
208
  warnIfTruncated(result.length, limit);
192
209
  outputIssues(result, options.format, options.fields, {
193
210
  team: options.team,
@@ -206,6 +223,11 @@ async function handleSearchIssues(query, options, command) {
206
223
  const rootOpts = getRootOpts(command);
207
224
  const { issuesService } = await createIssuesService(rootOpts);
208
225
  const limit = parsePositiveInt(options.limit, "--limit");
226
+ const explicitStatus = options.status ? splitList(options.status) : undefined;
227
+ // DEV-4478: default-exclude terminal states (Done/Canceled) unless the
228
+ // user passes --include-closed OR explicit --status. Explicit status
229
+ // wins because the user already named the workflow states they want.
230
+ const excludeTerminalStates = !options.includeClosed && explicitStatus === undefined;
209
231
  const searchArgs = {
210
232
  query,
211
233
  teamId: options.team ? resolveTeam(options.team) : undefined,
@@ -216,7 +238,8 @@ async function handleSearchIssues(query, options, command) {
216
238
  ? resolveMember(options.delegate)
217
239
  : undefined,
218
240
  project: resolveProjectFlag(options.project),
219
- status: options.status ? splitList(options.status) : undefined,
241
+ status: explicitStatus,
242
+ excludeTerminalStates,
220
243
  labelNames: options.labels ? splitList(options.labels) : undefined,
221
244
  priority: options.priority
222
245
  ? parsePriorityFilter(options.priority)
@@ -224,6 +247,9 @@ async function handleSearchIssues(query, options, command) {
224
247
  limit,
225
248
  };
226
249
  const result = sortIssues(await issuesService.searchIssues(searchArgs), options.sort);
250
+ if (excludeTerminalStates) {
251
+ outputWarning("excluded terminal states (Done / Canceled) by default; pass --include-closed to include them");
252
+ }
227
253
  warnIfTruncated(result.length, limit);
228
254
  outputIssues(result, options.format, options.fields, { query });
229
255
  }
@@ -869,6 +895,10 @@ export function setupIssuesCommands(program) {
869
895
  .alias("issue")
870
896
  .description("Issue operations");
871
897
  issues.action(() => issues.help());
898
+ // DEV-4480: `issues tree <ID>` lives in its own file because the
899
+ // recursive query builder + ASCII formatter belong together and are
900
+ // substantial enough to warrant the split.
901
+ setupTreeCommand(issues);
872
902
  issues
873
903
  .command("list")
874
904
  .description("List issues.")
@@ -881,6 +911,7 @@ export function setupIssuesCommands(program) {
881
911
  .option("--labels <labels>", "filter by labels (comma-separated names)")
882
912
  .option("--label <labels>", "alias for --labels")
883
913
  .option("--status <status>", "filter by status (comma-separated, e.g. Todo,Backlog)")
914
+ .option("--include-closed", "include issues in terminal states (Done / Canceled). Default is to exclude them; pass this flag to see everything. Ignored when --status is set (explicit choice wins).")
884
915
  .option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
885
916
  .option("--sort <field>", "sort results (priority, status, created, updated)")
886
917
  .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
@@ -895,6 +926,7 @@ export function setupIssuesCommands(program) {
895
926
  .option("--project <project>", "filter by project name or ID")
896
927
  .option("--no-project", "filter issues with no project assigned")
897
928
  .option("--status <status>", "filter by status (comma-separated)")
929
+ .option("--include-closed", "include issues in terminal states (Done / Canceled). Default is to exclude them; pass this flag to see everything. Ignored when --status is set (explicit choice wins).")
898
930
  .option("--labels <labels>", "filter by labels (comma-separated names)")
899
931
  .option("--label <labels>", "alias for --labels")
900
932
  .option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
@@ -965,7 +997,13 @@ export function setupIssuesCommands(program) {
965
997
  .option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
966
998
  "Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
967
999
  "Outputs the section text only — no JSON envelope. Single-issue only.")
968
- .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"')
1000
+ .option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope"). ' +
1001
+ "Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
1002
+ "Sibling of --field (singular). Named --sections rather than --fields because the program already has a global --fields for output-key filtering.")
1003
+ .option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
1004
+ 'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
1005
+ "(adds an array of cross-issue relations under a top-level `relations` key).")
1006
+ .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"\nWith relations: el-linear issue read DEV-123 --with relations')
969
1007
  .action(handleAsyncCommand(readIssues));
970
1008
  issues
971
1009
  .command("update <issueId>")
@@ -1,9 +1,12 @@
1
+ import { GET_ISSUE_RELATIONS_QUERY } from "../queries/issues.js";
1
2
  import { downloadLinearUploads } from "../utils/download-uploads.js";
2
- import { extractField } from "../utils/extract-field.js";
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
- import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
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
  */
@@ -23,7 +26,13 @@ export function setupReadShortcut(program) {
23
26
  .option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
24
27
  "Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
25
28
  "Outputs the section text only — no JSON envelope.")
26
- .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)')
29
+ .option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope,Steps"). ' +
30
+ "Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
31
+ "Sibling of --field (singular). Named --sections rather than --fields because the program already has a global --fields for output-key filtering.")
32
+ .option("--with <names>", "Comma-separated opt-in includes. Each value fetches an extra " +
33
+ 'block of data and adds it to the JSON envelope. Currently supported: "relations" ' +
34
+ "(adds an array of cross-issue relations under a top-level `relations` key).")
35
+ .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"\n el-linear read DEV-123 --with relations (issue + cross-issue links)')
27
36
  .action(handleAsyncCommand(readIssues));
28
37
  // Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
29
38
  const originalParse = program.parse.bind(program);
@@ -58,15 +67,43 @@ export function setupReadShortcut(program) {
58
67
  */
59
68
  export async function readIssues(issueIds, options, command) {
60
69
  const rootOpts = getRootOpts(command);
61
- const { issuesService } = await createIssuesService(rootOpts);
70
+ const { graphQLService, issuesService } = await createIssuesService(rootOpts);
62
71
  const fileService = await createFileService(rootOpts);
63
72
  const fieldName = typeof options.field === "string" ? options.field : null;
64
- // --field is single-issue only. With multiple issues, a section
65
- // extraction can't sensibly fan out to N different bodies — the
66
- // caller almost always wants one section from one issue.
67
- if (fieldName && issueIds.length > 1) {
68
- throw new Error("--field is single-issue only; pass exactly one issueId. " +
69
- "For multiple issues, drop --field and use --jq or --format summary.");
73
+ const sectionsRaw = typeof options.sections === "string" ? options.sections : null;
74
+ // DEV-4476: --with opt-in includes (currently `relations`). Throws on
75
+ // unknown values via parseWithIncludes — fail fast in the CLI per the
76
+ // deterministic-CLI doctrine.
77
+ const includes = typeof options.with === "string"
78
+ ? parseWithIncludes(options.with)
79
+ : { relations: false };
80
+ if (fieldName && sectionsRaw) {
81
+ throw new Error("--field and --sections are mutually exclusive. Use --field for a single section (plain-text output) or --sections for multiple (JSON map).");
82
+ }
83
+ // Both --field and --sections are single-issue only — section extraction
84
+ // can't sensibly fan out to N different bodies, the caller almost always
85
+ // wants the named sections of one issue.
86
+ if ((fieldName || sectionsRaw) && issueIds.length > 1) {
87
+ throw new Error("--field / --sections are single-issue only; pass exactly one issueId. " +
88
+ "For multiple issues, drop the section flags and use --jq or --format summary.");
89
+ }
90
+ // Parse the comma-separated --sections list once. Preserve the caller's
91
+ // order, trim each entry, and drop empties so trailing commas don't
92
+ // surface as ghost "" sections.
93
+ const sectionNames = sectionsRaw
94
+ ? sectionsRaw
95
+ .split(",")
96
+ .map((s) => s.trim())
97
+ .filter((s) => s.length > 0)
98
+ : null;
99
+ if (sectionNames !== null && sectionNames.length === 0) {
100
+ throw new Error("--sections was empty after trimming. Pass a comma-separated list of section names.");
101
+ }
102
+ // --field is also an extract-this-section operation; pairing it with
103
+ // --with would produce ambiguous output (section text vs. JSON envelope).
104
+ // Reject up front rather than silently dropping one.
105
+ if (fieldName && includes.relations) {
106
+ throw new Error("--field and --with are mutually exclusive (--field outputs raw section text; --with extends the JSON envelope).");
70
107
  }
71
108
  if (issueIds.length === 1) {
72
109
  const issue = await issuesService.getIssueById(issueIds[0]);
@@ -83,13 +120,84 @@ export async function readIssues(issueIds, options, command) {
83
120
  process.stdout.write(`${section}\n`);
84
121
  return;
85
122
  }
86
- outputSuccess(resolved);
123
+ if (sectionNames !== null) {
124
+ const sectionsMap = extractFields(resolved.description ?? "", sectionNames);
125
+ const sections = {};
126
+ const missing = [];
127
+ for (const [name, text] of sectionsMap) {
128
+ sections[name] = text;
129
+ if (text === null)
130
+ missing.push(name);
131
+ }
132
+ if (missing.length > 0) {
133
+ // Coalesce: one warning naming every missing section is denser than
134
+ // N entries and easier for an agent to act on (cycle-1 nit).
135
+ outputWarning(`sections not found in ${resolved.identifier}'s description: ${missing
136
+ .map((n) => `"${n}"`)
137
+ .join(", ")}`);
138
+ }
139
+ outputSuccess({
140
+ identifier: resolved.identifier,
141
+ sections,
142
+ });
143
+ return;
144
+ }
145
+ // DEV-4476: --with relations (mutually exclusive with --sections, which
146
+ // returns above). Enrich the single-issue envelope when requested.
147
+ const envelope = includes.relations
148
+ ? {
149
+ ...resolved,
150
+ relations: await fetchRelations(graphQLService, resolved.id),
151
+ }
152
+ : resolved;
153
+ outputSuccess(envelope);
87
154
  }
88
155
  else {
89
- const results = await Promise.all(issueIds.map(async (id) => {
90
- const issue = await issuesService.getIssueById(id);
91
- return downloadLinearUploads(issue, fileService);
156
+ // DEV-4477: one batched GraphQL call instead of N parallel single-issue
157
+ // queries. The service preserves input order and throws notFoundError
158
+ // on any missing ref. Attachment download still fans out per-issue —
159
+ // that's HTTP, not GraphQL, and downloadLinearUploads is a no-op when
160
+ // there's nothing to download.
161
+ const issues = await issuesService.getIssuesByRefs(issueIds);
162
+ const results = await Promise.all(
163
+ // Apply --with relations over the batch-fetched issues (DEV-4476),
164
+ // preserving DEV-4477's single batched GraphQL fetch above — don't
165
+ // re-fetch per id, which would defeat the batch optimization.
166
+ issues.map(async (issue) => {
167
+ const resolved = await downloadLinearUploads(issue, fileService);
168
+ if (!includes.relations) {
169
+ return resolved;
170
+ }
171
+ return {
172
+ ...resolved,
173
+ relations: await fetchRelations(graphQLService, resolved.id),
174
+ };
92
175
  }));
93
176
  outputSuccess(results);
94
177
  }
95
178
  }
179
+ /**
180
+ * Fetch relations for a known-UUID issue and flatten outgoing + incoming
181
+ * via the shared relations builders. `relatedIssue` / `issue` peers that
182
+ * Linear omits (rare — deleted-relation edge case) are skipped, matching
183
+ * the `issues related` command's behavior.
184
+ *
185
+ * Race semantics: when `result.issue` is null (the issue existed at the
186
+ * `getIssueById` call upstream but is missing here — Linear deleted /
187
+ * unarchived it between the two calls), returns `[]` rather than
188
+ * throwing. The base issue is still emitted, and the caller sees an
189
+ * empty `relations` array — preferable to failing the whole envelope
190
+ * for a rare race. This intentionally diverges from `handleRelatedIssues`
191
+ * (which throws), because that command's *primary* output is relations,
192
+ * whereas here relations are an opt-in side dish.
193
+ */
194
+ async function fetchRelations(graphQLService, issueId) {
195
+ const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, { id: issueId });
196
+ if (!result.issue) {
197
+ return [];
198
+ }
199
+ return [
200
+ ...buildOutgoingRelationEntries(result.issue.relations.nodes),
201
+ ...buildIncomingRelationEntries(result.issue.inverseRelations.nodes),
202
+ ];
203
+ }
@@ -1,2 +1,2 @@
1
- export declare const COMPLETE_ISSUE_FRAGMENT = "\n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n";
2
- export declare const COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT = "\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n";
1
+ export declare const COMPLETE_ISSUE_FRAGMENT = "\n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n";
2
+ export declare const COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT = "\n \n \n id\n identifier\n title\n description\n summary { content generationStatus }\n branchName\n priority\n estimate\n dueDate\n url\n createdAt\n updatedAt\n\n \n state {\n id\n name\n type\n }\n\n \n assignee {\n id\n name\n url\n }\n\n \n delegate {\n id\n name\n url\n }\n\n \n team {\n id\n key\n name\n }\n\n \n project {\n id\n name\n }\n\n \n labels {\n nodes {\n id\n name\n }\n }\n\n \n cycle {\n id\n name\n number\n }\n\n \n projectMilestone {\n id\n name\n targetDate\n }\n\n \n parent {\n id\n identifier\n title\n }\n\n \n children {\n nodes {\n id\n identifier\n title\n }\n }\n\n\n \n comments {\n nodes {\n id\n body\n createdAt\n updatedAt\n user {\n id\n name\n url\n }\n }\n }\n\n";
@@ -16,6 +16,7 @@ const ISSUE_STATE_FRAGMENT = `
16
16
  state {
17
17
  id
18
18
  name
19
+ type
19
20
  }
20
21
  `;
21
22
  const ISSUE_ASSIGNEE_FRAGMENT = `
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Tree-fetch query builder for `issues tree <ID>` (DEV-4480).
3
+ *
4
+ * GraphQL has no native depth-N recursion, so the query is generated by
5
+ * nesting `children { nodes { ... } }` N times at build time. This keeps
6
+ * the *fetch* to a single round-trip even for deep trees, at the cost of
7
+ * a larger query string.
8
+ *
9
+ * Each level fetches the minimal field set needed to render a tree entry:
10
+ * id, identifier, title, state.{name,type}, assignee.name. State.type is
11
+ * present so the renderer can flag terminal states (Done / Canceled) and
12
+ * the caller's `--include-closed` filter can be applied client-side after
13
+ * the fetch (Linear doesn't accept a top-level filter that scopes to the
14
+ * issue subtree — the children connection is filterless).
15
+ *
16
+ * Depth bounds: `1` (just the root + its direct children) through `5`.
17
+ * Above 5 the query string grows too large to be useful as a single call;
18
+ * callers wanting deeper walks should iterate `tree` on leaf nodes.
19
+ */
20
+ export declare const MIN_TREE_DEPTH = 1;
21
+ export declare const MAX_TREE_DEPTH = 5;
22
+ export declare const DEFAULT_TREE_DEPTH = 3;
23
+ export declare function buildIssueTreeQuery(depth: number): string;
24
+ /**
25
+ * Shape of a single tree node — used for both the GraphQL response and
26
+ * the post-filter return value (after `--include-closed` is applied
27
+ * client-side).
28
+ */
29
+ export interface IssueTreeNode {
30
+ id: string;
31
+ identifier: string;
32
+ title: string;
33
+ state: {
34
+ id: string;
35
+ name: string;
36
+ type: string;
37
+ } | null;
38
+ assignee: {
39
+ id: string;
40
+ name: string;
41
+ } | null;
42
+ children?: {
43
+ nodes: IssueTreeNode[];
44
+ };
45
+ }
46
+ export interface GetIssueTreeResponse {
47
+ issue: IssueTreeNode | null;
48
+ }