yadflow 4.4.0 → 4.5.0
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/CHANGELOG.md +24 -0
- package/README.md +2 -1
- package/bin/commands.mjs +2 -0
- package/bin/yad.mjs +58 -3
- package/cli/claims.mjs +16 -13
- package/cli/doctor.mjs +33 -0
- package/cli/manifest.mjs +3 -0
- package/cli/members.mjs +822 -0
- package/cli/people.mjs +13 -3
- package/cli/platform.mjs +1 -1
- package/cli/pr-list.mjs +125 -0
- package/cli/protection.mjs +4 -4
- package/cli/standup.mjs +709 -0
- package/cli/usage.mjs +18 -11
- package/cli/workspace.mjs +14 -5
- package/package.json +5 -3
- package/skills/yad-checks/templates/checks/member-check.mjs +197 -0
- package/skills/yad-checks/templates/github/yad-product-checks.yml +15 -0
- package/skills/yad-checks/templates/gitlab/yad-product-checks.gitlab-ci.yml +9 -0
- package/skills/yad-status/SKILL.md +3 -0
package/cli/standup.mjs
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
// `yad standup` (E132) — a daily status for each team member: what they did since the last standup, what
|
|
2
|
+
// they are working on now, and what is waiting.
|
|
3
|
+
//
|
|
4
|
+
// READ-ONLY, DERIVED LIVE, FACTS ONLY. Nothing here is stored; every line is rebuilt from git, the Product
|
|
5
|
+
// ledgers, the capture branches and the platform each time. No score, no ranking and no judging flag
|
|
6
|
+
// (`yad usage`'s rule), and never an email, a commit message, a PR title or a comment body. The one write
|
|
7
|
+
// is E131's access cache in git's own folder (`memberStatuses`), never a Product file.
|
|
8
|
+
//
|
|
9
|
+
// WHO. The team list is E131's member files. Each member is one block, joined through every identity the
|
|
10
|
+
// file holds: a commit by its email hash or login, a ledger record by login, a step owner, a claim or a
|
|
11
|
+
// capture branch by the git name (`wipName`), a PR by the platform login. Anyone seen who matches no member
|
|
12
|
+
// gets a section of their own — nobody is hidden (E71) — and a member whose access has gone (`left`) is
|
|
13
|
+
// listed once at the end. A piece of evidence two members both match is never given to one of them: it is
|
|
14
|
+
// listed with the people seen, and says so.
|
|
15
|
+
//
|
|
16
|
+
// WHEN. The window starts at midnight of the previous working day (Monday reads back to Friday; no holiday
|
|
17
|
+
// logic) in ONE declared zone — UTC unless `--tz` — and ends now; `--since` moves the start. Commits,
|
|
18
|
+
// claims and platform times are exact, compared in epoch milliseconds (never `--date=short`, which reads
|
|
19
|
+
// each commit in its own zone — the bug E71 fixed). Every ledger record carries a UTC date and no time, so
|
|
20
|
+
// one is in when its date is on or after the UTC date of the start: included, never dropped. The header
|
|
21
|
+
// says both.
|
|
22
|
+
//
|
|
23
|
+
// WAITING (the user's decision, 2026-10-06), each line saying what it waits for:
|
|
24
|
+
// (a) an open gate on a step the member owns — waiting for approvals, or for its review PR to merge;
|
|
25
|
+
// (b) a next move that is theirs — their owned author step is the epic's current step; comments recorded
|
|
26
|
+
// on their open gate;
|
|
27
|
+
// and their own open PRs/MRs whose checks fail or run, or that have no approving review yet.
|
|
28
|
+
// E62 requests no reviewers, so nothing here ever says a review is waiting ON someone.
|
|
29
|
+
//
|
|
30
|
+
// PLATFORM. The Product and EVERY registered repo, by its `git_url` (the user's decision) — one list call
|
|
31
|
+
// each (`cli/pr-list.mjs`). A repo that cannot be read prints "platform not read — <why>", and a block with
|
|
32
|
+
// no PR facts says so, never "nothing".
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { c, log, info, warn, fail, hand, note, readJSON, forTerminal } from './lib.mjs';
|
|
36
|
+
import { productConfigPath } from './manifest.mjs';
|
|
37
|
+
import { STEPS, epicIds, epicRoot, loadLedger, isPassed, stepStatus, authorStepFor, acceptedHashes, gatePredicate, gateRuleFor, gateRuleSum, optionalStepsFor, readFrontmatter, legacyLogins } from './epic-state.mjs';
|
|
38
|
+
import { isSolo, requireEngagement } from './gate.mjs';
|
|
39
|
+
import { readOwners } from './owners.mjs';
|
|
40
|
+
import { readClaims, fetchCaptures } from './claims.mjs';
|
|
41
|
+
import { gitIn, wipName, WIP_PREFIX } from './capture.mjs';
|
|
42
|
+
import { gitAuthors, personKey } from './people.mjs';
|
|
43
|
+
import { isBot } from './riskmap.mjs';
|
|
44
|
+
import { readMembers, memberStatuses, memberMatches, productIdentity, ttlDays, accountLabel, accessCheck } from './members.mjs';
|
|
45
|
+
import { ledgerEvents, esc, mdText, writeReport } from './usage.mjs';
|
|
46
|
+
import { readRegistry, judgeRepo, runnable } from './workspace.mjs';
|
|
47
|
+
import { listPrs, prTarget } from './pr-list.mjs';
|
|
48
|
+
import { shown } from './protection.mjs';
|
|
49
|
+
import { platformLogin } from './platform.mjs';
|
|
50
|
+
|
|
51
|
+
const DAY_MS = 86_400_000;
|
|
52
|
+
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
53
|
+
const SINCE_REL = /^([1-9]\d{0,3})([hd])$/;
|
|
54
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
55
|
+
|
|
56
|
+
// ---- the clock -----------------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
// Is `tz` an IANA zone this Node knows? (`Intl` throws a RangeError for one it does not.)
|
|
59
|
+
export function validZone(tz) {
|
|
60
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The wall-clock parts of an instant in a zone.
|
|
64
|
+
function zoneParts(ms, tz) {
|
|
65
|
+
const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
66
|
+
const p = Object.fromEntries(f.formatToParts(new Date(ms)).map((x) => [x.type, x.value]));
|
|
67
|
+
return { y: Number(p.year), m: Number(p.month), d: Number(p.day), h: Number(p.hour), mi: Number(p.minute), s: Number(p.second) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// How far the zone's wall clock is ahead of UTC at an instant, in milliseconds.
|
|
71
|
+
const zoneOffset = (ms, tz) => {
|
|
72
|
+
const p = zoneParts(ms, tz);
|
|
73
|
+
return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) - (ms - (((ms % 1000) + 1000) % 1000));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The instant of midnight starting a calendar day in a zone. Twice, so a day whose offset differs from the
|
|
77
|
+
// guess's (a daylight-saving change) still lands on its own midnight.
|
|
78
|
+
// A zone whose clock jumps over midnight (America/Santiago, America/Havana on their change days) has no
|
|
79
|
+
// 00:00 that day: the answer then lands an hour early, on the day before, so it is moved forward an hour
|
|
80
|
+
// at a time to the day's first instant.
|
|
81
|
+
export function zoneMidnight(y, m, d, tz) {
|
|
82
|
+
const guess = Date.UTC(y, m - 1, d);
|
|
83
|
+
const first = guess - zoneOffset(guess, tz);
|
|
84
|
+
let t = guess - zoneOffset(first, tz);
|
|
85
|
+
for (let i = 0; i < 3; i++) {
|
|
86
|
+
const p = zoneParts(t, tz);
|
|
87
|
+
if (Date.UTC(p.y, p.m - 1, p.d) >= guess) break;
|
|
88
|
+
t += 3_600_000 - ((p.mi * 60 + p.s) * 1000);
|
|
89
|
+
}
|
|
90
|
+
// A clock that goes BACK at midnight (Asia/Gaza, Asia/Amman before 2022; Asia/Colombo once by thirty
|
|
91
|
+
// minutes) shows 00:00 twice: the day starts at the first one. Stepped back a quarter-hour at a time —
|
|
92
|
+
// today's zones all change by whole quarter-hours — while the instant before still reads as the same day;
|
|
93
|
+
// four hours at most. A clock that goes back LATER in the day (Antarctica/Casey, 2010: at 02:00) is not
|
|
94
|
+
// this case and can start an hour or more late; a day a zone skipped (Pacific/Apia, 2011-12-30) gets the
|
|
95
|
+
// next day's start.
|
|
96
|
+
for (let i = 0; i < 16; i++) {
|
|
97
|
+
const p = zoneParts(t - 900_000, tz);
|
|
98
|
+
if (Date.UTC(p.y, p.m - 1, p.d) !== guess) break;
|
|
99
|
+
t -= 900_000;
|
|
100
|
+
}
|
|
101
|
+
return t;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `YYYY-MM-DD HH:MM` on the zone's wall clock.
|
|
105
|
+
export function zoneStamp(ms, tz) {
|
|
106
|
+
const p = zoneParts(ms, tz);
|
|
107
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
108
|
+
return `${p.y}-${two(p.m)}-${two(p.d)} ${two(p.h)}:${two(p.mi)}`;
|
|
109
|
+
}
|
|
110
|
+
const weekday = (ms, tz) => { const p = zoneParts(ms, tz); return WEEKDAYS[new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay()]; };
|
|
111
|
+
const utcDay = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
112
|
+
|
|
113
|
+
// The window: `{ startMs, endMs, tz, basis, startUtcDay }`, or `{ error, hint }`.
|
|
114
|
+
// no `since` midnight of the previous working day in `tz` — Monday, Saturday and Sunday read back to
|
|
115
|
+
// Friday; any other day to the day before.
|
|
116
|
+
// `YYYY-MM-DD` midnight of that day in `tz`.
|
|
117
|
+
// `<N>h` / `<N>d` N hours or days before now.
|
|
118
|
+
export function windowFor(nowMs, { tz = 'UTC', since = null } = {}) {
|
|
119
|
+
if (!validZone(tz)) return { error: `not a time zone this system knows: ${forTerminal(String(tz))}`, hint: 'pass an IANA zone name, e.g. --tz Europe/Berlin or --tz America/New_York (UTC is the default)' };
|
|
120
|
+
let startMs;
|
|
121
|
+
let basis;
|
|
122
|
+
if (since == null || since === '') {
|
|
123
|
+
const p = zoneParts(nowMs, tz);
|
|
124
|
+
const dow = new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay();
|
|
125
|
+
const back = dow === 1 ? 3 : dow === 0 ? 2 : 1;
|
|
126
|
+
const day = new Date(Date.UTC(p.y, p.m - 1, p.d) - back * DAY_MS);
|
|
127
|
+
startMs = zoneMidnight(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), tz);
|
|
128
|
+
basis = 'since the previous working day';
|
|
129
|
+
} else if (DATE_RE.test(since)) {
|
|
130
|
+
const [, y, m, d] = since.match(DATE_RE).map(Number);
|
|
131
|
+
const check = new Date(Date.UTC(y, m - 1, d));
|
|
132
|
+
if (check.getUTCFullYear() !== y || check.getUTCMonth() !== m - 1 || check.getUTCDate() !== d) return { error: `--since ${forTerminal(since)} is not a real date`, hint: 'use YYYY-MM-DD, <N>h or <N>d' };
|
|
133
|
+
startMs = zoneMidnight(y, m, d, tz);
|
|
134
|
+
basis = `since ${since}`;
|
|
135
|
+
} else if (SINCE_REL.test(since)) {
|
|
136
|
+
const [, n, unit] = since.match(SINCE_REL);
|
|
137
|
+
startMs = nowMs - Number(n) * (unit === 'h' ? 3_600_000 : DAY_MS);
|
|
138
|
+
basis = `the last ${n} ${unit === 'h' ? 'hour' : 'day'}${n === '1' ? '' : 's'}`;
|
|
139
|
+
} else {
|
|
140
|
+
return { error: `--since ${forTerminal(String(since))} is not a date or a length of time`, hint: 'use YYYY-MM-DD (midnight in --tz), <N>h or <N>d — e.g. --since 24h' };
|
|
141
|
+
}
|
|
142
|
+
if (startMs > nowMs) return { error: `--since ${forTerminal(since)} is in the future`, hint: 'pick a day on or before today' };
|
|
143
|
+
return { startMs, endMs: nowMs, tz, basis, startUtcDay: utcDay(startMs) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- the sources ---------------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
// Every capture branch (E43) saved inside the window: `{ name, epic, at }` — others' from origin's copies,
|
|
149
|
+
// this clone's own from its local branches (the freshest).
|
|
150
|
+
function wipSaves(root, env, startMs) {
|
|
151
|
+
const git = gitIn(root, env);
|
|
152
|
+
const me = wipName(git(['config', 'user.name']).out.trim(), git(['config', 'user.email']).out.trim());
|
|
153
|
+
const r = git(['for-each-ref', '--format=%(refname)%00%(committerdate:unix)', `refs/remotes/origin/${WIP_PREFIX}/`, ...(me ? [`refs/heads/${WIP_PREFIX}/${me}/`] : [])]);
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const line of r.ok ? r.out.split('\n').filter(Boolean) : []) {
|
|
156
|
+
const [ref, date] = line.split('\0');
|
|
157
|
+
const remote = ref.startsWith('refs/remotes/');
|
|
158
|
+
const rest = ref.replace(/^refs\/(remotes\/origin|heads)\//, '').slice(WIP_PREFIX.length + 1);
|
|
159
|
+
const slash = rest.indexOf('/');
|
|
160
|
+
const name = rest.slice(0, slash);
|
|
161
|
+
const epic = rest.slice(slash + 1);
|
|
162
|
+
if (slash < 1 || !/^EP-[^/]+$/.test(epic) || (remote && name === me)) continue;
|
|
163
|
+
const at = Number(date) * 1000;
|
|
164
|
+
if (Number.isFinite(at) && at >= startMs) out.push({ name, epic, at });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The repos to read: the Product and every registered repo. `dir` is set only when git may run there
|
|
170
|
+
// (judged first, E81); `gitUrl` is what the platform is asked about.
|
|
171
|
+
function repoSources(root, identity, productConfig, notRead, env) {
|
|
172
|
+
const productUrl = typeof productConfig?.git_url === 'string' && productConfig.git_url ? productConfig.git_url
|
|
173
|
+
: (gitIn(root, env)(['remote', 'get-url', 'origin']).out.trim() || '');
|
|
174
|
+
const out = [{ label: 'Product', dir: root, gitUrl: productUrl, platform: identity.platform }];
|
|
175
|
+
const { registry, problem } = readRegistry(root);
|
|
176
|
+
if (problem) notRead.push(`${safe(problem)} — the code repos are not read`);
|
|
177
|
+
for (const r of registry.repos) {
|
|
178
|
+
if (!r || typeof r.name !== 'string' || !r.name) continue;
|
|
179
|
+
const label = forTerminal(r.name);
|
|
180
|
+
let dir = null;
|
|
181
|
+
if (typeof r.path === 'string' && r.path) {
|
|
182
|
+
const j = judgeRepo(root, r);
|
|
183
|
+
if (runnable(j)) dir = path.resolve(root, r.path);
|
|
184
|
+
else notRead.push(`${label}: ${j.state === 'missing' ? 'not cloned on this machine' : j.linked ? "reached through a link inside a repo's tree" : safe(j.reason)} — its commits are not read`);
|
|
185
|
+
} else notRead.push(`${label}: no local path — its commits are not read`);
|
|
186
|
+
out.push({ label, dir, gitUrl: typeof r.git_url === 'string' ? r.git_url : '', platform: ['github', 'gitlab'].includes(r.platform) ? r.platform : null });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- attribution ---------------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
// The one member a piece of evidence belongs to: `{ member }`, `{ ambiguous: [...] }` when more than one
|
|
194
|
+
// matches, or `{}` when none does.
|
|
195
|
+
function whose(members, e) {
|
|
196
|
+
const hit = members.filter((m) => memberMatches(m, e));
|
|
197
|
+
if (hit.length === 1) return { member: hit[0] };
|
|
198
|
+
return hit.length ? { ambiguous: hit } : {};
|
|
199
|
+
}
|
|
200
|
+
// A git name made branch-safe (an owner file, a claim, a capture branch) — compared with the same form of
|
|
201
|
+
// each name a member file holds.
|
|
202
|
+
function whoseWip(members, w) {
|
|
203
|
+
const hit = members.filter((m) => m.names.some((n) => wipName(n) === w));
|
|
204
|
+
if (hit.length === 1) return { member: hit[0] };
|
|
205
|
+
return hit.length ? { ambiguous: hit } : {};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// The member whose account on this platform and host has this login — a PR/MR author. A login on GitHub
|
|
209
|
+
// and the same login on GitLab can be two people, so the platform and host must match too; an account two
|
|
210
|
+
// files claim (`matchLogins` leaves it out) matches nobody.
|
|
211
|
+
const keyOf = (a) => `${a.platform}\0${String(a.host).toLowerCase()}\0${String(a.login).toLowerCase()}`; // members.mjs's accountKey
|
|
212
|
+
function whoseAccount(members, { platform, host, login }, disputed = new Set()) {
|
|
213
|
+
const want = keyOf({ platform, host: host || '', login: login || '' });
|
|
214
|
+
// An account two files claim is matched by nobody — unless it is a member's own primary (E131's rule).
|
|
215
|
+
const hit = members.filter((m) => m.accounts.some((a) => keyOf(a) === want && (keyOf(a) === keyOf(m.primary) || !disputed.has(want))));
|
|
216
|
+
if (hit.length === 1) return { member: hit[0] };
|
|
217
|
+
if (hit.length) return { ambiguous: hit };
|
|
218
|
+
// Disputed and nobody's own: it is listed with the people seen, and says which files claim it.
|
|
219
|
+
const claimants = disputed.has(want) ? members.filter((m) => m.accounts.some((a) => keyOf(a) === want)) : [];
|
|
220
|
+
return claimants.length ? { ambiguous: claimants } : {};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- the model -----------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
// Every value a shared file holds is made safe before it joins a line (`forTerminal`: no control
|
|
226
|
+
// character reaches the terminal), and a name is shown only when it is not an email address (`shown`).
|
|
227
|
+
// The text is built once and shared by the terminal, Markdown and HTML outputs.
|
|
228
|
+
const safe = (v) => forTerminal(String(v ?? ''));
|
|
229
|
+
// An error's message, safe to print and to share: no control character (a JSON parse error quotes the bad
|
|
230
|
+
// bytes) and no absolute path of this machine (the report may be written to a file and shared).
|
|
231
|
+
const reEsc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
232
|
+
export function scrubPaths(text, places) {
|
|
233
|
+
let out = String(text ?? '');
|
|
234
|
+
// Longest first, so a registered repo inside the Product is named before the Product is. A path is cut only
|
|
235
|
+
// where it ends — `/a/b` never rewrites `/a/bc` — and a Windows path ends at `\` as well as `/`; a drive
|
|
236
|
+
// path is matched in any case (`C:\` and `c:\` are one folder). The name is put in as plain text: a repo
|
|
237
|
+
// named `$&` must not write the matched path back (a replacement string reads `$` patterns).
|
|
238
|
+
for (const [abs, name] of [...places].sort((x, y) => y[0].length - x[0].length)) {
|
|
239
|
+
if (!abs || abs === '/' || abs === '\\') continue;
|
|
240
|
+
const flags = /^[A-Za-z]:[\\/]/.test(abs) ? 'gi' : 'g';
|
|
241
|
+
out = out.replace(new RegExp(`${reEsc(abs)}(?=$|[\\\\/\\s:'"),;])`, flags), () => name);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
// Each folder's absolute path, as given and as the disk resolves it (macOS: /var is /private/var).
|
|
246
|
+
function placesFor(list) {
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const [dir, name] of list) {
|
|
249
|
+
const abs = path.resolve(dir);
|
|
250
|
+
out.push([abs, name]);
|
|
251
|
+
try { const real = fs.realpathSync(abs); if (real !== abs) out.push([real, name]); } catch { /* not on disk */ }
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
const why = (e, places) => safe(scrubPaths(e?.message ?? e, places));
|
|
256
|
+
const nameOf = (v) => shown(String(v ?? '').trim() || 'someone');
|
|
257
|
+
const isDay = (d) => typeof d === 'string' && DATE_RE.test(d);
|
|
258
|
+
// A ledger list's records: only plain objects (a `null` or a number in a shared file is skipped, not a crash).
|
|
259
|
+
const records = (list) => (Array.isArray(list) ? list.filter((x) => x && typeof x === 'object' && !Array.isArray(x)) : []);
|
|
260
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
261
|
+
const prWord = (platform) => (platform === 'gitlab' ? 'MR !' : 'PR #');
|
|
262
|
+
|
|
263
|
+
// The whole report as data. `now` is epoch milliseconds (injected, so a test can pin the day).
|
|
264
|
+
export function buildStandup(root, { now = Date.now(), tz = 'UTC', since = null, env = process.env, runner, fetch = true, access = accessCheck } = {}) {
|
|
265
|
+
const win = windowFor(now, { tz, since });
|
|
266
|
+
if (win.error) return win;
|
|
267
|
+
const productConfig = readJSON(productConfigPath(root), null);
|
|
268
|
+
const identity = productIdentity(root, { productConfig, ...(runner ? { runner } : {}) });
|
|
269
|
+
const notRead = [];
|
|
270
|
+
const notes = [];
|
|
271
|
+
const fetched = fetch ? fetchCaptures(root, env) : 'skipped';
|
|
272
|
+
const got = readMembers(root, { identity });
|
|
273
|
+
const ttl = ttlDays(productConfig);
|
|
274
|
+
const today = utcDay(now);
|
|
275
|
+
const inDay = (d) => isDay(d) && d >= win.startUtcDay;
|
|
276
|
+
const inTime = (ms) => Number.isFinite(ms) && ms >= win.startMs && ms <= now;
|
|
277
|
+
|
|
278
|
+
// Platform first: whether the Product's platform answered decides whether a member's access may be asked.
|
|
279
|
+
const sources = repoSources(root, identity, productConfig, notRead, env);
|
|
280
|
+
// Paths of this machine never reach the report: each folder is named by its label instead — in square
|
|
281
|
+
// brackets, never angle ones, which a Markdown viewer would read as an HTML tag and drop.
|
|
282
|
+
const places = placesFor([[root, '[Product]'], ...sources.filter((x) => x.dir && path.resolve(x.dir) !== path.resolve(root)).map((x) => [x.dir, `[${x.label}]`])]);
|
|
283
|
+
const seenRepo = new Set();
|
|
284
|
+
const prs = [];
|
|
285
|
+
const authCache = new Map();
|
|
286
|
+
for (const s of sources) {
|
|
287
|
+
const target = prTarget({ platform: s.platform, gitUrl: s.gitUrl }, { env, authCache, ...(runner ? { runner } : {}) });
|
|
288
|
+
// One repo, one call: a registered repo that is the Product itself (a monorepo) is not asked twice.
|
|
289
|
+
const key = target.host && target.repo ? `${target.host}/${target.repo}`.toLowerCase() : null;
|
|
290
|
+
if (key && seenRepo.has(key)) continue;
|
|
291
|
+
if (key) seenRepo.add(key);
|
|
292
|
+
const t = target.ok ? listPrs(target, { sinceMs: win.startMs, env, ...(runner ? { runner } : {}) }) : target;
|
|
293
|
+
prs.push({ label: s.label, ...t });
|
|
294
|
+
}
|
|
295
|
+
const prRead = prs.filter((p) => p.ok);
|
|
296
|
+
const prMissing = prs.filter((p) => !p.ok);
|
|
297
|
+
for (const p of prRead) {
|
|
298
|
+
if (p.openPartial) notes.push(`${p.label}: more than ${p.open.length} open ${p.platform === 'gitlab' ? 'MRs' : 'PRs'} — only the newest ${p.open.length} were read`);
|
|
299
|
+
if (p.mergedPartial) notes.push(`${p.label}: more ${p.platform === 'gitlab' ? 'MRs' : 'PRs'} were merged in the window than one page holds — only the newest were read`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Commits: exact times, over the window and the TTL look-back the status needs.
|
|
303
|
+
const readFrom = utcDay(Math.min(win.startMs - DAY_MS, Date.parse(`${today}T00:00:00Z`) - (ttl + 1) * DAY_MS));
|
|
304
|
+
const commits = [];
|
|
305
|
+
// One repository, read once: `git log --all` in a monorepo's registered folder (`path: .`, or `apps/web`)
|
|
306
|
+
// reads the very history the Product's read did, and would count each commit again.
|
|
307
|
+
const seenGit = new Map();
|
|
308
|
+
for (const s of sources) {
|
|
309
|
+
if (!s.dir) continue;
|
|
310
|
+
const common = gitIn(s.dir, env)(['rev-parse', '--git-common-dir']);
|
|
311
|
+
if (common.ok) {
|
|
312
|
+
let real = path.resolve(s.dir, common.out.trim());
|
|
313
|
+
try { real = fs.realpathSync(real); } catch { /* the path as git gave it */ }
|
|
314
|
+
if (seenGit.has(real)) { notes.push(`${s.label}: the same git repository as ${seenGit.get(real)} — its commits are counted once, under ${seenGit.get(real)}`); continue; }
|
|
315
|
+
seenGit.set(real, s.label);
|
|
316
|
+
}
|
|
317
|
+
const g = gitAuthors(s.dir, readFrom);
|
|
318
|
+
if (g.unknown) { notRead.push(`${s.label}: ${why(g.unknown, places)} — its commits are not read`); continue; }
|
|
319
|
+
for (const e of g.events) commits.push({ ...e, repo: s.label });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The ledgers: approvals, comments, ships (attributed as `yad usage` attributes them), and closed steps.
|
|
323
|
+
const aliases = legacyLogins(productConfig);
|
|
324
|
+
let epics = [];
|
|
325
|
+
try { epics = epicIds(root); } catch (e) { notRead.push(`the epic list could not be read: ${why(e, places)}`); }
|
|
326
|
+
const ledgerEv = [];
|
|
327
|
+
const ledgers = new Map();
|
|
328
|
+
for (const epic of epics) {
|
|
329
|
+
try { ledgers.set(epic, loadLedger(epicRoot(root, epic))); } catch (e) { notRead.push(`${epic}: its ledger cannot be read (${why(e, places)})`); continue; }
|
|
330
|
+
try { ledgerEv.push(...ledgerEvents(root, epic, aliases)); } catch (e) { notRead.push(`${epic}: its ledger cannot be read (${why(e, places)})`); }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Status (E131): active / idle / left / unknown, from commits, approvals and ships. When the Product's own
|
|
334
|
+
// platform was not read, nobody's access is asked — a bad network never makes anyone `left`.
|
|
335
|
+
// Only a real calendar date is activity: a date in a shared file is checked before the status reads it.
|
|
336
|
+
const activity = [...commits.map((e) => ({ ts: e.ts, login: e.login, emailHash: e.emailHash, name: e.name })),
|
|
337
|
+
...ledgerEv.filter((e) => (e.action === 'approved' || e.action === 'shipped') && isDay(e.ts) && typeof e.actor === 'string')
|
|
338
|
+
.map((e) => ({ ts: e.ts, login: e.login || e.actor, name: e.actor }))];
|
|
339
|
+
const offline = !prs[0]?.ok;
|
|
340
|
+
const members = memberStatuses(root, got.members, { events: activity, today, ttl, identity, access, offline });
|
|
341
|
+
|
|
342
|
+
const blocks = new Map(members.map((m) => [m, { done: [], working: [], waiting: [] }]));
|
|
343
|
+
const unlisted = new Map();
|
|
344
|
+
const place = (owner, display, key, section, item) => {
|
|
345
|
+
if (owner.member) { blocks.get(owner.member)[section].push(item); return; }
|
|
346
|
+
const k = owner.ambiguous ? `ambiguous:${key}` : key;
|
|
347
|
+
if (!unlisted.has(k)) unlisted.set(k, { who: nameOf(display), ambiguous: owner.ambiguous ? owner.ambiguous.map((m) => m.primary.login) : null, done: [], working: [], waiting: [] });
|
|
348
|
+
unlisted.get(k)[section].push(item);
|
|
349
|
+
};
|
|
350
|
+
const byEvent = (e, display, section, item) => place(whose(members, e), display, personKey({ login: e.login, name: e.name }) || `name:${display}`, section, item);
|
|
351
|
+
const byWip = (w, display, section, item) => place(whoseWip(members, w), display, `wip:${w}`, section, item);
|
|
352
|
+
const byAccount = (acct, section, item) => place(whoseAccount(members, acct, got.disputed), acct.login, `acct:${keyOf(acct)}`, section, item);
|
|
353
|
+
|
|
354
|
+
// DONE — commits, one line per person and repo.
|
|
355
|
+
const commitGroups = new Map();
|
|
356
|
+
for (const e of commits) {
|
|
357
|
+
if (!inTime(e.at * 1000)) continue;
|
|
358
|
+
const owner = whose(members, e);
|
|
359
|
+
const who = owner.member ? `m:${owner.member.rel}` : `${owner.ambiguous ? 'amb:' : ''}${personKey({ login: e.login, name: e.name })}`;
|
|
360
|
+
const k = `${who}\0${e.repo}`;
|
|
361
|
+
if (!commitGroups.has(k)) commitGroups.set(k, { e, count: 0, last: 0 });
|
|
362
|
+
const g = commitGroups.get(k);
|
|
363
|
+
g.count++;
|
|
364
|
+
g.last = Math.max(g.last, e.at * 1000);
|
|
365
|
+
}
|
|
366
|
+
for (const { e, count, last } of commitGroups.values()) {
|
|
367
|
+
byEvent(e, e.login || e.name || 'someone', 'done', { kind: 'commits', repo: e.repo, count, last: new Date(last).toISOString(), text: `${plural(count, 'commit')} in ${safe(e.repo)} (last ${zoneStamp(last, tz)})` });
|
|
368
|
+
}
|
|
369
|
+
// DONE — approvals, comments, ships (the ledger's date only).
|
|
370
|
+
// A ledger names people by platform login: a `[bot]` login is a robot, not a person seen.
|
|
371
|
+
for (const e of ledgerEv) {
|
|
372
|
+
if (!inDay(e.ts) || typeof e.actor !== 'string' || isBot(e.actor)) continue;
|
|
373
|
+
const what = e.artifact ? `${e.epic} ${safe(e.artifact)}` : e.epic;
|
|
374
|
+
const item = e.action === 'approved' ? { kind: 'approved', epic: e.epic, artifact: e.artifact || null, date: e.ts, text: `approved ${what} (${e.ts})` }
|
|
375
|
+
: e.action === 'commented' ? { kind: 'commented', epic: e.epic, artifact: e.artifact || null, date: e.ts, text: `commented on ${what} (${e.ts})` }
|
|
376
|
+
: { kind: 'shipped', epic: e.epic, story: e.story || null, task: e.task || null, repo: e.repo || null, date: e.ts, text: `engineer review of ${[e.story, e.task].filter(Boolean).map(safe).join('/') || e.epic}${e.repo ? ` in ${safe(e.repo)}` : ''}, shipped ${e.ts}` };
|
|
377
|
+
// A report, not the count: the name a ledger records is the platform login since E62, so it is matched
|
|
378
|
+
// against members' logins too. The gate count never reads this (E64: only a stamped record proves it).
|
|
379
|
+
byEvent({ login: e.login || e.actor, name: e.actor }, e.actor, 'done', item);
|
|
380
|
+
}
|
|
381
|
+
// DONE — steps closed. A merge is the merger's when the platform named one; anything else is `by`'s.
|
|
382
|
+
for (const [epic, led] of ledgers) {
|
|
383
|
+
for (const s of led.state?.steps || []) {
|
|
384
|
+
const cl = s?.closed;
|
|
385
|
+
if (!cl || typeof cl !== 'object' || !inDay(cl.date)) continue;
|
|
386
|
+
const who = cl.via === 'merge' && typeof cl.mergedBy === 'string' && cl.mergedBy.trim() ? cl.mergedBy : cl.by;
|
|
387
|
+
// On CI the closing record's `by` is the job's git name — `yad-gate-sync` in the shipped workflow, or a
|
|
388
|
+
// `[bot]` name. A closing record carries no email, so the exact name is the evidence here (`isGateBot`
|
|
389
|
+
// needs the address too, which a commit has and this record does not).
|
|
390
|
+
if (typeof who !== 'string' || !who.trim() || isBot(who) || who.trim() === 'yad-gate-sync') continue;
|
|
391
|
+
byEvent({ login: who, name: who }, who, 'done', { kind: 'closed', epic, step: s.id, via: cl.via || null, date: cl.date, text: `closed ${epic} ${safe(s.id)}${cl.via ? ` (${safe(cl.via)})` : ''}, ${cl.date}` });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// DONE — merged PRs/MRs, by their author.
|
|
395
|
+
for (const p of prRead) {
|
|
396
|
+
for (const pr of p.merged) {
|
|
397
|
+
if (!pr.author || !Number.isSafeInteger(pr.number)) continue;
|
|
398
|
+
byAccount({ platform: p.platform, host: p.host, login: pr.author }, 'done', { kind: 'merged', repo: p.label, number: pr.number, at: pr.mergedAt, text: `${prWord(p.platform)}${pr.number} merged in ${p.label} (${zoneStamp(Date.parse(pr.mergedAt), tz)})` });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// WORKING ON — owned open steps (E47), open claims (E46), capture branches saved in the window (E43).
|
|
403
|
+
for (const epic of epics) {
|
|
404
|
+
for (const o of readOwners(root, epic)) {
|
|
405
|
+
if (o.error || !o.live) continue;
|
|
406
|
+
byWip(o.owner, o.name || o.owner, 'working', { kind: 'owns', epic, step: o.step, state: o.stepState || null, text: `owns ${epic} ${safe(o.step)}${o.stepState ? ` (${safe(o.stepState)})` : ''}` });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const claimed = new Set();
|
|
410
|
+
const claimGroups = new Map();
|
|
411
|
+
for (const cl of readClaims(root, { env, now }).claims) {
|
|
412
|
+
const k = `${cl.name}\0${cl.epic}`;
|
|
413
|
+
if (!claimGroups.has(k)) claimGroups.set(k, { cl, files: [], last: 0 });
|
|
414
|
+
const g = claimGroups.get(k);
|
|
415
|
+
g.files.push(cl.path);
|
|
416
|
+
g.last = Math.max(g.last, Date.parse(cl.lastSavedAt));
|
|
417
|
+
claimed.add(k);
|
|
418
|
+
}
|
|
419
|
+
for (const { cl, files, last } of claimGroups.values()) {
|
|
420
|
+
byWip(cl.name, cl.person || cl.name, 'working', { kind: 'editing', epic: cl.epic, files, last: new Date(last).toISOString(), text: `editing ${files.map(safe).join(', ')} (last saved ${zoneStamp(last, tz)})` });
|
|
421
|
+
}
|
|
422
|
+
for (const w of wipSaves(root, env, win.startMs)) {
|
|
423
|
+
if (claimed.has(`${w.name}\0${w.epic}`)) continue;
|
|
424
|
+
byWip(w.name, w.name, 'working', { kind: 'drafts', epic: w.epic, last: new Date(w.at).toISOString(), text: `saved drafts on ${safe(w.epic)} (${zoneStamp(w.at, tz)}; nothing unmerged is open now)` });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// WAITING — gates, by the owner of the step they review: the live E47 owner, else the epic's `owner`.
|
|
428
|
+
// Each epic on its own: a shared file that is not what yad writes stops that epic's lines, said in
|
|
429
|
+
// "not read", never the whole report.
|
|
430
|
+
const solo = isSolo(productConfig);
|
|
431
|
+
const engagement = requireEngagement(productConfig);
|
|
432
|
+
const ownerless = [];
|
|
433
|
+
const mergeWord = identity.platform ? `its review ${identity.platform === 'gitlab' ? 'MR' : 'PR'} to merge` : '`yad gate advance` (no platform: the gate moves on when it is advanced)';
|
|
434
|
+
for (const [epic, led] of ledgers) {
|
|
435
|
+
try {
|
|
436
|
+
const state = led.state;
|
|
437
|
+
if (!state?.steps) continue;
|
|
438
|
+
const owners = new Map(readOwners(root, epic).filter((o) => !o.error && o.live).map((o) => [o.step, o]));
|
|
439
|
+
const front = readFrontmatter(path.join(epicRoot(root, epic), 'epic.md')).owner || readFrontmatter(path.join(epicRoot(root, epic), 'roadmap.md')).owner;
|
|
440
|
+
const epicOwner = typeof front === 'string' && front.trim() ? front.trim() : null;
|
|
441
|
+
const ownerOf = (stepId) => {
|
|
442
|
+
const o = owners.get(stepId);
|
|
443
|
+
if (o) return { at: whoseWip(members, o.owner), display: o.name || o.owner, key: `wip:${o.owner}` };
|
|
444
|
+
if (epicOwner) return { at: whose(members, { login: epicOwner, name: epicOwner }), display: epicOwner, key: personKey({ login: epicOwner }) };
|
|
445
|
+
return null;
|
|
446
|
+
};
|
|
447
|
+
const put = (stepId, item) => {
|
|
448
|
+
const o = ownerOf(stepId);
|
|
449
|
+
if (!o) { ownerless.push(item); return; }
|
|
450
|
+
place(o.at, o.display, o.key, 'waiting', item);
|
|
451
|
+
};
|
|
452
|
+
const approvals = records(led.approvals);
|
|
453
|
+
const comments = records(led.comments);
|
|
454
|
+
const reviewPrs = records(led.productPrs);
|
|
455
|
+
// (b) the current step is an author step someone owns, and it is not done.
|
|
456
|
+
const cur = state.steps.find((s) => s?.id === state.currentStep);
|
|
457
|
+
const curDef = STEPS.find((d) => d.id === cur?.id);
|
|
458
|
+
if (cur && !isPassed(cur) && curDef && curDef.kind !== 'review' && cur.type !== 'review+approve' && ownerOf(cur.id)) {
|
|
459
|
+
put(cur.id, { kind: 'next-move', epic, step: cur.id, state: stepStatus(cur), text: `${epic} ${safe(cur.id)} is the current step and theirs to author (${stepStatus(cur) || 'unknown state'})` });
|
|
460
|
+
}
|
|
461
|
+
// (a) an open gate: its author step passed, the review not yet. Read through the gate's own predicate,
|
|
462
|
+
// so this line never says more than `yad gate status` would (engagement, revoked approvals, solo).
|
|
463
|
+
for (const s of state.steps.filter((x) => x?.type === 'review+approve' && !isPassed(x))) {
|
|
464
|
+
const a = authorStepFor(state, s);
|
|
465
|
+
if (!a || !isPassed(a)) continue;
|
|
466
|
+
const verdict = gatePredicate({ step: s, approvals, acceptedHashes: acceptedHashes(epicRoot(root, epic), s.artifact), solo, requireEngagement: engagement, optional: optionalStepsFor(state) });
|
|
467
|
+
const rule = gateRuleFor(s);
|
|
468
|
+
const pr = reviewPrs.find((p) => (typeof p.step === 'string' && p.step === s.id) || (typeof p.artifact === 'string' && p.artifact === s.artifact));
|
|
469
|
+
const prNo = pr && Number.isSafeInteger(pr.number) ? pr.number : null;
|
|
470
|
+
const via = prNo != null ? `${prWord(identity.platform)}${prNo}` : null;
|
|
471
|
+
const asks = rule.riskStep ? `; the count asks ${gateRuleSum(rule)} (risk step advisory)` : '';
|
|
472
|
+
const waitsFor = solo
|
|
473
|
+
? `waits for ${via ? `review ${via} to merge` : mergeWord} (solo mode: approvals waived)`
|
|
474
|
+
: verdict.approvalsSatisfied
|
|
475
|
+
? `has the ${plural(verdict.have ?? 0, 'approval')} it needs to pass; waits for ${via ? `review ${via} to merge` : mergeWord}${asks}`
|
|
476
|
+
: `waits for approval — missing: ${verdict.missing.map(safe).join('; ')}${asks}${via ? `; review ${via}` : ''}`;
|
|
477
|
+
put(a.id, { kind: 'gate', epic, step: s.id, approvals: verdict.have ?? null, needed: rule.base, asks: rule.needed, missing: verdict.missing, pr: prNo, text: `${epic} ${safe(s.id)} ${waitsFor}` });
|
|
478
|
+
const rounds = comments.filter((x) => x.step === s.id && Number.isSafeInteger(x.round) && x.round >= 0);
|
|
479
|
+
if (rounds.length) {
|
|
480
|
+
const round = Math.max(...rounds.map((x) => x.round));
|
|
481
|
+
const count = rounds.filter((x) => x.round === round).reduce((n, x) => n + (Number.isSafeInteger(x.count) && x.count >= 0 && x.count <= 100_000 ? x.count : 1), 0);
|
|
482
|
+
put(a.id, { kind: 'comments', epic, step: s.id, round, count, text: `${epic} ${safe(s.id)}: ${plural(count, 'comment')} recorded in review round ${round} (yad does not know which are answered)` });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
} catch (e) {
|
|
486
|
+
notRead.push(`${epic}: its gates could not be read (${why(e, places)}) — nothing is said about what waits there`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// WAITING / WORKING — their own open PRs/MRs.
|
|
490
|
+
for (const p of prRead) {
|
|
491
|
+
for (const pr of p.open) {
|
|
492
|
+
if (!pr.author || !Number.isSafeInteger(pr.number)) continue;
|
|
493
|
+
const facts = [pr.draft ? 'draft' : null,
|
|
494
|
+
pr.checks === 'failing' ? 'checks failing' : pr.checks === 'running' ? 'checks running' : pr.checks === 'passing' ? 'checks passing' : null,
|
|
495
|
+
pr.review === 'none' ? 'no review yet' : pr.review === 'changes-requested' ? 'changes requested' : pr.review === 'reviewed' ? 'reviewed, not approved' : pr.review === 'approved' ? 'approved' : null,
|
|
496
|
+
pr.status ? `merge status ${forTerminal(pr.status)}` : null].filter(Boolean);
|
|
497
|
+
const waits = !pr.draft && (pr.waits || pr.checks === 'failing' || pr.checks === 'running' || ['none', 'changes-requested', 'reviewed'].includes(pr.review));
|
|
498
|
+
byAccount({ platform: p.platform, host: p.host, login: pr.author }, waits ? 'waiting' : 'working', { kind: 'open-pr', repo: p.label, number: pr.number, draft: pr.draft, checks: pr.checks, review: pr.review, text: `${prWord(p.platform)}${pr.number} open in ${p.label}${facts.length ? ` — ${facts.join(', ')}` : ''}` });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const prPartial = prRead.some((p) => p.openPartial || p.mergedPartial);
|
|
503
|
+
const prNote = prMissing.length ? `platform not read for ${prMissing.map((p) => p.label).join(', ')} — PR/MR facts there are unknown, not none` : null;
|
|
504
|
+
const shape = (m) => ({
|
|
505
|
+
login: m.primary.login, logins: m.accounts.map((a) => a.login), account: accountLabel(m.primary), accounts: m.accounts.map(accountLabel), status: m.status, lastActive: isDay(m.lastActive) ? m.lastActive : null, ...blocks.get(m),
|
|
506
|
+
});
|
|
507
|
+
const team = members.filter((m) => m.status !== 'left').map(shape);
|
|
508
|
+
const left = members.filter((m) => m.status === 'left').map(shape);
|
|
509
|
+
return {
|
|
510
|
+
window: { start: new Date(win.startMs).toISOString(), end: new Date(now).toISOString(), tz, basis: win.basis, ledgerFrom: win.startUtcDay },
|
|
511
|
+
fetched,
|
|
512
|
+
ttlDays: ttl,
|
|
513
|
+
team,
|
|
514
|
+
left,
|
|
515
|
+
unlisted: [...unlisted.values()],
|
|
516
|
+
ownerless,
|
|
517
|
+
platform: prs.map((p) => ({ repo: p.label, read: !!p.ok, ...(p.ok ? {} : { why: p.why }) })),
|
|
518
|
+
prNote,
|
|
519
|
+
prPartial,
|
|
520
|
+
notRead,
|
|
521
|
+
notes,
|
|
522
|
+
memberErrors: got.errors.map((e) => safe(e.error)),
|
|
523
|
+
duplicates: got.duplicates.map(safe),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Narrow the report to one member: by any of their logins (any account), case-insensitive.
|
|
528
|
+
export function onlyMember(model, login) {
|
|
529
|
+
const want = String(login).toLowerCase();
|
|
530
|
+
const pick = (list) => list.filter((m) => m.logins.some((l) => l.toLowerCase() === want));
|
|
531
|
+
return { ...model, team: pick(model.team), left: pick(model.left), unlisted: [], ownerless: [], member: login };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---- rendering -----------------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
const SECTIONS = [['done', 'Done'], ['working', 'Working on'], ['waiting', 'Waiting']];
|
|
537
|
+
const FOOTER = 'Derived, read-only — rebuilt from git, the Product ledgers, the capture branches and the platform each time it runs. Facts only: no score, no ranking, and no emails, commit messages, PR titles or comment bodies.';
|
|
538
|
+
|
|
539
|
+
const LOCAL_ONLY = 'no remote named origin — only your own capture branches are read';
|
|
540
|
+
|
|
541
|
+
// Everything that was not read or not used, for the end of every format — the same list the terminal prints.
|
|
542
|
+
export function gapLines(model) {
|
|
543
|
+
return [
|
|
544
|
+
...(model.fetched === 'failed' ? ['could not fetch the capture branches — claims and drafts are as last fetched'] : []),
|
|
545
|
+
...model.platform.filter((p) => !p.read).map((p) => `${p.repo}: platform not read — ${p.why}`),
|
|
546
|
+
...model.notRead,
|
|
547
|
+
...model.notes,
|
|
548
|
+
...(model.fetched === 'local' ? [LOCAL_ONLY] : []),
|
|
549
|
+
...model.memberErrors,
|
|
550
|
+
...model.duplicates.map((d) => `${d} — one account or email belongs to one member; neither file joins it until one is fixed`),
|
|
551
|
+
];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// The line said when no member block prints, the same in every format.
|
|
555
|
+
export function noTeamLine(model) {
|
|
556
|
+
if (model.team.length) return null;
|
|
557
|
+
if (model.member) return model.left.length ? `${forTerminal(model.member)} has left — listed under Left` : `no member ${forTerminal(model.member)} on the team list`;
|
|
558
|
+
if (model.left.length) return 'everyone on the team list has left';
|
|
559
|
+
if (model.memberErrors.length) return 'no member file could be read — the reasons are listed at the end';
|
|
560
|
+
return 'no team list yet — each person runs `yad member add` (or `yad join` does it)';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function headerLines(model) {
|
|
564
|
+
const w = model.window;
|
|
565
|
+
const start = Date.parse(w.start);
|
|
566
|
+
const end = Date.parse(w.end);
|
|
567
|
+
return [
|
|
568
|
+
`window: ${weekday(start, w.tz)} ${zoneStamp(start, w.tz)} → ${weekday(end, w.tz)} ${zoneStamp(end, w.tz)} (${w.tz}) — ${w.basis}`,
|
|
569
|
+
`ledger records carry a UTC date and no time: one dated ${w.ledgerFrom} or later is in`,
|
|
570
|
+
];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// "nothing" only when every source answered; otherwise the empty section says what may be missing.
|
|
574
|
+
const emptyLine = (model) => {
|
|
575
|
+
const gaps = [model.prNote ? 'the platform was not read for every repo' : null, model.prPartial ? 'not every PR/MR fitted on one page' : null, model.notRead.length ? 'some git history or ledgers were not read' : null,
|
|
576
|
+
model.fetched === 'failed' ? 'the capture branches could not be fetched' : null].filter(Boolean);
|
|
577
|
+
return gaps.length ? `nothing recorded here — but ${gaps.join(', and ')}, so something may be missing (see the end)` : 'nothing';
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
function blockText(model, b, title) {
|
|
581
|
+
log(`\n ${c.bold(title)}`);
|
|
582
|
+
for (const [k, label] of SECTIONS) {
|
|
583
|
+
log(` ${c.dim(label)}`);
|
|
584
|
+
if (!b[k].length) log(` ${c.dim(emptyLine(model))}`);
|
|
585
|
+
for (const it of b[k]) log(` • ${it.text}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const memberTitle = (m) => `${forTerminal(m.login)} ${c.dim(`— ${m.status}${m.lastActive ? `, last active ${m.lastActive}` : ''}${m.accounts.length > 1 ? ` · ${m.accounts.join(', ')}` : ''}`)}`;
|
|
590
|
+
const unlistedTitle = (u) => `${forTerminal(u.who)}${u.ambiguous ? c.dim(` — matches more than one member (${u.ambiguous.join(', ')}); not given to either`) : ''}`;
|
|
591
|
+
|
|
592
|
+
export function renderText(model) {
|
|
593
|
+
log(c.bold('\nyad standup'));
|
|
594
|
+
for (const l of headerLines(model)) info(l);
|
|
595
|
+
const none = noTeamLine(model);
|
|
596
|
+
if (none) info(none);
|
|
597
|
+
for (const m of model.team) blockText(model, m, memberTitle(m));
|
|
598
|
+
if (model.unlisted.length) {
|
|
599
|
+
log(`\n ${c.bold('Seen, not on the team list')} ${c.dim('— people in the record who match no member file')}`);
|
|
600
|
+
for (const u of model.unlisted) blockText(model, u, unlistedTitle(u));
|
|
601
|
+
}
|
|
602
|
+
if (model.ownerless.length) {
|
|
603
|
+
log(`\n ${c.bold('Open gates nobody owns')} ${c.dim('— no step owner (yad assign) and no epic owner')}`);
|
|
604
|
+
for (const it of model.ownerless) log(` • ${it.text}`);
|
|
605
|
+
}
|
|
606
|
+
if (model.left.length) {
|
|
607
|
+
log(`\n ${c.bold('Left')} ${c.dim('— no longer has access to the Product')}`);
|
|
608
|
+
for (const m of model.left) log(` • ${forTerminal(m.login)}${m.lastActive ? c.dim(` (last active ${m.lastActive})`) : ''}`);
|
|
609
|
+
}
|
|
610
|
+
log('');
|
|
611
|
+
const notes = new Set([...model.notes, LOCAL_ONLY]);
|
|
612
|
+
for (const l of gapLines(model)) (notes.has(l) ? info : warn)(l);
|
|
613
|
+
note(c.dim(FOOTER));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function renderMarkdown(model) {
|
|
617
|
+
const L = ['# Standup', '', ...headerLines(model).map((l) => `- ${mdText(l)}`), ''];
|
|
618
|
+
const block = (b, title) => {
|
|
619
|
+
L.push(`## ${mdText(title)}`, '');
|
|
620
|
+
for (const [k, label] of SECTIONS) {
|
|
621
|
+
L.push(`**${label}**`, '');
|
|
622
|
+
if (!b[k].length) L.push(`- _${emptyLine(model)}_`);
|
|
623
|
+
for (const it of b[k]) L.push(`- ${mdText(it.text)}`);
|
|
624
|
+
L.push('');
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const none = noTeamLine(model);
|
|
628
|
+
if (none) L.push(`_${mdText(none)}_`, '');
|
|
629
|
+
for (const m of model.team) block(m, `${m.login} — ${m.status}${m.lastActive ? `, last active ${m.lastActive}` : ''}`);
|
|
630
|
+
if (model.unlisted.length) {
|
|
631
|
+
L.push('# Seen, not on the team list', '');
|
|
632
|
+
for (const u of model.unlisted) block(u, `${u.who}${u.ambiguous ? ` (matches ${u.ambiguous.join(', ')})` : ''}`);
|
|
633
|
+
}
|
|
634
|
+
if (model.ownerless.length) { L.push('# Open gates nobody owns', ''); for (const it of model.ownerless) L.push(`- ${mdText(it.text)}`); L.push(''); }
|
|
635
|
+
if (model.left.length) { L.push('# Left', ''); for (const m of model.left) L.push(`- ${mdText(m.login)}${m.lastActive ? ` (last active ${m.lastActive})` : ''}`); L.push(''); }
|
|
636
|
+
const missing = gapLines(model);
|
|
637
|
+
if (missing.length) { L.push('# Not read', ''); for (const n of missing) L.push(`- ${mdText(n)}`); L.push(''); }
|
|
638
|
+
L.push(`_${FOOTER}_`, '');
|
|
639
|
+
return L.join('\n');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function renderHtml(model) {
|
|
643
|
+
const list = (items, empty) => (items.length ? `<ul>${items.map((it) => `<li>${esc(it.text)}</li>`).join('')}</ul>` : `<p class="none">${esc(empty)}</p>`);
|
|
644
|
+
const block = (b, title, sub = '') => `<section class="member"><h2>${esc(title)}${sub ? ` <span>${esc(sub)}</span>` : ''}</h2>${SECTIONS.map(([k, label]) => `<h3>${label}</h3>${list(b[k], emptyLine(model))}`).join('')}</section>`;
|
|
645
|
+
const missing = gapLines(model);
|
|
646
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
647
|
+
<title>yadflow — standup</title><style>
|
|
648
|
+
:root{--bg:#fff;--fg:#1d2026;--dim:#5d6470;--card:#f6f7f9;--line:#e1e4ea}
|
|
649
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0f1115;--fg:#e6e8ee;--dim:#9aa0ad;--card:#181b22;--line:#262a33}}
|
|
650
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
|
|
651
|
+
.wrap{max-width:860px;margin:0 auto;padding:28px 16px}h1{font-size:22px;margin:0 0 6px}.sub{color:var(--dim);margin:0 0 20px;padding-left:18px}
|
|
652
|
+
.member{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:0 0 12px}
|
|
653
|
+
h2{font-size:15px;margin:0 0 4px}h2 span{color:var(--dim);font-weight:400;font-size:13px}h3{font-size:12px;color:var(--dim);margin:10px 0 2px;text-transform:uppercase;letter-spacing:.04em}
|
|
654
|
+
ul{margin:0;padding-left:18px}.none{color:var(--dim);margin:0}.group{font-size:17px;margin:24px 0 10px}
|
|
655
|
+
footer{color:var(--dim);font-size:12px;margin-top:24px;border-top:1px solid var(--line);padding-top:12px}
|
|
656
|
+
</style></head><body><div class="wrap">
|
|
657
|
+
<h1>Standup</h1><ul class="sub">${headerLines(model).map((l) => `<li>${esc(l)}</li>`).join('')}</ul>
|
|
658
|
+
${model.team.length ? model.team.map((m) => block(m, m.login, `${m.status}${m.lastActive ? `, last active ${m.lastActive}` : ''}`)).join('') : `<p class="none">${esc(noTeamLine(model))}</p>`}
|
|
659
|
+
${model.unlisted.length ? `<h2 class="group">Seen, not on the team list</h2>${model.unlisted.map((u) => block(u, u.who, u.ambiguous ? `matches ${u.ambiguous.join(', ')}` : '')).join('')}` : ''}
|
|
660
|
+
${model.ownerless.length ? `<h2 class="group">Open gates nobody owns</h2>${list(model.ownerless, '')}` : ''}
|
|
661
|
+
${model.left.length ? `<h2 class="group">Left</h2><ul>${model.left.map((m) => `<li>${esc(m.login)}${m.lastActive ? ` (last active ${esc(m.lastActive)})` : ''}</li>`).join('')}</ul>` : ''}
|
|
662
|
+
${missing.length ? `<h2 class="group">Not read</h2><ul>${missing.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>` : ''}
|
|
663
|
+
<footer>${esc(FOOTER)}</footer>
|
|
664
|
+
</div></body></html>\n`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ---- the command ---------------------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
const STANDUP_FLAGS = new Set(['--since', '--tz', '--member', '--me', '--format', '--out', '--json', '--dir']);
|
|
670
|
+
|
|
671
|
+
// The flags a person typed that standup does not take (a `--sinse` typo must not be silent).
|
|
672
|
+
export function foreignFlags(argv) {
|
|
673
|
+
return argv.filter((a) => a.startsWith('-') && !STANDUP_FLAGS.has(a.split('=')[0]));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// `yad standup`. Returns the model (E1 makes it the `--json` answer), or undefined after refusing.
|
|
677
|
+
export async function runStandup(root, { now = Date.now(), tz = 'UTC', since = null, member = null, me = false, format = null, out = null, json = false, env = process.env, runner, fetch = true, access } = {}) {
|
|
678
|
+
const refuse = (msg, hint) => { fail(msg); if (hint) hand(hint); process.exitCode = 1; };
|
|
679
|
+
if (!fs.existsSync(productConfigPath(root))) return refuse('not a Product (no .sdlc/product.json here)', 'run it from the Product, or pass --dir');
|
|
680
|
+
if (!format && out) format = /\.html?$/i.test(out) ? 'html' : 'md';
|
|
681
|
+
if (format && !['md', 'html'].includes(format)) return refuse(`unknown --format ${forTerminal(String(format))}`, 'use --format md or --format html (or --json for the data)');
|
|
682
|
+
if (member && me) return refuse('pass --member <login> or --me, not both');
|
|
683
|
+
const win = windowFor(now, { tz, since });
|
|
684
|
+
if (win.error) return refuse(win.error, win.hint);
|
|
685
|
+
let who = member;
|
|
686
|
+
if (me) {
|
|
687
|
+
const productConfig = readJSON(productConfigPath(root), null);
|
|
688
|
+
const id = productIdentity(root, { productConfig, ...(runner ? { runner } : {}) });
|
|
689
|
+
who = id.platform ? platformLogin(root, id.platform, { env, host: id.host, ...(runner ? { runner } : {}) }) : null;
|
|
690
|
+
if (!who) {
|
|
691
|
+
const name = gitIn(root, env)(['config', 'user.name']).out.trim();
|
|
692
|
+
const found = name ? readMembers(root, { identity: id }).members.filter((m) => m.names.some((n) => n.toLowerCase() === name.toLowerCase())) : [];
|
|
693
|
+
if (found.length === 1) who = found[0].primary.login;
|
|
694
|
+
}
|
|
695
|
+
if (!who) return refuse('yad cannot tell who you are here — not logged in to the Product\'s platform, and your git name is on no single member file', 'pass --member <login>, or log in (gh auth login / glab auth login)');
|
|
696
|
+
}
|
|
697
|
+
let model = buildStandup(root, { now, tz, since, env, runner, fetch, ...(access ? { access } : {}) });
|
|
698
|
+
if (model.error) return refuse(model.error, model.hint);
|
|
699
|
+
if (who) model = onlyMember(model, who);
|
|
700
|
+
if (format) {
|
|
701
|
+
const dest = out || `standup-report.${format}`;
|
|
702
|
+
writeReport(dest, format === 'md' ? renderMarkdown(model) : renderHtml(model));
|
|
703
|
+
if (json) note(`wrote ${format.toUpperCase()} report → ${dest}`);
|
|
704
|
+
else info(`wrote ${format.toUpperCase()} report → ${c.bold(dest)}`);
|
|
705
|
+
return { ...model, out: dest };
|
|
706
|
+
}
|
|
707
|
+
if (!json) renderText(model);
|
|
708
|
+
return { ...model, out: null };
|
|
709
|
+
}
|