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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/cli/pr-list.mjs
ADDED
|
@@ -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
|
+
|
package/cli/protection.mjs
CHANGED
|
@@ -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);
|