yadflow 2.4.2 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -3
- package/cli/doctor.mjs +13 -2
- package/cli/gate.mjs +8 -3
- package/cli/openpr.mjs +12 -2
- package/cli/platform.mjs +105 -11
- package/cli/setup.mjs +61 -7
- package/docs/index.html +3 -3
- package/package.json +1 -1
- package/skills/yad-connect-repos/SKILL.md +14 -7
- package/skills/yad-connect-repos/references/hub-config.md +20 -11
- package/skills/yad-connect-repos/references/repos-registry.md +2 -1
- package/skills/yad-hub-bridge/references/login-roster.md +32 -5
- package/skills/yad-pr-template/SKILL.md +5 -0
- package/skills/yad-review-gate/references/gating.md +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
# [2.5.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.4.2...v2.5.0) (2026-06-14)
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
###
|
|
4
|
+
### Features
|
|
5
5
|
|
|
6
|
-
*
|
|
6
|
+
* per-scope roster roles + auto assignee/reviewer on PRs ([5ff066b](https://github.com/abdelrahmannasr/yadflow/commit/5ff066b2a83f63ddf25353ddaf0a088b91a6adb0))
|
|
7
7
|
|
|
8
8
|
# [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
|
|
9
9
|
|
package/cli/doctor.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSO
|
|
|
8
8
|
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
|
|
9
9
|
import { loadLedger, epicRoot } from './epic-state.mjs';
|
|
10
10
|
import { gitHead } from './setup.mjs';
|
|
11
|
-
import { cliFor } from './platform.mjs';
|
|
11
|
+
import { cliFor, validateLogin } from './platform.mjs';
|
|
12
12
|
|
|
13
13
|
const MIN_NODE = 18;
|
|
14
14
|
|
|
@@ -70,7 +70,18 @@ export function projectChecks(checks, root) {
|
|
|
70
70
|
if (cli) {
|
|
71
71
|
if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to file-only without it`);
|
|
72
72
|
else if (!run(cli, ['auth', 'status']).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated [YAD-ENV-002]`, `run \`${cli} auth login\``);
|
|
73
|
-
else
|
|
73
|
+
else {
|
|
74
|
+
check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
|
|
75
|
+
// Re-validate each roster login against the hub (warn-only). Skips when a login is already
|
|
76
|
+
// flagged unverified by setup; reports any that no longer resolve.
|
|
77
|
+
const bad = [];
|
|
78
|
+
for (const e of hub.roster || []) {
|
|
79
|
+
const v = validateLogin(hub.platform, e.login);
|
|
80
|
+
if (v.checked && !v.exists) bad.push(e.login);
|
|
81
|
+
}
|
|
82
|
+
if (bad.length) check(checks, 'roster', 'project', 'warn', `roster login(s) not found on ${hub.platform}: ${bad.join(', ')}`, 'fix the login or re-run `yad setup` (they cannot satisfy a gate)');
|
|
83
|
+
else check(checks, 'roster', 'project', 'ok', `roster: ${(hub.roster || []).length} member(s) validated on ${hub.platform}`);
|
|
84
|
+
}
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
}
|
package/cli/gate.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
14
|
artifactPaths, upsertHubPr,
|
|
15
15
|
} from './epic-state.mjs';
|
|
16
|
-
import { readPr, mapApprovers, createPr } from './platform.mjs';
|
|
16
|
+
import { readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
|
|
17
17
|
import { err } from './errors.mjs';
|
|
18
18
|
|
|
19
19
|
// ---- tiny frontmatter reader (key: value, and `repos: [a, b]`) ----------------------------------
|
|
@@ -412,10 +412,15 @@ export async function gateOpen(root, { epic, artifact } = {}) {
|
|
|
412
412
|
if (!hub?.platform) { warn('no hub platform — marked in_review file-only (no PR opened)'); ok(`${step.id} → in_review`); return; }
|
|
413
413
|
|
|
414
414
|
const body = fillHubTemplate({ epic, artifact, step, owner: ownerOf(epicDir), domains });
|
|
415
|
-
|
|
415
|
+
// Assignee = whoever opens the review PR (the committer); reviewers = the hub's reviewers +
|
|
416
|
+
// domain-owners of the touched repos, minus the committer (the owner/author is recorded, not asked
|
|
417
|
+
// to review their own artifact). Scope is the hub plus every touched domain.
|
|
418
|
+
const committer = resolveCommitterLogin(root, hub.roster || []);
|
|
419
|
+
const reviewers = reviewersForScopes(hub.roster || [], ['hub', ...domains], { excludeLogin: committer });
|
|
420
|
+
const assignees = committer ? [committer] : [];
|
|
416
421
|
const labels = isEscalated(step) ? domains.map((d) => `domain:${d}`) : [];
|
|
417
422
|
info(`opening review ${hub.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
|
|
418
|
-
const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, labels, cwd: root });
|
|
423
|
+
const r = createPr(hub.platform, { title: `review: ${artifact} (${epic})`, body, base: hub.default_branch || 'main', head: branch, reviewers, assignees, labels, cwd: root });
|
|
419
424
|
if (!r.ok) { warn(`could not open PR (${r.reason || 'unknown'}); step is in_review file-only`); return; }
|
|
420
425
|
|
|
421
426
|
const number = Number((r.url.match(/\/(\d+)(?:[/?#]|$)/) || [])[1]) || null;
|
package/cli/openpr.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { c, log, ok, info, hand, fail, run, exists, readJSON } from './lib.mjs';
|
|
8
8
|
import { PROJECT_FILES } from './manifest.mjs';
|
|
9
|
-
import { detectPlatform, createPr } from './platform.mjs';
|
|
9
|
+
import { detectPlatform, createPr, reviewersForScopes, resolveCommitterLogin } from './platform.mjs';
|
|
10
10
|
import { taskFromBranch } from './commit.mjs';
|
|
11
11
|
|
|
12
12
|
// Resolve the target code repo: --repo <name> from the registry, else --dir, else cwd.
|
|
@@ -57,7 +57,17 @@ export async function runOpenPr(root, opts = {}) {
|
|
|
57
57
|
task, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name,
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
// Auto-assign from the hub roster, scoped to this repo: assignee = the committer (resolved from
|
|
61
|
+
// local git identity), reviewers = the repo's reviewers + domain-owners, minus the committer.
|
|
62
|
+
// Degrades cleanly when there is no roster / the committer is unmapped (gh self-assigns via @me).
|
|
63
|
+
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
|
|
64
|
+
const roster = hub.roster || [];
|
|
65
|
+
const committer = resolveCommitterLogin(repoRoot, roster);
|
|
66
|
+
const scope = meta?.name ? [meta.name] : [];
|
|
67
|
+
const reviewers = reviewersForScopes(roster, scope, { excludeLogin: committer });
|
|
68
|
+
const assignees = committer ? [committer] : [];
|
|
69
|
+
|
|
70
|
+
const r = createPr(platform, { title, body, base: baseBranch, head: branch, reviewers, assignees, cwd: repoRoot });
|
|
61
71
|
if (!r.ok) { fail(`could not open PR/MR — ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
62
72
|
ok(`opened ${r.url}`);
|
|
63
73
|
if (opts.risk === 'high' || opts.contractChange) hand('high risk / contract surface — run `bash checks/risk-route.sh "<pr body>"` for required reviewers');
|
package/cli/platform.mjs
CHANGED
|
@@ -23,18 +23,102 @@ export function platformReady(platform) {
|
|
|
23
23
|
return !!cli && has(cli);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// ---- roster role model (per-scope map, with legacy back-compat) ---------------------------------
|
|
27
|
+
// A roster entry's roles live in a per-scope map: `roles: { hub: ["owner","reviewer"], <repo>: [...] }`.
|
|
28
|
+
// `rolesForScope` normalizes the three shapes a roster entry can take on disk:
|
|
29
|
+
// 1. new object map — `entry.roles = { hub: [...], backend: [...] }`
|
|
30
|
+
// 2. flat array variant — `entry.roles = ["owner","reviewer"]` (treated as hub roles)
|
|
31
|
+
// 3. legacy single role — `entry.role = "owner"` (a hub role; pre per-scope schema)
|
|
32
|
+
// The legacy `repos.json` `domain_owner` field is handled separately by resolveLogin's fallback.
|
|
33
|
+
export function rolesForScope(entry, scope) {
|
|
34
|
+
if (!entry) return [];
|
|
35
|
+
const r = entry.roles;
|
|
36
|
+
if (r && typeof r === 'object' && !Array.isArray(r)) return Array.isArray(r[scope]) ? r[scope] : [];
|
|
37
|
+
if (Array.isArray(r)) return scope === 'hub' ? r : [];
|
|
38
|
+
if (typeof entry.role === 'string' && entry.role) return scope === 'hub' ? [entry.role] : [];
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// True when the entry holds any of `wanted` roles across any of `scopes`.
|
|
43
|
+
export function hasAnyRole(entry, scopes = [], wanted = []) {
|
|
44
|
+
for (const scope of scopes) {
|
|
45
|
+
for (const role of rolesForScope(entry, scope)) {
|
|
46
|
+
if (wanted.includes(role)) return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Platform logins to auto-request as reviewers for the given scopes: everyone holding a `reviewer`
|
|
53
|
+
// or `domain-owner` role in any scope, minus `excludeLogin` (you don't review your own PR), deduped.
|
|
54
|
+
export function reviewersForScopes(roster = [], scopes = [], { excludeLogin = null } = {}) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const entry of roster) {
|
|
57
|
+
if (!entry.login || entry.login === excludeLogin) continue;
|
|
58
|
+
if (hasAnyRole(entry, scopes, ['reviewer', 'domain-owner']) && !out.includes(entry.login)) out.push(entry.login);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The committer/PR-opener's platform login, resolved from local git identity through the roster.
|
|
64
|
+
// Match on commit email first (the stable key), then fall back to name/login. null when unresolved.
|
|
65
|
+
export function resolveCommitterLogin(cwd, roster = []) {
|
|
66
|
+
const email = (run('git', ['config', 'user.email'], { cwd }).stdout || '').trim().toLowerCase();
|
|
67
|
+
const name = (run('git', ['config', 'user.name'], { cwd }).stdout || '').trim();
|
|
68
|
+
if (email) {
|
|
69
|
+
const byEmail = roster.find((r) => (r.email || '').toLowerCase() === email);
|
|
70
|
+
if (byEmail) return byEmail.login || null;
|
|
71
|
+
}
|
|
72
|
+
if (name) {
|
|
73
|
+
const byName = roster.find((r) => r.name === name || r.login === name);
|
|
74
|
+
if (byName) return byName.login || null;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Does this platform login exist on the hub? Warn-only (never throws): `checked:false` when the CLI
|
|
80
|
+
// is absent/unauthenticated so callers can distinguish "not a user" from "couldn't check".
|
|
81
|
+
export function validateLogin(platform, login) {
|
|
82
|
+
const cli = cliFor(platform);
|
|
83
|
+
if (!cli || !has(cli) || !login) return { ok: false, exists: false, checked: false };
|
|
84
|
+
if (platform === 'github') {
|
|
85
|
+
const r = run('gh', ['api', `users/${login}`]);
|
|
86
|
+
return { ok: r.ok, exists: r.ok, checked: true };
|
|
87
|
+
}
|
|
88
|
+
// gitlab: users?username=<login> returns an array; empty array => no such user.
|
|
89
|
+
const r = run('glab', ['api', `users?username=${encodeURIComponent(login)}`]);
|
|
90
|
+
if (!r.ok) return { ok: false, exists: false, checked: true };
|
|
91
|
+
let exists = false;
|
|
92
|
+
try { exists = Array.isArray(JSON.parse(r.stdout)) && JSON.parse(r.stdout).length > 0; } catch { exists = false; }
|
|
93
|
+
return { ok: exists, exists, checked: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
26
96
|
// ---- login -> yad identity (roster + derived domain-owner) -------------------------------------
|
|
27
|
-
// Returns the records this login's APPROVED review contributes.
|
|
28
|
-
//
|
|
97
|
+
// Returns the records this login's APPROVED review contributes. Roles are read from the per-scope
|
|
98
|
+
// map: hub roles plus, for each touched domain, that repo's scoped roles (domain-owner carries the
|
|
99
|
+
// `domain` tag). The legacy `repos.json` `domain_owner === name` mapping is kept as a fallback so
|
|
100
|
+
// pre per-scope projects still resolve domain owners.
|
|
29
101
|
export function resolveLogin(login, roster = [], repos = [], touchedDomains = []) {
|
|
30
102
|
const entry = roster.find((r) => r.login === login);
|
|
31
103
|
if (!entry) return [{ name: login, role: 'reviewer', unverified: true }];
|
|
32
|
-
const records = [
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
104
|
+
const records = [];
|
|
105
|
+
const push = (rec) => {
|
|
106
|
+
if (!records.some((x) => x.name === rec.name && x.role === rec.role && x.domain === rec.domain)) records.push(rec);
|
|
107
|
+
};
|
|
108
|
+
for (const role of rolesForScope(entry, 'hub')) push({ name: entry.name, role });
|
|
109
|
+
for (const d of touchedDomains) {
|
|
110
|
+
for (const role of rolesForScope(entry, d)) {
|
|
111
|
+
push(role === 'domain-owner' ? { name: entry.name, role, domain: d } : { name: entry.name, role });
|
|
36
112
|
}
|
|
113
|
+
// Legacy fallback: a repo whose domain_owner is this name confers domain-owner for that domain.
|
|
114
|
+
const legacy = repos.find((repo) => repo.name === d && repo.domain_owner === entry.name);
|
|
115
|
+
if (legacy) push({ name: entry.name, role: 'domain-owner', domain: d });
|
|
37
116
|
}
|
|
117
|
+
// An identity-only entry (no roles map and no legacy `role`) still contributes a base reviewer
|
|
118
|
+
// record so an approval from a known person is never silently dropped. An entry that DOES declare
|
|
119
|
+
// roles but none apply to these scopes contributes nothing here (it is scoped elsewhere).
|
|
120
|
+
const hasNoRoleInfo = !entry.role && !(entry.roles && (Array.isArray(entry.roles) ? entry.roles.length : Object.keys(entry.roles).length));
|
|
121
|
+
if (!records.length && hasNoRoleInfo) push({ name: entry.name, role: 'reviewer' });
|
|
38
122
|
return records;
|
|
39
123
|
}
|
|
40
124
|
|
|
@@ -134,18 +218,28 @@ export function readPr(platform, n, opts = {}) {
|
|
|
134
218
|
}
|
|
135
219
|
|
|
136
220
|
// ---- create a PR/MR -----------------------------------------------------------------------------
|
|
137
|
-
|
|
138
|
-
|
|
221
|
+
// `assignees` = the committer/PR-opener (always set, so the PR is owned by whoever pushed it);
|
|
222
|
+
// `reviewers` = the scope's reviewers + domain-owners (computed by reviewersForScopes). On GitHub an
|
|
223
|
+
// empty assignee list falls back to `@me` so the opener still self-assigns even without a roster.
|
|
224
|
+
// Pure argv builder for the create command — exported so the reviewer/assignee/label wiring is
|
|
225
|
+
// unit-testable without shelling out. gh always self-assigns (@me) when no assignee resolved.
|
|
226
|
+
export function buildPrArgs(platform, { title, body, base, head, reviewers = [], labels = [], assignees = [] } = {}) {
|
|
139
227
|
if (platform === 'gitlab') {
|
|
140
228
|
const args = ['mr', 'create', '--title', title, '--description', body, '--target-branch', base, '--source-branch', head, '--yes'];
|
|
141
229
|
if (reviewers.length) args.push('--reviewer', reviewers.join(','));
|
|
230
|
+
if (assignees.length) args.push('--assignee', assignees.join(','));
|
|
142
231
|
if (labels.length) args.push('--label', labels.join(','));
|
|
143
|
-
|
|
144
|
-
return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
|
|
232
|
+
return args;
|
|
145
233
|
}
|
|
146
234
|
const args = ['pr', 'create', '--title', title, '--body', body, '--base', base, '--head', head];
|
|
147
235
|
if (reviewers.length) args.push('--reviewer', reviewers.join(','));
|
|
236
|
+
args.push('--assignee', assignees.length ? assignees.join(',') : '@me');
|
|
148
237
|
if (labels.length) args.push('--label', labels.join(','));
|
|
149
|
-
|
|
238
|
+
return args;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function createPr(platform, opts = {}) {
|
|
242
|
+
if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
243
|
+
const r = run(cliFor(platform), buildPrArgs(platform, opts), { cwd: opts.cwd });
|
|
150
244
|
return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
|
|
151
245
|
}
|
package/cli/setup.mjs
CHANGED
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
moduleActions, repoActions, hubActions, authorsActions,
|
|
12
12
|
legacyModuleActions, legacyRepoActions, legacyHubActions,
|
|
13
13
|
} from './plan.mjs';
|
|
14
|
+
import { validateLogin } from './platform.mjs';
|
|
15
|
+
|
|
16
|
+
// Parse a comma/space separated list into a clean, deduped array of trimmed tokens.
|
|
17
|
+
function parseList(s) {
|
|
18
|
+
return [...new Set((s || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean))];
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
const ALL_IDES = [...IDE_FOLDER_TARGETS, '.opencode'];
|
|
16
22
|
|
|
@@ -34,7 +40,35 @@ export function insideRoot(root, rpath) {
|
|
|
34
40
|
// Validate + record one code repo into the registry (the testable half of the connect loop).
|
|
35
41
|
// A path that is not a git repository is rejected and NOTHING is written — a registry entry with
|
|
36
42
|
// syncedHead:null would only surface later as an unexplained "unknown status" in the CI gates.
|
|
37
|
-
|
|
43
|
+
// Grant per-repo roles to roster members by writing into each person's `roles[<repo>]` array in
|
|
44
|
+
// hub.json. `grants` maps a role -> the yad names that hold it for this repo. A name that is not in
|
|
45
|
+
// the roster is warned about and skipped (the roster is the source of identity). Idempotent.
|
|
46
|
+
export function addRepoRoles(root, repo, grants = {}) {
|
|
47
|
+
const hubPath = path.join(root, PROJECT_FILES.hubConfig);
|
|
48
|
+
const hub = readJSON(hubPath, null);
|
|
49
|
+
if (!hub || !Array.isArray(hub.roster)) return;
|
|
50
|
+
const byName = new Map(hub.roster.map((e) => [e.name, e]));
|
|
51
|
+
let touched = false;
|
|
52
|
+
for (const [role, names] of Object.entries(grants)) {
|
|
53
|
+
for (const nm of names) {
|
|
54
|
+
const entry = byName.get(nm);
|
|
55
|
+
if (!entry) { warn(`'${nm}' is not in the roster — skipped ${role} for ${repo}`); continue; }
|
|
56
|
+
// Normalize to the per-scope map, migrating the legacy shapes: a flat array or a single
|
|
57
|
+
// `role` string both become hub roles so nothing is lost.
|
|
58
|
+
if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) {
|
|
59
|
+
const hub = Array.isArray(entry.roles) ? entry.roles : (entry.role ? [entry.role] : []);
|
|
60
|
+
entry.roles = hub.length ? { hub } : {};
|
|
61
|
+
delete entry.role;
|
|
62
|
+
}
|
|
63
|
+
const list = Array.isArray(entry.roles[repo]) ? entry.roles[repo] : [];
|
|
64
|
+
if (!list.includes(role)) { list.push(role); touched = true; }
|
|
65
|
+
entry.roles[repo] = list;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (touched) writeJSON(hubPath, hub);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null }) {
|
|
38
72
|
if (!insideRoot(root, rpath)) {
|
|
39
73
|
warn(`${rpath} resolves outside the project root — skipped`);
|
|
40
74
|
return null;
|
|
@@ -49,8 +83,12 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
|
|
|
49
83
|
if (plat) warn(`unknown platform '${platform}' — using ${detected}`);
|
|
50
84
|
plat = detected;
|
|
51
85
|
}
|
|
86
|
+
// A repo can have multiple domain owners. `domain_owners` is the array of record; `domain_owner`
|
|
87
|
+
// is kept as the first element so anything still reading the legacy single-owner field works.
|
|
88
|
+
const owners = (domain_owners && domain_owners.length) ? domain_owners : (domain_owner ? [domain_owner] : []);
|
|
52
89
|
const repo = {
|
|
53
|
-
name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat,
|
|
90
|
+
name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat,
|
|
91
|
+
domain_owner: owners[0] || '', domain_owners: owners, default_branch,
|
|
54
92
|
connectedAt: today, lastSyncedAt: today,
|
|
55
93
|
syncedHead: head,
|
|
56
94
|
contextPack: `.sdlc/code-context/${name}/pack.md`,
|
|
@@ -226,9 +264,20 @@ export async function runSetup(root, opts = {}) {
|
|
|
226
264
|
const login = await ask(' reviewer platform login (blank to finish)', '');
|
|
227
265
|
if (!login) break;
|
|
228
266
|
const name = await ask(' yad name', login);
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
267
|
+
const email = await ask(' commit email (committer→login lookup + verified-commits gate; blank to skip)', '');
|
|
268
|
+
// Per-scope roles: capture the hub roles here; per-repo roles are added in step 7 when the
|
|
269
|
+
// repo is connected. A person can hold several roles (owner reviewer) at once.
|
|
270
|
+
const hubRoles = parseList(await ask(' hub roles (owner/reviewer, space-separated)', 'reviewer'));
|
|
271
|
+
const entry = { login, name, ...(email ? { email } : {}), roles: { hub: hubRoles } };
|
|
272
|
+
// Validate the login exists on the hub — warn-only (fail-open): a miss is flagged unverified
|
|
273
|
+
// but still saved. `checked:false` (no CLI/auth) skips the check silently.
|
|
274
|
+
if (platform !== 'none') {
|
|
275
|
+
const v = validateLogin(platform, login);
|
|
276
|
+
if (v.checked && !v.exists) { warn(`'${login}' not found on ${platform} — saved as unverified`); entry.unverified = true; }
|
|
277
|
+
else if (v.checked && v.exists) ok(`verified ${login} on ${platform}`);
|
|
278
|
+
}
|
|
279
|
+
if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) warn(`'${email}' does not look like an email address`);
|
|
280
|
+
roster.push(entry);
|
|
232
281
|
}
|
|
233
282
|
}
|
|
234
283
|
const default_branch = platform === 'none' ? 'main' : await ask('Hub default branch', 'main');
|
|
@@ -306,10 +355,15 @@ export async function runSetup(root, opts = {}) {
|
|
|
306
355
|
if (!insideRoot(root, rpath)) { warn(`${rpath} resolves outside the project root — skipped`); continue; }
|
|
307
356
|
const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
|
|
308
357
|
const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
|
|
309
|
-
|
|
358
|
+
// A repo can have one or more domain owners; reviewers/owners can also be scoped to it. Names
|
|
359
|
+
// refer to roster `name`s; each grant is written into that person's per-scope roles map.
|
|
360
|
+
const domain_owners = parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
|
|
361
|
+
const repoReviewers = parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
|
|
362
|
+
const repoOwners = parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
|
|
310
363
|
const default_branch = await ask(' default branch', 'main');
|
|
311
|
-
const repo = registerRepo(root, registry, { name, rpath, platform,
|
|
364
|
+
const repo = registerRepo(root, registry, { name, rpath, platform, domain_owners, default_branch, today: opts.today ?? null });
|
|
312
365
|
if (!repo) continue;
|
|
366
|
+
addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
|
|
313
367
|
known.add(name);
|
|
314
368
|
ok(`registered ${name}`);
|
|
315
369
|
packRepo(root, repo);
|
package/docs/index.html
CHANGED
|
@@ -475,7 +475,7 @@ each code repo/
|
|
|
475
475
|
<div class="term"><strong><code>approvals.json</code></strong><span class="badge b-file">files</span><br>Who approved which review, with name + role. The gate reads this to decide whether the rule is met.</div>
|
|
476
476
|
<div class="term"><strong><code>repos.json</code></strong><span class="badge b-file">files</span><br>The project-wide registry of connected code repos (path/URL, platform, domain owner, freshness sha).</div>
|
|
477
477
|
<div class="term"><strong><code>hub.json</code></strong><span class="badge b-file">files</span><br>The hub’s platform record (GitHub or GitLab) plus the reviewer roster, used to run front-half reviews on real PRs/MRs.</div>
|
|
478
|
-
<div class="term"><strong>Roster</strong><span class="badge b-role">roles</span><br>The mapping from each
|
|
478
|
+
<div class="term"><strong>Roster</strong><span class="badge b-role">roles</span><br>The mapping from each person’s GitHub/GitLab login + commit email to their SDLC name and a <strong>per-scope roles map</strong> (<code>roles: { hub: […], <repo>: […] }</code>). One person can be owner, reviewer <em>and</em> domain owner at once; one repo can have several people per role. Logins are validated against GitHub/GitLab at setup (warn-only). When a PR/MR is opened, the committer becomes the <strong>assignee</strong> and the scope’s reviewers + domain owners are <strong>auto-requested</strong>.</div>
|
|
479
479
|
<div class="term"><strong>Code-map</strong><span class="badge b-file">files</span><br>A lightweight, AI-readable summary of a connected code repo — its existing endpoints, events, data models and modules — cached under <code>.sdlc/code-context/</code> so the front-half steps don’t contradict code that already exists.</div>
|
|
480
480
|
<div class="term"><strong>Repomix pack</strong><span class="badge b-tool">tools</span><br>A compressed, secret-scanned snapshot of a repo’s code produced by the Repomix tool (<code>npx repomix</code>), cached alongside the code-map so AI can read the codebase.</div>
|
|
481
481
|
<div class="term"><strong>Fresh / stale</strong><span class="badge b-file">files</span><br>A connected repo’s cached picture is <em>fresh</em> if the repo hasn’t moved since it was packed (tracked by HEAD sha) and <em>stale</em> if it has. Refreshing is a deliberate human decision (<code>yad repo refresh</code>) — skills warn about staleness but never silently re-pack.</div>
|
|
@@ -718,12 +718,12 @@ each code repo/
|
|
|
718
718
|
<h4><code>yad-connect-repos</code></h4>
|
|
719
719
|
<div class="row"><span class="lbl lbl-what">What</span> Introduces your code repos to the product hub so the “brain” knows what is already built. It registers each repo in <code>repos.json</code> and caches an AI-readable picture of it (a Repomix pack + a code-map of existing endpoints, events, data models and modules). Also sets up the hub’s own platform record and reviewer roster.</div>
|
|
720
720
|
<div class="row"><span class="lbl lbl-how">How</span> Run it in the product hub, once per repo:</div>
|
|
721
|
-
<pre><code>yad-connect-repos action: connect repo: backend path: ../backend-repo
|
|
721
|
+
<pre><code>yad-connect-repos action: connect repo: backend path: ../backend-repo domain_owners: carol,dave
|
|
722
722
|
yad-connect-repos action: list # see every repo as fresh / stale
|
|
723
723
|
yad-connect-repos action: refresh repo: backend # re-pack after the code moved
|
|
724
724
|
yad-connect-repos action: disconnect repo: backend
|
|
725
725
|
yad-connect-repos action: detect-hub # record the hub's GitHub/GitLab platform
|
|
726
|
-
yad-connect-repos action: roster login: alice-gh name: alice
|
|
726
|
+
yad-connect-repos action: roster login: alice-gh name: alice email: alice@x.com roles: hub=owner,reviewer # validated vs the hub</code></pre>
|
|
727
727
|
<div class="row"><span class="lbl lbl-when">When</span> During one-time setup, and again any time you add a new code repo. Re-run <code>refresh</code> when a skill warns a repo is stale. <strong>Greenfield with no code yet? Skip it entirely</strong> — the brain proceeds without it.</div>
|
|
728
728
|
</div>
|
|
729
729
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.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, repo). A BMAD module + 22 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -30,10 +30,13 @@ epic's approvals. It only writes the project-wide registry and the per-repo cont
|
|
|
30
30
|
|
|
31
31
|
- `action` — `connect` | `refresh` | `list` | `disconnect` | `detect-hub` | `roster` (default `connect`).
|
|
32
32
|
- `repo` — the repo's short name (the key used in stories' `repos:` tag, e.g. `backend`).
|
|
33
|
-
- `login`, `name`, `
|
|
33
|
+
- `login`, `name`, `email`, `roles` — for `roster` (map login → name + commit email + the per-scope
|
|
34
|
+
`roles` map, e.g. `roles: hub=owner,reviewer backend=domain-owner`). Validate the login against the
|
|
35
|
+
hub (`gh api users/<login>` / `glab api users?username=`); a miss is flagged `unverified` (warn-only).
|
|
34
36
|
- `path` — local path to the code repo (relative to `{project-root}` or absolute). For local repos.
|
|
35
37
|
- `git_url` — optional remote (SSH or HTTPS; GitHub or GitLab). Used when the repo is not yet on disk.
|
|
36
|
-
- `
|
|
38
|
+
- `domain_owners` — the engineer(s) who own this repo's domain (a repo may have several; drives per-repo
|
|
39
|
+
review routing). Each name is also written into that person's `roles[<repo>]` map in `hub.json`.
|
|
37
40
|
|
|
38
41
|
## On Activation
|
|
39
42
|
|
|
@@ -83,7 +86,8 @@ HEAD sha as `syncedHead` (this drives staleness):
|
|
|
83
86
|
"path": "<path rel. to project-root>",
|
|
84
87
|
"git_url": "<url or null>",
|
|
85
88
|
"platform": "github|gitlab|null",
|
|
86
|
-
"
|
|
89
|
+
"domain_owners": ["<owner>", "…"],
|
|
90
|
+
"domain_owner": "<domain_owners[0] — legacy mirror>",
|
|
87
91
|
"default_branch": "<branch>",
|
|
88
92
|
"connectedAt": "<YYYY-MM-DD>",
|
|
89
93
|
"lastSyncedAt": "<YYYY-MM-DD>",
|
|
@@ -122,10 +126,13 @@ write only `{project-root}/.sdlc/hub.json` (`config.yaml` `hub.config`) — neve
|
|
|
122
126
|
repos: `github.com` → `github`, GitLab host → `gitlab`, no remote → `platform: null`. Record
|
|
123
127
|
`git_url`, `default_branch`, `detectedAt`, and `bridge_enabled: true` (preserve an existing roster).
|
|
124
128
|
Auth is the local user's own `gh`/`glab`/git; **store no tokens**. Idempotent — safe to re-run.
|
|
125
|
-
- **`roster`** — set one roster entry mapping a platform `login` → SDLC `name` + `
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
- **`roster`** — set one roster entry mapping a platform `login` → SDLC `name` + `email` + a per-scope
|
|
130
|
+
`roles` map (`roles: { hub: ["owner","reviewer"], <repo>: ["domain-owner", …] }`). Upsert by `login`;
|
|
131
|
+
a person may hold several roles across several scopes, and a repo several people per role. Validate the
|
|
132
|
+
`login` against the hub (warn-only; flag `unverified` on a miss). `domain-owner` is written into
|
|
133
|
+
`roles[<repo>]` (and still **derived** as a fallback when a roster `name` equals a repo's
|
|
134
|
+
`domain_owner`/`domain_owners` in `repos.json` — see `references/hub-config.md`). An unmapped login
|
|
135
|
+
degrades to a plain `reviewer`, never auto-promoted to owner/domain-owner.
|
|
129
136
|
|
|
130
137
|
If the hub has no remote (`platform: null`) or the bridge is disabled, the front-half gate runs
|
|
131
138
|
file-only with no error — the bridge is purely additive.
|
|
@@ -21,27 +21,36 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
|
|
|
21
21
|
"bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews
|
|
22
22
|
"detectedAt": "2026-06-08", // last detect-hub run (YYYY-MM-DD)
|
|
23
23
|
"roster": [
|
|
24
|
-
{ "login": "abdelrahmannasr", "name": "alice", "
|
|
25
|
-
|
|
24
|
+
{ "login": "abdelrahmannasr", "name": "alice", "email": "alice@example.com",
|
|
25
|
+
"roles": { "hub": ["owner", "reviewer"] } },
|
|
26
|
+
{ "login": "carol-gh", "name": "carol", "email": "carol@example.com",
|
|
27
|
+
"roles": { "hub": ["reviewer"], "backend": ["domain-owner", "owner"], "payments": ["reviewer"] } }
|
|
26
28
|
]
|
|
27
29
|
}
|
|
28
30
|
```
|
|
29
31
|
|
|
30
|
-
## The roster — login → name →
|
|
32
|
+
## The roster — login → name → per-scope roles
|
|
31
33
|
|
|
32
|
-
The roster is how a platform identity (a GitHub/GitLab **login**) becomes an SDLC **name + role** in
|
|
33
|
-
file ledger (`approvals.json` / `comments.json`). Roles are the same three the gate
|
|
34
|
+
The roster is how a platform identity (a GitHub/GitLab **login**) becomes an SDLC **name + role(s)** in
|
|
35
|
+
the file ledger (`approvals.json` / `comments.json`). Roles are the same three the gate uses:
|
|
34
36
|
`owner | reviewer | domain-owner`.
|
|
35
37
|
|
|
36
38
|
- **`login`** — the platform username whose PR review / approval is being mapped.
|
|
37
39
|
- **`name`** — the SDLC name written into the ledger (the same names used across `approvals.json`,
|
|
38
40
|
`comments.json`, and `epic.md` `owner`). Keep it stable.
|
|
39
|
-
- **`
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
- **`email`** — the commit email; drives the **committer → login** reverse lookup that auto-assigns PRs.
|
|
42
|
+
- **`roles`** — a **per-scope map**: scope (`hub`, or a connected repo name) → the roles held there. A
|
|
43
|
+
person can be **owner + reviewer + domain-owner at once** and across scopes; a repo gets **several**
|
|
44
|
+
people per role by appearing in several entries' maps. Validated against the hub during `yad setup` /
|
|
45
|
+
`yad doctor`; a login that does not resolve is flagged `unverified` (warn-only, never blocks).
|
|
46
|
+
|
|
47
|
+
**Back-compat:** readers also accept a flat array `"roles": ["owner","reviewer"]` (treated as `hub`
|
|
48
|
+
roles) and the legacy single `"role": "owner"` (a `hub` role).
|
|
49
|
+
|
|
50
|
+
**`domain-owner` may also be DERIVED from `repos.json`.** A roster entry whose `name` equals a repo's
|
|
51
|
+
`domain_owner`/`domain_owners` in `repos.json` is treated as that repo's domain-owner **when that repo is
|
|
52
|
+
a touched domain for the step under review** — kept as a fallback so pre per-scope projects still resolve.
|
|
53
|
+
New setups write the grant directly into the person's `roles[<repo>]` map.
|
|
45
54
|
|
|
46
55
|
- **Unmapped login fallback.** A login absent from the roster maps to `name: <login>`, `role: reviewer`,
|
|
47
56
|
and is flagged `<!-- unverified login: <login> -->` in the review record (mirrors the code-map
|
|
@@ -21,7 +21,8 @@ not under any `epics/EP-<slug>/.sdlc/`.
|
|
|
21
21
|
"path": "demo-repos/backend", // path to the code repo, rel. to {project-root} (or absolute)
|
|
22
22
|
"git_url": "git@github.com:org/backend.git", // optional remote; SSH or HTTPS; GitHub or GitLab; null if local-only
|
|
23
23
|
"platform": "github", // github | gitlab (from the URL host); null when local-only
|
|
24
|
-
"
|
|
24
|
+
"domain_owners": ["carol", "dave"], // engineers who own this repo's domain (review routing); a repo may have several
|
|
25
|
+
"domain_owner": "carol", // legacy single-owner mirror = domain_owners[0] (kept for back-compat readers)
|
|
25
26
|
"default_branch": "main",
|
|
26
27
|
"connectedAt": "2026-06-08", // first connect (YYYY-MM-DD)
|
|
27
28
|
"lastSyncedAt": "2026-06-08", // last connect/refresh
|
|
@@ -7,19 +7,33 @@ in `../../yad-connect-repos/references/hub-config.md`; this file covers how the
|
|
|
7
7
|
## Entry
|
|
8
8
|
|
|
9
9
|
```json
|
|
10
|
-
{
|
|
10
|
+
{
|
|
11
|
+
"login": "abdelrahmannasr",
|
|
12
|
+
"name": "alice",
|
|
13
|
+
"email": "alice@example.com",
|
|
14
|
+
"roles": { "hub": ["owner", "reviewer"], "backend": ["domain-owner"] }
|
|
15
|
+
}
|
|
11
16
|
```
|
|
12
17
|
|
|
13
18
|
- `login` — the GitHub/GitLab username whose review/approval is being mapped.
|
|
14
19
|
- `name` — the SDLC name written to `approvals.json` / `comments.json` (the same names as `epic.md`
|
|
15
20
|
`owner` and `repos.json` `domain_owner`). Keep it stable.
|
|
16
|
-
- `
|
|
21
|
+
- `email` — the commit email; drives the **committer → login** reverse lookup used to auto-assign PRs.
|
|
22
|
+
- `roles` — a **per-scope map** from a scope (`hub`, or a connected repo name) to the roles held there
|
|
23
|
+
(`owner` / `reviewer` / `domain-owner`). A person can hold several roles in one scope (owner **and**
|
|
24
|
+
reviewer **and** domain-owner at once), and several scopes; a repo gets several owners/reviewers/
|
|
25
|
+
domain-owners by being listed in several people's maps.
|
|
26
|
+
|
|
27
|
+
**Back-compat (read on all three shapes):** the per-scope object above; a flat array
|
|
28
|
+
`"roles": ["owner","reviewer"]` (treated as `hub` roles); and the legacy single `"role": "owner"`
|
|
29
|
+
(a `hub` role). The legacy `repos.json` `domain_owner` field is still honored (see Resolution step 2).
|
|
17
30
|
|
|
18
31
|
## Resolution
|
|
19
32
|
|
|
20
|
-
1. **login → name +
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
1. **login → name + roles** from the roster. The `hub` roles map straight to records; each touched
|
|
34
|
+
domain `R` contributes the roles in `roles[R]` (a `domain-owner` role carries `domain: R`).
|
|
35
|
+
2. **Legacy domain-owner fallback:** if the resolved `name` equals a repo's `domain_owner` in
|
|
36
|
+
`repos.json`, and that repo is a **touched domain** for the step under review, the bridge also emits a
|
|
23
37
|
`domain-owner` approval scoped to that repo (`domain: <repo>`). One person owning several repos yields
|
|
24
38
|
several `domain-owner` records with different `domain` values — exactly what the gate predicate allows.
|
|
25
39
|
3. **Unmapped login → reviewer (flagged).** A login not in the roster maps to `name: <login>`,
|
|
@@ -27,6 +41,19 @@ in `../../yad-connect-repos/references/hub-config.md`; this file covers how the
|
|
|
27
41
|
reviewer but is **never** auto-promoted to owner/domain-owner, so a stranger can never satisfy the
|
|
28
42
|
owner/domain-owner requirement. The marker prompts a human to add the login to the roster.
|
|
29
43
|
|
|
44
|
+
## Auto-assignee / auto-reviewer on PR/MR open
|
|
45
|
+
|
|
46
|
+
When a review PR/MR is opened (hub `yad gate open`, or a code-repo `yad open-pr`):
|
|
47
|
+
|
|
48
|
+
- **Assignee = the committer/opener** — resolved from local git identity (`user.email`, then
|
|
49
|
+
`user.name`) through the roster (`email`/`name`/`login`). On GitHub an unresolved committer still
|
|
50
|
+
self-assigns via `@me`.
|
|
51
|
+
- **Reviewers = `reviewer` + `domain-owner`** for the touched scope(s) (`hub` plus every touched
|
|
52
|
+
domain for a hub review; the repo itself for a code PR), **minus the committer** — you do not review
|
|
53
|
+
your own PR. The artifact **owner/author is recorded, not requested.**
|
|
54
|
+
- Logins are validated against the hub during `yad setup` / `yad doctor` (`gh api users/<login>`,
|
|
55
|
+
`glab api users?username=<login>`); a miss is flagged `unverified` but never blocks (fail-open).
|
|
56
|
+
|
|
30
57
|
## Per-repo routing (stories review, and any escalated step)
|
|
31
58
|
|
|
32
59
|
The stories review needs a `domain-owner` per repo in the **union of every story's `repos`**. On the
|
|
@@ -70,6 +70,11 @@ required reviewers:
|
|
|
70
70
|
approval per touched domain — identical to `yad-review-gate`'s escalation. The actual approvals are
|
|
71
71
|
recorded by the engineer review (Step E), via `yad-review-gate`.
|
|
72
72
|
|
|
73
|
+
When the PR/MR is actually opened with `yad open-pr`, these reviewers are **requested automatically**
|
|
74
|
+
from the repo-scoped roster (everyone with `reviewer`/`domain-owner` for the repo, minus the committer),
|
|
75
|
+
and the **committer is set as the assignee**. `risk-route.sh` remains the advisory printout of who the
|
|
76
|
+
gate will require.
|
|
77
|
+
|
|
73
78
|
### Step 4 — Stop (no auto-advance)
|
|
74
79
|
Report what was committed (or the routing result). The template and routing are advisory inputs to the
|
|
75
80
|
human review (Step E); they do not approve or merge. Do not touch the epic's `.sdlc/` state.
|
|
@@ -63,8 +63,14 @@ on a real PR/MR instead of (or as well as) the skill recording it directly. `act
|
|
|
63
63
|
tagged `"source": "bridge"`. **The predicate above is unchanged**: it counts owner/reviewer/domain-owner
|
|
64
64
|
approvals regardless of how they were recorded.
|
|
65
65
|
|
|
66
|
-
- login → role via the roster
|
|
67
|
-
|
|
66
|
+
- login → role(s) via the roster's **per-scope map** (`roles: { hub: [...], <repo>: [...] }`): a person
|
|
67
|
+
can hold owner + reviewer + domain-owner at once, and a repo can list several people per role. The
|
|
68
|
+
`hub` roles plus each touched domain's `roles[<repo>]` are emitted; `domain-owner` is also **derived**
|
|
69
|
+
when a roster `name` equals a repo's `domain_owner`/`domain_owners` (legacy fallback) and that repo is a
|
|
70
|
+
touched domain; an unmapped login is a plain `reviewer`, never promoted.
|
|
71
|
+
- On PR/MR open the assignee is the committer and reviewers are the scope's `reviewer` + `domain-owner`
|
|
72
|
+
members (minus the committer); the owner/author is recorded, not requested. See
|
|
73
|
+
`../yad-hub-bridge/references/login-roster.md`.
|
|
68
74
|
- `sync` is idempotent (upsert by `(step, approver, role, domain)`; supersede revoked; key comments on
|
|
69
75
|
comment id) and never touches **manual** approvals.
|
|
70
76
|
- The architecture+contract staleness rule applies to bridge approvals too: a re-lock discards bridge
|