@nanocollective/roster 0.1.0-alpha.22 → 0.1.0-alpha.24

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.
@@ -1,48 +1,84 @@
1
- /* Hiring and retiring, from the portal.
1
+ /* The Staff screen: a card per staff member, retiring, and the way in to hiring.
2
2
  *
3
- * Both run the CLI's own `buildPlan` and `applyPlan` on the server rather than describing
4
- * either again here. Two descriptions of how to create a repo is one too many, and the one in
5
- * the browser would be the one nobody updated.
6
- *
7
- * Plan then apply, the same as every roster command that changes something: you see every
8
- * file, every label and every existing staff member it would edit, and nothing happens until
9
- * you say so.
3
+ * Hiring itself is in hire.js. Retiring runs the CLI's own `buildRetirePlan` and
4
+ * `applyRetirePlan` on the server, plan then apply: you see what it stops and what it keeps,
5
+ * and nothing happens until you say so.
10
6
  */
11
7
 
12
- import { post } from "../api.js";
8
+ import { getStaffProgress, post } from "../api.js";
13
9
  import { askYes } from "../dialog.js";
14
10
  import { ago, el, esc } from "../dom.js";
15
- import { icon } from "../icons.js";
16
11
  import { refreshAll } from "../refresh.js";
17
12
  import { S } from "../state.js";
18
13
  import { appPanel } from "./app.js";
19
14
  import { credentialPanel } from "./credential.js";
20
- import { cronText } from "./health.js";
15
+ import { hireFlow, line, rolePicker } from "./hire.js";
21
16
  import { paste } from "./paste.js";
22
17
  import { runOnce } from "./runonce.js";
23
18
 
24
- export function viewStaff(m) {
19
+ /**
20
+ * @param {{open?: string, result?: object}} [o] `open` keeps one staff member's steps on
21
+ * screen across a repaint, which is how a finished hire lands on step 2.
22
+ */
23
+ export function viewStaff(m, o = {}) {
25
24
  m.append(el("h1", { textContent: "Staff" }));
26
- m.append(
27
- el("p", {
28
- className: "sub",
29
- textContent:
30
- S.data.staff.length +
31
- " on the roster. Hiring creates a repo, its workflows, its labels and its pinned issue, " +
32
- "and wires it to everyone already here.",
33
- }),
34
- );
25
+ const empty = !S.data.staff.length;
26
+ if (empty) m.append(el("p", { className: "sub", textContent: "No staff yet. Pick a role to hire your first." }));
35
27
 
36
- const list = el("div", { className: "grid", style: "margin-bottom:18px" });
37
- for (const s of S.data.staff) list.append(card(s));
38
- m.append(list);
28
+ const pane = el("div", { className: "hirepane" });
29
+
30
+ const pick = () => {
31
+ pane.replaceChildren(
32
+ ...(empty ? [] : [el("h3", { textContent: "Pick a role" })]),
33
+ rolePicker({ onPick: (role) => open(role) }),
34
+ );
35
+ if (!empty) {
36
+ const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
37
+ cancel.onclick = () => pane.replaceChildren();
38
+ pane.append(el("div", { className: "row" }, [cancel]));
39
+ }
40
+ };
41
+
42
+ /* One staff member's steps, hired or not yet. A hire repaints the whole screen, so the new
43
+ card is in the list and the steps reopen on the same person. */
44
+ function open(role, result) {
45
+ // Remembered, so a refresh after a trip to GitHub (creating the App) reopens it.
46
+ S.staffOpen = role;
47
+ pane.replaceChildren(
48
+ hireFlow({
49
+ role,
50
+ result,
51
+ onHired: (handle, r) => {
52
+ m.replaceChildren();
53
+ viewStaff(m, { open: handle, result: r });
54
+ },
55
+ onClose: () => {
56
+ S.staffOpen = null;
57
+ if (empty) pick();
58
+ else pane.replaceChildren();
59
+ },
60
+ }),
61
+ );
62
+ pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
63
+ }
39
64
 
40
- const hire = el("button", { className: "ghbtn primary", textContent: "Hire someone" });
41
- const pane = el("div");
42
- hire.onclick = () => hireForm(pane);
43
- m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [hire]));
65
+ if (!empty) {
66
+ const list = el("div", { className: "grid", style: "margin-bottom:18px" });
67
+ for (const s of S.data.staff) list.append(card(s));
68
+ m.append(list);
69
+ const hire = el("button", { className: "ghbtn primary", textContent: "Hire someone" });
70
+ hire.onclick = pick;
71
+ m.append(el("div", { className: "row", style: "margin-bottom:14px" }, [hire]));
72
+ }
44
73
  m.append(pane);
