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.
Files changed (156) hide show
  1. package/CHANGELOG.md +355 -0
  2. package/README.md +79 -26
  3. package/bin/commands.mjs +41 -0
  4. package/bin/yad.mjs +437 -124
  5. package/cli/artifact-status.mjs +34 -15
  6. package/cli/checkpoint.mjs +69 -49
  7. package/cli/codeowners-command.mjs +170 -0
  8. package/cli/codeowners.mjs +397 -0
  9. package/cli/commit.mjs +13 -9
  10. package/cli/companion.mjs +2 -2
  11. package/cli/dial.mjs +183 -0
  12. package/cli/docs.mjs +88 -32
  13. package/cli/doctor.mjs +1472 -97
  14. package/cli/epic-state.mjs +3478 -232
  15. package/cli/epic.mjs +506 -0
  16. package/cli/errors.mjs +4 -1
  17. package/cli/gate.mjs +1002 -209
  18. package/cli/history.mjs +556 -0
  19. package/cli/hook.mjs +266 -55
  20. package/cli/hubcommit.mjs +6 -17
  21. package/cli/index-command.mjs +87 -0
  22. package/cli/ledger.mjs +57 -7
  23. package/cli/lib.mjs +184 -18
  24. package/cli/manifest.mjs +367 -56
  25. package/cli/migrate.mjs +726 -53
  26. package/cli/mode.mjs +170 -0
  27. package/cli/next.mjs +349 -90
  28. package/cli/openpr.mjs +191 -39
  29. package/cli/people.mjs +654 -0
  30. package/cli/plan.mjs +417 -132
  31. package/cli/platform.mjs +110 -129
  32. package/cli/product-index.mjs +287 -0
  33. package/cli/protection.mjs +706 -0
  34. package/cli/reconcile.mjs +38 -12
  35. package/cli/repo-publish.mjs +24 -26
  36. package/cli/repo.mjs +23 -14
  37. package/cli/report.mjs +21 -15
  38. package/cli/review.mjs +24 -27
  39. package/cli/riskmap-command.mjs +289 -0
  40. package/cli/riskmap.mjs +373 -0
  41. package/cli/setup.mjs +139 -287
  42. package/cli/ship.mjs +7 -6
  43. package/cli/skill.mjs +180 -0
  44. package/cli/skip.mjs +211 -30
  45. package/cli/thread.mjs +42 -17
  46. package/cli/tidy.mjs +20 -20
  47. package/cli/update-commit.mjs +22 -22
  48. package/cli/usage.mjs +115 -109
  49. package/package.json +3 -3
  50. package/skills/sdlc/config.yaml +166 -87
  51. package/skills/sdlc/module-help.csv +35 -35
  52. package/skills/yad-analysis/SKILL.md +125 -65
  53. package/skills/yad-architecture/SKILL.md +34 -23
  54. package/skills/yad-architecture/references/contract-format.md +10 -8
  55. package/skills/yad-backfill/SKILL.md +14 -8
  56. package/skills/yad-backfill/references/backfill.md +1 -1
  57. package/skills/yad-change/SKILL.md +127 -52
  58. package/skills/yad-change/references/triage.md +42 -28
  59. package/skills/yad-checks/SKILL.md +89 -45
  60. package/skills/yad-checks/references/check-gates.md +315 -92
  61. package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
  62. package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
  63. package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
  64. package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
  65. package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
  66. package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
  67. package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
  68. package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
  69. package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
  70. package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
  71. package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
  72. package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
  73. package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
  74. package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
  75. package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
  76. package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
  77. package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
  78. package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
  79. package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
  80. package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
  81. package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
  82. package/skills/yad-commit/SKILL.md +6 -6
  83. package/skills/yad-connect-design/SKILL.md +6 -6
  84. package/skills/yad-connect-design/references/design-context.md +1 -1
  85. package/skills/yad-connect-design/references/design-registry.md +2 -2
  86. package/skills/yad-connect-docs/SKILL.md +12 -12
  87. package/skills/yad-connect-docs/references/docs-registry.md +1 -1
  88. package/skills/yad-connect-learning/SKILL.md +5 -5
  89. package/skills/yad-connect-learning/references/learning-registry.md +2 -2
  90. package/skills/yad-connect-repos/SKILL.md +92 -54
  91. package/skills/yad-connect-repos/references/code-context.md +6 -6
  92. package/skills/yad-connect-repos/references/hub-config.md +68 -58
  93. package/skills/yad-connect-repos/references/repos-registry.md +10 -9
  94. package/skills/yad-connect-repos/references/risk-map.md +81 -0
  95. package/skills/yad-connect-testing/SKILL.md +6 -6
  96. package/skills/yad-connect-testing/references/testing-context.md +3 -4
  97. package/skills/yad-connect-testing/references/testing-registry.md +2 -2
  98. package/skills/yad-defects/SKILL.md +8 -8
  99. package/skills/yad-discovery/SKILL.md +130 -94
  100. package/skills/yad-discovery/references/discovery-schema.md +23 -7
  101. package/skills/yad-discovery/references/foundation-schema.md +374 -0
  102. package/skills/yad-docs/SKILL.md +16 -11
  103. package/skills/yad-docs/references/data-mapping.md +9 -7
  104. package/skills/yad-docs/templates/app/package-lock.json +3 -3
  105. package/skills/yad-docs-overview/SKILL.md +32 -17
  106. package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
  107. package/skills/yad-docs-sync/SKILL.md +10 -5
  108. package/skills/yad-docs-sync/references/staleness.md +8 -7
  109. package/skills/yad-engineer-review/SKILL.md +88 -24
  110. package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
  111. package/skills/yad-epic/SKILL.md +178 -100
  112. package/skills/yad-epic/references/state-schema.md +626 -117
  113. package/skills/yad-hub-bridge/SKILL.md +66 -48
  114. package/skills/yad-hub-bridge/references/bridge.md +110 -83
  115. package/skills/yad-hub-bridge/references/login-roster.md +163 -70
  116. package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
  117. package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
  118. package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
  119. package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
  120. package/skills/yad-implement/SKILL.md +29 -15
  121. package/skills/yad-implement/references/implement-conventions.md +2 -2
  122. package/skills/yad-learn/SKILL.md +9 -9
  123. package/skills/yad-learn/references/learning-state.md +2 -2
  124. package/skills/yad-open-pr/SKILL.md +64 -29
  125. package/skills/yad-pair-review/SKILL.md +18 -16
  126. package/skills/yad-pair-review/references/session-state.md +4 -4
  127. package/skills/yad-pr-template/SKILL.md +48 -27
  128. package/skills/yad-pr-template/references/risk-routing.md +97 -24
  129. package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
  130. package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
  131. package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
  132. package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
  133. package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
  134. package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
  135. package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
  136. package/skills/yad-reconcile/SKILL.md +3 -3
  137. package/skills/yad-report/SKILL.md +5 -5
  138. package/skills/yad-review-companion/SKILL.md +12 -9
  139. package/skills/yad-review-gate/SKILL.md +198 -79
  140. package/skills/yad-review-gate/references/gating.md +230 -54
  141. package/skills/yad-run/SKILL.md +86 -56
  142. package/skills/yad-run/references/run-loop.md +67 -45
  143. package/skills/yad-ship/SKILL.md +18 -14
  144. package/skills/yad-spec/SKILL.md +31 -17
  145. package/skills/yad-spec/references/spec-handoff.md +17 -5
  146. package/skills/yad-status/SKILL.md +114 -56
  147. package/skills/yad-stories/SKILL.md +42 -27
  148. package/skills/yad-stories/references/story-schema.md +10 -9
  149. package/skills/yad-stub/SKILL.md +59 -48
  150. package/skills/yad-sync-repos/SKILL.md +3 -3
  151. package/skills/yad-test-cases/SKILL.md +37 -30
  152. package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
  153. package/skills/yad-timeline/SKILL.md +8 -7
  154. package/skills/yad-ui/SKILL.md +46 -25
  155. package/cli/roster.mjs +0 -164
  156. 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
