@nanocollective/roster 0.1.0-alpha.5 → 0.1.0-alpha.50
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 +4833 -2752
- 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 +93 -7
- package/docs/concepts.md +61 -14
- package/docs/cost.md +36 -1
- package/docs/developing.md +16 -21
- package/docs/doctor-codes.md +10 -2
- package/docs/export.md +2 -0
- package/docs/extending.md +2 -2
- package/docs/getting-started.md +128 -78
- 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 +40 -2
- package/docs/portal.md +165 -48
- package/docs/prompts.md +31 -4
- package/docs/security.md +37 -5
- package/docs/session-workflow.md +63 -17
- package/docs/staff-yaml.md +30 -3
- 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 +26 -1
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +41 -7
- 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/staff.yaml +4 -1
- package/templates/brain/strategy/ideas.md +7 -0
- package/templates/briefs/priorities.md +46 -0
- package/templates/ops/.github/workflows/session.yaml +236 -15
- package/templates/ops/agents.mjs +7 -3
- package/templates/ops/compose.mjs +31 -3
- package/templates/ops/inflight.mjs +157 -0
- package/templates/ops/org/operating.md +43 -4
- 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 +37 -9
- package/templates/ops/prompts/mention.md +21 -0
- package/templates/ops/run-record.mjs +146 -0
- package/templates/portal/css/base.css +167 -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/home.css +93 -0
- package/templates/portal/css/inbox.css +45 -25
- package/templates/portal/css/layout.css +90 -46
- 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 +21 -9
- package/templates/portal/js/api.js +44 -4
- package/templates/portal/js/app.js +94 -9
- package/templates/portal/js/dialog.js +83 -0
- package/templates/portal/js/homesort.js +174 -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 +70 -0
- package/templates/portal/js/refresh.js +10 -2
- package/templates/portal/js/state.js +11 -5
- package/templates/portal/js/views/app.js +24 -7
- package/templates/portal/js/views/brain.js +1 -1
- 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 +593 -0
- package/templates/portal/js/views/home.js +546 -0
- package/templates/portal/js/views/inbox.js +226 -70
- 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 +261 -75
- package/templates/portal/js/views/staff.js +170 -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
|
/**
|
|
@@ -104,16 +108,36 @@ export const viewInbox = (m) => inboxScreen(m, {});
|
|
|
104
108
|
*/
|
|
105
109
|
export const viewPrs = (m) => inboxScreen(m, { prs: true });
|
|
106
110
|
|
|
111
|
+
/**
|
|
112
|
+
* One thread, drawn into `host` on its own: the header, its actions, the reply box and the
|
|
113
|
+
* conversation, with no list beside it. Home opens a card this way, in a side sheet.
|
|
114
|
+
*
|
|
115
|
+
* It is the same screen as Trackers, built off-page, with only its viewer kept. Replying,
|
|
116
|
+
* closing and merging repaint a list nobody can see and then reopen this thread, which is
|
|
117
|
+
* what they do on Trackers too.
|
|
118
|
+
*/
|
|
119
|
+
export function threadInto(host, ref) {
|
|
120
|
+
/* Read fresh. Home can be minutes behind GitHub, and the reason to open something is usually
|
|
121
|
+
to read the reply that made its row change. Reading it again also brings the row up to
|
|
122
|
+
date, so Home is right when the sheet closes. */
|
|
123
|
+
const item = (S.inbox?.items ?? []).find((i) => i.repo === ref.repo && i.number === ref.number);
|
|
124
|
+
if (item) {
|
|
125
|
+
THREADS.delete(item.repo + "#" + item.number);
|
|
126
|
+
item.partial = true;
|
|
127
|
+
}
|
|
128
|
+
host.append(inboxScreen(el("div"), { prs: ref.kind === "pr", only: ref }));
|
|
129
|
+
}
|
|
130
|
+
|
|
107
131
|
function inboxScreen(m, opts) {
|
|
108
132
|
/* "Pending work", not "Pull requests". Every one of these is a piece of work a staff member
|
|
109
133
|
has finished and cannot land on their own, which is the thing you are being asked about.
|
|
110
134
|
That it arrives as a pull request is how it is delivered, not what it is. */
|
|
111
|
-
m.append(el("h1", { textContent: opts.prs ? "Pending work" : "
|
|
135
|
+
m.append(el("h1", { textContent: opts.prs ? "Pending work" : "Trackers" }));
|
|
112
136
|
const sub = el("p", {
|
|
113
137
|
className: "sub",
|
|
114
138
|
textContent: opts.prs
|
|
115
139
|
? "Work the staff have finished and cannot land themselves."
|
|
116
|
-
: "
|
|
140
|
+
: "Every issue on each staff member's tracker and the product repos. What needs you is on Home.",
|
|
117
141
|
});
|
|
118
142
|
m.append(sub);
|
|
119
143
|
|
|
@@ -122,37 +146,25 @@ function inboxScreen(m, opts) {
|
|
|
122
146
|
placeholder: "Filter by title, label, repo…",
|
|
123
147
|
value: S.query,
|
|
124
148
|
});
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
el("option", { value: "all", textContent: "Open and closed" }),
|
|
141
|
-
);
|
|
142
|
-
state.value = S.inboxState;
|
|
143
|
-
|
|
144
|
-
const whose = el("select", {
|
|
145
|
-
title: "Whose work: their brain repo, anything their bot wrote, and anything addressed to them",
|
|
146
|
-
});
|
|
147
|
-
whose.append(
|
|
148
|
-
el("option", { value: "", textContent: "Everyone" }),
|
|
149
|
-
...S.data.staff.map((s) => el("option", { value: s.handle, textContent: s.name })),
|
|
150
|
-
);
|
|
151
|
-
whose.value = S.inboxStaff;
|
|
149
|
+
/* Open or closed, as a toggle. Who and what (a staff member, what is on you, decisions)
|
|
150
|
+
moved to the sidebar under Inbox, where a row of dropdowns used to be. */
|
|
151
|
+
if (S.inboxState === "all") S.inboxState = "";
|
|
152
|
+
const toggle = el("div", { className: "modes inboxstate" });
|
|
153
|
+
for (const [value, label] of [["", "Open"], ["closed", "Closed"]]) {
|
|
154
|
+
const b = el("button", { className: "mode", textContent: label });
|
|
155
|
+
b.classList.toggle("on", (S.inboxState || "") === value);
|
|
156
|
+
b.onclick = () => {
|
|
157
|
+
S.inboxState = value;
|
|
158
|
+
for (const x of toggle.children) x.classList.toggle("on", x === b);
|
|
159
|
+
writeHash(false);
|
|
160
|
+
paint();
|
|
161
|
+
};
|
|
162
|
+
toggle.append(b);
|
|
163
|
+
}
|
|
152
164
|
|
|
153
165
|
const refresh = el("button", { className: "iconbtn", title: "Refresh from GitHub" });
|
|
154
166
|
refresh.innerHTML = '<span class="sync">' + iconHTML("refresh") + "</span>";
|
|
155
|
-
const controls = [search,
|
|
167
|
+
const controls = [search, toggle, refresh];
|
|
156
168
|
// A pull request comes from a branch, so there is nothing here that could open one.
|
|
157
169
|
if (!opts.prs) {
|
|
158
170
|
const newBtn = el("button", { className: "ghbtn", textContent: "New issue" });
|
|
@@ -162,7 +174,7 @@ function inboxScreen(m, opts) {
|
|
|
162
174
|
};
|
|
163
175
|
controls.push(newBtn);
|
|
164
176
|
}
|
|
165
|
-
m.append(el("div", { className: "row", style: "margin-bottom:16px" }, controls));
|
|
177
|
+
m.append(el("div", { className: "row inboxbar", style: "margin-bottom:16px" }, controls));
|
|
166
178
|
|
|
167
179
|
const split = el("div", { className: "split" });
|
|
168
180
|
const list = el("div", { className: "tree" });
|
|
@@ -171,16 +183,18 @@ function inboxScreen(m, opts) {
|
|
|
171
183
|
m.append(split);
|
|
172
184
|
|
|
173
185
|
search.oninput = () => { S.query = search.value; writeHash(false); paint(); };
|
|
174
|
-
scope.onchange = () => { S.inboxFilter = scope.value; writeHash(false); paint(); };
|
|
175
|
-
whose.onchange = () => { S.inboxStaff = whose.value; writeHash(false); paint(); };
|
|
176
|
-
state.onchange = () => { S.inboxState = state.value; writeHash(false); paint(); };
|
|
177
186
|
refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
|
|
178
187
|
|
|
179
188
|
if (S.inbox) { stampCounts(); paint(); restore(); } else load(false);
|
|
189
|
+
if (opts.only) return viewer;
|
|
180
190
|
|
|
181
191
|
/** A `?t=` in the URL names a thread. It used to be read into the state and then never
|
|
182
192
|
acted on, because only the already-loaded branch opened one. */
|
|
183
193
|
function restore() {
|
|
194
|
+
if (opts.only) {
|
|
195
|
+
openThread(opts.only);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
184
198
|
if (!S.inboxOpen) return;
|
|
185
199
|
// The two screens share one open thread, and each holds half the org. Whatever was open on
|
|
186
200
|
// the other one is not in this list, so it is dropped rather than opened beside it.
|
|
@@ -245,12 +259,22 @@ function inboxScreen(m, opts) {
|
|
|
245
259
|
// "On you" is on any of the humans this org answers to, not only the first one.
|
|
246
260
|
const onAHuman = (i) => (i.assignees ?? []).some(isHuman);
|
|
247
261
|
|
|
248
|
-
|
|
262
|
+
// The sidebar's filters are the inbox's. Pending work has no control for them, so it ignores them.
|
|
263
|
+
const staffPick = opts.prs ? "" : S.inboxStaff;
|
|
264
|
+
// "Issues" is the product repos: every repo a staff member works in.
|
|
265
|
+
const products = new Set(S.data.staff.flatMap((s) => s.worksIn ?? []));
|
|
266
|
+
const issuesOnly = staffPick === "@issues";
|
|
267
|
+
const unreadOnly = staffPick === "@unread";
|
|
268
|
+
const whoseStaff = staffPick && !issuesOnly ? S.data.staff.find((s) => s.handle === staffPick) : null;
|
|
249
269
|
/* The two screens partition the org rather than overlap on it. Pending work is every pull
|
|
250
270
|
request; the inbox is everything else. An inbox that also listed the PRs said the same
|
|
251
271
|
thing twice and made the badge beside it a number you could not act on. */
|
|
252
272
|
const mine = S.inbox.items
|
|
253
|
-
|
|
273
|
+
/* A staff member's link is their tracker: what is in their repo, whoever filed it. Matching
|
|
274
|
+
on who filed or was named put an issue the CMO filed for the CTO under both of them. */
|
|
275
|
+
.filter((i) =>
|
|
276
|
+
unreadOnly ? Boolean(i.unread) : issuesOnly ? products.has(i.repo) : !whoseStaff || i.repo === whoseStaff.brain,
|
|
277
|
+
)
|
|
254
278
|
.filter((i) => (i.kind === "pr") === !!opts.prs);
|
|
255
279
|
const isOpen = (i) => i.state === "OPEN";
|
|
256
280
|
const scoped = mine.filter(
|
|
@@ -261,8 +285,9 @@ function inboxScreen(m, opts) {
|
|
|
261
285
|
(i) => !q || (i.title + " " + i.repo + " " + i.labels.join(" ") + " #" + i.number)
|
|
262
286
|
.toLowerCase().includes(q),
|
|
263
287
|
);
|
|
264
|
-
|
|
265
|
-
|
|
288
|
+
const filter = opts.prs ? "" : S.inboxFilter;
|
|
289
|
+
if (filter === "mine") items = items.filter(onAHuman);
|
|
290
|
+
else if (filter === "decision") items = items.filter((i) => i.labels.includes("decision"));
|
|
266
291
|
/* `s=pr` was the inbox's "Open work (PRs)" scope, and Pending work is now that screen. An
|
|
267
292
|
old bookmark carrying it is ignored rather than honoured into an empty list. */
|
|
268
293
|
|
|
@@ -298,9 +323,15 @@ function inboxScreen(m, opts) {
|
|
|
298
323
|
list.append(el("div", { className: "tdir err", textContent: e }));
|
|
299
324
|
}
|
|
300
325
|
|
|
301
|
-
//
|
|
302
|
-
// happened to sort first; the project moves onto the row instead.
|
|
303
|
-
items = items
|
|
326
|
+
// Unread first, then newest first, flat. Grouping by repo buried a fresh comment under
|
|
327
|
+
// whichever project happened to sort first; the project moves onto the row instead.
|
|
328
|
+
items = items
|
|
329
|
+
.slice()
|
|
330
|
+
.sort(
|
|
331
|
+
(a, b) =>
|
|
332
|
+
Number(Boolean(b.unread)) - Number(Boolean(a.unread)) ||
|
|
333
|
+
String(b.updatedAt).localeCompare(String(a.updatedAt)),
|
|
334
|
+
);
|
|
304
335
|
if (!items.length) {
|
|
305
336
|
list.append(el("p", { className: "empty", textContent: "Nothing matches." }));
|
|
306
337
|
return;
|
|
@@ -308,7 +339,7 @@ function inboxScreen(m, opts) {
|
|
|
308
339
|
|
|
309
340
|
for (const i of items) {
|
|
310
341
|
const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
|
|
311
|
-
const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
|
|
342
|
+
const b = el("button", { className: "irow" + (shutState ? " shut" : "") + (i.unread ? " unread" : "") });
|
|
312
343
|
const yours = onAHuman(i);
|
|
313
344
|
const chips = i.labels
|
|
314
345
|
.slice(0, 3)
|
|
@@ -335,9 +366,9 @@ function inboxScreen(m, opts) {
|
|
|
335
366
|
: "") +
|
|
336
367
|
// How much conversation is on a thread, which is most of what tells a live one from
|
|
337
368
|
// something that was filed and never answered.
|
|
338
|
-
(i.comments.length
|
|
339
|
-
? '<span class="cc" title="' + i.comments.length + ' comments">' +
|
|
340
|
-
iconHTML("review") + i.comments.length + "</span>"
|
|
369
|
+
((i.commentCount ?? i.comments.length)
|
|
370
|
+
? '<span class="cc" title="' + (i.commentCount ?? i.comments.length) + ' comments">' +
|
|
371
|
+
iconHTML("review") + (i.commentCount ?? i.comments.length) + "</span>"
|
|
341
372
|
: "") +
|
|
342
373
|
chips +
|
|
343
374
|
'<span class="when">' + ago(i.updatedAt) + "</span>" +
|
|
@@ -367,22 +398,63 @@ function inboxScreen(m, opts) {
|
|
|
367
398
|
}
|
|
368
399
|
const row = list.querySelector('[data-ref="' + item.repo + "#" + item.number + '"]');
|
|
369
400
|
if (row) markCurrent(list, row);
|
|
401
|
+
// Opening it is reading it: cleared here at once, and on GitHub in the background.
|
|
402
|
+
if (item.unread) {
|
|
403
|
+
const thread = item.unread.thread;
|
|
404
|
+
item.unread = null;
|
|
405
|
+
row?.classList.remove("unread");
|
|
406
|
+
stampCounts();
|
|
407
|
+
post({ thread, ref: item.repo + "#" + item.number }, "/api/notifications/read").catch(() => {});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* The list carries no bodies or timelines (carrying them cost most of an hour's GitHub
|
|
411
|
+
allowance per load), so a thread is read when it is opened, and kept until GitHub says
|
|
412
|
+
it has changed. Reopening one costs nothing. */
|
|
413
|
+
if (item.partial) {
|
|
414
|
+
const key = item.repo + "#" + item.number;
|
|
415
|
+
const kept = THREADS.get(key);
|
|
416
|
+
const keep = { role: item.role, unread: item.unread };
|
|
417
|
+
if (kept && kept.updatedAt === item.updatedAt) {
|
|
418
|
+
Object.assign(item, kept, keep, { partial: false });
|
|
419
|
+
} else {
|
|
420
|
+
viewer.dataset.want = key;
|
|
421
|
+
viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 8));
|
|
422
|
+
getThread(item.repo, item.number, item.kind)
|
|
423
|
+
.then((fresh) => {
|
|
424
|
+
if (!fresh || fresh.error) throw new Error(fresh?.error ?? "could not read it");
|
|
425
|
+
THREADS.set(key, fresh);
|
|
426
|
+
Object.assign(item, fresh, keep, { partial: false });
|
|
427
|
+
})
|
|
428
|
+
.catch((e) => {
|
|
429
|
+
item.partial = false;
|
|
430
|
+
item.loadError = String(e.message || e);
|
|
431
|
+
})
|
|
432
|
+
.finally(() => {
|
|
433
|
+
// Somebody may have opened another thread while this one was loading.
|
|
434
|
+
if (viewer.dataset.want === key) openThread(ref);
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
370
439
|
|
|
371
440
|
const head = el("div", { className: "thead" });
|
|
372
441
|
head.innerHTML =
|
|
373
442
|
'<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
|
|
374
443
|
' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
|
|
375
|
-
" · " + esc(item.author) + " · " + ago(item.updatedAt) +
|
|
444
|
+
" · " + esc(item.author) + " · " + ago(item.updatedAt) +
|
|
445
|
+
// The labels sit on this line, after the time, rather than taking a line of their own.
|
|
446
|
+
(item.labels.length
|
|
447
|
+
? '<span class="tmetalabels">' +
|
|
448
|
+
item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
|
|
449
|
+
"</span>"
|
|
450
|
+
: "") +
|
|
451
|
+
"</div>" +
|
|
376
452
|
"<h3>" + esc(item.title) + "</h3>" +
|
|
377
453
|
(item.mergeable === "CONFLICTING"
|
|
378
454
|
? '<p class="tconflict">This branch conflicts with its base and cannot be merged ' +
|
|
379
455
|
"until someone rebases or merges the base into it.</p>"
|
|
380
456
|
: "") +
|
|
381
|
-
|
|
382
|
-
? '<div class="row tlabels">' +
|
|
383
|
-
item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
|
|
384
|
-
"</div>"
|
|
385
|
-
: "");
|
|
457
|
+
"";
|
|
386
458
|
head.append(threadActions(item));
|
|
387
459
|
delete viewer.dataset.wait;
|
|
388
460
|
viewer.replaceChildren(head);
|
|
@@ -393,6 +465,11 @@ function inboxScreen(m, opts) {
|
|
|
393
465
|
for a wrapper that would only ever hold one thing. */
|
|
394
466
|
const pane = item.kind === "pr" ? el("div") : viewer;
|
|
395
467
|
if (item.kind === "pr") viewer.append(prTabs(item, pane), pane);
|
|
468
|
+
if (item.loadError) {
|
|
469
|
+
pane.append(el("p", { className: "err", textContent: "Couldn't load the conversation: " + item.loadError }));
|
|
470
|
+
delete item.loadError;
|
|
471
|
+
item.partial = true; // so opening it again tries again
|
|
472
|
+
}
|
|
396
473
|
conversation(pane, item);
|
|
397
474
|
viewer.scrollTop = 0;
|
|
398
475
|
}
|
|
@@ -575,10 +652,11 @@ function inboxScreen(m, opts) {
|
|
|
575
652
|
status.textContent = msg ?? "";
|
|
576
653
|
status.className = "meta";
|
|
577
654
|
};
|
|
655
|
+
// busy(false) clears the status line, so it goes first: the other way round wiped the
|
|
656
|
+
// error as it was written, and a refused merge looked like nothing happening at all.
|
|
578
657
|
const failed = (e) => {
|
|
579
|
-
status.textContent = e.message;
|
|
580
|
-
status.className = "meta err";
|
|
581
658
|
busy(false);
|
|
659
|
+
status.textContent = e.message;
|
|
582
660
|
status.className = "meta err";
|
|
583
661
|
};
|
|
584
662
|
|
|
@@ -673,7 +751,20 @@ function inboxScreen(m, opts) {
|
|
|
673
751
|
body: body || undefined,
|
|
674
752
|
});
|
|
675
753
|
DRAFTS.delete(key);
|
|
676
|
-
|
|
754
|
+
/* What changed is known, so it is changed here rather than re-reading every repo,
|
|
755
|
+
which redrew the whole inbox for one issue. The next refresh confirms it. */
|
|
756
|
+
const me = humansOf()[0]?.github ?? "you";
|
|
757
|
+
const now = new Date().toISOString();
|
|
758
|
+
item.events = [
|
|
759
|
+
...(item.events ?? []),
|
|
760
|
+
...(body ? [{ type: "comment", actor: me, createdAt: now, body }] : []),
|
|
761
|
+
{ type: closing ? "closed" : "reopened", actor: me, createdAt: now },
|
|
762
|
+
];
|
|
763
|
+
item.state = closing ? "CLOSED" : "OPEN";
|
|
764
|
+
item.updatedAt = now;
|
|
765
|
+
stampCounts();
|
|
766
|
+
paint();
|
|
767
|
+
openThread({ repo: item.repo, number: item.number, kind: item.kind });
|
|
677
768
|
} catch (e) { failed(e); }
|
|
678
769
|
};
|
|
679
770
|
buttons.push(closeBtn);
|
|
@@ -693,11 +784,20 @@ function inboxScreen(m, opts) {
|
|
|
693
784
|
merge.title = "This branch conflicts with its base";
|
|
694
785
|
}
|
|
695
786
|
merge.onclick = async () => {
|
|
696
|
-
|
|
787
|
+
const yes = await askYes({
|
|
788
|
+
title: "Merge " + item.repo + " #" + item.number + " into its base branch?",
|
|
789
|
+
confirm: "Merge",
|
|
790
|
+
});
|
|
791
|
+
if (!yes) return;
|
|
697
792
|
busy(true, "merging…");
|
|
698
793
|
try {
|
|
699
794
|
await post({ action: "merge", repo: item.repo, number: item.number });
|
|
700
|
-
|
|
795
|
+
// As with closing: the change is known, so it is made here rather than re-reading every repo.
|
|
796
|
+
item.state = "MERGED";
|
|
797
|
+
item.updatedAt = new Date().toISOString();
|
|
798
|
+
stampCounts();
|
|
799
|
+
paint();
|
|
800
|
+
openThread({ repo: item.repo, number: item.number, kind: item.kind });
|
|
701
801
|
} catch (e) { failed(e); }
|
|
702
802
|
};
|
|
703
803
|
buttons.push(merge);
|
|
@@ -727,7 +827,12 @@ function inboxScreen(m, opts) {
|
|
|
727
827
|
const at = S.inbox.items.findIndex(
|
|
728
828
|
(i) => i.repo === item.repo && i.number === item.number,
|
|
729
829
|
);
|
|
730
|
-
if (at >= 0)
|
|
830
|
+
if (at >= 0) {
|
|
831
|
+
const old = S.inbox.items[at];
|
|
832
|
+
THREADS.set(item.repo + "#" + item.number, fresh);
|
|
833
|
+
// A single thread does not know which part of the org it is in, or whether it is unread.
|
|
834
|
+
S.inbox.items[at] = { ...old, ...fresh, role: old.role, unread: old.unread, partial: false };
|
|
835
|
+
}
|
|
731
836
|
}
|
|
732
837
|
} catch {
|
|
733
838
|
/* the comment posted; a stale pane is not worth an error */
|
|
@@ -763,6 +868,19 @@ function inboxScreen(m, opts) {
|
|
|
763
868
|
* you are still typing and the button has to stay as it was; once it closes there is a request
|
|
764
869
|
* in flight against GitHub that can take seconds, and that is what needs saying.
|
|
765
870
|
*/
|
|
871
|
+
/**
|
|
872
|
+
* Who a reply is for, by default: the staff member whose tracker the issue is on, or whose App
|
|
873
|
+
* opened it. They are only woken by an @mention, so a reply without one reached nobody.
|
|
874
|
+
*/
|
|
875
|
+
export function addressee(item) {
|
|
876
|
+
const staff = S.data?.staff ?? [];
|
|
877
|
+
const s =
|
|
878
|
+
staff.find((x) => x.brain === item.repo) ??
|
|
879
|
+
staff.find((x) => (x.soloBots ?? []).includes(item.author));
|
|
880
|
+
if (!s) return "";
|
|
881
|
+
return (s.mention || "@" + s.handle) + " ";
|
|
882
|
+
}
|
|
883
|
+
|
|
766
884
|
async function openReply(item, anchor, onSend) {
|
|
767
885
|
const key = item.repo + "#" + item.number + (anchor ? " · " + anchor.path : "");
|
|
768
886
|
let deaf = null;
|
|
@@ -771,7 +889,7 @@ async function openReply(item, anchor, onSend) {
|
|
|
771
889
|
title: "Reply to " + item.repo + " #" + item.number,
|
|
772
890
|
hint: anchor ? anchor.path : item.title,
|
|
773
891
|
confirm: "Comment",
|
|
774
|
-
value: DRAFTS.get(key) ??
|
|
892
|
+
value: DRAFTS.get(key) ?? addressee(item),
|
|
775
893
|
/* It goes out through whoever's `gh` is signed in here, which is only knowably one person
|
|
776
894
|
when the org has one. With two it would be a guess, and a wrong name on a reply box is
|
|
777
895
|
worse than no name. */
|
|
@@ -782,6 +900,10 @@ async function openReply(item, anchor, onSend) {
|
|
|
782
900
|
else DRAFTS.delete(key);
|
|
783
901
|
};
|
|
784
902
|
deaf = deafNote(ta, item, anchor);
|
|
903
|
+
// Typing starts after the @mention, not in front of it.
|
|
904
|
+
setTimeout(() => {
|
|
905
|
+
ta.selectionStart = ta.selectionEnd = ta.value.length;
|
|
906
|
+
}, 0);
|
|
785
907
|
return el("div", {}, [attachBox(ta, () => item.repo).node, deaf.node]);
|
|
786
908
|
},
|
|
787
909
|
});
|
|
@@ -1050,15 +1172,36 @@ function xref(source, item, openThread) {
|
|
|
1050
1172
|
return b;
|
|
1051
1173
|
}
|
|
1052
1174
|
|
|
1175
|
+
/* Who wrote something, as a person would say it: a staff member by their role, with their
|
|
1176
|
+
icon; one of the humans by name; anyone else by their login. */
|
|
1177
|
+
function whoWrote(login) {
|
|
1178
|
+
const s = (S.data?.staff ?? []).find((x) => (x.soloBots ?? []).includes(login));
|
|
1179
|
+
if (s) return { name: s.name, glyph: staffIcon(s), kind: "staff" };
|
|
1180
|
+
if (isHuman(login)) {
|
|
1181
|
+
const h = humansOf().find((x) => x.github === login);
|
|
1182
|
+
return { name: h?.name ?? login, glyph: "person", kind: "human" };
|
|
1183
|
+
}
|
|
1184
|
+
return { name: login || "?", glyph: "person", kind: "other" };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/* A comment is a card: a header strip saying who and when, then what they said. As text
|
|
1188
|
+
between thin rules, it was hard to see where one message ended and the next began. */
|
|
1053
1189
|
function comment({ author, when, body, first, repo, badge, reactions }) {
|
|
1054
|
-
const
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1190
|
+
const who = whoWrote(author);
|
|
1191
|
+
const d = el("div", { className: "cmt " + who.kind + (first ? " first" : "") });
|
|
1192
|
+
const head = el("div", { className: "chead" });
|
|
1193
|
+
head.append(icon(who.glyph, "ic"), el("b", { textContent: who.name }));
|
|
1194
|
+
if (who.name !== author && author) head.append(el("span", { className: "clogin", textContent: author }));
|
|
1195
|
+
if (badge) head.append(el("span", { className: "pill", textContent: badge }));
|
|
1196
|
+
head.append(
|
|
1197
|
+
el("span", { className: "cwhen", title: new Date(when).toLocaleString(), textContent: ago(when) }),
|
|
1198
|
+
);
|
|
1199
|
+
d.append(head);
|
|
1200
|
+
const bodyEl = el("div", { className: "cbodywrap" });
|
|
1201
|
+
bodyEl.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
|
|
1060
1202
|
const marks = reactionRow(reactions);
|
|
1061
|
-
if (marks)
|
|
1203
|
+
if (marks) bodyEl.append(marks);
|
|
1204
|
+
d.append(bodyEl);
|
|
1062
1205
|
return d;
|
|
1063
1206
|
}
|
|
1064
1207
|
|
|
@@ -1206,9 +1349,22 @@ function newIssueForm(viewer) {
|
|
|
1206
1349
|
body: body.value,
|
|
1207
1350
|
labels: picked.chosen(),
|
|
1208
1351
|
});
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1352
|
+
/* The form is done once GitHub has the issue. What you sent is shown as what it now is,
|
|
1353
|
+
a thread in the inbox, open and selected, rather than a link out to github.com beside
|
|
1354
|
+
a form still holding what you typed. The filters are set so its row is on the list. */
|
|
1355
|
+
const number = Number(String(r.url ?? "").match(/\/issues\/(\d+)/)?.[1]);
|
|
1356
|
+
S.inboxOpen = number ? { repo: s.brain, number, kind: "issue" } : null;
|
|
1357
|
+
S.inboxState = "";
|
|
1358
|
+
S.inboxFilter = "";
|
|
1359
|
+
S.query = "";
|
|
1360
|
+
if (S.inboxStaff && S.inboxStaff !== s.handle) S.inboxStaff = s.handle;
|
|
1361
|
+
writeHash(false);
|
|
1362
|
+
status.textContent = "sent · opening it…";
|
|
1363
|
+
// It is sent either way. A refresh that fails must not read as a send that failed.
|
|
1364
|
+
await refreshAll(false).catch(() => {
|
|
1365
|
+
status.textContent = "sent · refresh to see it";
|
|
1366
|
+
});
|
|
1367
|
+
return;
|
|
1212
1368
|
} catch (e) {
|
|
1213
1369
|
status.textContent = e.message;
|
|
1214
1370
|
status.className = "meta err";
|