yadflow 3.18.1 → 4.0.0-next.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/CHANGELOG.md +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/platform.mjs
CHANGED
|
@@ -42,122 +42,80 @@ export function platformReady(platform) {
|
|
|
42
42
|
return !!cli && has(cli);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// ----
|
|
46
|
-
//
|
|
47
|
-
// `
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Platform logins to auto-request as reviewers for the given scopes: everyone holding a `reviewer`
|
|
72
|
-
// or `domain-owner` role in any scope, minus `excludeLogin` (you don't review your own PR), deduped.
|
|
73
|
-
// `repos` (the registry) is consulted so a repo whose domain ownership lives ONLY in the legacy
|
|
74
|
-
// `repos.json` `domain_owner`/`domain_owners` field — not the roster roles map — is still requested
|
|
75
|
-
// as a reviewer for any scope that is its repo name. Without this the read side credits that login as
|
|
76
|
-
// a domain-owner (resolveLogin's legacy fallback) but the open side never asks them, so an escalated
|
|
77
|
-
// gate becomes structurally unsatisfiable through platform routing (BUG-1).
|
|
78
|
-
export function reviewersForScopes(roster = [], scopes = [], { excludeLogin = null, repos = [] } = {}) {
|
|
79
|
-
const out = [];
|
|
80
|
-
const add = (login) => { if (login && login !== excludeLogin && !out.includes(login)) out.push(login); };
|
|
81
|
-
for (const entry of roster) {
|
|
82
|
-
if (hasAnyRole(entry, scopes, ['reviewer', 'domain-owner'])) add(entry.login);
|
|
83
|
-
}
|
|
84
|
-
for (const scope of scopes) {
|
|
85
|
-
const repo = repos.find((r) => r.name === scope);
|
|
86
|
-
if (!repo) continue;
|
|
87
|
-
const names = repo.domain_owners || (repo.domain_owner ? [repo.domain_owner] : []);
|
|
88
|
-
for (const name of names) add(roster.find((r) => r.name === name)?.login);
|
|
89
|
-
}
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// The committer/PR-opener's platform login, resolved from local git identity through the roster.
|
|
94
|
-
// Match on commit email first (the stable key), then fall back to name/login. null when unresolved.
|
|
95
|
-
export function resolveCommitterLogin(cwd, roster = []) {
|
|
96
|
-
const email = (run('git', ['config', 'user.email'], { cwd }).stdout || '').trim().toLowerCase();
|
|
97
|
-
const name = (run('git', ['config', 'user.name'], { cwd }).stdout || '').trim();
|
|
98
|
-
if (email) {
|
|
99
|
-
const byEmail = roster.find((r) => (r.email || '').toLowerCase() === email);
|
|
100
|
-
if (byEmail) return byEmail.login || null;
|
|
101
|
-
}
|
|
102
|
-
if (name) {
|
|
103
|
-
const byName = roster.find((r) => r.name === name || r.login === name);
|
|
104
|
-
if (byName) return byName.login || null;
|
|
105
|
-
}
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Does this platform login exist on the hub? Warn-only (never throws): `checked:false` when the CLI
|
|
110
|
-
// is absent/unauthenticated so callers can distinguish "not a user" from "couldn't check".
|
|
111
|
-
export function validateLogin(platform, login) {
|
|
45
|
+
// ---- who is running this command ------------------------------------------------------------
|
|
46
|
+
// The platform login of whoever runs this command, asked of the platform's own CLI (`gh api user`,
|
|
47
|
+
// `glab api user`) rather than looked up in a stored list (E62). A list is a claim that goes stale; the
|
|
48
|
+
// CLI's answer is who is actually logged in. It is what a record's `by` names and what a commit subject
|
|
49
|
+
// shows as `@login`.
|
|
50
|
+
//
|
|
51
|
+
// Best effort, and never a gate: null when there is no platform, the CLI is missing or logged out, the
|
|
52
|
+
// network is down, the answer does not look like a login, or `YAD_PLATFORM_LOGIN=0` turns the lookup
|
|
53
|
+
// off (offline work, and the test suite, which must never ask the developer's real account). Callers
|
|
54
|
+
// then fall back to git `user.name` — see `actorName`. On CI the job token usually cannot read `/user`,
|
|
55
|
+
// so the fallback is the bot's git name, exactly what it was before.
|
|
56
|
+
//
|
|
57
|
+
// Asked once per platform and directory per process: a command that writes several records pays for
|
|
58
|
+
// one call. The timeout keeps a hung network from holding a command that only wanted a name.
|
|
59
|
+
//
|
|
60
|
+
// ON GITHUB THE HOST IS PASSED. `gh api` asks github.com unless told otherwise, so someone logged in to
|
|
61
|
+
// both github.com and a GitHub Enterprise server would be recorded under the wrong account. `host` is the
|
|
62
|
+
// caller's (the Product's `git_url`), else the directory's origin remote. `glab api` already resolves the
|
|
63
|
+
// host from the repo.
|
|
64
|
+
const LOGIN_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*(\[bot\])?$/; // GitLab allows a leading underscore
|
|
65
|
+
const loginCache = new Map();
|
|
66
|
+
export function platformLogin(cwd, platform, { runner = run, env = process.env, host } = {}) {
|
|
67
|
+
if (env.YAD_PLATFORM_LOGIN === '0') return null;
|
|
112
68
|
const cli = cliFor(platform);
|
|
113
|
-
if (!cli
|
|
69
|
+
if (!cli) return null;
|
|
70
|
+
const ghHost = platform === 'github'
|
|
71
|
+
? (host || hostFromGitUrl(runner('git', ['remote', 'get-url', 'origin'], { cwd }).stdout) || null)
|
|
72
|
+
: null;
|
|
73
|
+
const key = `${platform}\0${cwd}\0${ghHost || ''}`;
|
|
74
|
+
if (runner === run && loginCache.has(key)) return loginCache.get(key);
|
|
75
|
+
let login = null;
|
|
114
76
|
if (platform === 'github') {
|
|
115
|
-
const r =
|
|
116
|
-
|
|
77
|
+
const r = runner('gh', ['api', ...(ghHost ? ['--hostname', ghHost] : []), 'user', '--jq', '.login'], { cwd, timeout: 10000 });
|
|
78
|
+
if (r.ok) login = r.stdout.trim();
|
|
79
|
+
} else {
|
|
80
|
+
const r = runner('glab', ['api', 'user'], { cwd, timeout: 10000 });
|
|
81
|
+
if (r.ok) { try { login = String(JSON.parse(r.stdout).username || ''); } catch { /* not JSON — no login */ } }
|
|
117
82
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
let exists = false;
|
|
122
|
-
try { exists = Array.isArray(JSON.parse(r.stdout)) && JSON.parse(r.stdout).length > 0; } catch { /* malformed JSON -> exists stays false */ }
|
|
123
|
-
return { ok: exists, exists, checked: true };
|
|
83
|
+
if (!login || !LOGIN_RE.test(login)) login = null;
|
|
84
|
+
if (runner === run) loginCache.set(key, login);
|
|
85
|
+
return login;
|
|
124
86
|
}
|
|
125
87
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// domain-owner for that domain. Both spellings are honored — symmetric with reviewersForScopes,
|
|
145
|
-
// which REQUESTS from both, so a person routed as a domain owner is also credited as one.
|
|
146
|
-
const legacy = repos.find((repo) => repo.name === d
|
|
147
|
-
&& (repo.domain_owner === entry.name || (Array.isArray(repo.domain_owners) && repo.domain_owners.includes(entry.name))));
|
|
148
|
-
if (legacy) push({ name: entry.name, role: 'domain-owner', domain: d });
|
|
88
|
+
// Who wrote a record: the platform login, else git `user.name`, else null. Attribution is a nicety on
|
|
89
|
+
// the audit trail and never blocks the command that writes it.
|
|
90
|
+
export function actorName(cwd, platform, opts = {}) {
|
|
91
|
+
return platformLogin(cwd, platform, opts)
|
|
92
|
+
|| ((opts.runner || run)('git', ['config', 'user.name'], { cwd }).stdout || '').trim()
|
|
93
|
+
|| null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The roster names `legacyLogins` (cli/gate.mjs) leaves out because two logins share them, each with the
|
|
97
|
+
// logins that share it. An older record under such a
|
|
98
|
+
// name could be either person, so it is never matched by name — only as `upsertBridge` matches a record
|
|
99
|
+
// no name can place (an exact submission time, or an open step's one-to-one).
|
|
100
|
+
export function ambiguousLegacyNames(hub) {
|
|
101
|
+
const logins = new Map();
|
|
102
|
+
for (const e of Array.isArray(hub?.roster) ? hub.roster : []) {
|
|
103
|
+
if (!e || typeof e.name !== 'string' || !e.name || typeof e.login !== 'string' || !e.login) continue;
|
|
104
|
+
if (!logins.has(e.name)) logins.set(e.name, new Set());
|
|
105
|
+
logins.get(e.name).add(e.login);
|
|
149
106
|
}
|
|
150
|
-
|
|
151
|
-
// record so an approval from a known person is never silently dropped. An entry that DOES declare
|
|
152
|
-
// roles but none apply to these scopes contributes nothing here (it is scoped elsewhere).
|
|
153
|
-
const hasNoRoleInfo = !entry.role && !(entry.roles && (Array.isArray(entry.roles) ? entry.roles.length : Object.keys(entry.roles).length));
|
|
154
|
-
if (!records.length && hasNoRoleInfo) push({ name: entry.name, role: 'reviewer' });
|
|
155
|
-
return records;
|
|
107
|
+
return new Map([...logins].filter(([, set]) => set.size > 1));
|
|
156
108
|
}
|
|
157
109
|
|
|
158
110
|
// Normalized PR reviews -> approval records (only APPROVED states count). `submittedAt` rides along
|
|
159
111
|
// so the gate can tell a fresh re-approval from a stale one (revoke-on-change).
|
|
160
|
-
|
|
112
|
+
// The platform's own evidence rides along too, for the record only (E64): the review's `commit`, `url` and
|
|
113
|
+
// node id (`reviewId`) on GitHub. Each is left off when the read did not give it — never written `null`,
|
|
114
|
+
// because a `null` commit means a degraded read below. The gate decides on the login and the fingerprint.
|
|
115
|
+
// The record names the PLATFORM LOGIN that approved (E62). There is no stored list to look it up in and
|
|
116
|
+
// no role to give it: the platform's record of who approved is the evidence, and the gate counts
|
|
117
|
+
// people, not roles. A review with no login cannot be told apart from another one, so it is not counted.
|
|
118
|
+
export function mapApprovers(reviews = [], { headOid } = {}) {
|
|
161
119
|
const out = [];
|
|
162
120
|
for (const r of reviews) {
|
|
163
121
|
if (r.state !== 'APPROVED') continue;
|
|
@@ -175,16 +133,20 @@ export function mapApprovers(reviews = [], { roster, repos, touchedDomains, head
|
|
|
175
133
|
// engagement rides in the APPROVE review body (`<!-- yad:engagement verified -->`); a bare UI
|
|
176
134
|
// click has no marker → 'none'. Gameable by design (it makes review quality visible, not provable).
|
|
177
135
|
const engagement = parseEngagement(r.body);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
136
|
+
if (!r.login) continue;
|
|
137
|
+
out.push({
|
|
138
|
+
name: r.login, submittedAt: r.submittedAt || null, engagement,
|
|
139
|
+
...(typeof r.commit === 'string' && r.commit ? { commit: r.commit } : {}),
|
|
140
|
+
...(typeof r.url === 'string' && r.url ? { url: r.url } : {}),
|
|
141
|
+
...(typeof r.id === 'string' && r.id ? { reviewId: r.id } : {}),
|
|
142
|
+
});
|
|
181
143
|
}
|
|
182
144
|
return out;
|
|
183
145
|
}
|
|
184
146
|
|
|
185
147
|
// ---- read PR state (github) ---------------------------------------------------------------------
|
|
186
|
-
function readPrGitHub(n, { cwd } = {}) {
|
|
187
|
-
const view =
|
|
148
|
+
function readPrGitHub(n, { cwd, runner = run } = {}) {
|
|
149
|
+
const view = runner('gh', ['pr', 'view', String(n), '--json', 'state,mergedAt,mergedBy,mergeCommit,headRefOid'], { cwd });
|
|
188
150
|
if (!view.ok) return { ok: false, reason: view.stderr || 'gh pr view failed' };
|
|
189
151
|
const meta = JSON.parse(view.stdout);
|
|
190
152
|
let reviews = [];
|
|
@@ -192,7 +154,7 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
192
154
|
// Review-thread resolution via GraphQL (REST does not expose isResolved). Paginate so a PR with
|
|
193
155
|
// >100 threads is not mistakenly read as "all resolved".
|
|
194
156
|
let threads = [];
|
|
195
|
-
const nwo =
|
|
157
|
+
const nwo = runner('gh', ['repo', 'view', '--json', 'owner,name'], { cwd });
|
|
196
158
|
if (nwo.ok) {
|
|
197
159
|
const { owner, name } = JSON.parse(nwo.stdout);
|
|
198
160
|
// latestReviews collapses a reviewer's superseded reviews to their current one; commit.oid binds
|
|
@@ -201,17 +163,17 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
201
163
|
// latestReviews` does not expose the commit, so read it via GraphQL. Paginate so a PR with >100
|
|
202
164
|
// reviewers never silently omits one; any page failure aborts to the commitless fallback below,
|
|
203
165
|
// which fails closed rather than advancing on a partial read.
|
|
204
|
-
const rq = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){latestReviews(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{author{login} state submittedAt body commit{oid}}}}}}`;
|
|
166
|
+
const rq = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){latestReviews(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{id url author{login} state submittedAt body commit{oid}}}}}}`;
|
|
205
167
|
let rcursor = null;
|
|
206
168
|
reviewsOk = true;
|
|
207
169
|
for (let guard = 0; guard < 50; guard++) {
|
|
208
170
|
const args = ['api', 'graphql', '-f', `query=${rq}`, '-F', `o=${owner.login}`, '-F', `r=${name}`, '-F', `n=${n}`];
|
|
209
171
|
if (rcursor) args.push('-F', `c=${rcursor}`);
|
|
210
|
-
const rg =
|
|
172
|
+
const rg = runner('gh', args, { cwd });
|
|
211
173
|
if (!rg.ok) { reviewsOk = false; reviews = []; break; }
|
|
212
174
|
const conn = JSON.parse(rg.stdout)?.data?.repository?.pullRequest?.latestReviews;
|
|
213
175
|
for (const x of conn?.nodes || []) {
|
|
214
|
-
reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, body: x.body, commit: x.commit?.oid || null });
|
|
176
|
+
reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, body: x.body, commit: x.commit?.oid || null, id: x.id, url: x.url });
|
|
215
177
|
}
|
|
216
178
|
if (!conn?.pageInfo?.hasNextPage) break;
|
|
217
179
|
rcursor = conn.pageInfo.endCursor;
|
|
@@ -221,7 +183,7 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
221
183
|
for (let guard = 0; guard < 50; guard++) {
|
|
222
184
|
const args = ['api', 'graphql', '-f', `query=${q}`, '-F', `o=${owner.login}`, '-F', `r=${name}`, '-F', `n=${n}`];
|
|
223
185
|
if (cursor) args.push('-F', `c=${cursor}`);
|
|
224
|
-
const g =
|
|
186
|
+
const g = runner('gh', args, { cwd });
|
|
225
187
|
if (!g.ok) break;
|
|
226
188
|
const page = JSON.parse(g.stdout)?.data?.repository?.pullRequest?.reviewThreads;
|
|
227
189
|
for (const t of page?.nodes || []) {
|
|
@@ -241,7 +203,7 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
241
203
|
// is for the merged content), while CHANGES_REQUESTED is still honored — so a transient failure
|
|
242
204
|
// holds the gate, never advances it.
|
|
243
205
|
if (!reviewsOk) {
|
|
244
|
-
const rev =
|
|
206
|
+
const rev = runner('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
|
|
245
207
|
if (rev.ok) reviews = (JSON.parse(rev.stdout).latestReviews || [])
|
|
246
208
|
.map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, body: x.body, commit: null }));
|
|
247
209
|
}
|
|
@@ -249,6 +211,10 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
249
211
|
ok: true,
|
|
250
212
|
state: meta.state,
|
|
251
213
|
merged: meta.state === 'MERGED' || !!meta.mergedAt,
|
|
214
|
+
// What a closing record points at (E18). Null when the platform does not say, never invented.
|
|
215
|
+
mergedAt: meta.mergedAt || null,
|
|
216
|
+
mergedBy: meta.mergedBy?.login || null,
|
|
217
|
+
mergeCommit: meta.mergeCommit?.oid || null,
|
|
252
218
|
headOid: meta.headRefOid,
|
|
253
219
|
reviews,
|
|
254
220
|
threads,
|
|
@@ -256,13 +222,13 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
256
222
|
}
|
|
257
223
|
|
|
258
224
|
// ---- read PR state (gitlab) ---------------------------------------------------------------------
|
|
259
|
-
function readPrGitLab(n, { cwd } = {}) {
|
|
260
|
-
const view =
|
|
225
|
+
function readPrGitLab(n, { cwd, runner = run } = {}) {
|
|
226
|
+
const view = runner('glab', ['mr', 'view', String(n), '-F', 'json'], { cwd });
|
|
261
227
|
if (!view.ok) return { ok: false, reason: view.stderr || 'glab mr view failed' };
|
|
262
228
|
const mr = JSON.parse(view.stdout);
|
|
263
|
-
const approvals =
|
|
229
|
+
const approvals = runner('glab', ['api', `projects/:id/merge_requests/${mr.iid}/approvals`], { cwd });
|
|
264
230
|
const approvedBy = approvals.ok ? (JSON.parse(approvals.stdout).approved_by || []) : [];
|
|
265
|
-
const disc =
|
|
231
|
+
const disc = runner('glab', ['api', `projects/:id/merge_requests/${mr.iid}/discussions`], { cwd });
|
|
266
232
|
const discussions = disc.ok ? (JSON.parse(disc.stdout) || []) : [];
|
|
267
233
|
// A GitLab approval carries no body, so the companion's engagement marker rides in a NOTE the
|
|
268
234
|
// reviewer posts; attach the latest engagement-bearing note per username to their approval so
|
|
@@ -273,7 +239,13 @@ function readPrGitLab(n, { cwd } = {}) {
|
|
|
273
239
|
if (/<!--\s*yad:engagement\s+\w+\s*-->/i.test(nt.body || '')) engagementByUser.set(nt.author?.username, nt.body);
|
|
274
240
|
}
|
|
275
241
|
}
|
|
276
|
-
|
|
242
|
+
// `approved_at` is the time GitLab records for each approval (E64). An older self-managed instance may not
|
|
243
|
+
// send it; the approval then has no time, as every GitLab approval had before E64 read it. There is no
|
|
244
|
+
// per-approval commit on GitLab — the MR head is not the commit an approval was given on — so none is set.
|
|
245
|
+
const reviews = approvedBy.map((a) => ({
|
|
246
|
+
login: a.user?.username, state: 'APPROVED', body: engagementByUser.get(a.user?.username),
|
|
247
|
+
...(typeof a.approved_at === 'string' && a.approved_at ? { submittedAt: a.approved_at } : {}),
|
|
248
|
+
}));
|
|
277
249
|
const threads = discussions
|
|
278
250
|
.filter((d) => d.notes?.some((nt) => nt.resolvable))
|
|
279
251
|
.map((d, i) => ({
|
|
@@ -286,6 +258,10 @@ function readPrGitLab(n, { cwd } = {}) {
|
|
|
286
258
|
ok: true,
|
|
287
259
|
state: mr.state,
|
|
288
260
|
merged: mr.state === 'merged',
|
|
261
|
+
mergedAt: mr.merged_at || null,
|
|
262
|
+
// `merge_user` since GitLab 14.7; `merged_by` is the deprecated spelling older instances still send.
|
|
263
|
+
mergedBy: mr.merge_user?.username || mr.merged_by?.username || null,
|
|
264
|
+
mergeCommit: mr.merge_commit_sha || mr.squash_commit_sha || null,
|
|
289
265
|
headOid: mr.diff_refs?.head_sha || mr.sha,
|
|
290
266
|
reviews,
|
|
291
267
|
threads,
|
|
@@ -293,13 +269,16 @@ function readPrGitLab(n, { cwd } = {}) {
|
|
|
293
269
|
}
|
|
294
270
|
|
|
295
271
|
// Injectable entry point. gate.mjs accepts a `reader` override; default dispatches to gh/glab.
|
|
272
|
+
// `opts.runner` replaces the gh/glab calls, so the parsing is unit-testable without either installed
|
|
273
|
+
// (mirrors platformDefaultBranch). With a runner there is no CLI to probe, only a platform to name.
|
|
296
274
|
export function readPr(platform, n, opts = {}) {
|
|
297
|
-
|
|
275
|
+
const ready = opts.runner ? !!cliFor(platform) : platformReady(platform);
|
|
276
|
+
if (!ready) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
298
277
|
return platform === 'gitlab' ? readPrGitLab(n, opts) : readPrGitHub(n, opts);
|
|
299
278
|
}
|
|
300
279
|
|
|
301
280
|
// ---- find the PR/MR for a branch ----------------------------------------------------------------
|
|
302
|
-
// The review PR/MR opened for `review/EP-<slug>/<artifact>`, by HEAD/source branch. Under the
|
|
281
|
+
// The review PR/MR opened for `review/EP-<slug>/<artifact>`, by HEAD/source branch. Under the verified ledger
|
|
303
282
|
// the ledger records that pointer only at merge (CI is the sole writer), so without this a human has
|
|
304
283
|
// no way to name the review a merged PR belongs to — `gate sync` would just report "no open review PR
|
|
305
284
|
// recorded" for a PR that is sitting merged on the platform (issue #158).
|
|
@@ -418,7 +397,7 @@ export function platformDefaultBranch(platform, { cwd, runner = run } = {}) {
|
|
|
418
397
|
// though only this chain has a platform rung — they stop at the local `origin/HEAD`:
|
|
419
398
|
// 1 flag — an explicit --base; the human said so
|
|
420
399
|
// 2 registry — the repo's `default_branch` in .sdlc/repos.json
|
|
421
|
-
// 3 hub — hub.json's `default_branch`, for a PR against the
|
|
400
|
+
// 3 hub — hub.json's `default_branch`, for a PR against the Product itself
|
|
422
401
|
// 4 platform — what the remote says (see platformDefaultBranch)
|
|
423
402
|
// 5 origin-head — local `refs/remotes/origin/HEAD`, the same read repo.mjs/hubcommit.mjs use.
|
|
424
403
|
// Deliberately NOT `ls-remote`: see branchExists above for why a network probe on
|
|
@@ -467,9 +446,11 @@ export function resolveBaseBranch(platform, {
|
|
|
467
446
|
}
|
|
468
447
|
|
|
469
448
|
// ---- create a PR/MR -----------------------------------------------------------------------------
|
|
470
|
-
// `assignees` = the committer/PR-opener
|
|
471
|
-
//
|
|
472
|
-
//
|
|
449
|
+
// `assignees` = the committer/PR-opener on GitLab (empty when `glab` cannot say who is logged in);
|
|
450
|
+
// GitHub callers pass none and get `@me`, so the PR is owned by whoever pushed it;
|
|
451
|
+
// `reviewers` = logins to request. No caller passes any since E62 removed the roster that chose them;
|
|
452
|
+
// the parameter stays for E68, which suggests reviewers from history. On GitHub an empty assignee list
|
|
453
|
+
// falls back to `@me` so the opener still self-assigns when the platform login is unknown.
|
|
473
454
|
// Pure argv builder for the create command — exported so the reviewer/assignee/label wiring is
|
|
474
455
|
// unit-testable without shelling out. gh always self-assigns (@me) when no assignee resolved.
|
|
475
456
|
export function buildPrArgs(platform, { title, body, base, head, reviewers = [], labels = [], assignees = [] } = {}) {
|
|
@@ -586,7 +567,7 @@ export function createPr(platform, opts = {}) {
|
|
|
586
567
|
let mentioned = []; let dropped = [];
|
|
587
568
|
if (rest.length && iid) {
|
|
588
569
|
const ats = rest.map((m) => `@${m}`).join(' ');
|
|
589
|
-
const note = run('glab', ['mr', 'note', iid, '-m', `Review requested
|
|
570
|
+
const note = run('glab', ['mr', 'note', iid, '-m', `Review requested: ${ats} — please review and approve/comment on this MR (this drives the gate).`], { cwd: opts.cwd });
|
|
590
571
|
if (note.ok) mentioned = rest; else dropped = rest;
|
|
591
572
|
} else if (rest.length) {
|
|
592
573
|
dropped = rest; // could not parse the IID to post the note
|