@nanocollective/roster 0.1.0-alpha.52 → 0.1.0-alpha.54

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
@@ -2892,30 +2892,36 @@ function overBudget(doc, dir, budgets) {
2892
2892
  }
2893
2893
 
2894
2894
  // src/lib/runs.ts
2895
- import { existsSync as existsSync15, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, rmSync as rmSync2 } from "fs";
2896
- import { tmpdir as tmpdir2 } from "os";
2895
+ import { existsSync as existsSync15, mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, readFileSync as readFileSync14, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
2896
+ import { homedir, tmpdir as tmpdir2 } from "os";
2897
2897
  import { join as join16 } from "path";
2898
2898
  var KINDS2 = ["daily", "mention"];
2899
2899
  var records = /* @__PURE__ */ new Map();
2900
- async function staffRuns(brain, handle, days = 30) {
2900
+ var fetching = /* @__PURE__ */ new Set();
2901
+ function cacheDir() {
2902
+ return process.env.ROSTER_CACHE_DIR ?? join16(homedir(), ".cache", "roster", "runs");
2903
+ }
2904
+ function cacheFile(brain, id) {
2905
+ return join16(cacheDir(), brain.replace("/", "__"), `${id}.json`);
2906
+ }
2907
+ function knownRecord(brain, id) {
2908
+ const key = `${brain}#${id}`;
2909
+ if (records.has(key)) return records.get(key);
2910
+ const file = cacheFile(brain, id);
2911
+ if (!existsSync15(file)) return void 0;
2912
+ try {
2913
+ const record = JSON.parse(readFileSync14(file, "utf8")).record;
2914
+ records.set(key, record);
2915
+ return record;
2916
+ } catch {
2917
+ return void 0;
2918
+ }
2919
+ }
2920
+ async function staffRuns(brain, handle, days = 30, opts = {}) {
2901
2921
  const since = new Date(Date.now() - days * 864e5).toISOString().slice(0, 10);
2902
2922
  const lists = await Promise.all(
2903
2923
  KINDS2.map(
2904
- (kind) => ghJson([
2905
- "run",
2906
- "list",
2907
- "--repo",
2908
- brain,
2909
- "--workflow",
2910
- `${handle}-${kind}.yaml`,
2911
- "--created",
2912
- `>=${since}`,
2913
- "--limit",
2914
- // Skipped mentions (the gate saying no) count towards the limit, and there are hundreds.
2915
- kind === "mention" ? "500" : "100",
2916
- "--json",
2917
- "databaseId,conclusion,status,createdAt,updatedAt,url"
2918
- ]).then((res) => ({ kind, res }))
2924
+ (kind) => realRuns(brain, `${handle}-${kind}.yaml`, since).then((res) => ({ kind, res }))
2919
2925
  )
2920
2926
  );
2921
2927
  const errors = [];
@@ -2943,14 +2949,71 @@ async function staffRuns(brain, handle, days = 30) {
2943
2949
  }
2944
2950
  }
2945
2951
  rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
2946
- await pool(
2947
- rows.filter((r) => r.status === "completed"),
2948
- 6,
2949
- async (row) => {
2950
- row.record = await recordFor(brain, row.id);
2952
+ const missing = [];
2953
+ for (const row of rows.filter((r) => r.status === "completed")) {
2954
+ const known = knownRecord(brain, row.id);
2955
+ if (known === void 0) missing.push(row);
2956
+ else row.record = known;
2957
+ }
2958
+ if (opts.wait === false) {
2959
+ const todo = missing.filter((r) => !fetching.has(`${brain}#${r.id}`));
2960
+ for (const r of todo) fetching.add(`${brain}#${r.id}`);
2961
+ void pool(todo, 6, async (row) => {
2962
+ await recordFor(brain, row.id);
2963
+ fetching.delete(`${brain}#${row.id}`);
2964
+ });
2965
+ return { runs: rows, errors, pending: missing.length };
2966
+ }
2967
+ await pool(missing, 6, async (row) => {
2968
+ row.record = await recordFor(brain, row.id);
2969
+ });
2970
+ return { runs: rows, errors, pending: 0 };
2971
+ }
2972
+ var OUTCOMES = [
2973
+ "success",
2974
+ "failure",
2975
+ "cancelled",
2976
+ "timed_out",
2977
+ "action_required",
2978
+ "startup_failure",
2979
+ "in_progress",
2980
+ "queued"
2981
+ ];
2982
+ async function realRuns(brain, workflow, since) {
2983
+ const one = async (status) => {
2984
+ const out = [];
2985
+ for (let page2 = 1; page2 <= 10; page2++) {
2986
+ const res = await ghJson([
2987
+ "api",
2988
+ `repos/${brain}/actions/workflows/${workflow}/runs?created=>=${since}&status=${status}&per_page=100&page=${page2}`
2989
+ ]);
2990
+ if (!res.ok) return { ok: false, error: res.error };
2991
+ const runs = res.data?.workflow_runs ?? [];
2992
+ out.push(...runs);
2993
+ if (runs.length < 100) break;
2951
2994
  }
2952
- );
2953
- return { runs: rows, errors };
2995
+ return { ok: true, runs: out };
2996
+ };
2997
+ const answers = await Promise.all(OUTCOMES.map(one));
2998
+ const failed = answers.find((a) => !a.ok);
2999
+ if (failed && !failed.ok) return { ok: false, error: failed.error };
3000
+ const seen = /* @__PURE__ */ new Set();
3001
+ const data = [];
3002
+ for (const a of answers) {
3003
+ for (const r of a.ok ? a.runs : []) {
3004
+ if (seen.has(r.id)) continue;
3005
+ seen.add(r.id);
3006
+ data.push({
3007
+ databaseId: r.id,
3008
+ conclusion: r.conclusion,
3009
+ status: r.status,
3010
+ createdAt: r.created_at,
3011
+ updatedAt: r.updated_at,
3012
+ url: r.html_url
3013
+ });
3014
+ }
3015
+ }
3016
+ return { ok: true, data };
2954
3017
  }
2955
3018
  async function recordFor(brain, id) {
2956
3019
  const key = `${brain}#${id}`;
@@ -2971,6 +3034,13 @@ async function recordFor(brain, id) {
2971
3034
  const file = join16(dir, "run.json");
2972
3035
  const record = res.ok && existsSync15(file) ? JSON.parse(readFileSync14(file, "utf8")) : null;
2973
3036
  records.set(key, record);
3037
+ if (res.ok || /no valid artifacts|not found|no artifact/i.test(res.error ?? "")) {
3038
+ try {
3039
+ mkdirSync3(join16(cacheDir(), brain.replace("/", "__")), { recursive: true });
3040
+ writeFileSync4(cacheFile(brain, id), JSON.stringify({ record }));
3041
+ } catch {
3042
+ }
3043
+ }
2974
3044
  return record;
2975
3045
  } catch {
2976
3046
  records.set(key, null);
@@ -3005,19 +3075,19 @@ function budgetOf(value) {
3005
3075
  import { execFileSync as execFileSync5 } from "child_process";
3006
3076
  import {
3007
3077
  existsSync as existsSync16,
3008
- mkdirSync as mkdirSync3,
3078
+ mkdirSync as mkdirSync4,
3009
3079
  mkdtempSync as mkdtempSync4,
3010
3080
  readdirSync as readdirSync5,
3011
3081
  readFileSync as readFileSync15,
3012
3082
  rmSync as rmSync4,
3013
- writeFileSync as writeFileSync5
3083
+ writeFileSync as writeFileSync6
3014
3084
  } from "fs";
3015
3085
  import { tmpdir as tmpdir4 } from "os";
3016
3086
  import { dirname as dirname6, join as join18 } from "path";
3017
3087
 
3018
3088
  // src/lib/merge.ts
3019
3089
  import { execFileSync as execFileSync4 } from "child_process";
3020
- import { mkdtempSync as mkdtempSync3, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
3090
+ import { mkdtempSync as mkdtempSync3, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "fs";
3021
3091
  import { tmpdir as tmpdir3 } from "os";
3022
3092
  import { join as join17 } from "path";
3023
3093
  function merge3(current, base2, incoming) {
@@ -3026,9 +3096,9 @@ function merge3(current, base2, incoming) {
3026
3096
  const c = join17(dir, "current");
3027
3097
  const b = join17(dir, "base");
3028
3098
  const i = join17(dir, "incoming");
3029
- writeFileSync4(c, current);
3030
- writeFileSync4(b, base2);
3031
- writeFileSync4(i, incoming);
3099
+ writeFileSync5(c, current);
3100
+ writeFileSync5(b, base2);
3101
+ writeFileSync5(i, incoming);
3032
3102
  try {
3033
3103
  const text = execFileSync4(
3034
3104
  "git",
@@ -3237,8 +3307,8 @@ function apply(opsDir, seedDir, tplDir, plans) {
3237
3307
  if (p.verdict === "edited-managed") edited++;
3238
3308
  if (p.verdict === "conflict") {
3239
3309
  const out = join18(opsDir, p.rel + ".roster-merge");
3240
- mkdirSync3(dirname6(out), { recursive: true });
3241
- writeFileSync5(out, p.rejected);
3310
+ mkdirSync4(dirname6(out), { recursive: true });
3311
+ writeFileSync6(out, p.rejected);
3242
3312
  process.stdout.write(` wrote ${p.rel}.roster-merge \u2014 resolve it, then re-run
3243
3313
  `);
3244
3314
  left++;
@@ -3253,8 +3323,8 @@ function apply(opsDir, seedDir, tplDir, plans) {
3253
3323
  continue;
3254
3324
  }
3255
3325
  const dest = join18(opsDir, p.rel);
3256
- mkdirSync3(dirname6(dest), { recursive: true });
3257
- writeFileSync5(dest, p.next);
3326
+ mkdirSync4(dirname6(dest), { recursive: true });
3327
+ writeFileSync6(dest, p.next);
3258
3328
  seed(seedDir, tplDir, p.rel);
3259
3329
  wrote++;
3260
3330
  }
@@ -3263,7 +3333,7 @@ function apply(opsDir, seedDir, tplDir, plans) {
3263
3333
  process.stdout.write(left ? `, ${left} left for you
3264
3334
 
3265
3335
  ` : "\n\n");
3266
- if (!left) writeFileSync5(join18(opsDir, ".roster-version"), stamp() + "\n");
3336
+ if (!left) writeFileSync6(join18(opsDir, ".roster-version"), stamp() + "\n");
3267
3337
  if (edited) {
3268
3338
  process.stdout.write(
3269
3339
  ` ${edited} framework file${edited === 1 ? "" : "s"} still carr${edited === 1 ? "ies" : "y"} a local edit. Move it into the framework, or doctor keeps failing.
@@ -3275,8 +3345,8 @@ function apply(opsDir, seedDir, tplDir, plans) {
3275
3345
  }
3276
3346
  function seed(seedDir, tplDir, rel) {
3277
3347
  const dest = join18(seedDir, rel);
3278
- mkdirSync3(dirname6(dest), { recursive: true });
3279
- writeFileSync5(dest, readFileSync15(join18(tplDir, rel), "utf8"));
3348
+ mkdirSync4(dirname6(dest), { recursive: true });
3349
+ writeFileSync6(dest, readFileSync15(join18(tplDir, rel), "utf8"));
3280
3350
  }
3281
3351
  function writeBaseline(opsDir, seedDir, tplDir, ref) {
3282
3352
  const framework = join18(tplDir, "..", "..");
@@ -3301,10 +3371,10 @@ function writeBaseline(opsDir, seedDir, tplDir, ref) {
3301
3371
  maxBuffer: 16 * 1024 * 1024
3302
3372
  });
3303
3373
  const dest = join18(seedDir, rel);
3304
- mkdirSync3(dirname6(dest), { recursive: true });
3305
- writeFileSync5(dest, text);
3374
+ mkdirSync4(dirname6(dest), { recursive: true });
3375
+ writeFileSync6(dest, text);
3306
3376
  }
3307
- writeFileSync5(join18(opsDir, ".roster-version"), `${ref}
3377
+ writeFileSync6(join18(opsDir, ".roster-version"), `${ref}
3308
3378
  `);
3309
3379
  process.stdout.write(
3310
3380
  `
@@ -3408,8 +3478,8 @@ function planBrains(ws, parseYaml) {
3408
3478
  function unified(before, after) {
3409
3479
  const dir = mkdtempSync4(join18(tmpdir4(), "roster-diff-"));
3410
3480
  try {
3411
- writeFileSync5(join18(dir, "a"), before);
3412
- writeFileSync5(join18(dir, "b"), after);
3481
+ writeFileSync6(join18(dir, "a"), before);
3482
+ writeFileSync6(join18(dir, "b"), after);
3413
3483
  try {
3414
3484
  execFileSync5(
3415
3485
  "diff",
@@ -3481,8 +3551,8 @@ function applyBrains(brains) {
3481
3551
  }
3482
3552
  if (f.next === void 0) continue;
3483
3553
  const dest = join18(b.root, f.rel);
3484
- mkdirSync3(dirname6(dest), { recursive: true });
3485
- writeFileSync5(dest, f.next);
3554
+ mkdirSync4(dirname6(dest), { recursive: true });
3555
+ writeFileSync6(dest, f.next);
3486
3556
  wrote++;
3487
3557
  }
3488
3558
  }
@@ -4516,7 +4586,7 @@ function unkindedAsks(scope, repo, issues, humans) {
4516
4586
  }
4517
4587
 
4518
4588
  // src/commands/export.ts
4519
- import { writeFileSync as writeFileSync6 } from "fs";
4589
+ import { writeFileSync as writeFileSync7 } from "fs";
4520
4590
 
4521
4591
  // src/lib/export.ts
4522
4592
  import { execFileSync as execFileSync6 } from "child_process";
@@ -4707,7 +4777,7 @@ async function exportCommand(argv) {
4707
4777
  const data = buildExport(ws, readOrg(ws.opsDir, parseYaml), parseYaml);
4708
4778
  const json2 = JSON.stringify(data, null, 2);
4709
4779
  if (out) {
4710
- writeFileSync6(out, json2 + "\n");
4780
+ writeFileSync7(out, json2 + "\n");
4711
4781
  const facts = data.staff.reduce((n, s) => n + s.facts.length, 0);
4712
4782
  process.stdout.write(
4713
4783
  ` ${out} ${data.staff.length} staff, ${facts} facts, ${Math.round(json2.length / 1024)}kb
@@ -5062,7 +5132,7 @@ function parseFlags7(argv) {
5062
5132
 
5063
5133
  // src/commands/init.ts
5064
5134
  import { execFileSync as execFileSync7 } from "child_process";
5065
- import { existsSync as existsSync20, mkdirSync as mkdirSync4, readFileSync as readFileSync19, writeFileSync as writeFileSync7 } from "fs";
5135
+ import { existsSync as existsSync20, mkdirSync as mkdirSync5, readFileSync as readFileSync19, writeFileSync as writeFileSync8 } from "fs";
5066
5136
  import { dirname as dirname7, join as join22, resolve as resolve3 } from "path";
5067
5137
  var initHelp = `
5068
5138
  roster init --org <github-org> [--name "Acme"] [--human <login>] [--apply]
@@ -5155,16 +5225,16 @@ async function initCommand(argv) {
5155
5225
  process.stdout.write(" Nothing was created. Re-run with --apply.\n\n");
5156
5226
  return 0;
5157
5227
  }
5158
- mkdirSync4(opsDir, { recursive: true });
5228
+ mkdirSync5(opsDir, { recursive: true });
5159
5229
  for (const [rel, text] of files) {
5160
5230
  const dest = join22(opsDir, rel);
5161
- mkdirSync4(dirname7(dest), { recursive: true });
5162
- writeFileSync7(dest, text);
5231
+ mkdirSync5(dirname7(dest), { recursive: true });
5232
+ writeFileSync8(dest, text);
5163
5233
  }
5164
5234
  for (const rel of templateFiles(opsTemplateDir())) {
5165
5235
  const dest = join22(opsDir, ".roster", "seed", rel);
5166
- mkdirSync4(dirname7(dest), { recursive: true });
5167
- writeFileSync7(dest, readFileSync19(join22(opsTemplateDir(), rel), "utf8"));
5236
+ mkdirSync5(dirname7(dest), { recursive: true });
5237
+ writeFileSync8(dest, readFileSync19(join22(opsTemplateDir(), rel), "utf8"));
5168
5238
  }
5169
5239
  process.stdout.write(` wrote ${files.size} files and the recorded base
5170
5240
  `);
@@ -5658,6 +5728,17 @@ async function act(req) {
5658
5728
  await run3("gh", ["issue", "reopen", n, "--repo", repo], { encoding: "utf8" });
5659
5729
  return { ok: true, action };
5660
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
+ }
5661
5742
  if (action === "merge") {
5662
5743
  const how = req.mergeMethod ?? await mergeMethodFor(repo);
5663
5744
  const flag = MERGE_FLAG[how];
@@ -5717,6 +5798,10 @@ function whyNotMerged(ghError, pr) {
5717
5798
  return `Not merged: checks failed (${list(failing)}).`;
5718
5799
  if (pr?.mergeStateStatus === "BLOCKED" && running.length)
5719
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.";
5720
5805
  if (pr?.mergeStateStatus === "BLOCKED")
5721
5806
  return "Not merged: the branch rules require something first, such as a review. Open it in GitHub to see what.";
5722
5807
  if (pr?.mergeStateStatus === "BEHIND")
@@ -5734,7 +5819,15 @@ async function prState(repo, n) {
5734
5819
  try {
5735
5820
  const { stdout } = await run3(
5736
5821
  "gh",
5737
- ["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
+ ],
5738
5831
  { encoding: "utf8" }
5739
5832
  );
5740
5833
  return JSON.parse(stdout);
@@ -5776,7 +5869,7 @@ function execWithStdin(args, input) {
5776
5869
 
5777
5870
  // src/lib/attach.ts
5778
5871
  import { execFileSync as execFileSync8 } from "child_process";
5779
- import { existsSync as existsSync21, mkdirSync as mkdirSync5, writeFileSync as writeFileSync8 } from "fs";
5872
+ import { existsSync as existsSync21, mkdirSync as mkdirSync6, writeFileSync as writeFileSync9 } from "fs";
5780
5873
  import { basename, extname, join as join24 } from "path";
5781
5874
  var MAX_UPLOAD = 25 * 1024 * 1024;
5782
5875
  var ATTACH_DIR = "attachments";
@@ -5791,8 +5884,8 @@ function attach(opts) {
5791
5884
  }
5792
5885
  const rel = free(repoDir, `${ATTACH_DIR}/${today}-${safeName(opts.name)}`);
5793
5886
  const full = join24(repoDir, rel);
5794
- mkdirSync5(join24(repoDir, ATTACH_DIR), { recursive: true });
5795
- writeFileSync8(full, data);
5887
+ mkdirSync6(join24(repoDir, ATTACH_DIR), { recursive: true });
5888
+ writeFileSync9(full, data);
5796
5889
  const git4 = (args) => execFileSync8("git", ["-C", repoDir, ...args], {
5797
5890
  encoding: "utf8",
5798
5891
  stdio: ["ignore", "pipe", "pipe"]
@@ -5950,7 +6043,7 @@ var LIGHT = `number title url state createdAt updatedAt closedAt body
5950
6043
  closer: timelineItems(last:1, itemTypes:[CLOSED_EVENT]) {
5951
6044
  nodes { ... on ClosedEvent { actor { login } } }
5952
6045
  }`;
5953
- var LIGHT_PR = `${LIGHT} isDraft mergeable
6046
+ var LIGHT_PR = `${LIGHT} isDraft mergeable reviewDecision
5954
6047
  commits(last:1) { nodes { commit { statusCheckRollup { state } } } }`;
5955
6048
  var QUERY = `
5956
6049
  query($owner:String!, $name:String!) {
@@ -5983,7 +6076,7 @@ query($owner:String!, $name:String!, $number:Int!) {
5983
6076
  timelineItems(last:100, itemTypes:${ISSUE_TYPES}) { nodes { ${TIMELINE_COMMON} } }
5984
6077
  }
5985
6078
  ... on PullRequest {
5986
- number title body url state createdAt updatedAt isDraft mergeable ${ROW_EXTRAS}
6079
+ number title body url state createdAt updatedAt isDraft mergeable reviewDecision ${ROW_EXTRAS}
5987
6080
  author { login }
5988
6081
  ${REACTIONS}
5989
6082
  labels(first:12) { nodes { name } }
@@ -6009,6 +6102,7 @@ async function fetchInbox(repos) {
6009
6102
  item.draft = n.isDraft;
6010
6103
  item.checks = rollup(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state);
6011
6104
  item.mergeable = n.mergeable;
6105
+ if (n.reviewDecision) item.reviewDecision = n.reviewDecision;
6012
6106
  items.push(item);
6013
6107
  }
6014
6108
  const cutoff = Date.now() - CLOSED_DAYS * 864e5;
@@ -6223,6 +6317,7 @@ async function fetchThread(repo, number, kind) {
6223
6317
  item.draft = n.isDraft;
6224
6318
  item.checks = rollup(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state);
6225
6319
  item.mergeable = n.mergeable;
6320
+ if (n.reviewDecision) item.reviewDecision = n.reviewDecision;
6226
6321
  }
6227
6322
  return item;
6228
6323
  }
@@ -6501,7 +6596,7 @@ function portalAsset(rel) {
6501
6596
  }
6502
6597
 
6503
6598
  // src/commands/retire.ts
6504
- import { existsSync as existsSync24, readdirSync as readdirSync8, readFileSync as readFileSync21, writeFileSync as writeFileSync9 } from "fs";
6599
+ import { existsSync as existsSync24, readdirSync as readdirSync8, readFileSync as readFileSync21, writeFileSync as writeFileSync10 } from "fs";
6505
6600
  import { join as join27 } from "path";
6506
6601
  var retireHelp = `
6507
6602
  roster retire <handle>
@@ -6637,7 +6732,7 @@ async function applyRetirePlan(ws, plan2) {
6637
6732
  for (const p of plan2.peers) {
6638
6733
  const path = join27(ws.root, p.dir, "staff.yaml");
6639
6734
  const text = readFileSync21(path, "utf8");
6640
- writeFileSync9(path, removePeerLine(text, plan2.handle));
6735
+ writeFileSync10(path, removePeerLine(text, plan2.handle));
6641
6736
  process.stdout.write(` removed from ${p.dir}/staff.yaml
6642
6737
  `);
6643
6738
  if (!p.brain) continue;
@@ -6665,7 +6760,7 @@ async function applyRetirePlan(ws, plan2) {
6665
6760
  function removeFromOrgYaml(ws, plan2) {
6666
6761
  const path = join27(ws.opsDir, "org.yaml");
6667
6762
  const text = readFileSync21(path, "utf8");
6668
- writeFileSync9(path, removeOrgLines(text, plan2.handle, plan2.dir));
6763
+ writeFileSync10(path, removeOrgLines(text, plan2.handle, plan2.dir));
6669
6764
  }
6670
6765
  function removeOrgLines(text, handle, dir) {
6671
6766
  return text.split("\n").filter((line) => {
@@ -7777,7 +7872,9 @@ async function portalCommand(argv) {
7777
7872
  const staff = await Promise.all(
7778
7873
  (org.staff ?? []).map(async (s) => {
7779
7874
  const brain = `${org.org}/${s.dir ?? s.handle}`;
7780
- const { runs, errors } = await staffRuns(brain, s.handle);
7875
+ const { runs, errors, pending: pending2 } = await staffRuns(brain, s.handle, 30, {
7876
+ wait: false
7877
+ });
7781
7878
  return {
7782
7879
  handle: s.handle,
7783
7880
  name: s.name ?? s.handle,
@@ -7785,7 +7882,8 @@ async function portalCommand(argv) {
7785
7882
  budget: budgetOf(s.budget),
7786
7883
  spend: spend(runs),
7787
7884
  runs,
7788
- errors
7885
+ errors,
7886
+ pending: pending2
7789
7887
  };
7790
7888
  })
7791
7889
  );
@@ -7795,14 +7893,17 @@ async function portalCommand(argv) {
7795
7893
  total.known += s.spend.known;
7796
7894
  total.runs += s.spend.runs;
7797
7895
  }
7896
+ const pending = staff.reduce((n, s) => n + s.pending, 0);
7798
7897
  const body2 = {
7799
7898
  online: true,
7800
7899
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
7801
7900
  budget: budgetOf(org.budget),
7802
7901
  spend: total,
7803
- staff
7902
+ staff,
7903
+ // Costs still being read. The screen asks again until this is 0.
7904
+ pending
7804
7905
  };
7805
- runsCache = { at: Date.now(), body: body2 };
7906
+ if (!pending) runsCache = { at: Date.now(), body: body2 };
7806
7907
  return body2;
7807
7908
  })().then((body2) => json(res, body2)).catch(
7808
7909
  (err) => json(res, { online: false, error: String(err?.message ?? err), staff: [] })
@@ -8149,7 +8250,7 @@ function parseFlags11(argv) {
8149
8250
 
8150
8251
  // src/commands/prompt.ts
8151
8252
  import { execFileSync as execFileSync10 } from "child_process";
8152
- import { existsSync as existsSync26, mkdtempSync as mkdtempSync5, readFileSync as readFileSync24, writeFileSync as writeFileSync10 } from "fs";
8253
+ import { existsSync as existsSync26, mkdtempSync as mkdtempSync5, readFileSync as readFileSync24, writeFileSync as writeFileSync11 } from "fs";
8153
8254
  import { tmpdir as tmpdir5 } from "os";
8154
8255
  import { join as join29 } from "path";
8155
8256
 
@@ -8220,8 +8321,8 @@ async function promptCommand(argv) {
8220
8321
  const dir = mkdtempSync5(join29(tmpdir5(), "roster-diff-"));
8221
8322
  const a = join29(dir, "live.md");
8222
8323
  const b = join29(dir, "composed.md");
8223
- writeFileSync10(a, normalise(live));
8224
- writeFileSync10(b, normalise(composed));
8324
+ writeFileSync11(a, normalise(live));
8325
+ writeFileSync11(b, normalise(composed));
8225
8326
  const entry = org.staff?.find((s) => s.handle === handle);
8226
8327
  process.stdout.write(`
8227
8328
  ${entry?.name ?? handle} \xB7 ${kind}
@@ -8257,7 +8358,7 @@ async function gatherInflight(opsDir, root, handle) {
8257
8358
  const manifest = existsSync26(manifestPath) ? parseYaml(readFileSync24(manifestPath, "utf8"), "staff.yaml") : {};
8258
8359
  const gh = (args) => execFileSync10("gh", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
8259
8360
  const dir = mkdtempSync5(join29(tmpdir5(), "roster-inflight-"));
8260
- writeFileSync10(join29(dir, "inflight.md"), describe2(gather2({ org, manifest, gh })));
8361
+ writeFileSync11(join29(dir, "inflight.md"), describe2(gather2({ org, manifest, gh })));
8261
8362
  return dir;
8262
8363
  }
8263
8364
  function normalise(text) {
package/docs/portal.md CHANGED
@@ -87,7 +87,8 @@ Where the portal opens. An Ask box at the top, then these, in order.
87
87
  - **Ask.** Pick a staff member, type what you want, **Send**. It opens an issue on their
88
88
  tracker with their `@handle` in front, which wakes them in about a minute.
89
89
  - **Latest reports.** Each staff member's run report from the last day: the three lines they
90
- post on their status issue at the end of a daily run. Nothing shows until there is one.
90
+ post on their status issue at the end of a daily run, with the issues and pull requests it
91
+ names as links. Click a report to open their status issue. Nothing shows until there is one.
91
92
  - **Needs you.** Every ask a staff member has put on you, and every pull request ready to
92
93
  merge, oldest first. Each card has one action on its right:
93
94
  - a `decision` has **Reply**, and **Go with the default** when it carries one. The default
@@ -95,6 +96,9 @@ Where the portal opens. An Ask box at the top, then these, in order.
95
96
  - a `review` has **Approve**.
96
97
  - a `chore` has **Done**, which closes it.
97
98
  - a pull request has **Merge**, unless its checks fail, are still running, or it conflicts.
99
+ Where the branch rules require an approving review, it also has **Approve**, and Merge waits
100
+ until you have pressed it. Approving is your review, given on purpose; Merge never gives it
101
+ for you.
98
102
 
99
103
  Answers go out as you, with the staff member's `@handle`, so they act on them straight away.
100
104
  Once you have had the last word, the card leaves Needs you for Your requests, since it is
@@ -262,8 +266,14 @@ than drawing an empty table that reads as "nothing ran".
262
266
 
263
267
  Cost comes from the record each run leaves behind (see [cost](cost.md#what-each-run-cost)). A
264
268
  run from before records existed, or from an agent that does not report cost, shows a dash, and
265
- a total says how many runs it could price. Each record is downloaded once and kept for as long
266
- as the portal runs, so the first visit is the slow one.
269
+ a total says how many runs it could price. Each record is downloaded once, ever, and kept in
270
+ `~/.cache/roster/runs` (or `ROSTER_CACHE_DIR`), since a finished run's record never changes.
271
+ The table shows at once with the costs already known; any not yet read are fetched in the
272
+ background, and the screen says how many and fills them in as they arrive.
273
+
274
+ The run list asks GitHub for each outcome separately (succeeded, failed, cancelled and so on)
275
+ rather than paging through every run, because most of a mention caller's runs are comments that
276
+ woke nobody and were skipped.
267
277
 
268
278
  A skipped mention is not a run and is not listed. A `setup-failure` is a run that failed before
269
279
  the agent started, usually a token or a checkout. Past a [`budget`](org-yaml.md#budget), the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanocollective/roster",
3
- "version": "0.1.0-alpha.52",
3
+ "version": "0.1.0-alpha.54",
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.
@@ -76,10 +76,15 @@
76
76
  background:var(--blue);margin:0 7px 1px 0;vertical-align:middle}
77
77
 
78
78
  .homereports{display:flex;flex-direction:column;gap:14px;padding:14px 16px}
79
- .homereport{display:grid;grid-template-columns:190px 1fr;gap:12px;font-size:13px;line-height:1.5}
80
- .homereport p{margin:0 0 3px;color:var(--ink-dim)}
81
- .homereport p:first-child{color:var(--ink)}
82
- .homereport .homewho2{align-items:baseline;flex-wrap:wrap;min-width:0}
79
+ .homereport{display:grid;grid-template-columns:190px 1fr;gap:12px;font-size:13px;line-height:1.5;
80
+ cursor:pointer;border-radius:8px;padding:6px 8px;margin:-6px -8px}
81
+ .homereport:hover{background:var(--hover)}
82
+ .homereport:focus-visible{outline:2px solid var(--accent);outline-offset:0}
83
+ .homereportwho{display:flex;flex-direction:column;gap:2px;min-width:0}
84
+ .homereportwho .meta{padding-left:21px}
85
+ .homereportbody p{margin:0 0 4px;color:var(--ink-dim);font-size:13px;line-height:1.5}
86
+ .homereportbody p:first-child{color:var(--ink)}
87
+ .homereportbody a{color:var(--accent)}
83
88
  @media (max-width:640px){.homereport{grid-template-columns:1fr;gap:4px}}
84
89
 
85
90
  @media (max-width:640px){
@@ -8,6 +8,7 @@ import { getLive, post } from "../api.js";
8
8
  import { sheet } from "../dialog.js";
9
9
  import { ago, el, skeleton } from "../dom.js";
10
10
  import { daysUntil, sortHome } from "../homesort.js";
11
+ import { mdlite } from "../md.js";
11
12
  import { icon, iconHTML, staffIcon } from "../icons.js";
12
13
  import { ensureInbox, stampCounts } from "../refresh.js";
13
14
  import { go, render } from "../router.js";
@@ -58,16 +59,29 @@ export function viewHome(m) {
58
59
  page.append(section("Latest reports"));
59
60
  const box = el("div", { className: "card homereports" });
60
61
  for (const r of h.reports) {
61
- box.append(
62
- el("div", { className: "homereport" }, [
63
- el("div", { className: "homewho2" }, [
64
- icon(staffIcon(r.staff), "ic"),
65
- el("b", { textContent: r.staff.name }),
66
- el("span", { className: "meta", textContent: ago(r.at) }),
67
- ]),
68
- el("div", {}, r.lines.map((l) => el("p", { textContent: l }))),
62
+ /* The report is markdown the staff member wrote, with links to the work it names. The
63
+ whole report opens their status issue, where it was posted; a link inside it goes
64
+ where it points. */
65
+ const row = el("div", { className: "homereport homeclick", tabIndex: 0, role: "button" }, [
66
+ el("div", { className: "homereportwho" }, [
67
+ el("span", { className: "homewho2" }, [icon(staffIcon(r.staff), "ic"), el("b", { textContent: r.staff.name })]),
68
+ el("span", { className: "meta", textContent: ago(r.at) }),
69
69
  ]),
70
- );
70
+ el("div", {
71
+ className: "md homereportbody",
72
+ innerHTML: mdlite(r.lines.join("\n\n"), { repo: r.staff.brain }),
73
+ }),
74
+ ]);
75
+ row.onclick = (ev) => {
76
+ if (!ev.target.closest("a, button")) openThread(r.item);
77
+ };
78
+ row.onkeydown = (ev) => {
79
+ if (ev.target === row && (ev.key === "Enter" || ev.key === " ")) {
80
+ ev.preventDefault();
81
+ openThread(r.item);
82
+ }
83
+ };
84
+ box.append(row);
71
85
  }
72
86
  page.append(box);
73
87
  }
@@ -195,8 +209,31 @@ function needCard(e) {
195
209
  actions.append(button("Done", true, () => done(e, note, actions)));
196
210
  } else if (kind === "merge") {
197
211
  const blocked = item.checks === "failing" || item.mergeable === "CONFLICTING";
198
- const merge = button("Merge", true, () => merge_(e, note, actions));
199
- 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
+ }
200
237
  actions.append(merge);
201
238
  }
202
239
  card.append(actions);
@@ -220,7 +257,9 @@ function checksLine(item) {
220
257
  pending: "Checks are running.",
221
258
  none: "No checks.",
222
259
  };
223
- 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." : "");
224
263
  return el("p", {
225
264
  className: "homedue" + (item.checks === "failing" || conflict ? " late" : ""),
226
265
  textContent: (words[item.checks] ?? "") + conflict,
@@ -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
  }
@@ -77,6 +77,20 @@ function paint(m, data) {
77
77
  g.append(tile("Runs", String(data.spend?.runs ?? 0), "skipped mentions are not counted"));
78
78
  m.append(g);
79
79
 
80
+ /* The table shows at once with the costs already known; the rest are read from GitHub in the
81
+ background, and the screen asks again until they are all in. */
82
+ if (data.pending) {
83
+ m.append(
84
+ el("p", {
85
+ className: "hnote",
86
+ textContent: "Reading the cost of " + data.pending + " run" + (data.pending === 1 ? "" : "s") + " from GitHub…",
87
+ }),
88
+ );
89
+ setTimeout(() => {
90
+ if (S.view === "runs") load(true);
91
+ }, 3000);
92
+ }
93
+
80
94
  for (const s of data.staff ?? []) m.append(...staffSection(s));
81
95
 
82
96
  m.append(