@navbook/cli 0.3.0 → 0.5.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/dist/args.d.ts +28 -0
- package/dist/args.js +48 -0
- package/dist/args.js.map +1 -0
- package/dist/commands/complete.d.ts +10 -0
- package/dist/commands/complete.js +59 -51
- package/dist/commands/complete.js.map +1 -1
- package/dist/commands/compose.d.ts +35 -0
- package/dist/commands/doctor.d.ts +15 -0
- package/dist/commands/entity.d.ts +73 -0
- package/dist/commands/entity.js +59 -44
- package/dist/commands/entity.js.map +1 -1
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/install.d.ts +17 -0
- package/dist/commands/install.js +6 -3
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/issue.d.ts +35 -0
- package/dist/commands/issue.js +4 -5
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/plugin.d.ts +36 -0
- package/dist/commands/plugin.js +386 -0
- package/dist/commands/plugin.js.map +1 -0
- package/dist/commands/policy.d.ts +58 -0
- package/dist/commands/policy.js +28 -2
- package/dist/commands/policy.js.map +1 -1
- package/dist/commands/pr-elsewhere.d.ts +40 -0
- package/dist/commands/pr-elsewhere.js +209 -0
- package/dist/commands/pr-elsewhere.js.map +1 -0
- package/dist/commands/pr.d.ts +69 -0
- package/dist/commands/pr.js +132 -68
- package/dist/commands/pr.js.map +1 -1
- package/dist/context.d.ts +42 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -1
- package/dist/editor.d.ts +23 -0
- package/dist/editor.js +4 -0
- package/dist/editor.js.map +1 -1
- package/dist/errors.d.ts +17 -0
- package/dist/install/completions.d.ts +19 -0
- package/dist/install/hook.d.ts +32 -0
- package/dist/install/hook.js +48 -4
- package/dist/install/hook.js.map +1 -1
- package/dist/main.d.ts +21 -0
- package/dist/main.js +112 -8
- package/dist/main.js.map +1 -1
- package/dist/plugins/commands.d.ts +69 -0
- package/dist/plugins/commands.js +148 -0
- package/dist/plugins/commands.js.map +1 -0
- package/dist/plugins/hint.d.ts +23 -0
- package/dist/plugins/hint.js +65 -0
- package/dist/plugins/hint.js.map +1 -0
- package/dist/plugins/host.d.ts +125 -0
- package/dist/plugins/host.js +18 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/loader.d.ts +54 -0
- package/dist/plugins/loader.js +165 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/resolve.d.ts +50 -0
- package/dist/plugins/resolve.js +134 -0
- package/dist/plugins/resolve.js.map +1 -0
- package/dist/plugins/runtime.d.ts +69 -0
- package/dist/plugins/runtime.js +256 -0
- package/dist/plugins/runtime.js.map +1 -0
- package/dist/plugins/store.d.ts +96 -0
- package/dist/plugins/store.js +186 -0
- package/dist/plugins/store.js.map +1 -0
- package/dist/program.d.ts +49 -0
- package/dist/program.js +353 -114
- package/dist/program.js.map +1 -1
- package/dist/prompt.d.ts +31 -0
- package/dist/render/colors.d.ts +13 -0
- package/dist/render/detail.d.ts +22 -0
- package/dist/render/table.d.ts +32 -0
- package/dist/render/table.js +123 -13
- package/dist/render/table.js.map +1 -1
- package/dist/sort.d.ts +27 -0
- package/package.json +9 -2
- package/dist/commands/feature.js +0 -202
- package/dist/commands/feature.js.map +0 -1
package/dist/program.js
CHANGED
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { DEADLINE_TERMS, entityJson, MERGE_METHODS, QUERY_STATUSES, queryTermsFor, REVIEW_DECISIONS, readMarkerVersion, } from "@navbook/core";
|
|
7
8
|
import { Command, Option } from "commander";
|
|
9
|
+
import { finiteNumber, wholeNumber } from "./args.js";
|
|
8
10
|
import { cmdComplete } from "./commands/complete.js";
|
|
9
11
|
import { cmdDoctor } from "./commands/doctor.js";
|
|
10
12
|
import { cmdClose, cmdComment, cmdDelete, cmdEdit, cmdList, cmdReopen, cmdShow, } from "./commands/entity.js";
|
|
11
|
-
import { cmdFeatureEdit, cmdFeatureList, cmdFeatureOpen, cmdFeatureShow, cmdSpecAdd, cmdSpecEdit, cmdSpecList, } from "./commands/feature.js";
|
|
12
13
|
import { cmdId, cmdInit } from "./commands/init.js";
|
|
13
14
|
import { cmdInstall, cmdUninstall } from "./commands/install.js";
|
|
14
15
|
import { cmdIssueLink, cmdIssueOpen, cmdIssueUnlink } from "./commands/issue.js";
|
|
16
|
+
import { cmdPluginInstall, cmdPluginList, cmdPluginRemove, cmdPluginUpdate, } from "./commands/plugin.js";
|
|
17
|
+
import { warnNewerFormat } from "./commands/policy.js";
|
|
15
18
|
import { cmdPrClose, cmdPrList, cmdPrMerge, cmdPrOpen, cmdPrRequest, cmdPrReview, cmdPrUpdate, } from "./commands/pr.js";
|
|
19
|
+
import { YES_HELP } from "./commands/pr-elsewhere.js";
|
|
20
|
+
import { applyOption, buildPluginCommand as buildDeclaredCommand, optionCollision, } from "./plugins/commands.js";
|
|
16
21
|
import { DEFAULT_SORT, SORT_ORDERS } from "./sort.js";
|
|
17
22
|
/**
|
|
18
23
|
* Read straight from package.json rather than duplicating the version as a
|
|
@@ -27,26 +32,91 @@ function readOwnVersion() {
|
|
|
27
32
|
return pkg.version;
|
|
28
33
|
}
|
|
29
34
|
export const VERSION = readOwnVersion();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
/**
|
|
36
|
+
* What each keyed term means, in the words `--help` uses.
|
|
37
|
+
*
|
|
38
|
+
* Only the prose lives here: which terms exist, which noun has them and how
|
|
39
|
+
* they combine all come from `QUERY_TERMS`, which the parser reads too, and so
|
|
40
|
+
* do the values of the three keys that take a fixed set. Keyed by `QueryKey`,
|
|
41
|
+
* so a term added to the grammar does not type-check until it is described.
|
|
42
|
+
* `status` is the one whose values differ by noun, so `queryHelp` writes it.
|
|
43
|
+
*/
|
|
44
|
+
const TERM_HELP = {
|
|
45
|
+
label: { syntax: "label:L", hint: ["L is among the entity's labels (repeatable, ANDs)"] },
|
|
46
|
+
assignee: { syntax: "assignee:EMAIL", hint: ["assignee address, or a fragment of its domain"] },
|
|
47
|
+
author: { syntax: "author:EMAIL", hint: ["author address, same matching"] },
|
|
48
|
+
milestone: { syntax: "milestone:M", hint: ["exact milestone"] },
|
|
49
|
+
reviewer: { syntax: "reviewer:EMAIL", hint: ["asked to review it; same matching"] },
|
|
50
|
+
review: { syntax: "review:DECISION", hint: [`one of ${REVIEW_DECISIONS.join(", ")}`] },
|
|
51
|
+
awaiting: { syntax: "awaiting:EMAIL", hint: ["asked to review it and has not yet"] },
|
|
52
|
+
deadline: {
|
|
53
|
+
syntax: `deadline:${DEADLINE_TERMS.join("|")}`,
|
|
54
|
+
hint: ["overdue: due before today (UTC), strictly;", "none: no deadline at all"],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
/** How wide the syntax column is, as the help has always aligned it. */
|
|
58
|
+
const SYNTAX_WIDTH = 28;
|
|
59
|
+
function helpRow(syntax, hint) {
|
|
60
|
+
return hint.map((line, i) => ` ${(i === 0 ? syntax : "").padEnd(SYNTAX_WIDTH)}${line}`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A plugin's declared `help` line as rows of the table above.
|
|
64
|
+
*
|
|
65
|
+
* The manifest writes it as the built-in rows read, syntax then a gap then
|
|
66
|
+
* the hint; it is re-aligned here so a plugin need not know the column this
|
|
67
|
+
* help happens to use. A line with no gap is all syntax, and a syntax too wide
|
|
68
|
+
* for its column puts the hint on the next line rather than against it.
|
|
69
|
+
*/
|
|
70
|
+
export function pluginHelpRow(help) {
|
|
71
|
+
const [syntax = "", hint = ""] = help.trim().split(/\s{2,}(.*)/s);
|
|
72
|
+
if (hint === "")
|
|
73
|
+
return [` ${syntax}`];
|
|
74
|
+
if (syntax.length >= SYNTAX_WIDTH)
|
|
75
|
+
return [` ${syntax}`, ...helpRow("", [hint])];
|
|
76
|
+
return helpRow(syntax, [hint]);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The query grammar as `nav {issue,pr} list --help` states it.
|
|
80
|
+
*
|
|
81
|
+
* Built per noun, so neither page offers a term its own parser refuses. The
|
|
82
|
+
* terms plugins declared for the noun follow the built-in ones, read from
|
|
83
|
+
* their manifests alone (spec 04 §4.3: help costs no plugin code).
|
|
84
|
+
*/
|
|
85
|
+
function queryHelp(kind, pluginKeys = []) {
|
|
86
|
+
const terms = queryTermsFor(kind);
|
|
87
|
+
const rows = terms.flatMap(({ key }) => {
|
|
88
|
+
if (key === "status") {
|
|
89
|
+
return helpRow(`status:${QUERY_STATUSES[kind].join("|")}`, ["entity status (path)"]);
|
|
90
|
+
}
|
|
91
|
+
return helpRow(TERM_HELP[key].syntax, TERM_HELP[key].hint);
|
|
92
|
+
});
|
|
93
|
+
rows.push(...pluginKeys.flatMap((key) => pluginHelpRow(key.help)));
|
|
94
|
+
const combining = (combines) => terms
|
|
95
|
+
.filter((term) => term.combines === combines)
|
|
96
|
+
.map((term) => term.key)
|
|
97
|
+
.join(", ");
|
|
98
|
+
return [
|
|
99
|
+
"Query terms AND together. Terms:",
|
|
100
|
+
...rows,
|
|
101
|
+
...helpRow('WORD or "some phrase"', [
|
|
102
|
+
"case-insensitive substring of the title,",
|
|
103
|
+
"description, or any comment body; also the",
|
|
104
|
+
"entity's own ID, from four characters",
|
|
105
|
+
]),
|
|
106
|
+
`Same-key terms OR for single-valued fields (${combining("or")})`,
|
|
107
|
+
`and AND for multi-valued ones (${combining("and")}).`,
|
|
108
|
+
// A manifest says what its term means but not how it combines, so the
|
|
109
|
+
// sentence above cannot name it; its own description has to.
|
|
110
|
+
...(pluginKeys.length > 0 ? ["A plugin's terms combine as their descriptions say."] : []),
|
|
111
|
+
"The default query is status:open.",
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
45
114
|
/**
|
|
46
115
|
* Help for `--commit`, naming the subject the verb commits under (spec 03 §3.2).
|
|
47
116
|
*
|
|
48
|
-
* The argument is the commit's scope rather than an entity kind:
|
|
49
|
-
*
|
|
117
|
+
* The argument is the commit's scope rather than an entity kind: a plugin
|
|
118
|
+
* declares scopes of its own (spec 03 §3.2), and those are deliberately not
|
|
119
|
+
* kinds.
|
|
50
120
|
*/
|
|
51
121
|
function commitHelp(scope) {
|
|
52
122
|
return `wrap the change in a 'docs${scope ? `(${scope})` : ""}:' commit`;
|
|
@@ -63,7 +133,45 @@ function commitHelp(scope) {
|
|
|
63
133
|
function withoutHelpVerb(command) {
|
|
64
134
|
return command.helpCommand(false);
|
|
65
135
|
}
|
|
66
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The nouns and utilities this CLI defines, which a plugin may not take.
|
|
138
|
+
*
|
|
139
|
+
* Kept as data because two things read it: the collision check that refuses a
|
|
140
|
+
* plugin claiming one of these (spec 04 §4.3), and completion, which offers
|
|
141
|
+
* them alongside whatever plugins added.
|
|
142
|
+
*/
|
|
143
|
+
export const BUILTIN_NOUNS = [
|
|
144
|
+
"issue",
|
|
145
|
+
"pr",
|
|
146
|
+
"plugin",
|
|
147
|
+
"init",
|
|
148
|
+
"id",
|
|
149
|
+
"doctor",
|
|
150
|
+
"install",
|
|
151
|
+
"uninstall",
|
|
152
|
+
];
|
|
153
|
+
/**
|
|
154
|
+
* The top-level commands that do not warn about a newer tree: `doctor` reports
|
|
155
|
+
* it as D16 itself, and the rest set the repository up or print something
|
|
156
|
+
* that does not depend on the format.
|
|
157
|
+
*/
|
|
158
|
+
const FORMAT_INDEPENDENT = new Set([
|
|
159
|
+
"init",
|
|
160
|
+
"id",
|
|
161
|
+
"doctor",
|
|
162
|
+
"install",
|
|
163
|
+
"uninstall",
|
|
164
|
+
"plugin",
|
|
165
|
+
"__complete",
|
|
166
|
+
]);
|
|
167
|
+
/** Whether a command, by its top-level name, answers from the tree. */
|
|
168
|
+
function readsTheTree(action) {
|
|
169
|
+
let top = action;
|
|
170
|
+
while (top.parent?.parent)
|
|
171
|
+
top = top.parent;
|
|
172
|
+
return !FORMAT_INDEPENDENT.has(top.name());
|
|
173
|
+
}
|
|
174
|
+
export function buildProgram(getCtx, plugins) {
|
|
67
175
|
const program = withoutHelpVerb(new Command());
|
|
68
176
|
program
|
|
69
177
|
.name("nav")
|
|
@@ -71,6 +179,22 @@ export function buildProgram(getCtx) {
|
|
|
71
179
|
.version(VERSION, "-V, --version")
|
|
72
180
|
.showHelpAfterError()
|
|
73
181
|
.enablePositionalOptions();
|
|
182
|
+
// Before any command that reads or writes the tree, and before it can fail:
|
|
183
|
+
// an answer from a tool older than the tree is a guess, and the person
|
|
184
|
+
// reading it should know that first (spec 04 §4.3, D16).
|
|
185
|
+
program.hook("preAction", (_program, action) => {
|
|
186
|
+
if (!readsTheTree(action))
|
|
187
|
+
return;
|
|
188
|
+
let ctx;
|
|
189
|
+
try {
|
|
190
|
+
ctx = getCtx();
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return; // the action will report what is wrong with the context
|
|
194
|
+
}
|
|
195
|
+
if (ctx.hasNavbook)
|
|
196
|
+
warnNewerFormat(ctx, readMarkerVersion(ctx));
|
|
197
|
+
});
|
|
74
198
|
program
|
|
75
199
|
.command("init")
|
|
76
200
|
.description("create the Navbook skeleton at the repository root")
|
|
@@ -79,7 +203,7 @@ export function buildProgram(getCtx) {
|
|
|
79
203
|
program
|
|
80
204
|
.command("id")
|
|
81
205
|
.description("mint and print a fresh Navbook ID")
|
|
82
|
-
.option("-n, --count <n>", "how many IDs to print", (
|
|
206
|
+
.option("-n, --count <n>", "how many IDs to print", wholeNumber("IDs", 1), 1)
|
|
83
207
|
.action((opts) => cmdId(getCtx(), opts));
|
|
84
208
|
program
|
|
85
209
|
.command("doctor")
|
|
@@ -110,13 +234,91 @@ export function buildProgram(getCtx) {
|
|
|
110
234
|
.command("__complete", { hidden: true })
|
|
111
235
|
.description("internal: print completion candidates for the words typed so far")
|
|
112
236
|
.argument("[words...]")
|
|
113
|
-
.action((words) => cmdComplete(getCtx(), words));
|
|
114
|
-
program.addCommand(
|
|
115
|
-
program.addCommand(
|
|
116
|
-
program.addCommand(
|
|
237
|
+
.action(async (words) => cmdComplete(getCtx(), words, plugins));
|
|
238
|
+
program.addCommand(buildPluginCommand(getCtx));
|
|
239
|
+
program.addCommand(buildIssueCommand(getCtx, plugins));
|
|
240
|
+
program.addCommand(buildPrCommand(getCtx, plugins));
|
|
241
|
+
// Built from manifests alone: no plugin code is imported here, which is what
|
|
242
|
+
// lets `nav --help` cost the same with plugins installed as without
|
|
243
|
+
// (spec 04 §4.3).
|
|
244
|
+
for (const [, { plugin, spec }] of plugins?.commands.nouns ?? []) {
|
|
245
|
+
program.addCommand(buildDeclaredCommand(plugin, spec, getCtx, (target, path, args, opts) => plugins.run(getCtx(), target, path, args, opts)));
|
|
246
|
+
}
|
|
247
|
+
warnUnknownContributions(program, getCtx, plugins);
|
|
117
248
|
return program;
|
|
118
249
|
}
|
|
119
|
-
|
|
250
|
+
/**
|
|
251
|
+
* Say which contributions name a command that does not exist.
|
|
252
|
+
*
|
|
253
|
+
* Checked once the whole tree is built, so every noun — built-in, shared verb
|
|
254
|
+
* or another plugin's — has had its chance to be the target. A contribution
|
|
255
|
+
* that matches nothing does nothing, and a plugin author who typed
|
|
256
|
+
* `issue lsit` deserves to hear that rather than wonder where their option went.
|
|
257
|
+
*/
|
|
258
|
+
function warnUnknownContributions(program, getCtx, plugins) {
|
|
259
|
+
for (const [on, entries] of plugins?.commands.contributions ?? []) {
|
|
260
|
+
let command = program;
|
|
261
|
+
for (const word of on.split(" ").filter(Boolean)) {
|
|
262
|
+
command = command?.commands.find((candidate) => candidate.name() === word);
|
|
263
|
+
}
|
|
264
|
+
if (command !== undefined && command !== program)
|
|
265
|
+
continue;
|
|
266
|
+
for (const { plugin } of entries) {
|
|
267
|
+
stderrOf(getCtx).write(`nav: plugin ${plugin.name} contributes to '${on}', which is not a nav command\n`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Where to warn while the command tree is being built.
|
|
273
|
+
*
|
|
274
|
+
* The context's stream when there is one; the process's otherwise, because
|
|
275
|
+
* building a context needs a repository and `nav id` outside one must not fail
|
|
276
|
+
* over a warning about a plugin.
|
|
277
|
+
*/
|
|
278
|
+
function stderrOf(getCtx) {
|
|
279
|
+
try {
|
|
280
|
+
return getCtx().stderr;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return process.stderr;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* `nav plugin` — the store verbs of spec 04 §4.3.
|
|
288
|
+
*
|
|
289
|
+
* The store is per user, not per repository, so none of these needs one:
|
|
290
|
+
* installing a plugin from a home directory is as ordinary as installing it
|
|
291
|
+
* from a checkout. Inside a repository they still read its declaration.
|
|
292
|
+
*/
|
|
293
|
+
function buildPluginCommand(getRepoCtx) {
|
|
294
|
+
const getCtx = () => getRepoCtx({ requireRepo: false });
|
|
295
|
+
const plugin = withoutHelpVerb(new Command("plugin")).description("install and manage plugins");
|
|
296
|
+
plugin
|
|
297
|
+
.command("install")
|
|
298
|
+
.argument("[name...]", "packages to install; the repository's declaration otherwise")
|
|
299
|
+
.description("install plugins into the per-user store")
|
|
300
|
+
.option("-y, --yes", "do not ask for confirmation")
|
|
301
|
+
.action((names, opts) => cmdPluginInstall(getCtx(), names, opts));
|
|
302
|
+
plugin
|
|
303
|
+
.command("remove")
|
|
304
|
+
.argument("<name...>", "packages to remove")
|
|
305
|
+
.description("remove plugins from the store")
|
|
306
|
+
.option("-y, --yes", "do not ask for confirmation")
|
|
307
|
+
.action((names, opts) => cmdPluginRemove(getCtx(), names, opts));
|
|
308
|
+
plugin
|
|
309
|
+
.command("update")
|
|
310
|
+
.argument("[name...]", "packages to update; all of them otherwise")
|
|
311
|
+
.description("update installed plugins")
|
|
312
|
+
.option("-y, --yes", "do not ask for confirmation")
|
|
313
|
+
.action((names, opts) => cmdPluginUpdate(getCtx(), names, opts));
|
|
314
|
+
plugin
|
|
315
|
+
.command("list")
|
|
316
|
+
.description("what is installed, and whether this repository declares it")
|
|
317
|
+
.option("--json", "one JSON object per plugin, newline-delimited")
|
|
318
|
+
.action((opts) => cmdPluginList(getCtx(), opts));
|
|
319
|
+
return plugin;
|
|
320
|
+
}
|
|
321
|
+
function buildPrCommand(getCtx, plugins) {
|
|
120
322
|
const pr = withoutHelpVerb(new Command("pr")).description("work with pull requests");
|
|
121
323
|
pr.command("open")
|
|
122
324
|
.description("open a pull request from the current branch")
|
|
@@ -128,12 +330,12 @@ function buildPrCommand(getCtx) {
|
|
|
128
330
|
.option("--assignee <email>", "assign to a person (repeatable)", collect, [])
|
|
129
331
|
.option("--reviewer <email>", "ask a person to review it (repeatable)", collect, [])
|
|
130
332
|
.option("--milestone <name>", "milestone")
|
|
131
|
-
.option("--feature <slug>", "attach it to a feature (repeatable)", collect, [])
|
|
132
333
|
.option("--commit", commitHelp("pr"))
|
|
133
|
-
.action((opts) => cmdPrOpen(getCtx(), opts));
|
|
334
|
+
.action(async (opts) => cmdPrOpen(getCtx(), { ...opts, ext: await openFields(getCtx(), plugins, "pr open", opts) }));
|
|
134
335
|
pr.command("update")
|
|
135
336
|
.argument("<id>", "ID or unambiguous prefix")
|
|
136
337
|
.description("append a revision pinning the current HEAD")
|
|
338
|
+
.option("-y, --yes", YES_HELP)
|
|
137
339
|
.option("--commit", commitHelp("pr"))
|
|
138
340
|
.action((id, opts) => cmdPrUpdate(getCtx(), id, opts));
|
|
139
341
|
pr.command("request")
|
|
@@ -141,6 +343,7 @@ function buildPrCommand(getCtx) {
|
|
|
141
343
|
.argument("<email...>", "who to ask")
|
|
142
344
|
.description("ask people to review a pull request")
|
|
143
345
|
.option("--remove", "take them off the reviewers instead")
|
|
346
|
+
.option("-y, --yes", YES_HELP)
|
|
144
347
|
.option("--commit", commitHelp("pr"))
|
|
145
348
|
.action((id, people, opts) => cmdPrRequest(getCtx(), id, people, opts));
|
|
146
349
|
pr.command("review")
|
|
@@ -153,18 +356,19 @@ function buildPrCommand(getCtx) {
|
|
|
153
356
|
.option("--revision <sha>", "bind to this revision instead of the latest")
|
|
154
357
|
.option("--file <path>", "anchor the comment to a file")
|
|
155
358
|
.option("--line <n|start-end>", "anchor the comment to a line or range")
|
|
359
|
+
.option("-y, --yes", YES_HELP)
|
|
156
360
|
.option("--commit", commitHelp("pr"))
|
|
157
361
|
.action((id, opts) => cmdPrReview(getCtx(), id, opts));
|
|
158
362
|
pr.command("merge")
|
|
159
363
|
.argument("[id]", "ID or unambiguous prefix")
|
|
160
364
|
.description("merge a pull request into the checked-out target branch")
|
|
161
|
-
.option("--
|
|
365
|
+
.option("--method <name>", `how to land it (${MERGE_METHODS.join(" | ")})`)
|
|
162
366
|
.option("--continue", "finish a merge that stopped for conflict resolution")
|
|
163
367
|
.option("-y, --yes", "merge without asking when the review policy is not met")
|
|
164
368
|
.option("--no-sync-source", "leave the source branch behind instead of fast-forwarding it")
|
|
165
|
-
|
|
166
|
-
.action((id, opts) => cmdPrMerge(getCtx(), id, { ...opts, noFf: opts.ff === false }));
|
|
369
|
+
.action((id, opts) => cmdPrMerge(getCtx(), id, opts));
|
|
167
370
|
addSharedVerbs(pr, "pr", getCtx, {
|
|
371
|
+
...(plugins ? { plugins, verbPrefix: "pr" } : {}),
|
|
168
372
|
// No extra columns here: `cmdPrList` owns the PR listing's columns, because
|
|
169
373
|
// it appends a `refs` one when scanning across branches.
|
|
170
374
|
extraColumns: [],
|
|
@@ -174,74 +378,12 @@ function buildPrCommand(getCtx) {
|
|
|
174
378
|
},
|
|
175
379
|
runList: (ctx, terms, options) => cmdPrList(ctx, terms, options),
|
|
176
380
|
});
|
|
381
|
+
// After the shared verbs, which are as much this noun's commands as `open`:
|
|
382
|
+
// an option contributed to `pr list` has to find `list` already there.
|
|
383
|
+
applyContributedOptions(pr, "pr", plugins, getCtx);
|
|
177
384
|
return pr;
|
|
178
385
|
}
|
|
179
|
-
|
|
180
|
-
* `nav feature` — spec 04 §4.3.
|
|
181
|
-
*
|
|
182
|
-
* None of the shared verbs appear here. A feature does not open and close, and
|
|
183
|
-
* it is not discussed: the discussion belongs to the issues attached to it. So
|
|
184
|
-
* the family is small on purpose, and `spec` groups what acts on the documents
|
|
185
|
-
* rather than on the feature itself.
|
|
186
|
-
*/
|
|
187
|
-
function buildFeatureCommand(getCtx) {
|
|
188
|
-
const feature = withoutHelpVerb(new Command("feature")).description("work with features");
|
|
189
|
-
feature
|
|
190
|
-
.command("open")
|
|
191
|
-
.argument("<title>", "one-line name for the feature")
|
|
192
|
-
.description("create a feature")
|
|
193
|
-
.option("-m, --message <text>", "summary text; without it $EDITOR is opened")
|
|
194
|
-
.option("--slug <slug>", "directory name to file it under; derived from the title otherwise")
|
|
195
|
-
.option("--commit", commitHelp("feature"))
|
|
196
|
-
.action((title, opts) => cmdFeatureOpen(getCtx(), title, opts));
|
|
197
|
-
feature
|
|
198
|
-
.command("list")
|
|
199
|
-
.description("list features, with how much work is attached to each")
|
|
200
|
-
.option("--json", "one JSON object per feature, newline-delimited")
|
|
201
|
-
.action((opts) => cmdFeatureList(getCtx(), opts));
|
|
202
|
-
feature
|
|
203
|
-
.command("show")
|
|
204
|
-
.argument("<slug>", "the feature's directory name")
|
|
205
|
-
.description("render a feature, its documents, and what has touched it")
|
|
206
|
-
.option("--json", "emit a single JSON object naming its issues and pull requests")
|
|
207
|
-
.option("--commits <n>", "recent commits to list (default 10)", (value) => value.trim() === "" ? Number.NaN : Number(value))
|
|
208
|
-
.action((slug, opts) => cmdFeatureShow(getCtx(), slug, opts));
|
|
209
|
-
feature
|
|
210
|
-
.command("edit")
|
|
211
|
-
.argument("<slug>", "the feature's directory name")
|
|
212
|
-
.description("open the feature's feature.md in $EDITOR")
|
|
213
|
-
.option("--commit", commitHelp("feature"))
|
|
214
|
-
.action((slug, opts) => cmdFeatureEdit(getCtx(), slug, opts));
|
|
215
|
-
feature.addCommand(buildSpecCommand(getCtx));
|
|
216
|
-
return feature;
|
|
217
|
-
}
|
|
218
|
-
function buildSpecCommand(getCtx) {
|
|
219
|
-
const spec = withoutHelpVerb(new Command("spec")).description("work with a feature's specification documents");
|
|
220
|
-
spec
|
|
221
|
-
.command("add")
|
|
222
|
-
.argument("<slug>", "the feature's directory name")
|
|
223
|
-
.argument("<title>", "one-line name for the document")
|
|
224
|
-
.description("add a specification document to a feature")
|
|
225
|
-
.option("-m, --message <text>", "document text; without it $EDITOR is opened")
|
|
226
|
-
.option("--file <name>", "file to write it to; derived from the title otherwise")
|
|
227
|
-
.option("--commit", commitHelp("feature"))
|
|
228
|
-
.action((slug, title, opts) => cmdSpecAdd(getCtx(), slug, title, opts));
|
|
229
|
-
spec
|
|
230
|
-
.command("edit")
|
|
231
|
-
.argument("<slug>", "the feature's directory name")
|
|
232
|
-
.argument("<file>", "the document's file name")
|
|
233
|
-
.description("open a specification document in $EDITOR")
|
|
234
|
-
.option("--commit", commitHelp("feature"))
|
|
235
|
-
.action((slug, file, opts) => cmdSpecEdit(getCtx(), slug, file, opts));
|
|
236
|
-
spec
|
|
237
|
-
.command("list")
|
|
238
|
-
.argument("<slug>", "the feature's directory name")
|
|
239
|
-
.description("list a feature's specification documents")
|
|
240
|
-
.option("--json", "one JSON object per document, newline-delimited")
|
|
241
|
-
.action((slug, opts) => cmdSpecList(getCtx(), slug, opts));
|
|
242
|
-
return spec;
|
|
243
|
-
}
|
|
244
|
-
function buildIssueCommand(getCtx) {
|
|
386
|
+
function buildIssueCommand(getCtx, plugins) {
|
|
245
387
|
const issue = withoutHelpVerb(new Command("issue")).description("work with issues");
|
|
246
388
|
issue
|
|
247
389
|
.command("open")
|
|
@@ -251,15 +393,14 @@ function buildIssueCommand(getCtx) {
|
|
|
251
393
|
.option("--label <label>", "add a label (repeatable)", collect, [])
|
|
252
394
|
.option("--assignee <email>", "assign to a person (repeatable)", collect, [])
|
|
253
395
|
.option("--milestone <name>", "milestone")
|
|
254
|
-
.option("--
|
|
255
|
-
// Number rather than parseInt, and blank rather than 0, for the reason
|
|
256
|
-
// `--depth` does it: a value the command must refuse has to reach it
|
|
257
|
-
// intact rather than arrive silently rounded or defaulted.
|
|
258
|
-
.option("--rank <n>", "where it sits in the queue; lower first", (value) => value.trim() === "" ? Number.NaN : Number(value))
|
|
396
|
+
.option("--rank <n>", "where it sits in the queue; lower first", finiteNumber)
|
|
259
397
|
.option("--deadline <date>", "when the work is wanted, YYYY-MM-DD")
|
|
260
398
|
.option("--parent <id>", "file it as a subtask of an existing issue")
|
|
261
399
|
.option("--commit", commitHelp("issue"))
|
|
262
|
-
.action((title, opts) => cmdIssueOpen(getCtx(), title,
|
|
400
|
+
.action(async (title, opts) => cmdIssueOpen(getCtx(), title, {
|
|
401
|
+
...opts,
|
|
402
|
+
ext: await openFields(getCtx(), plugins, "issue open", opts),
|
|
403
|
+
}));
|
|
263
404
|
issue
|
|
264
405
|
.command("link")
|
|
265
406
|
.argument("<id>", "ID or unambiguous prefix")
|
|
@@ -275,17 +416,44 @@ function buildIssueCommand(getCtx) {
|
|
|
275
416
|
.option("--commit", commitHelp("issue"))
|
|
276
417
|
.action((id, opts) => cmdIssueUnlink(getCtx(), id, opts));
|
|
277
418
|
addSharedVerbs(issue, "issue", getCtx, {
|
|
419
|
+
...(plugins ? { plugins, verbPrefix: "issue" } : {}),
|
|
278
420
|
extraColumns: [],
|
|
279
421
|
configureList: (command) => command.option("--sort <order>", `listing order: ${SORT_ORDERS.join(", ")} (default ${DEFAULT_SORT})`),
|
|
280
422
|
configureShow: (command) =>
|
|
281
|
-
// Number, not parseInt: '2.5' has to reach the command as 2.5 so it can
|
|
282
|
-
// be refused, rather than being silently rounded to something valid.
|
|
283
423
|
// No default here — `cmdShow` owns it, so every caller gets the same one.
|
|
284
|
-
command.option("--depth <n>", "levels of subtasks to render (default 1)", (
|
|
424
|
+
command.option("--depth <n>", "levels of subtasks to render (default 1)", wholeNumber("levels")),
|
|
285
425
|
configureDelete: (command) => command.option("-r, --recursive", "delete its subtasks too, to any depth"),
|
|
286
426
|
});
|
|
427
|
+
// After the shared verbs, for the reason `buildPrCommand` gives.
|
|
428
|
+
applyContributedOptions(issue, "issue", plugins, getCtx);
|
|
287
429
|
return issue;
|
|
288
430
|
}
|
|
431
|
+
/** What plugins contributed to one of this noun's verbs, loading them if any. */
|
|
432
|
+
async function verbHandlers(ctx, shared, verb) {
|
|
433
|
+
if (shared.plugins === undefined || shared.verbPrefix === undefined)
|
|
434
|
+
return [];
|
|
435
|
+
return shared.plugins.handlersFor(ctx, `${shared.verbPrefix} ${verb}`);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* One `jsonExtra` from several, or undefined when no plugin contributed one.
|
|
439
|
+
*
|
|
440
|
+
* A key the entity's own JSON already has is dropped: `title`, `status` and
|
|
441
|
+
* every frontmatter key are the format's answer, and the one a reader of
|
|
442
|
+
* `nav --json` is entitled to (spec 04 §4.2). A plugin adds keys; it does not
|
|
443
|
+
* get to quietly replace the built-in ones.
|
|
444
|
+
*/
|
|
445
|
+
function mergedJsonExtra(handlers) {
|
|
446
|
+
const contributors = handlers.filter((handler) => handler.jsonExtra !== undefined);
|
|
447
|
+
if (contributors.length === 0)
|
|
448
|
+
return undefined;
|
|
449
|
+
return (entity) => {
|
|
450
|
+
const merged = Object.assign({}, ...contributors.map((handler) => handler.jsonExtra?.(entity) ?? {}));
|
|
451
|
+
// Which keys exist does not depend on the Navbook directory's name.
|
|
452
|
+
for (const key of Object.keys(entityJson("", entity)))
|
|
453
|
+
delete merged[key];
|
|
454
|
+
return merged;
|
|
455
|
+
};
|
|
456
|
+
}
|
|
289
457
|
/** Register the verbs both nouns share, so their behavior can never drift. */
|
|
290
458
|
export function addSharedVerbs(parent, kind, getCtx, shared) {
|
|
291
459
|
const noun = kind === "issue" ? "issue" : "pull request";
|
|
@@ -294,31 +462,55 @@ export function addSharedVerbs(parent, kind, getCtx, shared) {
|
|
|
294
462
|
.command("list")
|
|
295
463
|
.argument("[query...]", "query terms; default status:open")
|
|
296
464
|
.description(`list ${kind === "issue" ? "issues" : "pull requests"} matching a query`)
|
|
297
|
-
.addHelpText("after", `\n${
|
|
465
|
+
.addHelpText("after", `\n${queryHelp(kind, shared.plugins?.queryKeys(kind))}`)
|
|
298
466
|
.option("--json", "one JSON object per entity, newline-delimited");
|
|
299
467
|
shared.configureList?.(list);
|
|
300
|
-
list.action((terms, opts) =>
|
|
301
|
-
|
|
302
|
-
|
|
468
|
+
list.action(async (terms, opts) => {
|
|
469
|
+
const contributed = await verbHandlers(getCtx(), shared, "list");
|
|
470
|
+
const columns = [...extraColumns, ...contributed.flatMap((h) => h.columns ?? [])];
|
|
471
|
+
const jsonExtra = mergedJsonExtra(contributed);
|
|
472
|
+
const options = { ...opts, extraColumns: columns, ...(jsonExtra ? { jsonExtra } : {}) };
|
|
473
|
+
if (shared.runList)
|
|
474
|
+
shared.runList(getCtx(), terms, options);
|
|
475
|
+
else
|
|
476
|
+
cmdList(getCtx(), kind, terms, options);
|
|
477
|
+
});
|
|
303
478
|
const show = parent
|
|
304
479
|
.command("show")
|
|
305
480
|
.argument("<id>", "ID or unambiguous prefix")
|
|
306
481
|
.description(`render one ${noun} and its comments`)
|
|
307
482
|
.option("--json", "emit a single JSON object including comments");
|
|
308
483
|
shared.configureShow?.(show);
|
|
309
|
-
show.action((id, opts) =>
|
|
310
|
-
|
|
484
|
+
show.action(async (id, opts) => {
|
|
485
|
+
const contributed = await verbHandlers(getCtx(), shared, "show");
|
|
486
|
+
const jsonExtra = mergedJsonExtra(contributed);
|
|
487
|
+
const sections = contributed.flatMap((handlers) => handlers.showSection ? [handlers.showSection] : []);
|
|
488
|
+
cmdShow(getCtx(), kind, id, {
|
|
489
|
+
...opts,
|
|
490
|
+
...(jsonExtra ? { jsonExtra } : {}),
|
|
491
|
+
...(sections.length > 0 ? { sections } : {}),
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
// An issue lives on whatever branch you are standing on, so there is never
|
|
495
|
+
// another worktree to send its write to: the flag is a pull-request notion.
|
|
496
|
+
const edit = parent
|
|
311
497
|
.command("edit")
|
|
312
498
|
.argument("<id>", "ID or unambiguous prefix")
|
|
313
|
-
.description(`open the ${noun}'s file in $EDITOR`)
|
|
499
|
+
.description(`open the ${noun}'s file in $EDITOR`);
|
|
500
|
+
if (kind === "pr")
|
|
501
|
+
edit.option("-y, --yes", YES_HELP);
|
|
502
|
+
edit
|
|
314
503
|
.option("--commit", commitHelp(kind))
|
|
315
504
|
.action((id, opts) => cmdEdit(getCtx(), kind, id, opts));
|
|
316
|
-
parent
|
|
505
|
+
const comment = parent
|
|
317
506
|
.command("comment")
|
|
318
507
|
.argument("<id>", "ID or unambiguous prefix")
|
|
319
508
|
.description(`add a comment to a ${noun}`)
|
|
320
509
|
.option("-m, --message <text>", "comment text; without it $EDITOR is opened")
|
|
321
|
-
.option("--reply-to <comment-id>", "comment this one replies to")
|
|
510
|
+
.option("--reply-to <comment-id>", "comment this one replies to");
|
|
511
|
+
if (kind === "pr")
|
|
512
|
+
comment.option("-y, --yes", YES_HELP);
|
|
513
|
+
comment
|
|
322
514
|
.option("--commit", commitHelp(kind))
|
|
323
515
|
.action((id, opts) => cmdComment(getCtx(), kind, id, opts));
|
|
324
516
|
parent
|
|
@@ -353,5 +545,52 @@ export function addSharedVerbs(parent, kind, getCtx, shared) {
|
|
|
353
545
|
export function collect(value, previous) {
|
|
354
546
|
return [...previous, value];
|
|
355
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* Add the options plugins declared for one verb, refusing a collision.
|
|
550
|
+
*
|
|
551
|
+
* From the manifest, so no plugin code runs: a `--feature` on `issue open`
|
|
552
|
+
* exists in `--help` and in completion whether or not anybody has run the
|
|
553
|
+
* command that would load the plugin implementing it.
|
|
554
|
+
*/
|
|
555
|
+
function applyContributedOptions(noun, kind, plugins, getCtx) {
|
|
556
|
+
if (plugins === undefined)
|
|
557
|
+
return;
|
|
558
|
+
for (const [on, entries] of plugins.commands.contributions) {
|
|
559
|
+
const [target, verb] = on.split(" ");
|
|
560
|
+
if (target !== kind || verb === undefined)
|
|
561
|
+
continue;
|
|
562
|
+
const command = noun.commands.find((candidate) => candidate.name() === verb);
|
|
563
|
+
if (command === undefined)
|
|
564
|
+
continue;
|
|
565
|
+
for (const { plugin, spec } of entries) {
|
|
566
|
+
for (const option of spec.options ?? []) {
|
|
567
|
+
const problem = optionCollision(command, option);
|
|
568
|
+
if (problem !== null) {
|
|
569
|
+
// Before `.option()`, which throws on a duplicate flag: the plugin
|
|
570
|
+
// loses its option and says so, rather than taking the CLI down.
|
|
571
|
+
stderrOf(getCtx).write(`nav: plugin ${plugin.name} option skipped on '${on}': ${problem}\n`);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
applyOption(command, option);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Frontmatter a plugin wants on a newly opened entity, read off its options.
|
|
581
|
+
*
|
|
582
|
+
* This is the one contribution that loads plugin code on a built-in verb, and
|
|
583
|
+
* only when a plugin declared a contribution to *this* verb — so `nav issue
|
|
584
|
+
* open` with no such plugin imports nothing.
|
|
585
|
+
*/
|
|
586
|
+
async function openFields(ctx, plugins, verb, opts) {
|
|
587
|
+
if (plugins === undefined || !plugins.commands.contributions.has(verb))
|
|
588
|
+
return undefined;
|
|
589
|
+
const fields = {};
|
|
590
|
+
for (const handlers of await plugins.handlersFor(ctx, verb)) {
|
|
591
|
+
Object.assign(fields, handlers.openFields?.(opts) ?? {});
|
|
592
|
+
}
|
|
593
|
+
return Object.keys(fields).length > 0 ? fields : undefined;
|
|
594
|
+
}
|
|
356
595
|
export { Option };
|
|
357
596
|
//# sourceMappingURL=program.js.map
|