@nanocollective/roster 0.1.0-alpha.2 → 0.1.0-alpha.4
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/dist/cli.js +848 -164
- package/docs/README.md +10 -5
- package/docs/agents.md +320 -9
- package/docs/commands.md +5 -6
- package/docs/concepts.md +27 -9
- package/docs/cost.md +3 -2
- package/docs/doctor-codes.md +13 -4
- package/docs/export.md +2 -1
- package/docs/extending.md +11 -2
- package/docs/getting-started.md +89 -84
- 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 +36 -13
- package/docs/memory.md +9 -6
- package/docs/org-yaml.md +37 -9
- package/docs/portal.md +197 -31
- package/docs/prompts.md +50 -11
- package/docs/security.md +19 -7
- package/docs/session-workflow.md +8 -10
- package/docs/staff-yaml.md +3 -5
- package/docs/troubleshooting.md +17 -14
- package/docs/writing-a-charter.md +18 -17
- package/package.json +1 -1
- package/templates/brain/.github/workflows/%%STAFF%%-daily.yaml +1 -0
- package/templates/brain/.github/workflows/%%STAFF%%-mention.yaml +10 -4
- package/templates/brain/staff.yaml +0 -1
- package/templates/ops/.github/workflows/session.yaml +9 -26
- package/templates/ops/agents.mjs +121 -6
- package/templates/ops/compose.mjs +61 -4
- package/templates/ops/org/operating.md +0 -6
- package/templates/ops/prompts/_identity.md +8 -1
- package/templates/ops/prompts/mention.md +16 -2
- package/templates/portal/css/base.css +122 -8
- package/templates/portal/css/brain.css +8 -1
- package/templates/portal/css/diff.css +6 -2
- package/templates/portal/css/health.css +21 -2
- package/templates/portal/css/inbox.css +93 -5
- package/templates/portal/css/layout.css +26 -4
- package/templates/portal/css/markdown.css +23 -3
- package/templates/portal/css/setup.css +11 -6
- package/templates/portal/index.html +7 -1
- package/templates/portal/js/api.js +33 -0
- package/templates/portal/js/app.js +33 -7
- package/templates/portal/js/dialog.js +47 -4
- package/templates/portal/js/dom.js +25 -0
- package/templates/portal/js/icons.js +8 -1
- package/templates/portal/js/lightbox.js +273 -0
- package/templates/portal/js/md.js +23 -6
- package/templates/portal/js/mention.js +264 -0
- package/templates/portal/js/refresh.js +136 -6
- package/templates/portal/js/state.js +47 -5
- package/templates/portal/js/views/checklist.js +20 -7
- package/templates/portal/js/views/docs.js +94 -4
- package/templates/portal/js/views/files.js +58 -14
- package/templates/portal/js/views/health.js +163 -35
- package/templates/portal/js/views/inbox.js +882 -96
- package/templates/portal/js/views/memory.js +16 -1
- package/templates/portal/js/views/org.js +142 -62
- package/templates/portal/js/views/prompt.js +50 -63
- package/templates/portal/js/views/setup.js +37 -13
- package/templates/portal/js/views/staff.js +62 -2
- package/templates/portal/js/yaml.js +134 -0
- package/templates/brain/.github/workflows/%%STAFF%%-pr-mention.yaml +0 -50
- package/templates/ops/prompts/pr-mention.md +0 -57
|
@@ -1,17 +1,72 @@
|
|
|
1
1
|
/* The inbox: everything open across the org, and the thread beside it. */
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { getLabels, getPr, getThread, post, upload } from "../api.js";
|
|
4
|
+
import { askText } from "../dialog.js";
|
|
5
|
+
import { ago, el, esc, markCurrent, skeleton } from "../dom.js";
|
|
5
6
|
import { icon, iconHTML } from "../icons.js";
|
|
6
7
|
import { mdlite } from "../md.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
8
|
+
import { attachMentions } from "../mention.js";
|
|
9
|
+
import { ensureInbox, refreshAll, stampCounts } from "../refresh.js";
|
|
10
|
+
import { humanLabel, humansOf, isHuman, S, writeHash } from "../state.js";
|
|
11
|
+
import { renderDiff } from "./changed.js";
|
|
9
12
|
|
|
13
|
+
/* The labels roster itself creates, and what each one means about urgency. Everything else is
|
|
14
|
+
the org's own vocabulary and stays plain: colouring a word this file has never heard of
|
|
15
|
+
would be guessing. */
|
|
10
16
|
const LABEL_TONE = {
|
|
11
|
-
decision: "hot", blocked: "hot",
|
|
17
|
+
decision: "hot", blocked: "hot", review: "warm", submit: "warm",
|
|
12
18
|
idea: "cool", build: "cool", setup: "cool", data: "cool",
|
|
13
19
|
};
|
|
14
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A label's tone.
|
|
23
|
+
*
|
|
24
|
+
* `roster hire` puts a label on every tracker named after the human's marker, so "this is on a
|
|
25
|
+
* person" is a label like `will` or `sam` — it used to be spelled `will` in the map above,
|
|
26
|
+
* which coloured exactly one org's and left every other org's looking like an ordinary tag.
|
|
27
|
+
* It comes off org.yaml now, so it is right wherever this runs.
|
|
28
|
+
*/
|
|
29
|
+
function tone(label) {
|
|
30
|
+
if (LABEL_TONE[label]) return LABEL_TONE[label];
|
|
31
|
+
const name = String(label).toLowerCase();
|
|
32
|
+
return humansOf().some(
|
|
33
|
+
(h) => String(h.marker ?? "").toLowerCase() === name ||
|
|
34
|
+
String(h.github ?? "").toLowerCase() === name,
|
|
35
|
+
)
|
|
36
|
+
? "hot"
|
|
37
|
+
: "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The staff member this repo is the tracker for, or null.
|
|
42
|
+
*
|
|
43
|
+
* A staff member's caller workflow lives in their own brain repo and gates on their `@handle`
|
|
44
|
+
* appearing there. So a mention is a mechanism in exactly one repository per person, and
|
|
45
|
+
* decoration in every other — including the product repos, where the pull requests are.
|
|
46
|
+
*
|
|
47
|
+
* That asymmetry is invisible on the page otherwise: the reply box takes `@cto` anywhere and
|
|
48
|
+
* posts it anywhere, and the difference between "asked the CTO" and "typed their name" is a
|
|
49
|
+
* workflow file in another repository. This is what the two things below use to say so.
|
|
50
|
+
*/
|
|
51
|
+
function listener(repo) {
|
|
52
|
+
return S.data.staff.find((s) => s.brain && s.brain === repo) ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The handles mentioned in some text, as staff members. */
|
|
56
|
+
function mentioned(text) {
|
|
57
|
+
const said = String(text ?? "");
|
|
58
|
+
return S.data.staff.filter((s) => {
|
|
59
|
+
const at = String(s.mention ?? "@" + s.handle);
|
|
60
|
+
return new RegExp("(^|\\s)" + at.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(\\s|$)", "i").test(said);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Half-written replies, by thread, for as long as the page is open. */
|
|
65
|
+
const DRAFTS = new Map();
|
|
66
|
+
|
|
67
|
+
/** A pull request's commits and diff, once fetched. Same lifetime, same reason. */
|
|
68
|
+
const PRS = new Map();
|
|
69
|
+
|
|
15
70
|
/* Bookkeeping. Real history, worth being able to see, but not what you opened the thread
|
|
16
71
|
to read — so a run of it folds away. */
|
|
17
72
|
const QUIET = new Set([
|
|
@@ -37,9 +92,29 @@ export function belongsTo(item, s) {
|
|
|
37
92
|
return false;
|
|
38
93
|
}
|
|
39
94
|
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
95
|
+
export const viewInbox = (m) => inboxScreen(m, {});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The same screen, scoped to pull requests.
|
|
99
|
+
*
|
|
100
|
+
* Its own place in the sidebar because open PRs are a different question from an inbox: an
|
|
101
|
+
* inbox is what is waiting on you, and a PR is work that is finished and waiting on a merge.
|
|
102
|
+
* Everything else — the threads, the reactions, the commits and the diff — is the one
|
|
103
|
+
* implementation, because a PR is a thread with more on it and not a second kind of screen.
|
|
104
|
+
*/
|
|
105
|
+
export const viewPrs = (m) => inboxScreen(m, { prs: true });
|
|
106
|
+
|
|
107
|
+
function inboxScreen(m, opts) {
|
|
108
|
+
/* "Pending work", not "Pull requests". Every one of these is a piece of work a staff member
|
|
109
|
+
has finished and cannot land on their own, which is the thing you are being asked about.
|
|
110
|
+
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" : "Inbox" }));
|
|
112
|
+
const sub = el("p", {
|
|
113
|
+
className: "sub",
|
|
114
|
+
textContent: opts.prs
|
|
115
|
+
? "Work the staff have finished and cannot land themselves."
|
|
116
|
+
: "Everything open across the org.",
|
|
117
|
+
});
|
|
43
118
|
m.append(sub);
|
|
44
119
|
|
|
45
120
|
const search = el("input", {
|
|
@@ -50,11 +125,16 @@ export function viewInbox(m) {
|
|
|
50
125
|
const scope = el("select");
|
|
51
126
|
scope.append(
|
|
52
127
|
el("option", { value: "", textContent: "Everything" }),
|
|
53
|
-
el("option", { value: "mine", textContent: "On " + (
|
|
54
|
-
|
|
55
|
-
|
|
128
|
+
el("option", { value: "mine", textContent: "On " + humanLabel() }),
|
|
129
|
+
// Scoping pull requests to pull requests is not a filter, and a decision is not a PR.
|
|
130
|
+
...(opts.prs
|
|
131
|
+
? []
|
|
132
|
+
: [
|
|
133
|
+
el("option", { value: "decision", textContent: "Decisions" }),
|
|
134
|
+
el("option", { value: "pr", textContent: "Open work (PRs)" }),
|
|
135
|
+
]),
|
|
56
136
|
);
|
|
57
|
-
scope.value = S.inboxFilter;
|
|
137
|
+
scope.value = opts.prs && S.inboxFilter !== "mine" ? "" : S.inboxFilter;
|
|
58
138
|
|
|
59
139
|
/* Open only by default. An inbox is what is waiting on somebody, and burying that under
|
|
60
140
|
five months of finished work would be answering a different question. */
|
|
@@ -77,13 +157,17 @@ export function viewInbox(m) {
|
|
|
77
157
|
|
|
78
158
|
const refresh = el("button", { className: "iconbtn", title: "Refresh from GitHub" });
|
|
79
159
|
refresh.innerHTML = '<span class="sync">' + iconHTML("refresh") + "</span>";
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
el("
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
160
|
+
const controls = [search, whose, state, scope, refresh];
|
|
161
|
+
// A pull request comes from a branch, so there is nothing here that could open one.
|
|
162
|
+
if (!opts.prs) {
|
|
163
|
+
const newBtn = el("button", { className: "ghbtn", textContent: "New issue" });
|
|
164
|
+
newBtn.onclick = () => {
|
|
165
|
+
delete viewer.dataset.wait;
|
|
166
|
+
newIssueForm(viewer);
|
|
167
|
+
};
|
|
168
|
+
controls.push(newBtn);
|
|
169
|
+
}
|
|
170
|
+
m.append(el("div", { className: "row", style: "margin-bottom:16px" }, controls));
|
|
87
171
|
|
|
88
172
|
const split = el("div", { className: "split" });
|
|
89
173
|
const list = el("div", { className: "tree" });
|
|
@@ -97,45 +181,79 @@ export function viewInbox(m) {
|
|
|
97
181
|
state.onchange = () => { S.inboxState = state.value; writeHash(false); paint(); };
|
|
98
182
|
refresh.onclick = () => { refresh.classList.add("spin"); load(true); };
|
|
99
183
|
|
|
100
|
-
if (S.inbox) {
|
|
184
|
+
if (S.inbox) { stampCounts(); paint(); restore(); } else load(false);
|
|
101
185
|
|
|
102
186
|
/** A `?t=` in the URL names a thread. It used to be read into the state and then never
|
|
103
187
|
acted on, because only the already-loaded branch opened one. */
|
|
104
188
|
function restore() {
|
|
105
|
-
if (S.inboxOpen)
|
|
189
|
+
if (!S.inboxOpen) return;
|
|
190
|
+
// The two screens share one open thread. An issue carried over from the inbox is not on
|
|
191
|
+
// this one, so it is dropped rather than opened beside a list it is not in.
|
|
192
|
+
if (opts.prs && S.inboxOpen.kind !== "pr") {
|
|
193
|
+
S.inboxOpen = null;
|
|
194
|
+
writeHash(false);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
openThread(S.inboxOpen);
|
|
106
198
|
}
|
|
107
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Fetch, through the one request the shell may already have in flight.
|
|
202
|
+
*
|
|
203
|
+
* Waiting looks like what is coming: rows in the list, a thread in the viewer. The reads run
|
|
204
|
+
* across every repo in the org, so on a cold cache this is seconds, and seconds of a blank
|
|
205
|
+
* panel beside a blank panel reads as a page that has failed rather than one that is working.
|
|
206
|
+
*/
|
|
108
207
|
async function load(force) {
|
|
109
|
-
|
|
208
|
+
sub.dataset.busy = "1";
|
|
209
|
+
list.replaceChildren(
|
|
210
|
+
el("p", { className: "skhead", textContent: "Asking GitHub…" }),
|
|
211
|
+
...skeleton("row", 7),
|
|
212
|
+
);
|
|
213
|
+
/* The flag is what makes the placeholder safe to remove: you can open New issue while this
|
|
214
|
+
is still running, and the arriving inbox must not throw away what you have started
|
|
215
|
+
typing. Anything that writes real content into the viewer clears it. */
|
|
216
|
+
if (!S.inboxOpen) {
|
|
217
|
+
viewer.dataset.wait = "1";
|
|
218
|
+
viewer.replaceChildren(...skeleton("head", 1), ...skeleton("line", 6));
|
|
219
|
+
}
|
|
110
220
|
try {
|
|
111
|
-
|
|
221
|
+
await ensureInbox(force);
|
|
112
222
|
} catch (e) {
|
|
113
223
|
refresh.classList.remove("spin");
|
|
224
|
+
delete sub.dataset.busy;
|
|
225
|
+
clearPlaceholder();
|
|
114
226
|
list.replaceChildren(
|
|
115
227
|
el("p", { className: "empty err", textContent: "Could not reach GitHub: " + e.message }),
|
|
116
228
|
);
|
|
117
229
|
return;
|
|
118
230
|
}
|
|
119
231
|
refresh.classList.remove("spin");
|
|
120
|
-
|
|
232
|
+
delete sub.dataset.busy;
|
|
233
|
+
stampCounts();
|
|
121
234
|
paint();
|
|
122
235
|
restore();
|
|
236
|
+
// Nothing was selected, so the placeholder thread has nothing to become.
|
|
237
|
+
clearPlaceholder();
|
|
123
238
|
}
|
|
124
239
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
240
|
+
/** Take the waiting shapes away, and leave alone anything that replaced them. */
|
|
241
|
+
function clearPlaceholder() {
|
|
242
|
+
if (viewer.dataset.wait !== "1") return;
|
|
243
|
+
delete viewer.dataset.wait;
|
|
244
|
+
viewer.replaceChildren();
|
|
130
245
|
}
|
|
131
246
|
|
|
132
247
|
function paint() {
|
|
133
248
|
if (!S.inbox) return;
|
|
134
249
|
const q = S.query.trim().toLowerCase();
|
|
135
|
-
|
|
250
|
+
// "On you" is on any of the humans this org answers to, not only the first one.
|
|
251
|
+
const onAHuman = (i) => (i.assignees ?? []).some(isHuman);
|
|
136
252
|
|
|
137
253
|
const whoseStaff = S.inboxStaff ? S.data.staff.find((s) => s.handle === S.inboxStaff) : null;
|
|
138
|
-
const mine = S.inbox.items
|
|
254
|
+
const mine = S.inbox.items
|
|
255
|
+
.filter((i) => belongsTo(i, whoseStaff))
|
|
256
|
+
.filter((i) => !opts.prs || i.kind === "pr");
|
|
139
257
|
const isOpen = (i) => i.state === "OPEN";
|
|
140
258
|
const scoped = mine.filter(
|
|
141
259
|
S.inboxState === "closed" ? (i) => !isOpen(i) : S.inboxState === "all" ? () => true : isOpen,
|
|
@@ -145,23 +263,31 @@ export function viewInbox(m) {
|
|
|
145
263
|
(i) => !q || (i.title + " " + i.repo + " " + i.labels.join(" ") + " #" + i.number)
|
|
146
264
|
.toLowerCase().includes(q),
|
|
147
265
|
);
|
|
148
|
-
if (S.inboxFilter === "mine") items = items.filter(
|
|
266
|
+
if (S.inboxFilter === "mine") items = items.filter(onAHuman);
|
|
149
267
|
else if (S.inboxFilter === "decision") items = items.filter((i) => i.labels.includes("decision"));
|
|
150
268
|
else if (S.inboxFilter === "pr") items = items.filter((i) => i.kind === "pr");
|
|
151
269
|
|
|
152
270
|
const open = mine.filter(isOpen);
|
|
153
|
-
const onYou = open.filter(
|
|
271
|
+
const onYou = open.filter(onAHuman).length;
|
|
154
272
|
const prs = open.filter((i) => i.kind === "pr").length;
|
|
155
273
|
const shut = mine.length - open.length;
|
|
156
274
|
const where = whoseStaff
|
|
157
275
|
? " for <b>" + esc(whoseStaff.name) + "</b>"
|
|
158
276
|
: " across " + (S.inbox.repos ?? []).length + " repos";
|
|
277
|
+
/* On the PR screen the count that matters is not "how many are open" — it is how many are
|
|
278
|
+
green and still sitting there, because that is the pile you are the bottleneck on. */
|
|
279
|
+
const failing = open.filter((i) => i.checks === "failing").length;
|
|
280
|
+
const ready = open.filter((i) => i.checks === "passing").length;
|
|
159
281
|
sub.innerHTML =
|
|
160
282
|
(S.inboxState === "closed"
|
|
161
283
|
? shut + " closed in the last 45 days" + where
|
|
162
|
-
:
|
|
163
|
-
|
|
164
|
-
|
|
284
|
+
: opts.prs
|
|
285
|
+
? open.length + " open" + where + " · <b>" + ready + " with checks passing</b>" +
|
|
286
|
+
(failing ? " · " + failing + " failing" : "") +
|
|
287
|
+
(S.inboxState === "all" ? " · " + shut + " closed or merged" : "")
|
|
288
|
+
: open.length + " open" + where + " · <b>" + onYou + " on " +
|
|
289
|
+
esc(humanLabel()) + "</b> · " + prs + " waiting on a merge" +
|
|
290
|
+
(S.inboxState === "all" ? " · " + shut + " closed" : "")) +
|
|
165
291
|
(S.inbox.fetchedAt ? ' <span class="meta">· checked ' + ago(S.inbox.fetchedAt) + "</span>" : "");
|
|
166
292
|
|
|
167
293
|
list.replaceChildren();
|
|
@@ -180,13 +306,14 @@ export function viewInbox(m) {
|
|
|
180
306
|
for (const i of items) {
|
|
181
307
|
const shutState = i.state === "MERGED" ? "merged" : i.state === "OPEN" ? "" : "closed";
|
|
182
308
|
const b = el("button", { className: "irow" + (shutState ? " shut" : "") });
|
|
183
|
-
const yours = i
|
|
309
|
+
const yours = onAHuman(i);
|
|
184
310
|
const chips = i.labels
|
|
185
311
|
.slice(0, 3)
|
|
186
|
-
.map((l) => '<span class="chip ' + (
|
|
312
|
+
.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>")
|
|
187
313
|
.join("");
|
|
188
314
|
b.innerHTML =
|
|
189
|
-
'<div class="ititle">' +
|
|
315
|
+
'<div class="ititle">' +
|
|
316
|
+
(yours ? '<span class="dot" title="assigned to ' + esc(humanLabel()) + '"></span>' : "") +
|
|
190
317
|
esc(i.title) + "</div>" +
|
|
191
318
|
'<div class="imeta">' +
|
|
192
319
|
'<span class="repo">' + esc(i.repo.split("/")[1]) + "</span>" +
|
|
@@ -197,6 +324,12 @@ export function viewInbox(m) {
|
|
|
197
324
|
'<span class="num">' + (i.kind === "pr" ? "PR " : "") + "#" + i.number + "</span>" +
|
|
198
325
|
(i.checks && i.checks !== "none"
|
|
199
326
|
? '<span class="ck ' + i.checks + '">' + checkGlyph(i.checks) + "</span>" : "") +
|
|
327
|
+
// How much conversation is on a thread, which is most of what tells a live one from
|
|
328
|
+
// 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>"
|
|
332
|
+
: "") +
|
|
200
333
|
chips +
|
|
201
334
|
'<span class="when">' + ago(i.updatedAt) + "</span>" +
|
|
202
335
|
"</div>";
|
|
@@ -228,80 +361,321 @@ export function viewInbox(m) {
|
|
|
228
361
|
|
|
229
362
|
const head = el("div", { className: "thead" });
|
|
230
363
|
head.innerHTML =
|
|
231
|
-
'<div class="
|
|
232
|
-
'<
|
|
233
|
-
|
|
234
|
-
" · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
|
|
235
|
-
'<a class="ghbtn" href="' + esc(item.url) + '" target="_blank" rel="noopener">Open in GitHub</a>' +
|
|
236
|
-
"</div>" +
|
|
364
|
+
'<div class="meta">' + esc(item.repo) + " · " + (item.kind === "pr" ? "PR " : "") + "#" + item.number +
|
|
365
|
+
' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
|
|
366
|
+
" · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
|
|
237
367
|
"<h3>" + esc(item.title) + "</h3>" +
|
|
238
368
|
(item.labels.length
|
|
239
|
-
? '<div class="row
|
|
240
|
-
item.labels.map((l) => '<span class="chip ' + (
|
|
369
|
+
? '<div class="row tlabels">' +
|
|
370
|
+
item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
|
|
241
371
|
"</div>"
|
|
242
372
|
: "");
|
|
373
|
+
head.append(threadActions(item));
|
|
374
|
+
delete viewer.dataset.wait;
|
|
243
375
|
viewer.replaceChildren(head);
|
|
244
376
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
377
|
+
/* A pull request is a conversation, a set of commits and a diff. The inbox carries the
|
|
378
|
+
first; the other two are a click away rather than in every refresh of every repo.
|
|
379
|
+
An issue has only the conversation, so it stays flat in the viewer rather than paying
|
|
380
|
+
for a wrapper that would only ever hold one thing. */
|
|
381
|
+
const pane = item.kind === "pr" ? el("div") : viewer;
|
|
382
|
+
if (item.kind === "pr") viewer.append(prTabs(item, pane), pane);
|
|
383
|
+
conversation(pane, item);
|
|
248
384
|
viewer.scrollTop = 0;
|
|
249
385
|
}
|
|
250
386
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
387
|
+
/** Appends; whoever is swapping panes owns clearing them. */
|
|
388
|
+
function conversation(pane, item) {
|
|
389
|
+
/* Newest first, opening post last. GitHub's own order puts the answer you came for at the
|
|
390
|
+
bottom of a year of bookkeeping, and the thing you do most on this screen is read what
|
|
391
|
+
just happened. The title and the actions are in the header, so nothing you need is
|
|
392
|
+
further down than the first screenful. */
|
|
393
|
+
const from = pane.children.length;
|
|
394
|
+
for (const node of timeline(item, openThread)) pane.append(node);
|
|
395
|
+
pane.append(
|
|
396
|
+
comment({
|
|
397
|
+
author: item.author, when: item.createdAt, body: item.body, first: true,
|
|
398
|
+
repo: item.repo, reactions: item.reactions,
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
/* Whatever ends up first drops its own top rule. The header already draws a line under
|
|
402
|
+
itself, and with the thread reversed the first thing under it is any of three shapes. */
|
|
403
|
+
pane.children[from]?.classList?.add("lead");
|
|
404
|
+
}
|
|
260
405
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Conversation, commits, files — for pull requests only.
|
|
408
|
+
*
|
|
409
|
+
* The detail is fetched once per thread and kept, so switching back and forth is free. A
|
|
410
|
+
* failure is shown in the pane rather than swallowed: "no diff" and "GitHub would not give
|
|
411
|
+
* me the diff" are different answers.
|
|
412
|
+
*/
|
|
413
|
+
function prTabs(item, pane) {
|
|
414
|
+
const bar = el("div", { className: "tabs" });
|
|
415
|
+
const key = item.repo + "#" + item.number;
|
|
416
|
+
let detail = PRS.get(key) ?? null;
|
|
417
|
+
|
|
418
|
+
const tabs = [
|
|
419
|
+
["conversation", "Conversation", () => { pane.replaceChildren(); conversation(pane, item); }],
|
|
420
|
+
["commits", "Commits", () => paintCommits(pane, detail)],
|
|
421
|
+
["files", "Files", () => paintFiles(pane, detail, item)],
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
const pick = async (id) => {
|
|
425
|
+
for (const b of bar.children) b.setAttribute("aria-current", String(b.dataset.tab === id));
|
|
426
|
+
if (id === "conversation") {
|
|
427
|
+
pane.replaceChildren();
|
|
428
|
+
conversation(pane, item);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!detail) {
|
|
432
|
+
pane.replaceChildren(el("p", { className: "empty", textContent: "Asking GitHub…" }));
|
|
433
|
+
try {
|
|
434
|
+
detail = await getPr(item.repo, item.number);
|
|
435
|
+
PRS.set(key, detail);
|
|
436
|
+
} catch (e) {
|
|
437
|
+
pane.replaceChildren(el("p", { className: "empty err", textContent: e.message }));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (detail.error) {
|
|
442
|
+
pane.replaceChildren(el("p", { className: "empty err", textContent: detail.error }));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
tabs.find((t) => t[0] === id)[2]();
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
for (const [id, label] of tabs) {
|
|
449
|
+
const b = el("button", { className: "tab", textContent: label });
|
|
450
|
+
b.dataset.tab = id;
|
|
451
|
+
b.setAttribute("aria-current", String(id === "conversation"));
|
|
452
|
+
b.onclick = () => pick(id);
|
|
453
|
+
bar.append(b);
|
|
454
|
+
}
|
|
455
|
+
return bar;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function paintCommits(pane, d) {
|
|
459
|
+
pane.replaceChildren(
|
|
460
|
+
el("p", { className: "meta", style: "margin:14px 0 10px",
|
|
461
|
+
textContent: d.commits.length + " commits · " + d.head + " → " + d.base }),
|
|
462
|
+
);
|
|
463
|
+
if (!d.commits.length) {
|
|
464
|
+
pane.append(el("p", { className: "empty", textContent: "No commits on this branch yet." }));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
for (const c of d.commits) {
|
|
468
|
+
const row = el("div", { className: "prcommit" });
|
|
469
|
+
row.append(
|
|
470
|
+
el("a", { className: "sha", href: c.url, target: "_blank", rel: "noopener", textContent: c.sha }),
|
|
471
|
+
el("span", { className: "subj", textContent: c.subject }),
|
|
472
|
+
el("span", { className: "meta", textContent: c.author + (c.date ? " · " + ago(c.date) : "") }),
|
|
473
|
+
);
|
|
474
|
+
pane.append(row);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function paintFiles(pane, d, item) {
|
|
479
|
+
pane.replaceChildren(
|
|
480
|
+
el("p", { className: "meta", style: "margin:14px 0 10px",
|
|
481
|
+
textContent: d.changedFiles + " files · +" + d.additions + " −" + d.deletions }),
|
|
482
|
+
);
|
|
483
|
+
if (!d.files.length) {
|
|
484
|
+
pane.append(el("p", { className: "empty", textContent: "This pull request changes nothing." }));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
for (const f of d.files) {
|
|
488
|
+
/* renderDiff reads a git diff, so each file's patch is given the header the API leaves
|
|
489
|
+
off. A file with no patch is binary or too big for GitHub to send one — say which. */
|
|
490
|
+
if (!f.patch) {
|
|
491
|
+
const wrap = el("div", { className: "dblock" });
|
|
492
|
+
const h = el("div", { className: "dfile" });
|
|
493
|
+
h.innerHTML = "<b>" + esc(f.path) + "</b>" +
|
|
494
|
+
'<span class="p">+' + f.additions + '</span><span class="m">−' + f.deletions + "</span>";
|
|
495
|
+
wrap.append(h, el("p", { className: "dempty",
|
|
496
|
+
textContent: "No patch: this file is binary or too large for the API to send one." }));
|
|
497
|
+
pane.append(wrap);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const blocks = renderDiff("diff --git a/" + f.path + " b/" + f.path + "\n" + f.patch);
|
|
501
|
+
/* The ask goes in the file's own heading, because "this file is wrong" is the thing you
|
|
502
|
+
want to say while looking at one file, and the alternative is describing in prose which
|
|
503
|
+
of thirty files you meant. The hunk goes with it, so the agent gets the part you were
|
|
504
|
+
reading rather than the whole diff and a guess. */
|
|
505
|
+
const head = blocks[0]?.children?.[0];
|
|
506
|
+
if (item && head && String(head.className ?? "").includes("dfile")) {
|
|
507
|
+
head.append(replyAboutFile(item, f));
|
|
508
|
+
}
|
|
509
|
+
for (const block of blocks) pane.append(block);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The same reply box, opened about one file.
|
|
515
|
+
*
|
|
516
|
+
* "This bit is wrong" is what you want to say while looking at one file, and the alternative
|
|
517
|
+
* is describing in prose which of thirty files you meant. It is not a second kind of thing:
|
|
518
|
+
* it opens the composer the Reply button opens, with the file remembered, so the hunk rides
|
|
519
|
+
* along to whoever you name in it.
|
|
520
|
+
*/
|
|
521
|
+
function replyAboutFile(item, f) {
|
|
522
|
+
const b = el("button", {
|
|
523
|
+
className: "ghbtn askfile",
|
|
524
|
+
textContent: "Reply",
|
|
525
|
+
title: "Reply about " + f.path,
|
|
265
526
|
});
|
|
527
|
+
b.onclick = async () => {
|
|
528
|
+
b.disabled = true;
|
|
529
|
+
try {
|
|
530
|
+
const r = await openReply(item, f);
|
|
531
|
+
if (r) b.textContent = r.warning ? "sent, with a warning" : "sent";
|
|
532
|
+
} catch (e) {
|
|
533
|
+
b.textContent = e.message;
|
|
534
|
+
}
|
|
535
|
+
b.disabled = false;
|
|
536
|
+
};
|
|
537
|
+
return b;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Reply, close, merge — at the top of the thread, next to the title.
|
|
542
|
+
*
|
|
543
|
+
* They used to sit under the last comment, which meant scrolling a long thread to the end to
|
|
544
|
+
* answer it, and then the thread put the newest thing there too. The actions are about the
|
|
545
|
+
* thread rather than about its last message, so they belong where the thread starts. Writing
|
|
546
|
+
* happens in a dialog for the same reason: a box you can only reach by scrolling is a box you
|
|
547
|
+
* stop using.
|
|
548
|
+
*
|
|
549
|
+
* All of it goes out as the human, through their own `gh`, so it is indistinguishable from
|
|
550
|
+
* doing it on the site.
|
|
551
|
+
*/
|
|
552
|
+
function threadActions(item) {
|
|
553
|
+
const key = item.repo + "#" + item.number;
|
|
554
|
+
const status = el("span", { className: "meta" });
|
|
555
|
+
const buttons = [];
|
|
266
556
|
|
|
267
557
|
const busy = (on, msg) => {
|
|
268
|
-
|
|
558
|
+
for (const b of buttons) b.disabled = on;
|
|
269
559
|
status.textContent = msg ?? "";
|
|
270
560
|
status.className = "meta";
|
|
271
561
|
};
|
|
272
562
|
const failed = (e) => {
|
|
273
563
|
status.textContent = e.message;
|
|
274
564
|
status.className = "meta err";
|
|
565
|
+
busy(false);
|
|
566
|
+
status.className = "meta err";
|
|
275
567
|
};
|
|
276
568
|
|
|
277
|
-
send
|
|
278
|
-
|
|
279
|
-
|
|
569
|
+
/* A reply you started and did not send survives closing the dialog, and comes back the
|
|
570
|
+
next time you open it rather than being lost to a stray Escape. `compose` is kept for
|
|
571
|
+
the close button below, which also takes a parting comment. */
|
|
572
|
+
const compose = ({ title, hint, confirm, allowEmpty }) =>
|
|
573
|
+
askText({
|
|
574
|
+
title,
|
|
575
|
+
hint,
|
|
576
|
+
confirm,
|
|
577
|
+
allowEmpty,
|
|
578
|
+
value: DRAFTS.get(key) ?? "",
|
|
579
|
+
placeholder:
|
|
580
|
+
"Reply as " + (humansOf().length === 1 ? humansOf()[0].github : "yourself") + "…",
|
|
581
|
+
decorate: (ta) => {
|
|
582
|
+
ta.oninput = () => {
|
|
583
|
+
if (ta.value) DRAFTS.set(key, ta.value);
|
|
584
|
+
else DRAFTS.delete(key);
|
|
585
|
+
};
|
|
586
|
+
return attachBox(ta, () => item.repo).node;
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
/* One button, because there was no way to tell the two apart. Reply reaches whoever you
|
|
591
|
+
named in it; see `openReply`. */
|
|
592
|
+
const reply = el("button", { className: "ghbtn primary", textContent: "Reply" });
|
|
593
|
+
reply.onclick = async () => {
|
|
594
|
+
let r;
|
|
280
595
|
try {
|
|
281
|
-
await
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
596
|
+
r = await openReply(item, null);
|
|
597
|
+
} catch (e) {
|
|
598
|
+
failed(e);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (!r) return;
|
|
602
|
+
/* Repaint only when something was said on this thread, which is always true here: `ask`
|
|
603
|
+
posts the comment too. A warning means the tracker issue went up and the comment did
|
|
604
|
+
not, and then the status line is the only thing that says so. */
|
|
605
|
+
if (r.warning) {
|
|
606
|
+
status.className = "meta err";
|
|
607
|
+
status.textContent = r.warning;
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
await reloadThread(item);
|
|
285
611
|
};
|
|
612
|
+
buttons.push(reply);
|
|
286
613
|
|
|
614
|
+
const closeBtn = el("button", {
|
|
615
|
+
className: "ghbtn",
|
|
616
|
+
textContent: item.state === "OPEN" ? "Close" : "Reopen",
|
|
617
|
+
});
|
|
287
618
|
closeBtn.onclick = async () => {
|
|
288
619
|
const closing = item.state === "OPEN";
|
|
289
|
-
|
|
290
|
-
if (closing
|
|
620
|
+
let body;
|
|
621
|
+
if (closing) {
|
|
622
|
+
// Closing is the one thing here that is awkward to undo from a phone later, so it asks
|
|
623
|
+
// — and since it is asking anyway, it takes a parting comment.
|
|
624
|
+
body = await compose({
|
|
625
|
+
title: "Close " + item.repo + " #" + item.number + "?",
|
|
626
|
+
hint: "Anything you write here is posted as a comment first. Leave it empty to just close.",
|
|
627
|
+
confirm: "Close it",
|
|
628
|
+
allowEmpty: true,
|
|
629
|
+
});
|
|
630
|
+
if (body === null) return;
|
|
631
|
+
}
|
|
291
632
|
busy(true, closing ? "closing…" : "reopening…");
|
|
292
633
|
try {
|
|
293
634
|
await post({
|
|
294
635
|
action: closing ? "close" : "reopen",
|
|
295
636
|
repo: item.repo,
|
|
296
637
|
number: item.number,
|
|
297
|
-
body:
|
|
638
|
+
body: body || undefined,
|
|
298
639
|
});
|
|
640
|
+
DRAFTS.delete(key);
|
|
299
641
|
await refreshAll(false);
|
|
300
|
-
} catch (e) { failed(e);
|
|
642
|
+
} catch (e) { failed(e); }
|
|
301
643
|
};
|
|
644
|
+
buttons.push(closeBtn);
|
|
302
645
|
|
|
303
|
-
|
|
304
|
-
|
|
646
|
+
const row = el("div", { className: "row tacts" }, buttons);
|
|
647
|
+
|
|
648
|
+
/* Merging cannot be undone with another click, so it asks first and names what it is about
|
|
649
|
+
to merge. It does not ask *how*: squash, merge commit or rebase is a question about git
|
|
650
|
+
rather than about this pull request, and the repository has already answered it in its
|
|
651
|
+
own settings. The server reads it from there. */
|
|
652
|
+
if (item.kind === "pr" && item.state === "OPEN") {
|
|
653
|
+
const merge = el("button", { className: "ghbtn", textContent: "Merge" });
|
|
654
|
+
merge.onclick = async () => {
|
|
655
|
+
if (!confirm("Merge " + item.repo + " #" + item.number + " into its base branch?")) return;
|
|
656
|
+
busy(true, "merging…");
|
|
657
|
+
try {
|
|
658
|
+
await post({ action: "merge", repo: item.repo, number: item.number });
|
|
659
|
+
await refreshAll(false);
|
|
660
|
+
} catch (e) { failed(e); }
|
|
661
|
+
};
|
|
662
|
+
buttons.push(merge);
|
|
663
|
+
row.append(merge);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
row.append(status);
|
|
667
|
+
// Last, and pushed to the far end: it is the way out of this screen rather than one of the
|
|
668
|
+
// things you do on it. Same shape as the rest, so the row reads as one set of controls.
|
|
669
|
+
row.append(
|
|
670
|
+
el("a", {
|
|
671
|
+
className: "ghbtn out",
|
|
672
|
+
href: item.url,
|
|
673
|
+
target: "_blank",
|
|
674
|
+
rel: "noopener",
|
|
675
|
+
textContent: "Open in GitHub",
|
|
676
|
+
}),
|
|
677
|
+
);
|
|
678
|
+
return row;
|
|
305
679
|
}
|
|
306
680
|
|
|
307
681
|
async function reloadThread(item) {
|
|
@@ -318,18 +692,146 @@ export function viewInbox(m) {
|
|
|
318
692
|
/* the comment posted; a stale pane is not worth an error */
|
|
319
693
|
}
|
|
320
694
|
paint();
|
|
695
|
+
// The thread is newest first, so what you just wrote is the first thing on it.
|
|
321
696
|
openThread({ repo: item.repo, number: item.number, kind: item.kind });
|
|
322
697
|
}
|
|
323
698
|
}
|
|
324
699
|
|
|
700
|
+
/* -------------------------- one composer, one box ------------------------- */
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Reply to a thread, and reach whoever you named while doing it.
|
|
704
|
+
*
|
|
705
|
+
* There used to be two buttons on a thread, Reply and "Ask a staff member", and nothing on the
|
|
706
|
+
* page said which one you wanted. They did almost the same thing: both put your words on the
|
|
707
|
+
* thread, and both could open a request on somebody's tracker. The only real difference was
|
|
708
|
+
* that one of them made you pick a name from a dropdown, which is a worse way of saying who you
|
|
709
|
+
* mean than typing their name in the sentence.
|
|
710
|
+
*
|
|
711
|
+
* So there is one box. Type `@cto` in it, and if a comment here would not reach them the offer
|
|
712
|
+
* to open it on their tracker appears underneath, ticked. One press does both.
|
|
713
|
+
*
|
|
714
|
+
* @param anchor the file this is about, when it was opened from a diff. Its hunk rides along.
|
|
715
|
+
* @returns the result of the write, or null if nothing was written.
|
|
716
|
+
*/
|
|
717
|
+
async function openReply(item, anchor) {
|
|
718
|
+
const key = item.repo + "#" + item.number + (anchor ? " · " + anchor.path : "");
|
|
719
|
+
let deaf = null;
|
|
720
|
+
|
|
721
|
+
const body = await askText({
|
|
722
|
+
title: "Reply to " + item.repo + " #" + item.number,
|
|
723
|
+
hint: anchor ? anchor.path : item.title,
|
|
724
|
+
confirm: "Comment",
|
|
725
|
+
value: DRAFTS.get(key) ?? "",
|
|
726
|
+
/* It goes out through whoever's `gh` is signed in here, which is only knowably one person
|
|
727
|
+
when the org has one. With two it would be a guess, and a wrong name on a reply box is
|
|
728
|
+
worse than no name. */
|
|
729
|
+
placeholder: "Reply as " + (humansOf().length === 1 ? humansOf()[0].github : "yourself") + "…",
|
|
730
|
+
decorate: (ta) => {
|
|
731
|
+
ta.oninput = () => {
|
|
732
|
+
if (ta.value) DRAFTS.set(key, ta.value);
|
|
733
|
+
else DRAFTS.delete(key);
|
|
734
|
+
};
|
|
735
|
+
deaf = deafNote(ta, item, anchor);
|
|
736
|
+
return el("div", {}, [attachBox(ta, () => item.repo).node, deaf.node]);
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
if (!body) return null;
|
|
740
|
+
|
|
741
|
+
/* One press, both writes. `ask` posts the comment itself when told to, so these are not two
|
|
742
|
+
requests racing: the tracker issue goes up first because it is the half that actually
|
|
743
|
+
reaches anybody, and the comment on the thread carries a link to it.
|
|
744
|
+
|
|
745
|
+
Who to reach comes off the text that was submitted rather than off what was in the box
|
|
746
|
+
while it was being typed, so a handle you typed and then deleted is not asked. */
|
|
747
|
+
const reaching = listener(item.repo) ? [] : mentioned(body);
|
|
748
|
+
const to = deaf?.declined() ? null : reaching[0];
|
|
749
|
+
if (!to) {
|
|
750
|
+
DRAFTS.delete(key);
|
|
751
|
+
return post({ action: "comment", repo: item.repo, number: item.number, body });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const detail = PRS.get(item.repo + "#" + item.number);
|
|
755
|
+
const r = await post({
|
|
756
|
+
action: "ask",
|
|
757
|
+
repo: to.brain,
|
|
758
|
+
ask: {
|
|
759
|
+
staff: {
|
|
760
|
+
handle: to.handle,
|
|
761
|
+
name: to.name,
|
|
762
|
+
mention: to.mention ?? "@" + to.handle,
|
|
763
|
+
brain: to.brain,
|
|
764
|
+
},
|
|
765
|
+
pr: {
|
|
766
|
+
repo: item.repo,
|
|
767
|
+
number: item.number,
|
|
768
|
+
title: item.title ?? "",
|
|
769
|
+
url: item.url ?? "",
|
|
770
|
+
kind: item.kind,
|
|
771
|
+
// The branch names only. The rest of the detail is commits and every file's patch, and
|
|
772
|
+
// none of it belongs in a request that is about a sentence.
|
|
773
|
+
head: detail?.head,
|
|
774
|
+
base: detail?.base,
|
|
775
|
+
},
|
|
776
|
+
body,
|
|
777
|
+
...(anchor ? { anchor: { path: anchor.path, patch: anchor.patch } } : {}),
|
|
778
|
+
},
|
|
779
|
+
alsoOnPr: true,
|
|
780
|
+
});
|
|
781
|
+
DRAFTS.delete(key);
|
|
782
|
+
return r;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* "You typed a name nobody here answers to." And then an offer to fix it.
|
|
787
|
+
*
|
|
788
|
+
* The `@` list offers every staff member in every box, and posting one on a product repo looks
|
|
789
|
+
* exactly like posting one on a tracker: it goes up, it renders as a chip, and nothing happens.
|
|
790
|
+
*
|
|
791
|
+
* Saying so is not enough. An earlier version told you to post the comment and then go and
|
|
792
|
+
* press the other button, which is the round trip this whole lane exists to remove, with a
|
|
793
|
+
* lecture in front of it. If the box knows who you meant and knows a comment here will not
|
|
794
|
+
* reach them, it should offer to reach them.
|
|
795
|
+
*/
|
|
796
|
+
function deafNote(ta, on, anchor) {
|
|
797
|
+
const line = el("span", {});
|
|
798
|
+
const box = el("input", { type: "checkbox", checked: true, id: "reply-alsoask" });
|
|
799
|
+
const offer = el("label", { className: "meta", htmlFor: "reply-alsoask" }, [
|
|
800
|
+
box,
|
|
801
|
+
el("span", {
|
|
802
|
+
textContent:
|
|
803
|
+
" open it on their tracker too, with a link to this" +
|
|
804
|
+
(anchor ? " and the diff for " + anchor.path : ""),
|
|
805
|
+
}),
|
|
806
|
+
]);
|
|
807
|
+
const note = el("div", { className: "meta err", style: "margin:8px 0 0" }, [line, offer]);
|
|
808
|
+
|
|
809
|
+
const check = () => {
|
|
810
|
+
const deaf = listener(on.repo) ? [] : mentioned(ta.value);
|
|
811
|
+
note.hidden = !deaf.length;
|
|
812
|
+
line.textContent = deaf.length
|
|
813
|
+
? deaf.map((s) => s.mention ?? "@" + s.handle).join(" and ") +
|
|
814
|
+
" is not woken by a comment on " + on.repo + ": it is not their tracker. "
|
|
815
|
+
: "";
|
|
816
|
+
};
|
|
817
|
+
ta.addEventListener("input", check);
|
|
818
|
+
check();
|
|
819
|
+
/* Only whether the offer was declined. Who, and whether there is anybody at all, is worked
|
|
820
|
+
out from the text that was actually submitted. */
|
|
821
|
+
return { node: note, declined: () => !box.checked };
|
|
822
|
+
}
|
|
823
|
+
|
|
325
824
|
/* ------------------------------- the thread ------------------------------ */
|
|
326
825
|
|
|
327
826
|
/**
|
|
328
|
-
* A thread's history,
|
|
329
|
-
*
|
|
827
|
+
* A thread's history, newest first: comments and reviews as cards, references as lines, and a
|
|
828
|
+
* run of bookkeeping folded behind one disclosure.
|
|
330
829
|
*
|
|
331
830
|
* A run of exactly one stays inline. Hiding "added the build label" behind a click costs
|
|
332
831
|
* more attention than reading it does.
|
|
832
|
+
*
|
|
833
|
+
* Reversed rather than rendered in GitHub's order, because the question this screen answers is
|
|
834
|
+
* "what just happened", and the answer was at the bottom of everything that happened before it.
|
|
333
835
|
*/
|
|
334
836
|
function timeline(item, openThread) {
|
|
335
837
|
const out = [];
|
|
@@ -342,13 +844,23 @@ function timeline(item, openThread) {
|
|
|
342
844
|
quiet = [];
|
|
343
845
|
};
|
|
344
846
|
|
|
345
|
-
for (const e of item.events ?? []) {
|
|
847
|
+
for (const e of [...(item.events ?? [])].reverse()) {
|
|
346
848
|
if (QUIET.has(e.type)) { quiet.push(e); continue; }
|
|
347
849
|
flush();
|
|
348
850
|
if (e.type === "comment") {
|
|
349
|
-
out.push(
|
|
851
|
+
out.push(
|
|
852
|
+
comment({
|
|
853
|
+
author: e.actor, when: e.createdAt, body: e.body, repo: item.repo,
|
|
854
|
+
reactions: e.reactions,
|
|
855
|
+
}),
|
|
856
|
+
);
|
|
350
857
|
} else if (e.type === "review" && (e.body ?? "").trim()) {
|
|
351
|
-
out.push(
|
|
858
|
+
out.push(
|
|
859
|
+
comment({
|
|
860
|
+
author: e.actor, when: e.createdAt, body: e.body, repo: item.repo,
|
|
861
|
+
badge: reviewWord(e.state),
|
|
862
|
+
}),
|
|
863
|
+
);
|
|
352
864
|
} else {
|
|
353
865
|
out.push(eventLine(e, item, openThread));
|
|
354
866
|
}
|
|
@@ -488,34 +1000,150 @@ function xref(source, item, openThread) {
|
|
|
488
1000
|
return b;
|
|
489
1001
|
}
|
|
490
1002
|
|
|
491
|
-
function comment(author, when, body,
|
|
492
|
-
const d = el("div", { className: "cmt" + (
|
|
1003
|
+
function comment({ author, when, body, first, repo, badge, reactions }) {
|
|
1004
|
+
const d = el("div", { className: "cmt" + (first ? " first" : "") });
|
|
493
1005
|
const meta = el("div", { className: "meta" });
|
|
494
1006
|
meta.append((author || "?") + " · " + new Date(when).toLocaleString());
|
|
495
1007
|
if (badge) meta.append(el("span", { className: "pill", style: "margin-left:8px", textContent: badge }));
|
|
496
1008
|
d.append(meta);
|
|
497
1009
|
d.append(el("div", { className: "md cbody", innerHTML: mdlite(body || "_no description_", { repo }) }));
|
|
1010
|
+
const marks = reactionRow(reactions);
|
|
1011
|
+
if (marks) d.append(marks);
|
|
498
1012
|
return d;
|
|
499
1013
|
}
|
|
500
1014
|
|
|
1015
|
+
/* GitHub's eight, by their API names. An agent that has picked something up puts 👀 on it, and
|
|
1016
|
+
that acknowledgement is most of why these are worth rendering at all: without it a person
|
|
1017
|
+
posts a comment and has no way to see it landed short of opening GitHub. */
|
|
1018
|
+
const REACTION = {
|
|
1019
|
+
THUMBS_UP: "👍", THUMBS_DOWN: "👎", LAUGH: "😄", HOORAY: "🎉",
|
|
1020
|
+
CONFUSED: "😕", HEART: "❤️", ROCKET: "🚀", EYES: "👀",
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
function reactionRow(reactions) {
|
|
1024
|
+
const list = (reactions ?? []).filter((r) => r.count > 0);
|
|
1025
|
+
if (!list.length) return null;
|
|
1026
|
+
const row = el("div", { className: "reacts" });
|
|
1027
|
+
for (const r of list) {
|
|
1028
|
+
const extra = r.count - r.by.length;
|
|
1029
|
+
row.append(
|
|
1030
|
+
el("span", {
|
|
1031
|
+
className: "react",
|
|
1032
|
+
title: r.by.join(", ") + (extra > 0 ? " and " + extra + " more" : ""),
|
|
1033
|
+
textContent: (REACTION[r.content] ?? "•") + " " + r.count,
|
|
1034
|
+
}),
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
return row;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
501
1040
|
function checkGlyph(state) {
|
|
502
1041
|
return iconHTML(state === "passing" ? "check" : state === "failing" ? "close" : "dot");
|
|
503
1042
|
}
|
|
504
1043
|
|
|
505
1044
|
/* --------------------------------- new issue -------------------------------- */
|
|
506
1045
|
|
|
1046
|
+
/**
|
|
1047
|
+
* Ask one of the staff for something.
|
|
1048
|
+
*
|
|
1049
|
+
* One question, one dropdown: **who is this for**. It used to be two — a staff member and a
|
|
1050
|
+
* repo — which asked you to know that an issue reaches an agent by landing in their brain repo
|
|
1051
|
+
* *and* saying their `@handle`, and let you set the pair to a combination that reaches nobody.
|
|
1052
|
+
* Neither of those is a decision worth having; both follow from the name you picked.
|
|
1053
|
+
*
|
|
1054
|
+
* So the repo is their brain, the mention goes in the body and is kept in step with the choice,
|
|
1055
|
+
* and where it is going is said on the page rather than chosen on it.
|
|
1056
|
+
*/
|
|
507
1057
|
function newIssueForm(viewer) {
|
|
508
|
-
const repos = (S.inbox?.repos ?? []).map((r) => r.owner + "/" + r.name);
|
|
509
1058
|
const box = el("div");
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
1059
|
+
const title = el("input", { type: "search", placeholder: "Title", style: "width:100%" });
|
|
1060
|
+
const body = el("textarea", {
|
|
1061
|
+
placeholder: "What do you want them to do? (markdown)",
|
|
1062
|
+
rows: 8,
|
|
1063
|
+
value: "",
|
|
1064
|
+
});
|
|
515
1065
|
const status = el("span", { className: "meta" });
|
|
516
|
-
const create = el("button", { className: "ghbtn primary", textContent: "
|
|
1066
|
+
const create = el("button", { className: "ghbtn primary", textContent: "Send it" });
|
|
1067
|
+
|
|
1068
|
+
const mentionOf = (s) => s.mention ?? "@" + s.handle;
|
|
1069
|
+
const chosen = () => S.data.staff.find((x) => x.handle === who.value) ?? null;
|
|
1070
|
+
const repoOf = () => chosen()?.brain ?? "";
|
|
1071
|
+
|
|
1072
|
+
const picked = labelPicker(repoOf);
|
|
1073
|
+
const files = attachBox(body, repoOf);
|
|
1074
|
+
// The recipient's mention is written in for you; this is for the second one, when an issue
|
|
1075
|
+
// needs somebody else to see it too.
|
|
1076
|
+
attachMentions(body);
|
|
1077
|
+
|
|
1078
|
+
const who = el("select", { title: "Whose brain this goes to" });
|
|
1079
|
+
who.append(
|
|
1080
|
+
...S.data.staff.map((s) =>
|
|
1081
|
+
el("option", {
|
|
1082
|
+
value: s.handle,
|
|
1083
|
+
textContent: s.name + (s.brain ? "" : " · no brain repo"),
|
|
1084
|
+
}),
|
|
1085
|
+
),
|
|
1086
|
+
);
|
|
1087
|
+
if (!S.data.staff.length) {
|
|
1088
|
+
who.append(el("option", { value: "", textContent: "nobody has been hired yet" }));
|
|
1089
|
+
}
|
|
1090
|
+
who.value = S.inboxStaff || S.data.staff[0]?.handle || "";
|
|
1091
|
+
|
|
1092
|
+
/* The mention is the mechanism: their workflow gates on it, so a body without one is a note
|
|
1093
|
+
nobody is woken by. It is written in for you, and rewritten when you change your mind —
|
|
1094
|
+
leaving the last person's `@handle` at the top would send the issue to one staff member
|
|
1095
|
+
and address it to another. */
|
|
1096
|
+
const MENTIONS = S.data.staff.map(mentionOf);
|
|
1097
|
+
const retarget = (to) => {
|
|
1098
|
+
const at = mentionOf(to);
|
|
1099
|
+
let text = String(body.value ?? "");
|
|
1100
|
+
for (const old of MENTIONS) {
|
|
1101
|
+
if (old === at) continue;
|
|
1102
|
+
text = text.replace(new RegExp("^\\s*" + old.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*"), "");
|
|
1103
|
+
}
|
|
1104
|
+
body.value = text.includes(at) ? text : at + " " + text.replace(/^\s+/, "");
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
who.onchange = () => {
|
|
1108
|
+
const s = chosen();
|
|
1109
|
+
if (!s) return;
|
|
1110
|
+
retarget(s);
|
|
1111
|
+
picked.load();
|
|
1112
|
+
hint();
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
/* Where it lands and what wakes them, said out loud. Somebody who deletes the mention from
|
|
1116
|
+
the body has quietly turned a request into a note, and this is the only thing that says so. */
|
|
1117
|
+
const forWhom = el("p", { className: "meta", style: "margin:9px 0 0" });
|
|
1118
|
+
const hint = () => {
|
|
1119
|
+
const s = chosen();
|
|
1120
|
+
if (!s) {
|
|
1121
|
+
forWhom.textContent = "There is nobody to send this to yet. Hire a staff member first.";
|
|
1122
|
+
create.disabled = true;
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (!s.brain) {
|
|
1126
|
+
forWhom.textContent =
|
|
1127
|
+
s.name + " has no brain repo in their manifest, so there is nowhere to put this.";
|
|
1128
|
+
create.disabled = true;
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
create.disabled = false;
|
|
1132
|
+
forWhom.textContent =
|
|
1133
|
+
"Goes to " + s.brain + ". " +
|
|
1134
|
+
(String(body.value ?? "").includes(mentionOf(s))
|
|
1135
|
+
? mentionOf(s) + " in the body is what wakes them, usually within a minute."
|
|
1136
|
+
: "Put " + mentionOf(s) + " back in the body, or nothing will wake them.");
|
|
1137
|
+
};
|
|
1138
|
+
body.addEventListener("input", hint);
|
|
1139
|
+
|
|
1140
|
+
if (chosen()) retarget(chosen());
|
|
1141
|
+
picked.load();
|
|
1142
|
+
hint();
|
|
517
1143
|
|
|
518
1144
|
create.onclick = async () => {
|
|
1145
|
+
const s = chosen();
|
|
1146
|
+
if (!s?.brain) return;
|
|
519
1147
|
if (!title.value.trim()) { title.focus(); return; }
|
|
520
1148
|
create.disabled = true;
|
|
521
1149
|
status.textContent = "creating…";
|
|
@@ -523,10 +1151,10 @@ function newIssueForm(viewer) {
|
|
|
523
1151
|
try {
|
|
524
1152
|
const r = await post({
|
|
525
1153
|
action: "create",
|
|
526
|
-
repo:
|
|
1154
|
+
repo: s.brain,
|
|
527
1155
|
title: title.value,
|
|
528
1156
|
body: body.value,
|
|
529
|
-
labels:
|
|
1157
|
+
labels: picked.chosen(),
|
|
530
1158
|
});
|
|
531
1159
|
status.innerHTML =
|
|
532
1160
|
'created · <a href="' + esc(r.url ?? "") + '" target="_blank" rel="noopener">open it</a>';
|
|
@@ -539,11 +1167,169 @@ function newIssueForm(viewer) {
|
|
|
539
1167
|
};
|
|
540
1168
|
|
|
541
1169
|
box.append(
|
|
542
|
-
el("h3", { style: "margin:0 0 12px;font:600 16px var(--sans)", textContent: "
|
|
543
|
-
el("div", { className: "row", style: "margin-bottom:9px" }, [
|
|
1170
|
+
el("h3", { style: "margin:0 0 12px;font:600 16px var(--sans)", textContent: "Ask a staff member" }),
|
|
1171
|
+
el("div", { className: "row", style: "margin-bottom:9px" }, [
|
|
1172
|
+
el("label", { className: "meta", textContent: "For", htmlFor: "newissue-who" }),
|
|
1173
|
+
Object.assign(who, { id: "newissue-who" }),
|
|
1174
|
+
]),
|
|
1175
|
+
el("div", { className: "row", style: "margin-bottom:9px" }, [title]),
|
|
544
1176
|
body,
|
|
545
|
-
|
|
1177
|
+
forWhom,
|
|
1178
|
+
files.node,
|
|
1179
|
+
picked.node,
|
|
546
1180
|
el("div", { className: "row", style: "margin-top:10px" }, [create, status]),
|
|
547
1181
|
);
|
|
548
1182
|
viewer.replaceChildren(box);
|
|
549
1183
|
}
|
|
1184
|
+
|
|
1185
|
+
/* --------------------------------- labels --------------------------------- */
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* The repo's own labels, as toggles.
|
|
1189
|
+
*
|
|
1190
|
+
* This used to be a text box you typed comma-separated names into, which is a spelling test:
|
|
1191
|
+
* `from-cmo` and `from-CMO` are different labels and only one of them exists. The set is small
|
|
1192
|
+
* and it is knowable, so it is offered instead.
|
|
1193
|
+
*
|
|
1194
|
+
* When GitHub cannot be reached the labels already on items in the inbox stand in. They are
|
|
1195
|
+
* not the whole vocabulary, but they are real, and a picker with the common ones beats a
|
|
1196
|
+
* disabled one.
|
|
1197
|
+
*/
|
|
1198
|
+
function labelPicker(repoOf) {
|
|
1199
|
+
const node = el("div", { className: "labelpick" });
|
|
1200
|
+
const head = el("div", { className: "meta", textContent: "Labels" });
|
|
1201
|
+
const wrap = el("div", { className: "chips" });
|
|
1202
|
+
node.append(head, wrap);
|
|
1203
|
+
const chosen = new Set();
|
|
1204
|
+
|
|
1205
|
+
const paint = (labels, note) => {
|
|
1206
|
+
wrap.replaceChildren();
|
|
1207
|
+
head.textContent = "Labels" + (note ? " · " + note : "");
|
|
1208
|
+
if (!labels.length) {
|
|
1209
|
+
wrap.append(el("span", { className: "meta", textContent: "none on this repo" }));
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
for (const name of labels) {
|
|
1213
|
+
const b = el("button", { className: "chip pick " + tone(name), textContent: name });
|
|
1214
|
+
b.type = "button";
|
|
1215
|
+
b.setAttribute("aria-pressed", String(chosen.has(name)));
|
|
1216
|
+
b.onclick = () => {
|
|
1217
|
+
if (chosen.has(name)) chosen.delete(name);
|
|
1218
|
+
else chosen.add(name);
|
|
1219
|
+
b.setAttribute("aria-pressed", String(chosen.has(name)));
|
|
1220
|
+
};
|
|
1221
|
+
wrap.append(b);
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
const seen = (repo) => [
|
|
1226
|
+
...new Set((S.inbox?.items ?? []).filter((i) => i.repo === repo).flatMap((i) => i.labels ?? [])),
|
|
1227
|
+
].sort();
|
|
1228
|
+
|
|
1229
|
+
const load = async () => {
|
|
1230
|
+
const repo = repoOf();
|
|
1231
|
+
chosen.clear();
|
|
1232
|
+
if (!repo) { paint([]); return; }
|
|
1233
|
+
paint([], "asking GitHub…");
|
|
1234
|
+
try {
|
|
1235
|
+
const r = await getLabels(repo);
|
|
1236
|
+
if (r.error) paint(seen(repo), "GitHub said: " + r.error);
|
|
1237
|
+
else paint((r.labels ?? []).map((l) => l.name));
|
|
1238
|
+
} catch {
|
|
1239
|
+
paint(seen(repo), "offline, showing labels already in use");
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
|
|
1243
|
+
return { node, load, chosen: () => [...chosen] };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/* ------------------------------- attachments ------------------------------- */
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Files onto an issue.
|
|
1250
|
+
*
|
|
1251
|
+
* GitHub's own drag-and-drop attachments are minted by its web app and cannot be made with
|
|
1252
|
+
* `gh`, so a file dropped here is committed into the repo the issue is in and linked. That is
|
|
1253
|
+
* the better answer for this org anyway: every run clones the repo, so an agent opens the
|
|
1254
|
+
* screenshot off its own disk instead of being handed a URL it has no token for.
|
|
1255
|
+
*
|
|
1256
|
+
* Drop, pick or paste — paste is the one that matters, because a screenshot is on the
|
|
1257
|
+
* clipboard and never on disk.
|
|
1258
|
+
*/
|
|
1259
|
+
function attachBox(ta, repoOf) {
|
|
1260
|
+
const node = el("div", { className: "attach" });
|
|
1261
|
+
const pick = el("input", { type: "file", multiple: true, hidden: true });
|
|
1262
|
+
const btn = el("button", { className: "ghbtn", type: "button" });
|
|
1263
|
+
btn.append(icon("paperclip", "ic"), el("span", { textContent: "Attach files" }));
|
|
1264
|
+
const status = el("span", { className: "meta", textContent: "or drop them on the box above" });
|
|
1265
|
+
node.append(pick, btn, status);
|
|
1266
|
+
|
|
1267
|
+
const say = (text, bad) => {
|
|
1268
|
+
status.textContent = text;
|
|
1269
|
+
status.className = bad ? "meta err" : "meta";
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
/* Written at the cursor, so an attachment lands where you were typing rather than at the
|
|
1273
|
+
end of whatever you had written. The repo path goes in beside the link: the link is for
|
|
1274
|
+
the person, the path is for the model. */
|
|
1275
|
+
const insert = (a) => {
|
|
1276
|
+
const name = a.path.split("/").pop();
|
|
1277
|
+
const media = /\.(png|jpe?g|gif|svg|webp|mp4|mov|webm|m4v)$/i.test(name);
|
|
1278
|
+
const text = (media ? "!" : "") + "[" + name + "](" + a.url + ")\n`" + a.path + "`\n";
|
|
1279
|
+
const at = ta.selectionStart ?? ta.value.length;
|
|
1280
|
+
const before = ta.value.slice(0, at);
|
|
1281
|
+
ta.value = before + (before && !before.endsWith("\n") ? "\n" : "") + text + ta.value.slice(at);
|
|
1282
|
+
ta.selectionStart = ta.selectionEnd = before.length + text.length + 1;
|
|
1283
|
+
ta.focus();
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
const send = async (list) => {
|
|
1287
|
+
const repo = repoOf();
|
|
1288
|
+
if (!repo) { say("pick a repo first", true); return; }
|
|
1289
|
+
const files = [...list].filter((f) => f.size);
|
|
1290
|
+
if (!files.length) return;
|
|
1291
|
+
btn.disabled = true;
|
|
1292
|
+
for (const [n, f] of files.entries()) {
|
|
1293
|
+
say("uploading " + f.name + (files.length > 1 ? " (" + (n + 1) + "/" + files.length + ")" : "") + "…");
|
|
1294
|
+
try {
|
|
1295
|
+
const a = await upload(repo, f);
|
|
1296
|
+
insert(a);
|
|
1297
|
+
say(
|
|
1298
|
+
a.pushed
|
|
1299
|
+
? "committed " + a.path + " to " + repo
|
|
1300
|
+
: "committed locally but not pushed: " + a.note,
|
|
1301
|
+
!a.pushed,
|
|
1302
|
+
);
|
|
1303
|
+
} catch (e) {
|
|
1304
|
+
say(f.name + ": " + e.message, true);
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
btn.disabled = false;
|
|
1309
|
+
pick.value = "";
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
btn.onclick = () => pick.click();
|
|
1313
|
+
pick.onchange = () => send(pick.files);
|
|
1314
|
+
|
|
1315
|
+
for (const zone of [node, ta]) {
|
|
1316
|
+
zone.addEventListener("dragover", (e) => {
|
|
1317
|
+
e.preventDefault();
|
|
1318
|
+
ta.classList.add("dropping");
|
|
1319
|
+
});
|
|
1320
|
+
zone.addEventListener("dragleave", () => ta.classList.remove("dropping"));
|
|
1321
|
+
zone.addEventListener("drop", (e) => {
|
|
1322
|
+
e.preventDefault();
|
|
1323
|
+
ta.classList.remove("dropping");
|
|
1324
|
+
send(e.dataTransfer?.files ?? []);
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
ta.addEventListener("paste", (e) => {
|
|
1328
|
+
const files = [...(e.clipboardData?.files ?? [])];
|
|
1329
|
+
if (!files.length) return;
|
|
1330
|
+
e.preventDefault();
|
|
1331
|
+
send(files);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
return { node };
|
|
1335
|
+
}
|