yadflow 4.4.0 → 4.5.1

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/cli/people.mjs CHANGED
@@ -40,6 +40,7 @@ import { PROJECT_FILES, epicFiles } from './manifest.mjs';
40
40
  import { epicIds, epicRoot, unlistedLedgerDirs, FOUNDATION_EPIC, FOUNDATION_DIR, ledgerPersonLogin, legacyLogins, capLimit, capSeat, smallestTeam } from './epic-state.mjs';
41
41
  import { corruptShards, readShips } from './ledger.mjs';
42
42
  import { isBot, loginFromEmail } from './riskmap.mjs';
43
+ import { gateMemberMap, hashEmail } from './members.mjs';
43
44
 
44
45
  // yadflow's OWN gate bot on GitLab. The GitHub workflow commits as `yad-gate-sync[bot]`, which `isBot`
45
46
  // already skips; the GitLab one commits as `yad-gate-sync` <yad-gate-sync@noreply.<host>> (the wired
@@ -313,7 +314,9 @@ export function gitHas(repoRoot, sha) {
313
314
  return r.status === 0;
314
315
  }
315
316
 
316
- function gitAuthors(dir, since) {
317
+ // Exported for `yad standup` (E132), which reads the same authors over its own window: `at` is the commit's
318
+ // exact time in epoch seconds, beside the UTC day in `ts` the count reads.
319
+ export function gitAuthors(dir, since) {
317
320
  const git = (args) => spawnSync('git', args, { cwd: dir, encoding: 'utf8', maxBuffer: 1 << 30 });
318
321
  // Every reason below is printed: the folder is built from the shared registry (E81 review 15).
319
322
  const repoRoot = forTerminal(dir);
@@ -354,7 +357,8 @@ function gitAuthors(dir, since) {
354
357
  const secs = Number(ct);
355
358
  const ts = Number.isFinite(secs) && ct !== '' ? dayString(Math.floor(secs / 86400)) : null;
356
359
  if (ts === null) return { unknown: `${repoRoot}: a commit carries a date git cannot express as a calendar day` };
357
- out.push({ ts, name: String(name || '').trim(), login: loginFromEmail(email), how: 'committed' });
360
+ // The email itself is never kept — only its hash, which a member file (E131) can match.
361
+ out.push({ ts, at: Math.floor(secs), name: String(name || '').trim(), login: loginFromEmail(email), emailHash: hashEmail(email), how: 'committed' });
358
362
  }
359
363
  return { events: out };
360
364
  }
@@ -393,7 +397,11 @@ function connectedRepos(root) {
393
397
  }
394
398
 
395
399
  // Every piece of evidence, from every source, with one list of the reasons any of it is missing.
396
- export function peopleEvidence(root, { today = todayString(), aliases = new Map(), sinceDays = CAPACITY_MAX_DAYS * WALK_PAD } = {}) {
400
+ // E131: a commit whose author email a member file pairs with a login is that login's — one person, not a
401
+ // name and a login counted twice. `members` is that pairing (`gateMemberMap`): only proven pairs on a GitHub
402
+ // Product, never one two files share. It can only JOIN two keys, so it never removes anyone from the count;
403
+ // a member file that cannot be read adds no pairing, and the count stays as it was (over, not under).
404
+ export function peopleEvidence(root, { today = todayString(), aliases = new Map(), sinceDays = CAPACITY_MAX_DAYS * WALK_PAD, members = null } = {}) {
397
405
  const led = ledgerEvidence(root, aliases);
398
406
  const events = [...led.events];
399
407
  const unknown = [...led.unknown];
@@ -426,6 +434,8 @@ export function peopleEvidence(root, { today = todayString(), aliases = new Map(
426
434
  else events.push(...got.events);
427
435
  }
428
436
  }
437
+ const pairs = members || gateMemberMap(root);
438
+ if (pairs.size) for (const e of events) if (!e.login && e.emailHash && pairs.has(e.emailHash)) e.login = pairs.get(e.emailHash);
429
439
  return { events, merges: led.merges, unknown };
430
440
  }
431
441
 
package/cli/platform.mjs CHANGED
@@ -67,7 +67,7 @@ export function platformReady(platform) {
67
67
  // both github.com and a GitHub Enterprise server would be recorded under the wrong account. `host` is the
68
68
  // caller's (the Product's `git_url`), else the directory's origin remote. `glab api` already resolves the
69
69
  // host from the repo.
70
- const LOGIN_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*(\[bot\])?$/; // GitLab allows a leading underscore
70
+ export const LOGIN_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*(\[bot\])?$/; // GitLab allows a leading underscore
71
71
  const loginCache = new Map();
72
72
  export function platformLogin(cwd, platform, { runner = run, env = process.env, host } = {}) {
73
73
  if (env.YAD_PLATFORM_LOGIN === '0') return null;
@@ -0,0 +1,125 @@
1
+ // E132 — the open and recently merged PRs/MRs of one repo, for `yad standup`. A READ, and E70's rule holds
2
+ // here word for word: an empty list is an answer only when the platform answered it. Anything else — no
3
+ // platform, reads turned off, no `gh`/`glab`, not logged in, offline, a 403 — is `{ ok: false, why }`, which
4
+ // the standup prints as "platform not read — <why>", never as "no PRs".
5
+ //
6
+ // One call per list, never one per PR: GitHub's GraphQL answers the open PRs with their review decision
7
+ // and check state, and the merged ones, in one query; GitLab's MR list carries `detailed_merge_status`.
8
+ // A page is 100. A FULL page proves nothing about what is past it, so it is said ("more than 100 …, not
9
+ // all read"), never read as the whole list.
10
+ //
11
+ // Facts only: the number, the author's login, draft or not, the check state and the review state the
12
+ // platform gives. Never a title, a body, a branch name or a comment (no free text — `yad usage`'s rule).
13
+ // `runner` is injectable so every answer is testable without a network (`YAD_PLATFORM_READ=0` in tests).
14
+ import { run } from './lib.mjs';
15
+ import { cliFor, detectPlatform, hostFromGitUrl, plainHost } from './platform.mjs';
16
+ import { api, httpStatus, loggedIn, repoPathFromGitUrl, shown, whyFailed, PAGE, TIMEOUT } from './protection.mjs';
17
+
18
+ // The check state, one word: GitHub's rollup, or what GitLab's merge status says about the pipeline.
19
+ const GH_CHECKS = { SUCCESS: 'passing', FAILURE: 'failing', ERROR: 'failing', PENDING: 'running', EXPECTED: 'running' };
20
+
21
+ const GH_QUERY = `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){
22
+ open:pullRequests(states:OPEN,first:${PAGE},orderBy:{field:CREATED_AT,direction:DESC}){totalCount nodes{number isDraft author{__typename login} reviewDecision reviews{totalCount} latestOpinionatedReviews(first:100){nodes{state}} commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}
23
+ merged:pullRequests(states:MERGED,first:${PAGE},orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number mergedAt updatedAt author{__typename login}}}}}`;
24
+
25
+ // GitHub's review state, in the words the standup prints. `none` = no review of any kind yet.
26
+ // `reviewDecision` is null on a repo with no required-review rule — even on an approved PR — so the latest
27
+ // opinionated review of each reviewer decides when it is null: a change request wins, then an approval.
28
+ function ghReview(n) {
29
+ if (n.reviewDecision === 'CHANGES_REQUESTED') return 'changes-requested';
30
+ if (n.reviewDecision === 'APPROVED') return 'approved';
31
+ const states = (n.latestOpinionatedReviews?.nodes || []).map((r) => r?.state);
32
+ if (n.reviewDecision == null && states.includes('CHANGES_REQUESTED')) return 'changes-requested';
33
+ if (n.reviewDecision == null && states.includes('APPROVED')) return 'approved';
34
+ return (n.reviews?.totalCount || 0) > 0 ? 'reviewed' : 'none';
35
+ }
36
+
37
+ // GitLab's `detailed_merge_status`. Only the two pipeline words are turned into a check state; every other
38
+ // status is printed as GitLab wrote it, because reading more into it would say what GitLab did not —
39
+ // `mergeable` on a project with no approval rule was approved by nobody. `waits` lists the statuses where
40
+ // the MR cannot merge until something happens: those put it under "waiting".
41
+ const GL_CHECKS = { ci_must_pass: 'failing', ci_still_running: 'running' };
42
+ export const GL_WAITS = ['ci_must_pass', 'ci_still_running', 'not_approved', 'requested_changes', 'discussions_not_resolved', 'need_rebase', 'conflict'];
43
+
44
+ const author = (a) => (a && typeof a.login === 'string' && a.__typename !== 'Bot' && !/\[bot\]$/i.test(a.login) ? a.login : null);
45
+
46
+ // Where to ask, or why yad cannot: the same preamble E70's `readProtection` runs, in the same order.
47
+ export function prTarget({ platform = null, gitUrl = '' } = {}, { runner = run, env = process.env, authCache = new Map() } = {}) {
48
+ const rawHost = hostFromGitUrl(gitUrl || '');
49
+ const host = plainHost(rawHost);
50
+ const repo = repoPathFromGitUrl(gitUrl || '');
51
+ const plat = platform || detectPlatform(gitUrl || '') || null;
52
+ const base = { platform: plat, host, repo };
53
+ const no = (kind, why) => ({ ...base, ok: false, kind, why });
54
+ const cli = cliFor(plat);
55
+ if (!cli) return no('no-platform', plat ? `yad does not know the platform ${JSON.stringify(shown(plat))} (it reads GitHub and GitLab)` : 'no platform (GitHub or GitLab) is known for it');
56
+ if (env.YAD_PLATFORM_READ === '0') return no('off', 'platform reads are turned off (YAD_PLATFORM_READ=0)');
57
+ if (rawHost && !host) return no('no-url', 'its git URL names a host that is not a plain host name, so yad does not ask about it');
58
+ if (!host || !repo) return no('no-url', 'no git URL yad can read, so it cannot tell which repo to ask about');
59
+ if (!runner(process.platform === 'win32' ? 'where' : 'which', [cli], {}).ok) return no('no-cli', `${cli} is not installed`);
60
+ if (!loggedIn(runner, cli, host, authCache)) return no('no-login', `${cli} is not logged in for ${host} (or ${host} did not answer)`);
61
+ return { ...base, ok: true, cli };
62
+ }
63
+
64
+ // The PRs/MRs of one repo: `{ ok: true, open: [...], merged: [...], openPartial, mergedPartial }` or
65
+ // `{ ok: false, kind, why }`. `sinceMs` bounds the merged list (epoch milliseconds).
66
+ // open: { number, author, draft, checks: passing|failing|running|none|null, review: none|reviewed|approved|changes-requested|null,
67
+ // status? (GitLab's own word), waits? (GitLab: a status that holds the merge) }
68
+ // merged: { number, author, mergedAt (ISO) }
69
+ export function listPrs(target, { sinceMs, runner = run, env = process.env, authCache = new Map() } = {}) {
70
+ const t = target?.cli ? target : prTarget(target, { runner, env, authCache });
71
+ if (!t.ok) return t;
72
+ return t.platform === 'github' ? listGitHub(t, sinceMs, runner) : listGitLab(t, sinceMs, runner);
73
+ }
74
+
75
+ function listGitHub(t, sinceMs, runner) {
76
+ const parts = t.repo.split('/');
77
+ const fail = (res) => ({ ...t, ok: false, kind: 'other', why: whyFailed(res, { platform: 'github', host: t.host, what: `the pull requests of ${shown(t.repo)}`, plural: true }) });
78
+ if (parts.length !== 2) return { ...t, ok: false, kind: 'no-url', why: `${shown(t.repo)} is not an owner/name GitHub repo path` };
79
+ const r = runner('gh', ['api', '--hostname', t.host, 'graphql', '-f', `query=${GH_QUERY}`, '-f', `owner=${parts[0]}`, '-f', `name=${parts[1]}`], { timeout: TIMEOUT });
80
+ // gh exits non-zero when the answer carries GraphQL `errors`: a partial answer is not an answer. Such an
81
+ // error comes with no HTTP status (`GraphQL: Could not resolve to a Repository …`), and is not "offline".
82
+ if (!r.ok && httpStatus(r) == null && /\bGraphQL:/.test(`${r.stderr || ''}${r.stdout || ''}`)) {
83
+ return { ...t, ok: false, kind: 'other', why: `GitHub answered the pull-request query for ${shown(t.repo)} with an error (the repo may not exist, or your login may not see it)` };
84
+ }
85
+ if (!r.ok) return fail({ ok: false, status: httpStatus(r) });
86
+ let body;
87
+ try { body = JSON.parse(r.stdout); } catch { return fail({ unreadable: true }); }
88
+ const repo = body?.data?.repository;
89
+ if (!repo || !Array.isArray(repo.open?.nodes) || !Array.isArray(repo.merged?.nodes)) return fail({ unreadable: true });
90
+ const open = repo.open.nodes.map((n) => ({
91
+ number: n.number, author: author(n.author), draft: n.isDraft === true,
92
+ checks: GH_CHECKS[n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state] || (n.commits?.nodes?.[0]?.commit?.statusCheckRollup ? null : 'none'),
93
+ review: ghReview(n),
94
+ }));
95
+ const nodes = repo.merged.nodes;
96
+ const merged = nodes.filter((n) => Date.parse(n.mergedAt) >= sinceMs).map((n) => ({ number: n.number, author: author(n.author), mergedAt: n.mergedAt }));
97
+ // Sorted by last update, newest first: the list is whole for the window once its oldest entry is older.
98
+ const last = nodes[nodes.length - 1];
99
+ return {
100
+ ...t, ok: true, open, merged,
101
+ openPartial: (repo.open.totalCount ?? 0) > open.length,
102
+ mergedPartial: nodes.length >= PAGE && !(Date.parse(last?.updatedAt) < sinceMs),
103
+ };
104
+ }
105
+
106
+ function listGitLab(t, sinceMs, runner) {
107
+ const at = `projects/${encodeURIComponent(t.repo)}/merge_requests`;
108
+ const what = `the merge requests of ${shown(t.repo)}`;
109
+ const fail = (res) => ({ ...t, ok: false, kind: 'other', why: whyFailed(res, { platform: 'gitlab', host: t.host, what, plural: true }) });
110
+ const o = api(runner, 'glab', t.host, `${at}?state=opened&per_page=${PAGE}`);
111
+ if (!o.ok) return fail(o);
112
+ const since = new Date(sinceMs).toISOString();
113
+ const m = api(runner, 'glab', t.host, `${at}?state=merged&order_by=updated_at&sort=desc&updated_after=${encodeURIComponent(since)}&per_page=${PAGE}`);
114
+ if (!m.ok) return fail(m);
115
+ if (!Array.isArray(o.body) || !Array.isArray(m.body)) return fail({ unreadable: true });
116
+ const who = (a) => (a && typeof a.username === 'string' && a.bot !== true && !/\[bot\]$/i.test(a.username) ? a.username : null);
117
+ const open = o.body.map((x) => {
118
+ const st = typeof x.detailed_merge_status === 'string' ? x.detailed_merge_status : null;
119
+ const checks = st && Object.hasOwn(GL_CHECKS, st) ? GL_CHECKS[st] : null;
120
+ return { number: x.iid, author: who(x.author), draft: x.draft === true, checks, review: null, ...(st && !checks ? { status: st } : {}), waits: !!st && GL_WAITS.includes(st) };
121
+ });
122
+ const merged = m.body.filter((x) => Date.parse(x.merged_at) >= sinceMs).map((x) => ({ number: x.iid, author: who(x.author), mergedAt: x.merged_at }));
123
+ return { ...t, ok: true, open, merged, openPartial: o.body.length >= PAGE, mergedPartial: m.body.length >= PAGE };
124
+ }
125
+
@@ -28,7 +28,7 @@ import { SETTINGS_EDIT_HINT } from './manifest.mjs';
28
28
  import { cliFor, hostFromGitUrl, detectPlatform, plainHost } from './platform.mjs';
29
29
 
30
30
  export const PLATFORM_NAME = { github: 'GitHub', gitlab: 'GitLab' };
31
- const TIMEOUT = 10_000;
31
+ export const TIMEOUT = 10_000;
32
32
 
33
33
  // A word with an `@` after its first character may be an e-mail address, and no address is ever printed
34
34
  // (E67). A branch or a repo path is printed as written otherwise.
@@ -67,7 +67,7 @@ export function httpStatus(r) {
67
67
  // A failed call keeps its body too (E109): both CLIs print the platform's answer on stdout, so a caller
68
68
  // can read WHY it failed. A body that is not JSON is null, never an error. Only the GitLab branch read
69
69
  // looks at it today — see `gitlabBranchCause`.
70
- function api(runner, cli, host, pathname) {
70
+ export function api(runner, cli, host, pathname) {
71
71
  const r = runner(cli, ['api', '--hostname', host, pathname], { timeout: TIMEOUT });
72
72
  if (r.ok) {
73
73
  try { return { ok: true, body: JSON.parse(r.stdout) }; } catch { return { ok: false, status: null, unreadable: true }; }
@@ -96,7 +96,7 @@ const gitlabBranchCause = (res) => (res.status === 404 && typeof res.body?.messa
96
96
  export const PAGE = 100;
97
97
 
98
98
  // Why a call gave no answer, as a clause that finishes "not known — ".
99
- function whyFailed(res, { platform, host, what, plural = false }) {
99
+ export function whyFailed(res, { platform, host, what, plural = false }) {
100
100
  const name = PLATFORM_NAME[platform];
101
101
  if (res.unreadable) return `${name} answered ${what} with something yad could not read`;
102
102
  if (res.status == null) return `yad could not reach ${host} to read ${what} (offline, or the host did not answer)`;
@@ -134,7 +134,7 @@ const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v
134
134
  // `gh auth status` is asked once per CLI and host per doctor run (`authCache`, which the caller makes
135
135
  // fresh for each run): on a network that drops packets each ask can take the full timeout, and a Product
136
136
  // with many repos on one host would pay it once per repo. Never kept longer — a login can change.
137
- function loggedIn(runner, cli, host, authCache) {
137
+ export function loggedIn(runner, cli, host, authCache) {
138
138
  const key = `${cli}\0${host}`;
139
139
  if (!authCache.has(key)) authCache.set(key, runner(cli, ['auth', 'status', '--hostname', host], { timeout: TIMEOUT }).ok);
140
140
  return authCache.get(key);