@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 +171 -70
- package/docs/portal.md +13 -3
- package/package.json +1 -1
- package/templates/ops/.github/workflows/session.yaml +5 -0
- package/templates/portal/css/home.css +9 -4
- package/templates/portal/js/views/home.js +51 -12
- package/templates/portal/js/views/inbox.js +17 -0
- package/templates/portal/js/views/runs.js +14 -0
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
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
|
-
|
|
3241
|
-
|
|
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
|
-
|
|
3257
|
-
|
|
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)
|
|
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
|
-
|
|
3279
|
-
|
|
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
|
-
|
|
3305
|
-
|
|
3374
|
+
mkdirSync4(dirname6(dest), { recursive: true });
|
|
3375
|
+
writeFileSync6(dest, text);
|
|
3306
3376
|
}
|
|
3307
|
-
|
|
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
|
-
|
|
3412
|
-
|
|
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
|
-
|
|
3485
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
5228
|
+
mkdirSync5(opsDir, { recursive: true });
|
|
5159
5229
|
for (const [rel, text] of files) {
|
|
5160
5230
|
const dest = join22(opsDir, rel);
|
|
5161
|
-
|
|
5162
|
-
|
|
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
|
-
|
|
5167
|
-
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
5795
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
8224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
266
|
-
|
|
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
|
@@ -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
|
-
|
|
81
|
-
.homereport
|
|
82
|
-
.homereport
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
199
|
-
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
|
+
}
|
|
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 =
|
|
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(
|