@nanocollective/roster 0.1.0-alpha.4 → 0.1.0-alpha.5

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
@@ -4260,7 +4260,7 @@ query($owner:String!, $name:String!) {
4260
4260
  }
4261
4261
  pullRequests(states:OPEN, first:60, orderBy:{field:UPDATED_AT, direction:DESC}) {
4262
4262
  nodes {
4263
- number title body url state createdAt updatedAt isDraft
4263
+ number title body url state createdAt updatedAt isDraft mergeable
4264
4264
  author { login }
4265
4265
  ${REACTIONS}
4266
4266
  labels(first:12) { nodes { name } }
@@ -4315,6 +4315,7 @@ async function fetchInbox(repos) {
4315
4315
  const item = shape(n, full, r.role, "pr");
4316
4316
  item.draft = n.isDraft;
4317
4317
  item.checks = rollup(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state);
4318
+ item.mergeable = n.mergeable;
4318
4319
  items.push(item);
4319
4320
  }
4320
4321
  const cutoff = Date.now() - CLOSED_DAYS * 864e5;
@@ -4853,12 +4854,14 @@ async function syncOne(path, dir) {
4853
4854
  await git4("pull", "--ff-only", "--quiet");
4854
4855
  return { ...base2, ahead, behind: 0, dirty, pulled: true };
4855
4856
  } catch (err) {
4856
- return { ...base2, error: firstLine2(err) };
4857
+ return { ...base2, error: reason(err) };
4857
4858
  }
4858
4859
  }
4859
- function firstLine2(e) {
4860
- const msg = e instanceof Error ? e.message : String(e);
4861
- const line = msg.split("\n").find((l) => l.trim()) ?? msg;
4860
+ function reason(e) {
4861
+ const err = e;
4862
+ const text = String(err?.stderr ?? "").trim() || String(err?.message ?? e);
4863
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
4864
+ const line = lines.find((l) => !/^Command failed:/i.test(l)) ?? lines[0] ?? String(e);
4862
4865
  return line.length > 180 ? line.slice(0, 179) + "\u2026" : line;
4863
4866
  }
4864
4867
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.5",
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": {
@@ -40,10 +40,22 @@
40
40
  .ist{display:inline-flex;--ic:12px}
41
41
  .ist.closed{color:var(--derived)} .ist.merged{color:var(--derived)}
42
42
  .ck.passing{color:var(--accent)} .ck.failing{color:var(--err)} .ck.pending{color:var(--warn)}
43
+ /* Filled rather than outlined like the label chips beside it. A branch that no longer applies
44
+ to its base is the one state where a row can show green checks and still not be mergeable,
45
+ so it has to win the row it is in. */
46
+ .chip.conflict{color:var(--err);border-color:color-mix(in srgb,var(--err) 50%,transparent);
47
+ background:color-mix(in srgb,var(--err) 14%,transparent)}
43
48
 
44
49
  /* ---------- thread ---------- */
45
50
  .thead{border-bottom:1px solid var(--line);padding-bottom:16px}
46
51
  .thead h3{margin:8px 0 0;font:600 17px/1.35 var(--sans);letter-spacing:-.012em;color:var(--ink)}
52
+ /* The same fact as the chip, said in full, once, at the top of the thread it applies to.
53
+ Tight to the title and a clear gap above the buttons: it is a statement about this pull
54
+ request, not a caption on the controls. At an even 10/16 it read as the latter. */
55
+ .tconflict{margin:4px 0 0;font:12.5px/1.5 var(--sans);color:var(--err)}
56
+ /* A bottom margin here would collapse against the row's own 16px and change nothing, so the
57
+ extra room is put on the row. General sibling, not adjacent: labels can sit between them. */
58
+ .tconflict ~ .tacts{margin-top:24px}
47
59
  .tlabels{margin-top:10px}
48
60
  .cmt{border-top:1px solid var(--line);padding:16px 0}
49
61
  .cmt.first{border-top:0}
@@ -20,6 +20,17 @@
20
20
 
21
21
  .sect{padding:14px 20px 6px;font:600 10.5px var(--sans);letter-spacing:.09em;
22
22
  text-transform:uppercase;color:var(--ink-faint)}
23
+ /* The Staff heading is also the fold for the list under it, so it is a button — and has to
24
+ go on reading as a heading rather than as a control. The caret and the 20px left padding
25
+ are the staff rows' own, so the two carets line up in one column. */
26
+ .sectfold{display:flex;align-items:center;gap:7px;width:100%;background:none;border:0;
27
+ cursor:pointer;text-align:left;font:600 10.5px var(--sans);letter-spacing:.09em;
28
+ text-transform:uppercase;color:var(--ink-faint)}
29
+ .sectfold:hover{color:var(--ink-dim)}
30
+ .sectfold .caret{display:inline-flex;--ic:12px;flex:none;transition:transform .12s ease}
31
+ .sectfold[aria-expanded="false"] .caret{transform:rotate(-90deg)}
32
+ /* The count is a number, not a heading: mono, and none of the heading's letterspacing. */
33
+ .sectfold .n{font:10.5px var(--mono);letter-spacing:0;text-transform:none;color:var(--ink-faint)}
23
34
  /* Flex, not block with a float. The icons are block-level SVGs and the trailing count has to
24
35
  sit hard right; `float` does neither of those inside a flex row, and did neither of them
25
36
  outside one once the icons arrived. */
@@ -46,7 +46,11 @@
46
46
  <span class="ic" data-icon="docs"></span> Docs
47
47
  </button>
48
48
 
49
- <div class="sect">Staff</div>
49
+ <!-- The heading is the fold. Collapsed, it carries the count, because a hidden list still
50
+ has to say how much it is hiding. -->
51
+ <button class="sect sectfold" id="stafffold" aria-expanded="false" aria-controls="stafflist">
52
+ <span class="caret" data-icon="chevron"></span>Staff<span class="n" id="staffcount" hidden></span>
53
+ </button>
50
54
  <div id="stafflist"></div>
51
55
 
52
56
  <div class="sidefoot">
@@ -89,6 +89,7 @@ export async function boot() {
89
89
  }
90
90
  onRender(render);
91
91
  applyHash();
92
+ initStaffFold();
92
93
  paintSidebar();
93
94
 
94
95
  $("#inboxnav").onclick = () => { S.view = "inbox"; render(); };
@@ -180,6 +181,42 @@ function learnPeople() {
180
181
  */
181
182
  let paintedRoster = null;
182
183
 
184
+ /**
185
+ * The Staff heading is a fold, and folded it carries the count.
186
+ *
187
+ * It starts folded, so the count is not decoration: a bare "STAFF" heading over nothing is how
188
+ * a loaded org would look if the export came back empty, and those two states have to be
189
+ * tellable apart at a glance. The count sits on the heading rather than beside it so there is
190
+ * one thing to click.
191
+ */
192
+ let staffOpen = true;
193
+
194
+ function paintStaffFold() {
195
+ const b = $("#stafffold");
196
+ if (b) b.setAttribute("aria-expanded", String(staffOpen));
197
+ const list = $("#stafflist");
198
+ if (list) list.hidden = !staffOpen;
199
+ const n = $("#staffcount");
200
+ if (n) {
201
+ n.textContent = "(" + (S.data?.staff?.length ?? 0) + ")";
202
+ n.hidden = staffOpen;
203
+ }
204
+ }
205
+
206
+ function initStaffFold() {
207
+ // Folded until you say otherwise: the count on the heading is enough to know the roster is
208
+ // there, and the four org-wide screens above it are where a session starts.
209
+ staffOpen = store("roster.staffopen") === "1";
210
+ paintStaffFold();
211
+ const b = $("#stafffold");
212
+ if (!b) return;
213
+ b.onclick = () => {
214
+ staffOpen = !staffOpen;
215
+ store("roster.staffopen", staffOpen ? "1" : "0");
216
+ paintStaffFold();
217
+ };
218
+ }
219
+
183
220
  function paintSidebar() {
184
221
  const host = $("#stafflist");
185
222
  host.replaceChildren();
@@ -228,6 +265,9 @@ function paintSidebar() {
228
265
 
229
266
  host.append(head, views);
230
267
  }
268
+
269
+ // A refresh can hire or retire someone, and the folded heading is where that number shows.
270
+ paintStaffFold();
231
271
  }
232
272
 
233
273
  /** Which rows are lit, and which staff member is unfolded. */
@@ -36,7 +36,9 @@ export const S = {
36
36
  loadedAt: null,
37
37
 
38
38
  staffHandle: null,
39
- view: "brain",
39
+ /* What is waiting on you, not whose brain you read last. The org-wide screens are where a
40
+ session starts, which is also why the Staff list below them opens folded. */
41
+ view: "inbox",
40
42
  /** The inbox filter box. Brain and "what changed" keep their own, below. */
41
43
  query: "",
42
44
 
@@ -73,9 +75,10 @@ export const S = {
73
75
 
74
76
  /* The sidebar badge counts open items, not everything loaded. Once the inbox could show
75
77
  closed work the two stopped being the same number, and a badge that says 135 when 34 things
76
- need you is worse than no badge. */
78
+ need you is worse than no badge. Pull requests are excluded for the same reason: they are
79
+ Pending work's, and counting them here would bill you twice for the same pile. */
77
80
  export const openCount = () =>
78
- (S.inbox?.items ?? []).filter((i) => i.state === "OPEN").length;
81
+ (S.inbox?.items ?? []).filter((i) => i.state === "OPEN" && i.kind !== "pr").length;
79
82
 
80
83
  /** The same, for the screen that is only pull requests. */
81
84
  export const openPrCount = () =>
@@ -113,7 +113,7 @@ function inboxScreen(m, opts) {
113
113
  className: "sub",
114
114
  textContent: opts.prs
115
115
  ? "Work the staff have finished and cannot land themselves."
116
- : "Everything open across the org.",
116
+ : "Everything open across the org. Pull requests are in Pending work.",
117
117
  });
118
118
  m.append(sub);
119
119
 
@@ -126,13 +126,8 @@ function inboxScreen(m, opts) {
126
126
  scope.append(
127
127
  el("option", { value: "", textContent: "Everything" }),
128
128
  el("option", { value: "mine", textContent: "On " + humanLabel() }),
129
- // Scoping pull requests to pull requests is not a filter, and a decision is not a PR.
130
- ...(opts.prs
131
- ? []
132
- : [
133
- el("option", { value: "decision", textContent: "Decisions" }),
134
- el("option", { value: "pr", textContent: "Open work (PRs)" }),
135
- ]),
129
+ // A decision is not a PR, so this one is only ever worth offering on the inbox.
130
+ ...(opts.prs ? [] : [el("option", { value: "decision", textContent: "Decisions" })]),
136
131
  );
137
132
  scope.value = opts.prs && S.inboxFilter !== "mine" ? "" : S.inboxFilter;
138
133
 
@@ -187,9 +182,9 @@ function inboxScreen(m, opts) {
187
182
  acted on, because only the already-loaded branch opened one. */
188
183
  function restore() {
189
184
  if (!S.inboxOpen) return;
190
- // The two screens share one open thread. An issue carried over from the inbox is not on
191
- // this one, so it is dropped rather than opened beside a list it is not in.
192
- if (opts.prs && S.inboxOpen.kind !== "pr") {
185
+ // The two screens share one open thread, and each holds half the org. Whatever was open on
186
+ // the other one is not in this list, so it is dropped rather than opened beside it.
187
+ if (!!opts.prs !== (S.inboxOpen.kind === "pr")) {
193
188
  S.inboxOpen = null;
194
189
  writeHash(false);
195
190
  return;
@@ -251,9 +246,12 @@ function inboxScreen(m, opts) {
251
246
  const onAHuman = (i) => (i.assignees ?? []).some(isHuman);
252
247
 
253
248
  const whoseStaff = S.inboxStaff ? S.data.staff.find((s) => s.handle === S.inboxStaff) : null;
249
+ /* The two screens partition the org rather than overlap on it. Pending work is every pull
250
+ request; the inbox is everything else. An inbox that also listed the PRs said the same
251
+ thing twice and made the badge beside it a number you could not act on. */
254
252
  const mine = S.inbox.items
255
253
  .filter((i) => belongsTo(i, whoseStaff))
256
- .filter((i) => !opts.prs || i.kind === "pr");
254
+ .filter((i) => (i.kind === "pr") === !!opts.prs);
257
255
  const isOpen = (i) => i.state === "OPEN";
258
256
  const scoped = mine.filter(
259
257
  S.inboxState === "closed" ? (i) => !isOpen(i) : S.inboxState === "all" ? () => true : isOpen,
@@ -265,12 +263,17 @@ function inboxScreen(m, opts) {
265
263
  );
266
264
  if (S.inboxFilter === "mine") items = items.filter(onAHuman);
267
265
  else if (S.inboxFilter === "decision") items = items.filter((i) => i.labels.includes("decision"));
268
- else if (S.inboxFilter === "pr") items = items.filter((i) => i.kind === "pr");
266
+ /* `s=pr` was the inbox's "Open work (PRs)" scope, and Pending work is now that screen. An
267
+ old bookmark carrying it is ignored rather than honoured into an empty list. */
269
268
 
270
269
  const open = mine.filter(isOpen);
271
270
  const onYou = open.filter(onAHuman).length;
272
- const prs = open.filter((i) => i.kind === "pr").length;
273
271
  const shut = mine.length - open.length;
272
+ /* Pending work owns the pull requests now, so this is a pointer to that screen rather than
273
+ a count of anything on this list. Scoped the same way, or the two would not add up. */
274
+ const prs = S.inbox.items.filter(
275
+ (i) => i.kind === "pr" && isOpen(i) && belongsTo(i, whoseStaff),
276
+ ).length;
274
277
  const where = whoseStaff
275
278
  ? " for <b>" + esc(whoseStaff.name) + "</b>"
276
279
  : " across " + (S.inbox.repos ?? []).length + " repos";
@@ -286,7 +289,7 @@ function inboxScreen(m, opts) {
286
289
  (failing ? " · " + failing + " failing" : "") +
287
290
  (S.inboxState === "all" ? " · " + shut + " closed or merged" : "")
288
291
  : open.length + " open" + where + " · <b>" + onYou + " on " +
289
- esc(humanLabel()) + "</b> · " + prs + " waiting on a merge" +
292
+ esc(humanLabel()) + "</b> · " + prs + " in Pending work" +
290
293
  (S.inboxState === "all" ? " · " + shut + " closed" : "")) +
291
294
  (S.inbox.fetchedAt ? ' <span class="meta">· checked ' + ago(S.inbox.fetchedAt) + "</span>" : "");
292
295
 
@@ -324,6 +327,12 @@ function inboxScreen(m, opts) {
324
327
  '<span class="num">' + (i.kind === "pr" ? "PR " : "") + "#" + i.number + "</span>" +
325
328
  (i.checks && i.checks !== "none"
326
329
  ? '<span class="ck ' + i.checks + '">' + checkGlyph(i.checks) + "</span>" : "") +
330
+ /* Green checks on a branch that no longer applies is the one case where the row
331
+ reads as ready and is not. It has to say so here, not three clicks in. */
332
+ (i.mergeable === "CONFLICTING"
333
+ ? '<span class="chip conflict" title="This branch conflicts with its base">' +
334
+ "conflicts</span>"
335
+ : "") +
327
336
  // How much conversation is on a thread, which is most of what tells a live one from
328
337
  // something that was filed and never answered.
329
338
  (i.comments.length
@@ -365,6 +374,10 @@ function inboxScreen(m, opts) {
365
374
  ' · <span class="' + (item.state === "OPEN" ? "ok" : "") + '">' + esc(item.state) + "</span>" +
366
375
  " · " + esc(item.author) + " · " + ago(item.updatedAt) + "</div>" +
367
376
  "<h3>" + esc(item.title) + "</h3>" +
377
+ (item.mergeable === "CONFLICTING"
378
+ ? '<p class="tconflict">This branch conflicts with its base and cannot be merged ' +
379
+ "until someone rebases or merges the base into it.</p>"
380
+ : "") +
368
381
  (item.labels.length
369
382
  ? '<div class="row tlabels">' +
370
383
  item.labels.map((l) => '<span class="chip ' + tone(l) + '">' + esc(l) + "</span>").join("") +
@@ -527,8 +540,11 @@ function inboxScreen(m, opts) {
527
540
  b.onclick = async () => {
528
541
  b.disabled = true;
529
542
  try {
530
- const r = await openReply(item, f);
531
- if (r) b.textContent = r.warning ? "sent, with a warning" : "sent";
543
+ // Same reason as the header's Reply: the wait is after the box closes, not during it.
544
+ const r = await openReply(item, f, () => {
545
+ b.textContent = "Replying…";
546
+ });
547
+ b.textContent = r ? (r.warning ? "sent, with a warning" : "sent") : "Reply";
532
548
  } catch (e) {
533
549
  b.textContent = e.message;
534
550
  }
@@ -590,24 +606,43 @@ function inboxScreen(m, opts) {
590
606
  /* One button, because there was no way to tell the two apart. Reply reaches whoever you
591
607
  named in it; see `openReply`. */
592
608
  const reply = el("button", { className: "ghbtn primary", textContent: "Reply" });
609
+
610
+ /* Posting a comment is a round trip to GitHub through `gh`, and on a cold connection that
611
+ is seconds of a button that still says "Reply" and still takes clicks. Saying nothing
612
+ reads as a page that ignored you, which is how the same comment gets sent twice. */
613
+ const sending = (on) => {
614
+ busy(on, on ? "sending…" : "");
615
+ reply.textContent = on ? "Replying…" : "Reply";
616
+ };
617
+
593
618
  reply.onclick = async () => {
594
619
  let r;
595
620
  try {
596
- r = await openReply(item, null);
621
+ r = await openReply(item, null, () => sending(true));
597
622
  } catch (e) {
623
+ sending(false);
598
624
  failed(e);
599
625
  return;
600
626
  }
601
- if (!r) return;
627
+ // Closed the box without sending: nothing ever started, so there is nothing to put back.
628
+ if (!r) {
629
+ sending(false);
630
+ return;
631
+ }
602
632
  /* Repaint only when something was said on this thread, which is always true here: `ask`
603
633
  posts the comment too. A warning means the tracker issue went up and the comment did
604
634
  not, and then the status line is the only thing that says so. */
605
635
  if (r.warning) {
636
+ sending(false);
606
637
  status.className = "meta err";
607
638
  status.textContent = r.warning;
608
639
  return;
609
640
  }
641
+ /* Held across the reload as well as the post. The reply is not on screen until the
642
+ thread repaints, and a button that said "Reply" again before then would be claiming
643
+ it was done over a thread that still did not show it. */
610
644
  await reloadThread(item);
645
+ sending(false);
611
646
  };
612
647
  buttons.push(reply);
613
648
 
@@ -651,6 +686,12 @@ function inboxScreen(m, opts) {
651
686
  own settings. The server reads it from there. */
652
687
  if (item.kind === "pr" && item.state === "OPEN") {
653
688
  const merge = el("button", { className: "ghbtn", textContent: "Merge" });
689
+ /* A conflicted branch cannot be merged by anyone, so the button says why instead of
690
+ spending a round trip to come back with GitHub's version of the same sentence. */
691
+ if (item.mergeable === "CONFLICTING") {
692
+ merge.disabled = true;
693
+ merge.title = "This branch conflicts with its base";
694
+ }
654
695
  merge.onclick = async () => {
655
696
  if (!confirm("Merge " + item.repo + " #" + item.number + " into its base branch?")) return;
656
697
  busy(true, "merging…");
@@ -714,7 +755,15 @@ function inboxScreen(m, opts) {
714
755
  * @param anchor the file this is about, when it was opened from a diff. Its hunk rides along.
715
756
  * @returns the result of the write, or null if nothing was written.
716
757
  */
717
- async function openReply(item, anchor) {
758
+ /**
759
+ * @param onSend Called once, the moment the box is submitted and the writing starts.
760
+ *
761
+ * The dialog and the send are one `await` to whoever called this, so a caller cannot tell the
762
+ * two apart by itself — and the two want opposite things from a button. While the box is open
763
+ * you are still typing and the button has to stay as it was; once it closes there is a request
764
+ * in flight against GitHub that can take seconds, and that is what needs saying.
765
+ */
766
+ async function openReply(item, anchor, onSend) {
718
767
  const key = item.repo + "#" + item.number + (anchor ? " · " + anchor.path : "");
719
768
  let deaf = null;
720
769
 
@@ -737,6 +786,7 @@ async function openReply(item, anchor) {
737
786
  },
738
787
  });
739
788
  if (!body) return null;
789
+ onSend?.();
740
790
 
741
791
  /* One press, both writes. `ask` posts the comment itself when told to, so these are not two
742
792
  requests racing: the tracker issue goes up first because it is the half that actually