@harborppm/cli 0.97.0 → 0.99.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 +12 -4
- package/dist/app.mjs +387 -62
- package/lib/cli.mjs +581 -85
- package/lib/format.mjs +125 -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,
|
|
@@ -35,12 +35,17 @@ import {
|
|
|
35
35
|
notesText,
|
|
36
36
|
messagesText,
|
|
37
37
|
charterText,
|
|
38
|
+
landfallText,
|
|
38
39
|
daysText,
|
|
39
40
|
soundingText,
|
|
40
41
|
lifeboatsText,
|
|
41
42
|
followedText,
|
|
42
43
|
soundingsText,
|
|
43
44
|
goalsText,
|
|
45
|
+
goalText,
|
|
46
|
+
milestonesText,
|
|
47
|
+
milestoneLine,
|
|
48
|
+
reportsText,
|
|
44
49
|
templatesText,
|
|
45
50
|
approvalsText,
|
|
46
51
|
thanksText,
|
|
@@ -171,7 +176,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
171
176
|
[--also "NAME, NAME"] [--due DATE|pilot] [--starts DATE]
|
|
172
177
|
[--priority low|medium|high|urgent]
|
|
173
178
|
[--status S] [--comment TEXT] [--over-limit] [--dry-run]
|
|
174
|
-
[--
|
|
179
|
+
[--unless-exists] [--literal] [--template NAME]
|
|
180
|
+
[--parent TASK]
|
|
175
181
|
[--milestone NAME]
|
|
176
182
|
reads plain words in TITLE as the
|
|
177
183
|
website does: @name, by friday,
|
|
@@ -258,11 +264,19 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
258
264
|
harbor messages PROJECT ["TEXT"] [--limit N]
|
|
259
265
|
a project's messages, or write
|
|
260
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
|
|
261
270
|
harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
|
|
262
271
|
[--calls NAME]
|
|
263
272
|
a project's five-line charter
|
|
264
273
|
(why, in, out, done when, who
|
|
265
274
|
makes the calls): read or set
|
|
275
|
+
harbor landfall PROJECT [--expect T] [--after 30|90|180] [--measure T]
|
|
276
|
+
[--answer yes|partly|no] [--value N] [--note T]
|
|
277
|
+
did a Done project pay off? say
|
|
278
|
+
what should be true, and answer
|
|
279
|
+
on the day
|
|
266
280
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
267
281
|
a note for later: sealed until
|
|
268
282
|
its day, milestone, or Off track
|
|
@@ -274,6 +288,23 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
274
288
|
the ones waiting on your answer
|
|
275
289
|
harbor goals [PORTFOLIO] each portfolio's goals, how far
|
|
276
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
|
|
277
308
|
harbor templates [PROJECT] the task templates new tasks
|
|
278
309
|
start from (harbor add --template)
|
|
279
310
|
harbor template TASK [--name NAME] save a task as a template in its
|
|
@@ -320,8 +351,10 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
320
351
|
|
|
321
352
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
322
353
|
harbor (or set one with harbor use). harbor help COMMAND shows just that one.
|
|
323
|
-
Statuses: backlog, todo, doing, review, done
|
|
324
|
-
"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.
|
|
325
358
|
DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
|
|
326
359
|
Changes show in Harbor's History as yours, "via Harbor CLI". These commands
|
|
327
360
|
never delete; the full-screen app can, as the website can, and asks first,
|
|
@@ -609,6 +642,16 @@ function dueWords(v) {
|
|
|
609
642
|
return /^\d{4}-\d{2}-\d{2}$/.test(dueDate(v));
|
|
610
643
|
}
|
|
611
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
|
+
|
|
612
655
|
/** --due pilot: the date the owner's queue suggests, set by Harbor. */
|
|
613
656
|
const PILOT = new Set(["pilot", "suggested"]);
|
|
614
657
|
const duePilot = (v) => (v !== undefined && PILOT.has(v.toLowerCase()) ? true : undefined);
|
|
@@ -780,6 +823,20 @@ const DETAIL = {
|
|
|
780
823
|
" harbor add --project P < titles.txt writes: one task a line of stdin",
|
|
781
824
|
" harbor add --project P --dry-run reads: says what it would add, and",
|
|
782
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.",
|
|
783
840
|
"",
|
|
784
841
|
" --project can be left out when the harbor has one open project (First",
|
|
785
842
|
" voyage aside); the task goes there.",
|
|
@@ -788,11 +845,17 @@ const DETAIL = {
|
|
|
788
845
|
" venue @Quinn by friday !high #Website\" adds \"Book the venue\" for",
|
|
789
846
|
" Quinn, due Friday, high priority, in Website. Harbor says what it read.",
|
|
790
847
|
" A flag wins over the words (they stay in the title), and --literal keeps",
|
|
791
|
-
" 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.",
|
|
792
853
|
"",
|
|
793
854
|
" --assignee me|none|NAME, --also \"NAME, NAME\" (others on it with the",
|
|
794
855
|
" owner), --due DATE|pilot, --priority low|medium|high|urgent,",
|
|
795
|
-
" --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",
|
|
796
859
|
" ten lines with --comment add the same comment to all ten. More than 100",
|
|
797
860
|
" lines is harbor import's job.",
|
|
798
861
|
"",
|
|
@@ -1025,6 +1088,24 @@ const DETAIL = {
|
|
|
1025
1088
|
" --no-people. A task already in its project is left alone: by its key",
|
|
1026
1089
|
" (a Key column, as harbor export writes) or by title.",
|
|
1027
1090
|
],
|
|
1091
|
+
landfall: [
|
|
1092
|
+
" harbor landfall PROJECT reads its landfall: what should be",
|
|
1093
|
+
" true some days after it's Done,",
|
|
1094
|
+
" when Harbor asks, and the answer",
|
|
1095
|
+
" Say what landfall looks like:",
|
|
1096
|
+
' --expect "TEXT" what should be true by then',
|
|
1097
|
+
" --after 30|90|180 days after the project is marked Done to look",
|
|
1098
|
+
" (90 unless you say)",
|
|
1099
|
+
' --measure "TEXT" what the number counts, if there is one',
|
|
1100
|
+
" Answer it (on its day, or sooner):",
|
|
1101
|
+
" --answer yes|partly|no",
|
|
1102
|
+
" --value N the number on the day",
|
|
1103
|
+
' --note "TEXT" what you found',
|
|
1104
|
+
" --clear takes the landfall off (with --answer, only the answer), so it",
|
|
1105
|
+
" asks again. The owner is asked in their Inbox on the day; the portfolio",
|
|
1106
|
+
" counts the voyages that paid off. PROJECT is a name, a key like RR, or",
|
|
1107
|
+
" an id.",
|
|
1108
|
+
],
|
|
1028
1109
|
charter: [
|
|
1029
1110
|
" harbor charter PROJECT reads its charter, and any open task",
|
|
1030
1111
|
" that names something it leaves out",
|
|
@@ -1111,6 +1192,62 @@ const DETAIL = {
|
|
|
1111
1192
|
" left out), off track first, with how far",
|
|
1112
1193
|
" along each key result is",
|
|
1113
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
|
+
],
|
|
1114
1251
|
soundings: [
|
|
1115
1252
|
" harbor soundings [PROJECT] reads: open tasks with a sounding",
|
|
1116
1253
|
" (all projects when left out), and the",
|
|
@@ -1314,6 +1451,10 @@ const DETAIL = {
|
|
|
1314
1451
|
" One conversation for the whole project, for what isn't about one task;",
|
|
1315
1452
|
" harbor comment TASK is for that. @Name sends it to that person's Inbox.",
|
|
1316
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.",
|
|
1317
1458
|
],
|
|
1318
1459
|
voyage: [
|
|
1319
1460
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
@@ -1517,8 +1658,8 @@ const OTHER_NAMES = {
|
|
|
1517
1658
|
longpole: "harbor why PROJECT shows what's holding it up.",
|
|
1518
1659
|
"long-pole": "harbor why PROJECT shows what's holding it up.",
|
|
1519
1660
|
pilot: "harbor show TASK says the date its owner's queue suggests; --due pilot on add or edit takes it.",
|
|
1520
|
-
|
|
1521
|
-
|
|
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.",
|
|
1522
1663
|
decide: 'harbor decision "TITLE" --project P logs a decision.',
|
|
1523
1664
|
dock: "harbor try PROJECT is the dry dock.",
|
|
1524
1665
|
"dry-dock": "harbor try PROJECT is the dry dock.",
|
|
@@ -2285,16 +2426,21 @@ export async function run(argv, io = {}) {
|
|
|
2285
2426
|
query: positionals.join(" ") || undefined,
|
|
2286
2427
|
project: values.project,
|
|
2287
2428
|
assignee: values.assignee,
|
|
2288
|
-
status: values.status
|
|
2289
|
-
? values.status
|
|
2290
|
-
.split(",")
|
|
2291
|
-
.map((x) => x.trim())
|
|
2292
|
-
.filter(Boolean)
|
|
2293
|
-
.map(statusWord)
|
|
2294
|
-
: undefined,
|
|
2429
|
+
status: values.status ? statusList(values.status) : undefined,
|
|
2295
2430
|
overdue: values.late || undefined,
|
|
2296
2431
|
};
|
|
2297
|
-
|
|
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
|
+
);
|
|
2298
2444
|
// --all pages through every match, PAGE at a time (search_tasks' most).
|
|
2299
2445
|
const PAGE = 50;
|
|
2300
2446
|
const tasks = [];
|
|
@@ -2371,7 +2517,7 @@ export async function run(argv, io = {}) {
|
|
|
2371
2517
|
"",
|
|
2372
2518
|
"Waits on:",
|
|
2373
2519
|
...t.waitingOn.map(
|
|
2374
|
-
(w) => ` ${w.key ?? w.id} ${w.title} (${
|
|
2520
|
+
(w) => ` ${w.key ?? w.id} ${w.title} (${statusText(w)})`,
|
|
2375
2521
|
),
|
|
2376
2522
|
);
|
|
2377
2523
|
if (t.waitedOnBy.length)
|
|
@@ -2379,7 +2525,7 @@ export async function run(argv, io = {}) {
|
|
|
2379
2525
|
"",
|
|
2380
2526
|
"Holds up:",
|
|
2381
2527
|
...t.waitedOnBy.map(
|
|
2382
|
-
(w) => ` ${w.key ?? w.id} ${w.title} (${
|
|
2528
|
+
(w) => ` ${w.key ?? w.id} ${w.title} (${statusText(w)})`,
|
|
2383
2529
|
),
|
|
2384
2530
|
);
|
|
2385
2531
|
if (t.files?.length) lines.push("", "Files:", ...t.files.map((f) => ` ${fileText(f)}`));
|
|
@@ -2600,7 +2746,7 @@ export async function run(argv, io = {}) {
|
|
|
2600
2746
|
case "add": {
|
|
2601
2747
|
const { positionals, values, harbor } = parse(
|
|
2602
2748
|
spec(
|
|
2603
|
-
["over-limit", "dry-run", "literal"],
|
|
2749
|
+
["over-limit", "dry-run", "literal", "unless-exists"],
|
|
2604
2750
|
["project", "assignee", "also", "due", "starts", "priority", "status", "comment", "template", "parent", "milestone"],
|
|
2605
2751
|
),
|
|
2606
2752
|
);
|
|
@@ -2638,82 +2784,134 @@ export async function run(argv, io = {}) {
|
|
|
2638
2784
|
if (!values.project && !reads.every((r) => r.project))
|
|
2639
2785
|
values.project = await onlyProject(client, harbor, "--project NAME or #NAME in the title");
|
|
2640
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
|
+
});
|
|
2641
2805
|
if (values["dry-run"]) {
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
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))];
|
|
2656
2859
|
out(
|
|
2657
2860
|
jsonText({
|
|
2658
2861
|
dryRun: true,
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
read: reads.map((r) => ({
|
|
2664
|
-
project: projectOf(r),
|
|
2665
|
-
...(r.owner ? { assignee: r.owner.value } : {}),
|
|
2666
|
-
...(r.due ? { due: r.due.value } : {}),
|
|
2667
|
-
...(r.priority ? { priority: r.priority.value } : {}),
|
|
2668
|
-
})),
|
|
2669
|
-
}
|
|
2670
|
-
: {}),
|
|
2671
|
-
...(pilot
|
|
2672
|
-
? { due: pilot.map((d) => d.earliest) }
|
|
2673
|
-
: values.due
|
|
2674
|
-
? { due: titles.map(() => dueDate(values.due)) }
|
|
2675
|
-
: {}),
|
|
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,
|
|
2676
2866
|
}),
|
|
2677
2867
|
);
|
|
2678
|
-
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;
|
|
2679
2899
|
out(
|
|
2680
2900
|
plain(
|
|
2681
2901
|
[
|
|
2682
|
-
`Would add ${n}
|
|
2683
|
-
...
|
|
2684
|
-
|
|
2685
|
-
...(wordsLine(r) ? [` ${wordsLine(r)}`] : []),
|
|
2686
|
-
]),
|
|
2687
|
-
...(pilot
|
|
2688
|
-
? [
|
|
2689
|
-
`--due pilot: ${pilot[0].says}; ${pilot[0].why.map((w) => w.charAt(0).toLowerCase() + w.slice(1)).join("; ")}.`,
|
|
2690
|
-
]
|
|
2691
|
-
: []),
|
|
2902
|
+
`Would add ${n(would)}${skipped ? `, skip ${skipped}` : ""}${refused ? `; ${refused} would be refused` : ""}. Nothing changed.`,
|
|
2903
|
+
...body,
|
|
2904
|
+
...(pilot ? [`--due pilot: ${pilot.says}`] : []),
|
|
2692
2905
|
].join("\n"),
|
|
2693
2906
|
),
|
|
2694
2907
|
);
|
|
2695
|
-
|
|
2908
|
+
}
|
|
2909
|
+
return refused ? 1 : 0;
|
|
2696
2910
|
}
|
|
2697
2911
|
const added = [];
|
|
2698
2912
|
for (const words of reads) {
|
|
2699
2913
|
const r = await client
|
|
2700
|
-
.call("create_task", {
|
|
2701
|
-
harbor,
|
|
2702
|
-
project: projectOf(words),
|
|
2703
|
-
title: words.title || undefined,
|
|
2704
|
-
template: values.template,
|
|
2705
|
-
// Yours unless you say otherwise, as with n in the app (a template keeps its own owner).
|
|
2706
|
-
assignee: values.assignee ?? words.owner?.value ?? (values.template ? undefined : "me"),
|
|
2707
|
-
also: values.also === undefined ? undefined : alsoList(values.also),
|
|
2708
|
-
due: duePilot(values.due) ? undefined : (dueDate(values.due) ?? words.due?.value),
|
|
2709
|
-
suggestedDue: duePilot(values.due),
|
|
2710
|
-
starts: dueDate(values.starts),
|
|
2711
|
-
priority: values.priority ?? words.priority?.value,
|
|
2712
|
-
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
2713
|
-
parent: values.parent,
|
|
2714
|
-
milestone: values.milestone,
|
|
2715
|
-
overLimit: values["over-limit"] || undefined,
|
|
2716
|
-
})
|
|
2914
|
+
.call("create_task", { harbor, ...argsFor(words) })
|
|
2717
2915
|
.catch((err) => {
|
|
2718
2916
|
if (err instanceof HarborError) {
|
|
2719
2917
|
const done = added.length ? ` (added ${added.length} before it)` : "";
|
|
@@ -2721,6 +2919,11 @@ export async function run(argv, io = {}) {
|
|
|
2721
2919
|
}
|
|
2722
2920
|
throw err;
|
|
2723
2921
|
});
|
|
2922
|
+
// --unless-exists found it open already: nothing added, no comment.
|
|
2923
|
+
if (r.existing) {
|
|
2924
|
+
added.push(r);
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2724
2927
|
if (values.comment)
|
|
2725
2928
|
r.comment = (
|
|
2726
2929
|
await client.call("add_comment", {
|
|
@@ -2734,15 +2937,28 @@ export async function run(argv, io = {}) {
|
|
|
2734
2937
|
added.push(r);
|
|
2735
2938
|
}
|
|
2736
2939
|
await stalePromptCache(env);
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
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
|
+
);
|
|
2740
2954
|
else
|
|
2741
2955
|
out(
|
|
2742
2956
|
plain(
|
|
2743
2957
|
added
|
|
2744
2958
|
.map((r) =>
|
|
2745
|
-
|
|
2959
|
+
r.existing
|
|
2960
|
+
? `Already there, not added again: ${taskLine(r.existing, opts)}`
|
|
2961
|
+
: [
|
|
2746
2962
|
`Added: ${taskLine(r.created, opts)}${r.comment ? ", with your comment" : ""}`,
|
|
2747
2963
|
// What the title's words said, so nothing is read without being seen.
|
|
2748
2964
|
...(r.read ? [`${r.read} --literal keeps the words in the title.`] : []),
|
|
@@ -2752,7 +2968,7 @@ export async function run(argv, io = {}) {
|
|
|
2752
2968
|
...(values.due ? pilotLines(r, r.created) : []),
|
|
2753
2969
|
...(r.created.milestone ? [`Toward the milestone ${r.created.milestone}.`] : []),
|
|
2754
2970
|
...(r.milestoneReopened ? [`${r.milestoneReopened} is open again: it has an open task now.`] : []),
|
|
2755
|
-
|
|
2971
|
+
].join("\n"),
|
|
2756
2972
|
)
|
|
2757
2973
|
.join("\n"),
|
|
2758
2974
|
),
|
|
@@ -3286,6 +3502,70 @@ export async function run(argv, io = {}) {
|
|
|
3286
3502
|
: `Cleared ${r.project}'s charter.`,
|
|
3287
3503
|
);
|
|
3288
3504
|
}
|
|
3505
|
+
case "landfall": {
|
|
3506
|
+
const { positionals, values, harbor } = parse(
|
|
3507
|
+
spec(["clear"], ["expect", "after", "measure", "answer", "value", "note"]),
|
|
3508
|
+
);
|
|
3509
|
+
const project = one(positionals, 'project: harbor landfall "NAME"');
|
|
3510
|
+
const answering =
|
|
3511
|
+
values.answer !== undefined || values.value !== undefined || values.note !== undefined;
|
|
3512
|
+
const setting =
|
|
3513
|
+
values.expect !== undefined || values.after !== undefined || values.measure !== undefined;
|
|
3514
|
+
if (answering && setting) {
|
|
3515
|
+
throw new HarborError("Set the landfall or answer it, one at a time.");
|
|
3516
|
+
}
|
|
3517
|
+
const show = (r) => landfallText(r.project ?? r.name, r.landfall);
|
|
3518
|
+
const json = (r) => ({ project: r.project ?? r.name, landfall: r.landfall ?? null });
|
|
3519
|
+
if (answering || (values.clear && values.answer !== undefined)) {
|
|
3520
|
+
if (values.clear) {
|
|
3521
|
+
return call("answer_landfall", { harbor, project, clear: true }, show, json);
|
|
3522
|
+
}
|
|
3523
|
+
const result = String(values.answer ?? "").toLowerCase();
|
|
3524
|
+
if (!["yes", "partly", "no"].includes(result)) {
|
|
3525
|
+
throw new HarborError("Say --answer yes, partly or no.");
|
|
3526
|
+
}
|
|
3527
|
+
let value;
|
|
3528
|
+
if (values.value !== undefined) {
|
|
3529
|
+
value = Number(String(values.value).replace(/,/g, ""));
|
|
3530
|
+
if (!Number.isFinite(value)) throw new HarborError("--value takes a number, like 120.");
|
|
3531
|
+
}
|
|
3532
|
+
return call(
|
|
3533
|
+
"answer_landfall",
|
|
3534
|
+
{
|
|
3535
|
+
harbor,
|
|
3536
|
+
project,
|
|
3537
|
+
result,
|
|
3538
|
+
...(value !== undefined ? { value } : {}),
|
|
3539
|
+
...(values.note !== undefined ? { note: values.note } : {}),
|
|
3540
|
+
},
|
|
3541
|
+
show,
|
|
3542
|
+
json,
|
|
3543
|
+
);
|
|
3544
|
+
}
|
|
3545
|
+
if (values.clear) return call("set_landfall", { harbor, project, clear: true }, show, json);
|
|
3546
|
+
if (setting) {
|
|
3547
|
+
let afterDays;
|
|
3548
|
+
if (values.after !== undefined) {
|
|
3549
|
+
afterDays = Number(String(values.after).replace(/\s*days?$/i, ""));
|
|
3550
|
+
if (![30, 90, 180].includes(afterDays)) {
|
|
3551
|
+
throw new HarborError("--after takes 30, 90 or 180 (days after the project is Done).");
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
return call(
|
|
3555
|
+
"set_landfall",
|
|
3556
|
+
{
|
|
3557
|
+
harbor,
|
|
3558
|
+
project,
|
|
3559
|
+
...(values.expect !== undefined ? { expect: values.expect } : {}),
|
|
3560
|
+
...(afterDays !== undefined ? { afterDays } : {}),
|
|
3561
|
+
...(values.measure !== undefined ? { measure: values.measure } : {}),
|
|
3562
|
+
},
|
|
3563
|
+
show,
|
|
3564
|
+
json,
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
return call("get_project", { harbor, project }, show, json);
|
|
3568
|
+
}
|
|
3289
3569
|
case "sound": {
|
|
3290
3570
|
const { positionals, values, harbor } = parse(spec([], ["answer", "why", "ask", "settle"]));
|
|
3291
3571
|
const [task, ...extra] = positionals;
|
|
@@ -3426,6 +3706,210 @@ export async function run(argv, io = {}) {
|
|
|
3426
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.`,
|
|
3427
3707
|
);
|
|
3428
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
|
+
}
|
|
3429
3913
|
case "goals": {
|
|
3430
3914
|
const { positionals, harbor } = parse(spec());
|
|
3431
3915
|
const portfolio = positionals.join(" ").trim();
|
|
@@ -3522,13 +4006,25 @@ export async function run(argv, io = {}) {
|
|
|
3522
4006
|
return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
|
|
3523
4007
|
}
|
|
3524
4008
|
case "messages": {
|
|
3525
|
-
const { positionals, values, harbor } = parse(spec([], ["limit"]));
|
|
4009
|
+
const { positionals, values, harbor } = parse(spec([], ["limit", "edit", "message"]));
|
|
3526
4010
|
const [project, ...words] = positionals;
|
|
3527
4011
|
if (!project)
|
|
3528
4012
|
throw new HarborError(
|
|
3529
4013
|
'Give a project, and a message if you\'re writing one: harbor messages "NAME" "TEXT".',
|
|
3530
4014
|
);
|
|
3531
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
|
+
}
|
|
3532
4028
|
if (body) {
|
|
3533
4029
|
if (values.limit)
|
|
3534
4030
|
throw new HarborError("--limit is for reading; leave it off when you write.");
|