yadflow 3.0.0 → 3.1.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 +9 -9
- package/bin/yad.mjs +30 -5
- package/cli/companion.mjs +61 -0
- package/cli/doctor.mjs +11 -0
- package/cli/epic-state.mjs +13 -3
- package/cli/gate.mjs +87 -5
- package/cli/manifest.mjs +1 -0
- package/cli/openpr.mjs +5 -1
- package/cli/plan.mjs +10 -4
- package/cli/platform.mjs +149 -23
- package/cli/reconcile.mjs +3 -3
- package/cli/review.mjs +145 -0
- package/package.json +1 -1
- package/skills/sdlc/config.yaml +8 -0
- package/skills/sdlc/module-help.csv +1 -0
- package/skills/yad-connect-repos/references/hub-config.md +10 -0
- package/skills/yad-docs-overview/references/pipeline-model.md +1 -0
- package/skills/yad-engineer-review/SKILL.md +13 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +15 -2
- package/skills/yad-hub-bridge/references/bridge.md +34 -0
- package/skills/yad-open-pr/SKILL.md +10 -0
- package/skills/yad-pr-template/templates/checks/pr-template.sh +9 -0
- package/skills/yad-review-companion/SKILL.md +89 -0
- package/skills/yad-review-gate/SKILL.md +5 -1
- package/skills/yad-review-gate/references/gating.md +18 -0
package/cli/platform.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// no tokens are stored. Pure mapping fns (resolveLogin/mapApprovers) are exported for unit tests;
|
|
4
4
|
// readPr is injectable so the gate can be tested with a fake.
|
|
5
5
|
import { run, has } from './lib.mjs';
|
|
6
|
+
import { parseEngagement } from './companion.mjs';
|
|
6
7
|
|
|
7
8
|
// github | gitlab | null, from a repo/remote.
|
|
8
9
|
export function detectPlatform(remoteUrl = '') {
|
|
@@ -68,11 +69,22 @@ export function hasAnyRole(entry, scopes = [], wanted = []) {
|
|
|
68
69
|
|
|
69
70
|
// Platform logins to auto-request as reviewers for the given scopes: everyone holding a `reviewer`
|
|
70
71
|
// or `domain-owner` role in any scope, minus `excludeLogin` (you don't review your own PR), deduped.
|
|
71
|
-
|
|
72
|
+
// `repos` (the registry) is consulted so a repo whose domain ownership lives ONLY in the legacy
|
|
73
|
+
// `repos.json` `domain_owner`/`domain_owners` field — not the roster roles map — is still requested
|
|
74
|
+
// as a reviewer for any scope that is its repo name. Without this the read side credits that login as
|
|
75
|
+
// a domain-owner (resolveLogin's legacy fallback) but the open side never asks them, so an escalated
|
|
76
|
+
// gate becomes structurally unsatisfiable through platform routing (BUG-1).
|
|
77
|
+
export function reviewersForScopes(roster = [], scopes = [], { excludeLogin = null, repos = [] } = {}) {
|
|
72
78
|
const out = [];
|
|
79
|
+
const add = (login) => { if (login && login !== excludeLogin && !out.includes(login)) out.push(login); };
|
|
73
80
|
for (const entry of roster) {
|
|
74
|
-
if (
|
|
75
|
-
|
|
81
|
+
if (hasAnyRole(entry, scopes, ['reviewer', 'domain-owner'])) add(entry.login);
|
|
82
|
+
}
|
|
83
|
+
for (const scope of scopes) {
|
|
84
|
+
const repo = repos.find((r) => r.name === scope);
|
|
85
|
+
if (!repo) continue;
|
|
86
|
+
const names = repo.domain_owners || (repo.domain_owner ? [repo.domain_owner] : []);
|
|
87
|
+
for (const name of names) add(roster.find((r) => r.name === name)?.login);
|
|
76
88
|
}
|
|
77
89
|
return out;
|
|
78
90
|
}
|
|
@@ -127,8 +139,11 @@ export function resolveLogin(login, roster = [], repos = [], touchedDomains = []
|
|
|
127
139
|
for (const role of rolesForScope(entry, d)) {
|
|
128
140
|
push(role === 'domain-owner' ? { name: entry.name, role, domain: d } : { name: entry.name, role });
|
|
129
141
|
}
|
|
130
|
-
// Legacy fallback: a repo whose domain_owner
|
|
131
|
-
|
|
142
|
+
// Legacy fallback: a repo whose domain_owner / domain_owners[] includes this name confers
|
|
143
|
+
// domain-owner for that domain. Both spellings are honored — symmetric with reviewersForScopes,
|
|
144
|
+
// which REQUESTS from both, so a person routed as a domain owner is also credited as one.
|
|
145
|
+
const legacy = repos.find((repo) => repo.name === d
|
|
146
|
+
&& (repo.domain_owner === entry.name || (Array.isArray(repo.domain_owners) && repo.domain_owners.includes(entry.name))));
|
|
132
147
|
if (legacy) push({ name: entry.name, role: 'domain-owner', domain: d });
|
|
133
148
|
}
|
|
134
149
|
// An identity-only entry (no roles map and no legacy `role`) still contributes a base reviewer
|
|
@@ -156,8 +171,11 @@ export function mapApprovers(reviews = [], { roster, repos, touchedDomains, head
|
|
|
156
171
|
// commits" setting.
|
|
157
172
|
if (r.commit === null) continue;
|
|
158
173
|
if (headOid && r.commit !== undefined && r.commit !== headOid) continue;
|
|
174
|
+
// engagement rides in the APPROVE review body (`<!-- yad:engagement verified -->`); a bare UI
|
|
175
|
+
// click has no marker → 'none'. Gameable by design (it makes review quality visible, not provable).
|
|
176
|
+
const engagement = parseEngagement(r.body);
|
|
159
177
|
for (const rec of resolveLogin(r.login, roster, repos, touchedDomains)) {
|
|
160
|
-
out.push({ ...rec, submittedAt: r.submittedAt || null });
|
|
178
|
+
out.push({ ...rec, submittedAt: r.submittedAt || null, engagement });
|
|
161
179
|
}
|
|
162
180
|
}
|
|
163
181
|
return out;
|
|
@@ -182,7 +200,7 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
182
200
|
// latestReviews` does not expose the commit, so read it via GraphQL. Paginate so a PR with >100
|
|
183
201
|
// reviewers never silently omits one; any page failure aborts to the commitless fallback below,
|
|
184
202
|
// which fails closed rather than advancing on a partial read.
|
|
185
|
-
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 commit{oid}}}}}}`;
|
|
203
|
+
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}}}}}}`;
|
|
186
204
|
let rcursor = null;
|
|
187
205
|
reviewsOk = true;
|
|
188
206
|
for (let guard = 0; guard < 50; guard++) {
|
|
@@ -192,7 +210,7 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
192
210
|
if (!rg.ok) { reviewsOk = false; reviews = []; break; }
|
|
193
211
|
const conn = JSON.parse(rg.stdout)?.data?.repository?.pullRequest?.latestReviews;
|
|
194
212
|
for (const x of conn?.nodes || []) {
|
|
195
|
-
reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: x.commit?.oid || null });
|
|
213
|
+
reviews.push({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, body: x.body, commit: x.commit?.oid || null });
|
|
196
214
|
}
|
|
197
215
|
if (!conn?.pageInfo?.hasNextPage) break;
|
|
198
216
|
rcursor = conn.pageInfo.endCursor;
|
|
@@ -224,7 +242,7 @@ function readPrGitHub(n, { cwd } = {}) {
|
|
|
224
242
|
if (!reviewsOk) {
|
|
225
243
|
const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
|
|
226
244
|
if (rev.ok) reviews = (JSON.parse(rev.stdout).latestReviews || [])
|
|
227
|
-
.map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, commit: null }));
|
|
245
|
+
.map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt, body: x.body, commit: null }));
|
|
228
246
|
}
|
|
229
247
|
return {
|
|
230
248
|
ok: true,
|
|
@@ -243,19 +261,26 @@ function readPrGitLab(n, { cwd } = {}) {
|
|
|
243
261
|
const mr = JSON.parse(view.stdout);
|
|
244
262
|
const approvals = run('glab', ['api', `projects/:id/merge_requests/${mr.iid}/approvals`], { cwd });
|
|
245
263
|
const approvedBy = approvals.ok ? (JSON.parse(approvals.stdout).approved_by || []) : [];
|
|
246
|
-
const reviews = approvedBy.map((a) => ({ login: a.user?.username, state: 'APPROVED' }));
|
|
247
264
|
const disc = run('glab', ['api', `projects/:id/merge_requests/${mr.iid}/discussions`], { cwd });
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}));
|
|
265
|
+
const discussions = disc.ok ? (JSON.parse(disc.stdout) || []) : [];
|
|
266
|
+
// A GitLab approval carries no body, so the companion's engagement marker rides in a NOTE the
|
|
267
|
+
// reviewer posts; attach the latest engagement-bearing note per username to their approval so
|
|
268
|
+
// mapApprovers reads engagement uniformly with GitHub.
|
|
269
|
+
const engagementByUser = new Map();
|
|
270
|
+
for (const d of discussions) {
|
|
271
|
+
for (const nt of d.notes || []) {
|
|
272
|
+
if (/<!--\s*yad:engagement\s+\w+\s*-->/i.test(nt.body || '')) engagementByUser.set(nt.author?.username, nt.body);
|
|
273
|
+
}
|
|
258
274
|
}
|
|
275
|
+
const reviews = approvedBy.map((a) => ({ login: a.user?.username, state: 'APPROVED', body: engagementByUser.get(a.user?.username) }));
|
|
276
|
+
const threads = discussions
|
|
277
|
+
.filter((d) => d.notes?.some((nt) => nt.resolvable))
|
|
278
|
+
.map((d, i) => ({
|
|
279
|
+
id: d.id || `disc-${i}`,
|
|
280
|
+
resolved: !!d.notes.find((nt) => nt.resolvable)?.resolved,
|
|
281
|
+
login: d.notes[0]?.author?.username,
|
|
282
|
+
body: d.notes[0]?.body,
|
|
283
|
+
}));
|
|
259
284
|
return {
|
|
260
285
|
ok: true,
|
|
261
286
|
state: mr.state,
|
|
@@ -281,7 +306,9 @@ export function readPr(platform, n, opts = {}) {
|
|
|
281
306
|
export function buildPrArgs(platform, { title, body, base, head, reviewers = [], labels = [], assignees = [] } = {}) {
|
|
282
307
|
if (platform === 'gitlab') {
|
|
283
308
|
const args = ['mr', 'create', '--title', title, '--description', body, '--target-branch', base, '--source-branch', head, '--yes'];
|
|
284
|
-
|
|
309
|
+
// A Free/Core GitLab MR carries a SINGLE reviewer field (multiple reviewers is a Premium feature),
|
|
310
|
+
// so only the first reviewer goes in the field; createPr @-mentions the rest in a note (BUG-2).
|
|
311
|
+
if (reviewers.length) args.push('--reviewer', reviewers[0]);
|
|
285
312
|
if (assignees.length) args.push('--assignee', assignees.join(','));
|
|
286
313
|
if (labels.length) args.push('--label', labels.join(','));
|
|
287
314
|
return args;
|
|
@@ -293,8 +320,107 @@ export function buildPrArgs(platform, { title, body, base, head, reviewers = [],
|
|
|
293
320
|
return args;
|
|
294
321
|
}
|
|
295
322
|
|
|
323
|
+
// Number/IID from a PR/MR URL (…/pull/123, …/pulls/123, …/-/merge_requests/45). Anchored to the
|
|
324
|
+
// PR/MR path segment so a numeric group/org/repo earlier in the URL is never mistaken for it; falls
|
|
325
|
+
// back to a trailing number for non-standard URLs. null when unparsable.
|
|
326
|
+
export function prNumberFromUrl(url = '') {
|
|
327
|
+
const s = String(url);
|
|
328
|
+
const m = s.match(/\/(?:pull|pulls|merge_requests)\/(\d+)/);
|
|
329
|
+
if (m) return m[1];
|
|
330
|
+
const tail = s.match(/\/(\d+)(?:[/?#]|$)/);
|
|
331
|
+
return tail ? tail[1] : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Create a PR/MR and route the required reviewers, resiliently, on both platforms:
|
|
335
|
+
// GitHub — create WITHOUT reviewers, then add each via `gh pr edit --add-reviewer`. A bad/
|
|
336
|
+
// non-collaborator login then WARNS (dropped) instead of aborting the whole create (BUG-4).
|
|
337
|
+
// GitLab — assign the first reviewer to the MR field; @-mention the remaining required reviewers in
|
|
338
|
+
// an MR note so they are still notified/routed despite the single-reviewer-field cap (BUG-2).
|
|
339
|
+
// Returns { ok, url, reviewers (assigned), mentioned, dropped }.
|
|
340
|
+
// ---- post back to the platform (companion write helpers) ----------------------------------------
|
|
341
|
+
// The reviewer/companion writes to the PLATFORM (PR/MR body + comments + approval), never the ledger —
|
|
342
|
+
// so the ledger-guard check is never tripped. Each returns { ok, ... } and never throws.
|
|
343
|
+
|
|
344
|
+
// Current PR/MR description (for idempotent trailer-block upsert). null when unreadable.
|
|
345
|
+
export function getPrBody(platform, n, { cwd } = {}) {
|
|
346
|
+
if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
347
|
+
if (platform === 'github') {
|
|
348
|
+
const r = run('gh', ['pr', 'view', String(n), '--json', 'body', '-q', '.body'], { cwd });
|
|
349
|
+
return { ok: r.ok, body: r.ok ? r.stdout : '', reason: r.stderr };
|
|
350
|
+
}
|
|
351
|
+
const r = run('glab', ['mr', 'view', String(n), '-F', 'json'], { cwd });
|
|
352
|
+
if (!r.ok) return { ok: false, body: '', reason: r.stderr };
|
|
353
|
+
try { return { ok: true, body: JSON.parse(r.stdout).description || '' }; } catch { return { ok: false, body: '', reason: 'unparseable mr json' }; }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Replace the PR/MR description (used to upsert the trailer block).
|
|
357
|
+
export function editPrBody(platform, n, body, { cwd } = {}) {
|
|
358
|
+
if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
359
|
+
const r = platform === 'github'
|
|
360
|
+
? run('gh', ['pr', 'edit', String(n), '--body', body], { cwd })
|
|
361
|
+
: run('glab', ['mr', 'update', String(n), '--description', body], { cwd });
|
|
362
|
+
return { ok: r.ok, reason: r.stderr };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Post a top-level comment/note (companion card deck, chat log, nudge — pass a noBlock()-tagged body).
|
|
366
|
+
export function postComment(platform, n, body, { cwd } = {}) {
|
|
367
|
+
if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
368
|
+
const r = platform === 'github'
|
|
369
|
+
? run('gh', ['pr', 'comment', String(n), '--body', body], { cwd })
|
|
370
|
+
: run('glab', ['mr', 'note', String(n), '-m', body], { cwd });
|
|
371
|
+
return { ok: r.ok, reason: r.stderr };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Submit an APPROVE carrying the engagement marker. On GitLab an approval has no body, so the marker
|
|
375
|
+
// is posted as a note (readPrGitLab attaches it to the approval); on GitHub it rides in the review body.
|
|
376
|
+
export function submitApproval(platform, n, body = '', { cwd } = {}) {
|
|
377
|
+
if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
378
|
+
if (platform === 'github') {
|
|
379
|
+
const r = run('gh', ['pr', 'review', String(n), '--approve', '--body', body], { cwd });
|
|
380
|
+
return { ok: r.ok, reason: r.stderr };
|
|
381
|
+
}
|
|
382
|
+
const a = run('glab', ['mr', 'approve', String(n)], { cwd });
|
|
383
|
+
if (!a.ok) return { ok: false, reason: a.stderr };
|
|
384
|
+
if (body) {
|
|
385
|
+
// The engagement marker rides in this note (GitLab approvals carry no body). If it fails to post,
|
|
386
|
+
// the approval landed but the engagement signal is lost — report failure so the caller can retry.
|
|
387
|
+
const note = run('glab', ['mr', 'note', String(n), '-m', body], { cwd });
|
|
388
|
+
if (!note.ok) return { ok: false, reason: `approved, but failed to post the engagement note: ${note.stderr || 'unknown'}` };
|
|
389
|
+
}
|
|
390
|
+
return { ok: true };
|
|
391
|
+
}
|
|
392
|
+
|
|
296
393
|
export function createPr(platform, opts = {}) {
|
|
297
394
|
if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
298
|
-
const
|
|
299
|
-
|
|
395
|
+
const reviewers = opts.reviewers || [];
|
|
396
|
+
if (platform === 'github') {
|
|
397
|
+
const r = run('gh', buildPrArgs('github', { ...opts, reviewers: [] }), { cwd: opts.cwd });
|
|
398
|
+
if (!r.ok) return { ok: false, reason: r.stderr };
|
|
399
|
+
const url = r.stdout.split('\n').pop();
|
|
400
|
+
const number = prNumberFromUrl(url);
|
|
401
|
+
const added = []; const dropped = [];
|
|
402
|
+
if (number) {
|
|
403
|
+
for (const rv of reviewers) {
|
|
404
|
+
(run('gh', ['pr', 'edit', number, '--add-reviewer', rv], { cwd: opts.cwd }).ok ? added : dropped).push(rv);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { ok: true, url, reviewers: added, mentioned: [], dropped };
|
|
408
|
+
}
|
|
409
|
+
// gitlab
|
|
410
|
+
const r = run('glab', buildPrArgs('gitlab', opts), { cwd: opts.cwd });
|
|
411
|
+
if (!r.ok) return { ok: false, reason: r.stderr };
|
|
412
|
+
const url = r.stdout.split('\n').pop();
|
|
413
|
+
const iid = prNumberFromUrl(url);
|
|
414
|
+
const rest = reviewers.slice(1);
|
|
415
|
+
// Only report a reviewer as `mentioned` if the @-mention note actually posted; otherwise they were
|
|
416
|
+
// neither assigned (single-field cap) nor notified — surface them as `dropped` so the caller warns.
|
|
417
|
+
let mentioned = []; let dropped = [];
|
|
418
|
+
if (rest.length && iid) {
|
|
419
|
+
const ats = rest.map((m) => `@${m}`).join(' ');
|
|
420
|
+
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 });
|
|
421
|
+
if (note.ok) mentioned = rest; else dropped = rest;
|
|
422
|
+
} else if (rest.length) {
|
|
423
|
+
dropped = rest; // could not parse the IID to post the note
|
|
424
|
+
}
|
|
425
|
+
return { ok: true, url, reviewers: reviewers.slice(0, 1), mentioned, dropped };
|
|
300
426
|
}
|
package/cli/reconcile.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from './plan.mjs';
|
|
13
13
|
import { gitHead, packRepo } from './setup.mjs';
|
|
14
14
|
|
|
15
|
-
const MARK = { missing: c.red('missing'), outdated: c.yellow('outdated'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
|
|
15
|
+
const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
|
|
16
16
|
|
|
17
17
|
export async function reconcile(root, { fix = false, scope = 'all', force = false } = {}) {
|
|
18
18
|
log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
|
|
@@ -51,7 +51,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
51
51
|
if (!byScope.has(a.scope)) byScope.set(a.scope, []);
|
|
52
52
|
byScope.get(a.scope).push(a);
|
|
53
53
|
}
|
|
54
|
-
const counts = { missing: 0, outdated: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
|
|
54
|
+
const counts = { missing: 0, new: 0, outdated: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
|
|
55
55
|
for (const [scopeName, items] of byScope) {
|
|
56
56
|
const notOk = items.filter((i) => i.status !== 'ok');
|
|
57
57
|
items.forEach((i) => counts[i.status]++);
|
|
@@ -65,7 +65,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
65
65
|
a.status !== 'ok' && (scope === 'all' ? true : a.status !== 'missing'),
|
|
66
66
|
);
|
|
67
67
|
log('');
|
|
68
|
-
log(c.dim(`summary: ${counts.missing} missing, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
|
|
68
|
+
log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
|
|
69
69
|
|
|
70
70
|
if (!fix) {
|
|
71
71
|
if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
|
package/cli/review.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// `yad review trailer|context|nudge|reconcile` — the BACK-HALF Review Companion + bridge for code
|
|
2
|
+
// PR/MRs (the analogue of `yad gate …` for the front half). The fun process (trailer/cards/chat/nudge)
|
|
3
|
+
// makes the engineer review easy and visible; the bridge process (reconcile) maps the code PR's review
|
|
4
|
+
// state — including the engagement signal — into the build ledger (build-log.json) at merge.
|
|
5
|
+
//
|
|
6
|
+
// The CLI never calls an LLM: the skill (yad-review-companion / yad-engineer-review) generates the
|
|
7
|
+
// trailer/cards/chat text and posts it via these primitives, all to the PLATFORM (never a ledger file).
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { log, ok, info, warn, fail, run, readJSON, writeJSON } from './lib.mjs';
|
|
10
|
+
import { PROJECT_FILES, epicFiles } from './manifest.mjs';
|
|
11
|
+
import { epicRoot } from './epic-state.mjs';
|
|
12
|
+
import {
|
|
13
|
+
detectPlatform, readPr, mapApprovers, getPrBody, editPrBody, postComment, prNumberFromUrl,
|
|
14
|
+
} from './platform.mjs';
|
|
15
|
+
import { upsertTrailerBlock, nudgeMessage, parseEngagement } from './companion.mjs';
|
|
16
|
+
|
|
17
|
+
const NUDGE_CMD = 'yad review chat';
|
|
18
|
+
|
|
19
|
+
// Resolve the target code repo: --repo <name> from the registry (platform + path + roles), else cwd.
|
|
20
|
+
// An explicit --repo that is NOT in the registry is an error — never silently fall through to cwd (that
|
|
21
|
+
// would operate on the wrong repo). Returns { error } in that case for the caller to surface.
|
|
22
|
+
function resolveRepo(root, { repo, dir }) {
|
|
23
|
+
if (repo) {
|
|
24
|
+
const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
25
|
+
const found = (reg.repos || []).find((r) => r.name === repo);
|
|
26
|
+
if (!found) return { error: `repo '${repo}' is not in .sdlc/repos.json — connect it first (yad-connect-repos)` };
|
|
27
|
+
return { repoRoot: path.resolve(root, found.path), meta: found };
|
|
28
|
+
}
|
|
29
|
+
return { repoRoot: path.resolve(root, dir || '.'), meta: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function platformOf(root, repoRoot, meta) {
|
|
33
|
+
if (meta?.platform) return meta.platform;
|
|
34
|
+
const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
|
|
35
|
+
return detectPlatform(remote) || readJSON(path.join(root, PROJECT_FILES.hubConfig), {}).platform || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `yad review context --repo <r> --pr <n>` — print the grounding bundle the companion uses to generate
|
|
39
|
+
// the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
|
|
40
|
+
export async function reviewContext(root, { repo, dir, pr } = {}) {
|
|
41
|
+
const rr = resolveRepo(root, { repo, dir });
|
|
42
|
+
if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
|
|
43
|
+
const { repoRoot, meta } = rr;
|
|
44
|
+
const platform = platformOf(root, repoRoot, meta);
|
|
45
|
+
const base = meta?.default_branch || 'main';
|
|
46
|
+
const bundle = {
|
|
47
|
+
repo: meta?.name || null,
|
|
48
|
+
repoRoot,
|
|
49
|
+
platform,
|
|
50
|
+
pr: pr || null,
|
|
51
|
+
base,
|
|
52
|
+
diffCmd: `git -C ${repoRoot} diff ${base}...HEAD`,
|
|
53
|
+
codeMap: meta?.name ? path.join(root, '.sdlc/code-context', meta.name, 'code-map.md') : null,
|
|
54
|
+
pack: meta?.name ? path.join(root, '.sdlc/code-context', meta.name, 'pack.md') : null,
|
|
55
|
+
markers: { trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->', engagementVerified: '<!-- yad:engagement verified -->' },
|
|
56
|
+
};
|
|
57
|
+
log(JSON.stringify(bundle, null, 2));
|
|
58
|
+
return bundle;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `yad review trailer --repo <r> --pr <n> --body <text>` — idempotently upsert the 60-sec briefing into
|
|
62
|
+
// the code PR/MR description (delimited block; safe to re-run after a push).
|
|
63
|
+
export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPrBody, editBody = editPrBody } = {}) {
|
|
64
|
+
if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
|
|
65
|
+
if (!body || !String(body).trim()) { fail('trailer body is required: `yad review trailer --repo <r> --pr <n> --body <text>`'); process.exitCode = 1; return; }
|
|
66
|
+
const rr = resolveRepo(root, { repo, dir });
|
|
67
|
+
if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
|
|
68
|
+
const { repoRoot, meta } = rr;
|
|
69
|
+
const platform = platformOf(root, repoRoot, meta);
|
|
70
|
+
if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
|
|
71
|
+
const cur = getBody(platform, pr, { cwd: repoRoot });
|
|
72
|
+
if (!cur.ok) { fail(`could not read PR #${pr}: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
73
|
+
const r = editBody(platform, pr, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: repoRoot });
|
|
74
|
+
if (!r.ok) { fail(`could not update PR #${pr}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
75
|
+
ok(`trailer posted to ${platform === 'gitlab' ? 'MR' : 'PR'} #${pr}`);
|
|
76
|
+
return { number: pr };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `yad review nudge --repo <r> --pr <n>` — friendly public @-mention on a bare approve (engagement none)
|
|
80
|
+
// of a code PR. A platform comment (carries the noblock marker so it never blocks); call once per PR.
|
|
81
|
+
export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster = postComment } = {}) {
|
|
82
|
+
if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
|
|
83
|
+
const rr = resolveRepo(root, { repo, dir });
|
|
84
|
+
if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
|
|
85
|
+
const { repoRoot, meta } = rr;
|
|
86
|
+
const platform = platformOf(root, repoRoot, meta);
|
|
87
|
+
if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
|
|
88
|
+
const pull = reader(platform, pr, { cwd: repoRoot });
|
|
89
|
+
if (!pull.ok) { warn(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
|
|
90
|
+
let nudged = 0;
|
|
91
|
+
for (const rv of pull.reviews) {
|
|
92
|
+
if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login) continue;
|
|
93
|
+
if (poster(platform, pr, nudgeMessage(rv.login, NUDGE_CMD), { cwd: repoRoot }).ok) nudged++;
|
|
94
|
+
}
|
|
95
|
+
if (nudged) info(`nudged ${nudged} bare approval(s) — invited to run \`${NUDGE_CMD}\``);
|
|
96
|
+
else ok('no un-engaged approvals to nudge');
|
|
97
|
+
return { nudged };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `yad review reconcile --epic <id> --repo <r> --pr <n>` — the back-half BRIDGE: read the code PR's
|
|
101
|
+
// approvals (with the engagement signal) and stamp them onto the matching build-log.json ship record,
|
|
102
|
+
// so the build ledger reflects who actually engaged. The first CLI to write build-log.json. Matches the
|
|
103
|
+
// ship record by its `pr` field (url or number); if none exists yet, prints the engineer_review block
|
|
104
|
+
// for the engineer/CI to attach at ship time (we never fabricate a story/task).
|
|
105
|
+
export async function reviewReconcile(root, { epic, repo, dir, pr, reader = readPr } = {}) {
|
|
106
|
+
if (!epic) { fail('--epic <id> is required'); process.exitCode = 1; return; }
|
|
107
|
+
if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
|
|
108
|
+
const rr = resolveRepo(root, { repo, dir });
|
|
109
|
+
if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
|
|
110
|
+
const { repoRoot, meta } = rr;
|
|
111
|
+
const platform = platformOf(root, repoRoot, meta);
|
|
112
|
+
if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
|
|
113
|
+
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
|
|
114
|
+
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
115
|
+
const domain = meta?.name ? [meta.name] : [];
|
|
116
|
+
const pull = reader(platform, pr, { cwd: repoRoot });
|
|
117
|
+
if (!pull.ok) { fail(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
|
|
118
|
+
|
|
119
|
+
// Map platform approvals → engineer_review entries, deduped by (approver, role, domain), carrying the
|
|
120
|
+
// engagement signal read from the approve body/note.
|
|
121
|
+
const recs = mapApprovers(pull.reviews, { roster: hub.roster || [], repos: registry.repos || [], touchedDomains: domain, headOid: pull.headOid });
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
const engineerReview = [];
|
|
124
|
+
for (const r of recs) {
|
|
125
|
+
const key = `${r.name}|${r.role}|${r.domain || ''}`;
|
|
126
|
+
if (seen.has(key)) continue;
|
|
127
|
+
seen.add(key);
|
|
128
|
+
engineerReview.push({ approver: r.name, role: r.role, ...(r.domain ? { domain: r.domain } : {}), engagement: r.engagement === 'verified' ? 'verified' : 'none' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const file = epicFiles(epicRoot(root, epic)).buildLog;
|
|
132
|
+
const ledger = readJSON(file, null);
|
|
133
|
+
// Match by exact PR number — never substring (`--pr 5` must not match a ship recorded against #15).
|
|
134
|
+
const ship = ledger?.ships?.find((s) => s.pr != null
|
|
135
|
+
&& (String(s.pr) === String(pr) || prNumberFromUrl(s.pr) === String(pr)));
|
|
136
|
+
if (!ship) {
|
|
137
|
+
warn(`no build-log ship record matches PR #${pr} in ${epic} — attach this at ship time:`);
|
|
138
|
+
log(JSON.stringify({ engineer_review: engineerReview }, null, 2));
|
|
139
|
+
return { engineerReview, written: false };
|
|
140
|
+
}
|
|
141
|
+
ship.engineer_review = engineerReview;
|
|
142
|
+
writeJSON(file, ledger);
|
|
143
|
+
ok(`stamped engagement onto ${epic} ship ${ship.story || ''}${ship.task ? '/' + ship.task : ''} (PR #${pr})`);
|
|
144
|
+
return { engineerReview, written: true };
|
|
145
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 34 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
package/skills/sdlc/config.yaml
CHANGED
|
@@ -63,6 +63,14 @@ review_gate:
|
|
|
63
63
|
revoke_on: artifact-change # re-hash the artifact (contract surface for architecture); a changed
|
|
64
64
|
# hash drops the bound approvals so reviewers re-approve. NOT per-commit.
|
|
65
65
|
block_on_unresolved_comments: true # any unresolved thread / CHANGES_REQUESTED holds it in_review
|
|
66
|
+
# Review Companion engagement (yad-review-companion). Each approval records engagement: verified|none
|
|
67
|
+
# — verified when reviewed through the companion (trailer/cards/chat), none for a bare click. SOFT by
|
|
68
|
+
# default: both count, a bare approve still passes but draws a friendly public @-mention nudge so
|
|
69
|
+
# review quality is VISIBLE without blocking anyone ("visible, not impossible"). Flip to true to make
|
|
70
|
+
# only verified approvals count (the signal is gameable by design — it raises the cost of a rubber-
|
|
71
|
+
# stamp, it does not prove a human read the artifact). Persisted per-project in hub.json as
|
|
72
|
+
# `review.requireEngagement`; companion comments carry `<!-- yad:noblock -->` so they never block.
|
|
73
|
+
require_engagement: false
|
|
66
74
|
|
|
67
75
|
# Build half (Phase 3). Code repos are SEPARATE git repos (one .git each), not subfolders
|
|
68
76
|
# of the product repo — faithful to "per-repo specs in each code repo, contract singular in the
|
|
@@ -3,6 +3,7 @@ SDLC Workflow,yad-discovery,Project Discovery,DI,"Optional front-zero (once per
|
|
|
3
3
|
SDLC Workflow,yad-analysis,Author Analysis,AN,"Optional front state: with the analyst pressure-test a feature idea and write the discovery brief into analysis.md. Assigns the EP-<slug> ID and seeds .sdlc state (the chain that puts analysis before epic). If skipped, the epic step does this shaping inline. Never auto-advances.",,{idea: one-line feature idea},1-front,,yad-review-gate,false,epics/EP-<slug>/,analysis.md state.json
|
|
4
4
|
SDLC Workflow,yad-epic,Author Epic,AE,"Front state 1: shape an idea with analyst then pm into epic.md; assign EP-<slug> ID and seed .sdlc state. Never auto-advances.",,{idea: one-line feature idea},1-front,,yad-review-gate,true,epics/EP-<slug>/,epic.md state.json
|
|
5
5
|
SDLC Workflow,yad-review-gate,Team Review Gate,RG,"Reusable review+approve gate for all five reviews. Shares an artifact for review, records comments and approvals as files, enforces owner + 1 reviewer (escalates on contract/auth/payments; per-repo routing for stories), advances state only when approved.",,{artifact: file under the epic} {action: open|comment|approve|advance},1-front,,,true,epics/EP-<slug>/reviews/,reviews/*.md approvals.json state.json
|
|
6
|
+
SDLC Workflow,yad-review-companion,Review Companion,RC,"Fun, easy, transparent review companion for the review gates (front gate AND back-half code PR). Generates a 60-sec AI trailer of what changed and where the risk is, deals swipe-through review cards, and runs a grounded chat where a reviewer's questions become the review record — then records an engagement signal on the approval (verified vs none) and posts a friendly public @-mention nudge on a bare rubber-stamp. Companion comments carry a noblock marker so they never hold the gate. Soft by default (visible, not impossible); gates only when hub.review.requireEngagement. Never auto-advances.",,{epic: EP-<slug>} {artifact} | {repo} {pr} {action: trailer|cards|chat|nudge},1-front,,,false,epics/EP-<slug>/reviews/ | code PR,trailer/cards/chat (platform) approvals.json engagement
|
|
6
7
|
SDLC Workflow,yad-architecture,Author Architecture,AA,"Front state 3: with the architect author architecture.md and the locked contract.md; hash-lock the contract surface. Never auto-advances.",,{epic: EP-<slug>},1-front,yad-review-gate,yad-review-gate,true,epics/EP-<slug>/,architecture.md contract.md contract-lock.json state.json
|
|
7
8
|
SDLC Workflow,yad-ui,Author UI Design,AU,"Front state 5: with the ux-designer author ui-design.md and DESIGN.md, driving Impeccable slash-commands when installed. Never auto-advances.",,{epic: EP-<slug>},1-front,yad-review-gate,yad-review-gate,true,epics/EP-<slug>/,ui-design.md DESIGN.md state.json
|
|
8
9
|
SDLC Workflow,yad-stories,Author Stories,AS,"Front state 7: with the pm break the epic into repo-tagged stories with stable EP-<slug>-S0N IDs, one file each under stories/. Never auto-advances.",,{epic: EP-<slug>},1-front,yad-review-gate,yad-review-gate,true,epics/EP-<slug>/stories/,stories/EP-<slug>-S0N.md state.json
|
|
@@ -19,6 +19,7 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
|
|
|
19
19
|
"git_url": "https://github.com/abdelrahmannasr/yadflow.git",
|
|
20
20
|
"default_branch": "main",
|
|
21
21
|
"bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews
|
|
22
|
+
"review": { "requireEngagement": false }, // Review Companion: false (soft) counts bare approves but nudges; true counts only verified-engagement approvals
|
|
22
23
|
"detectedAt": "2026-06-08", // last detect-hub run (YYYY-MM-DD)
|
|
23
24
|
"roster": [
|
|
24
25
|
{ "login": "abdelrahmannasr", "name": "alice", "email": "alice@example.com",
|
|
@@ -76,6 +77,15 @@ as the registry). `detect-hub` upserts `hub.json` in place — it is idempotent
|
|
|
76
77
|
existing **file-only** flow with no error. The file ledger is the source of truth in both modes.
|
|
77
78
|
- The master switch `config.yaml` `hub.bridge: false` disables the bridge globally regardless of `hub.json`.
|
|
78
79
|
|
|
80
|
+
## Review Companion engagement (`review.requireEngagement`)
|
|
81
|
+
|
|
82
|
+
`review.requireEngagement` (default `false`) controls the [Review Companion](../../yad-review-companion/SKILL.md)
|
|
83
|
+
engagement gate. Each approval records `engagement: verified | none`. **Soft (`false`):** both count — a
|
|
84
|
+
bare approve still passes but draws a friendly public nudge, so review *quality* is visible without
|
|
85
|
+
blocking. **Strict (`true`):** the predicate counts only `verified` approvals. The signal is gameable by
|
|
86
|
+
design ("visible, not impossible") — it raises the cost of a rubber-stamp, it does not prove a human
|
|
87
|
+
read the artifact. Applies to both the front gate and the back-half engineer review.
|
|
88
|
+
|
|
79
89
|
## Git tracking
|
|
80
90
|
|
|
81
91
|
Commit `hub.json` — it is small, reviewable, and carries no secrets (logins and names only, never tokens).
|
|
@@ -56,6 +56,7 @@ The gated authoring chain + the reusable review gate (10 steps, or 12 with the o
|
|
|
56
56
|
| `yad-stories` | → `stories-review` (per-repo routing) | `stories/EP-<slug>-S0N.md` |
|
|
57
57
|
| `yad-test-cases` | → `test-cases-review` (parallel, non-blocking) | `test-cases.md`, `test-links.json` |
|
|
58
58
|
| `yad-review-gate` | the shared gate | `reviews/*.md`, `approvals.json`, `comments.json` |
|
|
59
|
+
| `yad-review-companion` | the fun/visible review layer (trailer/cards/chat + engagement) | `approvals.json` `engagement`, platform trailer/cards |
|
|
59
60
|
| `yad-hub-bridge` | the platform PR/MR bridge | `hub-prs.json` |
|
|
60
61
|
|
|
61
62
|
### Path: Build half (`phase: 3-build`)
|
|
@@ -39,11 +39,22 @@ a **second set of eyes, never the authority**: it cannot approve or merge. Where
|
|
|
39
39
|
(no remote), run an equivalent AI first-pass by hand and capture its notes. Record that the AI review
|
|
40
40
|
ran; surface its findings to the engineer. Do **not** treat AI approval as a gate.
|
|
41
41
|
|
|
42
|
+
Also run the **Review Companion** so the human review is easy and fun: post the 60-sec trailer
|
|
43
|
+
(`yad review trailer --repo <r> --pr <n> --body "<text>"`) and deal the swipe cards / open the grounded
|
|
44
|
+
chat from the bundle (`yad review context --repo <r> --pr <n>` → [yad-review-companion](../yad-review-companion/SKILL.md)).
|
|
45
|
+
Companion comments carry `<!-- yad:noblock -->` (history-only, never block); genuine concerns are posted
|
|
46
|
+
unflagged and block normally.
|
|
47
|
+
|
|
42
48
|
### Step 2 — `approve` (the engineer review — the human gate)
|
|
43
49
|
A human engineer reads the diff **against the spec** (`specs/<story>/`) and the acceptance criteria,
|
|
44
50
|
and records an approval. Determine the rule from the PR's Impact & Risk block (run
|
|
45
51
|
`../yad-pr-template/templates/checks/risk-route.sh` on the PR body): base, or escalated to a
|
|
46
52
|
domain-owner per touched domain. Record each approval; re-evaluate whether the rule is satisfied.
|
|
53
|
+
Record `engagement: verified` when the engineer reviewed through the companion (else `none` for a bare
|
|
54
|
+
approve); `yad review reconcile --epic <id> --repo <r> --pr <n>` stamps it onto the ship record from the
|
|
55
|
+
platform. Soft by default (both count; a bare approve draws `yad review nudge`); only gates when
|
|
56
|
+
`hub.review.requireEngagement: true`. The signal is gameable by design and sits **beside** the CI gates,
|
|
57
|
+
never above them.
|
|
47
58
|
Recording an approval does **not** ship — shipping is a separate, explicit step. Front-half discipline:
|
|
48
59
|
the gate talks only through files; refuse to treat AI review as a human approval.
|
|
49
60
|
|
|
@@ -55,8 +66,8 @@ engineer-review rule is satisfied (Step 2). Then:
|
|
|
55
66
|
```json
|
|
56
67
|
{ "story": "<story>", "task": "<task>", "repo": "<repo>", "branch": "feat/<story>-<task>-…",
|
|
57
68
|
"pr": "<url|#>", "mergeCommit": "<sha>", "gates": ["spec-link","contract-check","build-test-lint"],
|
|
58
|
-
"ai_review": "coderabbit (advisory)", "engineer_review": [{"approver":"<name>","role":"<role>","domain":"<opt>"}],
|
|
59
|
-
"risk": "<low|medium|high>", "shippedAt": "<YYYY-MM-DD>" }
|
|
69
|
+
"ai_review": "coderabbit (advisory)", "engineer_review": [{"approver":"<name>","role":"<role>","domain":"<opt>","engagement":"<verified|none>"}],
|
|
70
|
+
"companion": {"trailer":true,"cards":true,"chat":false}, "risk": "<low|medium|high>", "shippedAt": "<YYYY-MM-DD>" }
|
|
60
71
|
```
|
|
61
72
|
- **Update the story state** — when **every** task in `specs/<story>/tasks.md` has a ship record, set
|
|
62
73
|
the story frontmatter `status: shipped`; otherwise `status: in-build`. The chain
|
|
@@ -33,9 +33,10 @@ Append-only. One record per shipped task:
|
|
|
33
33
|
"gates": ["spec-link", "contract-check", "build-test-lint"],
|
|
34
34
|
"ai_review": "coderabbit (advisory)",
|
|
35
35
|
"engineer_review": [
|
|
36
|
-
{ "approver": "amelia", "role": "owner" },
|
|
37
|
-
{ "approver": "carol", "role": "reviewer" }
|
|
36
|
+
{ "approver": "amelia", "role": "owner", "engagement": "verified" },
|
|
37
|
+
{ "approver": "carol", "role": "reviewer", "engagement": "none" }
|
|
38
38
|
],
|
|
39
|
+
"companion": { "trailer": true, "cards": true, "chat": false },
|
|
39
40
|
"risk": "low",
|
|
40
41
|
"shippedAt": "2026-06-06"
|
|
41
42
|
}
|
|
@@ -46,6 +47,18 @@ Append-only. One record per shipped task:
|
|
|
46
47
|
This is the back-half analogue of the front half's `approvals.json` — files only, no hidden state, so a
|
|
47
48
|
future service can drive ship by writing the same records.
|
|
48
49
|
|
|
50
|
+
**Engagement (the Review Companion).** Each `engineer_review` entry carries `engagement: verified | none`
|
|
51
|
+
— `verified` when the engineer reviewed through the [companion](../../yad-review-companion/SKILL.md)
|
|
52
|
+
(`yad review trailer/context/nudge`, a real trailer/cards/chat session over the diff), `none` for a bare
|
|
53
|
+
approve. The optional `companion` block records which faces ran. It is **soft by default** (both count;
|
|
54
|
+
a bare approve draws a friendly `yad review nudge`); it only gates ship when
|
|
55
|
+
`hub.review.requireEngagement: true`. `yad review reconcile --epic <id> --repo <r> --pr <n>` reads the
|
|
56
|
+
code PR's approvals (with the engagement signal) and stamps them onto the matching ship record — the
|
|
57
|
+
back-half **bridge**, the analogue of `yad gate sync`. The signal is gameable by design ("visible, not
|
|
58
|
+
impossible"): it makes engineer-review quality visible, it does not prove a human read the diff. It sits
|
|
59
|
+
**beside** the CI gates (build/test/lint/contract/verified-commits) — never above them; CI still
|
|
60
|
+
decides machine safety, the merge is still the human act.
|
|
61
|
+
|
|
49
62
|
## Story state
|
|
50
63
|
|
|
51
64
|
The story frontmatter `status` reflects build progress:
|
|
@@ -35,6 +35,40 @@ glab api projects/:id/merge_requests/:iid/notes # discussion notes (com
|
|
|
35
35
|
All commands run as the **local user**; the bridge stores no tokens. If the CLI is missing/unauthenticated
|
|
36
36
|
or the remote is unreachable, the bridge stops and the gate falls back to file-only (no error).
|
|
37
37
|
|
|
38
|
+
> **GitLab read parity (GAP-6).** `readPrGitLab` reads approvals (`approved_by[]`) and discussions but
|
|
39
|
+
> does **not** map a "Request changes" reviewer state to `CHANGES_REQUESTED` — on GitLab the blocking
|
|
40
|
+
> signal is an **unresolved discussion**. So on GitLab the gate is held by unresolved threads, not by a
|
|
41
|
+
> reviewer state. (GitHub maps both.) If you need GitLab "Request changes" honored, read
|
|
42
|
+
> `reviewers[].state` from the MR and map it to `CHANGES_REQUESTED`.
|
|
43
|
+
|
|
44
|
+
## Open recipes (request the reviewers — used by `yad gate open` / `yad open-pr`)
|
|
45
|
+
|
|
46
|
+
Opening the review PR/MR must **request the required reviewers**, or an escalated gate is opened with
|
|
47
|
+
nobody asked. The CLI (`createPr` in `cli/platform.mjs`) does this; an agent opening a PR by hand uses:
|
|
48
|
+
|
|
49
|
+
**GitHub** — create, then add each reviewer (a bad/non-collaborator login WARNS instead of aborting the
|
|
50
|
+
whole create):
|
|
51
|
+
```bash
|
|
52
|
+
gh pr create --title "review: <artifact> (<epic>)" --body <body> --base <default> --head <branch> \
|
|
53
|
+
--assignee @me --label domain:<repo>
|
|
54
|
+
gh pr edit <n> --add-reviewer <login> # once per required reviewer
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**GitLab** — a Free/Core MR carries a **single** reviewer field (multiple reviewers is Premium), so
|
|
58
|
+
assign the first required reviewer and **@-mention the rest in a note** so they are still notified/routed:
|
|
59
|
+
```bash
|
|
60
|
+
glab mr create --title "review: <artifact> (<epic>)" --description <body> \
|
|
61
|
+
--target-branch <default> --source-branch <branch> --reviewer <first-login> --label domain:<repo> --yes
|
|
62
|
+
glab mr note <iid> -m "Review requested (owner + reviewer rule): @<l2> @<l3> — please review and approve/comment on this MR (this drives the gate)."
|
|
63
|
+
```
|
|
64
|
+
The read side counts a mentioned reviewer normally: their eventual **approval** still appears in
|
|
65
|
+
`…/approvals → approved_by[]`, and their **note** in `…/discussions` — so the single-reviewer-field cap
|
|
66
|
+
loses only the native "Reviewers" UI chip, not the gate routing.
|
|
67
|
+
|
|
68
|
+
Required reviewers = the hub's `reviewer`/`domain-owner` roster logins for the touched scopes, PLUS any
|
|
69
|
+
repo whose ownership lives only in `repos.json` `domain_owner`/`domain_owners` (those are resolved to a
|
|
70
|
+
login and requested too — otherwise an escalated step is structurally unsatisfiable through routing).
|
|
71
|
+
|
|
38
72
|
## Login → role resolution (order)
|
|
39
73
|
|
|
40
74
|
1. Roster (`.sdlc/hub.json`) maps `login` → `name` + base `role` (owner/reviewer).
|
|
@@ -62,6 +62,16 @@ PR/MR with the auto-assigned assignee + reviewers.
|
|
|
62
62
|
On `high` risk or a contract touch, run `bash checks/risk-route.sh <pr-body>` to print the required
|
|
63
63
|
domain-owner reviewers — the same escalation `yad-engineer-review` enforces.
|
|
64
64
|
|
|
65
|
+
### Step 3b — Post the review trailer (optional, recommended)
|
|
66
|
+
Make the reviewer's job easy: generate the 60-sec briefing and post it to the new PR/MR so it greets
|
|
67
|
+
every reviewer in the UI (idempotent; safe to re-run after a push):
|
|
68
|
+
```bash
|
|
69
|
+
yad review trailer --repo <name> --pr <n> --body "<companion-generated briefing>"
|
|
70
|
+
```
|
|
71
|
+
The full fun-review flow (cards + grounded chat + engagement) is driven by the
|
|
72
|
+
[Review Companion](../yad-review-companion/SKILL.md) during `yad-engineer-review`. Non-blocking by
|
|
73
|
+
design — companion comments carry `<!-- yad:noblock -->`.
|
|
74
|
+
|
|
65
75
|
### Step 4 — Stop (no merge)
|
|
66
76
|
Report the PR/MR URL and the requested reviewers. The PR now runs the check gates (Step C); the human
|
|
67
77
|
engineer review and merge happen in `yad-engineer-review` (Step E).
|