@nanocollective/roster 0.1.0-alpha.4 → 0.1.0-alpha.41
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 +70 -84
- package/dist/cli.js +4562 -2744
- package/docs/README.md +9 -6
- package/docs/agents.md +24 -20
- package/docs/architecture.md +13 -5
- package/docs/charters/analyst.md +65 -0
- package/docs/charters/cmo.md +69 -0
- package/docs/charters/community.md +63 -0
- package/docs/charters/cto.md +71 -0
- package/docs/charters/designer.md +65 -0
- package/docs/charters/devops.md +65 -0
- package/docs/charters/pm.md +70 -0
- package/docs/charters/qa.md +65 -0
- package/docs/charters/support.md +60 -0
- package/docs/charters/writer.md +63 -0
- package/docs/commands.md +90 -5
- package/docs/concepts.md +48 -14
- package/docs/cost.md +36 -1
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +8 -2
- package/docs/export.md +2 -0
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +126 -77
- package/docs/images/brain.jpg +0 -0
- package/docs/images/org.jpg +0 -0
- package/docs/images/prompt.jpg +0 -0
- package/docs/images/setup-org.jpg +0 -0
- package/docs/images/setup-plan.jpg +0 -0
- package/docs/images/staff.jpg +0 -0
- package/docs/manual-steps.md +94 -123
- package/docs/memory.md +21 -3
- package/docs/org-yaml.md +39 -2
- package/docs/portal.md +119 -44
- package/docs/prompts.md +31 -4
- package/docs/security.md +37 -5
- package/docs/session-workflow.md +49 -17
- package/docs/staff-yaml.md +14 -2
- package/docs/troubleshooting.md +8 -8
- package/docs/upgrading.md +9 -3
- package/docs/writing-a-charter.md +28 -0
- package/package.json +18 -20
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +6 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +6 -0
- package/templates/brain/CHARTER.md +3 -3
- package/templates/brain/README.md +1 -0
- package/templates/brain/log/decisions.md +3 -0
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/briefs/priorities.md +46 -0
- package/templates/ops/.github/workflows/session.yaml +108 -14
- package/templates/ops/agents.mjs +7 -3
- package/templates/ops/compose.mjs +16 -3
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +21 -1
- package/templates/ops/org/voice.md +9 -0
- package/templates/ops/prompts/_inflight.md +14 -0
- package/templates/ops/prompts/_paths.md +2 -1
- package/templates/ops/prompts/daily.md +16 -7
- package/templates/ops/prompts/mention.md +2 -0
- package/templates/ops/run-record.mjs +144 -0
- package/templates/portal/css/base.css +146 -73
- package/templates/portal/css/brain.css +23 -20
- package/templates/portal/css/diff.css +10 -9
- package/templates/portal/css/graph.css +12 -7
- package/templates/portal/css/health.css +13 -11
- package/templates/portal/css/inbox.css +57 -25
- package/templates/portal/css/layout.css +96 -41
- package/templates/portal/css/markdown.css +36 -14
- package/templates/portal/css/runs.css +13 -0
- package/templates/portal/css/setup.css +116 -34
- package/templates/portal/index.html +18 -2
- package/templates/portal/js/api.js +41 -4
- package/templates/portal/js/app.js +132 -9
- package/templates/portal/js/dialog.js +82 -0
- package/templates/portal/js/icons.js +37 -0
- package/templates/portal/js/inflight.js +18 -0
- package/templates/portal/js/md.js +5 -2
- package/templates/portal/js/mdedit.js +84 -0
- package/templates/portal/js/readiness.js +35 -0
- package/templates/portal/js/refresh.js +2 -1
- package/templates/portal/js/state.js +17 -4
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/checklist.js +10 -4
- package/templates/portal/js/views/credential.js +84 -0
- package/templates/portal/js/views/graph.js +1 -1
- package/templates/portal/js/views/health.js +17 -4
- package/templates/portal/js/views/hire.js +583 -0
- package/templates/portal/js/views/inbox.js +264 -84
- package/templates/portal/js/views/org.js +46 -106
- package/templates/portal/js/views/orgedit.js +234 -0
- package/templates/portal/js/views/paste.js +87 -21
- package/templates/portal/js/views/prompt.js +11 -4
- package/templates/portal/js/views/repos.js +20 -15
- package/templates/portal/js/views/runonce.js +94 -0
- package/templates/portal/js/views/runs.js +170 -0
- package/templates/portal/js/views/setup.js +257 -75
- package/templates/portal/js/views/staff.js +158 -243
- package/templates/portal/js/views/todo.js +62 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/* The inbox: everything open across the org, and the thread beside it. */
|
|
2
2
|
|
|
3
3
|
import { getLabels, getPr, getThread, post, upload } from "../api.js";
|
|
4
|
-
import { askText } from "../dialog.js";
|
|
4
|
+
import { askText, askYes } from "../dialog.js";
|
|
5
5
|
import { ago, el, esc, markCurrent, skeleton } from "../dom.js";
|
|
6
|
-
import { icon, iconHTML } from "../icons.js";
|
|
6
|
+
import { icon, iconHTML, staffIcon } from "../icons.js";
|
|
7
7
|
import { mdlite } from "../md.js";
|
|
8
8
|
import { attachMentions } from "../mention.js";
|
|
9
9
|
import { ensureInbox, refreshAll, stampCounts } from "../refresh.js";
|
|
@@ -92,6 +92,10 @@ export function belongsTo(item, s) {
|
|
|
92
92
|
return false;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/* Threads read so far, by repo#number, kept for the session. Each is reused while its
|
|
96
|
+
updatedAt matches the list's, so a thread is fetched again only when it has changed. */
|
|
97
|
+
const THREADS = new Map();
|
|
98
|
+
|
|
95
99
|
export const viewInbox = (m) => inboxScreen(m, {});
|
|
96
100
|
|
|
97
101
|
/**
|
|
@@ -113,7 +117,7 @@ function inboxScreen(m, opts) {
|
|
|
113
117
|
className: "sub",
|
|
114
118
|
textContent: opts.prs
|
|
115
119
|
? "Work the staff have finished and cannot land themselves."
|
|
116
|
-
: "Everything open across the org.",
|
|
120
|
+
: "Everything open across the org. Pull requests are in Pending work.",
|
|
117
121
|
});
|
|
118
122
|
m.append(sub);
|
|
119
123
|
|
|
@@ -122,42 +126,25 @@ function inboxScreen(m, opts) {
|
|
|
122
126
|
placeholder: "Filter by title, label, repo…",
|
|
123
127
|
value: S.query,
|
|
124
128
|
});
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
five months of finished work would be answering a different question. */
|
|
141
|
-
const state = el("select", { title: "Which items to list" });
|
|
142
|
-
state.append(
|
|
143
|
-
el("option", { value: "", textContent: "Open" }),
|
|
144
|
-
el("option", { value: "closed", textContent: "Recently closed" }),
|
|
145
|
-
el("option", { value: "all", textContent: "Open and closed" }),
|
|
146
|
-
);
|
|
147
|
-
state.value = S.inboxState;
|
|
148
|
-
|
|
149
|
-
const whose = el("select", {
|
|
150
|
-
title: "Whose work: their brain repo, anything their bot wrote, and anything addressed to them",
|
|
151
|
-
});
|
|
152
|
-
whose.append(
|
|
153
|
-
el("option", { value: "", textContent: "Everyone" }),
|
|
154
|
-
...S.data.staff.map((s) => el("option", { value: s.handle, textContent: s.name })),
|
|
155
|
-
);
|
|
156
|
-
whose.value = S.inboxStaff;
|
|
129
|
+
/* Open or closed, as a toggle. Who and what (a staff member, what is on you, decisions)
|
|
130
|
+
moved to the sidebar under Inbox, where a row of dropdowns used to be. */
|
|
131
|
+
if (S.inboxState === "all") S.inboxState = "";
|
|
132
|
+
const toggle = el("div", { className: "modes inboxstate" });
|
|
133
|
+
for (const [value, label] of [["", "Open"], ["closed", "Closed"]]) {
|
|
134
|
+
const b = el("button", { className: "mode", textContent: label });
|
|
135
|
+
b.classList.toggle("on", (S.inboxState || "") === value);
|
|
136
|
+
b.onclick = () => {
|
|
137
|
+
S.inboxState = value;
|
|
138
|
+
for (const x of toggle.children) x.classList.toggle("on", x === b);
|
|
139
|
+
writeHash(false);
|
|
140
|
+
paint();
|
|
141
|
+
};
|
|
142
|
+
toggle.append(b);
|
|
143
|
+
}
|
|
157
144
|
|
|
158
145
|
const refresh = el("button", { className: "iconbtn", title: "Refresh from GitHub" });
|
|
159
146
|
refresh.innerHTML = '<span class="sync">' + iconHTML("refresh") + "</span>";
|
|
160
|
-
const controls = [search,
|
|
147
|
+
const controls = [search, toggle, refresh];
|
|
161
148
|
// A pull request comes from a branch, so there is nothing here that could open one.
|
|
162
149
|
if (!opts.prs) {
|
|
163
150
|
const newBtn = el("button", { className: "ghbtn", textContent: "New issue" });
|
|
@@ -167,7 +154,7 @@ function inboxScreen(m, opts) {
|
|
|
167
154
|
};
|
|
168
155
|
controls.push(newBtn);
|
|
169
156
|
}
|
|
170
|
-
m.append(el("div", { className: "row", style: "margin-bottom:16px" }, controls));
|
|
157
|
+
m.append(el("div", { className: "row inboxbar", style: "margin-bottom:16px" }, controls));
|
|
171
158
|
|
|
172
159
|
const split = el("div", { className: "split" });
|
|
173
160
|
const list = el("div", { className: "tree" });
|
|
@@ -176,9 +163,6 @@ function inboxScreen(m, opts) {
|
|
|
176
163
|
m.append(split);
|
|
177
164
|
|
|
178
165
|
search.oninput = () => { S.query = search.value; writeHash(false); paint(); };
|
|
179
|
-
scope.onchange = () => { S.inboxFilter = scope.value; writeHash(false); paint(); };
|
|
180
|
-
whose.onchange = () => { S.inboxStaff = whose.value; writeHash(false); paint(); };
|
|
181
|
-
state.onchange = () => { S.inboxState = state.value; writeHash(false); paint(); };
|
|
182
166
|
refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
|
|
183
167
|
|
|
184
168
|
if (S.inbox) { stampCounts(); paint(); restore(); } else load(false);
|
|
@@ -187,9 +171,9 @@ function inboxScreen(m, opts) {
|
|
|
187
171
|
acted on, because only the already-loaded branch opened one. */
|
|
188
172
|
function restore() {
|
|
189
173
|
if (!S.inboxOpen) return;
|
|
190
|
-
// The two screens share one open thread
|
|
191
|
-
// this
|
|
192
|
-
if (opts.prs
|
|
174
|
+
// The two screens share one open thread, and each holds half the org. Whatever was open on
|
|
175
|
+
// the other one is not in this list, so it is dropped rather than opened beside it.
|
|
176
|
+
if (!!opts.prs !== (S.inboxOpen.kind === "pr")) {
|
|
193
177
|
S.inboxOpen = null;
|
|
194
178
|
writeHash(false);
|
|
195
179
|
return;
|
|
@@ -250,10 +234,23 @@ function inboxScreen(m, opts) {
|
|
|
250
234
|
// "On you" is on any of the humans this org answers to, not only the first one.
|
|
251
235
|
const onAHuman = (i) => (i.assignees ?? []).some(isHuman);
|
|
252
236
|
|
|
253
|
-
|
|
237
|
+
// The sidebar's filters are the inbox's. Pending work has no control for them, so it ignores them.
|
|
238
|
+
const staffPick = opts.prs ? "" : S.inboxStaff;
|
|
239
|
+
// "Issues" is the product repos: every repo a staff member works in.
|
|
240
|
+
const products = new Set(S.data.staff.flatMap((s) => s.worksIn ?? []));
|
|
241
|
+
const issuesOnly = staffPick === "@issues";
|
|
242
|
+
const unreadOnly = staffPick === "@unread";
|
|
243
|
+
const whoseStaff = staffPick && !issuesOnly ? S.data.staff.find((s) => s.handle === staffPick) : null;
|
|
244
|
+
/* The two screens partition the org rather than overlap on it. Pending work is every pull
|
|
245
|
+
request; the inbox is everything else. An inbox that also listed the PRs said the same
|
|
246
|
+
thing twice and made the badge beside it a number you could not act on. */
|
|
254
247
|
const mine = S.inbox.items
|
|
255
|
-
|
|
256
|
-
|
|
248
|
+
/* A staff member's link is their tracker: what is in their repo, whoever filed it. Matching
|
|
249
|
+
on who filed or was named put an issue the CMO filed for the CTO under both of them. */
|
|
250
|
+
.filter((i) =>
|
|
251
|
+
unreadOnly ? Boolean(i.unread) : issuesOnly ? products.has(i.repo) : !whoseStaff || i.repo === whoseStaff.brain,
|
|
252
|
+
)
|
|
253
|
+
.filter((i) => (i.kind === "pr") === !!opts.prs);
|
|
257
254
|
const isOpen = (i) => i.state === "OPEN";
|
|
258
255
|
const scoped = mine.filter(
|
|
259
256
|
S.inboxState === "closed" ? (i) => !isOpen(i) : S.inboxState === "all" ? () => true : isOpen,
|
|
@@ -263,14 +260,20 @@ function inboxScreen(m, opts) {
|
|
|
263
260
|
(i) => !q || (i.title + " " + i.repo + " " + i.labels.join(" ") + " #" + i.number)
|
|
264
261
|
.toLowerCase().includes(q),
|
|
265
262
|
);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
else if (
|
|
263
|
+
const filter = opts.prs ? "" : S.inboxFilter;
|
|
264
|
+
if (filter === "mine") items = items.filter(onAHuman);
|
|
265
|
+
else if (filter === "decision") items = items.filter((i) => i.labels.includes("decision"));
|
|
266
|
+
/* `s=pr` was the inbox's "Open work (PRs)" scope, and Pending work is now that screen. An
|
|
267
|
+
old bookmark carrying it is ignored rather than honoured into an empty list. */
|
|
269
268
|
|
|
270
269
|
const open = mine.filter(isOpen);
|
|
271
270
|
const onYou = open.filter(onAHuman).length;
|
|
272
|
-
const prs = open.filter((i) => i.kind === "pr").length;
|
|
273
271
|
const shut = mine.length - open.length;
|
|
272
|
+
/* Pending work owns the pull requests now, so this is a pointer to that screen rather than
|
|
273
|
+
a count of anything on this list. Scoped the same way, or the two would not add up. */
|
|
274
|
+
const prs = S.inbox.items.filter(
|
|
275
|
+
(i) => i.kind === "pr" && isOpen(i) && belongsTo(i, whoseStaff),
|
|
276
|
+
).length;
|
|
274
277
|
const where = whoseStaff
|
|
275
278
|
? " for <b>" + esc(whoseStaff.name) + "</b>"
|
|
276
279
|
: " across " + (S.inbox.repos ?? []).length + " repos";
|
|
@@ -286,7 +289,7 @@ function inboxScreen(m, opts) {
|
|
|
286
289
|
(failing ? " · " + failing + " failing" : "") +
|
|
287
290
|
(S.inboxState === "all" ? " · " + shut + " closed or merged" : "")
|
|
288
291
|
: open.length + " open" + where + " · <b>" + onYou + " on " +
|
|
289
|
-
esc(humanLabel()) + "</b> · " + prs + "
|
|
292
|
+
esc(humanLabel()) + "</b> · " + prs + " in Pending work" +
|
|
290
293
|
(S.inboxState === "all" ? " · " + shut + " closed" : "")) +
|
|
291
294
|
(S.inbox.fetchedAt ? ' <span class="meta">· checked ' + ago(S.inbox.fetchedAt) + "</span>" : "");
|
|
292
295
|
|
|
@@ -295,9 +298,15 @@ function inboxScreen(m, opts) {
|
|
|
295
298
|
list.append(el("div", { className: "tdir err", textContent: e }));
|
|
296
299
|
}
|
|
297
300
|
|
|
298
|
-
//
|
|
299
|
-
// happened to sort first; the project moves onto the row instead.
|
|
300
|
-
items = items
|
|
301
|
+
// Unread first, then newest first, flat. Grouping by repo buried a fresh comment under
|
|
302
|
+
// whichever project happened to sort first; the project moves onto the row instead.
|
|
303
|
+
items = items
|
|
304
|
+
.slice()
|
|
305
|
+
.sort(
|
|
306
|
+
(a, b) =>
|
|
307
|
+
Number(Boolean(b.unread)) - Number(Boolean(a.unread)) ||
|
|
308
|
+
String(b.updatedAt).localeCompare(String(a.updatedAt)),
|
|
309
|
+
);
|
|
301
310
|
if (!items.length) {
|
|
302
311
|
list.append(el("p", { className: "empty", textContent: "Nothing matches." }));
|
|
303
312
|
return;
|
|
@@ -305,7 +314,7 @@ function inboxScreen(m, opts) {
|
|
|
305
314
|
|
|
306
315
|
for (const i of items) {
|
|
307
316
|
const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
|
|
308
|
-
const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
|
|
317
|
+
const b = el("button", { className: "irow" + (shutState ? " shut" : "") + (i.unread ? " unread" : "") });
|
|
309
318
|
const yours = onAHuman(i);
|
|
310
319
|
const chips = i.labels
|
|
311
320
|
.slice(0, 3)
|
|
@@ -324,11 +333,17 @@ function inboxScreen(m, opts) {
|
|
|
324
333
|
'<span class="num">' + (i.kind === "pr" ? "PR " : "") + "#" + i.number + "</span>" +
|
|
325
334
|
(i.checks && i.checks !== "none"
|
|
326
335
|
? '<span class="ck ' + i.checks + '">' + checkGlyph(i.checks) + "</span>" : "") +
|
|
336
|
+
/* Green checks on a branch that no longer applies is the one case where the row
|
|
337
|
+
reads as ready and is not. It has to say so here, not three clicks in. */
|
|
338
|
+
(i.mergeable === "CONFLICTING"
|
|
339
|
+
? '<span class="chip conflict" title="This branch conflicts with its base">' +
|
|
340
|
+
"conflicts</span>"
|
|
341
|
+
: "") +
|
|
327
342
|
// How much conversation is on a thread, which is most of what tells a live one from
|
|
328
343
|
// something that was filed and never answered.
|
|
329
|
-
(i.comments.length
|
|
330
|
-
? '<span class="cc" title="' + i.comments.length + ' comments">' +
|
|
331
|
-
iconHTML("review") + i.comments.length + "</span>"
|
|
344
|
+
((i.commentCount ?? i.comments.length)
|
|
345
|
+
? '<span class="cc" title="' + (i.commentCount ?? i.comments.length) + ' comments">' +
|
|
346
|
+
iconHTML("review") + (i.commentCount ?? i.comments.length) + "</span>"
|
|
332
347
|
: "") +
|
|
333
348
|
chips +
|
|
334
349
|
'<span class="when">' + ago(i.updatedAt) + "</span>" +
|
|
@@ -358,18 +373,63 @@ function inboxScreen(m, opts) {
|
|
|
358
373
|
}
|
|
359
374
|
const row = list.querySelector('[data-ref="' + item.repo + "#" + item.number + '"]');
|
|
360
375
|
if (row) markCurrent(list, row);
|
|
376
|
+
// Opening it is reading it: cleared here at once, and on GitHub in the background.
|
|
377
|
+
if (item.unread) {
|
|
378
|
+
const thread = item.unread.thread;
|
|
379
|
+
item.unread = null;
|
|
380
|
+
row?.classList.remove("unread");
|
|
381
|
+
stampCounts();
|
|
382
|
+
post({ thread, ref: item.repo + "#" + item.number }, "/api/notifications/read").catch(() => {});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* The list carries no bodies or timelines (carrying them cost most of an hour's GitHub
|
|
386
|
+
allowance per load), so a thread is read when it is opened, and kept until GitHub says
|
|
387
|
+
it has changed. Reopening one costs nothing. */
|
|
388
|
+
if (item.partial) {
|
|
389
|
+
const key = item.repo + "#" + item.number;
|
|
390
|
+
const kept = THREADS.get(key);
|
|
391
|
+
const keep = { role: item.role, unread: item.unread };
|
|
392
|
+
if (kept && kept.updatedAt === item.updatedAt) {
|
|
393
|
+
Object.assign(item, kept, keep, { partial: false });
|
|
394
|
+
} else {
|
|
395
|
+
viewer.dataset.want = key;
|
|
396
|
+
viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 8));
|
|
397
|
+
getThread(item.repo, item.number, item.kind)
|
|
398
|
+
.then((fresh) => {
|
|
399
|
+
if (!fresh || fresh.error) throw new Error(fresh?.error ?? "could not read it");
|
|
400
|
+
THREADS.set(key, fresh);
|
|
401
|
+
Object.assign(item, fresh, keep, { partial: false });
|
|
402
|
+
})
|
|
403
|
+
.catch((e) => {
|
|
404
|
+
item.partial = false;
|
|
405
|
+
item.loadError = String(e.message || e);
|
|
406
|
+
})
|
|
407
|
+
.finally(() => {
|
|
408
|
+
// Somebody may have opened another thread while this one was loading.
|
|
409
|
+
if (viewer.dataset.want === key) openThread(ref);
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
361
414
|
|
|
362
415
|
const head = el("div", { className: "thead" });
|
|
363
416
|
head.innerHTML =
|
|
364
417
|
'<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
|
|
365
418
|
' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
|
|
366
|
-
" · " + esc(item.author) + " · " + ago(item.updatedAt) +
|
|
419
|
+
" · " + esc(item.author) + " · " + ago(item.updatedAt) +
|
|
420
|
+
// The labels sit on this line, after the time, rather than taking a line of their own.
|
|
421
|
+
(item.labels.length
|
|
422
|
+
? '<span class="tmetalabels">' +
|
|
423
|
+
item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
|
|
424
|
+
"</span>"
|
|
425
|
+
: "") +
|
|
426
|
+
"</div>" +
|
|
367
427
|
"<h3>" + esc(item.title) + "</h3>" +
|
|
368
|
-
(item.
|
|
369
|
-
? '<
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
428
|
+
(item.mergeable === "CONFLICTING"
|
|
429
|
+
? '<p class="tconflict">This branch conflicts with its base and cannot be merged ' +
|
|
430
|
+
"until someone rebases or merges the base into it.</p>"
|
|
431
|
+
: "") +
|
|
432
|
+
"";
|
|
373
433
|
head.append(threadActions(item));
|
|
374
434
|
delete viewer.dataset.wait;
|
|
375
435
|
viewer.replaceChildren(head);
|
|
@@ -380,6 +440,11 @@ function inboxScreen(m, opts) {
|
|
|
380
440
|
for a wrapper that would only ever hold one thing. */
|
|
381
441
|
const pane = item.kind === "pr" ? el("div") : viewer;
|
|
382
442
|
if (item.kind === "pr") viewer.append(prTabs(item, pane), pane);
|
|
443
|
+
if (item.loadError) {
|
|
444
|
+
pane.append(el("p", { className: "err", textContent: "Couldn't load the conversation: " + item.loadError }));
|
|
445
|
+
delete item.loadError;
|
|
446
|
+
item.partial = true; // so opening it again tries again
|
|
447
|
+
}
|
|
383
448
|
conversation(pane, item);
|
|
384
449
|
viewer.scrollTop = 0;
|
|
385
450
|
}
|
|
@@ -527,8 +592,11 @@ function inboxScreen(m, opts) {
|
|
|
527
592
|
b.onclick = async () => {
|
|
528
593
|
b.disabled = true;
|
|
529
594
|
try {
|
|
530
|
-
|
|
531
|
-
|
|
595
|
+
// Same reason as the header's Reply: the wait is after the box closes, not during it.
|
|
596
|
+
const r = await openReply(item, f, () => {
|
|
597
|
+
b.textContent = "Replying…";
|
|
598
|
+
});
|
|
599
|
+
b.textContent = r ? (r.warning ? "sent, with a warning" : "sent") : "Reply";
|
|
532
600
|
} catch (e) {
|
|
533
601
|
b.textContent = e.message;
|
|
534
602
|
}
|
|
@@ -590,24 +658,43 @@ function inboxScreen(m, opts) {
|
|
|
590
658
|
/* One button, because there was no way to tell the two apart. Reply reaches whoever you
|
|
591
659
|
named in it; see `openReply`. */
|
|
592
660
|
const reply = el("button", { className: "ghbtn primary", textContent: "Reply" });
|
|
661
|
+
|
|
662
|
+
/* Posting a comment is a round trip to GitHub through `gh`, and on a cold connection that
|
|
663
|
+
is seconds of a button that still says "Reply" and still takes clicks. Saying nothing
|
|
664
|
+
reads as a page that ignored you, which is how the same comment gets sent twice. */
|
|
665
|
+
const sending = (on) => {
|
|
666
|
+
busy(on, on ? "sending…" : "");
|
|
667
|
+
reply.textContent = on ? "Replying…" : "Reply";
|
|
668
|
+
};
|
|
669
|
+
|
|
593
670
|
reply.onclick = async () => {
|
|
594
671
|
let r;
|
|
595
672
|
try {
|
|
596
|
-
r = await openReply(item, null);
|
|
673
|
+
r = await openReply(item, null, () => sending(true));
|
|
597
674
|
} catch (e) {
|
|
675
|
+
sending(false);
|
|
598
676
|
failed(e);
|
|
599
677
|
return;
|
|
600
678
|
}
|
|
601
|
-
|
|
679
|
+
// Closed the box without sending: nothing ever started, so there is nothing to put back.
|
|
680
|
+
if (!r) {
|
|
681
|
+
sending(false);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
602
684
|
/* Repaint only when something was said on this thread, which is always true here: `ask`
|
|
603
685
|
posts the comment too. A warning means the tracker issue went up and the comment did
|
|
604
686
|
not, and then the status line is the only thing that says so. */
|
|
605
687
|
if (r.warning) {
|
|
688
|
+
sending(false);
|
|
606
689
|
status.className = "meta err";
|
|
607
690
|
status.textContent = r.warning;
|
|
608
691
|
return;
|
|
609
692
|
}
|
|
693
|
+
/* Held across the reload as well as the post. The reply is not on screen until the
|
|
694
|
+
thread repaints, and a button that said "Reply" again before then would be claiming
|
|
695
|
+
it was done over a thread that still did not show it. */
|
|
610
696
|
await reloadThread(item);
|
|
697
|
+
sending(false);
|
|
611
698
|
};
|
|
612
699
|
buttons.push(reply);
|
|
613
700
|
|
|
@@ -638,7 +725,20 @@ function inboxScreen(m, opts) {
|
|
|
638
725
|
body: body || undefined,
|
|
639
726
|
});
|
|
640
727
|
DRAFTS.delete(key);
|
|
641
|
-
|
|
728
|
+
/* What changed is known, so it is changed here rather than re-reading every repo,
|
|
729
|
+
which redrew the whole inbox for one issue. The next refresh confirms it. */
|
|
730
|
+
const me = humansOf()[0]?.github ?? "you";
|
|
731
|
+
const now = new Date().toISOString();
|
|
732
|
+
item.events = [
|
|
733
|
+
...(item.events ?? []),
|
|
734
|
+
...(body ? [{ type: "comment", actor: me, createdAt: now, body }] : []),
|
|
735
|
+
{ type: closing ? "closed" : "reopened", actor: me, createdAt: now },
|
|
736
|
+
];
|
|
737
|
+
item.state = closing ? "CLOSED" : "OPEN";
|
|
738
|
+
item.updatedAt = now;
|
|
739
|
+
stampCounts();
|
|
740
|
+
paint();
|
|
741
|
+
openThread({ repo: item.repo, number: item.number, kind: item.kind });
|
|
642
742
|
} catch (e) { failed(e); }
|
|
643
743
|
};
|
|
644
744
|
buttons.push(closeBtn);
|
|
@@ -651,12 +751,27 @@ function inboxScreen(m, opts) {
|
|
|
651
751
|
own settings. The server reads it from there. */
|
|
652
752
|
if (item.kind === "pr" && item.state === "OPEN") {
|
|
653
753
|
const merge = el("button", { className: "ghbtn", textContent: "Merge" });
|
|
754
|
+
/* A conflicted branch cannot be merged by anyone, so the button says why instead of
|
|
755
|
+
spending a round trip to come back with GitHub's version of the same sentence. */
|
|
756
|
+
if (item.mergeable === "CONFLICTING") {
|
|
757
|
+
merge.disabled = true;
|
|
758
|
+
merge.title = "This branch conflicts with its base";
|
|
759
|
+
}
|
|
654
760
|
merge.onclick = async () => {
|
|
655
|
-
|
|
761
|
+
const yes = await askYes({
|
|
762
|
+
title: "Merge " + item.repo + " #" + item.number + " into its base branch?",
|
|
763
|
+
confirm: "Merge",
|
|
764
|
+
});
|
|
765
|
+
if (!yes) return;
|
|
656
766
|
busy(true, "merging…");
|
|
657
767
|
try {
|
|
658
768
|
await post({ action: "merge", repo: item.repo, number: item.number });
|
|
659
|
-
|
|
769
|
+
// As with closing: the change is known, so it is made here rather than re-reading every repo.
|
|
770
|
+
item.state = "MERGED";
|
|
771
|
+
item.updatedAt = new Date().toISOString();
|
|
772
|
+
stampCounts();
|
|
773
|
+
paint();
|
|
774
|
+
openThread({ repo: item.repo, number: item.number, kind: item.kind });
|
|
660
775
|
} catch (e) { failed(e); }
|
|
661
776
|
};
|
|
662
777
|
buttons.push(merge);
|
|
@@ -686,7 +801,12 @@ function inboxScreen(m, opts) {
|
|
|
686
801
|
const at = S.inbox.items.findIndex(
|
|
687
802
|
(i) => i.repo === item.repo && i.number === item.number,
|
|
688
803
|
);
|
|
689
|
-
if (at >= 0)
|
|
804
|
+
if (at >= 0) {
|
|
805
|
+
const old = S.inbox.items[at];
|
|
806
|
+
THREADS.set(item.repo + "#" + item.number, fresh);
|
|
807
|
+
// A single thread does not know which part of the org it is in, or whether it is unread.
|
|
808
|
+
S.inbox.items[at] = { ...old, ...fresh, role: old.role, unread: old.unread, partial: false };
|
|
809
|
+
}
|
|
690
810
|
}
|
|
691
811
|
} catch {
|
|
692
812
|
/* the comment posted; a stale pane is not worth an error */
|
|
@@ -714,7 +834,28 @@ function inboxScreen(m, opts) {
|
|
|
714
834
|
* @param anchor the file this is about, when it was opened from a diff. Its hunk rides along.
|
|
715
835
|
* @returns the result of the write, or null if nothing was written.
|
|
716
836
|
*/
|
|
717
|
-
|
|
837
|
+
/**
|
|
838
|
+
* @param onSend Called once, the moment the box is submitted and the writing starts.
|
|
839
|
+
*
|
|
840
|
+
* The dialog and the send are one `await` to whoever called this, so a caller cannot tell the
|
|
841
|
+
* two apart by itself — and the two want opposite things from a button. While the box is open
|
|
842
|
+
* you are still typing and the button has to stay as it was; once it closes there is a request
|
|
843
|
+
* in flight against GitHub that can take seconds, and that is what needs saying.
|
|
844
|
+
*/
|
|
845
|
+
/**
|
|
846
|
+
* Who a reply is for, by default: the staff member whose tracker the issue is on, or whose App
|
|
847
|
+
* opened it. They are only woken by an @mention, so a reply without one reached nobody.
|
|
848
|
+
*/
|
|
849
|
+
export function addressee(item) {
|
|
850
|
+
const staff = S.data?.staff ?? [];
|
|
851
|
+
const s =
|
|
852
|
+
staff.find((x) => x.brain === item.repo) ??
|
|
853
|
+
staff.find((x) => (x.soloBots ?? []).includes(item.author));
|
|
854
|
+
if (!s) return "";
|
|
855
|
+
return (s.mention || "@" + s.handle) + " ";
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function openReply(item, anchor, onSend) {
|
|
718
859
|
const key = item.repo + "#" + item.number + (anchor ? " · " + anchor.path : "");
|
|
719
860
|
let deaf = null;
|
|
720
861
|
|
|
@@ -722,7 +863,7 @@ async function openReply(item, anchor) {
|
|
|
722
863
|
title: "Reply to " + item.repo + " #" + item.number,
|
|
723
864
|
hint: anchor ? anchor.path : item.title,
|
|
724
865
|
confirm: "Comment",
|
|
725
|
-
value: DRAFTS.get(key) ??
|
|
866
|
+
value: DRAFTS.get(key) ?? addressee(item),
|
|
726
867
|
/* It goes out through whoever's `gh` is signed in here, which is only knowably one person
|
|
727
868
|
when the org has one. With two it would be a guess, and a wrong name on a reply box is
|
|
728
869
|
worse than no name. */
|
|
@@ -733,10 +874,15 @@ async function openReply(item, anchor) {
|
|
|
733
874
|
else DRAFTS.delete(key);
|
|
734
875
|
};
|
|
735
876
|
deaf = deafNote(ta, item, anchor);
|
|
877
|
+
// Typing starts after the @mention, not in front of it.
|
|
878
|
+
setTimeout(() => {
|
|
879
|
+
ta.selectionStart = ta.selectionEnd = ta.value.length;
|
|
880
|
+
}, 0);
|
|
736
881
|
return el("div", {}, [attachBox(ta, () => item.repo).node, deaf.node]);
|
|
737
882
|
},
|
|
738
883
|
});
|
|
739
884
|
if (!body) return null;
|
|
885
|
+
onSend?.();
|
|
740
886
|
|
|
741
887
|
/* One press, both writes. `ask` posts the comment itself when told to, so these are not two
|
|
742
888
|
requests racing: the tracker issue goes up first because it is the half that actually
|
|
@@ -1000,15 +1146,36 @@ function xref(source, item, openThread) {
|
|
|
1000
1146
|
return b;
|
|
1001
1147
|
}
|
|
1002
1148
|
|
|
1149
|
+
/* Who wrote something, as a person would say it: a staff member by their role, with their
|
|
1150
|
+
icon; one of the humans by name; anyone else by their login. */
|
|
1151
|
+
function whoWrote(login) {
|
|
1152
|
+
const s = (S.data?.staff ?? []).find((x) => (x.soloBots ?? []).includes(login));
|
|
1153
|
+
if (s) return { name: s.name, glyph: staffIcon(s), kind: "staff" };
|
|
1154
|
+
if (isHuman(login)) {
|
|
1155
|
+
const h = humansOf().find((x) => x.github === login);
|
|
1156
|
+
return { name: h?.name ?? login, glyph: "person", kind: "human" };
|
|
1157
|
+
}
|
|
1158
|
+
return { name: login || "?", glyph: "person", kind: "other" };
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/* A comment is a card: a header strip saying who and when, then what they said. As text
|
|
1162
|
+
between thin rules, it was hard to see where one message ended and the next began. */
|
|
1003
1163
|
function comment({ author, when, body, first, repo, badge, reactions }) {
|
|
1004
|
-
const
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1164
|
+
const who = whoWrote(author);
|
|
1165
|
+
const d = el("div", { className: "cmt " + who.kind + (first ? " first" : "") });
|
|
1166
|
+
const head = el("div", { className: "chead" });
|
|
1167
|
+
head.append(icon(who.glyph, "ic"), el("b", { textContent: who.name }));
|
|
1168
|
+
if (who.name !== author && author) head.append(el("span", { className: "clogin", textContent: author }));
|
|
1169
|
+
if (badge) head.append(el("span", { className: "pill", textContent: badge }));
|
|
1170
|
+
head.append(
|
|
1171
|
+
el("span", { className: "cwhen", title: new Date(when).toLocaleString(), textContent: ago(when) }),
|
|
1172
|
+
);
|
|
1173
|
+
d.append(head);
|
|
1174
|
+
const bodyEl = el("div", { className: "cbodywrap" });
|
|
1175
|
+
bodyEl.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
|
|
1010
1176
|
const marks = reactionRow(reactions);
|
|
1011
|
-
if (marks)
|
|
1177
|
+
if (marks) bodyEl.append(marks);
|
|
1178
|
+
d.append(bodyEl);
|
|
1012
1179
|
return d;
|
|
1013
1180
|
}
|
|
1014
1181
|
|
|
@@ -1156,9 +1323,22 @@ function newIssueForm(viewer) {
|
|
|
1156
1323
|
body: body.value,
|
|
1157
1324
|
labels: picked.chosen(),
|
|
1158
1325
|
});
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1326
|
+
/* The form is done once GitHub has the issue. What you sent is shown as what it now is,
|
|
1327
|
+
a thread in the inbox, open and selected, rather than a link out to github.com beside
|
|
1328
|
+
a form still holding what you typed. The filters are set so its row is on the list. */
|
|
1329
|
+
const number = Number(String(r.url ?? "").match(/\/issues\/(\d+)/)?.[1]);
|
|
1330
|
+
S.inboxOpen = number ? { repo: s.brain, number, kind: "issue" } : null;
|
|
1331
|
+
S.inboxState = "";
|
|
1332
|
+
S.inboxFilter = "";
|
|
1333
|
+
S.query = "";
|
|
1334
|
+
if (S.inboxStaff && S.inboxStaff !== s.handle) S.inboxStaff = s.handle;
|
|
1335
|
+
writeHash(false);
|
|
1336
|
+
status.textContent = "sent · opening it…";
|
|
1337
|
+
// It is sent either way. A refresh that fails must not read as a send that failed.
|
|
1338
|
+
await refreshAll(false).catch(() => {
|
|
1339
|
+
status.textContent = "sent · refresh to see it";
|
|
1340
|
+
});
|
|
1341
|
+
return;
|
|
1162
1342
|
} catch (e) {
|
|
1163
1343
|
status.textContent = e.message;
|
|
1164
1344
|
status.className = "meta err";
|