yadflow 4.0.0-next.3 → 4.0.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 +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/gate.mjs
CHANGED
|
@@ -7,17 +7,18 @@ import path from 'node:path';
|
|
|
7
7
|
import {
|
|
8
8
|
c, log, ok, info, warn, hand, fail, note, readJSON, readJSONStrict, writeJSON, run, pushWithRebase, isPlainObject, writeMirrored, emitJSON, refuse, collectWarning,
|
|
9
9
|
} from './lib.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { OWNING_COMMIT_ENV } from './hook.mjs';
|
|
11
|
+
import { PROJECT_FILES, isVerifiedLedger , productConfigPath, settingsEditHint } from './manifest.mjs';
|
|
11
12
|
import {
|
|
12
13
|
epicIds, epicRel, epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, acceptedHashes, isStaleHash, gatePredicate, printable,
|
|
13
14
|
advanceState, closingRecord, markInReview, isEscalated, gateRuleFor, gateCapFor, gateReach, uniqueReach, peopleWord, capSeat, gateRuleSum, gateRuleEnforced, parseReviewBranch, artifactFromBase, legacyLogins,
|
|
14
|
-
|
|
15
|
-
canonicalApprovals, canonicalComments,
|
|
15
|
+
upsertProductPr, stateInvariants, repairState, DISCOVERY_FILES, FOUNDATION_REQUIRED, unwrittenSections,
|
|
16
|
+
canonicalApprovals, canonicalComments, canonicalProductPrs, optionalStepsFor, isSkippableStep, writeState, routeLacksStep,
|
|
16
17
|
isPassed, stepStatus, claimsSkipped, claimsInherited, DISCOVERY_EPIC, FOUNDATION_DIR, FOUNDATION_EPIC, staleFoundationGuards,
|
|
17
18
|
} from './epic-state.mjs';
|
|
18
19
|
import { activePeople, activeSum, activeBasis, approverCount, printTeamHint, soloTeamHint } from './people.mjs';
|
|
19
20
|
import { applyProductMove, planProductMove } from './migrate.mjs';
|
|
20
|
-
import { productGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './
|
|
21
|
+
import { productGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './productcommit.mjs';
|
|
21
22
|
import {
|
|
22
23
|
readPr, mapApprovers, createPr, platformLogin, actorName, ambiguousLegacyNames, prNumberFromUrl,
|
|
23
24
|
getPrBody, editPrBody, postComment, findPrForBranch, prBranch, branchExists,
|
|
@@ -32,8 +33,8 @@ import { err } from './errors.mjs';
|
|
|
32
33
|
// (`actorName`). On CI that is usually the bot's git name — a job token cannot read `/user`. Best-effort,
|
|
33
34
|
// like every record's `by`: attribution never blocks a gate. Kept here rather than imported from
|
|
34
35
|
// skip.mjs, which imports this file.
|
|
35
|
-
function closingActor(root,
|
|
36
|
-
return actorName(root,
|
|
36
|
+
function closingActor(root, productConfig) {
|
|
37
|
+
return actorName(root, productConfig?.platform);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
// One line for a step's closing record — `yad gate status` prints it under a review step (E18), and
|
|
@@ -145,38 +146,38 @@ export function warnIncompleteDiscovery(epicDir, artifact) {
|
|
|
145
146
|
// Fail fast on a corrupt or wrong-shape Product config: a silently-defaulted hub.json would degrade
|
|
146
147
|
// every gate to local without anyone noticing, and a typo'd platform would read as a local ledger.
|
|
147
148
|
export function loadProduct(root) {
|
|
148
|
-
const
|
|
149
|
+
const productFile = productConfigPath(root);
|
|
149
150
|
const regFile = path.join(root, PROJECT_FILES.reposRegistry);
|
|
150
151
|
// Distinguish an ABSENT hub.json (null default → fine, local gate) from one that exists but
|
|
151
152
|
// holds literal `null` (malformed — must not silently downgrade to local).
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
throw err('YAD-STATE-002', `${
|
|
153
|
+
const productConfig = readJSONStrict(productFile, null);
|
|
154
|
+
if (productConfig === null && fs.existsSync(productFile)) {
|
|
155
|
+
throw err('YAD-STATE-002', `${productFile}: contains \`null\` — expected a config object`, 'fix the file or re-run `yad setup`');
|
|
155
156
|
}
|
|
156
|
-
if (
|
|
157
|
-
if (typeof
|
|
158
|
-
if (![null, undefined, 'github', 'gitlab'].includes(
|
|
159
|
-
throw err('YAD-CFG-001', `${
|
|
157
|
+
if (productConfig !== null) {
|
|
158
|
+
if (typeof productConfig !== 'object' || Array.isArray(productConfig)) throw err('YAD-STATE-002', `${productFile}: expected a JSON object`, 'fix the file or re-run `yad setup`');
|
|
159
|
+
if (![null, undefined, 'github', 'gitlab'].includes(productConfig.platform)) {
|
|
160
|
+
throw err('YAD-CFG-001', `${productFile}: unknown platform '${productConfig.platform}'`, 'expected github, gitlab, or null — fix the file or re-run `yad setup`');
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
const registry = readJSONStrict(regFile, { repos: [] });
|
|
163
164
|
if (!Array.isArray(registry?.repos)) throw err('YAD-STATE-002', `${regFile}: expected a \`repos\` array`, 'fix the file or re-run `yad setup`');
|
|
164
|
-
return {
|
|
165
|
+
return { productConfig, repos: registry.repos };
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
// Solo mode (a lone developer): waive the approval requirement — on GitHub you cannot approve your own
|
|
168
169
|
// PR, so an approval gate would deadlock. The review PR/MR and its merge stay (CI runs on the PR; the
|
|
169
170
|
// merge advances the step). Recorded per-project in hub.json by `yad setup`.
|
|
170
|
-
export const isSolo = (
|
|
171
|
+
export const isSolo = (productConfig) => !!(productConfig && (productConfig.solo === true || productConfig.review_gate?.solo === true));
|
|
171
172
|
|
|
172
173
|
// Verified mode: CI is the sole ledger writer, so `gate open`/`sync` stay hands-off. The predicate is
|
|
173
174
|
// defined once in manifest.mjs (`isVerifiedLedger`) and shared with plan.mjs's wiring and the ledger
|
|
174
175
|
// hook, so no two readers can disagree about who owns the ledger (#186).
|
|
175
176
|
|
|
176
|
-
// requireEngagement (config `
|
|
177
|
+
// requireEngagement (config `review.requireEngagement` in the Product settings): when on, the predicate counts only
|
|
177
178
|
// approvals carrying a verified engagement signal. Soft-off by default — a bare approve still counts
|
|
178
179
|
// but is recorded `engagement: none` and draws the friendly nudge.
|
|
179
|
-
export const requireEngagement = (
|
|
180
|
+
export const requireEngagement = (productConfig) => !!(productConfig && (productConfig.review?.requireEngagement === true));
|
|
180
181
|
|
|
181
182
|
// `legacyLogins` LIVES in cli/epic-state.mjs now (E71): the active-people reader needs the same table to
|
|
182
183
|
// recognise an older record, and importing it from here would have made a cycle (gate.mjs prints the
|
|
@@ -566,8 +567,8 @@ function recordComments(comments, { artifact, stepId, today, blocking, aliases =
|
|
|
566
567
|
// the PR for this artifact's review branch. Without that confirmation a typo'd number naming some
|
|
567
568
|
// unrelated merged-and-approved PR would have its reviewers bound to this artifact's hash and satisfy
|
|
568
569
|
// the gate.
|
|
569
|
-
function resolveTargets(
|
|
570
|
-
const recorded =
|
|
570
|
+
function resolveTargets(productPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd }) {
|
|
571
|
+
const recorded = productPrs.filter((p) => !artifact || p.artifact === artifact);
|
|
571
572
|
const named = number == null || number === '' ? null : Number(number);
|
|
572
573
|
if (named !== null && (!Number.isInteger(named) || named <= 0)) {
|
|
573
574
|
return { targets: [], discovered: false, reason: `--pr must be a positive integer, got '${number}'` };
|
|
@@ -577,7 +578,7 @@ function resolveTargets(hubPrs, { epic, artifact, state, platform, number, finde
|
|
|
577
578
|
const step = findReviewStep(state, artifact);
|
|
578
579
|
if (!step) return { targets: [], discovered: false, reason: `no review step for ${artifact}` };
|
|
579
580
|
const branch = `review/${epic}/${base(artifact)}`;
|
|
580
|
-
// `
|
|
581
|
+
// `upsertProductPr` replaces the whole entry for an artifact, so a record built from scratch DROPS
|
|
581
582
|
// whatever the recorded one carried. That matters when `--pr` names the PR already on file: `nudged`
|
|
582
583
|
// is the idempotency set for the engagement nudge, so losing it makes the next writer run
|
|
583
584
|
// re-@-mention every bare approver on the PR — a platform write, not just a ledger one — and `url`
|
|
@@ -633,31 +634,31 @@ function stageIndexIfClean(root, git, commits = null) {
|
|
|
633
634
|
}
|
|
634
635
|
|
|
635
636
|
export async function gateSync(root, { epic, artifact, today, reader = readPr, finder = findPrForBranch, branchOf = prBranch, poster = postComment, number = null, local = false, dryRun = false, headCount = null } = {}) {
|
|
636
|
-
const {
|
|
637
|
-
if (!
|
|
638
|
-
const platform =
|
|
639
|
-
const aliases = legacyLogins(
|
|
640
|
-
const clashed = ambiguousLegacyNames(
|
|
641
|
-
const solo = isSolo(
|
|
642
|
-
const reqEng = requireEngagement(
|
|
637
|
+
const { productConfig } = loadProduct(root);
|
|
638
|
+
if (!productConfig?.platform) { warn(`no Product platform configured (${settingsEditHint(root)}) — local gate, nothing to sync`); return { epic, synced: 0, advanced: 0, written: false, gates: [] }; }
|
|
639
|
+
const platform = productConfig.platform;
|
|
640
|
+
const aliases = legacyLogins(productConfig);
|
|
641
|
+
const clashed = ambiguousLegacyNames(productConfig);
|
|
642
|
+
const solo = isSolo(productConfig);
|
|
643
|
+
const reqEng = requireEngagement(productConfig);
|
|
643
644
|
// Local invocation in verified mode is ADVISORY: CI is the sole ledger writer, so a human run reads
|
|
644
645
|
// the platform and prints the predicate but writes nothing. CI calls gateSync with local=false.
|
|
645
646
|
// With a local ledger (platform but no gate-sync CI) the local command stays the writer.
|
|
646
647
|
// dryRun forces the same read-only behavior regardless of the ledger — used for the Path B pre-merge
|
|
647
648
|
// evaluation, which must persist nothing (gateCi passes dryRun for a held branch event).
|
|
648
|
-
const readOnly = (local && isVerifiedLedger(
|
|
649
|
+
const readOnly = (local && isVerifiedLedger(productConfig)) || dryRun;
|
|
649
650
|
// Who writes this run's closing records (E18). Not asked on a read-only run, which writes nothing.
|
|
650
|
-
const by = readOnly ? null : closingActor(root,
|
|
651
|
+
const by = readOnly ? null : closingActor(root, productConfig);
|
|
651
652
|
const epicDir = epicRoot(root, epic);
|
|
652
653
|
const ledger = loadLedger(epicDir);
|
|
653
654
|
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { epic, synced: 0, advanced: 0, written: false, gates: [] }; }
|
|
654
655
|
|
|
655
|
-
let { approvals, comments,
|
|
656
|
+
let { approvals, comments, productPrs, state } = ledger;
|
|
656
657
|
// Migration (see stampLegacyPr): an approval written before PR provenance existed carries no `pr`,
|
|
657
658
|
// so it can never be told apart from one arriving on a replacement PR — and on GitLab, with no
|
|
658
659
|
// submittedAt either, the other proof is unavailable too. The pointer recorded here IS the PR those
|
|
659
660
|
// approvals came from, so stamp them before anything replaces it.
|
|
660
|
-
for (const p of
|
|
661
|
+
for (const p of productPrs) {
|
|
661
662
|
const s = p.number != null ? findReviewStep(state, p.artifact) : null;
|
|
662
663
|
if (s) stampLegacyPr(approvals, s.id, p.number);
|
|
663
664
|
}
|
|
@@ -674,14 +675,14 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
674
675
|
}
|
|
675
676
|
approvals = stampedOld.approvals;
|
|
676
677
|
comments = stampedOld.comments;
|
|
677
|
-
const resolved = resolveTargets(
|
|
678
|
+
const resolved = resolveTargets(productPrs, { epic, artifact, state, platform, number, finder, branchOf, cwd: root });
|
|
678
679
|
// Advance in CHAIN order, never in ledger order. `advanceState` opens the step that FOLLOWS the one
|
|
679
680
|
// it closes, so syncing two passing gates out of chain order rewinds the epic: closing
|
|
680
681
|
// architecture-review first (next: ui-design) and epic-review second (next: architecture) reopens the
|
|
681
682
|
// already-done `architecture` author step and points currentStep backward — the YAD-STATE-005 chain
|
|
682
683
|
// inconsistency `yad gate repair` exists to undo. This used to hold only by accident, because
|
|
683
684
|
// hub-prs.json happened to be in insertion order; now that the file is written sorted by artifact
|
|
684
|
-
// (see
|
|
685
|
+
// (see canonicalProductPrs) the accident is gone, so make the ordering explicit. `gate ci` is unaffected
|
|
685
686
|
// either way — it always names a single artifact.
|
|
686
687
|
const stepIndex = (p) => {
|
|
687
688
|
const s = findReviewStep(state, p.artifact);
|
|
@@ -703,7 +704,7 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
703
704
|
// A pointer resolved from the platform is adopted into the ledger on the WRITER path only. In
|
|
704
705
|
// verified mode this run is advisory and writes nothing, so the human never ends up with a gate-state
|
|
705
706
|
// file in their working tree for the ledger-guard check to reject.
|
|
706
|
-
if (resolved.discovered && !readOnly)
|
|
707
|
+
if (resolved.discovered && !readOnly) productPrs = upsertProductPr(productPrs, targets[0]);
|
|
707
708
|
|
|
708
709
|
let synced = 0;
|
|
709
710
|
let advanced = 0;
|
|
@@ -874,16 +875,16 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
874
875
|
// here, so the first sweep after the upgrade converges the file once and never churns it again.
|
|
875
876
|
approvals = canonicalApprovals(approvals);
|
|
876
877
|
comments = canonicalComments(comments);
|
|
877
|
-
|
|
878
|
+
productPrs = canonicalProductPrs(productPrs);
|
|
878
879
|
if (stampedOld.stamped) info(`${epic}: recorded the platform login on ${stampedOld.stamped} older approval/comment record(s) that named a roster name`);
|
|
879
880
|
writeJSON(ledger.files.approvals, approvals);
|
|
880
881
|
writeJSON(ledger.files.comments, comments);
|
|
881
|
-
writeMirrored(ledger.files.productPrs, ledger.files.
|
|
882
|
+
writeMirrored(ledger.files.productPrs, ledger.files.productPrsLegacy, productPrs);
|
|
882
883
|
writeState(ledger.files.state, state);
|
|
883
884
|
refreshApprovalRecord(epicDir, open, approvals, today); // the dated side file lists them in the same order
|
|
884
885
|
// Only a PERSON's sync (`local`): `gateCi` calls this too, and rebuilds the index itself, inside the
|
|
885
886
|
// one allowlisted commit it makes on a merge — and never in its read-only pre-merge run.
|
|
886
|
-
if (local) refreshIndexAfterWrite(root,
|
|
887
|
+
if (local) refreshIndexAfterWrite(root, productConfig);
|
|
887
888
|
return { epic, synced, advanced, written: true, currentStep: state.currentStep ?? null, gates };
|
|
888
889
|
}
|
|
889
890
|
|
|
@@ -919,8 +920,8 @@ export async function gateSync(root, { epic, artifact, today, reader = readPr, f
|
|
|
919
920
|
// branch: `dirty` — uncommitted or untracked files under either folder BEFORE this run began (the move
|
|
920
921
|
// copies what is on disk, so they would be committed and pushed to the default branch), and a HEAD that
|
|
921
922
|
// is not the default branch (the push goes to the default branch whatever is checked out).
|
|
922
|
-
export function convertProductLevel(root,
|
|
923
|
-
if (!isVerifiedLedger(
|
|
923
|
+
export function convertProductLevel(root, productConfig, { git = (...a) => run('git', a, { cwd: root }), defaultBranch = 'main', dirty = false } = {}) {
|
|
924
|
+
if (!isVerifiedLedger(productConfig)) return null;
|
|
924
925
|
const move = planProductMove(root, { verified: true, ci: true });
|
|
925
926
|
if (!move) return null;
|
|
926
927
|
const out = (r) => (r?.ok ? String(r.stdout || '').trim() : '');
|
|
@@ -957,17 +958,17 @@ export function convertProductLevel(root, hub, { git = (...a) => run('git', a, {
|
|
|
957
958
|
}
|
|
958
959
|
|
|
959
960
|
export async function gateCi(root, { branch, pr, merged = false, today, push = true, reader = readPr } = {}) {
|
|
960
|
-
const {
|
|
961
|
-
if (!
|
|
961
|
+
const { productConfig } = loadProduct(root);
|
|
962
|
+
if (!productConfig?.platform) { warn(`no Product platform configured (${settingsEditHint(root)}) — nothing to sync`); return { synced: 0, committed: false, pushed: false }; }
|
|
962
963
|
const git = (...args) => run('git', args, { cwd: root });
|
|
963
|
-
const defaultBranch =
|
|
964
|
+
const defaultBranch = productConfig.default_branch || (() => { const h = git('rev-parse', '--abbrev-ref', 'HEAD').stdout; return h && h !== 'HEAD' ? h : 'main'; })();
|
|
964
965
|
// Push is decided AFTER the sync, once we know whether any step advanced: a held step (no advance,
|
|
965
966
|
// not merged) is read-only and pushes nothing; an advance lands on the default branch (see below).
|
|
966
967
|
|
|
967
968
|
// Whether the old product-level folder (or foundation/) held a person's uncommitted work BEFORE this
|
|
968
969
|
// run writes anything — read now, because the sync below legitimately modifies that ledger. Only asked
|
|
969
970
|
// when a move could happen at all; an unreadable status counts as dirty (refuse rather than guess).
|
|
970
|
-
const legacyDirtyBefore = (merged || !branch) && isVerifiedLedger(
|
|
971
|
+
const legacyDirtyBefore = (merged || !branch) && isVerifiedLedger(productConfig)
|
|
971
972
|
&& fs.existsSync(path.join(epicRoot(root, DISCOVERY_EPIC), '.sdlc', 'state.json'))
|
|
972
973
|
? (() => {
|
|
973
974
|
const st = git('status', '--porcelain', '--untracked-files=all', '--', epicRel(DISCOVERY_EPIC), FOUNDATION_DIR);
|
|
@@ -1002,7 +1003,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
1002
1003
|
continue;
|
|
1003
1004
|
}
|
|
1004
1005
|
if (!ledger.state) continue;
|
|
1005
|
-
for (const p of ledger.
|
|
1006
|
+
for (const p of ledger.productPrs || []) {
|
|
1006
1007
|
const step = findReviewStep(ledger.state, p.artifact);
|
|
1007
1008
|
if (!step || isPassed(step)) continue;
|
|
1008
1009
|
jobs.push({ epic: e, base: base(p.artifact), artifact: p.artifact, branch: p.branch, pr: p.number });
|
|
@@ -1022,7 +1023,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
1022
1023
|
// history once PER open review PR; reading it above the early exits (no platform, an unparseable
|
|
1023
1024
|
// branch, no jobs) would pay for a 360-day `git log --all` on every push and throw it away. It is
|
|
1024
1025
|
// one fact about the Product, so every job also prints the same number.
|
|
1025
|
-
const sweepCount = jobs.length ? activePeople(root, { today: today || undefined, aliases: legacyLogins(
|
|
1026
|
+
const sweepCount = jobs.length ? activePeople(root, { today: today || undefined, aliases: legacyLogins(productConfig) }) : null;
|
|
1026
1027
|
for (const job of jobs) {
|
|
1027
1028
|
const epicDir = epicRoot(root, job.epic);
|
|
1028
1029
|
// Event mode (--branch) targets a single epic: fail loudly. Sweep mode skips the bad epic.
|
|
@@ -1047,7 +1048,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
1047
1048
|
|
|
1048
1049
|
// The merge event may fire before any hub-prs record exists (Path B never wrote one pre-merge) —
|
|
1049
1050
|
// build the entry from the event itself so the advance commit carries it onto the default branch.
|
|
1050
|
-
const existing = (ledger.
|
|
1051
|
+
const existing = (ledger.productPrs || []).find((x) => x.artifact === job.artifact);
|
|
1051
1052
|
const number = Number(job.pr) || existing?.number || null;
|
|
1052
1053
|
// Same migration as gateSync, at the one point CI knows the OLD pointer: stamp the approvals it
|
|
1053
1054
|
// recorded before replacing it, or a re-review on the replacement PR can never be told from a
|
|
@@ -1068,11 +1069,11 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
1068
1069
|
}
|
|
1069
1070
|
}
|
|
1070
1071
|
if (!existing || existing.number !== number || existing.branch !== job.branch) {
|
|
1071
|
-
ledger.
|
|
1072
|
-
step: step.id, artifact: job.artifact, platform:
|
|
1072
|
+
ledger.productPrs = upsertProductPr(ledger.productPrs, {
|
|
1073
|
+
step: step.id, artifact: job.artifact, platform: productConfig.platform, number,
|
|
1073
1074
|
url: existing?.url ?? null, branch: job.branch, lastSyncedAt: existing?.lastSyncedAt ?? null,
|
|
1074
1075
|
});
|
|
1075
|
-
writeMirrored(ledger.files.productPrs, ledger.files.
|
|
1076
|
+
writeMirrored(ledger.files.productPrs, ledger.files.productPrsLegacy, ledger.productPrs);
|
|
1076
1077
|
}
|
|
1077
1078
|
|
|
1078
1079
|
// No overlay: at merge the artifact is on the default branch CI checked out, so artifactHash
|
|
@@ -1116,8 +1117,8 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
1116
1117
|
// not stop the review that merged.
|
|
1117
1118
|
let stampedCount = 0;
|
|
1118
1119
|
if (merged) {
|
|
1119
|
-
const aliases = legacyLogins(
|
|
1120
|
-
const clashed = ambiguousLegacyNames(
|
|
1120
|
+
const aliases = legacyLogins(productConfig);
|
|
1121
|
+
const clashed = ambiguousLegacyNames(productConfig);
|
|
1121
1122
|
for (const e of aliases.size ? epicIds(root) : []) {
|
|
1122
1123
|
if (touched.has(e) || failedEpics.has(e)) continue;
|
|
1123
1124
|
const dirty = git('status', '--porcelain', '--untracked-files=all', '--', path.join(epicRel(e), '.sdlc'), path.join(epicRel(e), 'reviews'));
|
|
@@ -1141,7 +1142,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
1141
1142
|
}
|
|
1142
1143
|
}
|
|
1143
1144
|
const moved = (merged || !branch)
|
|
1144
|
-
? convertProductLevel(root,
|
|
1145
|
+
? convertProductLevel(root, productConfig, { git, defaultBranch, dirty: legacyDirtyBefore })
|
|
1145
1146
|
: null;
|
|
1146
1147
|
if (!touched.size && !moved) return { synced, committed: false, pushed: false };
|
|
1147
1148
|
|
|
@@ -1211,7 +1212,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
1211
1212
|
// (E19 review). `stageIndexIfClean` keeps it out of a commit that does not hold what it read.
|
|
1212
1213
|
const onDefault = (() => {
|
|
1213
1214
|
const head = git('rev-parse', '--abbrev-ref', 'HEAD');
|
|
1214
|
-
return head.ok && head.stdout === resolveDefaultBranch(git,
|
|
1215
|
+
return head.ok && head.stdout === resolveDefaultBranch(git, productConfig);
|
|
1215
1216
|
})();
|
|
1216
1217
|
const indexStaged = onDefault ? stageIndexIfClean(root, git) : false;
|
|
1217
1218
|
if (!onDefault) info(`${INDEX_FILE} not rebuilt — this checkout is not on the default branch, the only place it is written`);
|
|
@@ -1230,29 +1231,32 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
1230
1231
|
const subject = moved
|
|
1231
1232
|
? `chore(gate): move the product level to ${FOUNDATION_DIR}/ (shape 8) [skip ci]`
|
|
1232
1233
|
: `chore(gate): ${sync} [skip ci]`;
|
|
1233
|
-
|
|
1234
|
-
|
|
1234
|
+
// The owning command's own ledger commit: the git pre-commit guard (E48) refuses HAND commits to these
|
|
1235
|
+
// files, so it is told this one is not. In CI no clone has that hook; a person re-running this locally does.
|
|
1236
|
+
const cm = run('git', ['commit', '-m', subject, ...(moved && touched.size ? ['-m', `Also: ${sync}.`] : []),
|
|
1237
|
+
...(stampedCount ? ['-m', `Also: recorded the platform login on ${stampedCount} older approval/comment record(s) (E64).`] : [])],
|
|
1238
|
+
{ cwd: root, env: OWNING_COMMIT_ENV() });
|
|
1235
1239
|
if (!cm.ok) { fail(`commit failed: ${cm.stderr || cm.stdout}`); process.exitCode = 1; return { synced, committed: false, pushed: false }; }
|
|
1236
1240
|
ok(`committed gate update: ${c.dim(subject)}`);
|
|
1237
1241
|
if (!push) return { synced, committed: true, pushed: false };
|
|
1238
1242
|
|
|
1239
1243
|
if (pushWithRebase(root, target).ok) { ok(`pushed to origin/${target}`); return { synced, committed: true, pushed: true }; }
|
|
1240
|
-
fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-
|
|
1244
|
+
fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-product-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
|
|
1241
1245
|
process.exitCode = 1;
|
|
1242
1246
|
return { synced, committed: true, pushed: false };
|
|
1243
1247
|
}
|
|
1244
1248
|
|
|
1245
1249
|
export async function gateComments(root, { epic, artifact, today, reader = readPr } = {}) {
|
|
1246
|
-
const {
|
|
1250
|
+
const { productConfig } = loadProduct(root);
|
|
1247
1251
|
// The --json answer (E1): each review PR, and what still blocks it.
|
|
1248
1252
|
const prs = [];
|
|
1249
|
-
if (!
|
|
1253
|
+
if (!productConfig?.platform) { warn('no Product platform configured — nothing to fetch'); return { epic, prs }; }
|
|
1250
1254
|
const epicDir = epicRoot(root, epic);
|
|
1251
1255
|
const ledger = loadLedger(epicDir);
|
|
1252
|
-
const targets = (ledger.
|
|
1256
|
+
const targets = (ledger.productPrs || []).filter((p) => !artifact || p.artifact === artifact);
|
|
1253
1257
|
if (!targets.length) { warn('no review PR recorded — run `yad gate open` first'); return { epic, prs }; }
|
|
1254
1258
|
for (const pr of targets) {
|
|
1255
|
-
const pull = reader(
|
|
1259
|
+
const pull = reader(productConfig.platform, pr.number, { cwd: root });
|
|
1256
1260
|
if (!pull.ok) { warn(`${pr.artifact}: ${pull.reason}`); prs.push({ artifact: pr.artifact, pr: pr.number, read: false, reason: pull.reason ?? null }); continue; }
|
|
1257
1261
|
const cr = pull.reviews.filter((r) => r.state === 'CHANGES_REQUESTED');
|
|
1258
1262
|
const unresolved = (pull.threads || []).filter((t) => !t.resolved);
|
|
@@ -1278,9 +1282,9 @@ export async function gateStatus(root, { epic, headCount: given = null } = {}) {
|
|
|
1278
1282
|
const epicDir = epicRoot(root, epic);
|
|
1279
1283
|
const ledger = loadLedger(epicDir);
|
|
1280
1284
|
if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
|
|
1281
|
-
const {
|
|
1282
|
-
const solo = isSolo(
|
|
1283
|
-
const reqEng = requireEngagement(
|
|
1285
|
+
const { productConfig } = loadProduct(root);
|
|
1286
|
+
const solo = isSolo(productConfig);
|
|
1287
|
+
const reqEng = requireEngagement(productConfig);
|
|
1284
1288
|
const optional = optionalStepsFor(ledger.state); // which steps THIS epic's route allows to be skipped
|
|
1285
1289
|
// E71 — read ONCE for the whole view, not once per step: it is a Product-wide fact, and a per-step
|
|
1286
1290
|
// read would walk every connected repo's history once for every gate on the screen.
|
|
@@ -1289,7 +1293,7 @@ export async function gateStatus(root, { epic, headCount: given = null } = {}) {
|
|
|
1289
1293
|
// wrong one.
|
|
1290
1294
|
// `given` is a count the caller already read, as `gateSync` takes one — the CLI passes none; a test
|
|
1291
1295
|
// passes one so the cap (E72) can be seen on a fixture that has no git history of its own.
|
|
1292
|
-
const headCount = given || activePeople(root, { aliases: legacyLogins(
|
|
1296
|
+
const headCount = given || activePeople(root, { aliases: legacyLogins(productConfig) });
|
|
1293
1297
|
log(`\n ${c.bold(epic)} ${c.dim(`currentStep: ${printable(ledger.state.currentStep) ?? '(none)'}${solo ? ' — solo mode (approval waived; merge still required)' : ''}`)}`);
|
|
1294
1298
|
// Printed in solo mode too, exactly as the per-step count is: someone who later switches to team mode
|
|
1295
1299
|
// can see the number their gates will be capped against (E72), before it holds anything for them.
|
|
@@ -1297,7 +1301,7 @@ export async function gateStatus(root, { epic, headCount: given = null } = {}) {
|
|
|
1297
1301
|
note(c.dim(activeBasis(headCount)));
|
|
1298
1302
|
// E74: in solo mode, suggest `yad mode team` when the count shows more than one person may work here.
|
|
1299
1303
|
// An unknown count is already said by `activeSum` just above, so only a known suggestion prints.
|
|
1300
|
-
printTeamHint(soloTeamHint(root,
|
|
1304
|
+
printTeamHint(soloTeamHint(root, productConfig, { solo, headCount }));
|
|
1301
1305
|
const reachSeen = new Set(); // E73: each warning line once per view, not once per step
|
|
1302
1306
|
const gates = []; // the --json answer (E1): each review step as this view reads it
|
|
1303
1307
|
for (const s of ledger.state.steps.filter((x) => x.type === 'review+approve')) {
|
|
@@ -1423,7 +1427,10 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
|
|
|
1423
1427
|
const indexRebuilt = push ? false : refreshIndexAfterWrite(root, readJSON(productConfigPath(root), null));
|
|
1424
1428
|
ok(`closed ${closed.length} stranded author step(s): ${c.dim(closed.join(', '))}`);
|
|
1425
1429
|
if (!push) {
|
|
1426
|
-
|
|
1430
|
+
// On a verified Product a hand commit of state.json is what the git pre-commit guard refuses (E48), and
|
|
1431
|
+
// that refusal sends the person back here: so the way on is `--push`, whose commit is the owning one.
|
|
1432
|
+
if (isVerifiedLedger(readJSON(productConfigPath(root), null))) hand('re-run `yad doctor` to confirm, then re-run with --push — on a verified Product that commit is the one yad makes; a hand commit of state.json is refused');
|
|
1433
|
+
else hand(`re-run \`yad doctor\` to confirm, then commit epics/*/.sdlc/state.json${indexRebuilt ? ` and ${INDEX_FILE}` : ''} (or re-run with --push)`);
|
|
1427
1434
|
return did(true);
|
|
1428
1435
|
}
|
|
1429
1436
|
|
|
@@ -1431,8 +1438,8 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
|
|
|
1431
1438
|
preflightGuardReadiness(root);
|
|
1432
1439
|
const git = productGit(root);
|
|
1433
1440
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
1434
|
-
const defaultBranch = resolveDefaultBranch(git, loadProduct(root).
|
|
1435
|
-
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return did(true);
|
|
1441
|
+
const defaultBranch = resolveDefaultBranch(git, loadProduct(root).productConfig);
|
|
1442
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair', root })) return did(true);
|
|
1436
1443
|
|
|
1437
1444
|
const spec = path.relative(root, ledger.files.state);
|
|
1438
1445
|
if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return did(true); }
|
|
@@ -1444,7 +1451,8 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
|
|
|
1444
1451
|
const specs = [spec, ...(branch === defaultBranch && !isVerifiedLedger(readJSON(productConfigPath(root), null)) && stageIndexIfClean(root, git, [spec]) ? [INDEX_FILE] : [])];
|
|
1445
1452
|
|
|
1446
1453
|
const message = buildRepairMessage({ epic, steps: closed });
|
|
1447
|
-
|
|
1454
|
+
// `yad gate repair` IS the command the git pre-commit guard (E48) sends a person to, so its commit passes it.
|
|
1455
|
+
const cm = run('git', ['commit', '-m', message, '--', ...specs], { cwd: root, env: OWNING_COMMIT_ENV() });
|
|
1448
1456
|
if (!cm.ok) {
|
|
1449
1457
|
git('reset', '-q', '--', ...specs); // never leave them staged for an unrelated commit to sweep up
|
|
1450
1458
|
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
@@ -1466,7 +1474,7 @@ export async function gateRepair(root, { epic, push = false, allowBranch = false
|
|
|
1466
1474
|
// the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
|
|
1467
1475
|
// the real pushed head so the PR targets a branch that exists. `creator` is injected in tests.
|
|
1468
1476
|
export async function gateOpen(root, { epic, artifact, head, creator = createPr, hasBranch = branchExists, today = new Date().toISOString().slice(0, 10) } = {}) {
|
|
1469
|
-
const {
|
|
1477
|
+
const { productConfig } = loadProduct(root);
|
|
1470
1478
|
const epicDir = epicRoot(root, epic);
|
|
1471
1479
|
const ledger = loadLedger(epicDir);
|
|
1472
1480
|
if (!ledger.state) { fail(`no epic state at ${epicDir}`); process.exitCode = 1; return; }
|
|
@@ -1492,7 +1500,7 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
|
|
|
1492
1500
|
warnUnlockedContract(epicDir, artifact);
|
|
1493
1501
|
warnIncompleteDiscovery(epicDir, artifact);
|
|
1494
1502
|
|
|
1495
|
-
const verified = isVerifiedLedger(
|
|
1503
|
+
const verified = isVerifiedLedger(productConfig);
|
|
1496
1504
|
// The review branch must exist ON ORIGIN: this command opens a PR against it, it never creates or
|
|
1497
1505
|
// pushes it, and `gh pr create --head` explicitly does NOT push either — so a branch that is only
|
|
1498
1506
|
// local still fails inside the platform CLI, which is the opaque error this guard exists to replace.
|
|
@@ -1502,7 +1510,7 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
|
|
|
1502
1510
|
//
|
|
1503
1511
|
// Checked BEFORE any state is written: marking the step in_review and then refusing would leave the
|
|
1504
1512
|
// ledger claiming a review is open that was never opened.
|
|
1505
|
-
if (!head &&
|
|
1513
|
+
if (!head && productConfig?.platform) {
|
|
1506
1514
|
const present = hasBranch(root, branch);
|
|
1507
1515
|
if (present === false) {
|
|
1508
1516
|
fail(`review branch '${branch}' is not on origin`);
|
|
@@ -1520,11 +1528,11 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
|
|
|
1520
1528
|
const author = ledger.state.steps.find((s) => s?.type === 'author' && s.artifact === step.artifact && s.id !== step.id);
|
|
1521
1529
|
const closesAuthor = !verified && !!author && !isPassed(author) && !author.closed;
|
|
1522
1530
|
if (!verified) {
|
|
1523
|
-
ledger.state = markInReview(ledger.state, step, { by: closingActor(root,
|
|
1531
|
+
ledger.state = markInReview(ledger.state, step, { by: closingActor(root, productConfig), date: today, hash: artifactHash(epicDir, step.artifact) });
|
|
1524
1532
|
writeState(ledger.files.state, ledger.state);
|
|
1525
|
-
refreshIndexAfterWrite(root,
|
|
1533
|
+
refreshIndexAfterWrite(root, productConfig);
|
|
1526
1534
|
}
|
|
1527
|
-
if (!
|
|
1535
|
+
if (!productConfig?.platform) {
|
|
1528
1536
|
warn('no Product platform — marked in_review locally (no PR opened)');
|
|
1529
1537
|
ok(`${step.id} → in_review`);
|
|
1530
1538
|
return { epic, step: step.id, artifact, branch, opened: false, url: null, markedInReview: true };
|
|
@@ -1533,9 +1541,9 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
|
|
|
1533
1541
|
// Open the PR. In verified mode CI records the hub-prs entry (and advances) on the default branch at
|
|
1534
1542
|
// merge — `yad gate open` never commits gate-state files (the ledger-guard check enforces that), and
|
|
1535
1543
|
// CI writes nothing pre-merge. With a local ledger the local command records the PR itself (no CI will).
|
|
1536
|
-
const body =
|
|
1544
|
+
const body = fillProductTemplate({
|
|
1537
1545
|
epic, artifact, step, owner: ownerOf(epicDir), domains,
|
|
1538
|
-
active: activePeople(root, { aliases: legacyLogins(
|
|
1546
|
+
active: activePeople(root, { aliases: legacyLogins(productConfig) }).capacity.active,
|
|
1539
1547
|
// Does this epic's ROUTE have an architecture step? Asked of the recorded route and never of the
|
|
1540
1548
|
// chain (`routeLacksStep`): a truncated legacy chain has no `architecture` row and is not on a
|
|
1541
1549
|
// short lane, and telling its reviewer in writing that it is would be a false claim in a record
|
|
@@ -1548,11 +1556,11 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
|
|
|
1548
1556
|
// roles, and there is no stored list to pick them from any more. The team requests them on the PR,
|
|
1549
1557
|
// and E68 will suggest them from history. Said on the way out, so nobody waits for a request that
|
|
1550
1558
|
// was never sent.
|
|
1551
|
-
const committer =
|
|
1559
|
+
const committer = productConfig.platform === 'gitlab' ? platformLogin(root, productConfig.platform) : null;
|
|
1552
1560
|
const assignees = committer ? [committer] : [];
|
|
1553
1561
|
const labels = domains.map((d) => `domain:${d}`); // empty unless the step names its repos (touchedDomains)
|
|
1554
|
-
info(`opening review ${
|
|
1555
|
-
const r = creator(
|
|
1562
|
+
info(`opening review ${productConfig.platform === 'gitlab' ? 'MR' : 'PR'} on branch ${branch} …`);
|
|
1563
|
+
const r = creator(productConfig.platform, { title: `review: ${artifact} (${epic})`, body, base: productConfig.default_branch || 'main', head: branch, assignees, labels, cwd: root });
|
|
1556
1564
|
if (!r.ok) {
|
|
1557
1565
|
warn(`could not open PR (${r.reason || 'unknown'})${verified ? ' — open it manually; CI records the gate on merge' : '; step is in_review locally'}`);
|
|
1558
1566
|
return { epic, step: step.id, artifact, branch, opened: false, url: null, markedInReview: !verified, reason: r.reason || null };
|
|
@@ -1566,21 +1574,21 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
|
|
|
1566
1574
|
// sync would stamp THAT number on them, so an approval given again on the new PR could never be told
|
|
1567
1575
|
// from a re-read of the old one and, on GitLab (whose older records hold no submission time), would stay
|
|
1568
1576
|
// stale for good.
|
|
1569
|
-
const previous = (ledger.
|
|
1577
|
+
const previous = (ledger.productPrs || []).find((x) => x.artifact === artifact)?.number ?? null;
|
|
1570
1578
|
// Also when the new URL carries no number: the old pointer is about to be overwritten either way.
|
|
1571
1579
|
if (previous != null && previous !== opened && stampLegacyPr(ledger.approvals, step.id, previous)) {
|
|
1572
1580
|
writeJSON(ledger.files.approvals, canonicalApprovals(ledger.approvals));
|
|
1573
1581
|
}
|
|
1574
|
-
ledger.
|
|
1575
|
-
writeMirrored(ledger.files.productPrs, ledger.files.
|
|
1582
|
+
ledger.productPrs = upsertProductPr(ledger.productPrs, { step: step.id, artifact, platform: productConfig.platform, number: opened, url: r.url, branch, lastSyncedAt: null });
|
|
1583
|
+
writeMirrored(ledger.files.productPrs, ledger.files.productPrsLegacy, ledger.productPrs);
|
|
1576
1584
|
// The record was written before the PR existed, and the first close wins, so no later sync can add
|
|
1577
1585
|
// the number. Add it here, to the record this run wrote and to nothing older (E18).
|
|
1578
|
-
const number = ledger.
|
|
1586
|
+
const number = ledger.productPrs.find((p) => p.artifact === artifact)?.number ?? null;
|
|
1579
1587
|
const closedHere = closesAuthor ? ledger.state.steps.find((s) => s.id === author.id) : null;
|
|
1580
1588
|
if (number != null && closedHere?.closed?.via === 'review-opened' && closedHere.closed.pr == null) {
|
|
1581
1589
|
closedHere.closed = closingRecord({ ...closedHere.closed, pr: number });
|
|
1582
1590
|
writeState(ledger.files.state, ledger.state);
|
|
1583
|
-
refreshIndexAfterWrite(root,
|
|
1591
|
+
refreshIndexAfterWrite(root, productConfig);
|
|
1584
1592
|
}
|
|
1585
1593
|
}
|
|
1586
1594
|
ok(`opened ${r.url}`);
|
|
@@ -1597,24 +1605,24 @@ export async function gateOpen(root, { epic, artifact, head, creator = createPr,
|
|
|
1597
1605
|
// consumes this JSON, generates, and posts back via the platform (trailer/comments/approval).
|
|
1598
1606
|
// Assemble (but don't print) the Shape grounding bundle. Shared by `review` and `walkthrough` so the
|
|
1599
1607
|
// pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
|
|
1600
|
-
// Returns { error } when there is no epic state, else { bundle, epicDir,
|
|
1608
|
+
// Returns { error } when there is no epic state, else { bundle, epicDir, productConfig }.
|
|
1601
1609
|
function reviewBundle(root, { epic, artifact, headCount = null } = {}) {
|
|
1602
|
-
const {
|
|
1610
|
+
const { productConfig, repos } = loadProduct(root);
|
|
1603
1611
|
const epicDir = epicRoot(root, epic);
|
|
1604
1612
|
const ledger = loadLedger(epicDir);
|
|
1605
1613
|
if (!ledger.state) return { error: `no epic state at ${epicDir}` };
|
|
1606
|
-
const pr = (ledger.
|
|
1614
|
+
const pr = (ledger.productPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
|
|
1607
1615
|
const art = artifact || pr?.artifact || null;
|
|
1608
1616
|
const step = art ? findReviewStep(ledger.state, art) : null;
|
|
1609
1617
|
// E71 — the live capacity count. Its own read, and still once per command: `reviewBundle` has exactly
|
|
1610
1618
|
// two callers, `gate review` and `gate walkthrough`, and neither runs alongside `gate status` or
|
|
1611
1619
|
// `gate sync`. Read before the bundle because the step's cap (E72) is computed from it. `headCount`
|
|
1612
1620
|
// is a count the caller already read, as `gateSync` and `gateStatus` take one — the CLI passes none.
|
|
1613
|
-
const counted = headCount || activePeople(root, { aliases: legacyLogins(
|
|
1621
|
+
const counted = headCount || activePeople(root, { aliases: legacyLogins(productConfig) });
|
|
1614
1622
|
const bundle = {
|
|
1615
1623
|
epic,
|
|
1616
1624
|
artifact: art,
|
|
1617
|
-
platform:
|
|
1625
|
+
platform: productConfig?.platform || null,
|
|
1618
1626
|
pr: pr ? { number: pr.number, url: pr.url } : null,
|
|
1619
1627
|
// `gateRule` is E7's per-step rule — the number of distinct approvers the count asks for and the
|
|
1620
1628
|
// arithmetic behind it. `escalated` says the step carries a risk tag that raises the count. `cap`
|
|
@@ -1634,13 +1642,13 @@ function reviewBundle(root, { epic, artifact, headCount = null } = {}) {
|
|
|
1634
1642
|
name: r.name,
|
|
1635
1643
|
codeMap: r.name ? path.join(root, '.sdlc/code-context', r.name, 'code-map.md') : null,
|
|
1636
1644
|
})),
|
|
1637
|
-
requireEngagement: requireEngagement(
|
|
1645
|
+
requireEngagement: requireEngagement(productConfig),
|
|
1638
1646
|
markers: {
|
|
1639
1647
|
trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->',
|
|
1640
1648
|
engagementVerified: '<!-- yad:engagement verified -->', pair: '<!-- yad:pair -->',
|
|
1641
1649
|
},
|
|
1642
1650
|
};
|
|
1643
|
-
return { bundle, epicDir,
|
|
1651
|
+
return { bundle, epicDir, productConfig };
|
|
1644
1652
|
}
|
|
1645
1653
|
|
|
1646
1654
|
export async function gateReview(root, { epic, artifact, headCount = null } = {}) {
|
|
@@ -1656,8 +1664,8 @@ export async function gateReview(root, { epic, artifact, headCount = null } = {}
|
|
|
1656
1664
|
export async function gateWalkthrough(root, { epic, artifact, runner = run } = {}) {
|
|
1657
1665
|
const r = reviewBundle(root, { epic, artifact });
|
|
1658
1666
|
if (r.error) { refuse(r.error, null, { json: true }); return; }
|
|
1659
|
-
const { bundle,
|
|
1660
|
-
const defaultBranch =
|
|
1667
|
+
const { bundle, productConfig } = r;
|
|
1668
|
+
const defaultBranch = productConfig?.default_branch || 'main';
|
|
1661
1669
|
let stops = [];
|
|
1662
1670
|
if (bundle.artifactPath) {
|
|
1663
1671
|
const rel = path.relative(root, bundle.artifactPath) || bundle.artifact;
|
|
@@ -1679,17 +1687,17 @@ export async function gateWalkthrough(root, { epic, artifact, runner = run } = {
|
|
|
1679
1687
|
// briefing text and passes it here; this upserts it idempotently into the review PR/MR description as a
|
|
1680
1688
|
// delimited block, so regenerating on every artifact change never duplicates it. A platform write only.
|
|
1681
1689
|
export async function gateTrailer(root, { epic, artifact, body, number, getBody = getPrBody, editBody = editPrBody } = {}) {
|
|
1682
|
-
const {
|
|
1683
|
-
if (!
|
|
1690
|
+
const { productConfig } = loadProduct(root);
|
|
1691
|
+
if (!productConfig?.platform) { warn('no Product platform configured — the trailer posts to the PR/MR (local has none)'); return { number: null, posted: false }; }
|
|
1684
1692
|
if (!body || !String(body).trim()) { fail('trailer body is required: `yad gate trailer <epic> <artifact> --body <text>` (the companion generates it)'); process.exitCode = 1; return; }
|
|
1685
1693
|
const epicDir = epicRoot(root, epic);
|
|
1686
1694
|
const ledger = loadLedger(epicDir);
|
|
1687
|
-
const pr = (ledger.
|
|
1695
|
+
const pr = (ledger.productPrs || []).find((p) => !artifact || p.artifact === artifact) || null;
|
|
1688
1696
|
const n = number || pr?.number;
|
|
1689
1697
|
if (!n) { warn('no PR number — pass `--pr <n>` (in verified mode the PR is recorded in the ledger only at merge)'); return { number: null, posted: false }; }
|
|
1690
|
-
const cur = getBody(
|
|
1698
|
+
const cur = getBody(productConfig.platform, n, { cwd: root });
|
|
1691
1699
|
if (!cur.ok) { fail(`could not read PR #${n} description: ${cur.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
1692
|
-
const r = editBody(
|
|
1700
|
+
const r = editBody(productConfig.platform, n, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: root });
|
|
1693
1701
|
if (!r.ok) { fail(`could not update PR #${n}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
1694
1702
|
ok(`trailer posted to PR #${n}`);
|
|
1695
1703
|
return { number: n, posted: true };
|
|
@@ -1701,7 +1709,7 @@ const base = (artifact) => artifactBase(artifact);
|
|
|
1701
1709
|
// `active` is the caller's already-read count (E71, `activePeople`), or null when it did not read one.
|
|
1702
1710
|
// Passed in rather than read here for the same reason the predicate takes it: this builds a string and
|
|
1703
1711
|
// must stay callable from a test without a Product on disk.
|
|
1704
|
-
export function
|
|
1712
|
+
export function fillProductTemplate({ epic, artifact, step, owner, domains, hasArchitecture = true, active = null }) {
|
|
1705
1713
|
const rule = gateRuleFor(step);
|
|
1706
1714
|
const cap = gateCapFor(rule, active);
|
|
1707
1715
|
// What the count asks for. Only the base holds (until E108); the cap (E72) is shown when it lowered the
|
|
@@ -1736,8 +1744,8 @@ export function fillHubTemplate({ epic, artifact, step, owner, domains, hasArchi
|
|
|
1736
1744
|
'- **Approve** to record your approval; **comment / request changes** to hold the gate.',
|
|
1737
1745
|
'- This step advances when approvals are satisfied, all threads are resolved, and this PR is merged.',
|
|
1738
1746
|
'',
|
|
1739
|
-
// Required by the Product `pr-template` gate (
|
|
1740
|
-
// committed static template (yad-pr-template/templates/
|
|
1747
|
+
// Required by the Product `pr-template` gate (check_product_body). Mirrors the Checklist block of the
|
|
1748
|
+
// committed static template (yad-pr-template/templates/product/<platform>/) so the generated body
|
|
1741
1749
|
// passes on the first CI run.
|
|
1742
1750
|
'## Checklist',
|
|
1743
1751
|
'- [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)',
|