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