@harborppm/cli 0.92.0 → 0.94.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 +2 -1
- package/dist/app.mjs +178 -5
- package/lib/cli.mjs +35 -0
- package/lib/format.mjs +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,6 +213,7 @@ harbor import FILE [--project P] [--portfolio P] [--no-people]
|
|
|
213
213
|
add tasks from a CSV file
|
|
214
214
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
215
215
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
216
|
+
harbor messages PROJECT ["TEXT"] [--limit N]
|
|
216
217
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
217
218
|
harbor sound TASK [--answer DAYS|--ask NAMES|--settle DAYS] [--why TEXT]
|
|
218
219
|
harbor soundings [PROJECT]
|
|
@@ -258,7 +259,7 @@ A task has one owner (`--assignee`), who answers for it and whose load it counts
|
|
|
258
259
|
|
|
259
260
|
`harbor limit RR` prints how many tasks each of a board's columns takes at once; `harbor limit RR doing 8` changes one (1 to 20, or `none` for no limit), if you own the harbor. `harbor projects --json` carries each board's `limits` too.
|
|
260
261
|
|
|
261
|
-
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK --answer 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first. `harbor goals` lists each portfolio's goals (set on the portfolio's page on the website), off track first, with each key result's progress. `harbor ashore` lists the public holidays and time away in the next eight weeks; `--country US,GB` sets whose holidays the harbor takes and `--person NAME --country DE` one person's own, which the forecast and the weekly load then count as days off.
|
|
262
|
+
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours. `harbor messages PROJECT` reads a project's Messages tab, one conversation for the whole project, and `harbor messages PROJECT "TEXT"` writes to it; `@Name` sends it to that person's Inbox. `harbor note` leaves a note for later on a project: it stays sealed (the crew sees only that it's there and who left it) until its day, until one of the program's milestones is reached, or until the project turns Off track, and then it opens at the top of the project and in the brief. `harbor sound TASK --ask crew` takes a sounding: it asks you, the task's owner and whoever has open work on its project how many working days the task will take (`--ask "Sam,Priya"` picks who). Each person answers alone with `harbor sound TASK --answer 4 --why "…"`, and nobody sees the others' numbers until everyone has; then `harbor sound TASK` shows the spread, an estimate when the guesses are close, or "talk about this first" when they're far apart, and `--settle DAYS` records what you agreed. `harbor soundings` lists them, the ones waiting on your answer first. `harbor goals` lists each portfolio's goals (set on the portfolio's page on the website), off track first, with each key result's progress. `harbor ashore` lists the public holidays and time away in the next eight weeks; `--country US,GB` sets whose holidays the harbor takes and `--person NAME --country DE` one person's own, which the forecast and the weekly load then count as days off.
|
|
262
263
|
|
|
263
264
|
`harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux, which you may need to install). With `--json` it prints the forecast and stays quiet.
|
|
264
265
|
|
package/dist/app.mjs
CHANGED
|
@@ -2879,9 +2879,10 @@ function tideForecast(project, tasks, now, opts = {}) {
|
|
|
2879
2879
|
};
|
|
2880
2880
|
const weekly = weeklyFinished(tasks, project.startDate, now);
|
|
2881
2881
|
const finished = weekly.reduce((a, b) => a + b, 0);
|
|
2882
|
-
if (finished
|
|
2882
|
+
if (finished < 3) return {
|
|
2883
2883
|
kind: "no-pace",
|
|
2884
|
-
remaining
|
|
2884
|
+
remaining,
|
|
2885
|
+
finished
|
|
2885
2886
|
};
|
|
2886
2887
|
const today = todayISO(now);
|
|
2887
2888
|
const base = noon$3(today);
|
|
@@ -2947,11 +2948,11 @@ function tideQuiet(tide) {
|
|
|
2947
2948
|
case "empty": return "No tasks yet, so there's nothing to forecast.";
|
|
2948
2949
|
case "done": return "Every task is done.";
|
|
2949
2950
|
case "paused": return "Paused, so its pace says nothing about when it ends.";
|
|
2950
|
-
case "no-pace": return `Nothing finished in the last ${6} weeks, so there's no pace to forecast from yet.`;
|
|
2951
|
+
case "no-pace": return tide.finished === 0 ? `Nothing finished in the last ${6} weeks, so there's no pace to forecast from yet.` : `${tide.finished === 1 ? "One task has" : `${tide.finished} tasks have`} finished so far. Dates come once ${3} have, so they rest on a real pace.`;
|
|
2951
2952
|
default: return null;
|
|
2952
2953
|
}
|
|
2953
2954
|
}
|
|
2954
|
-
var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, noon$3;
|
|
2955
|
+
var TIDE_WEEKS, TRIALS, MAX_WEEKS, ROUGH_BELOW, TIDE_MIN_FINISHED, noon$3;
|
|
2955
2956
|
var init_tide = __esmMin((() => {
|
|
2956
2957
|
init_date_fns();
|
|
2957
2958
|
init_format();
|
|
@@ -2961,6 +2962,7 @@ var init_tide = __esmMin((() => {
|
|
|
2961
2962
|
TRIALS = 1e3;
|
|
2962
2963
|
MAX_WEEKS = 104;
|
|
2963
2964
|
ROUGH_BELOW = 5;
|
|
2965
|
+
TIDE_MIN_FINISHED = 3;
|
|
2964
2966
|
noon$3 = (iso) => /* @__PURE__ */ new Date(`${iso.slice(0, 10)}T12:00:00`);
|
|
2965
2967
|
}));
|
|
2966
2968
|
|
|
@@ -6293,7 +6295,7 @@ function tideLine(data, project, today) {
|
|
|
6293
6295
|
const tasks = tasksOf(data.tasks, project.id);
|
|
6294
6296
|
const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`), tideOptions(data));
|
|
6295
6297
|
if (tide.kind === "no-pace") return {
|
|
6296
|
-
text: "No forecast yet: nothing finished in the last few weeks",
|
|
6298
|
+
text: tide.finished === 0 ? "No forecast yet: nothing finished in the last few weeks" : `No forecast yet: ${tide.finished} finished, dates come at 3`,
|
|
6297
6299
|
late: false
|
|
6298
6300
|
};
|
|
6299
6301
|
if (tide.kind !== "forecast") return null;
|
|
@@ -10003,7 +10005,85 @@ function HistoryView({ active, width, height, go }) {
|
|
|
10003
10005
|
});
|
|
10004
10006
|
}
|
|
10005
10007
|
|
|
10008
|
+
// src/lib/project-messages.ts
|
|
10009
|
+
/** This project's messages, oldest first. */
|
|
10010
|
+
function messagesOf(reflections, projectId) {
|
|
10011
|
+
return (reflections ?? []).filter((r) => r.projectId === projectId && isMessage(r)).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
10012
|
+
}
|
|
10013
|
+
/** Why a message can't be posted, or null. */
|
|
10014
|
+
function messageProblem(body) {
|
|
10015
|
+
const t = body.trim();
|
|
10016
|
+
if (!t) return "Write something first.";
|
|
10017
|
+
if (t.length > 4e3) return `Keep it under ${MESSAGE_MAX.toLocaleString("en")} characters.`;
|
|
10018
|
+
return null;
|
|
10019
|
+
}
|
|
10020
|
+
/** Who a message @mentions, leaving out its writer. */
|
|
10021
|
+
function messageMentions(body, people, authorId) {
|
|
10022
|
+
return parseMentions(body, people).filter((id) => id !== authorId);
|
|
10023
|
+
}
|
|
10024
|
+
/** Whether someone may change or delete a message: its writer, or the harbor's owner (delete only). */
|
|
10025
|
+
function mayChangeMessage(message, personId, role, action) {
|
|
10026
|
+
if (message.authorId === personId) return true;
|
|
10027
|
+
return action === "delete" && role === "owner";
|
|
10028
|
+
}
|
|
10029
|
+
/**
|
|
10030
|
+
* What a person's Inbox says about project messages since `since`: each
|
|
10031
|
+
* message that @mentions them, and for each project they've written in, the
|
|
10032
|
+
* messages others posted after their last one (a mention among them is
|
|
10033
|
+
* already its own line). A finished project's replies are left out, but a
|
|
10034
|
+
* mention there still reaches you: a wrap-up "thanks @Dana" is meant to.
|
|
10035
|
+
*/
|
|
10036
|
+
function messageLines(data, personId, since) {
|
|
10037
|
+
const done = new Set(data.projects.filter((p) => p.health === "done").map((p) => p.id));
|
|
10038
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
10039
|
+
for (const r of data.reflections ?? []) {
|
|
10040
|
+
if (!isMessage(r)) continue;
|
|
10041
|
+
const list = byProject.get(r.projectId);
|
|
10042
|
+
if (list) list.push(r);
|
|
10043
|
+
else byProject.set(r.projectId, [r]);
|
|
10044
|
+
}
|
|
10045
|
+
const out = [];
|
|
10046
|
+
for (const [projectId, list] of byProject) {
|
|
10047
|
+
const finished = done.has(projectId);
|
|
10048
|
+
list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
10049
|
+
let joined = false;
|
|
10050
|
+
let replies = [];
|
|
10051
|
+
for (const m of list) {
|
|
10052
|
+
if (m.authorId === personId) {
|
|
10053
|
+
joined = true;
|
|
10054
|
+
replies = [];
|
|
10055
|
+
continue;
|
|
10056
|
+
}
|
|
10057
|
+
if (m.createdAt < since) continue;
|
|
10058
|
+
if ((m.mentionedIds ?? []).includes(personId)) {
|
|
10059
|
+
out.push({
|
|
10060
|
+
kind: "mention",
|
|
10061
|
+
message: m,
|
|
10062
|
+
authorIds: [m.authorId]
|
|
10063
|
+
});
|
|
10064
|
+
continue;
|
|
10065
|
+
}
|
|
10066
|
+
if (joined && !finished) replies.push(m);
|
|
10067
|
+
}
|
|
10068
|
+
const latest = replies.at(-1);
|
|
10069
|
+
if (latest) out.push({
|
|
10070
|
+
kind: "reply",
|
|
10071
|
+
message: latest,
|
|
10072
|
+
authorIds: [...new Set(replies.map((m) => m.authorId))]
|
|
10073
|
+
});
|
|
10074
|
+
}
|
|
10075
|
+
return out;
|
|
10076
|
+
}
|
|
10077
|
+
var MESSAGE_MAX, isMessage, messageText;
|
|
10078
|
+
var init_project_messages = __esmMin((() => {
|
|
10079
|
+
init_work();
|
|
10080
|
+
MESSAGE_MAX = 4e3;
|
|
10081
|
+
isMessage = (r) => r.kind === "message";
|
|
10082
|
+
messageText = (body) => body.trim().slice(0, MESSAGE_MAX);
|
|
10083
|
+
}));
|
|
10084
|
+
|
|
10006
10085
|
// src/lib/read-marks.ts
|
|
10086
|
+
init_project_messages();
|
|
10007
10087
|
/**
|
|
10008
10088
|
* Inbox read marks. A harbor keeps one list of them (`readPingIds`), shared
|
|
10009
10089
|
* by everyone in it, so each mark names whose it is: reading a mention that
|
|
@@ -10313,6 +10393,25 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
10313
10393
|
href: `/projects/${task.projectId}`
|
|
10314
10394
|
});
|
|
10315
10395
|
}
|
|
10396
|
+
for (const line of messageLines(data, personId, since)) {
|
|
10397
|
+
const project = data.projects.find((p) => p.id === line.message.projectId);
|
|
10398
|
+
if (!project) continue;
|
|
10399
|
+
const names = line.authorIds.map((id) => {
|
|
10400
|
+
const who = data.people.find((p) => p.id === id);
|
|
10401
|
+
return who ? firstName$3(who.name) : "Someone";
|
|
10402
|
+
});
|
|
10403
|
+
const snippet = excerpt(line.message.body);
|
|
10404
|
+
const where = `in ${project.name}’s messages`;
|
|
10405
|
+
pings.push({
|
|
10406
|
+
id: `message-${line.kind}:${line.message.id}`,
|
|
10407
|
+
kind: line.kind,
|
|
10408
|
+
title: line.kind === "mention" ? `${names[0]} mentioned you` : `${listNames(names)} wrote back`,
|
|
10409
|
+
detail: snippet ? `${where} — ${snippet}` : where,
|
|
10410
|
+
at: line.message.createdAt,
|
|
10411
|
+
personId: line.message.authorId,
|
|
10412
|
+
href: `/projects/${project.id}?view=messages`
|
|
10413
|
+
});
|
|
10414
|
+
}
|
|
10316
10415
|
for (const task of data.tasks) {
|
|
10317
10416
|
if (task.assignedBy !== personId || !task.assigneeId || task.assigneeId === personId) continue;
|
|
10318
10417
|
if (task.status !== "done" || !task.completedAt || task.completedAt < since) continue;
|
|
@@ -21071,6 +21170,7 @@ function approvalsWaitingOn(tasks, personId) {
|
|
|
21071
21170
|
}
|
|
21072
21171
|
|
|
21073
21172
|
// src/lib/mcp/tools.ts
|
|
21173
|
+
init_project_messages();
|
|
21074
21174
|
init_subtasks();
|
|
21075
21175
|
init_thanks();
|
|
21076
21176
|
init_task_keys();
|
|
@@ -22466,6 +22566,36 @@ const HARBOR_TOOLS = [
|
|
|
22466
22566
|
};
|
|
22467
22567
|
}
|
|
22468
22568
|
}),
|
|
22569
|
+
tool({
|
|
22570
|
+
name: "list_messages",
|
|
22571
|
+
title: "A project's messages",
|
|
22572
|
+
description: "The conversation on a project's Messages tab, for what isn't about one task: each message with who wrote it, when, whether it was edited, and who it @mentions, oldest first (the newest `limit`, default 50; more says how many earlier ones were left out). post_message writes one." + DATA_NOTE,
|
|
22573
|
+
input: object({
|
|
22574
|
+
harbor: harborArg,
|
|
22575
|
+
project: string().min(1).max(200).describe("Project id or name."),
|
|
22576
|
+
limit: number().int().min(1).max(500).optional()
|
|
22577
|
+
}),
|
|
22578
|
+
run: (ctx, input) => {
|
|
22579
|
+
const { data } = ctx;
|
|
22580
|
+
const p = findOne(data.projects, input.project, "project");
|
|
22581
|
+
const people = byId(data.people);
|
|
22582
|
+
const all = messagesOf(data.reflections, p.id);
|
|
22583
|
+
const limit = input.limit ?? 50;
|
|
22584
|
+
const shown = all.slice(-limit);
|
|
22585
|
+
return {
|
|
22586
|
+
project: p.name,
|
|
22587
|
+
messages: shown.map((m) => ({
|
|
22588
|
+
id: m.id,
|
|
22589
|
+
from: nameOf$2(people, m.authorId),
|
|
22590
|
+
at: m.createdAt,
|
|
22591
|
+
...m.editedAt ? { edited: m.editedAt } : {},
|
|
22592
|
+
body: m.body,
|
|
22593
|
+
...(m.mentionedIds ?? []).length ? { mentions: (m.mentionedIds ?? []).map((id) => nameOf$2(people, id)) } : {}
|
|
22594
|
+
})),
|
|
22595
|
+
...all.length > shown.length ? { more: all.length - shown.length } : {}
|
|
22596
|
+
};
|
|
22597
|
+
}
|
|
22598
|
+
}),
|
|
22469
22599
|
tool({
|
|
22470
22600
|
name: "list_soundings",
|
|
22471
22601
|
title: "Soundings",
|
|
@@ -25372,6 +25502,48 @@ function milestonesRaidSlice({ get, write }) {
|
|
|
25372
25502
|
write({ reflections: [...s.reflections ?? [], row] });
|
|
25373
25503
|
return row.id;
|
|
25374
25504
|
},
|
|
25505
|
+
postMessage: (projectId, body) => {
|
|
25506
|
+
const s = get();
|
|
25507
|
+
if (messageProblem(body)) return null;
|
|
25508
|
+
const project = s.projects.find((p) => p.id === projectId);
|
|
25509
|
+
if (!project) return null;
|
|
25510
|
+
const me = s.currentUserId;
|
|
25511
|
+
const text = messageText(body);
|
|
25512
|
+
const row = {
|
|
25513
|
+
id: uid("msg"),
|
|
25514
|
+
projectId,
|
|
25515
|
+
kind: "message",
|
|
25516
|
+
authorId: me,
|
|
25517
|
+
body: text,
|
|
25518
|
+
createdAt: stamp(),
|
|
25519
|
+
mentionedIds: messageMentions(text, s.people, me)
|
|
25520
|
+
};
|
|
25521
|
+
write({ reflections: [...s.reflections ?? [], row] });
|
|
25522
|
+
get().log(`wrote in ${project.name}’s messages`, `/projects/${projectId}?view=messages`);
|
|
25523
|
+
return row.id;
|
|
25524
|
+
},
|
|
25525
|
+
editMessage: (id, body) => {
|
|
25526
|
+
const s = get();
|
|
25527
|
+
const all = s.reflections ?? [];
|
|
25528
|
+
const row = all.find((r) => r.id === id && isMessage(r));
|
|
25529
|
+
if (!row || !mayChangeMessage(row, s.currentUserId, s.myRole, "edit")) return;
|
|
25530
|
+
if (messageProblem(body)) return;
|
|
25531
|
+
const text = messageText(body);
|
|
25532
|
+
if (text === row.body) return;
|
|
25533
|
+
write({ reflections: all.map((r) => r.id === id ? {
|
|
25534
|
+
...r,
|
|
25535
|
+
body: text,
|
|
25536
|
+
mentionedIds: messageMentions(text, s.people, r.authorId),
|
|
25537
|
+
editedAt: stamp()
|
|
25538
|
+
} : r) });
|
|
25539
|
+
},
|
|
25540
|
+
deleteMessage: (id) => {
|
|
25541
|
+
const s = get();
|
|
25542
|
+
const all = s.reflections ?? [];
|
|
25543
|
+
const row = all.find((r) => r.id === id && isMessage(r));
|
|
25544
|
+
if (!row || !mayChangeMessage(row, s.currentUserId, s.myRole, "delete")) return;
|
|
25545
|
+
write({ reflections: all.filter((r) => r.id !== id) });
|
|
25546
|
+
},
|
|
25375
25547
|
writeVoyageLine: (projectId, body) => {
|
|
25376
25548
|
const text = body.trim().slice(0, 600);
|
|
25377
25549
|
const me = get().currentUserId;
|
|
@@ -25465,6 +25637,7 @@ var init_milestones_raid = __esmMin((() => {
|
|
|
25465
25637
|
init_raid_history();
|
|
25466
25638
|
init_passage();
|
|
25467
25639
|
init_format();
|
|
25640
|
+
init_project_messages();
|
|
25468
25641
|
init_utils();
|
|
25469
25642
|
init_shared();
|
|
25470
25643
|
}));
|
package/lib/cli.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
myWork,
|
|
34
34
|
premortemText,
|
|
35
35
|
notesText,
|
|
36
|
+
messagesText,
|
|
36
37
|
charterText,
|
|
37
38
|
daysText,
|
|
38
39
|
soundingText,
|
|
@@ -251,6 +252,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
251
252
|
answer or read it
|
|
252
253
|
harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
|
|
253
254
|
your line in it
|
|
255
|
+
harbor messages PROJECT ["TEXT"] [--limit N]
|
|
256
|
+
a project's messages, or write
|
|
257
|
+
one (@Name tells them)
|
|
254
258
|
harbor charter PROJECT [--why T] [--in T] [--out T] [--done-when T]
|
|
255
259
|
[--calls NAME]
|
|
256
260
|
a project's five-line charter
|
|
@@ -1270,6 +1274,14 @@ const DETAIL = {
|
|
|
1270
1274
|
" it explains. The weekly brief names projects that grew a lot since",
|
|
1271
1275
|
" their baseline.",
|
|
1272
1276
|
],
|
|
1277
|
+
messages: [
|
|
1278
|
+
" harbor messages PROJECT reads a project's Messages tab",
|
|
1279
|
+
' harbor messages PROJECT "TEXT" writes: posts to everyone on it',
|
|
1280
|
+
"",
|
|
1281
|
+
" One conversation for the whole project, for what isn't about one task;",
|
|
1282
|
+
" harbor comment TASK is for that. @Name sends it to that person's Inbox.",
|
|
1283
|
+
" Reading shows the newest 50, oldest first; --limit N shows up to 500.",
|
|
1284
|
+
],
|
|
1273
1285
|
voyage: [
|
|
1274
1286
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
1275
1287
|
' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
|
|
@@ -3458,6 +3470,29 @@ export async function run(argv, io = {}) {
|
|
|
3458
3470
|
}
|
|
3459
3471
|
return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
|
|
3460
3472
|
}
|
|
3473
|
+
case "messages": {
|
|
3474
|
+
const { positionals, values, harbor } = parse(spec([], ["limit"]));
|
|
3475
|
+
const [project, ...words] = positionals;
|
|
3476
|
+
if (!project)
|
|
3477
|
+
throw new HarborError(
|
|
3478
|
+
'Give a project, and a message if you\'re writing one: harbor messages "NAME" "TEXT".',
|
|
3479
|
+
);
|
|
3480
|
+
const body = words.join(" ").trim();
|
|
3481
|
+
if (body) {
|
|
3482
|
+
if (values.limit)
|
|
3483
|
+
throw new HarborError("--limit is for reading; leave it off when you write.");
|
|
3484
|
+
return call(
|
|
3485
|
+
"post_message",
|
|
3486
|
+
{ harbor, project, body },
|
|
3487
|
+
(r) =>
|
|
3488
|
+
`Posted in ${r.posted.project}'s messages${r.mentioned?.length ? `; told ${r.mentioned.join(", ")}` : ""}.`,
|
|
3489
|
+
);
|
|
3490
|
+
}
|
|
3491
|
+
const limit = values.limit ? Number(values.limit) : undefined;
|
|
3492
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 500))
|
|
3493
|
+
throw new HarborError("--limit is a whole number from 1 to 500.");
|
|
3494
|
+
return call("list_messages", { harbor, project, ...(limit ? { limit } : {}) }, messagesText);
|
|
3495
|
+
}
|
|
3461
3496
|
case "watch": {
|
|
3462
3497
|
const { positionals, values, harbor } = parse(spec(["bell", "once"], ["project", "since"]));
|
|
3463
3498
|
none(positionals);
|
package/lib/format.mjs
CHANGED
|
@@ -1295,3 +1295,22 @@ export function goalsText(r) {
|
|
|
1295
1295
|
}
|
|
1296
1296
|
return lines.join("\n");
|
|
1297
1297
|
}
|
|
1298
|
+
|
|
1299
|
+
/** A project's Messages tab, as list_messages answers it: oldest first, one block a message. */
|
|
1300
|
+
export function messagesText(r) {
|
|
1301
|
+
const list = r.messages ?? [];
|
|
1302
|
+
if (!list.length)
|
|
1303
|
+
return `No messages on ${r.project} yet. harbor messages "${r.project}" "TEXT" writes the first.`;
|
|
1304
|
+
const head = r.more
|
|
1305
|
+
? [
|
|
1306
|
+
`${r.more} earlier ${r.more === 1 ? "message" : "messages"} left out (--limit N shows more).`,
|
|
1307
|
+
]
|
|
1308
|
+
: [];
|
|
1309
|
+
return [
|
|
1310
|
+
...head,
|
|
1311
|
+
...list.map(
|
|
1312
|
+
(m) =>
|
|
1313
|
+
`${m.from ?? "Someone"} · ${m.at.slice(0, 16).replace("T", " ")}${m.edited ? " (edited)" : ""}\n ${m.body.replace(/\n/g, "\n ")}`,
|
|
1314
|
+
),
|
|
1315
|
+
].join("\n\n");
|
|
1316
|
+
}
|