@nanocollective/roster 0.1.0-alpha.53 → 0.1.0-alpha.55
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 +28 -3
- package/docs/portal.md +16 -0
- package/package.json +1 -1
- package/templates/ops/.github/workflows/session.yaml +5 -0
- package/templates/portal/css/search.css +48 -0
- package/templates/portal/index.html +5 -0
- package/templates/portal/js/app.js +15 -1
- package/templates/portal/js/icons.js +4 -0
- package/templates/portal/js/search.js +366 -0
- package/templates/portal/js/views/home.js +29 -4
- package/templates/portal/js/views/inbox.js +17 -0
package/dist/cli.js
CHANGED
|
@@ -5728,6 +5728,17 @@ async function act(req) {
|
|
|
5728
5728
|
await run3("gh", ["issue", "reopen", n, "--repo", repo], { encoding: "utf8" });
|
|
5729
5729
|
return { ok: true, action };
|
|
5730
5730
|
}
|
|
5731
|
+
if (action === "approve") {
|
|
5732
|
+
const args = ["pr", "review", n, "--repo", repo, "--approve"];
|
|
5733
|
+
if (req.body?.trim()) args.push("--body", req.body.trim());
|
|
5734
|
+
try {
|
|
5735
|
+
await run3("gh", args, { encoding: "utf8" });
|
|
5736
|
+
} catch (err) {
|
|
5737
|
+
const line = errText(err).split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("Command failed"));
|
|
5738
|
+
throw new Error(`Not approved: ${line ?? "GitHub refused it without saying why."}`);
|
|
5739
|
+
}
|
|
5740
|
+
return { ok: true, action };
|
|
5741
|
+
}
|
|
5731
5742
|
if (action === "merge") {
|
|
5732
5743
|
const how = req.mergeMethod ?? await mergeMethodFor(repo);
|
|
5733
5744
|
const flag = MERGE_FLAG[how];
|
|
@@ -5787,6 +5798,10 @@ function whyNotMerged(ghError, pr) {
|
|
|
5787
5798
|
return `Not merged: checks failed (${list(failing)}).`;
|
|
5788
5799
|
if (pr?.mergeStateStatus === "BLOCKED" && running.length)
|
|
5789
5800
|
return `Not merged: checks are still running (${list(running)}). Merge again once they pass.`;
|
|
5801
|
+
if (pr?.mergeStateStatus === "BLOCKED" && pr.reviewDecision === "REVIEW_REQUIRED")
|
|
5802
|
+
return "Not merged: the branch rules need an approving review first. Press Approve, then Merge.";
|
|
5803
|
+
if (pr?.mergeStateStatus === "BLOCKED" && pr.reviewDecision === "CHANGES_REQUESTED")
|
|
5804
|
+
return "Not merged: a reviewer has asked for changes. Open it to see what.";
|
|
5790
5805
|
if (pr?.mergeStateStatus === "BLOCKED")
|
|
5791
5806
|
return "Not merged: the branch rules require something first, such as a review. Open it in GitHub to see what.";
|
|
5792
5807
|
if (pr?.mergeStateStatus === "BEHIND")
|
|
@@ -5804,7 +5819,15 @@ async function prState(repo, n) {
|
|
|
5804
5819
|
try {
|
|
5805
5820
|
const { stdout } = await run3(
|
|
5806
5821
|
"gh",
|
|
5807
|
-
[
|
|
5822
|
+
[
|
|
5823
|
+
"pr",
|
|
5824
|
+
"view",
|
|
5825
|
+
n,
|
|
5826
|
+
"--repo",
|
|
5827
|
+
repo,
|
|
5828
|
+
"--json",
|
|
5829
|
+
"mergeStateStatus,statusCheckRollup,reviewDecision"
|
|
5830
|
+
],
|
|
5808
5831
|
{ encoding: "utf8" }
|
|
5809
5832
|
);
|
|
5810
5833
|
return JSON.parse(stdout);
|
|
@@ -6020,7 +6043,7 @@ var LIGHT = `number title url state createdAt updatedAt closedAt body
|
|
|
6020
6043
|
closer: timelineItems(last:1, itemTypes:[CLOSED_EVENT]) {
|
|
6021
6044
|
nodes { ... on ClosedEvent { actor { login } } }
|
|
6022
6045
|
}`;
|
|
6023
|
-
var LIGHT_PR = `${LIGHT} isDraft mergeable
|
|
6046
|
+
var LIGHT_PR = `${LIGHT} isDraft mergeable reviewDecision
|
|
6024
6047
|
commits(last:1) { nodes { commit { statusCheckRollup { state } } } }`;
|
|
6025
6048
|
var QUERY = `
|
|
6026
6049
|
query($owner:String!, $name:String!) {
|
|
@@ -6053,7 +6076,7 @@ query($owner:String!, $name:String!, $number:Int!) {
|
|
|
6053
6076
|
timelineItems(last:100, itemTypes:${ISSUE_TYPES}) { nodes { ${TIMELINE_COMMON} } }
|
|
6054
6077
|
}
|
|
6055
6078
|
... on PullRequest {
|
|
6056
|
-
number title body url state createdAt updatedAt isDraft mergeable ${ROW_EXTRAS}
|
|
6079
|
+
number title body url state createdAt updatedAt isDraft mergeable reviewDecision ${ROW_EXTRAS}
|
|
6057
6080
|
author { login }
|
|
6058
6081
|
${REACTIONS}
|
|
6059
6082
|
labels(first:12) { nodes { name } }
|
|
@@ -6079,6 +6102,7 @@ async function fetchInbox(repos) {
|
|
|
6079
6102
|
item.draft = n.isDraft;
|
|
6080
6103
|
item.checks = rollup(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state);
|
|
6081
6104
|
item.mergeable = n.mergeable;
|
|
6105
|
+
if (n.reviewDecision) item.reviewDecision = n.reviewDecision;
|
|
6082
6106
|
items.push(item);
|
|
6083
6107
|
}
|
|
6084
6108
|
const cutoff = Date.now() - CLOSED_DAYS * 864e5;
|
|
@@ -6293,6 +6317,7 @@ async function fetchThread(repo, number, kind) {
|
|
|
6293
6317
|
item.draft = n.isDraft;
|
|
6294
6318
|
item.checks = rollup(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state);
|
|
6295
6319
|
item.mergeable = n.mergeable;
|
|
6320
|
+
if (n.reviewDecision) item.reviewDecision = n.reviewDecision;
|
|
6296
6321
|
}
|
|
6297
6322
|
return item;
|
|
6298
6323
|
}
|
package/docs/portal.md
CHANGED
|
@@ -18,6 +18,19 @@ Keep the repos checked out beside each other, in the same shape the runner uses.
|
|
|
18
18
|
|
|
19
19
|
## The sidebar
|
|
20
20
|
|
|
21
|
+
**Search everything** with ⌘K (Ctrl+K off a Mac), from anywhere, or the Search field at the
|
|
22
|
+
top of the sidebar, which opens the same dialog. It searches as you type, across:
|
|
23
|
+
|
|
24
|
+
- issues and pull requests, open and recently closed, which open in the side sheet
|
|
25
|
+
- staff, every fact in every memory, and every note and file in every brain
|
|
26
|
+
- the org files, the docs (every page, not only titles), and runs from the last 30 days, which
|
|
27
|
+
open their log on GitHub
|
|
28
|
+
- the screens themselves, so it doubles as a way to get anywhere
|
|
29
|
+
|
|
30
|
+
Every word has to match; a title that starts with one ranks first. Arrow keys move, Enter opens,
|
|
31
|
+
Escape closes. Everything except the docs is searched in the page, from what the portal has
|
|
32
|
+
already read.
|
|
33
|
+
|
|
21
34
|
Anything that asks before it acts (a merge, a push, a hire, a retire, a paid run) asks in the
|
|
22
35
|
page's own dialog, never the browser's `confirm()`, which blocks the whole tab.
|
|
23
36
|
|
|
@@ -96,6 +109,9 @@ Where the portal opens. An Ask box at the top, then these, in order.
|
|
|
96
109
|
- a `review` has **Approve**.
|
|
97
110
|
- a `chore` has **Done**, which closes it.
|
|
98
111
|
- a pull request has **Merge**, unless its checks fail, are still running, or it conflicts.
|
|
112
|
+
Where the branch rules require an approving review, it also has **Approve**, and Merge waits
|
|
113
|
+
until you have pressed it. Approving is your review, given on purpose; Merge never gives it
|
|
114
|
+
for you.
|
|
99
115
|
|
|
100
116
|
Answers go out as you, with the staff member's `@handle`, so they act on them straight away.
|
|
101
117
|
Once you have had the last word, the card leaves Needs you for Your requests, since it is
|
package/package.json
CHANGED
|
@@ -357,6 +357,11 @@ jobs:
|
|
|
357
357
|
PUBLIC_TOKEN: ${{ steps.public.outputs.token }}
|
|
358
358
|
with:
|
|
359
359
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.AGENT_TOKEN }}
|
|
360
|
+
# The action refuses to run for a bot unless it is named here. A peer's ask is opened
|
|
361
|
+
# by that peer's App, so on a peer run, and only then, the one bot that opened it is
|
|
362
|
+
# allowed. The caller has already checked it is an App, not this staff member's own,
|
|
363
|
+
# and that the issue carries a from- label. Every other run allows no bots.
|
|
364
|
+
allowed_bots: ${{ inputs.trigger == 'peer' && github.actor || '' }}
|
|
360
365
|
# These repos are private and single-user, so the usual reason to hide a run's output does
|
|
361
366
|
# not apply, and hiding it turns any failure into "is_error: true" with no message.
|
|
362
367
|
# Registered secrets are still masked by the runner.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Search everything: the ⌘K dialog and the sidebar field that opens it. */
|
|
2
|
+
|
|
3
|
+
.sidesearch{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;
|
|
4
|
+
margin:0 0 10px;padding:6px 10px;border-radius:8px;border:1px solid var(--line);
|
|
5
|
+
background:var(--bg-elev);color:var(--ink-faint);font:13px var(--sans);cursor:pointer;text-align:left}
|
|
6
|
+
.sidesearch:hover{border-color:var(--border-strong);color:var(--ink-dim)}
|
|
7
|
+
.sidesearch:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
8
|
+
.sidesearch .ic{--ic:14px;flex:none}
|
|
9
|
+
.sidesearch .t{flex:1}
|
|
10
|
+
.sidesearch kbd{font:500 10.5px var(--mono);border:1px solid var(--border-strong);border-radius:5px;
|
|
11
|
+
padding:0 5px;color:var(--ink-faint)}
|
|
12
|
+
|
|
13
|
+
dialog.palette{position:fixed;margin:12vh auto auto;padding:0;width:min(640px,calc(100vw - 32px));
|
|
14
|
+
max-height:min(560px,76vh);display:flex;flex-direction:column;overflow:hidden;
|
|
15
|
+
border:1px solid var(--border-strong);border-radius:var(--r-lg);background:var(--bg-elev);
|
|
16
|
+
color:var(--ink);box-shadow:var(--shadow-lg)}
|
|
17
|
+
dialog.palette:not([open]){display:none}
|
|
18
|
+
dialog.palette[open]{animation:dlgin .16s var(--ease)}
|
|
19
|
+
dialog.palette::backdrop{background:rgba(0,0,0,.35);backdrop-filter:blur(8px)}
|
|
20
|
+
@media (prefers-reduced-motion:reduce){dialog.palette[open]{animation:none}}
|
|
21
|
+
|
|
22
|
+
.palhead{display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--line)}
|
|
23
|
+
.palhead .ic{--ic:18px;color:var(--ink-faint);flex:none}
|
|
24
|
+
/* Scoped under the dialog to outrank the portal's own input[type=search] rules, which draw a
|
|
25
|
+
box and a focus ring that belong in a form, not in a search bar that is the whole header. */
|
|
26
|
+
dialog.palette .palinput,dialog.palette .palinput:hover,dialog.palette .palinput:focus{
|
|
27
|
+
flex:1;width:auto;border:0;outline:none;box-shadow:none;background:none;padding:0;
|
|
28
|
+
color:var(--ink);font:16px/1.4 var(--sans)}
|
|
29
|
+
.palinput::-webkit-search-cancel-button{display:none}
|
|
30
|
+
|
|
31
|
+
.palresults{overflow-y:auto;padding:6px;flex:1}
|
|
32
|
+
.palgroup{font:600 10.5px var(--sans);letter-spacing:.06em;text-transform:uppercase;
|
|
33
|
+
color:var(--ink-faint);padding:10px 10px 4px}
|
|
34
|
+
.palrow{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:10px;width:100%;
|
|
35
|
+
padding:8px 10px;border-radius:8px;cursor:pointer}
|
|
36
|
+
.palrow[aria-selected="true"]{background:var(--sel)}
|
|
37
|
+
.palrow .ic{--ic:15px;color:var(--ink-dim);flex:none}
|
|
38
|
+
.paltext{display:flex;flex-direction:column;min-width:0}
|
|
39
|
+
.paltitle{font:500 13.5px/1.35 var(--sans);color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
40
|
+
.paltitle mark{background:var(--accent-soft);color:inherit;border-radius:3px;padding:0 1px}
|
|
41
|
+
.palsub{font:12px/1.4 var(--sans);color:var(--ink-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
42
|
+
.palempty{color:var(--ink-faint);font-size:13px;padding:22px 12px;margin:0;text-align:center}
|
|
43
|
+
|
|
44
|
+
.palfoot{display:flex;gap:16px;padding:8px 14px;border-top:1px solid var(--line);
|
|
45
|
+
font:11.5px var(--sans);color:var(--ink-faint)}
|
|
46
|
+
.palfoot kbd{font:500 10.5px var(--mono);border:1px solid var(--border-strong);border-radius:4px;
|
|
47
|
+
padding:0 4px;margin-right:3px}
|
|
48
|
+
@media (max-width:640px){.palfoot{display:none}dialog.palette{margin-top:8vh}}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<link rel="stylesheet" href="/assets/css/brain.css">
|
|
15
15
|
<link rel="stylesheet" href="/assets/css/inbox.css">
|
|
16
16
|
<link rel="stylesheet" href="/assets/css/home.css">
|
|
17
|
+
<link rel="stylesheet" href="/assets/css/search.css">
|
|
17
18
|
<link rel="stylesheet" href="/assets/css/graph.css">
|
|
18
19
|
<link rel="stylesheet" href="/assets/css/diff.css">
|
|
19
20
|
<link rel="stylesheet" href="/assets/css/health.css">
|
|
@@ -28,6 +29,10 @@
|
|
|
28
29
|
<span class="sync" data-icon="refresh"></span><span id="loaded">data now</span>
|
|
29
30
|
</button>
|
|
30
31
|
|
|
32
|
+
<button class="sidesearch" id="sidesearch" title="Search everything (⌘K)">
|
|
33
|
+
<span class="ic" data-icon="search"></span><span class="t">Search</span><kbd id="searchkey">⌘K</kbd>
|
|
34
|
+
</button>
|
|
35
|
+
|
|
31
36
|
<!-- Only while something is left to set up; app.js decides. -->
|
|
32
37
|
<button class="nav top" id="setupnav" data-view="setup" hidden>
|
|
33
38
|
<span class="ic" data-icon="task-done"></span> Getting started
|
|
@@ -20,6 +20,7 @@ import { viewChanged } from "./views/changed.js";
|
|
|
20
20
|
import { viewDocs } from "./views/docs.js";
|
|
21
21
|
import { viewGraph } from "./views/graph.js";
|
|
22
22
|
import { viewHealth } from "./views/health.js";
|
|
23
|
+
import { openSearch } from "./search.js";
|
|
23
24
|
import { viewHome } from "./views/home.js";
|
|
24
25
|
import { viewInbox, viewPrs } from "./views/inbox.js";
|
|
25
26
|
import { viewOrg } from "./views/org.js";
|
|
@@ -102,7 +103,7 @@ export async function boot() {
|
|
|
102
103
|
/* Every one of these reads a tenant, so during setup they answer 409 and the sidebar is a
|
|
103
104
|
row of four dead ends beside an empty Staff heading. There is one thing to do on this
|
|
104
105
|
screen; the navigation comes back with the org. */
|
|
105
|
-
for (const el of document.querySelectorAll("#homenav, #inboxnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
|
|
106
|
+
for (const el of document.querySelectorAll("#sidesearch, #homenav, #inboxnav, #runsnav, #orgnav, #staffnav, #docsnav, #refreshall, .sect, #stafflist")) {
|
|
106
107
|
el.hidden = true;
|
|
107
108
|
}
|
|
108
109
|
for (const slot of document.querySelectorAll("[data-icon]")) {
|
|
@@ -144,6 +145,19 @@ export async function boot() {
|
|
|
144
145
|
addEventListener("focus", () => {
|
|
145
146
|
if (S.loadedAt && Date.now() - S.loadedAt > 60000) refreshQuietly();
|
|
146
147
|
});
|
|
148
|
+
/* ⌘K (Ctrl+K off a Mac) opens search from anywhere, even from inside a text box: it is the
|
|
149
|
+
one shortcut that should never be swallowed by whatever has focus. The sidebar's field
|
|
150
|
+
opens the same dialog. */
|
|
151
|
+
addEventListener("keydown", (e) => {
|
|
152
|
+
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
openSearch();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
const side = $("#sidesearch");
|
|
158
|
+
if (side) side.onclick = () => openSearch();
|
|
159
|
+
const key = $("#searchkey");
|
|
160
|
+
if (key && !/Mac|iPhone|iPad/.test(navigator.platform ?? "")) key.textContent = "Ctrl K";
|
|
147
161
|
addEventListener("keydown", (e) => {
|
|
148
162
|
if (e.key === "r" && !e.metaKey && !e.ctrlKey &&
|
|
149
163
|
!/^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName ?? "")) {
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const PATHS = {
|
|
16
|
+
"search":
|
|
17
|
+
"<circle cx=\"11\" cy=\"11\" r=\"8\" /> <path d=\"m21 21-4.3-4.3\" />",
|
|
18
|
+
"lightbulb":
|
|
19
|
+
"<path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\" /> <path d=\"M9 18h6\" /> <path d=\"M10 22h4\" />",
|
|
16
20
|
"code":
|
|
17
21
|
"<polyline points=\"16 18 22 12 16 6\" /> <polyline points=\"8 6 2 12 8 18\" />",
|
|
18
22
|
"megaphone":
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/* Search everything: ⌘K, or the search field in the sidebar.
|
|
2
|
+
*
|
|
3
|
+
* One dialog over the whole portal: issues and pull requests, staff, every fact and file in
|
|
4
|
+
* every brain, the org files, the docs, runs, and the screens themselves. All of it except the
|
|
5
|
+
* docs is already in the page, so it is searched here as you type; the docs are searched on the
|
|
6
|
+
* server, which reads every page rather than only titles.
|
|
7
|
+
*
|
|
8
|
+
* `index()` and `rank()` are plain functions over plain data, so what is found, and in which
|
|
9
|
+
* order, can be tested without a page. */
|
|
10
|
+
|
|
11
|
+
import { getOrgLayer, getRuns, searchDocs } from "./api.js";
|
|
12
|
+
import { ago, el } from "./dom.js";
|
|
13
|
+
import { icon, staffIcon } from "./icons.js";
|
|
14
|
+
import { ensureInbox } from "./refresh.js";
|
|
15
|
+
import { go } from "./router.js";
|
|
16
|
+
import { S } from "./state.js";
|
|
17
|
+
import { openThread } from "./views/home.js";
|
|
18
|
+
|
|
19
|
+
/** The groups, in the order they are shown. */
|
|
20
|
+
const GROUPS = ["Screens", "Staff", "Issues", "Pull requests", "Memory", "Files", "Org", "Runs", "Docs"];
|
|
21
|
+
/** How many of each a query shows; an empty query shows only screens and staff. */
|
|
22
|
+
const PER_GROUP = 5;
|
|
23
|
+
|
|
24
|
+
const SCREENS = [
|
|
25
|
+
["home", "Home", "What needs you, and who is working"],
|
|
26
|
+
["inbox", "Trackers", "Every open issue on each tracker"],
|
|
27
|
+
["runs", "Runs", "What ran, and what it cost"],
|
|
28
|
+
["org", "Org", "The files every staff member reads"],
|
|
29
|
+
["staff", "Staff", "Hire, set up, retire"],
|
|
30
|
+
["docs", "Docs", "How roster works"],
|
|
31
|
+
];
|
|
32
|
+
const STAFF_SCREENS = [
|
|
33
|
+
["brain", "Brain"],
|
|
34
|
+
["prompt", "Prompt"],
|
|
35
|
+
["graph", "Graph"],
|
|
36
|
+
["changed", "What changed"],
|
|
37
|
+
["health", "Health"],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every searchable thing, as `{ group, title, sub, text, icon, open }`. `text` is what is
|
|
42
|
+
* matched beyond the title; `open` is what choosing it does.
|
|
43
|
+
*/
|
|
44
|
+
export function index(state) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const staff = state.data?.staff ?? [];
|
|
47
|
+
|
|
48
|
+
for (const [view, title, sub] of SCREENS) {
|
|
49
|
+
out.push({ group: "Screens", title, sub, text: view, icon: "square", open: () => go({ view }) });
|
|
50
|
+
}
|
|
51
|
+
for (const s of staff) {
|
|
52
|
+
for (const [view, label] of STAFF_SCREENS) {
|
|
53
|
+
out.push({
|
|
54
|
+
group: "Screens",
|
|
55
|
+
title: `${s.name} · ${label}`,
|
|
56
|
+
sub: s.handle,
|
|
57
|
+
text: `${s.handle} ${label}`,
|
|
58
|
+
icon: staffIcon(s),
|
|
59
|
+
open: () => go({ view, staffHandle: s.handle }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const s of staff) {
|
|
65
|
+
out.push({
|
|
66
|
+
group: "Staff",
|
|
67
|
+
title: s.name,
|
|
68
|
+
sub: [s.handle, s.brain].filter(Boolean).join(" · "),
|
|
69
|
+
text: `${s.handle} ${s.brain ?? ""} ${s.mention ?? ""}`,
|
|
70
|
+
icon: staffIcon(s),
|
|
71
|
+
open: () => go({ view: "brain", staffHandle: s.handle, openFile: "mem:*", fileQuery: "" }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
for (const f of s.facts ?? []) {
|
|
75
|
+
out.push({
|
|
76
|
+
group: "Memory",
|
|
77
|
+
title: f.statement,
|
|
78
|
+
sub: `${s.name} · ${f.section} · ${f.slug}`,
|
|
79
|
+
text: `${f.slug} ${f.consequence ?? ""} ${f.section}`,
|
|
80
|
+
icon: "lightbulb",
|
|
81
|
+
open: () =>
|
|
82
|
+
go({ view: "brain", staffHandle: s.handle, openFile: "fact:" + f.slug, fileQuery: "" }),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const surface of s.surfaces ?? []) {
|
|
87
|
+
for (const file of surface.files ?? []) {
|
|
88
|
+
const note = /(^|\/)notes\//.test(file.path);
|
|
89
|
+
out.push({
|
|
90
|
+
group: note ? "Memory" : "Files",
|
|
91
|
+
title: file.path.split("/").pop(),
|
|
92
|
+
sub: `${s.name} · ${file.path}`,
|
|
93
|
+
text: file.path,
|
|
94
|
+
icon: "file",
|
|
95
|
+
open: () => go({ view: "brain", staffHandle: s.handle, openFile: file.path, fileQuery: "" }),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const brains = new Map(staff.filter((s) => s.brain).map((s) => [s.brain, s]));
|
|
102
|
+
for (const item of state.inbox?.items ?? []) {
|
|
103
|
+
const owner = brains.get(item.repo);
|
|
104
|
+
const repo = item.repo.split("/")[1] ?? item.repo;
|
|
105
|
+
const where = owner ? owner.name : repo;
|
|
106
|
+
const open = item.state === "OPEN";
|
|
107
|
+
out.push({
|
|
108
|
+
group: item.kind === "pr" ? "Pull requests" : "Issues",
|
|
109
|
+
title: item.title,
|
|
110
|
+
sub: `${where} · #${item.number} · ${open ? "open" : item.state.toLowerCase()} · ${ago(item.updatedAt)}`,
|
|
111
|
+
text: `#${item.number} ${repo}#${item.number} ${(item.labels ?? []).join(" ")} ${item.author}`,
|
|
112
|
+
icon: item.kind === "pr" ? (item.state === "MERGED" ? "merged" : "review") : open ? "issue-open" : "issue-closed",
|
|
113
|
+
// Open work first among equal matches: it is usually what is being looked for.
|
|
114
|
+
weight: open ? 1 : 0,
|
|
115
|
+
open: () => openThread(item),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const f of state.orgFiles ?? []) {
|
|
120
|
+
out.push({
|
|
121
|
+
group: "Org",
|
|
122
|
+
title: f.path.split("/").pop(),
|
|
123
|
+
sub: f.path,
|
|
124
|
+
text: f.path,
|
|
125
|
+
icon: "file",
|
|
126
|
+
open: () => go({ view: "org", orgOpen: f.path }),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const s of state.runs?.staff ?? []) {
|
|
131
|
+
for (const r of s.runs ?? []) {
|
|
132
|
+
const word = r.record?.outcome ?? r.conclusion ?? r.status;
|
|
133
|
+
out.push({
|
|
134
|
+
group: "Runs",
|
|
135
|
+
title: `${s.name} · ${r.kind} run · ${word}`,
|
|
136
|
+
sub: `${ago(r.createdAt)} · opens the log on GitHub`,
|
|
137
|
+
text: `${s.handle} ${r.kind} ${word} ${r.id}`,
|
|
138
|
+
icon: "play",
|
|
139
|
+
open: () => window.open(r.url, "_blank", "noopener"),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The best matches for a query, grouped. Every word has to appear somewhere; a match in the
|
|
149
|
+
* title outranks one elsewhere, and the start of the title outranks the middle of it.
|
|
150
|
+
*/
|
|
151
|
+
export function rank(entries, query) {
|
|
152
|
+
const words = String(query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
153
|
+
const groups = new Map(GROUPS.map((g) => [g, []]));
|
|
154
|
+
if (!words.length) {
|
|
155
|
+
for (const e of entries) {
|
|
156
|
+
if (e.group === "Screens" || e.group === "Staff") groups.get(e.group).push({ e, score: 0 });
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
for (const e of entries) {
|
|
160
|
+
const title = e.title.toLowerCase();
|
|
161
|
+
const all = `${title} ${String(e.sub ?? "").toLowerCase()} ${String(e.text ?? "").toLowerCase()}`;
|
|
162
|
+
if (!words.every((w) => all.includes(w))) continue;
|
|
163
|
+
let score = e.weight ?? 0;
|
|
164
|
+
for (const w of words) {
|
|
165
|
+
if (title.startsWith(w)) score += 6;
|
|
166
|
+
else if (new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(title)) score += 4;
|
|
167
|
+
else if (title.includes(w)) score += 2;
|
|
168
|
+
}
|
|
169
|
+
groups.get(e.group)?.push({ e, score });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const [group, list] of groups) {
|
|
174
|
+
if (!list.length) continue;
|
|
175
|
+
list.sort((a, b) => b.score - a.score);
|
|
176
|
+
const shown = list.slice(0, words.length ? PER_GROUP : 40);
|
|
177
|
+
out.push({ group, entries: shown.map((x) => x.e), more: list.length - shown.length });
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* --------------------------------- the dialog --------------------------------- */
|
|
183
|
+
|
|
184
|
+
let current = null;
|
|
185
|
+
|
|
186
|
+
/** Open the search dialog, or focus it when it is already open. */
|
|
187
|
+
export function openSearch() {
|
|
188
|
+
if (current) {
|
|
189
|
+
current.input.focus();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const box = document.createElement("dialog");
|
|
193
|
+
if (typeof box.showModal !== "function") return;
|
|
194
|
+
box.className = "palette";
|
|
195
|
+
|
|
196
|
+
const input = el("input", {
|
|
197
|
+
type: "search",
|
|
198
|
+
className: "palinput",
|
|
199
|
+
placeholder: "Search issues, pull requests, memory, files, runs and docs",
|
|
200
|
+
autocomplete: "off",
|
|
201
|
+
spellcheck: false,
|
|
202
|
+
});
|
|
203
|
+
const results = el("div", { className: "palresults", role: "listbox" });
|
|
204
|
+
const foot = el("div", { className: "palfoot" }, [
|
|
205
|
+
el("span", { innerHTML: "<kbd>↑</kbd><kbd>↓</kbd> move" }),
|
|
206
|
+
el("span", { innerHTML: "<kbd>↵</kbd> open" }),
|
|
207
|
+
el("span", { innerHTML: "<kbd>esc</kbd> close" }),
|
|
208
|
+
]);
|
|
209
|
+
box.append(el("div", { className: "palhead" }, [icon("search", "ic"), input]), results, foot);
|
|
210
|
+
document.body.append(box);
|
|
211
|
+
box.showModal();
|
|
212
|
+
input.focus();
|
|
213
|
+
|
|
214
|
+
let entries = index(S);
|
|
215
|
+
let flat = [];
|
|
216
|
+
let at = 0;
|
|
217
|
+
let docs = [];
|
|
218
|
+
let docsFor = "";
|
|
219
|
+
let docTimer = null;
|
|
220
|
+
|
|
221
|
+
const close = () => {
|
|
222
|
+
if (!current) return;
|
|
223
|
+
current = null;
|
|
224
|
+
clearTimeout(docTimer);
|
|
225
|
+
box.close();
|
|
226
|
+
box.remove();
|
|
227
|
+
};
|
|
228
|
+
current = { input, close };
|
|
229
|
+
box.addEventListener("close", () => {
|
|
230
|
+
current = null;
|
|
231
|
+
box.remove();
|
|
232
|
+
});
|
|
233
|
+
box.addEventListener("click", (e) => {
|
|
234
|
+
if (e.target === box) close();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const choose = (e) => {
|
|
238
|
+
close();
|
|
239
|
+
e.open();
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const paint = () => {
|
|
243
|
+
const q = input.value.trim();
|
|
244
|
+
const grouped = rank(entries, q);
|
|
245
|
+
if (q && docsFor === q && docs.length) {
|
|
246
|
+
grouped.push({
|
|
247
|
+
group: "Docs",
|
|
248
|
+
entries: docs.slice(0, PER_GROUP).map((h) => ({
|
|
249
|
+
title: h.title,
|
|
250
|
+
sub: h.matches?.[0]?.text ?? h.file,
|
|
251
|
+
icon: "book-open",
|
|
252
|
+
open: () => go({ view: "docs", openDoc: h.file, docQuery: q }),
|
|
253
|
+
})),
|
|
254
|
+
more: Math.max(0, docs.length - PER_GROUP),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
flat = grouped.flatMap((g) => g.entries);
|
|
258
|
+
at = Math.min(at, Math.max(0, flat.length - 1));
|
|
259
|
+
results.replaceChildren();
|
|
260
|
+
if (!flat.length) {
|
|
261
|
+
results.append(el("p", { className: "palempty", textContent: q ? "Nothing matches “" + q + "”." : "" }));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
let i = 0;
|
|
265
|
+
for (const g of grouped) {
|
|
266
|
+
results.append(
|
|
267
|
+
el("div", { className: "palgroup", textContent: g.group + (g.more ? ` · ${g.more} more` : "") }),
|
|
268
|
+
);
|
|
269
|
+
for (const e of g.entries) {
|
|
270
|
+
const n = i++;
|
|
271
|
+
const row = el("button", { className: "palrow", role: "option" }, [
|
|
272
|
+
icon(e.icon ?? "file", "ic"),
|
|
273
|
+
el("span", { className: "paltext" }, [
|
|
274
|
+
el("span", { className: "paltitle", innerHTML: highlight(e.title, q) }),
|
|
275
|
+
el("span", { className: "palsub", textContent: e.sub ?? "" }),
|
|
276
|
+
]),
|
|
277
|
+
]);
|
|
278
|
+
row.setAttribute("aria-selected", String(n === at));
|
|
279
|
+
row.onmouseenter = () => {
|
|
280
|
+
at = n;
|
|
281
|
+
mark();
|
|
282
|
+
};
|
|
283
|
+
row.onclick = () => choose(e);
|
|
284
|
+
results.append(row);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
mark();
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const mark = () => {
|
|
291
|
+
const rows = results.querySelectorAll(".palrow");
|
|
292
|
+
rows.forEach((r, n) => r.setAttribute("aria-selected", String(n === at)));
|
|
293
|
+
rows[at]?.scrollIntoView({ block: "nearest" });
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
input.addEventListener("input", () => {
|
|
297
|
+
at = 0;
|
|
298
|
+
paint();
|
|
299
|
+
const q = input.value.trim();
|
|
300
|
+
clearTimeout(docTimer);
|
|
301
|
+
if (q.length < 2) return;
|
|
302
|
+
docTimer = setTimeout(() => {
|
|
303
|
+
searchDocs(q)
|
|
304
|
+
.then((r) => {
|
|
305
|
+
docs = r.hits ?? [];
|
|
306
|
+
docsFor = q;
|
|
307
|
+
if (current && input.value.trim() === q) paint();
|
|
308
|
+
})
|
|
309
|
+
.catch(() => {});
|
|
310
|
+
}, 150);
|
|
311
|
+
});
|
|
312
|
+
input.addEventListener("keydown", (e) => {
|
|
313
|
+
if (e.key === "ArrowDown") {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
at = Math.min(at + 1, flat.length - 1);
|
|
316
|
+
mark();
|
|
317
|
+
} else if (e.key === "ArrowUp") {
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
at = Math.max(at - 1, 0);
|
|
320
|
+
mark();
|
|
321
|
+
} else if (e.key === "Enter") {
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
if (flat[at]) choose(flat[at]);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
paint();
|
|
328
|
+
|
|
329
|
+
/* What is not in the page yet is fetched once and folded in as it lands: the issues, the org
|
|
330
|
+
files and the runs. The dialog is usable straight away with whatever is already here. */
|
|
331
|
+
const refill = () => {
|
|
332
|
+
if (!current) return;
|
|
333
|
+
entries = index(S);
|
|
334
|
+
paint();
|
|
335
|
+
};
|
|
336
|
+
if (!S.inbox) ensureInbox(false).then(refill).catch(() => {});
|
|
337
|
+
if (!S.orgFiles) {
|
|
338
|
+
getOrgLayer()
|
|
339
|
+
.then((r) => {
|
|
340
|
+
S.orgFiles = r.files ?? [];
|
|
341
|
+
refill();
|
|
342
|
+
})
|
|
343
|
+
.catch(() => {});
|
|
344
|
+
}
|
|
345
|
+
if (!S.runs) {
|
|
346
|
+
getRuns(false)
|
|
347
|
+
.then((r) => {
|
|
348
|
+
S.runs = r;
|
|
349
|
+
refill();
|
|
350
|
+
})
|
|
351
|
+
.catch(() => {});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The title with each query word marked, escaped first. */
|
|
356
|
+
function highlight(title, q) {
|
|
357
|
+
let html = String(title ?? "")
|
|
358
|
+
.replace(/&/g, "&")
|
|
359
|
+
.replace(/</g, "<")
|
|
360
|
+
.replace(/>/g, ">");
|
|
361
|
+
for (const w of String(q ?? "").toLowerCase().split(/\s+/).filter((x) => x.length > 1)) {
|
|
362
|
+
const re = new RegExp(`(${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "ig");
|
|
363
|
+
html = html.replace(re, "<mark>$1</mark>");
|
|
364
|
+
}
|
|
365
|
+
return html;
|
|
366
|
+
}
|
|
@@ -209,8 +209,31 @@ function needCard(e) {
|
|
|
209
209
|
actions.append(button("Done", true, () => done(e, note, actions)));
|
|
210
210
|
} else if (kind === "merge") {
|
|
211
211
|
const blocked = item.checks === "failing" || item.mergeable === "CONFLICTING";
|
|
212
|
-
const
|
|
213
|
-
merge
|
|
212
|
+
const needsReview = item.reviewDecision === "REVIEW_REQUIRED";
|
|
213
|
+
const merge = button("Merge", !needsReview, () => merge_(e, note, actions));
|
|
214
|
+
merge.disabled = blocked || item.checks === "pending" || needsReview;
|
|
215
|
+
/* The branch rules want an approving review. Approve is the person's review, given on
|
|
216
|
+
purpose; Merge waits for it rather than approving on their behalf. */
|
|
217
|
+
if (needsReview) {
|
|
218
|
+
const approve = button("Approve", true, async () => {
|
|
219
|
+
approve.disabled = true;
|
|
220
|
+
note.className = "meta";
|
|
221
|
+
note.textContent = "Approving…";
|
|
222
|
+
try {
|
|
223
|
+
await post({ action: "approve", repo: item.repo, number: item.number });
|
|
224
|
+
item.reviewDecision = "APPROVED";
|
|
225
|
+
note.textContent = "Approved.";
|
|
226
|
+
approve.remove();
|
|
227
|
+
merge.className = "ghbtn primary";
|
|
228
|
+
merge.disabled = blocked || item.checks === "pending";
|
|
229
|
+
} catch (err) {
|
|
230
|
+
note.textContent = String(err.message || err);
|
|
231
|
+
note.className = "meta err";
|
|
232
|
+
approve.disabled = false;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
actions.append(approve);
|
|
236
|
+
}
|
|
214
237
|
actions.append(merge);
|
|
215
238
|
}
|
|
216
239
|
card.append(actions);
|
|
@@ -234,7 +257,9 @@ function checksLine(item) {
|
|
|
234
257
|
pending: "Checks are running.",
|
|
235
258
|
none: "No checks.",
|
|
236
259
|
};
|
|
237
|
-
const conflict =
|
|
260
|
+
const conflict =
|
|
261
|
+
(item.mergeable === "CONFLICTING" ? " Conflicts with its base." : "") +
|
|
262
|
+
(item.reviewDecision === "REVIEW_REQUIRED" ? " Needs your approval to merge." : "");
|
|
238
263
|
return el("p", {
|
|
239
264
|
className: "homedue" + (item.checks === "failing" || conflict ? " late" : ""),
|
|
240
265
|
textContent: (words[item.checks] ?? "") + conflict,
|
|
@@ -516,7 +541,7 @@ function repaintInPlace() {
|
|
|
516
541
|
* repaints Home, since a reply or a close there changes what belongs where. Where there is no
|
|
517
542
|
* <dialog> (the test shim), it falls back to the thread on Trackers.
|
|
518
543
|
*/
|
|
519
|
-
function openThread(item) {
|
|
544
|
+
export function openThread(item) {
|
|
520
545
|
const ref = { repo: item.repo, number: item.number, kind: item.kind };
|
|
521
546
|
const body = el("div");
|
|
522
547
|
const x = el("button", { className: "iconbtn", title: "Close (Esc)", ariaLabel: "Close" });
|
|
@@ -800,6 +800,23 @@ function inboxScreen(m, opts) {
|
|
|
800
800
|
openThread({ repo: item.repo, number: item.number, kind: item.kind });
|
|
801
801
|
} catch (e) { failed(e); }
|
|
802
802
|
};
|
|
803
|
+
/* The branch rules want an approving review before a merge. Approve is your review,
|
|
804
|
+
given on purpose; Merge never gives it for you, so it waits until there is one. */
|
|
805
|
+
if (item.reviewDecision === "REVIEW_REQUIRED" && item.mergeable !== "CONFLICTING") {
|
|
806
|
+
merge.disabled = true;
|
|
807
|
+
merge.title = "Approve it first: the branch rules need an approving review";
|
|
808
|
+
const approve = el("button", { className: "ghbtn primary", textContent: "Approve" });
|
|
809
|
+
approve.onclick = async () => {
|
|
810
|
+
busy(true, "approving…");
|
|
811
|
+
try {
|
|
812
|
+
await post({ action: "approve", repo: item.repo, number: item.number });
|
|
813
|
+
item.reviewDecision = "APPROVED";
|
|
814
|
+
openThread({ repo: item.repo, number: item.number, kind: item.kind });
|
|
815
|
+
} catch (e) { failed(e); }
|
|
816
|
+
};
|
|
817
|
+
buttons.push(approve);
|
|
818
|
+
row.append(approve);
|
|
819
|
+
}
|
|
803
820
|
buttons.push(merge);
|
|
804
821
|
row.append(merge);
|
|
805
822
|
}
|