linear-sdk-cli 0.1.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/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +9 -0
- package/CHANGELOG.md +807 -0
- package/COVERAGE.md +495 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +220 -0
- package/README.md +524 -0
- package/package.json +87 -0
- package/skills/linear-sdk-cli/SKILL.md +534 -0
- package/skills/linear-sdk-cli/references/api.md +27 -0
- package/skills/linear-sdk-cli/references/attachment.md +82 -0
- package/skills/linear-sdk-cli/references/auth.md +174 -0
- package/skills/linear-sdk-cli/references/commands.md +37 -0
- package/skills/linear-sdk-cli/references/comment.md +154 -0
- package/skills/linear-sdk-cli/references/completion.md +17 -0
- package/skills/linear-sdk-cli/references/config.md +99 -0
- package/skills/linear-sdk-cli/references/cycle.md +131 -0
- package/skills/linear-sdk-cli/references/document.md +180 -0
- package/skills/linear-sdk-cli/references/favorite.md +78 -0
- package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
- package/skills/linear-sdk-cli/references/initiative.md +256 -0
- package/skills/linear-sdk-cli/references/issue.md +928 -0
- package/skills/linear-sdk-cli/references/label.md +112 -0
- package/skills/linear-sdk-cli/references/milestone.md +139 -0
- package/skills/linear-sdk-cli/references/notification.md +123 -0
- package/skills/linear-sdk-cli/references/open.md +29 -0
- package/skills/linear-sdk-cli/references/organization.md +99 -0
- package/skills/linear-sdk-cli/references/project-update.md +67 -0
- package/skills/linear-sdk-cli/references/project.md +234 -0
- package/skills/linear-sdk-cli/references/roadmap.md +150 -0
- package/skills/linear-sdk-cli/references/schema.md +21 -0
- package/skills/linear-sdk-cli/references/state.md +61 -0
- package/skills/linear-sdk-cli/references/team.md +227 -0
- package/skills/linear-sdk-cli/references/user.md +103 -0
- package/skills/linear-sdk-cli/references/webhook.md +151 -0
- package/skills/linear-sdk-cli/references/whoami.md +26 -0
- package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
- package/src/bin/linear.ts +97 -0
- package/src/cli.ts +232 -0
- package/src/client.ts +172 -0
- package/src/commands/agent-session.ts +121 -0
- package/src/commands/api.ts +317 -0
- package/src/commands/attach.ts +90 -0
- package/src/commands/attachment.ts +74 -0
- package/src/commands/comment.ts +225 -0
- package/src/commands/completion.ts +69 -0
- package/src/commands/cycle.ts +125 -0
- package/src/commands/discover.ts +170 -0
- package/src/commands/document.ts +211 -0
- package/src/commands/favorite.ts +72 -0
- package/src/commands/initiative-update.ts +47 -0
- package/src/commands/initiative.ts +261 -0
- package/src/commands/issue.ts +1263 -0
- package/src/commands/label.ts +119 -0
- package/src/commands/meta.ts +534 -0
- package/src/commands/milestone.ts +163 -0
- package/src/commands/notification.ts +110 -0
- package/src/commands/open.ts +84 -0
- package/src/commands/organization.ts +79 -0
- package/src/commands/project-update.ts +47 -0
- package/src/commands/project.ts +339 -0
- package/src/commands/roadmap.ts +131 -0
- package/src/commands/state.ts +58 -0
- package/src/commands/team.ts +243 -0
- package/src/commands/user.ts +107 -0
- package/src/commands/webhook.ts +156 -0
- package/src/config.ts +1236 -0
- package/src/context.ts +117 -0
- package/src/git.ts +174 -0
- package/src/index.ts +10 -0
- package/src/lib/action.ts +26 -0
- package/src/lib/body.ts +172 -0
- package/src/lib/errors.ts +195 -0
- package/src/lib/introspect.ts +150 -0
- package/src/lib/keyring.ts +270 -0
- package/src/lib/mutation.ts +53 -0
- package/src/lib/open.ts +32 -0
- package/src/lib/options.ts +640 -0
- package/src/lib/output-shapes.ts +562 -0
- package/src/lib/pagination.ts +145 -0
- package/src/lib/prompt.ts +91 -0
- package/src/lib/resolve.ts +596 -0
- package/src/lib/shape.ts +273 -0
- package/src/lib/status-update.ts +91 -0
- package/src/lib/upload.ts +329 -0
- package/src/output/color.ts +52 -0
- package/src/output/format.ts +238 -0
- package/src/output/markdown.ts +133 -0
- package/src/output/pager.ts +131 -0
- package/src/output/sanitize.ts +76 -0
- package/src/output/table.ts +198 -0
- package/src/services/agent-session.ts +289 -0
- package/src/services/attachment.ts +198 -0
- package/src/services/comment.ts +262 -0
- package/src/services/cycle.ts +224 -0
- package/src/services/document.ts +357 -0
- package/src/services/favorite.ts +147 -0
- package/src/services/initiative-update.ts +59 -0
- package/src/services/initiative.ts +449 -0
- package/src/services/issue.ts +1153 -0
- package/src/services/label.ts +193 -0
- package/src/services/milestone.ts +262 -0
- package/src/services/notification.ts +190 -0
- package/src/services/organization.ts +144 -0
- package/src/services/project-update.ts +36 -0
- package/src/services/project.ts +494 -0
- package/src/services/roadmap.ts +177 -0
- package/src/services/state.ts +127 -0
- package/src/services/team.ts +386 -0
- package/src/services/user.ts +137 -0
- package/src/services/webhook.ts +203 -0
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `linear issue` (alias `i`) — the core, git-branch-aware command group.
|
|
3
|
+
*
|
|
4
|
+
* When an issue id is omitted, it is inferred from the current git branch
|
|
5
|
+
* (`tes-123-foo` → `TES-123`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command, Option } from "commander";
|
|
9
|
+
import { action } from "../lib/action.js";
|
|
10
|
+
import {
|
|
11
|
+
addFilterOptions,
|
|
12
|
+
addCoreFilterOptions,
|
|
13
|
+
addAliasOption,
|
|
14
|
+
readAlias,
|
|
15
|
+
parseList,
|
|
16
|
+
parseIntOption,
|
|
17
|
+
parsePriority,
|
|
18
|
+
CYCLE_FLAG,
|
|
19
|
+
CYCLE_DESC,
|
|
20
|
+
suggestSubcommand,
|
|
21
|
+
} from "../lib/options.js";
|
|
22
|
+
import { registerIssueCommentGroup } from "./comment.js";
|
|
23
|
+
import { noteWorkspaceWide } from "./project.js";
|
|
24
|
+
import { resolveBody } from "../lib/body.js";
|
|
25
|
+
import { confirmDestructive, promptInput } from "../lib/prompt.js";
|
|
26
|
+
import { usageError } from "../lib/errors.js";
|
|
27
|
+
import {
|
|
28
|
+
currentIssueId,
|
|
29
|
+
checkoutBranch,
|
|
30
|
+
isGitRepo,
|
|
31
|
+
buildTrailer,
|
|
32
|
+
buildDescription,
|
|
33
|
+
buildPrContent,
|
|
34
|
+
buildPrArgs,
|
|
35
|
+
} from "../git.js";
|
|
36
|
+
import { execFileSync } from "node:child_process";
|
|
37
|
+
import { readFileSync } from "node:fs";
|
|
38
|
+
import { CliError, normalizeError } from "../lib/errors.js";
|
|
39
|
+
import { openUrl } from "../lib/open.js";
|
|
40
|
+
import type { Context } from "../context.js";
|
|
41
|
+
import * as svc from "../services/issue.js";
|
|
42
|
+
import * as commentSvc from "../services/comment.js";
|
|
43
|
+
import { isSelf, normalizeIssueReference, resolveIssue } from "../lib/resolve.js";
|
|
44
|
+
import type { Column } from "../output/table.js";
|
|
45
|
+
|
|
46
|
+
/** Resolve the target issue id from an argument/current branch, expanding `42` via the default team. */
|
|
47
|
+
function requireId(idArg: string | undefined, defaultTeam?: string): string {
|
|
48
|
+
const id = idArg ?? currentIssueId();
|
|
49
|
+
if (!id) {
|
|
50
|
+
throw usageError("No issue id given and none could be inferred from the current branch.");
|
|
51
|
+
}
|
|
52
|
+
return normalizeIssueReference(id, defaultTeam);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ROW_COLUMNS: Column<svc.IssueRow>[] = [
|
|
56
|
+
{ key: "id", header: "ID", value: (r) => r.identifier },
|
|
57
|
+
// `--include-archived` mixes live, archived and trashed rows; mark the latter
|
|
58
|
+
// 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
|
+
},
|
|
65
|
+
{ key: "priority", header: "Pri", value: (r) => shortPriority(r.priority) },
|
|
66
|
+
{ key: "assignee", header: "Assignee", value: (r) => r.assignee?.displayName ?? "—", max: 16 },
|
|
67
|
+
{ key: "title", header: "Title", value: (r) => r.title, max: 60 },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Columns the default table leaves out (it is wide already) but `--fields` can
|
|
72
|
+
* ask for by name: `--fields id,milestone,title`. A cycle shows its name, or
|
|
73
|
+
* `#n` when it has none — the generic row-key fallback would print its id.
|
|
74
|
+
*/
|
|
75
|
+
const EXTRA_COLUMNS: Column<svc.IssueRow>[] = [
|
|
76
|
+
{ key: "milestone", header: "Milestone", value: (r) => r.milestone?.name ?? "—", max: 30 },
|
|
77
|
+
{ key: "cycle", header: "Cycle", value: (r) => cycleLabel(r.cycle) },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** The list table's columns: the defaults, plus the optional ones once `--fields` is selecting. */
|
|
81
|
+
function listColumns(ctx: Context): Column<svc.IssueRow>[] {
|
|
82
|
+
return ctx.options.fields?.length ? [...ROW_COLUMNS, ...EXTRA_COLUMNS] : ROW_COLUMNS;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `Sprint 3`, or `#3` for an unnamed cycle; `—` for none. */
|
|
86
|
+
function cycleLabel(c: svc.IssueRow["cycle"]): string {
|
|
87
|
+
return c ? (c.name ?? `#${c.number}`) : "—";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function shortPriority(p: number): string {
|
|
91
|
+
return ["—", "Urgent", "High", "Med", "Low"][p] ?? String(p);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** ` (trashed)` / ` (archived)` for a row that is not live; empty otherwise. */
|
|
95
|
+
function lifecycleMark(r: Pick<svc.IssueRow, "trashed" | "archivedAt">): string {
|
|
96
|
+
return r.trashed ? " (trashed)" : r.archivedAt ? " (archived)" : "";
|
|
97
|
+
}
|
|
98
|
+
|
|
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
|
+
/**
|
|
110
|
+
* schpet/linear-cli `issue` subcommands that a migrating user may type and that
|
|
111
|
+
* do not exist under that name here. Since `view` is the default subcommand,
|
|
112
|
+
* `linear issue link x` lands in `view` with "link" as the id; without this
|
|
113
|
+
* the error would only say it is not an issue id. Keep in step with MIGRATING.md.
|
|
114
|
+
* (`issue attach` is a real subcommand now — TES-602 — so it never lands here.)
|
|
115
|
+
*/
|
|
116
|
+
const SCHPET_ISSUE_SUBCOMMANDS: Record<string, string> = {
|
|
117
|
+
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>'.",
|
|
120
|
+
"agent-session": "Use 'linear issue agent-session list|view <issue>'.",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export function registerIssue(program: Command): void {
|
|
124
|
+
const issue = program.command("issue").alias("i").description("Work with issues");
|
|
125
|
+
|
|
126
|
+
// view --------------------------------------------------------------------
|
|
127
|
+
issue
|
|
128
|
+
.command("view [id]", { isDefault: true })
|
|
129
|
+
.description("Show an issue (defaults to the current branch's issue)")
|
|
130
|
+
.option("-w, --web", "open the issue in the browser instead of printing")
|
|
131
|
+
.option("--app", "open the issue in Linear.app instead of printing")
|
|
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")
|
|
141
|
+
.action(
|
|
142
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
143
|
+
// `view` is the default subcommand, so `linear issue lst` lands here
|
|
144
|
+
// with "lst" as the id. Say what it probably was, not just what it is not.
|
|
145
|
+
if (idArg !== undefined && !ISSUE_ID_RE.test(idArg)) {
|
|
146
|
+
// A schpet/linear-cli subcommand that lives elsewhere here (or not at
|
|
147
|
+
// all) is the likeliest thing a migrating user types; name the
|
|
148
|
+
// equivalent before falling back to a spelling guess.
|
|
149
|
+
const ported = SCHPET_ISSUE_SUBCOMMANDS[idArg.toLowerCase()];
|
|
150
|
+
const guess = ported ? undefined : suggestSubcommand(issue, idArg);
|
|
151
|
+
throw usageError(
|
|
152
|
+
`'${idArg}' is not a valid issue id (expected e.g. TES-123, 123 with a default team, or a UUID).${
|
|
153
|
+
ported ? ` ${ported}` : guess ? ` Did you mean 'linear issue ${guess}'?` : ""
|
|
154
|
+
}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (opts.web && opts.app) throw usageError("Pass either --web or --app, not both.");
|
|
158
|
+
const includeComments = opts.comments !== false && !opts.web && !opts.app;
|
|
159
|
+
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
160
|
+
includeComments,
|
|
161
|
+
});
|
|
162
|
+
if (opts.web || opts.app) {
|
|
163
|
+
await openUrl(detail.url, { app: opts.app === true });
|
|
164
|
+
ctx.output.emit(
|
|
165
|
+
{ 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
|
+
),
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
await renderIssueDetail(ctx, detail, includeComments, opts.showResolvedThreads === true);
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// list --------------------------------------------------------------------
|
|
178
|
+
const list = issue
|
|
179
|
+
.command("list")
|
|
180
|
+
.alias("ls")
|
|
181
|
+
// `query` is the reference CLI's name for this command; same code path.
|
|
182
|
+
.alias("query")
|
|
183
|
+
.description("List issues with filters")
|
|
184
|
+
.addHelpText(
|
|
185
|
+
"after",
|
|
186
|
+
[
|
|
187
|
+
"",
|
|
188
|
+
"Examples:",
|
|
189
|
+
" linear issue list --assignee me --state 'In Progress'",
|
|
190
|
+
" linear issue list --team TES --team ENG --state started --state 'In Review'",
|
|
191
|
+
" linear issue list --unassigned --created-after 2026-01-01",
|
|
192
|
+
" linear issue list --project-label mobile --updated-after 2026-08-01",
|
|
193
|
+
" linear issue list --cycle current --json | jq -r '.[].identifier'",
|
|
194
|
+
].join("\n"),
|
|
195
|
+
)
|
|
196
|
+
.action(
|
|
197
|
+
action(async (ctx: Context, opts) => {
|
|
198
|
+
// `--all-states` is already how `list` behaves; accepting it keeps
|
|
199
|
+
// transplanted commands working, but pairing it with an explicit
|
|
200
|
+
// --state is a contradiction, so say so rather than pick one.
|
|
201
|
+
if (opts.allStates && opts.state?.length) {
|
|
202
|
+
throw usageError("Pass either --state or --all-states, not both.");
|
|
203
|
+
}
|
|
204
|
+
noteWorkspaceWide(ctx, opts);
|
|
205
|
+
const rows = await svc.listIssues(
|
|
206
|
+
ctx.client,
|
|
207
|
+
{
|
|
208
|
+
team: opts.team ?? ctx.defaultTeam,
|
|
209
|
+
allTeams: opts.allTeams,
|
|
210
|
+
assignee: opts.assignee,
|
|
211
|
+
unassigned: opts.unassigned,
|
|
212
|
+
state: opts.state,
|
|
213
|
+
project: opts.project,
|
|
214
|
+
projectLabel: opts.projectLabel,
|
|
215
|
+
milestone: opts.milestone,
|
|
216
|
+
label: opts.label,
|
|
217
|
+
priority: opts.priority,
|
|
218
|
+
cycle: opts.cycle,
|
|
219
|
+
createdAfter: opts.createdAfter,
|
|
220
|
+
updatedAfter: opts.updatedAfter,
|
|
221
|
+
query: readAlias(opts, "--query", "--search"),
|
|
222
|
+
sort: svc.resolveIssueSort(opts.sort, ctx.config),
|
|
223
|
+
includeArchived: opts.includeArchived,
|
|
224
|
+
},
|
|
225
|
+
ctx.limit,
|
|
226
|
+
ctx.defaultTeam,
|
|
227
|
+
);
|
|
228
|
+
ctx.output.list(rows, listColumns(ctx), rows);
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
addFilterOptions(list).addOption(
|
|
232
|
+
// Not an alias of anything here — `list` already spans every state — but
|
|
233
|
+
// 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(),
|
|
238
|
+
);
|
|
239
|
+
addNoopAllAssignees(list);
|
|
240
|
+
|
|
241
|
+
// mine --------------------------------------------------------------------
|
|
242
|
+
// `list` stays general; `mine` is the opinionated "what's on my plate" view
|
|
243
|
+
// the reference CLI ships as its default listing — same defaults (you, and
|
|
244
|
+
// unstarted work only), so a transplanted `linear issue mine` behaves.
|
|
245
|
+
const mine = issue
|
|
246
|
+
.command("mine")
|
|
247
|
+
// Deliberately NO `l` alias, which is what the reference uses: our `list` is
|
|
248
|
+
// `ls`, so `l` and `ls` would sit one keystroke apart and return completely
|
|
249
|
+
// different sets. `linear issue l` failing loudly is the better outcome.
|
|
250
|
+
.description("List your unstarted issues (--all-states for every state)")
|
|
251
|
+
.addHelpText(
|
|
252
|
+
"after",
|
|
253
|
+
[
|
|
254
|
+
"",
|
|
255
|
+
"Examples:",
|
|
256
|
+
" linear issue mine # your unstarted issues",
|
|
257
|
+
" linear issue mine --all-states # every state, still yours",
|
|
258
|
+
" linear issue mine --state started # one specific state instead",
|
|
259
|
+
].join("\n"),
|
|
260
|
+
)
|
|
261
|
+
.action(
|
|
262
|
+
action(async (ctx: Context, opts) => {
|
|
263
|
+
if (opts.allStates && opts.state?.length) {
|
|
264
|
+
throw usageError("Pass either --state or --all-states, not both.");
|
|
265
|
+
}
|
|
266
|
+
noteWorkspaceWide(ctx, opts);
|
|
267
|
+
const rows = await svc.listIssues(
|
|
268
|
+
ctx.client,
|
|
269
|
+
{
|
|
270
|
+
team: opts.team ?? ctx.defaultTeam,
|
|
271
|
+
allTeams: opts.allTeams,
|
|
272
|
+
// The whole point of the command: never overridable.
|
|
273
|
+
assignee: "me",
|
|
274
|
+
// An explicit --state (repeatable) replaces the default set rather
|
|
275
|
+
// than intersecting it; --all-states drops the restriction entirely.
|
|
276
|
+
state: opts.state?.length
|
|
277
|
+
? opts.state
|
|
278
|
+
: opts.allStates
|
|
279
|
+
? undefined
|
|
280
|
+
: svc.MINE_STATE_TYPES,
|
|
281
|
+
project: opts.project,
|
|
282
|
+
projectLabel: opts.projectLabel,
|
|
283
|
+
milestone: opts.milestone,
|
|
284
|
+
label: opts.label,
|
|
285
|
+
priority: opts.priority,
|
|
286
|
+
cycle: opts.cycle,
|
|
287
|
+
createdAfter: opts.createdAfter,
|
|
288
|
+
updatedAfter: opts.updatedAfter,
|
|
289
|
+
query: readAlias(opts, "--query", "--search"),
|
|
290
|
+
sort: svc.resolveIssueSort(opts.sort, ctx.config),
|
|
291
|
+
includeArchived: opts.includeArchived,
|
|
292
|
+
},
|
|
293
|
+
ctx.limit,
|
|
294
|
+
ctx.defaultTeam,
|
|
295
|
+
);
|
|
296
|
+
ctx.output.list(rows, listColumns(ctx), rows);
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
addFilterOptions(mine, { assignee: false }).addOption(
|
|
300
|
+
new Option("--all-states", "include every workflow state, not just unstarted"),
|
|
301
|
+
);
|
|
302
|
+
addNoopAllAssignees(mine);
|
|
303
|
+
|
|
304
|
+
// search ------------------------------------------------------------------
|
|
305
|
+
const search = issue
|
|
306
|
+
.command("search <text>")
|
|
307
|
+
.description("Full-text search across issues (scoped to the default team; --all-teams widens)")
|
|
308
|
+
.addHelpText(
|
|
309
|
+
"after",
|
|
310
|
+
[
|
|
311
|
+
"",
|
|
312
|
+
"Examples:",
|
|
313
|
+
" linear issue search 'login crash' --state started",
|
|
314
|
+
" linear issue search 'login crash' --all-teams --assignee me",
|
|
315
|
+
].join("\n"),
|
|
316
|
+
)
|
|
317
|
+
.action(
|
|
318
|
+
action(async (ctx: Context, opts, text: string) => {
|
|
319
|
+
noteWorkspaceWide(ctx, opts);
|
|
320
|
+
const rows = await svc.searchIssues(
|
|
321
|
+
ctx.client,
|
|
322
|
+
text,
|
|
323
|
+
{
|
|
324
|
+
team: opts.team ?? ctx.defaultTeam,
|
|
325
|
+
allTeams: opts.allTeams,
|
|
326
|
+
assignee: opts.assignee,
|
|
327
|
+
unassigned: opts.unassigned,
|
|
328
|
+
state: opts.state,
|
|
329
|
+
project: opts.project,
|
|
330
|
+
projectLabel: opts.projectLabel,
|
|
331
|
+
milestone: opts.milestone,
|
|
332
|
+
label: opts.label,
|
|
333
|
+
priority: opts.priority,
|
|
334
|
+
cycle: opts.cycle,
|
|
335
|
+
createdAfter: opts.createdAfter,
|
|
336
|
+
updatedAfter: opts.updatedAfter,
|
|
337
|
+
includeArchived: opts.includeArchived,
|
|
338
|
+
searchComments: opts.searchComments,
|
|
339
|
+
},
|
|
340
|
+
ctx.limit,
|
|
341
|
+
ctx.defaultTeam,
|
|
342
|
+
);
|
|
343
|
+
ctx.output.list(rows, listColumns(ctx), rows);
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
// Search-only: the plain `issues` query has nowhere to put it, so this does
|
|
347
|
+
// not belong in the shared filter options.
|
|
348
|
+
addCoreFilterOptions(search).option(
|
|
349
|
+
"--search-comments",
|
|
350
|
+
"match comment bodies as well as titles and descriptions",
|
|
351
|
+
);
|
|
352
|
+
addNoopAllAssignees(search);
|
|
353
|
+
|
|
354
|
+
// create ------------------------------------------------------------------
|
|
355
|
+
const create = issue
|
|
356
|
+
.command("create")
|
|
357
|
+
.alias("new")
|
|
358
|
+
.description("Create a new issue")
|
|
359
|
+
.option("--title <title>", "issue title")
|
|
360
|
+
.option("-d, --description <text>", "issue description (body)")
|
|
361
|
+
.option("--description-file <path>", "read description from a file ('-' = stdin)")
|
|
362
|
+
.option("--editor", "compose the description in $EDITOR")
|
|
363
|
+
.option("-a, --assignee <who>", "assignee (me|email|name|id)")
|
|
364
|
+
.option("-s, --state <name>", "workflow state name or type")
|
|
365
|
+
.option("-P, --priority <0-4>", "priority", parsePriority)
|
|
366
|
+
.option("-l, --label <name>", "label (repeatable / comma-separated)", parseList)
|
|
367
|
+
.option("-p, --project <name>", "project name or id")
|
|
368
|
+
.option("--milestone <name>", "project milestone (requires --project)")
|
|
369
|
+
.option(CYCLE_FLAG, CYCLE_DESC)
|
|
370
|
+
.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
|
+
)
|
|
375
|
+
.option("--due <date>", "due date (YYYY-MM-DD)")
|
|
376
|
+
.option("--template <name|id>", "create from an issue template (the team's or a shared one)")
|
|
377
|
+
.option("--no-default-template", "do not apply the team's default issue template")
|
|
378
|
+
.option(
|
|
379
|
+
"--start",
|
|
380
|
+
"then start work: check out the branch, move to the first 'started' state (or --state), assign to you",
|
|
381
|
+
)
|
|
382
|
+
.addHelpText(
|
|
383
|
+
"after",
|
|
384
|
+
[
|
|
385
|
+
"",
|
|
386
|
+
"Examples:",
|
|
387
|
+
" linear issue create --title 'Fix login' --team TES --assignee me",
|
|
388
|
+
" linear issue create --title 'Bug' -l bug -l urgent --priority 1",
|
|
389
|
+
" linear issue create --title 'Sprint task' --cycle current --state 'In Progress'",
|
|
390
|
+
" linear issue create --title 'Sub-task' --parent TES-42 # inherits TES-42's project",
|
|
391
|
+
" linear issue create --title 'Bug report' --template 'Bug' # from a template",
|
|
392
|
+
" linear issue create --title 'Hotfix' --start # create, branch, In Progress",
|
|
393
|
+
" linear issue create --title 'API' --team TES --json | jq -r '.identifier'",
|
|
394
|
+
].join("\n"),
|
|
395
|
+
)
|
|
396
|
+
.action(
|
|
397
|
+
action(async (ctx: Context, opts) => {
|
|
398
|
+
// `--start` means *you* are starting on it, so it assigns to you (as the
|
|
399
|
+
// reference CLI does); naming somebody else at the same time is a contradiction.
|
|
400
|
+
if (opts.start && opts.assignee && !isSelf(opts.assignee)) {
|
|
401
|
+
throw usageError(
|
|
402
|
+
"--start assigns the issue to you; pass either --start or --assignee <someone else>, not both.",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
let title: string | undefined = opts.title;
|
|
406
|
+
if (!title) title = await promptInput(ctx, "Title:", { required: true });
|
|
407
|
+
const description = resolveBody({
|
|
408
|
+
arg: opts.description,
|
|
409
|
+
file: opts.descriptionFile,
|
|
410
|
+
interactive: !!opts.editor && ctx.isTTY,
|
|
411
|
+
editorRequested: !!opts.editor,
|
|
412
|
+
});
|
|
413
|
+
const created = await svc.createIssue(
|
|
414
|
+
ctx.client,
|
|
415
|
+
{
|
|
416
|
+
title,
|
|
417
|
+
description,
|
|
418
|
+
team: opts.team ?? ctx.defaultTeam,
|
|
419
|
+
assignee: opts.start ? (opts.assignee ?? "me") : opts.assignee,
|
|
420
|
+
state: opts.state,
|
|
421
|
+
priority: opts.priority,
|
|
422
|
+
label: opts.label,
|
|
423
|
+
project: opts.project,
|
|
424
|
+
milestone: opts.milestone,
|
|
425
|
+
cycle: opts.cycle,
|
|
426
|
+
estimate: opts.estimate,
|
|
427
|
+
parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
|
|
428
|
+
dueDate: readAlias(opts, "--due", "--due-date"),
|
|
429
|
+
template: opts.template,
|
|
430
|
+
// Both spellings: ours, and the reference CLI's `--no-use-default-template`.
|
|
431
|
+
useDefaultTemplate: opts.defaultTemplate !== false && opts.useDefaultTemplate !== false,
|
|
432
|
+
},
|
|
433
|
+
ctx.defaultTeam,
|
|
434
|
+
);
|
|
435
|
+
if (!opts.start) {
|
|
436
|
+
ctx.output.emit(
|
|
437
|
+
{ id: created.id, identifier: created.identifier, url: created.url },
|
|
438
|
+
() => ctx.output.success(`Created ${created.identifier}: ${created.url}`),
|
|
439
|
+
);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// --start: the same two steps `issue start --move` takes, on the issue
|
|
443
|
+
// just created. An explicit --state already put it where it belongs, so
|
|
444
|
+
// only the default case moves it (to the team's first `started` state).
|
|
445
|
+
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
|
+
await svc.moveIssueState(ctx.client, created, { move: moved });
|
|
450
|
+
ctx.output.emit(
|
|
451
|
+
{
|
|
452
|
+
id: created.id,
|
|
453
|
+
identifier: created.identifier,
|
|
454
|
+
url: created.url,
|
|
455
|
+
branch: branchResult?.branch ?? created.branchName,
|
|
456
|
+
checkedOut: !!branchResult,
|
|
457
|
+
stateChanged: moved,
|
|
458
|
+
},
|
|
459
|
+
() => {
|
|
460
|
+
ctx.output.success(`Created ${created.identifier}: ${created.url}`);
|
|
461
|
+
if (branchResult)
|
|
462
|
+
ctx.output.success(
|
|
463
|
+
`${branchResult.created ? "Created and checked out" : "Checked out"} ${branchResult.branch}`,
|
|
464
|
+
);
|
|
465
|
+
else ctx.output.info(`Branch name: ${created.branchName}`);
|
|
466
|
+
if (moved) ctx.output.success(`Moved ${created.identifier} → started`);
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
addAliasOption(create, "--due-date <date>", "--due");
|
|
472
|
+
addAliasOption(create, "--no-use-default-template", "--no-default-template");
|
|
473
|
+
|
|
474
|
+
// update ------------------------------------------------------------------
|
|
475
|
+
const update = issue
|
|
476
|
+
.command("update [id]")
|
|
477
|
+
.alias("edit")
|
|
478
|
+
.description("Update an issue")
|
|
479
|
+
.option("--title <title>", "new title")
|
|
480
|
+
.option("-d, --description <text>", "new description")
|
|
481
|
+
.option("--description-file <path>", "read description from a file ('-' = stdin)")
|
|
482
|
+
// Declared locally so help says what `--team` *does here* — this is the one
|
|
483
|
+
// command where the global team flag moves the issue instead of scoping a
|
|
484
|
+
// lookup. (addGlobalOptions leaves a locally-declared global alone.)
|
|
485
|
+
.option("-t, --team <key>", "move the issue to another team (changes its identifier)")
|
|
486
|
+
.option("-a, --assignee <who>", "assignee (me|email|name|id)")
|
|
487
|
+
.option("-s, --state <name>", "workflow state name or type")
|
|
488
|
+
.option("-P, --priority <0-4>", "priority", parsePriority)
|
|
489
|
+
.option("-p, --project <name>", "project name or id")
|
|
490
|
+
.option("--milestone <name>", "project milestone")
|
|
491
|
+
.option(CYCLE_FLAG, CYCLE_DESC)
|
|
492
|
+
.option("--estimate <n>", "estimate points", parseIntOption)
|
|
493
|
+
.option("--parent <id>", "parent issue id")
|
|
494
|
+
.option("--due <date>", "due date (YYYY-MM-DD)")
|
|
495
|
+
.option("-l, --label <name>", "replace all labels (repeatable / comma-separated)", parseList)
|
|
496
|
+
.option("--add-label <name>", "add a label (repeatable)", parseList)
|
|
497
|
+
.option("--remove-label <name>", "remove a label (repeatable)", parseList)
|
|
498
|
+
.option("--unassign", "clear the assignee")
|
|
499
|
+
.option("--clear-cycle", "remove the issue from its cycle")
|
|
500
|
+
.addHelpText(
|
|
501
|
+
"after",
|
|
502
|
+
[
|
|
503
|
+
"",
|
|
504
|
+
"Examples:",
|
|
505
|
+
" linear issue update TES-42 --state 'In Progress' --assignee me",
|
|
506
|
+
" linear issue update --priority 1 --add-label regression # id from branch",
|
|
507
|
+
" linear issue update TES-42 --cycle current --json",
|
|
508
|
+
" linear issue update TES-42 --team ENG # move to another team (new identifier)",
|
|
509
|
+
].join("\n"),
|
|
510
|
+
)
|
|
511
|
+
.action(
|
|
512
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
513
|
+
const description = resolveBody({
|
|
514
|
+
arg: opts.description,
|
|
515
|
+
file: opts.descriptionFile,
|
|
516
|
+
interactive: false,
|
|
517
|
+
});
|
|
518
|
+
const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
519
|
+
title: opts.title,
|
|
520
|
+
description,
|
|
521
|
+
// Only the explicit flag moves an issue — never `ctx.defaultTeam`, or
|
|
522
|
+
// every update in a repo with a configured team would be a team move.
|
|
523
|
+
team: opts.team,
|
|
524
|
+
assignee: opts.assignee,
|
|
525
|
+
state: opts.state,
|
|
526
|
+
priority: opts.priority,
|
|
527
|
+
project: opts.project,
|
|
528
|
+
milestone: opts.milestone,
|
|
529
|
+
cycle: opts.cycle,
|
|
530
|
+
estimate: opts.estimate,
|
|
531
|
+
parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
|
|
532
|
+
dueDate: readAlias(opts, "--due", "--due-date"),
|
|
533
|
+
label: opts.label,
|
|
534
|
+
addLabel: opts.addLabel,
|
|
535
|
+
removeLabel: opts.removeLabel,
|
|
536
|
+
unassign: opts.unassign,
|
|
537
|
+
clearCycle: opts.clearCycle,
|
|
538
|
+
});
|
|
539
|
+
ctx.output.emit(
|
|
540
|
+
{ id: updated.id, identifier: updated.identifier, url: updated.url },
|
|
541
|
+
() => {
|
|
542
|
+
ctx.output.success(`Updated ${updated.identifier}`);
|
|
543
|
+
// A move renumbers the issue and Linear drops what the destination
|
|
544
|
+
// team cannot hold. Say so once, rather than letting a script's next
|
|
545
|
+
// `TES-42` fail with "no such issue". Verified live — see CHANGELOG.
|
|
546
|
+
if (opts.team) {
|
|
547
|
+
ctx.output.info(
|
|
548
|
+
`Moved to team ${updated.identifier.split("-")[0]}: the issue is now ${updated.identifier}. ` +
|
|
549
|
+
`Its cycle, team-scoped labels, and any project the new team is not part of do not carry over.`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
);
|
|
554
|
+
}),
|
|
555
|
+
);
|
|
556
|
+
addAliasOption(update, "--due-date <date>", "--due");
|
|
557
|
+
|
|
558
|
+
// assign / state ----------------------------------------------------------
|
|
559
|
+
issue
|
|
560
|
+
.command("assign [idOrAssignee] [assignee]")
|
|
561
|
+
.description("Assign an issue (use 'me', email, name, or id). Issue defaults to the branch.")
|
|
562
|
+
.action(
|
|
563
|
+
action(async (ctx: Context, _opts, a?: string, b?: string) => {
|
|
564
|
+
const { idArg, value } = oneOrTwo(a, b, "assignee");
|
|
565
|
+
const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
566
|
+
assignee: value,
|
|
567
|
+
});
|
|
568
|
+
ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
|
|
569
|
+
ctx.output.success(`Assigned ${updated.identifier}`),
|
|
570
|
+
);
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
issue
|
|
575
|
+
.command("state [idOrState] [state]")
|
|
576
|
+
.description("Move an issue to a workflow state. Issue defaults to the branch.")
|
|
577
|
+
.action(
|
|
578
|
+
action(async (ctx: Context, _opts, a?: string, b?: string) => {
|
|
579
|
+
const { idArg, value } = oneOrTwo(a, b, "state");
|
|
580
|
+
const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
581
|
+
state: value,
|
|
582
|
+
});
|
|
583
|
+
ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
|
|
584
|
+
ctx.output.success(`Moved ${updated.identifier} → ${value}`),
|
|
585
|
+
);
|
|
586
|
+
}),
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
// label -------------------------------------------------------------------
|
|
590
|
+
issue
|
|
591
|
+
.command("label [id]")
|
|
592
|
+
.description("Add/remove labels on an issue")
|
|
593
|
+
.option("--add <name>", "add a label (repeatable)", parseList)
|
|
594
|
+
.option("--remove <name>", "remove a label (repeatable)", parseList)
|
|
595
|
+
.action(
|
|
596
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
597
|
+
if (!opts.add && !opts.remove) throw usageError("Pass --add and/or --remove.");
|
|
598
|
+
const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
599
|
+
addLabel: opts.add,
|
|
600
|
+
removeLabel: opts.remove,
|
|
601
|
+
});
|
|
602
|
+
ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
|
|
603
|
+
ctx.output.success(`Updated labels on ${updated.identifier}`),
|
|
604
|
+
);
|
|
605
|
+
}),
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
// comment / comments ------------------------------------------------------
|
|
609
|
+
const comment = issue
|
|
610
|
+
.command("comment [id] [body]")
|
|
611
|
+
.description(
|
|
612
|
+
'Add a comment to an issue; on a matching branch, `issue comment "<body>"` is enough (or use the add/list/update/delete subcommands)',
|
|
613
|
+
)
|
|
614
|
+
.option("--body-file <path>", "read comment body from a file ('-' = stdin)")
|
|
615
|
+
.action(
|
|
616
|
+
action(async (ctx: Context, opts, a?: string, b?: string) => {
|
|
617
|
+
// Both operands are optional, so a lone one is ambiguous: `TES-42` is
|
|
618
|
+
// an id (body from --body-file or $EDITOR), anything else is the body
|
|
619
|
+
// with the id inferred from the branch — the README's headline
|
|
620
|
+
// `linear issue comment "shipped"`. Same rule as `assign`/`state`.
|
|
621
|
+
const { idArg, bodyArg } = idAndBody(a, b);
|
|
622
|
+
// Settle the id BEFORE the editor can open, so nobody writes a comment
|
|
623
|
+
// only to be told there was nowhere to put it.
|
|
624
|
+
const id = requireId(idArg, ctx.defaultTeam);
|
|
625
|
+
const body = resolveBody({ arg: bodyArg, file: opts.bodyFile, interactive: ctx.isTTY });
|
|
626
|
+
if (!body) throw usageError("No comment body provided.");
|
|
627
|
+
const { issue: iss, comment } = await commentSvc.addComment(ctx.client, id, body);
|
|
628
|
+
ctx.output.emit({ id: comment?.id, issue: iss.identifier }, () =>
|
|
629
|
+
ctx.output.success(`Commented on ${iss.identifier}`),
|
|
630
|
+
);
|
|
631
|
+
}),
|
|
632
|
+
);
|
|
633
|
+
// The reference CLI's `issue comment {add,list,update,delete}` layout, mounted
|
|
634
|
+
// on the same handlers as the top-level `comment` group. Commander dispatches
|
|
635
|
+
// to a subcommand only when the first operand matches one of these four names,
|
|
636
|
+
// so `linear issue comment TES-1 'body'` is untouched.
|
|
637
|
+
registerIssueCommentGroup(comment);
|
|
638
|
+
|
|
639
|
+
issue
|
|
640
|
+
.command("comments [id]")
|
|
641
|
+
.description("List comments on an issue")
|
|
642
|
+
.action(
|
|
643
|
+
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
644
|
+
// Same implementation and row shape as `comment list` — TES-629: this
|
|
645
|
+
// 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
|
+
);
|
|
651
|
+
ctx.output.list(
|
|
652
|
+
comments,
|
|
653
|
+
[
|
|
654
|
+
{ key: "createdAt", header: "Date", value: (c) => c.createdAt.slice(0, 10) },
|
|
655
|
+
{ key: "user", header: "Author", value: (c) => c.user?.displayName ?? "—", max: 18 },
|
|
656
|
+
{ key: "body", header: "Comment", value: (c) => c.body.replace(/\n/g, " "), max: 70 },
|
|
657
|
+
],
|
|
658
|
+
comments,
|
|
659
|
+
);
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
// start -------------------------------------------------------------------
|
|
664
|
+
// "Start" moves the issue: to the team's first `started` state, or to
|
|
665
|
+
// `--state`. That is what the word means, what schpet/linear-cli does
|
|
666
|
+
// unconditionally (T `src/utils/actions.ts`), and what an agent that just
|
|
667
|
+
// said "start" expects to have happened. It used to be opt-in (`--move`), so
|
|
668
|
+
// a transplanted `linear issue start TES-1` checked the branch out and left
|
|
669
|
+
// the issue in Backlog without a word (TES-637 item 4). `--no-move` is the
|
|
670
|
+
// opt-out; `--move` is still accepted (hidden) so an existing script keeps
|
|
671
|
+
// working. Both the state change and the checkout are reported, so neither
|
|
672
|
+
// is a surprise.
|
|
673
|
+
issue
|
|
674
|
+
.command("start [id]")
|
|
675
|
+
.description(
|
|
676
|
+
"Start work on an issue: check out its branch and move it to the first 'started' state",
|
|
677
|
+
)
|
|
678
|
+
.option("--state <name>", "move to this state instead of the first 'started' one")
|
|
679
|
+
.option("--no-move", "do not change the state; only check out the branch")
|
|
680
|
+
.addOption(new Option("--move", "accepted for compatibility: moving is the default").hideHelp())
|
|
681
|
+
.option("--no-checkout", "do not touch git; only update state")
|
|
682
|
+
.addHelpText(
|
|
683
|
+
"after",
|
|
684
|
+
[
|
|
685
|
+
"",
|
|
686
|
+
"Examples:",
|
|
687
|
+
" linear issue start TES-42 # branch + first 'started' state",
|
|
688
|
+
" linear issue start TES-42 --no-move # branch only",
|
|
689
|
+
" linear issue start TES-42 --state 'In Review' --no-checkout",
|
|
690
|
+
" linear issue start --json | jq -r '.branch' # id from branch",
|
|
691
|
+
].join("\n"),
|
|
692
|
+
)
|
|
693
|
+
.action(
|
|
694
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
695
|
+
// `--state` is a move; `--no-move` says don't. Not a coin flip.
|
|
696
|
+
if (opts.state !== undefined && opts.move === false) {
|
|
697
|
+
throw usageError("Pass either --state or --no-move, not both.");
|
|
698
|
+
}
|
|
699
|
+
const moved = opts.state !== undefined || opts.move !== false;
|
|
700
|
+
const issueModel = await resolveIssue(ctx.client, requireId(idArg, ctx.defaultTeam));
|
|
701
|
+
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
|
+
if (opts.checkout !== false && isGitRepo()) {
|
|
705
|
+
branchResult = checkoutBranch(issueModel.branchName);
|
|
706
|
+
}
|
|
707
|
+
await svc.moveIssueState(ctx.client, issueModel, {
|
|
708
|
+
stateInput: opts.state,
|
|
709
|
+
move: moved,
|
|
710
|
+
});
|
|
711
|
+
ctx.output.emit(
|
|
712
|
+
{
|
|
713
|
+
id: issueModel.id,
|
|
714
|
+
identifier: issueModel.identifier,
|
|
715
|
+
branch: branchResult?.branch ?? issueModel.branchName,
|
|
716
|
+
checkedOut: !!branchResult,
|
|
717
|
+
stateChanged: moved,
|
|
718
|
+
},
|
|
719
|
+
() => {
|
|
720
|
+
if (branchResult)
|
|
721
|
+
ctx.output.success(
|
|
722
|
+
`${branchResult.created ? "Created and checked out" : "Checked out"} ${branchResult.branch}`,
|
|
723
|
+
);
|
|
724
|
+
if (moved)
|
|
725
|
+
ctx.output.success(`Moved ${issueModel.identifier} → ${opts.state ?? "started"}`);
|
|
726
|
+
if (!branchResult && !moved) ctx.output.info(`Branch name: ${issueModel.branchName}`);
|
|
727
|
+
},
|
|
728
|
+
);
|
|
729
|
+
}),
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
// describe ----------------------------------------------------------------
|
|
733
|
+
// The output is a whole commit message — `git commit -m "$(linear issue
|
|
734
|
+
// describe)"` — in schpet/linear-cli's exact shape (`ID Title`, blank line,
|
|
735
|
+
// `Linear-issue:` / `Linear-issue-url:` trailers; see `buildDescription` in
|
|
736
|
+
// git.ts). It used to be `Title` + a bare `Fixes ID` line, so the same
|
|
737
|
+
// pipeline produced a different commit (TES-637 item 5).
|
|
738
|
+
issue
|
|
739
|
+
.command("describe [id]")
|
|
740
|
+
.description("Print a commit message for the issue: 'ID Title' plus Linear-issue trailers")
|
|
741
|
+
.option("-r, --references", "link without closing: 'References <ID>' instead of 'Fixes <ID>'")
|
|
742
|
+
.action(
|
|
743
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
744
|
+
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
745
|
+
includeComments: false,
|
|
746
|
+
});
|
|
747
|
+
const references = opts.references === true;
|
|
748
|
+
const trailer = buildTrailer(detail.identifier, { references });
|
|
749
|
+
const message = buildDescription(detail.identifier, detail.title, detail.url, {
|
|
750
|
+
references,
|
|
751
|
+
});
|
|
752
|
+
ctx.output.emit(
|
|
753
|
+
{
|
|
754
|
+
identifier: detail.identifier,
|
|
755
|
+
title: detail.title,
|
|
756
|
+
url: detail.url,
|
|
757
|
+
/** The magic-word phrase (`Fixes TES-1`); the trailers are `Linear-issue: <trailer>` + `Linear-issue-url: <url>`. */
|
|
758
|
+
trailer,
|
|
759
|
+
/** The full message, exactly as the human output prints it. */
|
|
760
|
+
message,
|
|
761
|
+
},
|
|
762
|
+
() => ctx.output.line(message),
|
|
763
|
+
);
|
|
764
|
+
}),
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
// pull-request ------------------------------------------------------------
|
|
768
|
+
issue
|
|
769
|
+
.command("pull-request [id]")
|
|
770
|
+
.alias("pr")
|
|
771
|
+
.description("Create a GitHub PR for the issue via the gh CLI")
|
|
772
|
+
.option("--base <branch>", "base branch for the PR")
|
|
773
|
+
.option("--head <branch>", "head branch for the PR")
|
|
774
|
+
.option("--draft", "create the PR as a draft")
|
|
775
|
+
.option("--title <title>", "PR title after the issue id (default: the issue title)")
|
|
776
|
+
.option("-w, --web", "open the PR creation page in the browser")
|
|
777
|
+
.action(
|
|
778
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
779
|
+
if (!isGitRepo()) {
|
|
780
|
+
throw usageError("`issue pr` must be run inside a git repository.");
|
|
781
|
+
}
|
|
782
|
+
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
783
|
+
includeComments: false,
|
|
784
|
+
});
|
|
785
|
+
// Title `ID Title` (a custom --title is prefixed the same way), body the
|
|
786
|
+
// two Linear-issue trailers — schpet/linear-cli's PR, plus the magic word
|
|
787
|
+
// (`buildPrContent`). The issue's *description* is no longer copied in
|
|
788
|
+
// (TES-637 item 5): a GitHub PR body is a wider audience than a Linear
|
|
789
|
+
// issue, and Linear links the PR from the trailer, not from the prose.
|
|
790
|
+
const { title, body } = buildPrContent(detail, opts.title);
|
|
791
|
+
const args = buildPrArgs({
|
|
792
|
+
title,
|
|
793
|
+
body,
|
|
794
|
+
base: opts.base,
|
|
795
|
+
head: opts.head,
|
|
796
|
+
draft: opts.draft,
|
|
797
|
+
web: opts.web,
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
if (opts.web) {
|
|
801
|
+
runGh(args);
|
|
802
|
+
ctx.output.emit({ web: true, identifier: detail.identifier }, () =>
|
|
803
|
+
ctx.output.info(`Opening a PR for ${detail.identifier} in the browser…`),
|
|
804
|
+
);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const url = runGh(args).trim();
|
|
809
|
+
ctx.output.emit({ url, identifier: detail.identifier, title }, () =>
|
|
810
|
+
ctx.output.success(`Created PR for ${detail.identifier}: ${url}`),
|
|
811
|
+
);
|
|
812
|
+
}),
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
// relation ----------------------------------------------------------------
|
|
816
|
+
issue
|
|
817
|
+
.command("relation <id> <op> [other]")
|
|
818
|
+
.description("Manage issue relations: op = add|remove|list")
|
|
819
|
+
.option("--blocks", "relation type: blocks")
|
|
820
|
+
.option("--blocked-by", "relation type: blocked by")
|
|
821
|
+
.option("--related", "relation type: related (default)")
|
|
822
|
+
.option("--duplicate", "relation type: duplicate")
|
|
823
|
+
.action(
|
|
824
|
+
action(async (ctx: Context, opts, id: string, op: string, other?: string) => {
|
|
825
|
+
const issueId = normalizeIssueReference(id, ctx.defaultTeam);
|
|
826
|
+
if (op === "list") {
|
|
827
|
+
const rels = await svc.listRelations(ctx.client, issueId);
|
|
828
|
+
ctx.output.list(
|
|
829
|
+
rels,
|
|
830
|
+
[
|
|
831
|
+
{ key: "type", header: "Type", value: (r) => r.type },
|
|
832
|
+
{ key: "issue", header: "Issue", value: (r) => r.issue },
|
|
833
|
+
{ key: "title", header: "Title", value: (r) => r.title, max: 60 },
|
|
834
|
+
],
|
|
835
|
+
rels,
|
|
836
|
+
);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (op !== "add" && op !== "remove") throw usageError("op must be add, remove, or list.");
|
|
840
|
+
if (!other) throw usageError("Specify the other issue id.");
|
|
841
|
+
const type = opts.blocks
|
|
842
|
+
? "blocks"
|
|
843
|
+
: opts.blockedBy
|
|
844
|
+
? "blocked_by"
|
|
845
|
+
: opts.duplicate
|
|
846
|
+
? "duplicate"
|
|
847
|
+
: "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
|
+
);
|
|
855
|
+
ctx.output.emit(
|
|
856
|
+
{
|
|
857
|
+
issueId: a.id,
|
|
858
|
+
issueIdentifier: a.identifier,
|
|
859
|
+
otherId: b.id,
|
|
860
|
+
otherIdentifier: b.identifier,
|
|
861
|
+
type,
|
|
862
|
+
op,
|
|
863
|
+
},
|
|
864
|
+
() =>
|
|
865
|
+
ctx.output.success(
|
|
866
|
+
`${op === "add" ? "Added" : "Removed"} ${type} relation ${a.identifier} ↔ ${b.identifier}`,
|
|
867
|
+
),
|
|
868
|
+
);
|
|
869
|
+
}),
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
// subscribe / unsubscribe -------------------------------------------------
|
|
873
|
+
issue
|
|
874
|
+
.command("subscribe [id]")
|
|
875
|
+
.description("Subscribe to an issue")
|
|
876
|
+
.action(
|
|
877
|
+
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
878
|
+
const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), true);
|
|
879
|
+
ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: true }, () =>
|
|
880
|
+
ctx.output.success(`Subscribed to ${iss.identifier}`),
|
|
881
|
+
);
|
|
882
|
+
}),
|
|
883
|
+
);
|
|
884
|
+
issue
|
|
885
|
+
.command("unsubscribe [id]")
|
|
886
|
+
.description("Unsubscribe from an issue")
|
|
887
|
+
.action(
|
|
888
|
+
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
889
|
+
const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), false);
|
|
890
|
+
ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: false }, () =>
|
|
891
|
+
ctx.output.success(`Unsubscribed from ${iss.identifier}`),
|
|
892
|
+
);
|
|
893
|
+
}),
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
// archive / unarchive / delete -------------------------------------------
|
|
897
|
+
issue
|
|
898
|
+
.command("archive [id]")
|
|
899
|
+
.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
|
+
.action(
|
|
904
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
905
|
+
await runBulkIssueMutation(ctx, opts, idArg, "archive", (id) =>
|
|
906
|
+
svc.archiveIssue(ctx.client, id, false),
|
|
907
|
+
);
|
|
908
|
+
}),
|
|
909
|
+
);
|
|
910
|
+
issue
|
|
911
|
+
.command("unarchive [id]")
|
|
912
|
+
.description("Unarchive an issue")
|
|
913
|
+
.action(
|
|
914
|
+
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
915
|
+
const iss = await svc.archiveIssue(ctx.client, requireId(idArg, ctx.defaultTeam), true);
|
|
916
|
+
ctx.output.emit({ id: iss.id, identifier: iss.identifier, archived: false }, () =>
|
|
917
|
+
ctx.output.success(`Unarchived ${iss.identifier}`),
|
|
918
|
+
);
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
issue
|
|
922
|
+
.command("delete [id]")
|
|
923
|
+
.alias("rm")
|
|
924
|
+
.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
|
+
.action(
|
|
929
|
+
action(async (ctx: Context, opts, idArg?: string) => {
|
|
930
|
+
await runBulkIssueMutation(ctx, opts, idArg, "delete", (id) =>
|
|
931
|
+
svc.deleteIssue(ctx.client, id),
|
|
932
|
+
);
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
// scalar getters: id / title / url / branch ------------------------------
|
|
937
|
+
registerScalar(issue, "id", "Print the current issue's identifier", (d) => d.identifier);
|
|
938
|
+
registerScalar(issue, "title", "Print the issue title", (d) => d.title);
|
|
939
|
+
registerScalar(issue, "url", "Print the issue URL", (d) => d.url);
|
|
940
|
+
registerScalar(issue, "branch", "Print the suggested git branch name", (d) => d.branchName);
|
|
941
|
+
}
|
|
942
|
+
|
|
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
|
+
/**
|
|
1053
|
+
* Render a single issue's detail block. Shared by `issue view` and the bare
|
|
1054
|
+
* `linear` command so `linear --json` === `issue view <id> --json`.
|
|
1055
|
+
*/
|
|
1056
|
+
export async function renderIssueDetail(
|
|
1057
|
+
ctx: Context,
|
|
1058
|
+
detail: svc.IssueDetail,
|
|
1059
|
+
includeComments: boolean,
|
|
1060
|
+
showResolvedThreads = false,
|
|
1061
|
+
): Promise<void> {
|
|
1062
|
+
const { cycle, team } = detail;
|
|
1063
|
+
const commentPairs = includeComments
|
|
1064
|
+
? issueCommentPairs(detail.comments, showResolvedThreads)
|
|
1065
|
+
: [];
|
|
1066
|
+
ctx.output.detail(detail, [
|
|
1067
|
+
["Issue", `${detail.identifier} ${detail.title}`],
|
|
1068
|
+
// A deleted issue used to view exactly like a live one. Say so first, and
|
|
1069
|
+
// 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
|
+
],
|
|
1074
|
+
["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
|
|
1075
|
+
["State", detail.state?.name ?? null],
|
|
1076
|
+
["Priority", detail.priorityLabel],
|
|
1077
|
+
["Assignee", detail.assignee?.displayName ?? null],
|
|
1078
|
+
["Team", team ? `${team.key} ${team.name}` : null],
|
|
1079
|
+
["Project", detail.project?.name ?? null],
|
|
1080
|
+
["Milestone", detail.milestone?.name ?? null],
|
|
1081
|
+
["Cycle", cycle ? `#${cycle.number}${cycle.name ? ` ${cycle.name}` : ""}` : null],
|
|
1082
|
+
["Parent", detail.parent ? issueRefLabel(detail.parent) : null],
|
|
1083
|
+
[
|
|
1084
|
+
"Sub-issues",
|
|
1085
|
+
detail.children.length ? `\n${detail.children.map(issueRefLabel).join("\n")}` : null,
|
|
1086
|
+
],
|
|
1087
|
+
["Estimate", detail.estimate],
|
|
1088
|
+
["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
|
+
["Due", detail.dueDate],
|
|
1101
|
+
["URL", detail.url],
|
|
1102
|
+
["Updated", detail.updatedAt],
|
|
1103
|
+
["Description", detail.description ? `\n${detail.description}` : null],
|
|
1104
|
+
...commentPairs,
|
|
1105
|
+
]);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
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
|
+
function registerScalar(
|
|
1186
|
+
issue: Command,
|
|
1187
|
+
name: string,
|
|
1188
|
+
description: string,
|
|
1189
|
+
pick: (d: svc.IssueDetail) => string,
|
|
1190
|
+
): void {
|
|
1191
|
+
issue
|
|
1192
|
+
.command(`${name} [id]`)
|
|
1193
|
+
.description(description)
|
|
1194
|
+
.action(
|
|
1195
|
+
action(async (ctx: Context, _opts, idArg?: string) => {
|
|
1196
|
+
const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
|
|
1197
|
+
includeComments: false,
|
|
1198
|
+
});
|
|
1199
|
+
ctx.output.emit({ [name]: pick(detail) }, () => ctx.output.line(pick(detail)));
|
|
1200
|
+
}),
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
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;
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Disambiguate `[id] [value]` where both are optional so the issue id can be
|
|
1209
|
+
* inferred from the branch. Two args → (id, value). One arg → it's the value
|
|
1210
|
+
* unless it looks like an issue id (then the value is missing → usage error).
|
|
1211
|
+
*/
|
|
1212
|
+
function oneOrTwo(
|
|
1213
|
+
a: string | undefined,
|
|
1214
|
+
b: string | undefined,
|
|
1215
|
+
valueName: string,
|
|
1216
|
+
): {
|
|
1217
|
+
idArg?: string;
|
|
1218
|
+
value: string;
|
|
1219
|
+
} {
|
|
1220
|
+
if (a !== undefined && b !== undefined) return { idArg: a, value: b };
|
|
1221
|
+
if (a === undefined) throw usageError(`Missing ${valueName}.`);
|
|
1222
|
+
if (ISSUE_ID_RE.test(a)) {
|
|
1223
|
+
throw usageError(
|
|
1224
|
+
`Missing ${valueName}. Usage: <id> <${valueName}> (or just <${valueName}> on a matching branch)`,
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
return { value: a };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* `oneOrTwo`'s sibling for `issue comment [id] [body]`, where the value may
|
|
1232
|
+
* legitimately be absent (it can come from --body-file or $EDITOR): a lone
|
|
1233
|
+
* operand that looks like an issue id IS the id; anything else is the body.
|
|
1234
|
+
*/
|
|
1235
|
+
function idAndBody(
|
|
1236
|
+
a: string | undefined,
|
|
1237
|
+
b: string | undefined,
|
|
1238
|
+
): { idArg?: string; bodyArg?: string } {
|
|
1239
|
+
if (a !== undefined && b !== undefined) return { idArg: a, bodyArg: b };
|
|
1240
|
+
if (a === undefined) return {};
|
|
1241
|
+
return ISSUE_ID_RE.test(a) ? { idArg: a } : { bodyArg: a };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Invoke `gh` with the given argv, returning its stdout. `gh`'s stderr is
|
|
1246
|
+
* captured (so it never pollutes our stdout / the `--json` contract) and only
|
|
1247
|
+
* surfaced when gh fails — as the message of a clear CliError. Failures map to
|
|
1248
|
+
* helpful errors: a missing `gh` becomes a usage error.
|
|
1249
|
+
*/
|
|
1250
|
+
function runGh(args: string[]): string {
|
|
1251
|
+
try {
|
|
1252
|
+
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
const e = err as NodeJS.ErrnoException & { status?: number; stderr?: Buffer | string };
|
|
1255
|
+
if (e.code === "ENOENT") {
|
|
1256
|
+
throw usageError(
|
|
1257
|
+
"GitHub CLI (gh) is required for `issue pr`. Install it from https://cli.github.com.",
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
const stderr = e.stderr ? e.stderr.toString().trim() : "";
|
|
1261
|
+
throw new CliError(stderr || `gh exited with code ${e.status ?? 1}.`, "runtime");
|
|
1262
|
+
}
|
|
1263
|
+
}
|