yadflow 2.4.2 → 2.6.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 +11 -2
- package/README.md +44 -22
- package/bin/yad.mjs +5 -0
- package/cli/doctor.mjs +14 -2
- package/cli/gate.mjs +8 -3
- package/cli/manifest.mjs +18 -2
- package/cli/openpr.mjs +12 -2
- package/cli/platform.mjs +105 -11
- package/cli/setup.mjs +61 -7
- package/cli/ship.mjs +37 -0
- package/docs/index.html +47 -16
- package/package.json +2 -2
- package/skills/sdlc/config.yaml +7 -2
- package/skills/sdlc/install.sh +1 -1
- package/skills/sdlc/module-help.csv +7 -4
- package/skills/yad-checks/references/check-gates.md +58 -2
- package/skills/yad-checks/templates/checks/commit-message.sh +82 -0
- package/skills/yad-checks/templates/github/yad-checks.yml +27 -0
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +36 -0
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +20 -0
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +39 -0
- package/skills/yad-commit/SKILL.md +66 -0
- 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-engineer-review/SKILL.md +86 -0
- package/skills/{yad-ship → yad-engineer-review}/references/ship-and-record.md +2 -2
- package/skills/{yad-ship → yad-engineer-review}/templates/.coderabbit.yaml +1 -1
- package/skills/yad-epic/references/state-schema.md +1 -1
- package/skills/yad-hub-bridge/references/login-roster.md +32 -5
- package/skills/yad-implement/SKILL.md +1 -1
- package/skills/yad-implement/references/implement-conventions.md +1 -1
- package/skills/yad-open-pr/SKILL.md +72 -0
- package/skills/yad-pr-template/SKILL.md +5 -0
- package/skills/yad-pr-template/templates/checks/pr-template.sh +62 -0
- package/skills/yad-pr-template/templates/checks/pr-title.sh +51 -0
- package/skills/yad-review-gate/references/gating.md +8 -2
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +4 -4
- package/skills/yad-ship/SKILL.md +44 -66
- package/skills/yad-spec/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
# [2.6.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.5.0...v2.6.0) (2026-06-14)
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
### Bug Fixes
|
|
5
5
|
|
|
6
|
-
*
|
|
6
|
+
* allow scoped/breaking commit subjects + titles; parse only the trailer block ([63444c0](https://github.com/abdelrahmannasr/yadflow/commit/63444c08c1e33b4151c7389eb5e87f6ae682aee6))
|
|
7
|
+
* harden pattern-gate CI — pass PR title via env, write body to mktemp ([2415397](https://github.com/abdelrahmannasr/yadflow/commit/2415397f81280f459785b8fa9ca29007b473561b))
|
|
8
|
+
* let `yad ship` derive the PR title from the committed subject ([a5adba3](https://github.com/abdelrahmannasr/yadflow/commit/a5adba3212b236ffc5a2470b9ea50bf97c6c4138))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add `yad ship` CLI to commit and open a PR/MR in one step ([c493e93](https://github.com/abdelrahmannasr/yadflow/commit/c493e93e5626eee590cd061c9e7dbc8047e78718))
|
|
14
|
+
* add commit-message/pr-title/pr-template pattern gates (code + hub) ([6658837](https://github.com/abdelrahmannasr/yadflow/commit/6658837d7685b826884b665a5e7661fd6ae99828))
|
|
15
|
+
* add yad-commit/yad-open-pr/yad-ship skills; rename Step E to yad-engineer-review ([c566567](https://github.com/abdelrahmannasr/yadflow/commit/c5665679e45f24ea53c682aca3a78eb52c9f984f))
|
|
7
16
|
|
|
8
17
|
# [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
|
|
9
18
|
|
package/README.md
CHANGED
|
@@ -60,7 +60,10 @@ human**. Detailed walkthroughs for each phase follow below.
|
|
|
60
60
|
| `skills/yad-pr-template/` | Build Step D: install the platform PR/MR template + risk routing (code repos **and** the hub). |
|
|
61
61
|
| `skills/yad-review-comments/` | Install platform-matched PR/MR review-comment scaffolds (code repos and the hub). |
|
|
62
62
|
| `skills/yad-hub-bridge/` | The templated PR/MR **review bridge**: open a review PR/MR on the hub and sync platform approvals/comments into the file ledger. |
|
|
63
|
-
| `skills/yad-
|
|
63
|
+
| `skills/yad-commit/` | Build helper: commit ONE staged atomic change by the conventions (Conventional subject, trailers, `--ai` footer, ≤3-file guard). |
|
|
64
|
+
| `skills/yad-open-pr/` | Build helper: open a code-repo task PR/MR from the committed template (push, prefill, roster auto-assign). |
|
|
65
|
+
| `skills/yad-ship/` | Build helper: commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`). |
|
|
66
|
+
| `skills/yad-engineer-review/` | Build Step E: AI review (advisory) → engineer review → merge + record in the build log. |
|
|
64
67
|
| `skills/yad-backfill/` | Generate a human-verified spec for already-built code (Repomix), gated per touched feature. |
|
|
65
68
|
| `skills/yad-run/` | Phase 4 orchestrator: drive a story's back half on the `automation` dial; kill switch. |
|
|
66
69
|
| `skills/yad-status/` | Read-only view: front chain, build-half dials, trust record, fleet roll-up. |
|
|
@@ -94,13 +97,15 @@ with `npx` from your **product hub** repo — no clone needed.
|
|
|
94
97
|
| `yad gate ci [--branch <head>] [--pr <n>]` | The CI entry the hub workflow calls on review/merge events: derive the epic/artifact from the `review/EP-*` branch, run the same sync, and commit **only the ledger** to the hub default branch (sweep every open review PR when no `--branch`). |
|
|
95
98
|
| `yad commit --type <t> -m <subject>` | Commit by the SDLC convention — Conventional subject, `Task`/`Contract-Change`/`Co-Authored-By` trailers, atomic-file guard. |
|
|
96
99
|
| `yad open-pr [--repo <name>]` | Open a code-repo **task** PR/MR from the repo's platform template (build half). |
|
|
100
|
+
| `yad ship --type <t> -m <subject>` | Commit **and** open the task PR/MR in one step (`yad commit` then `yad open-pr`). |
|
|
97
101
|
| `yad repo list` / `yad repo refresh [name]` | List connected repos as **fresh / stale**, and re-pack a stale one — staleness is now an explicit human decision, never an automatic skill side-effect. |
|
|
98
102
|
| `npx yadflow --version` | Print the installed CLI version. |
|
|
99
103
|
|
|
100
104
|
Flags: `--dir <path>` targets a project other than the cwd; `--force` re-copies unchanged files (or
|
|
101
105
|
bypasses the commit atomic guard). Commit flags: `--type`, `-m/--message`, `--task`, `--ai
|
|
102
106
|
<claude\|copilot\|cursor\|coderabbit\|none>`, `--contract-change`, `--dry-run`. `open-pr` flags:
|
|
103
|
-
`--repo`, `--risk <low\|medium\|high>`, `--contract-change`.
|
|
107
|
+
`--repo`, `--risk <low\|medium\|high>`, `--contract-change`. `ship` takes the union of the `commit`
|
|
108
|
+
and `open-pr` flags (it runs `open-pr` only if the commit lands).
|
|
104
109
|
|
|
105
110
|
### The PR-driven review gate
|
|
106
111
|
|
|
@@ -128,7 +133,7 @@ simultaneous advancements can be lost; the next event or scheduled sweep re-sync
|
|
|
128
133
|
### What `setup` walks you through (10 steps)
|
|
129
134
|
|
|
130
135
|
1. **Preflight** — confirm the hub is a git repo (offers `git init`); check `git`/`node`/`npx`.
|
|
131
|
-
2. **Install the module** — copy all
|
|
136
|
+
2. **Install the module** — copy all 25 `yad-*` skills into the IDE skill dirs you pick
|
|
132
137
|
(`.claude/`, `.agents/`, `.zencoder/`, `.opencode/`) and register `_bmad/sdlc/`.
|
|
133
138
|
3. **Hub platform & roster** — detect GitHub/GitLab from the remote; record reviewers → `.sdlc/hub.json`.
|
|
134
139
|
4. **Connect a design tool** — record the design tool (Figma / pencil / none) → `.sdlc/design.json` so
|
|
@@ -184,7 +189,7 @@ with a fix-it hint per finding. Failures carry stable, greppable codes, also pri
|
|
|
184
189
|
|
|
185
190
|
Filing a bug? Attach `yad doctor --json` — it contains no secrets (names, paths, and check results only).
|
|
186
191
|
|
|
187
|
-
## Agent skills (all
|
|
192
|
+
## Agent skills (all 25)
|
|
188
193
|
|
|
189
194
|
The CLI **installs and wires** the module; the skills below are the **agents you invoke by name** in your
|
|
190
195
|
AI IDE (e.g. *“run `yad-epic`”*) to actually do the work. State lives in files you can also edit
|
|
@@ -276,15 +281,25 @@ directly. Each skill stops at a gate and never auto-advances unless a step has *
|
|
|
276
281
|
(≤3 files) on its own branch. The diff stays inside the files the task declared (flag and STOP if it
|
|
277
282
|
would grow). Commit ends with the task ID; `Contract-Change: yes` only if it touches the locked
|
|
278
283
|
contract surface.
|
|
279
|
-
- **`yad-checks`** — Step C, the production-safety gates. Wire and run
|
|
284
|
+
- **`yad-checks`** — Step C, the production-safety gates. Wire and run the CI gates: **spec-link**
|
|
280
285
|
(every change links a real story/spec), **contract-check** (a contract-surface diff without a
|
|
281
|
-
re-locked contract FAILS),
|
|
286
|
+
re-locked contract FAILS), **build/test/lint**, **verified-commits** (signed + roster-known authors),
|
|
287
|
+
and the **pattern gates** — **commit-message** (Conventional subject + trailer order), **pr-title**,
|
|
288
|
+
and **pr-template** (the PR/MR body uses the template). Profile-aware (`code`|`hub`), so they run on
|
|
289
|
+
both code repos and the product hub. CI-agnostic bash for GitHub Actions and GitLab CI.
|
|
282
290
|
- **`yad-pr-template`** — Step D. Detect the repo's platform and commit the matching PR/MR template with
|
|
283
291
|
an Impact & Risk block; high risk (or a contract/auth/payments surface) routes the review to domain
|
|
284
|
-
owners. Includes `risk-route.sh
|
|
285
|
-
- **`yad-
|
|
286
|
-
|
|
287
|
-
|
|
292
|
+
owners. Includes `risk-route.sh` plus the `pr-title.sh` / `pr-template.sh` gate scripts.
|
|
293
|
+
- **`yad-commit`** — build helper. Commit ONE staged atomic change by the conventions (Conventional
|
|
294
|
+
subject, `Task → Contract-Change → Co-Authored-By` trailers, the `--ai` co-author footer, the ≤3-file
|
|
295
|
+
atomic guard). Drives `yad commit`.
|
|
296
|
+
- **`yad-open-pr`** — build helper. Open a code-repo task PR/MR from the committed template: push the
|
|
297
|
+
branch, prefill the body, auto-assign the repo-scoped roster. Drives `yad open-pr`.
|
|
298
|
+
- **`yad-ship`** — build helper. Commit **and** open the task PR/MR in one step (`yad commit` then
|
|
299
|
+
`yad open-pr`; the PR step runs only if the commit lands). Drives `yad ship`.
|
|
300
|
+
- **`yad-engineer-review`** — Step E. AI review (CodeRabbit, advisory) → engineer review (the human gate,
|
|
301
|
+
owner + 1 reviewer with the same escalation) → on merge, record the ship in the epic build-log and
|
|
302
|
+
update the story state so the epic → story → task → PR chain stays traceable.
|
|
288
303
|
- **`yad-backfill`** — Step G. Generate specs for already-built features in an existing repo so new work
|
|
289
304
|
doesn't break them: pack one feature at a time with Repomix, write a DRAFT spec, require human approval
|
|
290
305
|
before it counts. A change is blocked only until the features it touches have approved specs.
|
|
@@ -382,12 +397,16 @@ build half by hand”** below.
|
|
|
382
397
|
11. `yad-implement story:<id> repo:<repo> task:<T0N>` → one atomic task = one branch = one commit
|
|
383
398
|
(repeat per task). Commit by convention with **`yad commit --type <t> -m <subject> [--ai <tool>]`**
|
|
384
399
|
(Task/Contract-Change/Co-Authored-By trailers, atomic-file guard).
|
|
385
|
-
12. `yad-checks repo:<repo> action: run` → spec-link, contract-check, build/test/lint,
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
in
|
|
400
|
+
12. `yad-checks repo:<repo> action: run` → spec-link, contract-check, build/test/lint, verified-commits
|
|
401
|
+
(platform-Verified signature + roster-allowlisted author), and commit-message must pass. (The
|
|
402
|
+
`pr-title` / `pr-template` gates need the PR title + body, so they run in CI once the PR exists —
|
|
403
|
+
step 13.)
|
|
404
|
+
13. Open the PR/MR from the wired template with **`yad open-pr --repo <repo> [--risk <level>]`** (or do
|
|
405
|
+
12+13 in one step with **`yad ship --type <t> -m <subject> --repo <repo>`**). The PR's CI now also
|
|
406
|
+
runs the `pr-title` and `pr-template` gates; `yad-pr-template repo:<repo> action: route` prints the
|
|
407
|
+
required reviewers from the Impact & Risk block.
|
|
408
|
+
14. `yad-engineer-review` → `ai-review` (advisory) → `approve` (the human engineer gate) → `ship` (merge,
|
|
409
|
+
record in `build-log.json`, update story status to `in-build`/`shipped`).
|
|
391
410
|
- **Multi-repo:** repeat 10–14 in each repo, all from the **one** locked contract.
|
|
392
411
|
- **Existing code:** `yad-backfill` first, to produce a human-verified spec for a built feature.
|
|
393
412
|
|
|
@@ -514,16 +533,19 @@ the product repo. Code repos are **separate git repos** under `demo-repos/<repo>
|
|
|
514
533
|
(`feat/<story>-<task>-…`) = one PR. The diff stays inside the files the task declared. Commit with
|
|
515
534
|
**`yad commit`** — it builds the conventional subject, derives the `Task:` trailer from the branch
|
|
516
535
|
(add `--contract-change` only if the locked surface is touched), appends an optional `--ai` co-author,
|
|
517
|
-
and refuses a non-atomic stage. Open the PR with **`yad open-pr --repo <repo>`** (template prefilled)
|
|
518
|
-
|
|
536
|
+
and refuses a non-atomic stage. Open the PR with **`yad open-pr --repo <repo>`** (template prefilled),
|
|
537
|
+
or do both in one step with **`yad ship`** (commit then open-pr).
|
|
538
|
+
3. **Check gates** — `yad-checks` wires the CI gates (GitHub + GitLab) that must pass before merge:
|
|
519
539
|
**spec-link** (links a real story/spec), **contract-check** (a contract-surface change without
|
|
520
540
|
`Contract-Change` + a re-locked contract FAILS, routing back to the architecture gate),
|
|
521
|
-
**build/test/lint
|
|
541
|
+
**build/test/lint**, **verified-commits**, and the **pattern gates** **commit-message** / **pr-title**
|
|
542
|
+
/ **pr-template** (profile-aware `code`|`hub`, so they also run on the product hub). They fail closed
|
|
543
|
+
on a bad base ref.
|
|
522
544
|
4. **PR/MR template + risk routing** — `yad-pr-template` drops the platform-matched template with an
|
|
523
545
|
Impact & Risk block; `high` risk (or a contract/auth/payments surface) routes the review to domain
|
|
524
546
|
owners (`risk-route.sh`), the same escalation as the gate.
|
|
525
|
-
5. **AI review → engineer review →
|
|
526
|
-
the authority); a human engineer approves (owner + 1 reviewer, escalating to domain owners); on
|
|
547
|
+
5. **AI review → engineer review → merge** — `yad-engineer-review`: CodeRabbit is an advisory first pass
|
|
548
|
+
(never the authority); a human engineer approves (owner + 1 reviewer, escalating to domain owners); on
|
|
527
549
|
merge the ship is recorded in `.sdlc/build-log.json` and the story state becomes `in-build` →
|
|
528
550
|
`shipped`. The epic → story → task → PR → mergeCommit chain is traceable both ways.
|
|
529
551
|
|
|
@@ -551,7 +573,7 @@ with their dials, per repo) and `trust-log.json` (every run's verdict). See
|
|
|
551
573
|
- **Drive a story's back half:** `yad-run {story} {repo}` walks `spec → tasks → implement → checks`,
|
|
552
574
|
reading each step's dial. On `machine_advance` it advances on its own; on `human_approve` it stops
|
|
553
575
|
for a human; on any FAIL, scope overrun, or contract-surface touch it **halts and pulls in a human**.
|
|
554
|
-
It always stops at the engineer review (`yad-
|
|
576
|
+
It always stops at the engineer review (`yad-engineer-review`), which is never automated.
|
|
555
577
|
- **Read the trust log:** `yad-status {epic}` shows each back step's dial, status, and trust record —
|
|
556
578
|
runs, % `approved-unchanged`, and whether that clears the threshold (`automation.trust_threshold` in
|
|
557
579
|
`config.yaml`, default ≥5 runs and ≥80% unchanged). The engineer review records each run's verdict
|
package/bin/yad.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { gateOpen, gateSync, gateComments, gateStatus, gateCi } from '../cli/gat
|
|
|
8
8
|
import { isValidEpicId } from '../cli/epic-state.mjs';
|
|
9
9
|
import { runCommit } from '../cli/commit.mjs';
|
|
10
10
|
import { runOpenPr } from '../cli/openpr.mjs';
|
|
11
|
+
import { runShip } from '../cli/ship.mjs';
|
|
11
12
|
import { runRepo } from '../cli/repo.mjs';
|
|
12
13
|
import { runDoctor } from '../cli/doctor.mjs';
|
|
13
14
|
|
|
@@ -34,6 +35,7 @@ ${c.bold('Review gate (front half)')}
|
|
|
34
35
|
${c.bold('Build helpers')}
|
|
35
36
|
yad commit --type <t> -m <subject> Commit by convention (trailers, atomic guard)
|
|
36
37
|
yad open-pr [--repo <name>] Open a code-repo task PR/MR from the template
|
|
38
|
+
yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step
|
|
37
39
|
yad repo list Show connected repos (fresh / stale)
|
|
38
40
|
yad repo refresh [name] Re-pack a stale repo (a human decision)
|
|
39
41
|
|
|
@@ -123,6 +125,9 @@ async function main() {
|
|
|
123
125
|
case 'open-pr':
|
|
124
126
|
await runOpenPr(o.dir, { repo: o.repo, platform: o.platform, base: o.base, title: o.title || o.message, task: o.task, risk: o.risk, contractChange: o.contractChange });
|
|
125
127
|
break;
|
|
128
|
+
case 'ship':
|
|
129
|
+
await runShip(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, repo: o.repo, platform: o.platform, base: o.base, title: o.title, risk: o.risk });
|
|
130
|
+
break;
|
|
126
131
|
case 'repo': {
|
|
127
132
|
const [, action, name] = o._;
|
|
128
133
|
await runRepo(o.dir, { action: action || 'list', name, today });
|
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
|
}
|
|
@@ -184,6 +195,7 @@ export function ciTagsChecks(checks, root, hub, registry) {
|
|
|
184
195
|
fragments.push(
|
|
185
196
|
{ scope: 'hub', file: '.gitlab/ci/yad-gate-sync.yml', path: path.join(root, '.gitlab/ci/yad-gate-sync.yml') },
|
|
186
197
|
{ scope: 'hub', file: '.gitlab/ci/yad-verified-commits.yml', path: path.join(root, '.gitlab/ci/yad-verified-commits.yml') },
|
|
198
|
+
{ scope: 'hub', file: '.gitlab/ci/yad-hub-checks.yml', path: path.join(root, '.gitlab/ci/yad-hub-checks.yml') },
|
|
187
199
|
);
|
|
188
200
|
}
|
|
189
201
|
for (const repo of registry?.repos || []) {
|
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/manifest.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
11
|
export const VERSION = version;
|
|
12
12
|
|
|
13
|
-
// The
|
|
13
|
+
// The 25 hand-authored yad-* skills (mirrors skills/sdlc/install.sh).
|
|
14
14
|
export const SKILLS = [
|
|
15
15
|
'yad-analysis',
|
|
16
16
|
'yad-epic',
|
|
@@ -29,7 +29,10 @@ export const SKILLS = [
|
|
|
29
29
|
'yad-pr-template',
|
|
30
30
|
'yad-review-comments',
|
|
31
31
|
'yad-hub-bridge',
|
|
32
|
+
'yad-commit',
|
|
33
|
+
'yad-open-pr',
|
|
32
34
|
'yad-ship',
|
|
35
|
+
'yad-engineer-review',
|
|
33
36
|
'yad-backfill',
|
|
34
37
|
'yad-run',
|
|
35
38
|
'yad-review-gate',
|
|
@@ -51,7 +54,11 @@ export const LEGACY_SKILLS = {
|
|
|
51
54
|
'yad-pr-template': 'sdlc-pr-template',
|
|
52
55
|
'yad-review-comments': 'sdlc-review-comments',
|
|
53
56
|
'yad-hub-bridge': 'sdlc-hub-bridge',
|
|
54
|
-
|
|
57
|
+
// Step E ("ship") was renamed to yad-engineer-review (the yad-ship name now belongs to the new
|
|
58
|
+
// commit+open-PR combined skill). Pre-2.0 installs carry sdlc-ship → migrate it to yad-engineer-review.
|
|
59
|
+
// A 2.x install carrying yad-ship-as-Step-E is simply overwritten with the new combined content on
|
|
60
|
+
// `yad update` (same name, fresh copy), and yad-engineer-review is installed as a new skill.
|
|
61
|
+
'yad-engineer-review': 'sdlc-ship',
|
|
55
62
|
'yad-backfill': 'sdlc-backfill',
|
|
56
63
|
'yad-run': 'sdlc-run',
|
|
57
64
|
'yad-review-gate': 'sdlc-review-gate',
|
|
@@ -153,7 +160,10 @@ export const REPO_WIRING = {
|
|
|
153
160
|
{ src: 'skills/yad-checks/templates/checks/contract-check.sh', dest: 'checks/contract-check.sh', exec: true },
|
|
154
161
|
{ src: 'skills/yad-checks/templates/checks/build-test-lint.sh', dest: 'checks/build-test-lint.sh', exec: true },
|
|
155
162
|
{ src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
|
|
163
|
+
{ src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
|
|
156
164
|
{ src: 'skills/yad-pr-template/templates/checks/risk-route.sh', dest: 'checks/risk-route.sh', exec: true },
|
|
165
|
+
{ src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
|
|
166
|
+
{ src: 'skills/yad-pr-template/templates/checks/pr-template.sh', dest: 'checks/pr-template.sh', exec: true },
|
|
157
167
|
],
|
|
158
168
|
github: [
|
|
159
169
|
{ src: 'skills/yad-checks/templates/github/yad-checks.yml', dest: '.github/workflows/yad-checks.yml' },
|
|
@@ -179,13 +189,19 @@ export const wiringFor = (platform) => [
|
|
|
179
189
|
export const HUB_WIRING = {
|
|
180
190
|
common: [
|
|
181
191
|
{ src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
|
|
192
|
+
// Pattern gates run on the hub too (profile: hub) — commit subject + PR title + PR body.
|
|
193
|
+
{ src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
|
|
194
|
+
{ src: 'skills/yad-pr-template/templates/checks/pr-title.sh', dest: 'checks/pr-title.sh', exec: true },
|
|
195
|
+
{ src: 'skills/yad-pr-template/templates/checks/pr-template.sh', dest: 'checks/pr-template.sh', exec: true },
|
|
182
196
|
],
|
|
183
197
|
github: [
|
|
184
198
|
{ src: 'skills/yad-hub-bridge/templates/github/yad-gate-sync.yml', dest: '.github/workflows/yad-gate-sync.yml' },
|
|
185
199
|
{ src: 'skills/yad-checks/templates/github/yad-verified-commits.yml', dest: '.github/workflows/yad-verified-commits.yml' },
|
|
200
|
+
{ src: 'skills/yad-checks/templates/github/yad-hub-checks.yml', dest: '.github/workflows/yad-hub-checks.yml' },
|
|
186
201
|
],
|
|
187
202
|
gitlab: [
|
|
188
203
|
{ src: 'skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml', dest: '.gitlab/ci/yad-gate-sync.yml' },
|
|
189
204
|
{ src: 'skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml', dest: '.gitlab/ci/yad-verified-commits.yml' },
|
|
205
|
+
{ src: 'skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml', dest: '.gitlab/ci/yad-hub-checks.yml' },
|
|
190
206
|
],
|
|
191
207
|
};
|
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);
|