linear-sdk-cli 0.1.3 → 0.2.3
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +96 -58
- package/COVERAGE.md +5 -5
- package/MIGRATING.md +71 -69
- package/README.md +115 -90
- package/package.json +6 -7
- package/skills/README.md +15 -0
- package/skills/linear-sdk-cli/SKILL.md +27 -3
- package/skills/linear-sdk-cli/references/api.md +9 -9
- package/skills/linear-sdk-cli/references/auth.md +18 -0
- package/skills/linear-sdk-cli/references/commands.md +2 -2
- package/skills/linear-sdk-cli/references/comment.md +9 -6
- package/skills/linear-sdk-cli/references/config.md +9 -11
- package/skills/linear-sdk-cli/references/issue.md +72 -18
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/team.md +16 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
- package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
- package/src/bin/linear.ts +16 -6
- package/src/cli.ts +3 -1
- package/src/commands/agent-session.ts +13 -5
- package/src/commands/api.ts +15 -9
- package/src/commands/attach.ts +15 -4
- package/src/commands/comment.ts +50 -11
- package/src/commands/completion.ts +2 -6
- package/src/commands/cycle.ts +6 -1
- package/src/commands/discover.ts +6 -2
- package/src/commands/document.ts +4 -1
- package/src/commands/initiative.ts +5 -1
- package/src/commands/issue.ts +392 -91
- package/src/commands/meta.ts +139 -31
- package/src/commands/milestone.ts +4 -1
- package/src/commands/notification.ts +15 -6
- package/src/commands/open.ts +84 -0
- package/src/commands/project.ts +17 -4
- package/src/commands/team.ts +13 -0
- package/src/commands/user.ts +2 -2
- package/src/commands/webhook.ts +12 -2
- package/src/config.ts +501 -184
- package/src/context.ts +13 -4
- package/src/git.ts +28 -3
- package/src/lib/body.ts +26 -2
- package/src/lib/errors.ts +33 -8
- package/src/lib/introspect.ts +76 -7
- package/src/lib/keyring.ts +51 -9
- package/src/lib/mentions.ts +49 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +108 -12
- package/src/lib/output-shapes.ts +81 -9
- package/src/lib/pagination.ts +50 -17
- package/src/lib/resolve.ts +40 -7
- package/src/lib/shape.ts +16 -12
- package/src/lib/upload.ts +3 -1
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +96 -18
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +1 -1
- package/src/output/table.ts +7 -7
- package/src/services/agent-session.ts +11 -3
- package/src/services/attachment.ts +14 -5
- package/src/services/comment.ts +37 -9
- package/src/services/cycle.ts +11 -4
- package/src/services/document.ts +29 -10
- package/src/services/favorite.ts +15 -9
- package/src/services/initiative-update.ts +16 -15
- package/src/services/initiative.ts +15 -10
- package/src/services/issue.ts +263 -19
- package/src/services/label.ts +12 -2
- package/src/services/milestone.ts +8 -5
- package/src/services/notification.ts +16 -2
- package/src/services/organization.ts +23 -18
- package/src/services/project.ts +55 -34
- package/src/services/roadmap.ts +7 -6
- package/src/services/state.ts +9 -3
- package/src/services/team.ts +67 -24
- package/src/services/user.ts +2 -2
- package/src/services/webhook.ts +28 -17
package/src/commands/issue.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
readAlias,
|
|
15
15
|
parseList,
|
|
16
16
|
parseIntOption,
|
|
17
|
+
parsePriority,
|
|
18
|
+
collectArray,
|
|
17
19
|
CYCLE_FLAG,
|
|
18
20
|
CYCLE_DESC,
|
|
19
21
|
suggestSubcommand,
|
|
@@ -33,27 +35,34 @@ import {
|
|
|
33
35
|
buildPrArgs,
|
|
34
36
|
} from "../git.js";
|
|
35
37
|
import { execFileSync } from "node:child_process";
|
|
36
|
-
import {
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
39
|
+
import { CliError, normalizeError } from "../lib/errors.js";
|
|
40
|
+
import { openUrl } from "../lib/open.js";
|
|
37
41
|
import type { Context } from "../context.js";
|
|
38
42
|
import * as svc from "../services/issue.js";
|
|
39
43
|
import * as commentSvc from "../services/comment.js";
|
|
40
|
-
import { isSelf } from "../lib/resolve.js";
|
|
44
|
+
import { isSelf, normalizeIssueReference, resolveIssue } from "../lib/resolve.js";
|
|
41
45
|
import type { Column } from "../output/table.js";
|
|
42
46
|
|
|
43
|
-
/** Resolve the target issue id from an argument
|
|
44
|
-
function requireId(idArg: string | undefined): string {
|
|
47
|
+
/** Resolve the target issue id from an argument/current branch, expanding `42` via the default team. */
|
|
48
|
+
function requireId(idArg: string | undefined, defaultTeam?: string): string {
|
|
45
49
|
const id = idArg ?? currentIssueId();
|
|
46
50
|
if (!id) {
|
|
47
51
|
throw usageError("No issue id given and none could be inferred from the current branch.");
|
|
48
52
|
}
|
|
49
|
-
return id;
|
|
53
|
+
return normalizeIssueReference(id, defaultTeam);
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
const ROW_COLUMNS: Column<svc.IssueRow>[] = [
|
|
53
57
|
{ key: "id", header: "ID", value: (r) => r.identifier },
|
|
54
58
|
// `--include-archived` mixes live, archived and trashed rows; mark the latter
|
|
55
59
|
// two so they cannot pass for live. The state name alone stays under 14.
|
|
56
|
-
{
|
|
60
|
+
{
|
|
61
|
+
key: "state",
|
|
62
|
+
header: "State",
|
|
63
|
+
value: (r) => `${r.state?.name ?? ""}${lifecycleMark(r)}`,
|
|
64
|
+
max: 26,
|
|
65
|
+
},
|
|
57
66
|
{ key: "priority", header: "Pri", value: (r) => shortPriority(r.priority) },
|
|
58
67
|
{ key: "assignee", header: "Assignee", value: (r) => r.assignee?.displayName ?? "—", max: 16 },
|
|
59
68
|
{ key: "title", header: "Title", value: (r) => r.title, max: 60 },
|
|
@@ -88,6 +97,16 @@ function lifecycleMark(r: Pick<svc.IssueRow, "trashed" | "archivedAt">): string
|
|
|
88
97
|
return r.trashed ? " (trashed)" : r.archivedAt ? " (archived)" : "";
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
/** A removed reference flag whose meaning is already this CLI's query default. */
|
|
101
|
+
function addNoopAllAssignees(command: Command): void {
|
|
102
|
+
command.addOption(
|
|
103
|
+
new Option(
|
|
104
|
+
"--all-assignees",
|
|
105
|
+
"accepted for compatibility (does not change the query)",
|
|
106
|
+
).hideHelp(),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
/**
|
|
92
111
|
* schpet/linear-cli `issue` subcommands that a migrating user may type and that
|
|
93
112
|
* do not exist under that name here. Since `view` is the default subcommand,
|
|
@@ -97,7 +116,8 @@ function lifecycleMark(r: Pick<svc.IssueRow, "trashed" | "archivedAt">): string
|
|
|
97
116
|
*/
|
|
98
117
|
const SCHPET_ISSUE_SUBCOMMANDS: Record<string, string> = {
|
|
99
118
|
link: "Use 'linear attachment create <issue> --url <url>'.",
|
|
100
|
-
commits:
|
|
119
|
+
commits:
|
|
120
|
+
"'issue commits' is not available here (jj/git log integration is not adopted). Use 'git log --grep <ID>'.",
|
|
101
121
|
"agent-session": "Use 'linear issue agent-session list|view <issue>'.",
|
|
102
122
|
};
|
|
103
123
|
|
|
@@ -109,7 +129,16 @@ export function registerIssue(program: Command): void {
|
|
|
109
129
|
.command("view [id]", { isDefault: true })
|
|
110
130
|
.description("Show an issue (defaults to the current branch's issue)")
|
|
111
131
|
.option("-w, --web", "open the issue in the browser instead of printing")
|
|
112
|
-
.option("--
|
|
132
|
+
.option("--app", "open the issue in Linear.app instead of printing")
|
|
133
|
+
.option("--no-comments", "exclude comments from the output")
|
|
134
|
+
// The old opt-in spelling remains accepted; comments are now on by default.
|
|
135
|
+
.addOption(
|
|
136
|
+
new Option(
|
|
137
|
+
"--comments",
|
|
138
|
+
"accepted for compatibility: comments are included by default",
|
|
139
|
+
).hideHelp(),
|
|
140
|
+
)
|
|
141
|
+
.option("--show-resolved-threads", "include resolved comment threads")
|
|
113
142
|
.action(
|
|
114
143
|
action(async (ctx: Context, opts, idArg?: string) => {
|
|
115
144
|
// `view` is the default subcommand, so `linear issue lst` lands here
|
|
@@ -121,21 +150,28 @@ export function registerIssue(program: Command): void {
|
|
|
121
150
|
const ported = SCHPET_ISSUE_SUBCOMMANDS[idArg.toLowerCase()];
|
|
122
151
|
const guess = ported ? undefined : suggestSubcommand(issue, idArg);
|
|
123
152
|
throw usageError(
|
|
124
|
-
`'${idArg}' is not a valid issue id (expected e.g. TES-123 or a UUID).${
|
|
153
|
+
`'${idArg}' is not a valid issue id (expected e.g. TES-123, 123 with a default team, or a UUID).${
|
|
125
154
|
ported ? ` ${ported}` : guess ? ` Did you mean 'linear issue ${guess}'?` : ""
|
|
126
155
|
}`,
|
|
127
156
|
);
|
|
128
157
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
if (opts.web && opts.app) throw usageError("Pass either --web or --app, not both.");
|
|
159
|
+
const includeComments = opts.comments !== false && !opts.web && !opts.app;
|
|
160
|
+
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
161
|
+
includeComments,
|
|
162
|
+
});
|
|
163
|
+
if (opts.web || opts.app) {
|
|
164
|
+
await openUrl(detail.url, { app: opts.app === true });
|
|
132
165
|
ctx.output.emit(
|
|
133
166
|
{ id: detail.id, identifier: detail.identifier, url: detail.url, opened: true },
|
|
134
|
-
() =>
|
|
167
|
+
() =>
|
|
168
|
+
ctx.output.success(
|
|
169
|
+
`Opened ${detail.identifier} in ${opts.app ? "Linear.app" : "the browser"}`,
|
|
170
|
+
),
|
|
135
171
|
);
|
|
136
172
|
return;
|
|
137
173
|
}
|
|
138
|
-
await renderIssueDetail(ctx, detail,
|
|
174
|
+
await renderIssueDetail(ctx, detail, includeComments, opts.showResolvedThreads === true);
|
|
139
175
|
}),
|
|
140
176
|
);
|
|
141
177
|
|
|
@@ -196,8 +232,12 @@ export function registerIssue(program: Command): void {
|
|
|
196
232
|
addFilterOptions(list).addOption(
|
|
197
233
|
// Not an alias of anything here — `list` already spans every state — but
|
|
198
234
|
// the reference ships it, so accept it as a no-op instead of erroring.
|
|
199
|
-
new Option(
|
|
235
|
+
new Option(
|
|
236
|
+
"--all-states",
|
|
237
|
+
"accepted for compatibility (list is all-states already)",
|
|
238
|
+
).hideHelp(),
|
|
200
239
|
);
|
|
240
|
+
addNoopAllAssignees(list);
|
|
201
241
|
|
|
202
242
|
// mine --------------------------------------------------------------------
|
|
203
243
|
// `list` stays general; `mine` is the opinionated "what's on my plate" view
|
|
@@ -260,6 +300,7 @@ export function registerIssue(program: Command): void {
|
|
|
260
300
|
addFilterOptions(mine, { assignee: false }).addOption(
|
|
261
301
|
new Option("--all-states", "include every workflow state, not just unstarted"),
|
|
262
302
|
);
|
|
303
|
+
addNoopAllAssignees(mine);
|
|
263
304
|
|
|
264
305
|
// search ------------------------------------------------------------------
|
|
265
306
|
const search = issue
|
|
@@ -309,6 +350,7 @@ export function registerIssue(program: Command): void {
|
|
|
309
350
|
"--search-comments",
|
|
310
351
|
"match comment bodies as well as titles and descriptions",
|
|
311
352
|
);
|
|
353
|
+
addNoopAllAssignees(search);
|
|
312
354
|
|
|
313
355
|
// create ------------------------------------------------------------------
|
|
314
356
|
const create = issue
|
|
@@ -321,13 +363,16 @@ export function registerIssue(program: Command): void {
|
|
|
321
363
|
.option("--editor", "compose the description in $EDITOR")
|
|
322
364
|
.option("-a, --assignee <who>", "assignee (me|email|name|id)")
|
|
323
365
|
.option("-s, --state <name>", "workflow state name or type")
|
|
324
|
-
.option("-P, --priority <0-4>", "priority",
|
|
366
|
+
.option("-P, --priority <0-4>", "priority", parsePriority)
|
|
325
367
|
.option("-l, --label <name>", "label (repeatable / comma-separated)", parseList)
|
|
326
368
|
.option("-p, --project <name>", "project name or id")
|
|
327
369
|
.option("--milestone <name>", "project milestone (requires --project)")
|
|
328
370
|
.option(CYCLE_FLAG, CYCLE_DESC)
|
|
329
371
|
.option("--estimate <n>", "estimate points", parseIntOption)
|
|
330
|
-
.option(
|
|
372
|
+
.option(
|
|
373
|
+
"--parent <id>",
|
|
374
|
+
"parent issue id (the sub-issue joins the parent's project unless --project says otherwise)",
|
|
375
|
+
)
|
|
331
376
|
.option("--due <date>", "due date (YYYY-MM-DD)")
|
|
332
377
|
.option("--template <name|id>", "create from an issue template (the team's or a shared one)")
|
|
333
378
|
.option("--no-default-template", "do not apply the team's default issue template")
|
|
@@ -380,7 +425,7 @@ export function registerIssue(program: Command): void {
|
|
|
380
425
|
milestone: opts.milestone,
|
|
381
426
|
cycle: opts.cycle,
|
|
382
427
|
estimate: opts.estimate,
|
|
383
|
-
parent: opts.parent,
|
|
428
|
+
parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
|
|
384
429
|
dueDate: readAlias(opts, "--due", "--due-date"),
|
|
385
430
|
template: opts.template,
|
|
386
431
|
// Both spellings: ours, and the reference CLI's `--no-use-default-template`.
|
|
@@ -389,8 +434,9 @@ export function registerIssue(program: Command): void {
|
|
|
389
434
|
ctx.defaultTeam,
|
|
390
435
|
);
|
|
391
436
|
if (!opts.start) {
|
|
392
|
-
ctx.output.emit(
|
|
393
|
-
|
|
437
|
+
ctx.output.emit(
|
|
438
|
+
{ id: created.id, identifier: created.identifier, url: created.url },
|
|
439
|
+
() => ctx.output.success(`Created ${created.identifier}: ${created.url}`),
|
|
394
440
|
);
|
|
395
441
|
return;
|
|
396
442
|
}
|
|
@@ -398,8 +444,10 @@ export function registerIssue(program: Command): void {
|
|
|
398
444
|
// just created. An explicit --state already put it where it belongs, so
|
|
399
445
|
// only the default case moves it (to the team's first `started` state).
|
|
400
446
|
const moved = !opts.state;
|
|
401
|
-
|
|
447
|
+
// Local checkout is the preflight for starting work. If git refuses
|
|
448
|
+
// (dirty tree, invalid ref, …), the issue remains in its existing state.
|
|
402
449
|
const branchResult = isGitRepo() ? checkoutBranch(created.branchName) : undefined;
|
|
450
|
+
await svc.moveIssueState(ctx.client, created, { move: moved });
|
|
403
451
|
ctx.output.emit(
|
|
404
452
|
{
|
|
405
453
|
id: created.id,
|
|
@@ -438,13 +486,14 @@ export function registerIssue(program: Command): void {
|
|
|
438
486
|
.option("-t, --team <key>", "move the issue to another team (changes its identifier)")
|
|
439
487
|
.option("-a, --assignee <who>", "assignee (me|email|name|id)")
|
|
440
488
|
.option("-s, --state <name>", "workflow state name or type")
|
|
441
|
-
.option("-P, --priority <0-4>", "priority",
|
|
489
|
+
.option("-P, --priority <0-4>", "priority", parsePriority)
|
|
442
490
|
.option("-p, --project <name>", "project name or id")
|
|
443
491
|
.option("--milestone <name>", "project milestone")
|
|
444
492
|
.option(CYCLE_FLAG, CYCLE_DESC)
|
|
445
493
|
.option("--estimate <n>", "estimate points", parseIntOption)
|
|
446
494
|
.option("--parent <id>", "parent issue id")
|
|
447
495
|
.option("--due <date>", "due date (YYYY-MM-DD)")
|
|
496
|
+
.option("-l, --label <name>", "replace all labels (repeatable / comma-separated)", parseList)
|
|
448
497
|
.option("--add-label <name>", "add a label (repeatable)", parseList)
|
|
449
498
|
.option("--remove-label <name>", "remove a label (repeatable)", parseList)
|
|
450
499
|
.option("--unassign", "clear the assignee")
|
|
@@ -467,7 +516,7 @@ export function registerIssue(program: Command): void {
|
|
|
467
516
|
file: opts.descriptionFile,
|
|
468
517
|
interactive: false,
|
|
469
518
|
});
|
|
470
|
-
const updated = await svc.updateIssue(ctx.client, requireId(idArg), {
|
|
519
|
+
const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
471
520
|
title: opts.title,
|
|
472
521
|
description,
|
|
473
522
|
// Only the explicit flag moves an issue — never `ctx.defaultTeam`, or
|
|
@@ -480,25 +529,29 @@ export function registerIssue(program: Command): void {
|
|
|
480
529
|
milestone: opts.milestone,
|
|
481
530
|
cycle: opts.cycle,
|
|
482
531
|
estimate: opts.estimate,
|
|
483
|
-
parent: opts.parent,
|
|
532
|
+
parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
|
|
484
533
|
dueDate: readAlias(opts, "--due", "--due-date"),
|
|
534
|
+
label: opts.label,
|
|
485
535
|
addLabel: opts.addLabel,
|
|
486
536
|
removeLabel: opts.removeLabel,
|
|
487
537
|
unassign: opts.unassign,
|
|
488
538
|
clearCycle: opts.clearCycle,
|
|
489
539
|
});
|
|
490
|
-
ctx.output.emit(
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
540
|
+
ctx.output.emit(
|
|
541
|
+
{ id: updated.id, identifier: updated.identifier, url: updated.url },
|
|
542
|
+
() => {
|
|
543
|
+
ctx.output.success(`Updated ${updated.identifier}`);
|
|
544
|
+
// A move renumbers the issue and Linear drops what the destination
|
|
545
|
+
// team cannot hold. Say so once, rather than letting a script's next
|
|
546
|
+
// `TES-42` fail with "no such issue". Verified live — see CHANGELOG.
|
|
547
|
+
if (opts.team) {
|
|
548
|
+
ctx.output.info(
|
|
549
|
+
`Moved to team ${updated.identifier.split("-")[0]}: the issue is now ${updated.identifier}. ` +
|
|
550
|
+
`Its cycle, team-scoped labels, and any project the new team is not part of do not carry over.`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
);
|
|
502
555
|
}),
|
|
503
556
|
);
|
|
504
557
|
addAliasOption(update, "--due-date <date>", "--due");
|
|
@@ -510,7 +563,9 @@ export function registerIssue(program: Command): void {
|
|
|
510
563
|
.action(
|
|
511
564
|
action(async (ctx: Context, _opts, a?: string, b?: string) => {
|
|
512
565
|
const { idArg, value } = oneOrTwo(a, b, "assignee");
|
|
513
|
-
const updated = await svc.updateIssue(ctx.client, requireId(idArg), {
|
|
566
|
+
const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
567
|
+
assignee: value,
|
|
568
|
+
});
|
|
514
569
|
ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
|
|
515
570
|
ctx.output.success(`Assigned ${updated.identifier}`),
|
|
516
571
|
);
|
|
@@ -523,7 +578,9 @@ export function registerIssue(program: Command): void {
|
|
|
523
578
|
.action(
|
|
524
579
|
action(async (ctx: Context, _opts, a?: string, b?: string) => {
|
|
525
580
|
const { idArg, value } = oneOrTwo(a, b, "state");
|
|
526
|
-
const updated = await svc.updateIssue(ctx.client, requireId(idArg), {
|
|
581
|
+
const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
582
|
+
state: value,
|
|
583
|
+
});
|
|
527
584
|
ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
|
|
528
585
|
ctx.output.success(`Moved ${updated.identifier} → ${value}`),
|
|
529
586
|
);
|
|
@@ -539,7 +596,7 @@ export function registerIssue(program: Command): void {
|
|
|
539
596
|
.action(
|
|
540
597
|
action(async (ctx: Context, opts, idArg?: string) => {
|
|
541
598
|
if (!opts.add && !opts.remove) throw usageError("Pass --add and/or --remove.");
|
|
542
|
-
const updated = await svc.updateIssue(ctx.client, requireId(idArg), {
|
|
599
|
+
const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
543
600
|
addLabel: opts.add,
|
|
544
601
|
removeLabel: opts.remove,
|
|
545
602
|
});
|
|
@@ -553,9 +610,14 @@ export function registerIssue(program: Command): void {
|
|
|
553
610
|
const comment = issue
|
|
554
611
|
.command("comment [id] [body]")
|
|
555
612
|
.description(
|
|
556
|
-
|
|
613
|
+
'Add a comment to an issue; on a matching branch, `issue comment "<body>"` is enough (or use the add/list/update/delete subcommands)',
|
|
557
614
|
)
|
|
558
615
|
.option("--body-file <path>", "read comment body from a file ('-' = stdin)")
|
|
616
|
+
.option(
|
|
617
|
+
"--mention <user>",
|
|
618
|
+
"prepend a real Linear mention (name, email, me, or id; repeatable)",
|
|
619
|
+
collectArray,
|
|
620
|
+
)
|
|
559
621
|
.action(
|
|
560
622
|
action(async (ctx: Context, opts, a?: string, b?: string) => {
|
|
561
623
|
// Both operands are optional, so a lone one is ambiguous: `TES-42` is
|
|
@@ -565,10 +627,17 @@ export function registerIssue(program: Command): void {
|
|
|
565
627
|
const { idArg, bodyArg } = idAndBody(a, b);
|
|
566
628
|
// Settle the id BEFORE the editor can open, so nobody writes a comment
|
|
567
629
|
// only to be told there was nowhere to put it.
|
|
568
|
-
const id = requireId(idArg);
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
630
|
+
const id = requireId(idArg, ctx.defaultTeam);
|
|
631
|
+
const mentions: string[] = opts.mention ?? [];
|
|
632
|
+
const body = resolveBody({
|
|
633
|
+
arg: bodyArg,
|
|
634
|
+
file: opts.bodyFile,
|
|
635
|
+
interactive: ctx.isTTY && mentions.length === 0,
|
|
636
|
+
});
|
|
637
|
+
if (!body && mentions.length === 0) throw usageError("No comment body provided.");
|
|
638
|
+
const { issue: iss, comment } = await commentSvc.addComment(ctx.client, id, body ?? "", {
|
|
639
|
+
mentions,
|
|
640
|
+
});
|
|
572
641
|
ctx.output.emit({ id: comment?.id, issue: iss.identifier }, () =>
|
|
573
642
|
ctx.output.success(`Commented on ${iss.identifier}`),
|
|
574
643
|
);
|
|
@@ -587,7 +656,11 @@ export function registerIssue(program: Command): void {
|
|
|
587
656
|
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
588
657
|
// Same implementation and row shape as `comment list` — TES-629: this
|
|
589
658
|
// used to be a second, narrower lister with its own JSON.
|
|
590
|
-
const comments = await commentSvc.listComments(
|
|
659
|
+
const comments = await commentSvc.listComments(
|
|
660
|
+
ctx.client,
|
|
661
|
+
requireId(idArg, ctx.defaultTeam),
|
|
662
|
+
ctx.limit,
|
|
663
|
+
);
|
|
591
664
|
ctx.output.list(
|
|
592
665
|
comments,
|
|
593
666
|
[
|
|
@@ -612,7 +685,9 @@ export function registerIssue(program: Command): void {
|
|
|
612
685
|
// is a surprise.
|
|
613
686
|
issue
|
|
614
687
|
.command("start [id]")
|
|
615
|
-
.description(
|
|
688
|
+
.description(
|
|
689
|
+
"Start work on an issue: check out its branch and move it to the first 'started' state",
|
|
690
|
+
)
|
|
616
691
|
.option("--state <name>", "move to this state instead of the first 'started' one")
|
|
617
692
|
.option("--no-move", "do not change the state; only check out the branch")
|
|
618
693
|
.addOption(new Option("--move", "accepted for compatibility: moving is the default").hideHelp())
|
|
@@ -635,14 +710,17 @@ export function registerIssue(program: Command): void {
|
|
|
635
710
|
throw usageError("Pass either --state or --no-move, not both.");
|
|
636
711
|
}
|
|
637
712
|
const moved = opts.state !== undefined || opts.move !== false;
|
|
638
|
-
const issueModel = await
|
|
639
|
-
stateInput: opts.state,
|
|
640
|
-
move: moved,
|
|
641
|
-
});
|
|
713
|
+
const issueModel = await resolveIssue(ctx.client, requireId(idArg, ctx.defaultTeam));
|
|
642
714
|
let branchResult: { branch: string; created: boolean } | undefined;
|
|
715
|
+
// Branch first, Linear second. A checkout failure is recoverable local
|
|
716
|
+
// feedback and must never leave the remote issue claiming work started.
|
|
643
717
|
if (opts.checkout !== false && isGitRepo()) {
|
|
644
718
|
branchResult = checkoutBranch(issueModel.branchName);
|
|
645
719
|
}
|
|
720
|
+
await svc.moveIssueState(ctx.client, issueModel, {
|
|
721
|
+
stateInput: opts.state,
|
|
722
|
+
move: moved,
|
|
723
|
+
});
|
|
646
724
|
ctx.output.emit(
|
|
647
725
|
{
|
|
648
726
|
id: issueModel.id,
|
|
@@ -656,7 +734,8 @@ export function registerIssue(program: Command): void {
|
|
|
656
734
|
ctx.output.success(
|
|
657
735
|
`${branchResult.created ? "Created and checked out" : "Checked out"} ${branchResult.branch}`,
|
|
658
736
|
);
|
|
659
|
-
if (moved)
|
|
737
|
+
if (moved)
|
|
738
|
+
ctx.output.success(`Moved ${issueModel.identifier} → ${opts.state ?? "started"}`);
|
|
660
739
|
if (!branchResult && !moved) ctx.output.info(`Branch name: ${issueModel.branchName}`);
|
|
661
740
|
},
|
|
662
741
|
);
|
|
@@ -675,10 +754,14 @@ export function registerIssue(program: Command): void {
|
|
|
675
754
|
.option("-r, --references", "link without closing: 'References <ID>' instead of 'Fixes <ID>'")
|
|
676
755
|
.action(
|
|
677
756
|
action(async (ctx: Context, opts, idArg?: string) => {
|
|
678
|
-
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg)
|
|
757
|
+
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
758
|
+
includeComments: false,
|
|
759
|
+
});
|
|
679
760
|
const references = opts.references === true;
|
|
680
761
|
const trailer = buildTrailer(detail.identifier, { references });
|
|
681
|
-
const message = buildDescription(detail.identifier, detail.title, detail.url, {
|
|
762
|
+
const message = buildDescription(detail.identifier, detail.title, detail.url, {
|
|
763
|
+
references,
|
|
764
|
+
});
|
|
682
765
|
ctx.output.emit(
|
|
683
766
|
{
|
|
684
767
|
identifier: detail.identifier,
|
|
@@ -709,7 +792,9 @@ export function registerIssue(program: Command): void {
|
|
|
709
792
|
if (!isGitRepo()) {
|
|
710
793
|
throw usageError("`issue pr` must be run inside a git repository.");
|
|
711
794
|
}
|
|
712
|
-
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg)
|
|
795
|
+
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
796
|
+
includeComments: false,
|
|
797
|
+
});
|
|
713
798
|
// Title `ID Title` (a custom --title is prefixed the same way), body the
|
|
714
799
|
// two Linear-issue trailers — schpet/linear-cli's PR, plus the magic word
|
|
715
800
|
// (`buildPrContent`). The issue's *description* is no longer copied in
|
|
@@ -750,8 +835,9 @@ export function registerIssue(program: Command): void {
|
|
|
750
835
|
.option("--duplicate", "relation type: duplicate")
|
|
751
836
|
.action(
|
|
752
837
|
action(async (ctx: Context, opts, id: string, op: string, other?: string) => {
|
|
838
|
+
const issueId = normalizeIssueReference(id, ctx.defaultTeam);
|
|
753
839
|
if (op === "list") {
|
|
754
|
-
const rels = await svc.listRelations(ctx.client,
|
|
840
|
+
const rels = await svc.listRelations(ctx.client, issueId);
|
|
755
841
|
ctx.output.list(
|
|
756
842
|
rels,
|
|
757
843
|
[
|
|
@@ -772,7 +858,13 @@ export function registerIssue(program: Command): void {
|
|
|
772
858
|
: opts.duplicate
|
|
773
859
|
? "duplicate"
|
|
774
860
|
: "related";
|
|
775
|
-
const { issue: a, other: b } = await svc.addRemoveRelation(
|
|
861
|
+
const { issue: a, other: b } = await svc.addRemoveRelation(
|
|
862
|
+
ctx.client,
|
|
863
|
+
issueId,
|
|
864
|
+
op,
|
|
865
|
+
type as any,
|
|
866
|
+
normalizeIssueReference(other, ctx.defaultTeam),
|
|
867
|
+
);
|
|
776
868
|
ctx.output.emit(
|
|
777
869
|
{
|
|
778
870
|
issueId: a.id,
|
|
@@ -796,7 +888,7 @@ export function registerIssue(program: Command): void {
|
|
|
796
888
|
.description("Subscribe to an issue")
|
|
797
889
|
.action(
|
|
798
890
|
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
799
|
-
const iss = await svc.setSubscription(ctx.client, requireId(idArg), true);
|
|
891
|
+
const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), true);
|
|
800
892
|
ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: true }, () =>
|
|
801
893
|
ctx.output.success(`Subscribed to ${iss.identifier}`),
|
|
802
894
|
);
|
|
@@ -807,7 +899,7 @@ export function registerIssue(program: Command): void {
|
|
|
807
899
|
.description("Unsubscribe from an issue")
|
|
808
900
|
.action(
|
|
809
901
|
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
810
|
-
const iss = await svc.setSubscription(ctx.client, requireId(idArg), false);
|
|
902
|
+
const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), false);
|
|
811
903
|
ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: false }, () =>
|
|
812
904
|
ctx.output.success(`Unsubscribed from ${iss.identifier}`),
|
|
813
905
|
);
|
|
@@ -818,13 +910,13 @@ export function registerIssue(program: Command): void {
|
|
|
818
910
|
issue
|
|
819
911
|
.command("archive [id]")
|
|
820
912
|
.description("Archive an issue")
|
|
913
|
+
.option("--bulk <ids>", "archive comma-separated issue ids (repeatable)", parseList)
|
|
914
|
+
.option("--bulk-file <path>", "archive issue ids from a file (one per line)")
|
|
915
|
+
.option("--bulk-stdin", "archive issue ids read from stdin (one per line)")
|
|
821
916
|
.action(
|
|
822
|
-
action(async (ctx: Context,
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const iss = await svc.archiveIssue(ctx.client, id, false);
|
|
826
|
-
ctx.output.emit({ id: iss.id, identifier: iss.identifier, archived: true }, () =>
|
|
827
|
-
ctx.output.success(`Archived ${iss.identifier}`),
|
|
917
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
918
|
+
await runBulkIssueMutation(ctx, opts, idArg, "archive", (id) =>
|
|
919
|
+
svc.archiveIssue(ctx.client, id, false),
|
|
828
920
|
);
|
|
829
921
|
}),
|
|
830
922
|
);
|
|
@@ -833,7 +925,7 @@ export function registerIssue(program: Command): void {
|
|
|
833
925
|
.description("Unarchive an issue")
|
|
834
926
|
.action(
|
|
835
927
|
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
836
|
-
const iss = await svc.archiveIssue(ctx.client, requireId(idArg), true);
|
|
928
|
+
const iss = await svc.archiveIssue(ctx.client, requireId(idArg, ctx.defaultTeam), true);
|
|
837
929
|
ctx.output.emit({ id: iss.id, identifier: iss.identifier, archived: false }, () =>
|
|
838
930
|
ctx.output.success(`Unarchived ${iss.identifier}`),
|
|
839
931
|
);
|
|
@@ -843,13 +935,13 @@ export function registerIssue(program: Command): void {
|
|
|
843
935
|
.command("delete [id]")
|
|
844
936
|
.alias("rm")
|
|
845
937
|
.description("Delete (trash) an issue")
|
|
938
|
+
.option("--bulk <ids>", "delete comma-separated issue ids (repeatable)", parseList)
|
|
939
|
+
.option("--bulk-file <path>", "delete issue ids from a file (one per line)")
|
|
940
|
+
.option("--bulk-stdin", "delete issue ids read from stdin (one per line)")
|
|
846
941
|
.action(
|
|
847
|
-
action(async (ctx: Context,
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const iss = await svc.deleteIssue(ctx.client, id);
|
|
851
|
-
ctx.output.emit({ id: iss.id, identifier: iss.identifier, deleted: true }, () =>
|
|
852
|
-
ctx.output.success(`Deleted ${iss.identifier}`),
|
|
942
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
943
|
+
await runBulkIssueMutation(ctx, opts, idArg, "delete", (id) =>
|
|
944
|
+
svc.deleteIssue(ctx.client, id),
|
|
853
945
|
);
|
|
854
946
|
}),
|
|
855
947
|
);
|
|
@@ -861,6 +953,115 @@ export function registerIssue(program: Command): void {
|
|
|
861
953
|
registerScalar(issue, "branch", "Print the suggested git branch name", (d) => d.branchName);
|
|
862
954
|
}
|
|
863
955
|
|
|
956
|
+
type BulkAction = "archive" | "delete";
|
|
957
|
+
type BulkIssueResult = {
|
|
958
|
+
input: string;
|
|
959
|
+
id?: string;
|
|
960
|
+
identifier?: string;
|
|
961
|
+
archived?: boolean;
|
|
962
|
+
deleted?: boolean;
|
|
963
|
+
error?: { message: string; code: string };
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Collect issue ids from one explicit source. A positional id remains the
|
|
968
|
+
* ergonomic path for one issue (and can still be inferred from the branch),
|
|
969
|
+
* while bulk sources intentionally never consult the branch: an empty input
|
|
970
|
+
* must not accidentally mutate the current issue.
|
|
971
|
+
*/
|
|
972
|
+
function bulkIssueIds(
|
|
973
|
+
opts: Record<string, unknown>,
|
|
974
|
+
idArg: string | undefined,
|
|
975
|
+
): string[] | undefined {
|
|
976
|
+
const sources = [
|
|
977
|
+
Array.isArray(opts.bulk) && opts.bulk.length > 0 ? "--bulk" : undefined,
|
|
978
|
+
typeof opts.bulkFile === "string" ? "--bulk-file" : undefined,
|
|
979
|
+
opts.bulkStdin === true ? "--bulk-stdin" : undefined,
|
|
980
|
+
].filter((v): v is string => !!v);
|
|
981
|
+
if (sources.length === 0) return undefined;
|
|
982
|
+
if (sources.length > 1 || idArg !== undefined) {
|
|
983
|
+
throw usageError(
|
|
984
|
+
"Pass either one issue id or exactly one of --bulk, --bulk-file, and --bulk-stdin.",
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
let values: string[];
|
|
988
|
+
if (sources[0] === "--bulk") values = opts.bulk as string[];
|
|
989
|
+
else {
|
|
990
|
+
const path = sources[0] === "--bulk-stdin" ? 0 : (opts.bulkFile as string);
|
|
991
|
+
let text: string;
|
|
992
|
+
try {
|
|
993
|
+
text = readFileSync(path, "utf8");
|
|
994
|
+
} catch (err) {
|
|
995
|
+
throw new CliError(
|
|
996
|
+
`Cannot read ${sources[0] === "--bulk-stdin" ? "stdin" : path}: ${(err as Error).message}`,
|
|
997
|
+
"runtime",
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
values = text.split(/[\s,]+/).filter(Boolean);
|
|
1001
|
+
}
|
|
1002
|
+
const ids = [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
1003
|
+
if (ids.length === 0) throw usageError(`${sources[0]} did not contain any issue ids.`);
|
|
1004
|
+
return ids;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function runBulkIssueMutation(
|
|
1008
|
+
ctx: Context,
|
|
1009
|
+
opts: Record<string, unknown>,
|
|
1010
|
+
idArg: string | undefined,
|
|
1011
|
+
actionName: BulkAction,
|
|
1012
|
+
mutate: (id: string) => Promise<{ id: string; identifier: string }>,
|
|
1013
|
+
): Promise<void> {
|
|
1014
|
+
const bulk = bulkIssueIds(opts, idArg);
|
|
1015
|
+
const ids = bulk ?? [requireId(idArg, ctx.defaultTeam)];
|
|
1016
|
+
const single = bulk === undefined;
|
|
1017
|
+
const noun = actionName === "archive" ? "Archive" : "Delete";
|
|
1018
|
+
const message = single ? `${noun} issue ${ids[0]}?` : `${noun} ${ids.length} issues?`;
|
|
1019
|
+
if (!(await confirmDestructive(ctx, message))) return;
|
|
1020
|
+
|
|
1021
|
+
const results: BulkIssueResult[] = [];
|
|
1022
|
+
for (const input of ids) {
|
|
1023
|
+
const id = normalizeIssueReference(input, ctx.defaultTeam);
|
|
1024
|
+
try {
|
|
1025
|
+
const issue = await mutate(id);
|
|
1026
|
+
results.push({
|
|
1027
|
+
input,
|
|
1028
|
+
id: issue.id,
|
|
1029
|
+
identifier: issue.identifier,
|
|
1030
|
+
[actionName === "archive" ? "archived" : "deleted"]: true,
|
|
1031
|
+
});
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
const normalized = normalizeError(err);
|
|
1034
|
+
results.push({ input, error: { message: normalized.message, code: normalized.code } });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (single) {
|
|
1039
|
+
const result = results[0]!;
|
|
1040
|
+
if (result.error)
|
|
1041
|
+
throw new CliError(result.error.message, result.error.code as CliError["code"]);
|
|
1042
|
+
ctx.output.emit(
|
|
1043
|
+
actionName === "archive"
|
|
1044
|
+
? { id: result.id!, identifier: result.identifier!, archived: true }
|
|
1045
|
+
: { id: result.id!, identifier: result.identifier!, deleted: true },
|
|
1046
|
+
() => ctx.output.success(`${noun}d ${result.identifier}`),
|
|
1047
|
+
);
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const failures = results.filter((result) => result.error).length;
|
|
1052
|
+
ctx.output.emit(
|
|
1053
|
+
{ action: actionName, results, succeeded: results.length - failures, failed: failures },
|
|
1054
|
+
() => {
|
|
1055
|
+
for (const result of results) {
|
|
1056
|
+
if (result.error) ctx.output.warn(`${result.input}: ${result.error.message}`);
|
|
1057
|
+
else ctx.output.success(`${noun}d ${result.identifier}`);
|
|
1058
|
+
}
|
|
1059
|
+
ctx.output.info(`${results.length - failures}/${results.length} issues ${actionName}d.`);
|
|
1060
|
+
},
|
|
1061
|
+
);
|
|
1062
|
+
if (failures > 0) process.exitCode = 1;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
864
1065
|
/**
|
|
865
1066
|
* Render a single issue's detail block. Shared by `issue view` and the bare
|
|
866
1067
|
* `linear` command so `linear --json` === `issue view <id> --json`.
|
|
@@ -869,14 +1070,20 @@ export async function renderIssueDetail(
|
|
|
869
1070
|
ctx: Context,
|
|
870
1071
|
detail: svc.IssueDetail,
|
|
871
1072
|
includeComments: boolean,
|
|
1073
|
+
showResolvedThreads = false,
|
|
872
1074
|
): Promise<void> {
|
|
873
|
-
const comments = includeComments ? await commentSvc.listComments(ctx.client, detail.id, 10) : [];
|
|
874
1075
|
const { cycle, team } = detail;
|
|
875
|
-
|
|
1076
|
+
const commentPairs = includeComments
|
|
1077
|
+
? issueCommentPairs(detail.comments, showResolvedThreads)
|
|
1078
|
+
: [];
|
|
1079
|
+
ctx.output.detail(detail, [
|
|
876
1080
|
["Issue", `${detail.identifier} ${detail.title}`],
|
|
877
1081
|
// A deleted issue used to view exactly like a live one. Say so first, and
|
|
878
1082
|
// in capitals: an agent that deletes and re-reads must see the change.
|
|
879
|
-
[
|
|
1083
|
+
[
|
|
1084
|
+
"Trashed",
|
|
1085
|
+
detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null,
|
|
1086
|
+
],
|
|
880
1087
|
["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
|
|
881
1088
|
["State", detail.state?.name ?? null],
|
|
882
1089
|
["Priority", detail.priorityLabel],
|
|
@@ -885,21 +1092,109 @@ export async function renderIssueDetail(
|
|
|
885
1092
|
["Project", detail.project?.name ?? null],
|
|
886
1093
|
["Milestone", detail.milestone?.name ?? null],
|
|
887
1094
|
["Cycle", cycle ? `#${cycle.number}${cycle.name ? ` ${cycle.name}` : ""}` : null],
|
|
888
|
-
["Parent", detail.parent
|
|
1095
|
+
["Parent", detail.parent ? issueRefLabel(detail.parent) : null],
|
|
1096
|
+
[
|
|
1097
|
+
"Sub-issues",
|
|
1098
|
+
detail.children.length ? `\n${detail.children.map(issueRefLabel).join("\n")}` : null,
|
|
1099
|
+
],
|
|
889
1100
|
["Estimate", detail.estimate],
|
|
890
1101
|
["Labels", detail.labels.length ? detail.labels.map((l) => l.name).join(", ") : null],
|
|
1102
|
+
[
|
|
1103
|
+
"Attachments",
|
|
1104
|
+
detail.attachments.length ? `\n${detail.attachments.map(attachmentLabel).join("\n")}` : null,
|
|
1105
|
+
],
|
|
1106
|
+
[
|
|
1107
|
+
"Documents",
|
|
1108
|
+
detail.documents.length
|
|
1109
|
+
? `\n${detail.documents.map((d) => `${d.title}: ${d.url}`).join("\n")}`
|
|
1110
|
+
: null,
|
|
1111
|
+
],
|
|
1112
|
+
["Relations", relationLabels(detail)],
|
|
891
1113
|
["Due", detail.dueDate],
|
|
892
1114
|
["URL", detail.url],
|
|
893
1115
|
["Updated", detail.updatedAt],
|
|
894
1116
|
["Description", detail.description ? `\n${detail.description}` : null],
|
|
895
|
-
...
|
|
896
|
-
? comments.map(
|
|
897
|
-
(c) => [c.createdAt.slice(0, 10) + " " + (c.user?.displayName ?? "—"), c.body] as [string, unknown],
|
|
898
|
-
)
|
|
899
|
-
: []),
|
|
1117
|
+
...commentPairs,
|
|
900
1118
|
]);
|
|
901
1119
|
}
|
|
902
1120
|
|
|
1121
|
+
function issueRefLabel(issue: svc.IssueDetailRef): string {
|
|
1122
|
+
return `${issue.identifier} ${issue.title}${issue.state ? ` [${issue.state.name}]` : ""}`;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function attachmentLabel(attachment: svc.IssueAttachmentDetail): string {
|
|
1126
|
+
const source = attachment.sourceType ? ` [${attachment.sourceType}]` : "";
|
|
1127
|
+
const subtitle = attachment.subtitle ? ` — ${attachment.subtitle}` : "";
|
|
1128
|
+
return `${attachment.title}: ${attachment.url}${source}${subtitle}`;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function relationLabels(detail: svc.IssueDetail): string | null {
|
|
1132
|
+
const lines = [
|
|
1133
|
+
...detail.relations.map(
|
|
1134
|
+
(r) => `${relationVerb(r.type, false)} ${issueRefLabel(r.relatedIssue)}`,
|
|
1135
|
+
),
|
|
1136
|
+
...detail.inverseRelations.map(
|
|
1137
|
+
(r) => `${relationVerb(r.type, true)} ${issueRefLabel(r.issue)}`,
|
|
1138
|
+
),
|
|
1139
|
+
];
|
|
1140
|
+
return lines.length ? `\n${lines.join("\n")}` : null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function relationVerb(type: string, inverse: boolean): string {
|
|
1144
|
+
if (type === "blocks") return inverse ? "Blocked by" : "Blocks";
|
|
1145
|
+
if (type === "duplicate") return inverse ? "Duplicated by" : "Duplicates";
|
|
1146
|
+
return type === "related" ? "Related to" : `${inverse ? "Inverse" : "Related"} (${type})`;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Human comment rows grouped as threads, hiding resolved roots unless requested. */
|
|
1150
|
+
function issueCommentPairs(
|
|
1151
|
+
comments: svc.IssueCommentDetail[],
|
|
1152
|
+
showResolvedThreads: boolean,
|
|
1153
|
+
): Array<[string, unknown]> {
|
|
1154
|
+
const byId = new Map(comments.map((comment) => [comment.id, comment]));
|
|
1155
|
+
const rootId = (comment: svc.IssueCommentDetail): string => {
|
|
1156
|
+
const seen = new Set<string>();
|
|
1157
|
+
let current = comment;
|
|
1158
|
+
while (current.parent && !seen.has(current.id)) {
|
|
1159
|
+
seen.add(current.id);
|
|
1160
|
+
const parent = byId.get(current.parent.id);
|
|
1161
|
+
if (!parent) break;
|
|
1162
|
+
current = parent;
|
|
1163
|
+
}
|
|
1164
|
+
return current.id;
|
|
1165
|
+
};
|
|
1166
|
+
const roots = comments.filter((comment) => !comment.parent);
|
|
1167
|
+
const visibleRoots = roots.filter((comment) => showResolvedThreads || !comment.resolvedAt);
|
|
1168
|
+
const visibleIds = new Set(visibleRoots.map((comment) => comment.id));
|
|
1169
|
+
const pairs: Array<[string, unknown]> = [];
|
|
1170
|
+
|
|
1171
|
+
for (const root of visibleRoots) {
|
|
1172
|
+
pairs.push(["Comment", commentLabel(root, false)]);
|
|
1173
|
+
for (const reply of comments) {
|
|
1174
|
+
if (reply.parent && rootId(reply) === root.id && visibleIds.has(root.id)) {
|
|
1175
|
+
pairs.push(["Reply", commentLabel(reply, true)]);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const hidden = roots.length - visibleRoots.length;
|
|
1180
|
+
if (hidden > 0) {
|
|
1181
|
+
pairs.push([
|
|
1182
|
+
"Comments",
|
|
1183
|
+
`${hidden} resolved ${hidden === 1 ? "thread" : "threads"} hidden; use --show-resolved-threads to show ${hidden === 1 ? "it" : "them"}.`,
|
|
1184
|
+
]);
|
|
1185
|
+
}
|
|
1186
|
+
return pairs;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function commentLabel(comment: svc.IssueCommentDetail, reply: boolean): string {
|
|
1190
|
+
const author = comment.user?.displayName ?? comment.externalUser?.displayName ?? "Unknown";
|
|
1191
|
+
const thread = reply ? "" : ` [thread: ${comment.id}]`;
|
|
1192
|
+
const resolved = comment.resolvedAt
|
|
1193
|
+
? ` [resolved${comment.resolvingUser ? ` by ${comment.resolvingUser.displayName}` : ""}]`
|
|
1194
|
+
: "";
|
|
1195
|
+
return `@${author} ${comment.createdAt.slice(0, 10)}${thread}${resolved}\n${comment.body}`;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
903
1198
|
function registerScalar(
|
|
904
1199
|
issue: Command,
|
|
905
1200
|
name: string,
|
|
@@ -911,27 +1206,36 @@ function registerScalar(
|
|
|
911
1206
|
.description(description)
|
|
912
1207
|
.action(
|
|
913
1208
|
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
914
|
-
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg)
|
|
1209
|
+
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
1210
|
+
includeComments: false,
|
|
1211
|
+
});
|
|
915
1212
|
ctx.output.emit({ [name]: pick(detail) }, () => ctx.output.line(pick(detail)));
|
|
916
1213
|
}),
|
|
917
1214
|
);
|
|
918
1215
|
}
|
|
919
1216
|
|
|
920
|
-
const ISSUE_ID_RE =
|
|
1217
|
+
const ISSUE_ID_RE =
|
|
1218
|
+
/^(\d+|[a-zA-Z][a-zA-Z0-9]*-\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
921
1219
|
|
|
922
1220
|
/**
|
|
923
1221
|
* Disambiguate `[id] [value]` where both are optional so the issue id can be
|
|
924
1222
|
* inferred from the branch. Two args → (id, value). One arg → it's the value
|
|
925
1223
|
* unless it looks like an issue id (then the value is missing → usage error).
|
|
926
1224
|
*/
|
|
927
|
-
function oneOrTwo(
|
|
1225
|
+
function oneOrTwo(
|
|
1226
|
+
a: string | undefined,
|
|
1227
|
+
b: string | undefined,
|
|
1228
|
+
valueName: string,
|
|
1229
|
+
): {
|
|
928
1230
|
idArg?: string;
|
|
929
1231
|
value: string;
|
|
930
1232
|
} {
|
|
931
1233
|
if (a !== undefined && b !== undefined) return { idArg: a, value: b };
|
|
932
1234
|
if (a === undefined) throw usageError(`Missing ${valueName}.`);
|
|
933
1235
|
if (ISSUE_ID_RE.test(a)) {
|
|
934
|
-
throw usageError(
|
|
1236
|
+
throw usageError(
|
|
1237
|
+
`Missing ${valueName}. Usage: <id> <${valueName}> (or just <${valueName}> on a matching branch)`,
|
|
1238
|
+
);
|
|
935
1239
|
}
|
|
936
1240
|
return { value: a };
|
|
937
1241
|
}
|
|
@@ -941,7 +1245,10 @@ function oneOrTwo(a: string | undefined, b: string | undefined, valueName: strin
|
|
|
941
1245
|
* legitimately be absent (it can come from --body-file or $EDITOR): a lone
|
|
942
1246
|
* operand that looks like an issue id IS the id; anything else is the body.
|
|
943
1247
|
*/
|
|
944
|
-
function idAndBody(
|
|
1248
|
+
function idAndBody(
|
|
1249
|
+
a: string | undefined,
|
|
1250
|
+
b: string | undefined,
|
|
1251
|
+
): { idArg?: string; bodyArg?: string } {
|
|
945
1252
|
if (a !== undefined && b !== undefined) return { idArg: a, bodyArg: b };
|
|
946
1253
|
if (a === undefined) return {};
|
|
947
1254
|
return ISSUE_ID_RE.test(a) ? { idArg: a } : { bodyArg: a };
|
|
@@ -967,9 +1274,3 @@ function runGh(args: string[]): string {
|
|
|
967
1274
|
throw new CliError(stderr || `gh exited with code ${e.status ?? 1}.`, "runtime");
|
|
968
1275
|
}
|
|
969
1276
|
}
|
|
970
|
-
|
|
971
|
-
async function openUrl(url: string): Promise<void> {
|
|
972
|
-
const { execFile } = await import("node:child_process");
|
|
973
|
-
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
974
|
-
await new Promise<void>((resolve) => execFile(cmd, [url], () => resolve()));
|
|
975
|
-
}
|