@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 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
- ["pr", "view", n, "--repo", repo, "--json", "mergeStateStatus,statusCheckRollup"],
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.53",
3
+ "version": "0.1.0-alpha.55",
4
4
  "description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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, "&amp;")
359
+ .replace(/</g, "&lt;")
360
+ .replace(/>/g, "&gt;");
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 merge = button("Merge", true, () => merge_(e, note, actions));
213
- merge.disabled = blocked || item.checks === "pending";
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 = item.mergeable === "CONFLICTING" ? " Conflicts with its base." : "";
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
  }