@enrichlayer/el-linear 1.19.0 → 1.20.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 +64 -0
- package/claude-skills/linear-operations/SKILL.md +6 -1
- package/dist/commands/comments.js +6 -0
- package/dist/commands/issues/tree.d.ts +22 -0
- package/dist/commands/issues/tree.js +102 -0
- package/dist/commands/issues.js +40 -5
- package/dist/commands/read-shortcut.js +57 -13
- package/dist/queries/common.d.ts +2 -2
- package/dist/queries/common.js +1 -0
- package/dist/queries/issue-tree.d.ts +48 -0
- package/dist/queries/issue-tree.js +67 -0
- package/dist/queries/issues-types.d.ts +12 -0
- package/dist/queries/issues.d.ts +29 -7
- package/dist/queries/issues.js +30 -0
- package/dist/queries/project-milestones.d.ts +1 -1
- package/dist/types/linear.d.ts +7 -0
- package/dist/utils/extract-field.d.ts +16 -0
- package/dist/utils/extract-field.js +22 -0
- package/dist/utils/format-tree.d.ts +14 -0
- package/dist/utils/format-tree.js +47 -0
- package/dist/utils/graphql-issues-service.d.ts +26 -0
- package/dist/utils/graphql-issues-service.js +109 -2
- package/package.json +2 -2
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,52 @@ 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
|
+
|
|
538
602
|
## Wrapping Linear references in arbitrary text
|
|
539
603
|
|
|
540
604
|
`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
|
-
|
|
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
|
+
}
|
package/dist/commands/issues.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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,10 @@ 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
|
-
.
|
|
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
|
+
.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"')
|
|
969
1004
|
.action(handleAsyncCommand(readIssues));
|
|
970
1005
|
issues
|
|
971
1006
|
.command("update <issueId>")
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { downloadLinearUploads } from "../utils/download-uploads.js";
|
|
2
|
-
import { extractField } from "../utils/extract-field.js";
|
|
2
|
+
import { extractField, extractFields } from "../utils/extract-field.js";
|
|
3
3
|
import { createFileService } from "../utils/file-service.js";
|
|
4
4
|
import { createIssuesService } from "../utils/issues-service-bootstrap.js";
|
|
5
|
-
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
|
|
5
|
+
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
6
6
|
import { getRootOpts } from "../utils/root-opts.js";
|
|
7
7
|
/**
|
|
8
8
|
* Issue ID pattern: 1-5 uppercase letters, dash, 1+ digits (e.g. ADM-652, DEV-12).
|
|
@@ -23,7 +23,10 @@ export function setupReadShortcut(program) {
|
|
|
23
23
|
.option("--field <name>", 'Extract a single named section from the issue description (e.g. "Done when"). ' +
|
|
24
24
|
"Matches H2/H3 headers and bold pseudo-headers case-insensitively. " +
|
|
25
25
|
"Outputs the section text only — no JSON envelope.")
|
|
26
|
-
.
|
|
26
|
+
.option("--sections <names>", 'Extract multiple named description sections in one call (comma-separated, e.g. "Done when,Out of scope,Steps"). ' +
|
|
27
|
+
"Single-issue only. Returns a JSON envelope { identifier, sections: { name -> text|null } }; missing sections appear as null + a _warnings entry. " +
|
|
28
|
+
"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"')
|
|
27
30
|
.action(handleAsyncCommand(readIssues));
|
|
28
31
|
// Catch-all: if argv looks like `el-linear ADM-652 [DEV-123 ...]`, run read
|
|
29
32
|
const originalParse = program.parse.bind(program);
|
|
@@ -61,12 +64,28 @@ export async function readIssues(issueIds, options, command) {
|
|
|
61
64
|
const { issuesService } = await createIssuesService(rootOpts);
|
|
62
65
|
const fileService = await createFileService(rootOpts);
|
|
63
66
|
const fieldName = typeof options.field === "string" ? options.field : null;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const sectionsRaw = typeof options.sections === "string" ? options.sections : null;
|
|
68
|
+
if (fieldName && sectionsRaw) {
|
|
69
|
+
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
|
+
}
|
|
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.");
|
|
77
|
+
}
|
|
78
|
+
// Parse the comma-separated --sections list once. Preserve the caller's
|
|
79
|
+
// order, trim each entry, and drop empties so trailing commas don't
|
|
80
|
+
// surface as ghost "" sections.
|
|
81
|
+
const sectionNames = sectionsRaw
|
|
82
|
+
? sectionsRaw
|
|
83
|
+
.split(",")
|
|
84
|
+
.map((s) => s.trim())
|
|
85
|
+
.filter((s) => s.length > 0)
|
|
86
|
+
: null;
|
|
87
|
+
if (sectionNames !== null && sectionNames.length === 0) {
|
|
88
|
+
throw new Error("--sections was empty after trimming. Pass a comma-separated list of section names.");
|
|
70
89
|
}
|
|
71
90
|
if (issueIds.length === 1) {
|
|
72
91
|
const issue = await issuesService.getIssueById(issueIds[0]);
|
|
@@ -83,13 +102,38 @@ export async function readIssues(issueIds, options, command) {
|
|
|
83
102
|
process.stdout.write(`${section}\n`);
|
|
84
103
|
return;
|
|
85
104
|
}
|
|
105
|
+
if (sectionNames !== null) {
|
|
106
|
+
const sectionsMap = extractFields(resolved.description ?? "", sectionNames);
|
|
107
|
+
const sections = {};
|
|
108
|
+
const missing = [];
|
|
109
|
+
for (const [name, text] of sectionsMap) {
|
|
110
|
+
sections[name] = text;
|
|
111
|
+
if (text === null)
|
|
112
|
+
missing.push(name);
|
|
113
|
+
}
|
|
114
|
+
if (missing.length > 0) {
|
|
115
|
+
// Coalesce: one warning naming every missing section is denser than
|
|
116
|
+
// N entries and easier for an agent to act on (cycle-1 nit).
|
|
117
|
+
outputWarning(`sections not found in ${resolved.identifier}'s description: ${missing
|
|
118
|
+
.map((n) => `"${n}"`)
|
|
119
|
+
.join(", ")}`);
|
|
120
|
+
}
|
|
121
|
+
outputSuccess({
|
|
122
|
+
identifier: resolved.identifier,
|
|
123
|
+
sections,
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
86
127
|
outputSuccess(resolved);
|
|
87
128
|
}
|
|
88
129
|
else {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
130
|
+
// DEV-4477: one batched GraphQL call instead of N parallel single-issue
|
|
131
|
+
// queries. The service preserves input order and throws notFoundError
|
|
132
|
+
// on any missing ref. Attachment download still fans out per-issue —
|
|
133
|
+
// that's HTTP, not GraphQL, and downloadLinearUploads is a no-op when
|
|
134
|
+
// there's nothing to download.
|
|
135
|
+
const issues = await issuesService.getIssuesByRefs(issueIds);
|
|
136
|
+
const results = await Promise.all(issues.map((issue) => downloadLinearUploads(issue, fileService)));
|
|
93
137
|
outputSuccess(results);
|
|
94
138
|
}
|
|
95
139
|
}
|
package/dist/queries/common.d.ts
CHANGED
|
@@ -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";
|
package/dist/queries/common.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 const MIN_TREE_DEPTH = 1;
|
|
21
|
+
export const MAX_TREE_DEPTH = 5;
|
|
22
|
+
export const DEFAULT_TREE_DEPTH = 3;
|
|
23
|
+
// Field set per tree node — kept minimal so the depth-N query stays small.
|
|
24
|
+
// `state.type` is required client-side for `--no-include-closed` pruning;
|
|
25
|
+
// `state.name` for the [Done]/[Canceled] suffix on terminal nodes.
|
|
26
|
+
// `priority` was intentionally dropped after cycle-1: the ASCII formatter
|
|
27
|
+
// doesn't render it and including it inflates the query string by ~N nodes.
|
|
28
|
+
const TREE_NODE_FIELDS = `
|
|
29
|
+
id
|
|
30
|
+
identifier
|
|
31
|
+
title
|
|
32
|
+
state { id name type }
|
|
33
|
+
assignee { id name }
|
|
34
|
+
`;
|
|
35
|
+
/**
|
|
36
|
+
* Recursive children fragment generator. `depth=0` emits nothing (no
|
|
37
|
+
* children fetched at this level — terminal); `depth=1` emits a single
|
|
38
|
+
* `children { nodes { ...fields } }`; higher depths nest further.
|
|
39
|
+
*/
|
|
40
|
+
function buildChildrenBlock(depth) {
|
|
41
|
+
if (depth <= 0) {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
return `
|
|
45
|
+
children {
|
|
46
|
+
nodes {
|
|
47
|
+
${TREE_NODE_FIELDS}
|
|
48
|
+
${buildChildrenBlock(depth - 1)}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
export function buildIssueTreeQuery(depth) {
|
|
54
|
+
if (!Number.isInteger(depth) ||
|
|
55
|
+
depth < MIN_TREE_DEPTH ||
|
|
56
|
+
depth > MAX_TREE_DEPTH) {
|
|
57
|
+
throw new Error(`Tree depth must be an integer in [${MIN_TREE_DEPTH}, ${MAX_TREE_DEPTH}]; got ${depth}`);
|
|
58
|
+
}
|
|
59
|
+
return `
|
|
60
|
+
query GetIssueTree($id: String!) {
|
|
61
|
+
issue(id: $id) {
|
|
62
|
+
${TREE_NODE_FIELDS}
|
|
63
|
+
${buildChildrenBlock(depth)}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
@@ -25,6 +25,12 @@ import type { LinearPriority } from "../types/linear.js";
|
|
|
25
25
|
interface IdNameRef {
|
|
26
26
|
id: string;
|
|
27
27
|
name: string;
|
|
28
|
+
/**
|
|
29
|
+
* Workflow-state type — selected only on state fragments. Optional so
|
|
30
|
+
* non-state IdNameRef users (project, team, assignee, etc.) stay valid.
|
|
31
|
+
* DEV-4478.
|
|
32
|
+
*/
|
|
33
|
+
type?: string;
|
|
28
34
|
}
|
|
29
35
|
interface IdKeyNameRef {
|
|
30
36
|
id: string;
|
|
@@ -134,6 +140,12 @@ export interface GetIssueByIdentifierResponse {
|
|
|
134
140
|
nodes: IssueWithCommentsNode[];
|
|
135
141
|
};
|
|
136
142
|
}
|
|
143
|
+
/** Response shape for `BATCH_GET_ISSUES_QUERY` (DEV-4477). */
|
|
144
|
+
export interface BatchGetIssuesResponse {
|
|
145
|
+
issues: {
|
|
146
|
+
nodes: IssueWithCommentsNode[];
|
|
147
|
+
};
|
|
148
|
+
}
|
|
137
149
|
/** Response shape for `GET_ISSUES_QUERY`. */
|
|
138
150
|
export interface GetIssuesResponse {
|
|
139
151
|
issues: {
|
package/dist/queries/issues.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\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 }\n }\n";
|
|
2
|
-
export declare const SEARCH_ISSUES_QUERY = "\n query SearchIssues($term: String!, $first: Int!) {\n searchIssues(term: $term, first: $first, includeArchived: false) {\n nodes {\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 }\n }\n";
|
|
3
|
-
export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\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 }\n }\n";
|
|
1
|
+
export declare const GET_ISSUES_QUERY = "\n query GetIssues($first: Int!, $orderBy: PaginationOrderBy) {\n issues(\n first: $first\n orderBy: $orderBy\n filter: {\n state: { type: { neq: \"completed\" } }\n }\n ) {\n nodes {\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 }\n }\n";
|
|
2
|
+
export declare const SEARCH_ISSUES_QUERY = "\n query SearchIssues($term: String!, $first: Int!) {\n searchIssues(term: $term, first: $first, includeArchived: false) {\n nodes {\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 }\n }\n";
|
|
3
|
+
export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIssues(\n $first: Int!\n $filter: IssueFilter\n $orderBy: PaginationOrderBy\n ) {\n issues(\n first: $first\n filter: $filter\n orderBy: $orderBy\n includeArchived: false\n ) {\n nodes {\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 }\n }\n";
|
|
4
4
|
/**
|
|
5
5
|
* Batch-resolves a search's team/project/assignee/delegate filter inputs.
|
|
6
6
|
*
|
|
@@ -11,8 +11,30 @@ export declare const FILTERED_SEARCH_ISSUES_QUERY = "\n query FilteredSearchIss
|
|
|
11
11
|
* name-resolution arm is needed here.)
|
|
12
12
|
*/
|
|
13
13
|
export declare const BATCH_RESOLVE_FOR_SEARCH_QUERY = "\n query BatchResolveForSearch(\n $teamKey: String\n $teamName: String\n $projectName: String\n $hasProjectName: Boolean = false\n $assigneeEmail: String\n $delegateEmail: String\n ) {\n teams(\n filter: {\n or: [\n { key: { eq: $teamKey } }\n { name: { eqIgnoreCase: $teamName } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n key\n name\n }\n }\n\n projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 1\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n }\n }\n\n users(filter: { email: { eq: $assigneeEmail } }, first: 1) {\n nodes {\n id\n name\n email\n }\n }\n\n delegates: users(filter: { email: { eq: $delegateEmail } }, first: 1) {\n nodes {\n id\n name\n email\n }\n }\n }\n";
|
|
14
|
-
export declare const GET_ISSUE_BY_ID_QUERY = "\n query GetIssue($id: String!) {\n issue(id: $id) {\n \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\n }\n }\n";
|
|
15
|
-
export declare const GET_ISSUE_BY_IDENTIFIER_QUERY = "\n query GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n issues(\n filter: {\n team: { key: { eq: $teamKey } }\n number: { eq: $number }\n }\n first: 1\n ) {\n nodes {\n \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\n }\n }\n }\n";
|
|
14
|
+
export declare const GET_ISSUE_BY_ID_QUERY = "\n query GetIssue($id: String!) {\n issue(id: $id) {\n \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\n }\n }\n";
|
|
15
|
+
export declare const GET_ISSUE_BY_IDENTIFIER_QUERY = "\n query GetIssueByIdentifier($teamKey: String!, $number: Float!) {\n issues(\n filter: {\n team: { key: { eq: $teamKey } }\n number: { eq: $number }\n }\n first: 1\n ) {\n nodes {\n \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\n }\n }\n }\n";
|
|
16
|
+
/**
|
|
17
|
+
* Batch issue fetch. Used by `issues read <id...>` (DEV-4477) to collapse
|
|
18
|
+
* N parallel single-issue queries into one round-trip. The `$filter` is
|
|
19
|
+
* a top-level OR built client-side; each OR clause is either:
|
|
20
|
+
* { id: { in: [...uuids] } } ← all UUIDs in one IN
|
|
21
|
+
* { team: { key: { eq: <key> } }, number: { in: [...nums] } } ← identifiers
|
|
22
|
+
* grouped by team
|
|
23
|
+
* Linear returns nodes in DB order, NOT input order — the caller is
|
|
24
|
+
* responsible for re-sorting to match the input list before surfacing.
|
|
25
|
+
*
|
|
26
|
+
* `$first` is `refCount * 2`, clamped to [100, 250] — the floor absorbs the
|
|
27
|
+
* rare case where Linear returns more rows than requested via OR-clause
|
|
28
|
+
* collisions; the ceiling is Linear's connection-page cap, beyond which the
|
|
29
|
+
* server silently truncates.
|
|
30
|
+
*
|
|
31
|
+
* Raw GraphQL (not @linear/sdk) is required because the SDK's
|
|
32
|
+
* `client.issues({ filter: ... })` strips through the same query under
|
|
33
|
+
* the hood but forces one round-trip per call site; the doctrine in
|
|
34
|
+
* CLAUDE.md ("Performance-critical loops where the SDK's per-edge
|
|
35
|
+
* resolver promises produce N+1 round-trips") explicitly authorizes this.
|
|
36
|
+
*/
|
|
37
|
+
export declare const BATCH_GET_ISSUES_QUERY = "\n query BatchGetIssues($filter: IssueFilter!, $first: Int!) {\n issues(filter: $filter, first: $first) {\n nodes {\n \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\n }\n }\n }\n";
|
|
16
38
|
/**
|
|
17
39
|
* Batch-resolves an update's project/milestone/issue inputs.
|
|
18
40
|
*
|
|
@@ -23,8 +45,8 @@ export declare const GET_ISSUE_BY_IDENTIFIER_QUERY = "\n query GetIssueByIdenti
|
|
|
23
45
|
* `--project-milestone` name resolution; a name uses `projectsByName`.
|
|
24
46
|
*/
|
|
25
47
|
export declare const BATCH_RESOLVE_FOR_UPDATE_QUERY = "\n query BatchResolveForUpdate(\n $projectName: String\n $projectId: ID\n $hasProjectName: Boolean = false\n $hasProjectId: Boolean = false\n $teamKey: String\n $issueNumber: Float\n $milestoneName: String\n $hasMilestoneName: Boolean = false\n ) {\n projectsByName: projects(\n filter: { name: { eqIgnoreCase: $projectName } }\n first: 5\n ) @include(if: $hasProjectName) {\n nodes {\n id\n name\n teams {\n nodes { id key }\n }\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n projectsById: projects(\n filter: { id: { eq: $projectId } }\n first: 1\n ) @include(if: $hasProjectId) {\n nodes {\n id\n name\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n\n milestones: projectMilestones(\n filter: { name: { eq: $milestoneName } }\n first: 1\n ) @include(if: $hasMilestoneName) {\n nodes {\n id\n name\n }\n }\n\n issues(\n filter: {\n and: [\n { team: { key: { eq: $teamKey } } }\n { number: { eq: $issueNumber } }\n ]\n }\n first: 1\n ) {\n nodes {\n id\n identifier\n team {\n id\n key\n }\n labels {\n nodes {\n id\n name\n }\n }\n project {\n id\n projectMilestones {\n nodes {\n id\n name\n }\n }\n }\n }\n }\n }\n";
|
|
26
|
-
export declare const CREATE_ISSUE_MUTATION = "\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\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 }\n }\n";
|
|
27
|
-
export declare const UPDATE_ISSUE_MUTATION = "\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\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 }\n }\n";
|
|
48
|
+
export declare const CREATE_ISSUE_MUTATION = "\n mutation CreateIssue($input: IssueCreateInput!) {\n issueCreate(input: $input) {\n success\n issue {\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 }\n }\n";
|
|
49
|
+
export declare const UPDATE_ISSUE_MUTATION = "\n mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {\n issueUpdate(id: $id, input: $input) {\n success\n issue {\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 }\n }\n";
|
|
28
50
|
export declare const ARCHIVE_ISSUE_MUTATION = "\n mutation ArchiveIssue($id: String!) {\n issueArchive(id: $id) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
|
29
51
|
export declare const DELETE_ISSUE_MUTATION = "\n mutation DeleteIssue($id: String!, $permanentlyDelete: Boolean) {\n issueDelete(id: $id, permanentlyDelete: $permanentlyDelete) {\n success\n lastSyncId\n entity {\n id\n }\n }\n }\n";
|
|
30
52
|
/**
|
package/dist/queries/issues.js
CHANGED
|
@@ -124,6 +124,36 @@ export const GET_ISSUE_BY_IDENTIFIER_QUERY = `
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
`;
|
|
127
|
+
/**
|
|
128
|
+
* Batch issue fetch. Used by `issues read <id...>` (DEV-4477) to collapse
|
|
129
|
+
* N parallel single-issue queries into one round-trip. The `$filter` is
|
|
130
|
+
* a top-level OR built client-side; each OR clause is either:
|
|
131
|
+
* { id: { in: [...uuids] } } ← all UUIDs in one IN
|
|
132
|
+
* { team: { key: { eq: <key> } }, number: { in: [...nums] } } ← identifiers
|
|
133
|
+
* grouped by team
|
|
134
|
+
* Linear returns nodes in DB order, NOT input order — the caller is
|
|
135
|
+
* responsible for re-sorting to match the input list before surfacing.
|
|
136
|
+
*
|
|
137
|
+
* `$first` is `refCount * 2`, clamped to [100, 250] — the floor absorbs the
|
|
138
|
+
* rare case where Linear returns more rows than requested via OR-clause
|
|
139
|
+
* collisions; the ceiling is Linear's connection-page cap, beyond which the
|
|
140
|
+
* server silently truncates.
|
|
141
|
+
*
|
|
142
|
+
* Raw GraphQL (not @linear/sdk) is required because the SDK's
|
|
143
|
+
* `client.issues({ filter: ... })` strips through the same query under
|
|
144
|
+
* the hood but forces one round-trip per call site; the doctrine in
|
|
145
|
+
* CLAUDE.md ("Performance-critical loops where the SDK's per-edge
|
|
146
|
+
* resolver promises produce N+1 round-trips") explicitly authorizes this.
|
|
147
|
+
*/
|
|
148
|
+
export const BATCH_GET_ISSUES_QUERY = `
|
|
149
|
+
query BatchGetIssues($filter: IssueFilter!, $first: Int!) {
|
|
150
|
+
issues(filter: $filter, first: $first) {
|
|
151
|
+
nodes {
|
|
152
|
+
${COMPLETE_ISSUE_WITH_COMMENTS_FRAGMENT}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
`;
|
|
127
157
|
/**
|
|
128
158
|
* Batch-resolves an update's project/milestone/issue inputs.
|
|
129
159
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const LIST_PROJECT_MILESTONES_QUERY = "\n query ListProjectMilestones($projectId: String!, $first: Int!) {\n project(id: $projectId) {\n id\n name\n projectMilestones(first: $first) {\n nodes {\n id\n name\n description\n targetDate\n sortOrder\n createdAt\n updatedAt\n }\n }\n }\n }\n";
|
|
2
|
-
export declare const GET_PROJECT_MILESTONE_BY_ID_QUERY = "\n query GetProjectMilestone($id: String!, $issuesFirst: Int) {\n projectMilestone(id: $id) {\n id\n name\n description\n targetDate\n sortOrder\n createdAt\n updatedAt\n project {\n id\n name\n }\n issues(first: $issuesFirst) {\n nodes {\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 }\n }\n }\n";
|
|
2
|
+
export declare const GET_PROJECT_MILESTONE_BY_ID_QUERY = "\n query GetProjectMilestone($id: String!, $issuesFirst: Int) {\n projectMilestone(id: $id) {\n id\n name\n description\n targetDate\n sortOrder\n createdAt\n updatedAt\n project {\n id\n name\n }\n issues(first: $issuesFirst) {\n nodes {\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 }\n }\n }\n";
|
|
3
3
|
export declare const FIND_PROJECT_MILESTONE_BY_NAME_SCOPED = "\n query FindProjectMilestoneScoped($name: String!, $projectId: String!) {\n project(id: $projectId) {\n projectMilestones(filter: { name: { eq: $name } }, first: 10) {\n nodes {\n id\n name\n targetDate\n sortOrder\n project {\n id\n name\n }\n }\n }\n }\n }\n";
|
|
4
4
|
export declare const FIND_PROJECT_MILESTONE_BY_NAME_GLOBAL = "\n query FindProjectMilestoneGlobal($name: String!) {\n projectMilestones(filter: { name: { eq: $name } }, first: 10) {\n nodes {\n id\n name\n targetDate\n sortOrder\n project {\n id\n name\n }\n }\n }\n }\n";
|
|
5
5
|
export declare const CREATE_PROJECT_MILESTONE_MUTATION = "\n mutation CreateProjectMilestone($projectId: String!, $name: String!, $description: String, $targetDate: TimelessDate) {\n projectMilestoneCreate(input: {\n projectId: $projectId\n name: $name\n description: $description\n targetDate: $targetDate\n }) {\n success\n projectMilestone {\n id\n name\n description\n targetDate\n sortOrder\n createdAt\n updatedAt\n project {\n id\n name\n }\n }\n }\n }\n";
|
package/dist/types/linear.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ interface UserRef {
|
|
|
27
27
|
interface StateRef {
|
|
28
28
|
id: string;
|
|
29
29
|
name: string;
|
|
30
|
+
/**
|
|
31
|
+
* Linear workflow-state type: `triage` / `backlog` / `unstarted` /
|
|
32
|
+
* `started` / `completed` / `canceled`. Optional because pre-DEV-4478
|
|
33
|
+
* callers and some legacy GraphQL selections didn't request it; new
|
|
34
|
+
* code (default state-filtering, e.g.) should set it.
|
|
35
|
+
*/
|
|
36
|
+
type?: string;
|
|
30
37
|
}
|
|
31
38
|
interface ProjectRef {
|
|
32
39
|
id: string;
|
|
@@ -16,4 +16,20 @@
|
|
|
16
16
|
* Returns `null` when the field isn't found, so callers can distinguish
|
|
17
17
|
* "missing" from "empty body".
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* Multi-section variant of `extractField`. Extracts each requested section
|
|
21
|
+
* by name in one call and returns a `{section -> text|null}` map preserving
|
|
22
|
+
* the caller's order. Missing sections map to `null` so the caller can
|
|
23
|
+
* distinguish "absent" from "empty" the same way `extractField` does.
|
|
24
|
+
*
|
|
25
|
+
* Each name is matched independently via `extractField` — semantics are
|
|
26
|
+
* identical (first-match wins, case-insensitive, fenced-code-block aware).
|
|
27
|
+
*
|
|
28
|
+
* DEV-4479: this is the primitive behind `el-linear issues read --sections
|
|
29
|
+
* "Done when,Out of scope"` so agents can pull several sections in one
|
|
30
|
+
* call instead of N spawns. (The CLI flag landed as `--sections` rather
|
|
31
|
+
* than `--fields` because `--fields` is already the program-level
|
|
32
|
+
* output-key filter — see the option's `addHelpText` for context.)
|
|
33
|
+
*/
|
|
34
|
+
export declare function extractFields(body: string, fieldNames: readonly string[]): Map<string, string | null>;
|
|
19
35
|
export declare function extractField(body: string, fieldName: string): string | null;
|
|
@@ -59,6 +59,28 @@ function matchHeader(line) {
|
|
|
59
59
|
}
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Multi-section variant of `extractField`. Extracts each requested section
|
|
64
|
+
* by name in one call and returns a `{section -> text|null}` map preserving
|
|
65
|
+
* the caller's order. Missing sections map to `null` so the caller can
|
|
66
|
+
* distinguish "absent" from "empty" the same way `extractField` does.
|
|
67
|
+
*
|
|
68
|
+
* Each name is matched independently via `extractField` — semantics are
|
|
69
|
+
* identical (first-match wins, case-insensitive, fenced-code-block aware).
|
|
70
|
+
*
|
|
71
|
+
* DEV-4479: this is the primitive behind `el-linear issues read --sections
|
|
72
|
+
* "Done when,Out of scope"` so agents can pull several sections in one
|
|
73
|
+
* call instead of N spawns. (The CLI flag landed as `--sections` rather
|
|
74
|
+
* than `--fields` because `--fields` is already the program-level
|
|
75
|
+
* output-key filter — see the option's `addHelpText` for context.)
|
|
76
|
+
*/
|
|
77
|
+
export function extractFields(body, fieldNames) {
|
|
78
|
+
const out = new Map();
|
|
79
|
+
for (const name of fieldNames) {
|
|
80
|
+
out.set(name, extractField(body, name));
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
62
84
|
export function extractField(body, fieldName) {
|
|
63
85
|
if (!body)
|
|
64
86
|
return null;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII tree formatter for `issues tree <ID>` (DEV-4480).
|
|
3
|
+
*
|
|
4
|
+
* Renders a parent → children tree using the same box-drawing prefixes as
|
|
5
|
+
* the standard `tree` Unix utility (`├── `, `└── `, `│ `, ` `). Each
|
|
6
|
+
* line is `<prefix> <identifier> <title> <state-suffix>`, where the
|
|
7
|
+
* state-suffix is `[Done]` / `[Canceled]` for terminal-typed states and
|
|
8
|
+
* omitted otherwise.
|
|
9
|
+
*
|
|
10
|
+
* Pure rendering — does not fetch or filter. The depth bound and the
|
|
11
|
+
* terminal-state exclusion are enforced by the caller before this runs.
|
|
12
|
+
*/
|
|
13
|
+
import type { IssueTreeNode } from "../queries/issue-tree.js";
|
|
14
|
+
export declare function formatTree(root: IssueTreeNode): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII tree formatter for `issues tree <ID>` (DEV-4480).
|
|
3
|
+
*
|
|
4
|
+
* Renders a parent → children tree using the same box-drawing prefixes as
|
|
5
|
+
* the standard `tree` Unix utility (`├── `, `└── `, `│ `, ` `). Each
|
|
6
|
+
* line is `<prefix> <identifier> <title> <state-suffix>`, where the
|
|
7
|
+
* state-suffix is `[Done]` / `[Canceled]` for terminal-typed states and
|
|
8
|
+
* omitted otherwise.
|
|
9
|
+
*
|
|
10
|
+
* Pure rendering — does not fetch or filter. The depth bound and the
|
|
11
|
+
* terminal-state exclusion are enforced by the caller before this runs.
|
|
12
|
+
*/
|
|
13
|
+
const BRANCH_TEE = "├── ";
|
|
14
|
+
const BRANCH_END = "└── ";
|
|
15
|
+
const VERTICAL = "│ ";
|
|
16
|
+
const SPACE = " ";
|
|
17
|
+
export function formatTree(root) {
|
|
18
|
+
const lines = [formatNode(root)];
|
|
19
|
+
const kids = root.children?.nodes ?? [];
|
|
20
|
+
for (let i = 0; i < kids.length; i++) {
|
|
21
|
+
appendSubtree(lines, kids[i], "", i === kids.length - 1);
|
|
22
|
+
}
|
|
23
|
+
return lines.join("\n");
|
|
24
|
+
}
|
|
25
|
+
function appendSubtree(lines, node, prefix, isLast) {
|
|
26
|
+
const branch = isLast ? BRANCH_END : BRANCH_TEE;
|
|
27
|
+
lines.push(`${prefix}${branch}${formatNode(node)}`);
|
|
28
|
+
const childPrefix = prefix + (isLast ? SPACE : VERTICAL);
|
|
29
|
+
const kids = node.children?.nodes ?? [];
|
|
30
|
+
for (let i = 0; i < kids.length; i++) {
|
|
31
|
+
appendSubtree(lines, kids[i], childPrefix, i === kids.length - 1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function formatNode(node) {
|
|
35
|
+
const stateSuffix = node.state ? formatStateSuffix(node.state) : "";
|
|
36
|
+
const assigneeSuffix = node.assignee ? ` (@${node.assignee.name})` : "";
|
|
37
|
+
return `${node.identifier} ${node.title}${stateSuffix}${assigneeSuffix}`;
|
|
38
|
+
}
|
|
39
|
+
function formatStateSuffix(state) {
|
|
40
|
+
// Only annotate terminal-typed states; the renderer is otherwise
|
|
41
|
+
// state-name-agnostic so workspace-custom workflow states (e.g. "In
|
|
42
|
+
// Review") don't get a noisy suffix.
|
|
43
|
+
if (state.type === "completed" || state.type === "canceled") {
|
|
44
|
+
return ` [${state.name}]`;
|
|
45
|
+
}
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
@@ -41,6 +41,14 @@ export interface SearchIssueArgs {
|
|
|
41
41
|
delegateId?: string;
|
|
42
42
|
/** Issue states to include (e.g. `["Todo", "In Progress"]`). */
|
|
43
43
|
status?: string[];
|
|
44
|
+
/**
|
|
45
|
+
* Exclude terminal states (workflow type `completed` or `canceled`).
|
|
46
|
+
* Default `false` for back-compat at the service layer; the CLI's
|
|
47
|
+
* `issues list` / `issues search` flip this to `true` by default
|
|
48
|
+
* (DEV-4478) and accept `--include-closed` to opt back in. When
|
|
49
|
+
* `status` is also set, this is ignored — explicit user choice wins.
|
|
50
|
+
*/
|
|
51
|
+
excludeTerminalStates?: boolean;
|
|
44
52
|
/** Label names to require (intersection). */
|
|
45
53
|
labelNames?: string[];
|
|
46
54
|
/** Priority values (0-4) to include. */
|
|
@@ -149,6 +157,24 @@ export declare class GraphQLIssuesService {
|
|
|
149
157
|
constructor(graphQLService: GraphQLService, linearService: LinearService);
|
|
150
158
|
getIssues(limit?: number): Promise<LinearIssue[]>;
|
|
151
159
|
getIssueById(issueId: string): Promise<LinearIssue>;
|
|
160
|
+
/**
|
|
161
|
+
* Batch-fetches N issues in a single GraphQL round-trip (DEV-4477).
|
|
162
|
+
*
|
|
163
|
+
* Replaces `Promise.all(refs.map(getIssueById))` — that pattern issues N
|
|
164
|
+
* concurrent queries; this one issues exactly one. Useful for
|
|
165
|
+
* `el-linear issues read <id...>`, the only caller today.
|
|
166
|
+
*
|
|
167
|
+
* Identifier shapes accepted (mirrors `getIssueById`):
|
|
168
|
+
* - UUIDs (`isUuid(ref)` matches) — batched into a single `id.in` clause.
|
|
169
|
+
* - `TEAM-N` identifiers (`parseIssueIdentifier(ref)` matches) — grouped
|
|
170
|
+
* by team key into one `{ team.key.eq, number.in }` clause per team.
|
|
171
|
+
*
|
|
172
|
+
* Linear returns nodes in DB order; this method re-sorts to match the
|
|
173
|
+
* input list and throws `notFoundError` if any ref did not resolve, with
|
|
174
|
+
* the first missing ref named in the error. Order-preservation matters
|
|
175
|
+
* for the read handler — the JSON envelope is a positional array.
|
|
176
|
+
*/
|
|
177
|
+
getIssuesByRefs(refs: string[]): Promise<LinearIssue[]>;
|
|
152
178
|
startIssue(issueId: string): Promise<StartIssueResult>;
|
|
153
179
|
updateIssue(args: UpdateIssueArgs, labelMode?: string): Promise<LinearIssue>;
|
|
154
180
|
archiveIssue(issueId: string): Promise<IssueArchiveOperationResult>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveUserDisplayName } from "../config/resolver.js";
|
|
2
|
-
import { ARCHIVE_ISSUE_MUTATION, 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_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";
|
|
@@ -61,6 +61,95 @@ export class GraphQLIssuesService {
|
|
|
61
61
|
}
|
|
62
62
|
return this.transformIssueData(issueData);
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Batch-fetches N issues in a single GraphQL round-trip (DEV-4477).
|
|
66
|
+
*
|
|
67
|
+
* Replaces `Promise.all(refs.map(getIssueById))` — that pattern issues N
|
|
68
|
+
* concurrent queries; this one issues exactly one. Useful for
|
|
69
|
+
* `el-linear issues read <id...>`, the only caller today.
|
|
70
|
+
*
|
|
71
|
+
* Identifier shapes accepted (mirrors `getIssueById`):
|
|
72
|
+
* - UUIDs (`isUuid(ref)` matches) — batched into a single `id.in` clause.
|
|
73
|
+
* - `TEAM-N` identifiers (`parseIssueIdentifier(ref)` matches) — grouped
|
|
74
|
+
* by team key into one `{ team.key.eq, number.in }` clause per team.
|
|
75
|
+
*
|
|
76
|
+
* Linear returns nodes in DB order; this method re-sorts to match the
|
|
77
|
+
* input list and throws `notFoundError` if any ref did not resolve, with
|
|
78
|
+
* the first missing ref named in the error. Order-preservation matters
|
|
79
|
+
* for the read handler — the JSON envelope is a positional array.
|
|
80
|
+
*/
|
|
81
|
+
async getIssuesByRefs(refs) {
|
|
82
|
+
if (refs.length === 0) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
if (refs.length === 1) {
|
|
86
|
+
// Single ref → fall through to the single-issue path, which has
|
|
87
|
+
// the right error semantics (notFoundError on miss) and avoids
|
|
88
|
+
// building an OR clause with one branch.
|
|
89
|
+
return [await this.getIssueById(refs[0])];
|
|
90
|
+
}
|
|
91
|
+
const uuids = [];
|
|
92
|
+
const byTeam = new Map();
|
|
93
|
+
for (const ref of refs) {
|
|
94
|
+
if (isUuid(ref)) {
|
|
95
|
+
uuids.push(ref);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const { teamKey, issueNumber } = parseIssueIdentifier(ref);
|
|
99
|
+
const existing = byTeam.get(teamKey);
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.push(issueNumber);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
byTeam.set(teamKey, [issueNumber]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const orClauses = [];
|
|
108
|
+
if (uuids.length > 0) {
|
|
109
|
+
orClauses.push({ id: { in: uuids } });
|
|
110
|
+
}
|
|
111
|
+
for (const [teamKey, numbers] of byTeam) {
|
|
112
|
+
orClauses.push({
|
|
113
|
+
team: { key: { eq: teamKey } },
|
|
114
|
+
number: { in: numbers },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
// `first` is sized at 2x refs.length to absorb the rare case where
|
|
118
|
+
// Linear returns extra rows from the OR clauses (e.g. a typo-collision
|
|
119
|
+
// across teams). Floor of 100 covers small batches comfortably; ceiling
|
|
120
|
+
// of 250 is Linear's connection cap — exceeding it would silently
|
|
121
|
+
// truncate and we'd `notFoundError` on the truncated refs. With the
|
|
122
|
+
// ceiling clamped, a >125-ref batch would risk under-fetching, but
|
|
123
|
+
// `read <id...>` is typed-by-hand and never approaches that scale.
|
|
124
|
+
// (Cycle-1 nit.)
|
|
125
|
+
const first = Math.min(Math.max(refs.length * 2, 100), 250);
|
|
126
|
+
const result = await this.graphQLService.rawRequest(BATCH_GET_ISSUES_QUERY, { filter: { or: orClauses }, first });
|
|
127
|
+
const nodes = result.issues?.nodes ?? [];
|
|
128
|
+
// Build an index for O(1) per-ref lookup, then re-emit in input order.
|
|
129
|
+
// A node is reachable by both its UUID and its identifier; both index
|
|
130
|
+
// keys point at the same node object. This is intentional: if a caller
|
|
131
|
+
// passes both forms for the same issue (`["xxxxxx-...", "DEV-1"]`),
|
|
132
|
+
// or the same ref twice (`["DEV-1", "DEV-1"]`), each input ref gets
|
|
133
|
+
// its own output entry — the contract is "output shape mirrors input
|
|
134
|
+
// shape", not "dedupe by underlying issue". (Cycle-1 nit.)
|
|
135
|
+
const byUuid = new Map();
|
|
136
|
+
const byIdentifier = new Map();
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
byUuid.set(node.id, node);
|
|
139
|
+
if (node.identifier) {
|
|
140
|
+
byIdentifier.set(node.identifier, node);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const ordered = [];
|
|
144
|
+
for (const ref of refs) {
|
|
145
|
+
const node = isUuid(ref) ? byUuid.get(ref) : byIdentifier.get(ref);
|
|
146
|
+
if (!node) {
|
|
147
|
+
throw notFoundError("Issue", ref);
|
|
148
|
+
}
|
|
149
|
+
ordered.push(node);
|
|
150
|
+
}
|
|
151
|
+
return ordered.map((issue) => this.transformIssueData(issue));
|
|
152
|
+
}
|
|
64
153
|
async startIssue(issueId) {
|
|
65
154
|
const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
|
|
66
155
|
const context = await this.graphQLService.rawRequest(GET_ISSUE_START_CONTEXT_QUERY, { id: resolvedIssueId });
|
|
@@ -391,6 +480,7 @@ export class GraphQLIssuesService {
|
|
|
391
480
|
delegateId: finalDelegateId,
|
|
392
481
|
project: projectFilter,
|
|
393
482
|
status: args.status,
|
|
483
|
+
excludeTerminalStates: args.excludeTerminalStates,
|
|
394
484
|
labelNames: args.labelNames,
|
|
395
485
|
priority: args.priority,
|
|
396
486
|
});
|
|
@@ -401,6 +491,7 @@ export class GraphQLIssuesService {
|
|
|
401
491
|
delegateId: finalDelegateId,
|
|
402
492
|
project: projectFilter,
|
|
403
493
|
status: args.status,
|
|
494
|
+
excludeTerminalStates: args.excludeTerminalStates,
|
|
404
495
|
labelNames: args.labelNames,
|
|
405
496
|
priority: args.priority,
|
|
406
497
|
});
|
|
@@ -820,6 +911,11 @@ export class GraphQLIssuesService {
|
|
|
820
911
|
if (filters.status && filters.status.length > 0) {
|
|
821
912
|
filtered = filtered.filter((issue) => filters.status.includes(issue.state?.name ?? ""));
|
|
822
913
|
}
|
|
914
|
+
else if (filters.excludeTerminalStates) {
|
|
915
|
+
// Implicit terminal-state exclusion (DEV-4478). Explicit `status`
|
|
916
|
+
// always wins — the user already named the states they want.
|
|
917
|
+
filtered = filtered.filter((issue) => issue.state?.type !== "completed" && issue.state?.type !== "canceled");
|
|
918
|
+
}
|
|
823
919
|
if (filters.labelNames && filters.labelNames.length > 0) {
|
|
824
920
|
const lowerNames = filters.labelNames.map((n) => n.toLowerCase());
|
|
825
921
|
filtered = filtered.filter((issue) => {
|
|
@@ -858,6 +954,11 @@ export class GraphQLIssuesService {
|
|
|
858
954
|
if (filters.status && filters.status.length > 0) {
|
|
859
955
|
filter.state = { name: { in: filters.status } };
|
|
860
956
|
}
|
|
957
|
+
else if (filters.excludeTerminalStates) {
|
|
958
|
+
// Implicit terminal-state exclusion (DEV-4478). Explicit `status`
|
|
959
|
+
// always wins — the user already named the states they want.
|
|
960
|
+
filter.state = { type: { nin: ["completed", "canceled"] } };
|
|
961
|
+
}
|
|
861
962
|
if (filters.labelNames && filters.labelNames.length > 0) {
|
|
862
963
|
if (filters.labelNames.length === 1) {
|
|
863
964
|
filter.labels = {
|
|
@@ -1097,7 +1198,13 @@ export class GraphQLIssuesService {
|
|
|
1097
1198
|
transformIssueRelations(issue) {
|
|
1098
1199
|
return {
|
|
1099
1200
|
state: issue.state
|
|
1100
|
-
? {
|
|
1201
|
+
? {
|
|
1202
|
+
id: issue.state.id,
|
|
1203
|
+
name: issue.state.name,
|
|
1204
|
+
// DEV-4478: surface workflow-state `type` so callers can filter
|
|
1205
|
+
// terminal states (`completed`, `canceled`) without re-querying.
|
|
1206
|
+
type: issue.state.type,
|
|
1207
|
+
}
|
|
1101
1208
|
: undefined,
|
|
1102
1209
|
assignee: issue.assignee
|
|
1103
1210
|
? {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enrichlayer/el-linear",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@inquirer/prompts": "^8.4.2",
|
|
55
|
-
"@linear/sdk": "^
|
|
55
|
+
"@linear/sdk": "^86.0.0",
|
|
56
56
|
"commander": "^14.0.0",
|
|
57
57
|
"picocolors": "^1.1.1"
|
|
58
58
|
},
|