45
74
 
75
+ const opened = o.open && S.data.staff.find((s) => s.handle === o.open);
76
+ const kept = !opened && S.staffOpen;
77
+ const keptStaff = kept && S.data.staff.find((s) => s.handle === kept.handle);
78
+ if (opened) open(roleOf(opened), o.result);
79
+ else if (kept) open(keptStaff ? roleOf(keptStaff) : kept);
80
+ else if (empty) pick();
81
+
46
82
  /* --------------------------- one staff member --------------------------- */
47
83
 
48
84
  function card(s) {
@@ -109,138 +145,23 @@ export function viewStaff(m) {
109
145
  pane.scrollIntoView?.({ behavior: "smooth", block: "start" });
110
146
  };
111
147
 
112
- d.append(
113
- el("div", { className: "row", style: "margin-top:10px" }, [write, app, cred, run, go, status]),
114
- );
115
- return d;
116
- }
117
-
118
- /* ------------------------------- hiring -------------------------------- */
119
-
120
- function hireForm(host) {
121
- /* App names are copied from whoever is already here. The first hire has nobody to copy
122
- from, and the CLI asks for --app and --public-app at that point; these are those two. */
123
- const first = !S.data.staff.length;
124
- const box = el("div", { className: "card" });
125
- box.append(
126
- el("h3", { textContent: "Hire someone" }),
127
- el("p", {
128
- className: "sub",
129
- style: "margin-bottom:14px",
130
- textContent: first
131
- ? "Only the handle is required. This is the first hire, so there is nobody to copy " +
132
- "App names from: name them here, and later hires follow the pattern."
133
- : "Only the handle is required. Everything else is copied from whoever is already " +
134
- "here, because app slugs carry a house naming scheme and the public identity is " +
135
- "genuinely shared.",
136
- }),
137
- );
138
-
139
- const handle = field("handle", "cfo", "lowercase, digits and dashes");
140
- const name = field("name", "Chief Financial Officer", "defaults to the handle, uppercased");
141
- const dir = field("dir", "finance", "directory and repo name; defaults to the handle");
142
- const schedule = scheduleField();
143
- const fields = [handle, name, dir, schedule];
144
- const apps = [];
145
- if (first) {
146
- const org = S.data.org;
147
- apps.push(
148
- ["app", field("app", org + "-cfo", "this staff member's GitHub App. Names are unique across GitHub, so prefix it")],
149
- ["publicApp", field("public app", org + "-robot", "the shared identity for public product repos. Optional when they are all private")],
150
- );
151
- fields.push(...apps.map(([, f]) => f));
152
- }
153
- for (const f of fields) box.append(f.row);
154
-
155
- const status = el("span", { className: "meta" });
156
- const plan = el("button", { className: "ghbtn primary", textContent: "Show the plan" });
157
- const cancel = el("button", { className: "ghbtn", textContent: "Cancel" });
158
- cancel.onclick = () => host.replaceChildren();
159
- box.append(el("div", { className: "row", style: "margin-top:12px" }, [plan, cancel, status]));
160
-
161
- const out = el("div");
162
- box.append(out);
163
- host.replaceChildren(box);
164
- handle.input.focus();
165
-
166
- plan.onclick = async () => {
167
- if (!handle.input.value.trim()) {
168
- handle.input.focus();
169
- return;
170
- }
171
- status.textContent = "planning…";
172
- status.className = "meta";
173
- out.replaceChildren();
174
- const params = new URLSearchParams({ handle: handle.input.value.trim() });
175
- for (const [key, f] of [["name", name], ["dir", dir], ["schedule", schedule], ...apps]) {
176
- const value = (f.value ? f.value() : f.input.value).trim();
177
- if (value) params.set(key, value);
178
- }
179
- try {
180
- const data = await (await fetch("/api/staff/plan?" + params, { cache: "no-store" })).json();
181
- if (data.error) throw new Error(data.error);
182
- status.textContent = "";
183
- out.replaceChildren(hirePlan(data.plan, params));
184
- } catch (e) {
185
- status.textContent = e.message;
186
- status.className = "meta err";
187
- }
188
- };
189
- }
190
-
191
- function hirePlan(plan, params) {
192
- const box = el("div", { className: "plan" });
193
- box.append(el("div", { className: "planhead", textContent: "This would create" }));
194
-
195
- const s = plan.staff;
196
- box.append(
197
- line("ok", s.brain + ", private"),
198
- line("ok", plan.files.length + " files, including three caller workflows"),
199
- line("ok", plan.labels.length + " labels: " + plan.labels.join(", ")),
200
- line("ok", "a pinned status issue"),
201
- line("ok", "runs at " + s.schedule + ", on " + s.model),
202
- );
203
- for (const p of plan.peers) {
204
- box.append(line("ok", p.brain + " gains a " + p.label + " label"));
148
+ /* Whether setup is finished, as far as can be told cheaply: the charter from disk at once,
149
+ and the App's secrets and a first run from GitHub when it answers. */
150
+ const finish = el("button", { className: "ghbtn primary", textContent: "Finish setting up", hidden: true });
151
+ finish.onclick = () => open(roleOf(s));
152
+ if (s.rig?.charterStub) finish.hidden = false;
153
+ else {
154
+ getStaffProgress(s.handle)
155
+ .then((p) => {
156
+ if (p.app === false || p.ran === false) finish.hidden = false;
157
+ })
158
+ .catch(() => {});
205
159
  }
206
- /* Commits into repos that already exist, as you. Listed because they are writes to
207
- somebody else's repo, and a plan that hid them would be the silent kind. */
208
- box.append(el("div", { className: "planhead", textContent: "Commits and pushes, as you" }));
209
- for (const c of plan.commits ?? []) box.append(line("ok", c.repo + ": " + c.file + ", " + c.why));
210
160
 
211
- const cred = s.agentSecret ?? "the agent credential";
212
- box.append(
213
- plan.orgSecret?.visibility === "selected"
214
- ? line("ok", s.brain + " is added to the repos that can read the org secret " + cred)
215
- : plan.orgSecret
216
- ? line("ok", "the org secret " + cred + " already reaches " + plan.orgSecret.visibility + " repos")
217
- : line("warn", "no org secret " + cred + " yet: store it once with Agent credential, after this"),
218
- );
219
-
220
- box.append(el("div", { className: "planhead", textContent: "Then, on the new card" }));
221
- box.append(
222
- line("todo", "Create their GitHub App, then install it on the repos it opens with ticked"),
223
- line("todo", "Write the charter"),
224
- line("todo", "Run once now, to check everything works"),
161
+ d.append(
162
+ el("div", { className: "row", style: "margin-top:10px" }, [finish, write, app, cred, run, go, status]),
225
163
  );
226
- for (const w of plan.warnings ?? []) box.append(line("warn", S.data.staff.length ? w : portalWords(w)));
227
-
228
- const status = el("span", { className: "meta" });
229
- const go = el("button", { className: "ghbtn primary", textContent: "Hire " + s.handle });
230
- go.onclick = () =>
231
- apply(
232
- {
233
- action: "hire",
234
- handle: s.handle,
235
- flags: Object.fromEntries(params),
236
- },
237
- { title: "Create " + s.brain + " and wire it up?", hint: "This creates a repository on GitHub.", confirm: "Hire " + s.handle },
238
- go,
239
- status,
240
- box,
241
- );
242
- box.append(el("div", { className: "row", style: "margin-top:14px" }, [go, status]));
243
- return box;
164
+ return d;
244
165
  }
245
166
 
246
167
  /* ------------------------------- retiring ------------------------------- */
@@ -320,88 +241,7 @@ export function viewStaff(m) {
320
241
  }
321
242
  }
322
243
 
323
- /* The plan's warnings are the CLI's, and name its flags. Here the same two options are fields
324
- on the form above, and a flag the page has no box for reads as a dead end. */
325
- function portalWords(warning) {
326
- return warning
327
- .replace(/pass --public-app\b/, "fill in public app above")
328
- .replace(/pass --app\b/, "fill in app above");
329
- }
330
-
331
- function field(label, placeholder, hint) {
332
- const input = el("input", { type: "search", placeholder, value: "", style: "width:100%" });
333
- input.dataset.field = label;
334
- const row = el("div", { className: "ffield" });
335
- row.append(
336
- el("label", { textContent: label }),
337
- input,
338
- el("small", { textContent: hint }),
339
- );
340
- return { row, input };
341
- }
342
-
343
- /**
344
- * When the daily run happens, as a time and a set of days rather than as cron.
345
- *
346
- * The value on the wire is still a cron expression, because that is what goes in the workflow
347
- * and what `roster hire` takes. But nobody hiring their first staff member knows that
348
- * `0 9 * * 1-5` is nine in the morning on weekdays, and a field that demands it is a field
349
- * that gets a wrong answer or an empty one.
350
- *
351
- * UTC, said out loud, with the local equivalent beside it: GitHub schedules in UTC, and an
352
- * agent that starts an hour off twice a year is worse than one you had to think about once.
353
- */
354
- function scheduleField() {
355
- const time = el("input", { type: "time", value: "", step: "60", style: "width:130px" });
356
- const days = el("select");
357
- days.append(
358
- el("option", { value: "1-5", textContent: "Weekdays" }),
359
- el("option", { value: "*", textContent: "Every day" }),
360
- el("option", { value: "1", textContent: "Mondays" }),
361
- el("option", { value: "1,3,5", textContent: "Mon, Wed, Fri" }),
362
- );
363
- // Said rather than relying on "the first option is selected", which is true of a rendered
364
- // <select> and not of one that has only been built.
365
- days.value = "1-5";
366
-
367
- const said = el("small");
368
- const value = () => {
369
- if (!time.value) return "";
370
- const [h, m] = time.value.split(":");
371
- return `${Number(m)} ${Number(h)} * * ${days.value}`;
372
- };
373
- const say = () => {
374
- const cron = value();
375
- said.textContent = cron
376
- ? cronText(cron) + local(time.value)
377
- : "Leave it empty for a slot clear of everyone else.";
378
- };
379
- time.oninput = say;
380
- days.onchange = say;
381
- say();
382
-
383
- const row = el("div", { className: "ffield" });
384
- row.append(
385
- el("label", { textContent: "schedule" }),
386
- el("div", { className: "row" }, [time, days, el("span", { className: "meta", textContent: "UTC" })]),
387
- said,
388
- );
389
- return { row, input: time, value };
390
- }
391
-
392
- /** What that UTC time is where the person reading it is, when the two differ. */
393
- function local(hhmm) {
394
- if (!hhmm) return "";
395
- const [h, m] = hhmm.split(":").map(Number);
396
- const at = new Date(Date.UTC(2026, 0, 5, h, m));
397
- const here = at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
398
- return here === hhmm ? "" : " · " + here + " where you are";
399
- }
400
-
401
- const MARK = { ok: "check", stop: "closed", keep: "issue-open", todo: "dash", warn: "label" };
402
-
403
- function line(kind, text) {
404
- const d = el("div", { className: "planline " + kind });
405
- d.append(icon(MARK[kind] ?? "dot", "ic"), el("span", { textContent: text }));
406
- return d;
244
+ /** The flow's view of somebody already hired. */
245
+ function roleOf(s) {
246
+ return { handle: s.handle, name: s.name, dir: s.dir };
407
247
  }
@@ -0,0 +1,62 @@
1
+ /* The numbered step card shared by Getting started and the hiring flow on the Staff screen. */
2
+
3
+ import { el } from "../dom.js";
4
+
5
+ /**
6
+ * One numbered thing to do. Done ones fold to a single ticked line, and Change opens them again,
7
+ * so the list shows what is left without hiding what was done.
8
+ *
9
+ * `setLocked(reason)` greys a step that cannot start yet and says why in the pill; `null`
10
+ * unlocks it. A locked step shows no body, because a form you cannot use yet is a trap.
11
+ */
12
+ export function todo(n, title, done) {
13
+ const card = el("section", { className: "todo" });
14
+ const num = el("span", { className: "todon" });
15
+ const pill = el("span", { className: "todopill" });
16
+ const change = el("button", { className: "ghbtn todochange", textContent: "Change" });
17
+ const head = el("div", { className: "todohead" }, [num, el("h3", { textContent: title }), pill, change]);
18
+ const hintEl = el("p", { className: "sub todohint" });
19
+ const body = el("div", { className: "todobody" });
20
+ card.append(head, hintEl, body);
21
+ let open = false;
22
+ let locked = null;
23
+ let isDone = done;
24
+ const paint = () => {
25
+ card.classList.toggle("done", isDone && !locked);
26
+ card.classList.toggle("locked", Boolean(locked));
27
+ num.textContent = isDone && !locked ? "✓" : String(n);
28
+ pill.textContent = locked ?? (isDone ? "Done" : "To do");
29
+ change.hidden = !isDone || Boolean(locked);
30
+ const shown = !locked && (!isDone || open);
31
+ body.hidden = !shown;
32
+ hintEl.hidden = !shown || !hintEl.textContent;
33
+ };
34
+ const setDone = (d) => {
35
+ isDone = Boolean(d);
36
+ paint();
37
+ };
38
+ change.onclick = () => {
39
+ open = !open;
40
+ change.textContent = open ? "Close" : "Change";
41
+ paint();
42
+ };
43
+ paint();
44
+ return {
45
+ card,
46
+ body,
47
+ setDone,
48
+ isDone: () => isDone,
49
+ setLocked: (reason) => {
50
+ locked = reason || null;
51
+ paint();
52
+ },
53
+ renumber: (m) => {
54
+ n = m;
55
+ paint();
56
+ },
57
+ hint: (text) => {
58
+ hintEl.textContent = text;
59
+ paint();
60
+ },
61
+ };
62
+ }