- // ---- roster role model (per-scope map, with legacy back-compat) ---------------------------------
46
- // A roster entry's roles live in a per-scope map: `roles: { hub: ["owner","reviewer"], <repo>: [...] }`.
47
- // `rolesForScope` normalizes the three shapes a roster entry can take on disk:
48
- // 1. new object map — `entry.roles = { hub: [...], backend: [...] }`
49
- // 2. flat array variant — `entry.roles = ["owner","reviewer"]` (treated as hub roles)
50
- // 3. legacy single role — `entry.role = "owner"` (a hub role; pre per-scope schema)
51
- // The legacy `repos.json` `domain_owner` field is handled separately by resolveLogin's fallback.
52
- export function rolesForScope(entry, scope) {
53
- if (!entry) return [];
54
- const r = entry.roles;
55
- if (r && typeof r === 'object' && !Array.isArray(r)) return Array.isArray(r[scope]) ? r[scope] : [];
56
- if (Array.isArray(r)) return scope === 'hub' ? r : [];
57
- if (typeof entry.role === 'string' && entry.role) return scope === 'hub' ? [entry.role] : [];
58
- return [];
59
- }
60
-
61
- // True when the entry holds any of `wanted` roles across any of `scopes`.
62
- export function hasAnyRole(entry, scopes = [], wanted = []) {
63
- for (const scope of scopes) {
64
- for (const role of rolesForScope(entry, scope)) {
65
- if (wanted.includes(role)) return true;
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 || !has(cli) || !login) return { ok: false, exists: false, checked: false };
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 = run('gh', ['api', `users/${login}`]);
116
- return { ok: r.ok, exists: r.ok, checked: true };
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
- // gitlab: users?username=<login> returns an array; empty array => no such user.
119
- const r = run('glab', ['api', `users?username=${encodeURIComponent(login)}`]);
120
- if (!r.ok) return { ok: false, exists: false, checked: true };
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
- // ---- login -> yad identity (roster + derived domain-owner) -------------------------------------
127
- // Returns the records this login's APPROVED review contributes. Roles are read from the per-scope
128
- // map: hub roles plus, for each touched domain, that repo's scoped roles (domain-owner carries the
129
- // `domain` tag). The legacy `repos.json` `domain_owner === name` mapping is kept as a fallback so
130
- // pre per-scope projects still resolve domain owners.
131
- export function resolveLogin(login, roster = [], repos = [], touchedDomains = []) {
132
- const entry = roster.find((r) => r.login === login);
133
- if (!entry) return [{ name: login, role: 'reviewer', unverified: true }];
134
- const records = [];
135
- const push = (rec) => {
136
- if (!records.some((x) => x.name === rec.name && x.role === rec.role && x.domain === rec.domain)) records.push(rec);
137
- };
138
- for (const role of rolesForScope(entry, 'hub')) push({ name: entry.name, role });
139
- for (const d of touchedDomains) {
140
- for (const role of rolesForScope(entry, d)) {
141
- push(role === 'domain-owner' ? { name: entry.name, role, domain: d } : { name: entry.name, role });
142
- }
143
- // Legacy fallback: a repo whose domain_owner / domain_owners[] includes this name confers
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
- // An identity-only entry (no roles map and no legacy `role`) still contributes a base reviewer
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
- export function mapApprovers(reviews = [], { roster, repos, touchedDomains, headOid } = {}) {
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
- for (const rec of resolveLogin(r.login, roster, repos, touchedDomains)) {
179
- out.push({ ...rec, submittedAt: r.submittedAt || null, engagement });
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 = run('gh', ['pr', 'view', String(n), '--json', 'state,mergedAt,headRefOid'], { cwd });
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 = run('gh', ['repo', 'view', '--json', 'owner,name'], { cwd });
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 = run('gh', args, { cwd });
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 = run('gh', args, { cwd });
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 = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
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 = run('glab', ['mr', 'view', String(n), '-F', 'json'], { cwd });
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 = run('glab', ['api', `projects/:id/merge_requests/${mr.iid}/approvals`], { cwd });
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 = run('glab', ['api', `projects/:id/merge_requests/${mr.iid}/discussions`], { cwd });
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
- const reviews = approvedBy.map((a) => ({ login: a.user?.username, state: 'APPROVED', body: engagementByUser.get(a.user?.username) }));
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
- if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
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 bridge
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 product hub itself
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 (always set, so the PR is owned by whoever pushed it);
471
- // `reviewers` = the scope's reviewers + domain-owners (computed by reviewersForScopes). On GitHub an
472
- // empty assignee list falls back to `@me` so the opener still self-assigns even without a roster.
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 (owner + reviewer rule): ${ats} — please review and approve/comment on this MR (this drives the gate).`], { cwd: opts.cwd });
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