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