@harborppm/cli 0.98.0 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/dist/app.mjs +6947 -5657
- package/lib/cli.mjs +493 -85
- package/lib/format.mjs +97 -4
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -21,8 +21,8 @@ import {
|
|
|
21
21
|
importText,
|
|
22
22
|
forecastList,
|
|
23
23
|
forecastOne,
|
|
24
|
-
STATUS,
|
|
25
24
|
statusWord,
|
|
25
|
+
statusText,
|
|
26
26
|
exportData,
|
|
27
27
|
harbors,
|
|
28
28
|
whoami,
|
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
followedText,
|
|
43
43
|
soundingsText,
|
|
44
44
|
goalsText,
|
|
45
|
+
goalText,
|
|
46
|
+
milestonesText,
|
|
47
|
+
milestoneLine,
|
|
48
|
+
reportsText,
|
|
45
49
|
templatesText,
|
|
46
50
|
approvalsText,
|
|
47
51
|
thanksText,
|
|
@@ -172,7 +176,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
172
176
|
[--also "NAME, NAME"] [--due DATE|pilot] [--starts DATE]
|
|
173
177
|
[--priority low|medium|high|urgent]
|
|
174
178
|
[--status S] [--comment TEXT] [--over-limit] [--dry-run]
|
|
175
|
-
[--
|
|
179
|
+
[--unless-exists] [--literal] [--template NAME]
|
|
180
|
+
[--parent TASK]
|
|
176
181
|
[--milestone NAME]
|
|
177
182
|
reads plain words in TITLE as the
|
|
178
183
|
website does: @name, by friday,
|
|
@@ -259,6 +264,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
259
264
|
harbor messages PROJECT ["TEXT"] [--limit N]
|
|
260
265
|
a project's messages, or write
|
|
261
266
|
one (@Name tells them)
|
|
267
|
+
harbor messages PROJECT --edit "TEXT" [--message ID]
|
|
268
|
+
fix your latest message (or that
|
|
269
|
+
one) in its project
|
|
262
270
|
harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
|
|
263
271
|
[--calls NAME]
|
|
264
272
|
a project's five-line charter
|
|
@@ -280,6 +288,23 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
280
288
|
the ones waiting on your answer
|
|
281
289
|
harbor goals [PORTFOLIO] each portfolio's goals, how far
|
|
282
290
|
along and whether on track
|
|
291
|
+
harbor goal "GOAL" [--title T] [--owner NAME] [--due DATE|none] [--why T]
|
|
292
|
+
[--link WORK] [--unlink WORK] [--kr "KR" [--at N|--target N]]
|
|
293
|
+
change a goal, count work toward
|
|
294
|
+
it, or move a key result
|
|
295
|
+
harbor goal "TITLE" --new [--portfolio P] [--owner NAME] [--due DATE]
|
|
296
|
+
set a new goal (harbor help goal)
|
|
297
|
+
harbor milestones [PROGRAM] [--project P] [--done]
|
|
298
|
+
milestones soonest first: tasks
|
|
299
|
+
done, sign-off, what they wait on
|
|
300
|
+
harbor milestone "NAME" [--program P] [--due DATE] [--rename T]
|
|
301
|
+
[--reached|--open] [--signs-off NAME|none]
|
|
302
|
+
[--approve|--changes NOTE]
|
|
303
|
+
change one, or sign it off
|
|
304
|
+
harbor milestone "NAME" --new --program P --due DATE
|
|
305
|
+
add one to a program
|
|
306
|
+
harbor reports [DASHBOARD] the Reports page's dashboards,
|
|
307
|
+
each chart as its numbers
|
|
283
308
|
harbor templates [PROJECT] the task templates new tasks
|
|
284
309
|
start from (harbor add --template)
|
|
285
310
|
harbor template TASK [--name NAME] save a task as a template in its
|
|
@@ -326,8 +351,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
326
351
|
|
|
327
352
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
328
353
|
harbor (or set one with harbor use). harbor help COMMAND shows just that one.
|
|
329
|
-
Statuses: backlog, todo, doing, review, done
|
|
330
|
-
"to do", "in play"
|
|
354
|
+
Statuses: backlog, todo, doing, review, done. The board's words work
|
|
355
|
+
too ("to do", "in play", "in progress", "in review"), and harbor tasks
|
|
356
|
+
takes --status all. Lists say your board's own name for a column it
|
|
357
|
+
renamed; --json keeps Harbor's word in status and the board's in column.
|
|
331
358
|
DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
|
|
332
359
|
Changes show in Harbor's History as yours, "via Harbor CLI". These commands
|
|
333
360
|
never delete; the full-screen app can, as the website can, and asks first,
|
|
@@ -615,6 +642,16 @@ function dueWords(v) {
|
|
|
615
642
|
return /^\d{4}-\d{2}-\d{2}$/.test(dueDate(v));
|
|
616
643
|
}
|
|
617
644
|
|
|
645
|
+
/** --status S,S as Harbor's words; "all" is every status, done ones included. */
|
|
646
|
+
function statusList(typed) {
|
|
647
|
+
const list = typed
|
|
648
|
+
.split(",")
|
|
649
|
+
.map((x) => x.trim())
|
|
650
|
+
.filter(Boolean);
|
|
651
|
+
if (list.some((x) => x.toLowerCase() === "all")) return ["backlog", "todo", "doing", "review", "done"];
|
|
652
|
+
return list.map(statusWord);
|
|
653
|
+
}
|
|
654
|
+
|
|
618
655
|
/** --due pilot: the date the owner's queue suggests, set by Harbor. */
|
|
619
656
|
const PILOT = new Set(["pilot", "suggested"]);
|
|
620
657
|
const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
|
|
@@ -786,6 +823,20 @@ const DETAIL = {
|
|
|
786
823
|
" harbor add --project P < titles.txt writes: one task a line of stdin",
|
|
787
824
|
" harbor add --project P --dry-run reads: says what it would add, and",
|
|
788
825
|
" adds nothing",
|
|
826
|
+
" harbor add \"TITLE\" --unless-exists writes: only if no open task in the",
|
|
827
|
+
" project has that title (safe to retry)",
|
|
828
|
+
"",
|
|
829
|
+
" --dry-run asks Harbor to add them on a copy that's thrown away, so it",
|
|
830
|
+
" shows each one as it would really be: its likely key, column, owner,",
|
|
831
|
+
" priority, dates, parent and milestone, the words read from its title,",
|
|
832
|
+
" an open task that already has the title, and anything Harbor would",
|
|
833
|
+
" refuse (a full column, an unknown person) with why. It exits 1 when",
|
|
834
|
+
" something would be refused. --json lists the same under tasks.",
|
|
835
|
+
"",
|
|
836
|
+
" --unless-exists skips a title the project already has open (case and",
|
|
837
|
+
" spacing aside), saying which task it found; a done one doesn't count,",
|
|
838
|
+
" so a weekly chore is still added. --json lists added and skipped tasks,",
|
|
839
|
+
" each with its key, the same shape for one title or a hundred.",
|
|
789
840
|
"",
|
|
790
841
|
" --project can be left out when the harbor has one open project (First",
|
|
791
842
|
" voyage aside); the task goes there.",
|
|
@@ -794,11 +845,17 @@ const DETAIL = {
|
|
|
794
845
|
" venue @Quinn by friday !high #Website\" adds \"Book the venue\" for",
|
|
795
846
|
" Quinn, due Friday, high priority, in Website. Harbor says what it read.",
|
|
796
847
|
" A flag wins over the words (they stay in the title), and --literal keeps",
|
|
797
|
-
" every title exactly as typed.",
|
|
848
|
+
" every title exactly as typed. What's read: a person after @ or \"assign",
|
|
849
|
+
" to\"; a day after by, due, on, before or until, or as the very last",
|
|
850
|
+
" words (\"Call the mill friday\"), never after to, for or from (\"Move",
|
|
851
|
+
" the call to Friday\" keeps it); !high or \"high priority\"; #Project.",
|
|
852
|
+
" With --dry-run --literal it says what the words would have read as.",
|
|
798
853
|
"",
|
|
799
854
|
" --assignee me|none|NAME, --also \"NAME, NAME\" (others on it with the",
|
|
800
855
|
" owner), --due DATE|pilot, --priority low|medium|high|urgent,",
|
|
801
|
-
" --status S,
|
|
856
|
+
" --status S (backlog, todo, doing, review or done; \"in play\", \"in",
|
|
857
|
+
" progress\" and the board's other words work too), --comment TEXT and",
|
|
858
|
+
" --over-limit apply to every task added, so",
|
|
802
859
|
" ten lines with --comment add the same comment to all ten. More than 100",
|
|
803
860
|
" lines is harbor import's job.",
|
|
804
861
|
"",
|
|
@@ -1135,6 +1192,62 @@ const DETAIL = {
|
|
|
1135
1192
|
" left out), off track first, with how far",
|
|
1136
1193
|
" along each key result is",
|
|
1137
1194
|
],
|
|
1195
|
+
goal: [
|
|
1196
|
+
' harbor goal "GOAL" reads: one goal and its key results',
|
|
1197
|
+
' harbor goal "TITLE" --new [--portfolio P] [--owner NAME] [--due DATE] [--why T]',
|
|
1198
|
+
" writes: sets a new goal on a portfolio",
|
|
1199
|
+
' harbor goal "GOAL" --title T --owner NAME --due DATE|none --starts DATE|none',
|
|
1200
|
+
" --why T writes: changes it",
|
|
1201
|
+
' harbor goal "GOAL" --link WORK writes: counts a program, project or',
|
|
1202
|
+
" task (RR-42) toward it; --unlink stops",
|
|
1203
|
+
' harbor goal "GOAL" --kr "KR" --at N writes: moves a number key result',
|
|
1204
|
+
' harbor goal "GOAL" --kr "KR" --target N [--start N] [--unit U]',
|
|
1205
|
+
" writes: adds that number key result, or",
|
|
1206
|
+
" changes its numbers",
|
|
1207
|
+
' harbor goal "GOAL" --kr "KR" --counts "WORK, WORK"',
|
|
1208
|
+
" writes: adds a key result counting work",
|
|
1209
|
+
"",
|
|
1210
|
+
" GOAL is its title, or enough of it to tell it apart. --link and",
|
|
1211
|
+
" --unlink take names separated by commas.",
|
|
1212
|
+
],
|
|
1213
|
+
milestones: [
|
|
1214
|
+
" harbor milestones [PROGRAM] reads: a program's open milestones",
|
|
1215
|
+
" (every program's when left out),",
|
|
1216
|
+
" soonest first",
|
|
1217
|
+
" harbor milestones --project P reads: the milestones of P's program",
|
|
1218
|
+
"",
|
|
1219
|
+
" Each says its due day and whether it's late, how many of the tasks tied",
|
|
1220
|
+
" to it are done, who signs it off and where that stands, and the",
|
|
1221
|
+
" projects it waits on. --done adds the reached ones.",
|
|
1222
|
+
],
|
|
1223
|
+
milestone: [
|
|
1224
|
+
' harbor milestone "NAME" reads: one milestone',
|
|
1225
|
+
' harbor milestone "NAME" --new --program P --due DATE',
|
|
1226
|
+
" writes: adds it to the program",
|
|
1227
|
+
' harbor milestone "NAME" --due DATE --rename T',
|
|
1228
|
+
" writes: moves or renames it",
|
|
1229
|
+
' harbor milestone "NAME" --reached|--open',
|
|
1230
|
+
" writes: ticks it reached, or opens it",
|
|
1231
|
+
' harbor milestone "NAME" --signs-off NAME|none',
|
|
1232
|
+
" writes: who signs it off (a stage gate)",
|
|
1233
|
+
' harbor milestone "NAME" --approve | --changes "WHAT"',
|
|
1234
|
+
" writes: your sign-off, if it's yours",
|
|
1235
|
+
"",
|
|
1236
|
+
" A stage gate is reached only by its sign-off, so --reached is refused",
|
|
1237
|
+
" on one. A milestone with tasks tied to it (harbor add --milestone) is",
|
|
1238
|
+
" reached when they're done and opens again when one reopens. Without",
|
|
1239
|
+
" --new a name it doesn't know is refused with the program's milestones,",
|
|
1240
|
+
" so a typo never adds a second one. --program narrows the name when two",
|
|
1241
|
+
" programs use it.",
|
|
1242
|
+
],
|
|
1243
|
+
reports: [
|
|
1244
|
+
" harbor reports reads: every dashboard on the Reports",
|
|
1245
|
+
" page, each chart as its numbers",
|
|
1246
|
+
" harbor reports DASHBOARD reads: that one",
|
|
1247
|
+
"",
|
|
1248
|
+
" Each chart says what it counts, how it's split and the part of the",
|
|
1249
|
+
" harbor it reads. Dashboards are made and changed on the website.",
|
|
1250
|
+
],
|
|
1138
1251
|
soundings: [
|
|
1139
1252
|
" harbor soundings [PROJECT] reads: open tasks with a sounding",
|
|
1140
1253
|
" (all projects when left out), and the",
|
|
@@ -1338,6 +1451,10 @@ const DETAIL = {
|
|
|
1338
1451
|
" One conversation for the whole project, for what isn't about one task;",
|
|
1339
1452
|
" harbor comment TASK is for that. @Name sends it to that person's Inbox.",
|
|
1340
1453
|
" Reading shows the newest 50, oldest first; --limit N shows up to 500.",
|
|
1454
|
+
' harbor messages PROJECT --edit "TEXT" writes: changes your latest message',
|
|
1455
|
+
" there (--message ID: that one; --json",
|
|
1456
|
+
" on a read gives the ids). It shows as",
|
|
1457
|
+
" edited; only the writer can change one.",
|
|
1341
1458
|
],
|
|
1342
1459
|
voyage: [
|
|
1343
1460
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
@@ -1541,8 +1658,8 @@ const OTHER_NAMES = {
|
|
|
1541
1658
|
longpole: "harbor why PROJECT shows what's holding it up.",
|
|
1542
1659
|
"long-pole": "harbor why PROJECT shows what's holding it up.",
|
|
1543
1660
|
pilot: "harbor show TASK says the date its owner's queue suggests; --due pilot on add or edit takes it.",
|
|
1544
|
-
|
|
1545
|
-
|
|
1661
|
+
dashboards: "harbor reports lists the Reports page's dashboards, each chart as its numbers.",
|
|
1662
|
+
report: "harbor report PROGRAM is a program's status report; harbor reports lists the dashboards.",
|
|
1546
1663
|
decide: 'harbor decision "TITLE" --project P logs a decision.',
|
|
1547
1664
|
dock: "harbor try PROJECT is the dry dock.",
|
|
1548
1665
|
"dry-dock": "harbor try PROJECT is the dry dock.",
|
|
@@ -2309,16 +2426,21 @@ export async function run(argv, io = {}) {
|
|
|
2309
2426
|
query: positionals.join(" ") || undefined,
|
|
2310
2427
|
project: values.project,
|
|
2311
2428
|
assignee: values.assignee,
|
|
2312
|
-
status: values.status
|
|
2313
|
-
? values.status
|
|
2314
|
-
.split(",")
|
|
2315
|
-
.map((x) => x.trim())
|
|
2316
|
-
.filter(Boolean)
|
|
2317
|
-
.map(statusWord)
|
|
2318
|
-
: undefined,
|
|
2429
|
+
status: values.status ? statusList(values.status) : undefined,
|
|
2319
2430
|
overdue: values.late || undefined,
|
|
2320
2431
|
};
|
|
2321
|
-
|
|
2432
|
+
// Capped at 20 without --all: --json says so too (more, and how to get them),
|
|
2433
|
+
// so a script counting tasks can't take the first page for all of them.
|
|
2434
|
+
if (!values.all)
|
|
2435
|
+
return call(
|
|
2436
|
+
"search_tasks",
|
|
2437
|
+
args,
|
|
2438
|
+
(r) => taskList(r, opts),
|
|
2439
|
+
(r) =>
|
|
2440
|
+
r.total > (r.tasks?.length ?? 0)
|
|
2441
|
+
? { ...r, more: r.total - r.tasks.length, note: "Capped at 20; --all lists every one." }
|
|
2442
|
+
: r,
|
|
2443
|
+
);
|
|
2322
2444
|
// --all pages through every match, PAGE at a time (search_tasks' most).
|
|
2323
2445
|
const PAGE = 50;
|
|
2324
2446
|
const tasks = [];
|
|
@@ -2395,7 +2517,7 @@ export async function run(argv, io = {}) {
|
|
|
2395
2517
|
"",
|
|
2396
2518
|
"Waits on:",
|
|
2397
2519
|
...t.waitingOn.map(
|
|
2398
|
-
(w) => ` ${w.key ?? w.id} ${w.title} (${
|
|
2520
|
+
(w) => ` ${w.key ?? w.id} ${w.title} (${statusText(w)})`,
|
|
2399
2521
|
),
|
|
2400
2522
|
);
|
|
2401
2523
|
if (t.waitedOnBy.length)
|
|
@@ -2403,7 +2525,7 @@ export async function run(argv, io = {}) {
|
|
|
2403
2525
|
"",
|
|
2404
2526
|
"Holds up:",
|
|
2405
2527
|
...t.waitedOnBy.map(
|
|
2406
|
-
(w) => ` ${w.key ?? w.id} ${w.title} (${
|
|
2528
|
+
(w) => ` ${w.key ?? w.id} ${w.title} (${statusText(w)})`,
|
|
2407
2529
|
),
|
|
2408
2530
|
);
|
|
2409
2531
|
if (t.files?.length) lines.push("", "Files:", ...t.files.map((f) => ` ${fileText(f)}`));
|
|
@@ -2624,7 +2746,7 @@ export async function run(argv, io = {}) {
|
|
|
2624
2746
|
case "add": {
|
|
2625
2747
|
const { positionals, values, harbor } = parse(
|
|
2626
2748
|
spec(
|
|
2627
|
-
["over-limit", "dry-run", "literal"],
|
|
2749
|
+
["over-limit", "dry-run", "literal", "unless-exists"],
|
|
2628
2750
|
["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent", "milestone"],
|
|
2629
2751
|
),
|
|
2630
2752
|
);
|
|
@@ -2662,82 +2784,134 @@ export async function run(argv, io = {}) {
|
|
|
2662
2784
|
if (!values.project && !reads.every((r) => r.project))
|
|
2663
2785
|
values.project = await onlyProject(client, harbor, "--project NAME or #NAME in the title");
|
|
2664
2786
|
const projectOf = (r) => values.project ?? r.project?.value;
|
|
2787
|
+
// What create_task gets for one line, the same for a dry run and the real thing.
|
|
2788
|
+
const argsFor = (words) => ({
|
|
2789
|
+
project: projectOf(words),
|
|
2790
|
+
title: words.title || undefined,
|
|
2791
|
+
template: values.template,
|
|
2792
|
+
// Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
|
|
2793
|
+
assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
|
|
2794
|
+
also: values.also === undefined ? undefined : alsoList(values.also),
|
|
2795
|
+
due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
|
|
2796
|
+
suggestedDue: duePilot(values.due),
|
|
2797
|
+
starts: dueDate(values.starts),
|
|
2798
|
+
priority: values.priority ?? words.priority?.value,
|
|
2799
|
+
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
2800
|
+
parent: values.parent,
|
|
2801
|
+
milestone: values.milestone,
|
|
2802
|
+
overLimit: values["over-limit"] || undefined,
|
|
2803
|
+
unlessExists: values["unless-exists"] || undefined,
|
|
2804
|
+
});
|
|
2665
2805
|
if (values["dry-run"]) {
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2806
|
+
// Harbor runs each one on a copy and says what it would be; nothing is saved.
|
|
2807
|
+
const preview = await client
|
|
2808
|
+
.call("preview_tasks", { harbor, tasks: reads.map(argsFor) })
|
|
2809
|
+
.catch((err) => {
|
|
2810
|
+
if (err instanceof HarborError) throw new HarborError(hint(err.message, values.project));
|
|
2811
|
+
throw err;
|
|
2812
|
+
});
|
|
2813
|
+
// --literal: what the words would have read as, so it's plain what it kept.
|
|
2814
|
+
const unread =
|
|
2815
|
+
values.literal && !fromTemplate ? await readTitles(client, harbor, titles, values) : null;
|
|
2816
|
+
const lines = preview.tasks.map((p, i) => {
|
|
2817
|
+
const t = p.created;
|
|
2818
|
+
const words = reads[i];
|
|
2819
|
+
return {
|
|
2820
|
+
title: t?.title ?? p.title ?? words.title,
|
|
2821
|
+
...(t
|
|
2822
|
+
? {
|
|
2823
|
+
key: t.key ?? null,
|
|
2824
|
+
project: t.project,
|
|
2825
|
+
status: t.status,
|
|
2826
|
+
...(t.column ? { column: t.column } : {}),
|
|
2827
|
+
assignee: t.assignee,
|
|
2828
|
+
...(t.alsoOn ? { alsoOn: t.alsoOn } : {}),
|
|
2829
|
+
priority: t.priority,
|
|
2830
|
+
due: t.due,
|
|
2831
|
+
...(t.starts ? { starts: t.starts } : {}),
|
|
2832
|
+
...(t.subtaskOf ? { subtaskOf: t.subtaskOf } : {}),
|
|
2833
|
+
...(t.milestone ? { milestone: t.milestone } : {}),
|
|
2834
|
+
}
|
|
2835
|
+
: {}),
|
|
2836
|
+
...(values.template ? { template: values.template } : {}),
|
|
2837
|
+
...(values.comment ? { comment: values.comment } : {}),
|
|
2838
|
+
...(words.owner || words.due || words.priority || words.project
|
|
2839
|
+
? {
|
|
2840
|
+
read: {
|
|
2841
|
+
...(words.owner ? { assignee: words.owner.value, from: words.owner.text } : {}),
|
|
2842
|
+
...(words.due ? { due: words.due.value } : {}),
|
|
2843
|
+
...(words.priority ? { priority: words.priority.value } : {}),
|
|
2844
|
+
...(words.project ? { project: words.project.value } : {}),
|
|
2845
|
+
},
|
|
2846
|
+
}
|
|
2847
|
+
: {}),
|
|
2848
|
+
...(unread && wordsLine(unread[i]) ? { keptWords: wordsLine(unread[i]) } : {}),
|
|
2849
|
+
...(p.sameAs ? { sameAs: p.sameAs } : {}),
|
|
2850
|
+
...(p.sameAs && values["unless-exists"] ? { skipped: true } : {}),
|
|
2851
|
+
...(p.refused ? { refused: p.refused } : {}),
|
|
2852
|
+
};
|
|
2853
|
+
});
|
|
2854
|
+
const refused = lines.filter((l) => l.refused).length;
|
|
2855
|
+
const skipped = lines.filter((l) => l.skipped).length;
|
|
2856
|
+
const would = lines.length - refused - skipped;
|
|
2857
|
+
if (asJson) {
|
|
2858
|
+
const where = [...new Set(lines.map((l) => l.project).filter(Boolean))];
|
|
2680
2859
|
out(
|
|
2681
2860
|
jsonText({
|
|
2682
2861
|
dryRun: true,
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
read: reads.map((r) => ({
|
|
2688
|
-
project: projectOf(r),
|
|
2689
|
-
...(r.owner ? { assignee: r.owner.value } : {}),
|
|
2690
|
-
...(r.due ? { due: r.due.value } : {}),
|
|
2691
|
-
...(r.priority ? { priority: r.priority.value } : {}),
|
|
2692
|
-
})),
|
|
2693
|
-
}
|
|
2694
|
-
: {}),
|
|
2695
|
-
...(pilot
|
|
2696
|
-
? { due: pilot.map((d) => d.earliest) }
|
|
2697
|
-
: values.due
|
|
2698
|
-
? { due: titles.map(() => dueDate(values.due)) }
|
|
2699
|
-
: {}),
|
|
2862
|
+
literal: Boolean(values.literal),
|
|
2863
|
+
project: where.length === 1 ? where[0] : (values.project ?? null),
|
|
2864
|
+
titles: lines.map((l) => l.title),
|
|
2865
|
+
tasks: lines,
|
|
2700
2866
|
}),
|
|
2701
2867
|
);
|
|
2702
|
-
else
|
|
2868
|
+
} else {
|
|
2869
|
+
const n = (k) => (k === 1 ? "1 task" : `${k} tasks`);
|
|
2870
|
+
const body = lines.flatMap((l, i) => {
|
|
2871
|
+
if (l.refused) return [` ${l.title}`, ` Would be refused: ${l.refused}`];
|
|
2872
|
+
const bits = [
|
|
2873
|
+
l.key ? `as ${l.key}` : null,
|
|
2874
|
+
statusText(l),
|
|
2875
|
+
l.assignee ?? "nobody's",
|
|
2876
|
+
l.alsoOn ? `with ${l.alsoOn.join(", ")}` : null,
|
|
2877
|
+
l.priority !== "medium" ? `${l.priority} priority` : null,
|
|
2878
|
+
l.due ? `due ${dayWithWeekday(l.due)}` : "no due date",
|
|
2879
|
+
l.starts ? `starts ${l.starts}` : null,
|
|
2880
|
+
l.subtaskOf ? `inside ${l.subtaskOf}` : null,
|
|
2881
|
+
l.milestone ? `toward ${l.milestone}` : null,
|
|
2882
|
+
l.project,
|
|
2883
|
+
].filter(Boolean);
|
|
2884
|
+
const words = reads[i];
|
|
2885
|
+
return [
|
|
2886
|
+
` ${l.title}${l.skipped ? " (already there, skipped)" : ""}`,
|
|
2887
|
+
` ${bits.join(" · ")}`,
|
|
2888
|
+
...(wordsLine(words) ? [` ${wordsLine(words)} --literal keeps them in the title.`] : []),
|
|
2889
|
+
...(l.keptWords
|
|
2890
|
+
? [` --literal kept the words as typed. ${l.keptWords.replace("Read from your words", "Without it, they'd read as")}`]
|
|
2891
|
+
: []),
|
|
2892
|
+
...(l.comment ? [` with your comment: ${l.comment}`] : []),
|
|
2893
|
+
...(l.sameAs && !l.skipped
|
|
2894
|
+
? [` ${l.sameAs} is already open with this title; --unless-exists skips it.`]
|
|
2895
|
+
: []),
|
|
2896
|
+
];
|
|
2897
|
+
});
|
|
2898
|
+
const pilot = duePilot(values.due) && preview.tasks.find((p) => p.pilot)?.pilot;
|
|
2703
2899
|
out(
|
|
2704
2900
|
plain(
|
|
2705
2901
|
[
|
|
2706
|
-
`Would add ${n}
|
|
2707
|
-
...
|
|
2708
|
-
|
|
2709
|
-
...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
|
|
2710
|
-
]),
|
|
2711
|
-
...(pilot
|
|
2712
|
-
? [
|
|
2713
|
-
`--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
|
|
2714
|
-
]
|
|
2715
|
-
: []),
|
|
2902
|
+
`Would add ${n(would)}${skipped ? `, skip ${skipped}` : ""}${refused ? `; ${refused} would be refused` : ""}. Nothing changed.`,
|
|
2903
|
+
...body,
|
|
2904
|
+
...(pilot ? [`--due pilot: ${pilot.says}`] : []),
|
|
2716
2905
|
].join("\n"),
|
|
2717
2906
|
),
|
|
2718
2907
|
);
|
|
2719
|
-
|
|
2908
|
+
}
|
|
2909
|
+
return refused ? 1 : 0;
|
|
2720
2910
|
}
|
|
2721
2911
|
const added = [];
|
|
2722
2912
|
for (const words of reads) {
|
|
2723
2913
|
const r = await client
|
|
2724
|
-
.call("create_task", {
|
|
2725
|
-
harbor,
|
|
2726
|
-
project: projectOf(words),
|
|
2727
|
-
title: words.title || undefined,
|
|
2728
|
-
template: values.template,
|
|
2729
|
-
// Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
|
|
2730
|
-
assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
|
|
2731
|
-
also: values.also === undefined ? undefined : alsoList(values.also),
|
|
2732
|
-
due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
|
|
2733
|
-
suggestedDue: duePilot(values.due),
|
|
2734
|
-
starts: dueDate(values.starts),
|
|
2735
|
-
priority: values.priority ?? words.priority?.value,
|
|
2736
|
-
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
2737
|
-
parent: values.parent,
|
|
2738
|
-
milestone: values.milestone,
|
|
2739
|
-
overLimit: values["over-limit"] || undefined,
|
|
2740
|
-
})
|
|
2914
|
+
.call("create_task", { harbor, ...argsFor(words) })
|
|
2741
2915
|
.catch((err) => {
|
|
2742
2916
|
if (err instanceof HarborError) {
|
|
2743
2917
|
const done = added.length ? ` (added ${added.length} before it)` : "";
|
|
@@ -2745,6 +2919,11 @@ export async function run(argv, io = {}) {
|
|
|
2745
2919
|
}
|
|
2746
2920
|
throw err;
|
|
2747
2921
|
});
|
|
2922
|
+
// --unless-exists found it open already: nothing added, no comment.
|
|
2923
|
+
if (r.existing) {
|
|
2924
|
+
added.push(r);
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2748
2927
|
if (values.comment)
|
|
2749
2928
|
r.comment = (
|
|
2750
2929
|
await client.call("add_comment", {
|
|
@@ -2758,15 +2937,28 @@ export async function run(argv, io = {}) {
|
|
|
2758
2937
|
added.push(r);
|
|
2759
2938
|
}
|
|
2760
2939
|
await stalePromptCache(env);
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2940
|
+
const made = added.filter((r) => r.created);
|
|
2941
|
+
const kept = added.filter((r) => r.existing);
|
|
2942
|
+
// One shape however many titles: `added` lists what was added and
|
|
2943
|
+
// `skipped` what --unless-exists found open already, each entry with
|
|
2944
|
+
// its key at the top; one title added also keeps its `created`.
|
|
2945
|
+
const entry = (r, t) => ({ key: t.key ?? null, id: t.id, title: t.title, ...r });
|
|
2946
|
+
if (asJson)
|
|
2947
|
+
out(
|
|
2948
|
+
jsonText({
|
|
2949
|
+
...(made.length === 1 && added.length === 1 ? made[0] : {}),
|
|
2950
|
+
added: made.map((r) => entry(r, r.created)),
|
|
2951
|
+
skipped: kept.map((r) => entry(r, r.existing)),
|
|
2952
|
+
}),
|
|
2953
|
+
);
|
|
2764
2954
|
else
|
|
2765
2955
|
out(
|
|
2766
2956
|
plain(
|
|
2767
2957
|
added
|
|
2768
2958
|
.map((r) =>
|
|
2769
|
-
|
|
2959
|
+
r.existing
|
|
2960
|
+
? `Already there, not added again: ${taskLine(r.existing, opts)}`
|
|
2961
|
+
: [
|
|
2770
2962
|
`Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
|
|
2771
2963
|
// What the title's words said, so nothing is read without being seen.
|
|
2772
2964
|
...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
|
|
@@ -2776,7 +2968,7 @@ export async function run(argv, io = {}) {
|
|
|
2776
2968
|
...(values.due ? pilotLines(r, r.created) : []),
|
|
2777
2969
|
...(r.created.milestone ? [`Toward the milestone ${r.created.milestone}.`] : []),
|
|
2778
2970
|
...(r.milestoneReopened ? [`${r.milestoneReopened} is open again: it has an open task now.`] : []),
|
|
2779
|
-
|
|
2971
|
+
].join("\n"),
|
|
2780
2972
|
)
|
|
2781
2973
|
.join("\n"),
|
|
2782
2974
|
),
|
|
@@ -3514,6 +3706,210 @@ export async function run(argv, io = {}) {
|
|
|
3514
3706
|
`Saved “${r.template.name}” as a template in ${r.template.project}. harbor add --project "${r.template.project}" --template "${r.template.name}" starts a task from it.`,
|
|
3515
3707
|
);
|
|
3516
3708
|
}
|
|
3709
|
+
case "milestones": {
|
|
3710
|
+
const { positionals, values, harbor } = parse(spec(["done"], ["project", "program"]));
|
|
3711
|
+
const program = values.program ?? (positionals.join(" ").trim() || undefined);
|
|
3712
|
+
if (program && values.project) throw new HarborError("Say a program or --project, not both.");
|
|
3713
|
+
return call(
|
|
3714
|
+
"list_milestones",
|
|
3715
|
+
{ harbor, program, project: values.project, done: Boolean(values.done) },
|
|
3716
|
+
milestonesText,
|
|
3717
|
+
);
|
|
3718
|
+
}
|
|
3719
|
+
case "milestone": {
|
|
3720
|
+
const { positionals, values, harbor } = parse(
|
|
3721
|
+
spec(
|
|
3722
|
+
["new", "reached", "open", "approve"],
|
|
3723
|
+
["program", "due", "rename", "signs-off", "changes"],
|
|
3724
|
+
),
|
|
3725
|
+
);
|
|
3726
|
+
const name = text(positionals, "the milestone");
|
|
3727
|
+
if (values.reached && values.open) throw new HarborError("Say --reached or --open, not both.");
|
|
3728
|
+
const changes =
|
|
3729
|
+
values.new ||
|
|
3730
|
+
values.reached ||
|
|
3731
|
+
values.open ||
|
|
3732
|
+
values.approve ||
|
|
3733
|
+
["due", "rename", "signs-off", "changes"].some((k) => values[k] !== undefined);
|
|
3734
|
+
if (!changes) {
|
|
3735
|
+
// Reading one: the program's (or harbor's) milestones, reached ones too.
|
|
3736
|
+
const r = await client.call("list_milestones", { harbor, program: values.program, done: true });
|
|
3737
|
+
const want = name.toLowerCase();
|
|
3738
|
+
const same = r.milestones.filter((m) => m.name.toLowerCase() === want);
|
|
3739
|
+
const near = same.length ? same : r.milestones.filter((m) => m.name.toLowerCase().includes(want));
|
|
3740
|
+
if (near.length !== 1)
|
|
3741
|
+
throw new HarborError(
|
|
3742
|
+
near.length
|
|
3743
|
+
? `"${name}" could be ${near.map((m) => `${m.name} (${m.program})`).join(" or ")}. Say --program.`
|
|
3744
|
+
: `No milestone matches "${name}". harbor milestones lists them.`,
|
|
3745
|
+
);
|
|
3746
|
+
out(asJson ? jsonText({ milestone: near[0] }) : plain(milestoneLine(near[0])));
|
|
3747
|
+
return 0;
|
|
3748
|
+
}
|
|
3749
|
+
const due = values.due === undefined ? undefined : dueDate(values.due);
|
|
3750
|
+
return call(
|
|
3751
|
+
"set_milestone",
|
|
3752
|
+
{
|
|
3753
|
+
harbor,
|
|
3754
|
+
milestone: name,
|
|
3755
|
+
program: values.program,
|
|
3756
|
+
new: Boolean(values.new),
|
|
3757
|
+
name: values.rename,
|
|
3758
|
+
due,
|
|
3759
|
+
reached: values.reached ? true : values.open ? false : undefined,
|
|
3760
|
+
signOff: values["signs-off"],
|
|
3761
|
+
approve: values.approve || undefined,
|
|
3762
|
+
changes: values.changes,
|
|
3763
|
+
},
|
|
3764
|
+
(r) => (r.added ? `Added to ${r.added.program}: ${milestoneLine(r.added, { program: false })}` : milestoneLine(r.milestone)),
|
|
3765
|
+
);
|
|
3766
|
+
}
|
|
3767
|
+
case "goal": {
|
|
3768
|
+
const { positionals, values, harbor } = parse(
|
|
3769
|
+
spec(
|
|
3770
|
+
["new"],
|
|
3771
|
+
["portfolio", "title", "owner", "due", "starts", "why", "link", "unlink", "kr", "at", "target", "start", "unit", "counts"],
|
|
3772
|
+
),
|
|
3773
|
+
);
|
|
3774
|
+
const ref = text(positionals, "the goal");
|
|
3775
|
+
const num = (flag) => {
|
|
3776
|
+
if (values[flag] === undefined) return undefined;
|
|
3777
|
+
const n = Number(String(values[flag]).replace(/,/g, ""));
|
|
3778
|
+
if (!Number.isFinite(n) || String(values[flag]).trim() === "")
|
|
3779
|
+
throw new HarborError(`--${flag} is a number, like 12 or 2.5.`);
|
|
3780
|
+
return n;
|
|
3781
|
+
};
|
|
3782
|
+
const list = (flag) =>
|
|
3783
|
+
values[flag] === undefined
|
|
3784
|
+
? undefined
|
|
3785
|
+
: String(values[flag])
|
|
3786
|
+
.split(",")
|
|
3787
|
+
.map((x) => x.trim())
|
|
3788
|
+
.filter(Boolean);
|
|
3789
|
+
const day = (flag) =>
|
|
3790
|
+
values[flag] === undefined ? undefined : /^none$/i.test(values[flag]) ? "none" : dueDate(values[flag]);
|
|
3791
|
+
if (values.new) {
|
|
3792
|
+
if (values.kr && (values.at !== undefined || values.counts === undefined) && values.target === undefined)
|
|
3793
|
+
throw new HarborError("A new number key result needs --target N (or --counts WORK for work).");
|
|
3794
|
+
return call(
|
|
3795
|
+
"set_goal",
|
|
3796
|
+
{
|
|
3797
|
+
harbor,
|
|
3798
|
+
portfolio: values.portfolio,
|
|
3799
|
+
title: ref,
|
|
3800
|
+
owner: values.owner,
|
|
3801
|
+
due: day("due") === "none" ? undefined : day("due"),
|
|
3802
|
+
why: values.why,
|
|
3803
|
+
keyResults: values.kr
|
|
3804
|
+
? [
|
|
3805
|
+
values.counts !== undefined
|
|
3806
|
+
? { title: values.kr, kind: "work", counts: list("counts") }
|
|
3807
|
+
: { title: values.kr, kind: "number", start: num("start"), target: num("target"), unit: values.unit },
|
|
3808
|
+
]
|
|
3809
|
+
: [],
|
|
3810
|
+
},
|
|
3811
|
+
(r) => `Set on ${r.goal.portfolio}: ${r.goal.title} (${r.goal.progress}).${r.goal.keyResults.length ? "" : ` Add a key result with harbor goal "${r.goal.title}" --kr "TITLE" --target N.`}`,
|
|
3812
|
+
);
|
|
3813
|
+
}
|
|
3814
|
+
// The goal as Harbor has it, for its id and key results.
|
|
3815
|
+
const goals = await client.call("list_goals", { harbor, ...(values.portfolio ? { portfolio: values.portfolio } : {}) });
|
|
3816
|
+
const all = (goals.portfolios ?? []).flatMap((pf) => (pf.goals ?? []).map((g) => ({ pf, g })));
|
|
3817
|
+
const want = ref.trim().toLowerCase();
|
|
3818
|
+
const exact = all.filter((x) => x.g.title.trim().toLowerCase() === want || x.g.id === ref);
|
|
3819
|
+
const near = exact.length ? exact : all.filter((x) => x.g.title.toLowerCase().includes(want));
|
|
3820
|
+
if (near.length !== 1)
|
|
3821
|
+
throw new HarborError(
|
|
3822
|
+
near.length
|
|
3823
|
+
? `"${ref}" could be ${near.map((x) => `"${x.g.title}" (${x.pf.name})`).join(" or ")}. Say more of it, or --portfolio.`
|
|
3824
|
+
: `No goal matches "${ref}".${all.length ? ` harbor goals lists them.` : ` Set one with harbor goal "TITLE" --new.`}`,
|
|
3825
|
+
);
|
|
3826
|
+
const { pf, g } = near[0];
|
|
3827
|
+
const edits = ["title", "owner", "due", "starts", "why", "link", "unlink"].some((k) => values[k] !== undefined);
|
|
3828
|
+
if (!values.kr && !edits) {
|
|
3829
|
+
out(asJson ? jsonText({ goal: g, portfolio: pf.name }) : plain(goalText(g, pf.name)));
|
|
3830
|
+
return 0;
|
|
3831
|
+
}
|
|
3832
|
+
if (!values.kr && ["at", "target", "start", "unit", "counts"].some((k) => values[k] !== undefined))
|
|
3833
|
+
throw new HarborError('Say which key result with --kr "TITLE".');
|
|
3834
|
+
const results = [];
|
|
3835
|
+
if (edits)
|
|
3836
|
+
results.push(
|
|
3837
|
+
await client.call("edit_goal", {
|
|
3838
|
+
harbor,
|
|
3839
|
+
goal: g.id,
|
|
3840
|
+
title: values.title,
|
|
3841
|
+
owner: values.owner,
|
|
3842
|
+
due: day("due"),
|
|
3843
|
+
starts: day("starts"),
|
|
3844
|
+
why: values.why,
|
|
3845
|
+
link: list("link"),
|
|
3846
|
+
unlink: list("unlink"),
|
|
3847
|
+
}),
|
|
3848
|
+
);
|
|
3849
|
+
if (values.kr) {
|
|
3850
|
+
const kr = g.keyResults.find((k) => k.title.trim().toLowerCase() === values.kr.trim().toLowerCase());
|
|
3851
|
+
const shape = ["target", "start", "unit"].some((k) => values[k] !== undefined);
|
|
3852
|
+
if (!kr) {
|
|
3853
|
+
if (values.at !== undefined && !shape)
|
|
3854
|
+
throw new HarborError(
|
|
3855
|
+
`"${g.title}" has no key result "${values.kr}"${g.keyResults.length ? ` (it has ${g.keyResults.map((k) => `"${k.title}"`).join(", ")})` : ""}. --target N adds one.`,
|
|
3856
|
+
);
|
|
3857
|
+
if (values.counts === undefined && values.target === undefined)
|
|
3858
|
+
throw new HarborError("A new key result needs --target N for a number, or --counts WORK to count work.");
|
|
3859
|
+
results.push(
|
|
3860
|
+
await client.call("set_goal", {
|
|
3861
|
+
harbor,
|
|
3862
|
+
portfolio: pf.name,
|
|
3863
|
+
goal: g.id,
|
|
3864
|
+
keyResults: [
|
|
3865
|
+
values.counts !== undefined
|
|
3866
|
+
? { title: values.kr, kind: "work", counts: list("counts") }
|
|
3867
|
+
: { title: values.kr, kind: "number", start: num("start"), target: num("target"), unit: values.unit },
|
|
3868
|
+
],
|
|
3869
|
+
}),
|
|
3870
|
+
);
|
|
3871
|
+
} else {
|
|
3872
|
+
if (shape || values.counts !== undefined)
|
|
3873
|
+
results.push(
|
|
3874
|
+
await client.call("edit_goal", {
|
|
3875
|
+
harbor,
|
|
3876
|
+
goal: g.id,
|
|
3877
|
+
keyResult: { ref: kr.id, start: num("start"), target: num("target"), unit: values.unit },
|
|
3878
|
+
...(values.counts !== undefined ? { link: list("counts") } : {}),
|
|
3879
|
+
}),
|
|
3880
|
+
);
|
|
3881
|
+
if (values.at !== undefined) {
|
|
3882
|
+
if (kr.kind !== "number")
|
|
3883
|
+
throw new HarborError(`"${kr.title}" counts work, so it moves as its tasks finish.`);
|
|
3884
|
+
results.push(
|
|
3885
|
+
await client.call("update_key_result", { harbor, goal: g.id, keyResult: kr.id, value: num("at") }),
|
|
3886
|
+
);
|
|
3887
|
+
}
|
|
3888
|
+
if (values.at === undefined && !shape && values.counts === undefined)
|
|
3889
|
+
throw new HarborError(`Say what to do with "${kr.title}": --at N, or --target N to change its numbers.`);
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
const last = results.at(-1);
|
|
3893
|
+
const goal = last.goal;
|
|
3894
|
+
if (asJson) out(jsonText(last));
|
|
3895
|
+
else
|
|
3896
|
+
out(
|
|
3897
|
+
plain(
|
|
3898
|
+
[
|
|
3899
|
+
`${goal.title}: ${goal.progress}${goal.owner !== undefined ? ` · ${goal.owner ?? "nobody"}` : ""}${goal.due ? ` · by ${goal.due}` : ""}.`,
|
|
3900
|
+
...(last.keyResult
|
|
3901
|
+
? [` ${last.keyResult.title} · ${last.keyResult.text}`]
|
|
3902
|
+
: (goal.keyResults ?? []).map((k) => ` ${k.title} · ${k.text}`)),
|
|
3903
|
+
].join("\n"),
|
|
3904
|
+
),
|
|
3905
|
+
);
|
|
3906
|
+
return 0;
|
|
3907
|
+
}
|
|
3908
|
+
case "reports": {
|
|
3909
|
+
const { positionals, harbor } = parse(spec());
|
|
3910
|
+
const dashboard = positionals.join(" ").trim();
|
|
3911
|
+
return call("list_dashboards", { harbor, ...(dashboard ? { dashboard } : {}) }, reportsText);
|
|
3912
|
+
}
|
|
3517
3913
|
case "goals": {
|
|
3518
3914
|
const { positionals, harbor } = parse(spec());
|
|
3519
3915
|
const portfolio = positionals.join(" ").trim();
|
|
@@ -3610,13 +4006,25 @@ export async function run(argv, io = {}) {
|
|
|
3610
4006
|
return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
|
|
3611
4007
|
}
|
|
3612
4008
|
case "messages": {
|
|
3613
|
-
const { positionals, values, harbor } = parse(spec([], ["limit"]));
|
|
4009
|
+
const { positionals, values, harbor } = parse(spec([], ["limit", "edit", "message"]));
|
|
3614
4010
|
const [project, ...words] = positionals;
|
|
3615
4011
|
if (!project)
|
|
3616
4012
|
throw new HarborError(
|
|
3617
4013
|
'Give a project, and a message if you\'re writing one: harbor messages "NAME" "TEXT".',
|
|
3618
4014
|
);
|
|
3619
4015
|
const body = words.join(" ").trim();
|
|
4016
|
+
if (values.edit !== undefined || values.message !== undefined) {
|
|
4017
|
+
const edit = (values.edit ?? "").trim() || body;
|
|
4018
|
+
if (!edit) throw new HarborError('Say the new words: harbor messages "NAME" --edit "TEXT".');
|
|
4019
|
+
if (values.edit && body) throw new HarborError("Give the new words once, after --edit.");
|
|
4020
|
+
if (values.limit) throw new HarborError("--limit is for reading; leave it off when you edit.");
|
|
4021
|
+
return call(
|
|
4022
|
+
"edit_message",
|
|
4023
|
+
{ harbor, project, body: edit, ...(values.message ? { message: values.message } : {}) },
|
|
4024
|
+
(r) =>
|
|
4025
|
+
`Changed your message in ${r.edited.project}${r.mentioned?.length ? `; told ${r.mentioned.join(", ")}` : ""}.`,
|
|
4026
|
+
);
|
|
4027
|
+
}
|
|
3620
4028
|
if (body) {
|
|
3621
4029
|
if (values.limit)
|
|
3622
4030
|
throw new HarborError("--limit is for reading; leave it off when you write.");
|