@enrichlayer/el-linear 1.20.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
@@ -599,6 +599,30 @@ with `--field`. (Named `--sections` rather than the seemingly-obvious
599
599
  `--fields` because `--fields` is already taken at the program level for
600
600
  output-key filtering — `el-linear` is the namespace owner.)
601
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
+
602
626
  ## Wrapping Linear references in arbitrary text
603
627
 
604
628
  `el-linear refs wrap` takes plain text on stdin (or via `--file`) and rewrites
@@ -1000,7 +1000,10 @@ export function setupIssuesCommands(program) {
1000
1000
  .option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope"). ' +
1001
1001
  "Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
1002
1002
  "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"')
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')
1004
1007
  .action(handleAsyncCommand(readIssues));
1005
1008
  issues
1006
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
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
  */
@@ -26,7 +29,10 @@ export function setupReadShortcut(program) {
26
29
  .option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope,Steps"). ' +
27
30
  "Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
28
31
  "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"')
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)')
30
36
  .action(handleAsyncCommand(readIssues));
31
37
  // Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
32
38
  const originalParse = program.parse.bind(program);
@@ -61,10 +67,16 @@ export function setupReadShortcut(program) {
61
67
  */
62
68
  export async function readIssues(issueIds, options, command) {
63
69
  const rootOpts = getRootOpts(command);
64
- const { issuesService } = await createIssuesService(rootOpts);
70
+ const { graphQLService, issuesService } = await createIssuesService(rootOpts);
65
71
  const fileService = await createFileService(rootOpts);
66
72
  const fieldName = typeof options.field === "string" ? options.field : null;
67
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 };
68
80
  if (fieldName && sectionsRaw) {
69
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).");
70
82
  }
@@ -87,6 +99,12 @@ export async function readIssues(issueIds, options, command) {
87
99
  if (sectionNames !== null && sectionNames.length === 0) {
88
100
  throw new Error("--sections was empty after trimming. Pass a comma-separated list of section names.");
89
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).");
107
+ }
90
108
  if (issueIds.length === 1) {
91
109
  const issue = await issuesService.getIssueById(issueIds[0]);
92
110
  const resolved = await downloadLinearUploads(issue, fileService);
@@ -124,7 +142,15 @@ export async function readIssues(issueIds, options, command) {
124
142
  });
125
143
  return;
126
144
  }
127
- outputSuccess(resolved);
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);
128
154
  }
129
155
  else {
130
156
  // DEV-4477: one batched GraphQL call instead of N parallel single-issue
@@ -133,7 +159,45 @@ export async function readIssues(issueIds, options, command) {
133
159
  // that's HTTP, not GraphQL, and downloadLinearUploads is a no-op when
134
160
  // there's nothing to download.
135
161
  const issues = await issuesService.getIssuesByRefs(issueIds);
136
- const results = await Promise.all(issues.map((issue) => downloadLinearUploads(issue, fileService)));
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
+ };
175
+ }));
137
176
  outputSuccess(results);
138
177
  }
139
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
+ }
@@ -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.21.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",