@enrichlayer/el-linear 1.18.1 → 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 +85 -0
- package/claude-skills/linear-operations/SKILL.md +6 -1
- package/dist/commands/comments.js +6 -0
- package/dist/commands/config.js +164 -1
- 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/config/migrate-from-personal.d.ts +77 -0
- package/dist/config/migrate-from-personal.js +236 -0
- 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
|
@@ -356,6 +356,27 @@ preferences; those stay in personal config. Example team file:
|
|
|
356
356
|
Run `el-linear config show` to see the resolved config and confirm which team
|
|
357
357
|
config path is active (`teamConfig` field in the output).
|
|
358
358
|
|
|
359
|
+
#### Migrating a personal-heavy config
|
|
360
|
+
|
|
361
|
+
Before the team-config split, members typically carried full local copies of
|
|
362
|
+
`members` / `teams` / `labels` / `statusDefaults` / `teamAliases` — and often
|
|
363
|
+
a deprecated `brand: { name, reject }` key. Once a team config is in place
|
|
364
|
+
those local copies just silently shadow the shared values (and silently
|
|
365
|
+
*diverge* when they drift). To clean it up safely:
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
el-linear config migrate-from-personal # dry-run — prints the plan per file
|
|
369
|
+
el-linear config migrate-from-personal --apply # write the slimmed files (with .bak-<ts> backups)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Targets the global personal config + every named profile's `config.json`. For
|
|
373
|
+
each top-level shadowable key, the slim drops it **only** when the local copy
|
|
374
|
+
is a strict subset of team (zero divergence, zero non-trivial personal-only
|
|
375
|
+
entries); otherwise the key is left untouched and the divergence is reported
|
|
376
|
+
in the plan so a human resolves it. The deprecated `brand` key is dropped
|
|
377
|
+
when content-identical to a team `terms[]` entry, or converted to a personal
|
|
378
|
+
`terms[]` entry when it differs (with a warning).
|
|
379
|
+
|
|
359
380
|
## Term enforcement (with brand-promotion examples)
|
|
360
381
|
|
|
361
382
|
The `terms` rules let you keep a list of canonical names and the misspellings
|
|
@@ -426,6 +447,24 @@ el-linear <command> --help # detailed help for one command
|
|
|
426
447
|
All `list` subcommands support `-l, --limit <n>`. All commands accept the
|
|
427
448
|
top-level filters: `--format <json|summary>`, `--raw`, `--jq <expr>`, `--fields <list>`.
|
|
428
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
|
+
|
|
429
468
|
## Output formats
|
|
430
469
|
|
|
431
470
|
Every command accepts `--format <kind>` at the root:
|
|
@@ -514,6 +553,52 @@ el-linear read ADM-652 --field "Out of scope"
|
|
|
514
553
|
Single-issue only — pair it with `--jq` on full JSON for batch
|
|
515
554
|
extraction across many issues.
|
|
516
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
|
+
|
|
517
602
|
## Wrapping Linear references in arbitrary text
|
|
518
603
|
|
|
519
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
|
}
|
package/dist/commands/config.js
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { getActiveTeamConfigInfo, getActiveTeamConfigPath, loadConfig, loadLocalConfig, } from "../config/config.js";
|
|
5
|
-
import {
|
|
5
|
+
import { planMigration, } from "../config/migrate-from-personal.js";
|
|
6
|
+
import { CONFIG_PATH, isSafeProfileName, PROFILES_DIR, resolveActiveProfile, } from "../config/paths.js";
|
|
6
7
|
import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
|
|
7
8
|
import { updateConfig } from "./init/shared.js";
|
|
8
9
|
/**
|
|
@@ -185,4 +186,166 @@ export function setupConfigCommands(program) {
|
|
|
185
186
|
fs.writeFileSync(localPath, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
|
|
186
187
|
outputSuccess({ data: updated });
|
|
187
188
|
}));
|
|
189
|
+
// ── config migrate-from-personal ──────────────────────────────────
|
|
190
|
+
//
|
|
191
|
+
// Slim personal/profile configs that duplicate keys the team config now
|
|
192
|
+
// provides (DEV-4458). The companion pure logic + tests live in
|
|
193
|
+
// `src/config/migrate-from-personal.ts`. This command just discovers
|
|
194
|
+
// the target files, reads the active team config, calls planMigration
|
|
195
|
+
// for each, and on --apply backs up + atomically rewrites them.
|
|
196
|
+
config
|
|
197
|
+
.command("migrate-from-personal")
|
|
198
|
+
.description("Slim personal/profile configs against the active team config — drops duplicated members/teams/labels/statusDefaults/teamAliases entries, and migrates the deprecated 'brand' key to a 'terms[]' entry. Dry-run by default; pass --apply to write.")
|
|
199
|
+
.option("--apply", "Write the slimmed files (with timestamped .bak-<ts> backups + atomic replace). Default is dry-run — only prints the plan.")
|
|
200
|
+
.option("--file <path>", "Scope to one file. Default: ~/.config/el-linear/config.json plus every <profiles>/<name>/config.json with a safe name.")
|
|
201
|
+
.addHelpText("after", `\nThe slim only drops a shadowable top-level key when the personal copy is a strict subset of team (zero divergence and zero non-trivial personal-only entries). Anything that would silently shadow a team value, or carry a personal-only entry team doesn't have, is left untouched and reported in the plan so a human can decide.\n\nThe active team config is never a target — passing --file <team-path> is rejected, and a profile config that happens to coincide with the team path is skipped silently with a warning. The first --apply may re-format JSON to 2-space indent + trailing newline; this is harmless but the .bak-<ts> backup preserves the original byte-for-byte.\n\nUses the currently-active team config for every target. If you have profiles pointing at different team configs, run --file <path> per profile with EL_LINEAR_TEAM_CONFIG set.\n\nExamples:\n el-linear config migrate-from-personal\n el-linear config migrate-from-personal --apply\n el-linear config migrate-from-personal --file ~/.config/el-linear/profiles/foo/config.json --apply`)
|
|
202
|
+
.action(handleAsyncCommand(async (opts) => {
|
|
203
|
+
const teamPath = getActiveTeamConfigPath();
|
|
204
|
+
if (!teamPath) {
|
|
205
|
+
throw new Error("No active team config found. Set one with `el-linear config team set-path <path>` (or EL_LINEAR_TEAM_CONFIG) before running migrate-from-personal — without a team layer there's nothing to dedupe against.");
|
|
206
|
+
}
|
|
207
|
+
let team;
|
|
208
|
+
try {
|
|
209
|
+
team = JSON.parse(fs.readFileSync(teamPath, "utf8"));
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
throw new Error(`Failed to read team config at ${teamPath}: ${err.message}`);
|
|
213
|
+
}
|
|
214
|
+
const apply = opts.apply ?? false;
|
|
215
|
+
const resolvedTeamPath = path.resolve(teamPath);
|
|
216
|
+
// Self-diff guard: refuse to migrate the team config against itself
|
|
217
|
+
// (cycle-1 nit). Without this, every shadowable key in team would
|
|
218
|
+
// classify as "drop" (a key is trivially a subset of itself) and
|
|
219
|
+
// --apply would gut the team config.
|
|
220
|
+
if (opts.file &&
|
|
221
|
+
path.resolve(expandHome(opts.file)) === resolvedTeamPath) {
|
|
222
|
+
throw new Error(`--file resolves to the active team config (${resolvedTeamPath}). Refusing to migrate the team config against itself.`);
|
|
223
|
+
}
|
|
224
|
+
const targetsBeforeFilter = opts.file
|
|
225
|
+
? [path.resolve(expandHome(opts.file))]
|
|
226
|
+
: enumerateMigrationTargets();
|
|
227
|
+
const targets = [];
|
|
228
|
+
for (const t of targetsBeforeFilter) {
|
|
229
|
+
if (path.resolve(t) === resolvedTeamPath) {
|
|
230
|
+
outputWarning(`Skipping ${t} — it resolves to the active team config; migrating it against itself would erase team-shared keys.`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
targets.push(t);
|
|
234
|
+
}
|
|
235
|
+
const results = targets.map((file) => migrateOneFile(file, team, apply));
|
|
236
|
+
// Forward per-file plan warnings to the standard _warnings envelope
|
|
237
|
+
// (cycle-1 nit) so scripters reading the stable warning channel see
|
|
238
|
+
// them without having to inspect each target.
|
|
239
|
+
for (const r of results) {
|
|
240
|
+
for (const w of r.warnings)
|
|
241
|
+
outputWarning(`${r.file}: ${w}`);
|
|
242
|
+
}
|
|
243
|
+
// --file <single>: an unreadable / unparseable target is a hard
|
|
244
|
+
// failure (cycle-1 nit). Default enumeration stays soft so one bad
|
|
245
|
+
// profile doesn't crash the run for the rest.
|
|
246
|
+
if (opts.file && results.length === 1 && results[0].error) {
|
|
247
|
+
throw new Error(results[0].error);
|
|
248
|
+
}
|
|
249
|
+
outputSuccess({
|
|
250
|
+
data: {
|
|
251
|
+
teamConfigPath: teamPath,
|
|
252
|
+
applied: apply,
|
|
253
|
+
targetCount: results.length,
|
|
254
|
+
targets: results,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Enumerate every personal/profile config file the migration tool should
|
|
261
|
+
* consider by default: the global personal config, plus each profile dir
|
|
262
|
+
* whose name passes `isSafeProfileName` and whose `config.json` exists.
|
|
263
|
+
*/
|
|
264
|
+
function enumerateMigrationTargets() {
|
|
265
|
+
const out = [];
|
|
266
|
+
if (fs.existsSync(CONFIG_PATH))
|
|
267
|
+
out.push(CONFIG_PATH);
|
|
268
|
+
if (fs.existsSync(PROFILES_DIR)) {
|
|
269
|
+
const entries = fs.readdirSync(PROFILES_DIR);
|
|
270
|
+
for (const name of entries.sort()) {
|
|
271
|
+
if (!isSafeProfileName(name))
|
|
272
|
+
continue;
|
|
273
|
+
const p = path.join(PROFILES_DIR, name, "config.json");
|
|
274
|
+
if (fs.existsSync(p))
|
|
275
|
+
out.push(p);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
function migrateOneFile(file, team, apply) {
|
|
281
|
+
let rawText;
|
|
282
|
+
let personal;
|
|
283
|
+
try {
|
|
284
|
+
rawText = fs.readFileSync(file, "utf8");
|
|
285
|
+
personal = JSON.parse(rawText);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
return {
|
|
289
|
+
file,
|
|
290
|
+
changed: false,
|
|
291
|
+
before: { sizeBytes: 0, keys: [] },
|
|
292
|
+
after: { sizeBytes: 0, keys: [] },
|
|
293
|
+
keys: [],
|
|
294
|
+
brand: { status: "absent" },
|
|
295
|
+
warnings: [],
|
|
296
|
+
error: `Could not read/parse: ${err.message}`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const { slimmed, plan } = planMigration(personal, team);
|
|
300
|
+
const slimmedText = `${JSON.stringify(slimmed, null, 2)}\n`;
|
|
301
|
+
const beforeBytes = Buffer.byteLength(rawText, "utf8");
|
|
302
|
+
const afterBytes = Buffer.byteLength(slimmedText, "utf8");
|
|
303
|
+
const changed = slimmedText !== rawText;
|
|
304
|
+
const result = {
|
|
305
|
+
file,
|
|
306
|
+
changed,
|
|
307
|
+
before: { sizeBytes: beforeBytes, keys: Object.keys(personal) },
|
|
308
|
+
after: { sizeBytes: afterBytes, keys: Object.keys(slimmed) },
|
|
309
|
+
keys: plan.keys,
|
|
310
|
+
brand: plan.brand,
|
|
311
|
+
warnings: plan.warnings,
|
|
312
|
+
};
|
|
313
|
+
if (apply && changed) {
|
|
314
|
+
// Backup names include milliseconds (sub-second collisions on rapid
|
|
315
|
+
// re-apply would otherwise overwrite the original backup) AND copy
|
|
316
|
+
// with COPYFILE_EXCL so a same-millisecond collision throws instead
|
|
317
|
+
// of clobbering. Same for the temp file via `wx` flag.
|
|
318
|
+
const { backup, tmp } = acquireBackupAndTempPaths(file);
|
|
319
|
+
fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
|
|
320
|
+
fs.writeFileSync(tmp, slimmedText, { encoding: "utf8", flag: "wx" });
|
|
321
|
+
fs.renameSync(tmp, file);
|
|
322
|
+
result.backup = backup;
|
|
323
|
+
}
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
function backupTimestamp() {
|
|
327
|
+
const d = new Date();
|
|
328
|
+
const pad = (n) => n.toString().padStart(2, "0");
|
|
329
|
+
const pad3 = (n) => n.toString().padStart(3, "0");
|
|
330
|
+
return (`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
|
331
|
+
`-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` +
|
|
332
|
+
`-${pad3(d.getMilliseconds())}`);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Pick a `.bak-<ts>` and `.tmp-<ts>` pair that don't collide with anything
|
|
336
|
+
* on disk. Millisecond resolution makes a collision extraordinarily rare,
|
|
337
|
+
* but a tight retry loop closes the remaining gap (and the `COPYFILE_EXCL`
|
|
338
|
+
* / `wx` flags at the call site fail loudly if the gap is ever crossed).
|
|
339
|
+
*/
|
|
340
|
+
function acquireBackupAndTempPaths(file) {
|
|
341
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
342
|
+
const ts = backupTimestamp();
|
|
343
|
+
const suffix = attempt === 0 ? ts : `${ts}-${attempt}`;
|
|
344
|
+
const backup = `${file}.bak-${suffix}`;
|
|
345
|
+
const tmp = `${file}.tmp-${suffix}`;
|
|
346
|
+
if (!fs.existsSync(backup) && !fs.existsSync(tmp)) {
|
|
347
|
+
return { backup, tmp };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
throw new Error(`Could not pick a unique backup name for ${file} after 100 attempts; clean up old .bak-/.tmp- siblings and retry.`);
|
|
188
351
|
}
|
|
@@ -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>")
|