@juangcarmona/lighthouse-sdlc 0.17.0 → 0.18.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/README.md +15 -3
- package/capabilities.json +2 -1
- package/completion.md +2 -0
- package/epic-delivery.md +35 -0
- package/github-actions.md +19 -3
- package/host-contract.md +2 -0
- package/package.json +4 -1
- package/schemas/product-change.schema.json +23 -4
- package/schemas/stage-result.schema.json +23 -1
- package/skills/product-change/SKILL.md +3 -3
- package/skills/refine/SKILL.md +3 -2
- package/src/completion-actions.mjs +57 -13
- package/src/completion.mjs +1 -1
- package/src/contracts.mjs +10 -0
- package/src/decompose.mjs +5 -2
- package/src/epic-dispatch.mjs +59 -0
- package/src/epic-github.mjs +111 -0
- package/src/epic-journal.mjs +104 -0
- package/src/epic-merge.mjs +91 -0
- package/src/epic-native.mjs +465 -0
- package/src/epic.mjs +86 -0
- package/src/handoff.mjs +97 -0
- package/src/host-contract.mjs +22 -6
- package/src/implementation.mjs +33 -5
- package/src/install-workflows.mjs +1 -1
- package/src/issue-actions.mjs +360 -78
- package/src/opencode-execute.mjs +20 -0
- package/src/pr-state-actions.mjs +21 -7
- package/src/pr-state.mjs +18 -1
- package/src/process-publication.mjs +2 -1
- package/src/product-change-actions.mjs +158 -46
- package/src/product-change.mjs +92 -11
- package/src/proposal-review.mjs +16 -6
- package/src/proposal.mjs +13 -1
- package/src/stacked.mjs +14 -3
- package/src/stage-dispatch.mjs +66 -19
- package/src/stage-transition.mjs +151 -2
- package/src/triage.mjs +4 -2
- package/src/unattended-actions.mjs +39 -12
- package/src/unattended-planning-evidence.mjs +44 -0
- package/src/unattended.mjs +1 -1
- package/src/worker-maintenance.mjs +15 -3
- package/src/worker-recovery.mjs +204 -6
- package/workflows/lighthouse-sdlc-apply-product-change.lock.yml +97 -11
- package/workflows/lighthouse-sdlc-apply-product-change.md +75 -6
- package/workflows/lighthouse-sdlc-apply-review.lock.yml +33 -2
- package/workflows/lighthouse-sdlc-apply-review.md +29 -1
- package/workflows/lighthouse-sdlc-epic.yml +79 -0
- package/workflows/lighthouse-sdlc-implement.lock.yml +38 -2
- package/workflows/lighthouse-sdlc-implement.md +33 -2
- package/workflows/lighthouse-sdlc-integrate.lock.yml +4 -1
- package/workflows/lighthouse-sdlc-integrate.md +3 -0
- package/workflows/lighthouse-sdlc-product-change.lock.yml +39 -4
- package/workflows/lighthouse-sdlc-product-change.md +39 -6
- package/workflows/lighthouse-sdlc-propose.lock.yml +13 -1
- package/workflows/lighthouse-sdlc-propose.md +11 -1
- package/workflows/lighthouse-sdlc-refine.lock.yml +29 -2
- package/workflows/lighthouse-sdlc-refine.md +25 -2
- package/workflows/lighthouse-sdlc-review.lock.yml +4 -1
- package/workflows/lighthouse-sdlc-review.md +3 -0
- package/workflows/lighthouse-sdlc-router.yml +47 -3
- package/workflows/lighthouse-sdlc-triage.lock.yml +30 -3
- package/workflows/lighthouse-sdlc-triage.md +26 -3
- package/workflows/lighthouse-worker-maintenance.yml +70 -1
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { issueComments, readDispatchReceipt } from './stage-dispatch.mjs';
|
|
5
|
+
import { taskDigest } from './issue-actions.mjs';
|
|
6
|
+
import { loadRefinement } from './proposal.mjs';
|
|
7
|
+
import { validateStageEnvelope } from './host-contract.mjs';
|
|
8
|
+
import { assessDecomposition, epicPlanDigest } from './epic.mjs';
|
|
9
|
+
import { reconcileEpicChildren, readEpicMarker, epicMarker, epicCorrelation, epicChildBody, epicJournalKey } from './epic-github.mjs';
|
|
10
|
+
import { createEpicJournal, readEpicChildReceipt, readEpicResourceReceipt } from './epic-journal.mjs';
|
|
11
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
12
|
+
import { checkImplementationPullRequest } from './proposal-review.mjs';
|
|
13
|
+
import { readActionsJson } from './actions-artifact.mjs';
|
|
14
|
+
import { loadDispatch, checkDispatchClaim } from './stage-dispatch.mjs';
|
|
15
|
+
import { validateChanges, validateCheckReceipt } from './implementation.mjs';
|
|
16
|
+
import { digest } from './host-contract.mjs';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { list, loadProfile, requiredPolicy, checkCommit } from './completion.mjs';
|
|
19
|
+
import { observeEpicMerge, mergeEpicChild } from './epic-merge.mjs';
|
|
20
|
+
import { setPullRequestDraft, projectImplementationPrState } from './pr-state.mjs';
|
|
21
|
+
|
|
22
|
+
const isOwnerAnswer = (comment, owner) => comment.user?.login === owner && !comment.body?.includes('<!-- lighthouse-run:') && !comment.body?.includes('<!-- lighthouse-actions:');
|
|
23
|
+
const paused = issue => issue.state !== 'open' || issue.pull_request || issue.labels.some(label => ['future', 'human-working', 'agent-working', 'needs-info', 'waiting-review', 'execution-failed', 'triage', 'refine', 'propose', 'implement'].includes(label.name));
|
|
24
|
+
|
|
25
|
+
export async function prepareNativeEpic(api, repository, owner, issueNumber, load = loadRefinement) {
|
|
26
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') || typeof owner !== 'string' || !Number.isSafeInteger(issueNumber) || issueNumber < 1) throw Error('Invalid epic identity');
|
|
27
|
+
const issue = await api(`issues/${issueNumber}`), comments = await issueComments(api, issueNumber), repo = await api('');
|
|
28
|
+
if (paused(issue) || !issue.labels.some(label => label.name === 'ready')) throw Error('Epic parent is not ready or is paused');
|
|
29
|
+
const sourceRevision = (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha;
|
|
30
|
+
const current = { owner, issue: issueNumber, sourceRevision, taskDigest: taskDigest(issue, comments.filter(comment => isOwnerAnswer(comment, owner))) };
|
|
31
|
+
const evidence = await load(api, repository, comments, current);
|
|
32
|
+
if (evidence.request?.issue !== issueNumber || evidence.request.stage !== 'refine' || evidence.request.sourceRevision !== sourceRevision || evidence.request.taskDigest !== current.taskDigest || validateStageEnvelope(evidence.envelope, evidence.request).length) throw Error('Epic refinement result is invalid or stale');
|
|
33
|
+
const assessment = assessDecomposition(evidence.envelope.result.output);
|
|
34
|
+
if (assessment.status !== 'planned') throw Error('Epic refinement has no valid oversized plan');
|
|
35
|
+
const parentNumber = assessment.plan.parent.number ?? assessment.plan.parent.id.match(/^(?:GH-)?([1-9]\d*)$/)?.[1];
|
|
36
|
+
if (String(parentNumber) !== String(issueNumber)) throw Error('Epic plan targets a different parent');
|
|
37
|
+
const report = comments.find(comment => comment.html_url === evidence.report);
|
|
38
|
+
if (!report || report.user?.login !== 'github-actions[bot]' || report.user.type !== 'Bot') throw Error('Epic refinement report is not available');
|
|
39
|
+
return { issue: issueNumber, repository, owner, sourceRevision, defaultBranch: repo.default_branch, taskDigest: current.taskDigest, reportId: report.id, reportBody: report.body, plan: assessment.plan };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function authorizeNativeEpic(api, snapshot) {
|
|
43
|
+
const issue = await api(`issues/${snapshot.issue}`), comments = await issueComments(api, snapshot.issue), repo = await api('');
|
|
44
|
+
const report = comments.find(comment => comment.id === snapshot.reportId);
|
|
45
|
+
const later = comments.some(comment => Number(comment.id) > Number(snapshot.reportId) && comment.user?.login === 'github-actions[bot]' && comment.user.type === 'Bot' && /<!-- lighthouse-(?:stage:refine:|dispatch:)/.test(comment.body ?? ''));
|
|
46
|
+
if (paused(issue) || !issue.labels.some(label => label.name === 'ready') || repo.default_branch !== snapshot.defaultBranch || (await api(`commits/${encodeURIComponent(repo.default_branch)}`)).sha !== snapshot.sourceRevision || taskDigest(issue, comments.filter(comment => isOwnerAnswer(comment, snapshot.owner))) !== snapshot.taskDigest || !report || report.user?.login !== 'github-actions[bot]' || report.user.type !== 'Bot' || report.body !== snapshot.reportBody || later) throw Error('Epic parent authority or refinement changed');
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function authenticateEpicChild({ api, repository, owner, child, load, readArtifact, allowClosed = false }) {
|
|
51
|
+
if (!Number.isSafeInteger(child) || child < 1) throw Error('Invalid epic child');
|
|
52
|
+
const issue = await api(`issues/${child}`), marker = readEpicMarker(issue.body);
|
|
53
|
+
if (issue.number !== child || !Number.isSafeInteger(issue.id) || issue.id < 1 || issue.state !== 'open' && !(allowClosed && issue.state === 'closed') || issue.pull_request || issue.user?.login !== 'github-actions[bot]' || issue.user.type !== 'Bot' || !marker || marker.kind !== 'child' || marker.repository !== repository || marker.parent === child) throw Error('Epic child identity is not trusted');
|
|
54
|
+
const snapshot = await prepareNativeEpic(api, repository, owner, marker.parent, load);
|
|
55
|
+
const slice = snapshot.plan.slices.find(item => item.id === marker.slice);
|
|
56
|
+
const expected = slice && epicCorrelation({ repository, parent: marker.parent, slice: slice.id, planDigest: epicPlanDigest(snapshot.plan), base: `lighthouse/epic-${marker.parent}`, dependencies: slice.dependencies });
|
|
57
|
+
if (!expected || !isDeepStrictEqual(marker, expected) || issue.title !== slice.title || issue.body !== epicChildBody(marker.parent, slice, expected)) throw Error('Epic child no longer matches the current plan');
|
|
58
|
+
const parent = await api(`issues/${child}/parent`);
|
|
59
|
+
if (parent?.number !== marker.parent) throw Error('Epic child parent link changed');
|
|
60
|
+
await readEpicChildReceipt({ api, repository, owner, correlation: expected, sourceRevision: snapshot.sourceRevision, defaultBranch: snapshot.defaultBranch, resource: { id: issue.id, number: child }, ...(readArtifact ? { readArtifact } : {}) });
|
|
61
|
+
await authorizeNativeEpic(api, snapshot);
|
|
62
|
+
return { parent: marker.parent, slice: marker.slice, planDigest: marker.planDigest, base: marker.base, sourceRevision: snapshot.sourceRevision };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function authenticateEpicPullRequest({ api, repository, owner, child, pr, load, readArtifact, journal }) {
|
|
66
|
+
const current = await authenticateEpicChild({ api, repository, owner, child, load, readArtifact });
|
|
67
|
+
const target = checkImplementationPullRequest(pr, repository);
|
|
68
|
+
if (target.issue !== child || target.base !== current.base || !Number.isSafeInteger(pr.id) || pr.id < 1 || !Number.isSafeInteger(pr.number) || pr.number < 1) throw Error('Epic pull request identity changed');
|
|
69
|
+
const marker = readEpicMarker((await api(`issues/${child}`)).body);
|
|
70
|
+
if (!marker || marker.parent !== current.parent || marker.slice !== current.slice || marker.planDigest !== current.planDigest) throw Error('Epic child correlation changed');
|
|
71
|
+
const correlation = epicCorrelation({ repository, parent: current.parent, slice: current.slice, planDigest: current.planDigest, base: current.base, dependencies: marker.dependencies, kind: 'pull-request' });
|
|
72
|
+
const resource = { id: pr.id, number: pr.number };
|
|
73
|
+
if (journal) {
|
|
74
|
+
const receipt = await journal.readReceipt(epicJournalKey(correlation));
|
|
75
|
+
if (!receipt || !['linked', 'merging'].includes(receipt.state) || !isDeepStrictEqual(receipt.correlation, correlation) || !isDeepStrictEqual(receipt.resource, resource) || receipt.publication?.head !== pr.head.sha) throw Error('Epic pull request journal does not match');
|
|
76
|
+
} else {
|
|
77
|
+
const repo = await api('');
|
|
78
|
+
const receipt = await readEpicResourceReceipt({ api, repository, owner, correlation, sourceRevision: current.sourceRevision, defaultBranch: repo.default_branch, resource, ...(readArtifact ? { readArtifact } : {}) });
|
|
79
|
+
if (receipt.publication?.head !== pr.head.sha) throw Error('Epic pull request head lacks a current publication receipt');
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function verifyEpicChecks(api, { repository, sourceRevision, base, candidate, loadPolicy }) {
|
|
85
|
+
const policy = loadPolicy ? await loadPolicy() : await requiredPolicy(api, base, await loadProfile(api, repository, sourceRevision));
|
|
86
|
+
if (!Array.isArray(policy?.checks) || !policy.checks.length) return false;
|
|
87
|
+
return (await checkCommit(api, candidate, policy.checks)).every(check => check.status === 'passed');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function verifyAcceptedEpicMerge(api, { repository, owner, sourceRevision, base, receipt, loadPolicy }) {
|
|
91
|
+
if (receipt?.state !== 'accepted' || !Number.isSafeInteger(receipt.resource?.id) || !Number.isSafeInteger(receipt.resource?.number) || !receipt.merge || receipt.publication?.head !== receipt.merge.head) throw Error('Epic dependency acceptance receipt is invalid');
|
|
92
|
+
const merged = receipt.merge, pr = await api(`pulls/${receipt.resource.number}`);
|
|
93
|
+
if (pr.id !== receipt.resource.id || !pr.merged || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.head?.sha !== merged.head || pr.base?.ref !== base || pr.head.repo?.full_name !== repository || pr.base.repo?.full_name !== repository || pr.merge_commit_sha !== merged.commit || pr.merged_by?.login !== 'github-actions[bot]' || pr.merged_by.type !== 'Bot') throw Error('Epic dependency merge no longer matches its receipt');
|
|
94
|
+
const actual = await api(`git/commits/${merged.commit}`), candidate = await api(`git/commits/${merged.candidate}`);
|
|
95
|
+
if (!Array.isArray(actual.parents) || actual.parents.length !== 1 || actual.parents[0].sha !== merged.base || !/^[a-f0-9]{40}$/.test(actual.tree?.sha ?? '') || actual.tree.sha !== candidate.tree?.sha) throw Error('Epic dependency commit does not match its accepted candidate');
|
|
96
|
+
const reviews = await list(api, `pulls/${receipt.resource.number}/reviews`);
|
|
97
|
+
const latest = reviews.filter(review => review.user?.login === owner && review.user.type === 'User' && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)).sort((a, b) => b.id - a.id)[0];
|
|
98
|
+
if (latest?.id !== merged.review || latest.state !== 'APPROVED' || latest.commit_id !== merged.head) throw Error('Epic dependency owner approval was revoked');
|
|
99
|
+
if (!await verifyEpicChecks(api, { repository, sourceRevision, base, candidate: merged.candidate, loadPolicy })) throw Error('Epic dependency checks are no longer valid');
|
|
100
|
+
return merged;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function inspectEpicDependencies({ api, repository, owner, child, current, load, loadPolicy, readArtifact = readActionsJson, authenticate = authenticateEpicChild, findCandidates = findEpicCandidates, readReceipt = readEpicResourceReceipt }) {
|
|
104
|
+
const authenticatedChild = await authenticate({ api, repository, owner, child, load, readArtifact });
|
|
105
|
+
if (!isDeepStrictEqual(authenticatedChild, current)) throw Error('Epic child authority changed');
|
|
106
|
+
const issue = await api(`issues/${child}`), marker = readEpicMarker(issue.body);
|
|
107
|
+
if (!marker || marker.parent !== current.parent || marker.slice !== current.slice || marker.planDigest !== current.planDigest) throw Error('Epic child correlation changed');
|
|
108
|
+
const branch = await api(`git/ref/heads/${current.base}`), baseSha = branch.object?.sha;
|
|
109
|
+
if (branch.ref !== `refs/heads/${current.base}` || !/^[a-f0-9]{40}$/.test(baseSha ?? '')) throw Error('Epic integration branch is unavailable');
|
|
110
|
+
const repo = await api(''), expectedIds = [];
|
|
111
|
+
for (const dependency of marker.dependencies) {
|
|
112
|
+
const childCorrelation = epicCorrelation({ repository, parent: current.parent, slice: dependency, planDigest: current.planDigest, base: current.base, dependencies: [] });
|
|
113
|
+
const candidates = await findCandidates(api, childCorrelation);
|
|
114
|
+
if (!Array.isArray(candidates) || candidates.length !== 1) throw Error('Epic dependency identity is uncertain');
|
|
115
|
+
const prior = candidates[0], priorMarker = readEpicMarker(prior.body);
|
|
116
|
+
if (!priorMarker || prior.state !== 'closed') throw Error('Epic dependency is not internally complete');
|
|
117
|
+
const authenticated = await authenticate({ api, repository, owner, child: prior.number, load, readArtifact, allowClosed: true });
|
|
118
|
+
if (authenticated.parent !== current.parent || authenticated.slice !== dependency || authenticated.planDigest !== current.planDigest || authenticated.base !== current.base) throw Error('Epic dependency authority changed');
|
|
119
|
+
expectedIds.push(prior.id);
|
|
120
|
+
const correlation = epicCorrelation({ ...priorMarker, kind: 'pull-request' });
|
|
121
|
+
const receipt = await readReceipt({ api, repository, owner, correlation, sourceRevision: current.sourceRevision, defaultBranch: repo.default_branch, allowedStates: ['accepted'], readArtifact });
|
|
122
|
+
const merged = await verifyAcceptedEpicMerge(api, { repository, owner, sourceRevision: current.sourceRevision, base: current.base, receipt, loadPolicy });
|
|
123
|
+
const comparison = await api(`compare/${merged.commit}...${baseSha}`);
|
|
124
|
+
if (!['identical', 'ahead'].includes(comparison.status) || comparison.merge_base_commit?.sha !== merged.commit) throw Error('Epic dependency is not included in the current base');
|
|
125
|
+
}
|
|
126
|
+
const native = await list(api, `issues/${child}/dependencies/blocked_by`);
|
|
127
|
+
if (native.some(item => !Number.isSafeInteger(item.id)) || !isDeepStrictEqual(native.map(item => item.id).sort((a, b) => a - b), expectedIds.sort((a, b) => a - b))) throw Error('Native epic dependencies differ from the authorized plan');
|
|
128
|
+
if ((await api(`git/ref/heads/${current.base}`)).object?.sha !== baseSha || !isDeepStrictEqual(await authenticate({ api, repository, owner, child, load, readArtifact }), current)) throw Error('Epic authority or base changed while checking dependencies');
|
|
129
|
+
return { baseSha };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function checkEpicCapacity({ api, repository, owner, child, current, capacity, excludeRequest, load, prepare = prepareNativeEpic, findCandidates = findEpicCandidates }) {
|
|
133
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > 8) throw Error('Epic capacity must be between 1 and 8');
|
|
134
|
+
const snapshot = await prepare(api, repository, owner, current.parent, load);
|
|
135
|
+
if (snapshot.sourceRevision !== current.sourceRevision || epicPlanDigest(snapshot.plan) !== current.planDigest) throw Error('Epic plan changed before capacity check');
|
|
136
|
+
let occupied = 0;
|
|
137
|
+
for (const slice of snapshot.plan.slices) {
|
|
138
|
+
const correlation = epicCorrelation({ repository, parent: current.parent, slice: slice.id, planDigest: current.planDigest, base: current.base, dependencies: slice.dependencies });
|
|
139
|
+
const candidates = await findCandidates(api, correlation);
|
|
140
|
+
if (!Array.isArray(candidates) || candidates.length !== 1) throw Error('Epic child inventory is incomplete or ambiguous');
|
|
141
|
+
const issue = await api(`issues/${candidates[0].number}`);
|
|
142
|
+
if (issue.id !== candidates[0].id || issue.user?.login !== 'github-actions[bot]' || issue.user.type !== 'Bot' || !isDeepStrictEqual(readEpicMarker(issue.body), correlation)) throw Error('Epic child inventory changed');
|
|
143
|
+
if (issue.state === 'closed') continue;
|
|
144
|
+
if (issue.state !== 'open' || issue.pull_request) throw Error('Epic child state is uncertain');
|
|
145
|
+
const comments = await issueComments(api, issue.number);
|
|
146
|
+
const active = comments.filter(comment => comment.user?.login === 'github-actions[bot]' && comment.user.type === 'Bot' && comment.body?.includes('<!-- lighthouse-dispatch:')).map(comment => readDispatchReceipt(comment, repository, issue.number)).some(receipt => receipt.stage === 'implement' && receipt.id !== excludeRequest && ['requested', 'running', 'unknown'].includes(receipt.status));
|
|
147
|
+
if (active || issue.number !== child && issue.labels.some(label => label.name === 'agent-working')) occupied++;
|
|
148
|
+
}
|
|
149
|
+
if (occupied >= capacity) throw Error('Epic runner capacity is occupied');
|
|
150
|
+
return { occupied, capacity };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function reconcileEpicPullRequest({ api, repository, owner, child, runId, journal, authorize, load, readArtifact = readActionsJson }) {
|
|
154
|
+
if (!Number.isSafeInteger(child) || child < 1 || !/^[1-9][0-9]*$/.test(String(runId)) || typeof authorize !== 'function') throw Error('Invalid epic publication identity');
|
|
155
|
+
const current = await authenticateEpicChild({ api, repository, owner, child, load, readArtifact });
|
|
156
|
+
const run = await api(`actions/runs/${runId}`);
|
|
157
|
+
if (String(run.id) !== String(runId) || run.status !== 'completed' || run.run_attempt !== 1 || !['.github/workflows/lighthouse-sdlc-implement.lock.yml', '.github/workflows/lighthouse-sdlc-apply-review.lock.yml'].includes(run.path) || run.event !== 'workflow_dispatch' || run.repository?.full_name !== repository) throw Error('Epic implementation run is not eligible for reconciliation');
|
|
158
|
+
const inputName = `lighthouse-implement-input-${child}-1`, resultName = `lighthouse-implement-result-${child}-1`;
|
|
159
|
+
const snapshot = await readArtifact(api, repository, run.id, inputName, 'request.json');
|
|
160
|
+
const envelope = await readArtifact(api, repository, run.id, resultName, 'envelope.json');
|
|
161
|
+
const changes = await readArtifact(api, repository, run.id, resultName, 'changes.json');
|
|
162
|
+
const checks = await readArtifact(api, repository, run.id, resultName, 'checks.json');
|
|
163
|
+
const published = await readArtifact(api, repository, run.id, `lighthouse-implement-publication-${child}-1`, 'published-pr.json');
|
|
164
|
+
if (snapshot?.issue !== child || snapshot.stage !== 'implement' || snapshot.runId !== digest(`${repository}:${child}:implement:${run.id}:1`) || String(snapshot.workflowRunId) !== String(run.id) || snapshot.epic?.parent !== current.parent || snapshot.epic?.slice !== current.slice || snapshot.epic?.planDigest !== current.planDigest || snapshot.epic?.base !== current.base || snapshot.epic?.sourceRevision !== current.sourceRevision || snapshot.baseRevision !== current.sourceRevision || (snapshot.implementationReview ? snapshot.implementationReview.head !== snapshot.sourceRevision || snapshot.implementationReview.base !== current.base || run.path !== '.github/workflows/lighthouse-sdlc-apply-review.lock.yml' : snapshot.epic?.baseSha !== snapshot.sourceRevision || run.path !== '.github/workflows/lighthouse-sdlc-implement.lock.yml') || validateStageEnvelope(envelope, snapshot).length || envelope.result.status !== 'passed') throw Error('Epic implementation evidence is invalid or stale');
|
|
165
|
+
const dispatch = await loadDispatch(api, repository, owner, child, snapshot.dispatch?.commentId, run, readArtifact);
|
|
166
|
+
const { baseSha: dispatchedBase, ...dispatchedEpic } = dispatch.request.epic ?? {};
|
|
167
|
+
if (dispatch.request.id !== snapshot.dispatch.requestId || dispatch.receipt.digest !== snapshot.dispatch.digest || dispatch.request.stage !== 'implement' || dispatch.request.workflowRevision !== snapshot.workflowRevision || dispatch.request.taskDigest !== snapshot.taskDigest || dispatchedBase !== snapshot.epic.baseSha || !isDeepStrictEqual(dispatchedEpic, current) || !isDeepStrictEqual(dispatch.request.epic, snapshot.epic)) throw Error('Epic implementation dispatch changed');
|
|
168
|
+
await checkDispatchClaim(api, repository, child, snapshot.dispatch, run.id);
|
|
169
|
+
validateChanges(changes, snapshot.implementation.policy);
|
|
170
|
+
if (!changes.length || !validateCheckReceipt(checks, snapshot, changes) || !Number.isSafeInteger(published?.number) || published.number < 1 || !/^[a-f0-9]{40}$/.test(published.head ?? '')) throw Error('Epic implementation checks or publication receipt are invalid');
|
|
171
|
+
const pr = await api(`pulls/${published.number}`), target = checkImplementationPullRequest(pr, repository);
|
|
172
|
+
if (target.issue !== child || target.base !== current.base || target.head !== published.head || (snapshot.implementationReview ? target.number !== snapshot.implementationReview.number || target.branch !== snapshot.implementationReview.branch : !pr.body.includes(`<!-- lighthouse-implementation:${snapshot.runId} -->`)) || !Number.isSafeInteger(pr.id) || pr.id < 1) throw Error('Epic implementation PR does not match its publication receipt');
|
|
173
|
+
const commit = await api(`git/commits/${published.head}`);
|
|
174
|
+
if (!Array.isArray(commit.parents) || commit.parents.length !== 1 || commit.parents[0].sha !== snapshot.sourceRevision) throw Error('Epic implementation commit has an unexpected base');
|
|
175
|
+
const trees = [];
|
|
176
|
+
for (const revision of [snapshot.sourceRevision, published.head]) {
|
|
177
|
+
const tree = await api(`git/trees/${revision}?recursive=1`);
|
|
178
|
+
if (tree.truncated || !Array.isArray(tree.tree) || tree.tree.length > 10000) throw Error('Epic implementation tree exceeds reconciliation bounds');
|
|
179
|
+
trees.push(new Map(tree.tree.filter(item => item.type !== 'tree').map(item => [item.path, item])));
|
|
180
|
+
}
|
|
181
|
+
const [before, after] = trees, changed = [...new Set([...before.keys(), ...after.keys()])].filter(path => before.get(path)?.sha !== after.get(path)?.sha || before.get(path)?.mode !== after.get(path)?.mode).sort();
|
|
182
|
+
if (!isDeepStrictEqual(changed, changes.map(change => change.path).sort())) throw Error('Epic implementation tree differs from validated changes');
|
|
183
|
+
for (const change of changes) {
|
|
184
|
+
const actual = after.get(change.path);
|
|
185
|
+
if (change.content === null) { if (actual) throw Error('Epic deletion was not published'); continue; }
|
|
186
|
+
const content = Buffer.from(change.content, 'base64');
|
|
187
|
+
const expected = createHash('sha1').update(`blob ${content.length}\0`).update(content).digest('hex');
|
|
188
|
+
if (actual?.type !== 'blob' || actual.mode !== change.mode || actual.sha !== expected) throw Error('Epic implementation file differs from validated content');
|
|
189
|
+
}
|
|
190
|
+
const marker = readEpicMarker((await api(`issues/${child}`)).body);
|
|
191
|
+
const correlation = epicCorrelation({ repository, parent: current.parent, slice: current.slice, planDigest: current.planDigest, base: current.base, dependencies: marker.dependencies, kind: 'pull-request' });
|
|
192
|
+
const key = epicJournalKey(correlation), resource = { id: pr.id, number: pr.number }, publication = { run: String(run.id), attempt: run.run_attempt, head: published.head };
|
|
193
|
+
let receipt = await journal.readReceipt(key);
|
|
194
|
+
if (receipt && (!isDeepStrictEqual(receipt.correlation, correlation) || receipt.resource && !isDeepStrictEqual(receipt.resource, resource) || !['intent', 'created', 'linked'].includes(receipt.state))) throw Error('Epic PR journal conflicts with publication');
|
|
195
|
+
if (await authorize() !== true) throw Error('Epic PR reconciliation authority changed');
|
|
196
|
+
if (!receipt) { receipt = { schemaVersion: 1, state: 'intent', correlation }; await journal.writeReceipt(key, receipt); }
|
|
197
|
+
if (receipt.state === 'intent') { receipt = { ...receipt, state: 'created', resource, publication }; await journal.writeReceipt(key, receipt); }
|
|
198
|
+
if (['created', 'linked'].includes(receipt.state) && !isDeepStrictEqual(receipt.publication, publication)) { receipt = { ...receipt, publication }; await journal.writeReceipt(key, receipt); }
|
|
199
|
+
if (receipt.state === 'created') { receipt = { ...receipt, state: 'linked' }; await journal.writeReceipt(key, receipt); }
|
|
200
|
+
return { status: 'linked', pullRequest: pr.number, head: published.head };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function readyEpicPullRequest({ api, repository, owner, parent, child, number, head, base, journal, authorize, load, loadPolicy, readArtifact, setDraft }) {
|
|
204
|
+
if (!Number.isSafeInteger(number) || number < 1 || !/^[a-f0-9]{40}$/.test(head ?? '') || !/^[a-f0-9]{40}$/.test(base ?? '') || typeof setDraft !== 'function') throw Error('Invalid epic readiness request');
|
|
205
|
+
const observe = async () => {
|
|
206
|
+
const current = await authenticateEpicChild({ api, repository, owner, child, load, readArtifact });
|
|
207
|
+
if (current.parent !== parent) throw Error('Epic child belongs to another parent');
|
|
208
|
+
const issue = await api(`issues/${child}`);
|
|
209
|
+
if (issue.labels.some(label => ['future', 'human-working', 'agent-working', 'needs-info'].includes(label.name))) throw Error('Epic child is paused or reserved');
|
|
210
|
+
const dependencies = await inspectEpicDependencies({ api, repository, owner, child, current, load, loadPolicy, readArtifact });
|
|
211
|
+
if (dependencies.baseSha !== base) throw Error('Epic base changed before readiness');
|
|
212
|
+
const pr = await api(`pulls/${number}`), target = checkImplementationPullRequest(pr, repository);
|
|
213
|
+
if (target.issue !== child || target.base !== current.base || target.head !== head || pr.mergeable !== true || !/^[a-f0-9]{40}$/.test(pr.merge_commit_sha ?? '')) throw Error('Epic PR changed before readiness');
|
|
214
|
+
await authenticateEpicPullRequest({ api, repository, owner, child, pr, load, readArtifact, journal });
|
|
215
|
+
const candidate = await api(`git/commits/${pr.merge_commit_sha}`);
|
|
216
|
+
if (!Array.isArray(candidate.parents) || candidate.parents.length !== 2 || candidate.parents[0].sha !== base || candidate.parents[1].sha !== head || !await verifyEpicChecks(api, { repository, sourceRevision: current.sourceRevision, base: current.base, candidate: pr.merge_commit_sha, loadPolicy })) throw Error('Epic readiness checks do not bind the current head and base');
|
|
217
|
+
if (await authorize() !== true) throw Error('Epic readiness authority changed');
|
|
218
|
+
return { pr, candidate: pr.merge_commit_sha };
|
|
219
|
+
};
|
|
220
|
+
const first = await observe();
|
|
221
|
+
if (first.pr.draft) {
|
|
222
|
+
try { await setDraft(first.pr, false); }
|
|
223
|
+
catch (error) { if ((await api(`pulls/${number}`)).draft) throw error; }
|
|
224
|
+
}
|
|
225
|
+
const fresh = await observe();
|
|
226
|
+
if (fresh.pr.draft || fresh.candidate !== first.candidate) throw Error('Epic readiness outcome is uncertain');
|
|
227
|
+
await projectImplementationPrState(api, child, false);
|
|
228
|
+
return { status: 'ready', pullRequest: number, head, base };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function findEpicCandidates(api, correlation) {
|
|
232
|
+
const candidates = [];
|
|
233
|
+
for (let page = 1; page <= 5; page++) {
|
|
234
|
+
const issues = await api(`issues?state=all&per_page=100&page=${page}`);
|
|
235
|
+
if (!Array.isArray(issues)) throw Error('Epic issue inventory unavailable');
|
|
236
|
+
for (const issue of issues) {
|
|
237
|
+
const marker = readEpicMarker(issue.body);
|
|
238
|
+
if (marker?.repository === correlation.repository && marker.parent === correlation.parent && marker.slice === correlation.slice) candidates.push(issue);
|
|
239
|
+
}
|
|
240
|
+
if (issues.length < 100) return candidates;
|
|
241
|
+
}
|
|
242
|
+
throw Error('Epic issue inventory exceeds bounds');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function checkEpicLeaseConfiguration(api, revision) {
|
|
246
|
+
async function file(path) {
|
|
247
|
+
const value = await api(`contents/${path}?ref=${revision}`);
|
|
248
|
+
if (value?.type !== 'file' || value.encoding !== 'base64' || typeof value.content !== 'string' || !Number.isSafeInteger(value.size) || value.size < 1 || value.size > 128000) throw Error('Epic lease configuration is unavailable');
|
|
249
|
+
return Buffer.from(value.content, 'base64').toString('utf8');
|
|
250
|
+
}
|
|
251
|
+
const router = (await file('.github/workflows/lighthouse-sdlc-router.yml')).split('\n');
|
|
252
|
+
const starts = router.flatMap((line, index) => line === ' dispatch:' ? [index] : []);
|
|
253
|
+
if (starts.length !== 1) throw Error('Epic requires a unique serialized router dispatch');
|
|
254
|
+
let end = starts[0] + 1;
|
|
255
|
+
while (end < router.length && !/^ [A-Za-z0-9_-]+:/.test(router[end])) end++;
|
|
256
|
+
const dispatch = router.slice(starts[0], end).join('\n');
|
|
257
|
+
if (!dispatch.includes(' concurrency:\n group: lighthouse-issue-${{ needs.route.outputs.epic_parent || needs.route.outputs.issue }}\n cancel-in-progress: false\n queue: max')) throw Error('Epic requires the shared issue dispatch lease');
|
|
258
|
+
const workflow = await file('.github/workflows/lighthouse-sdlc-epic.yml');
|
|
259
|
+
if (!workflow.includes('\nconcurrency:\n group: lighthouse-issue-${{ inputs.issue }}\n cancel-in-progress: false\n queue: max\n')) throw Error('Epic workflow does not hold the shared issue lease');
|
|
260
|
+
for (const stage of ['triage', 'refine', 'implement', 'apply-review']) {
|
|
261
|
+
const worker = await file(`.github/workflows/lighthouse-sdlc-${stage}.lock.yml`);
|
|
262
|
+
if (!worker.includes("\nconcurrency:\n cancel-in-progress: false\n group: ${{ inputs.epic-parent && format('lighthouse-epic-child-{0}', inputs.issue) || format('lighthouse-issue-{0}', inputs.issue) }}\n queue: max\n")) throw Error(`Epic ${stage} worker does not hold the child lease`);
|
|
263
|
+
for (const job of ['prepare', 'publish']) {
|
|
264
|
+
const lines = worker.split('\n'), starts = lines.flatMap((line, index) => line === ` ${job}:` ? [index] : []);
|
|
265
|
+
if (starts.length !== 1) throw Error(`Epic ${stage} ${job} job is not unique`);
|
|
266
|
+
let end = starts[0] + 1;
|
|
267
|
+
while (end < lines.length && !/^ [A-Za-z0-9_-]+:/.test(lines[end])) end++;
|
|
268
|
+
const block = lines.slice(starts[0], end).join('\n');
|
|
269
|
+
if (!block.includes(` concurrency:\n cancel-in-progress: false\n group: \${{ inputs.epic-parent && format('lighthouse-issue-{0}', inputs.epic-parent) || format('lighthouse-${job}-{0}', github.run_id) }}\n queue: max`)) throw Error(`Epic ${stage} ${job} does not hold the shared parent lease`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function verifyEpicBranchHistory(api, snapshot, journal, head, requireAll = false, loadPolicy) {
|
|
275
|
+
const base = `lighthouse/epic-${snapshot.issue}`, accepted = new Map();
|
|
276
|
+
for (const slice of snapshot.plan.slices) {
|
|
277
|
+
const correlation = epicCorrelation({ repository: snapshot.repository, parent: snapshot.issue, slice: slice.id, planDigest: epicPlanDigest(snapshot.plan), base, dependencies: slice.dependencies, kind: 'pull-request' });
|
|
278
|
+
const receipt = await journal.readReceipt(epicJournalKey(correlation));
|
|
279
|
+
if (receipt?.state !== 'accepted') continue;
|
|
280
|
+
if (!isDeepStrictEqual(receipt.correlation, correlation)) throw Error('Epic merge correlation changed');
|
|
281
|
+
const merged = await verifyAcceptedEpicMerge(api, { repository: snapshot.repository, owner: snapshot.owner, sourceRevision: snapshot.sourceRevision, base, receipt, loadPolicy });
|
|
282
|
+
if (accepted.has(merged.commit)) throw Error('Duplicate epic merge receipt');
|
|
283
|
+
accepted.set(merged.commit, merged.base);
|
|
284
|
+
}
|
|
285
|
+
let revision = head, count = 0;
|
|
286
|
+
while (revision !== snapshot.sourceRevision) {
|
|
287
|
+
if (!accepted.has(revision) || ++count > accepted.size) throw Error('Epic branch contains an unverified commit');
|
|
288
|
+
revision = accepted.get(revision);
|
|
289
|
+
}
|
|
290
|
+
if (count !== accepted.size) throw Error('Accepted epic merges are absent from the branch');
|
|
291
|
+
if (requireAll && accepted.size !== snapshot.plan.slices.length) throw Error('Not all epic children are accepted');
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function publishEpicAggregate({ api, repository, owner, snapshot, journal, authorize, load, loadPolicy, readArtifact, findCandidates = findEpicCandidates, authenticate = authenticateEpicChild }) {
|
|
296
|
+
const base = `lighthouse/epic-${snapshot.issue}`, ref = await api(`git/ref/heads/${base}`), head = ref.object?.sha;
|
|
297
|
+
if (ref.ref !== `refs/heads/${base}` || !/^[a-f0-9]{40}$/.test(head ?? '') || head === snapshot.sourceRevision) throw Error('Epic branch has no accepted aggregate changes');
|
|
298
|
+
await verifyEpicBranchHistory(api, snapshot, journal, head, true, loadPolicy);
|
|
299
|
+
for (const slice of snapshot.plan.slices) {
|
|
300
|
+
const correlation = epicCorrelation({ repository, parent: snapshot.issue, slice: slice.id, planDigest: epicPlanDigest(snapshot.plan), base, dependencies: slice.dependencies });
|
|
301
|
+
const matches = await findCandidates(api, correlation);
|
|
302
|
+
if (matches.length !== 1 || matches[0].state !== 'closed') throw Error('Epic child completion is missing or ambiguous');
|
|
303
|
+
const current = await authenticate({ api, repository, owner, child: matches[0].number, load, readArtifact, allowClosed: true });
|
|
304
|
+
if (current.parent !== snapshot.issue || current.slice !== slice.id || current.planDigest !== correlation.planDigest || current.base !== base || current.sourceRevision !== snapshot.sourceRevision) throw Error('Epic child completion authority changed');
|
|
305
|
+
}
|
|
306
|
+
const correlation = epicCorrelation({ repository, parent: snapshot.issue, slice: 'aggregate', planDigest: epicPlanDigest(snapshot.plan), base, dependencies: snapshot.plan.slices.map(slice => slice.id), kind: 'aggregate' });
|
|
307
|
+
const key = epicJournalKey(correlation), marker = epicMarker(correlation);
|
|
308
|
+
const body = `Aggregate changes for #${snapshot.issue}.\n\nRefs #${snapshot.issue}.\n\nReview and merge remain separate decisions.\n\n${marker}`;
|
|
309
|
+
let receipt = await journal.readReceipt(key);
|
|
310
|
+
if (receipt && (!isDeepStrictEqual(receipt.correlation, correlation) || !['intent', 'created', 'linked'].includes(receipt.state))) throw Error('Aggregate journal is invalid');
|
|
311
|
+
const pulls = await api(`pulls?state=all&head=${encodeURIComponent(`${owner}:${base}`)}&base=${encodeURIComponent(snapshot.defaultBranch)}&per_page=100`);
|
|
312
|
+
if (!Array.isArray(pulls) || pulls.length >= 100 || pulls.length > 1) throw Error('Aggregate PR inventory is unavailable or ambiguous');
|
|
313
|
+
let pr = pulls[0];
|
|
314
|
+
if (pr && (!receipt?.resource || pr.id !== receipt.resource.id || pr.number !== receipt.resource.number || pr.html_url !== `https://github.com/${repository}/pull/${pr.number}` || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.head?.ref !== base || pr.head.sha !== head || pr.head.repo?.full_name !== repository || pr.base?.ref !== snapshot.defaultBranch || pr.base.repo?.full_name !== repository || pr.body !== body || !isDeepStrictEqual(readEpicMarker(pr.body), correlation))) throw Error('Aggregate PR lacks its authenticated receipt or changed');
|
|
315
|
+
if (!pr && receipt) throw Error('Aggregate PR creation is missing or uncertain');
|
|
316
|
+
if (!pr) {
|
|
317
|
+
if (await authorize() !== true) throw Error('Aggregate publication authority changed');
|
|
318
|
+
receipt = { schemaVersion: 1, state: 'intent', correlation };
|
|
319
|
+
await journal.writeReceipt(key, receipt);
|
|
320
|
+
if ((await api(`git/ref/heads/${base}`)).object?.sha !== head || await authorize() !== true) throw Error('Epic branch or authority changed before aggregate publication');
|
|
321
|
+
await verifyEpicBranchHistory(api, snapshot, journal, head, true, loadPolicy);
|
|
322
|
+
pr = await api('pulls', 'POST', { title: `Epic #${snapshot.issue}: ${snapshot.plan.parent.title}`.slice(0, 180), head: base, base: snapshot.defaultBranch, draft: true, body });
|
|
323
|
+
if (!Number.isSafeInteger(pr?.id) || pr.id < 1 || !Number.isSafeInteger(pr.number) || pr.number < 1 || pr.html_url !== `https://github.com/${repository}/pull/${pr.number}` || pr.head?.sha !== head || pr.head.ref !== base || pr.head.repo?.full_name !== repository || pr.base?.ref !== snapshot.defaultBranch || pr.base.repo?.full_name !== repository || pr.user?.login !== 'github-actions[bot]' || pr.user.type !== 'Bot' || pr.body !== body || !isDeepStrictEqual(readEpicMarker(pr.body), correlation)) throw Error('Aggregate PR creation could not be confirmed');
|
|
324
|
+
receipt = { ...receipt, state: 'created', resource: { id: pr.id, number: pr.number } };
|
|
325
|
+
await journal.writeReceipt(key, receipt);
|
|
326
|
+
}
|
|
327
|
+
if (pr.state !== 'open' || pr.merged) throw Error('Aggregate PR is no longer open; final acceptance needs reconciliation');
|
|
328
|
+
if (receipt.state === 'created') { receipt = { ...receipt, state: 'linked' }; await journal.writeReceipt(key, receipt); }
|
|
329
|
+
const notice = `<!-- lighthouse-epic-aggregate:${correlation.planDigest} -->`;
|
|
330
|
+
const comments = await issueComments(api, snapshot.issue);
|
|
331
|
+
const prior = comments.filter(comment => comment.user?.login === 'github-actions[bot]' && comment.user.type === 'Bot' && comment.body?.includes(notice));
|
|
332
|
+
if (prior.length > 1 || prior.length === 1 && !prior[0].body.includes(pr.html_url)) throw Error('Aggregate handoff is ambiguous');
|
|
333
|
+
if (!prior.length) {
|
|
334
|
+
if ((await api(`git/ref/heads/${base}`)).object?.sha !== head || await authorize() !== true) throw Error('Epic branch or authority changed before the aggregate handoff');
|
|
335
|
+
await verifyEpicBranchHistory(api, snapshot, journal, head, true, loadPolicy);
|
|
336
|
+
await api(`issues/${snapshot.issue}/comments`, 'POST', { body: `Aggregate draft PR: ${pr.html_url}\n\nReview and merge remain separate decisions. The parent remains open until the aggregate outcome is accepted.\n\n${notice}` });
|
|
337
|
+
}
|
|
338
|
+
return { status: 'published', pullRequest: pr.number, head, parent: 'open' };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function readyEpicAggregate({ api, repository, owner, snapshot, journal, authorize, load, loadPolicy, readArtifact, setDraft, findCandidates, authenticate }) {
|
|
342
|
+
if (typeof setDraft !== 'function') throw Error('Aggregate readiness requires a draft updater');
|
|
343
|
+
const observe = async () => {
|
|
344
|
+
const published = await publishEpicAggregate({ api, repository, owner, snapshot, journal, authorize, load, loadPolicy, readArtifact, ...(findCandidates ? { findCandidates } : {}), ...(authenticate ? { authenticate } : {}) });
|
|
345
|
+
const pr = await api(`pulls/${published.pullRequest}`);
|
|
346
|
+
const base = await api(`git/ref/heads/${snapshot.defaultBranch}`);
|
|
347
|
+
if (base.ref !== `refs/heads/${snapshot.defaultBranch}` || !/^[a-f0-9]{40}$/.test(base.object?.sha ?? '') || pr.head?.sha !== published.head || pr.base?.ref !== snapshot.defaultBranch || pr.mergeable !== true || !/^[a-f0-9]{40}$/.test(pr.merge_commit_sha ?? '')) throw Error('Aggregate readiness evidence is unavailable');
|
|
348
|
+
const candidate = await api(`git/commits/${pr.merge_commit_sha}`);
|
|
349
|
+
if (!Array.isArray(candidate?.parents) || candidate.parents.length !== 2 || candidate.parents[0].sha !== base.object.sha || candidate.parents[1].sha !== published.head || !await verifyEpicChecks(api, { repository, sourceRevision: snapshot.sourceRevision, base: snapshot.defaultBranch, candidate: pr.merge_commit_sha, loadPolicy }) || await authorize() !== true) throw Error('Aggregate checks do not bind the current head and base');
|
|
350
|
+
return { pr, head: published.head, base: base.object.sha, candidate: pr.merge_commit_sha };
|
|
351
|
+
};
|
|
352
|
+
const first = await observe();
|
|
353
|
+
if (first.pr.draft) {
|
|
354
|
+
try { await setDraft(first.pr, false); }
|
|
355
|
+
catch (error) { if ((await api(`pulls/${first.pr.number}`)).draft) throw error; }
|
|
356
|
+
}
|
|
357
|
+
const fresh = await observe();
|
|
358
|
+
if (fresh.pr.draft || fresh.head !== first.head || fresh.base !== first.base || fresh.candidate !== first.candidate) throw Error('Aggregate readiness outcome is uncertain');
|
|
359
|
+
return { status: 'ready', pullRequest: fresh.pr.number, head: fresh.head, base: fresh.base, parent: 'open' };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function ensureEpicBase(api, snapshot, authorize, verifyAdvanced = async () => false) {
|
|
363
|
+
const branch = `lighthouse/epic-${snapshot.issue}`;
|
|
364
|
+
const read = () => api(`git/ref/heads/${branch}`, 'GET', undefined, true);
|
|
365
|
+
const current = await read();
|
|
366
|
+
if (current) {
|
|
367
|
+
if (current.ref !== `refs/heads/${branch}` || !/^[a-f0-9]{40}$/.test(current.object?.sha ?? '') || current.object.sha !== snapshot.sourceRevision && await verifyAdvanced(current.object.sha) !== true) throw Error('Epic branch already exists at an unverified revision');
|
|
368
|
+
return branch;
|
|
369
|
+
}
|
|
370
|
+
if (await authorize() !== true) throw Error('Epic branch authorization changed');
|
|
371
|
+
try {
|
|
372
|
+
const created = await api('git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: snapshot.sourceRevision });
|
|
373
|
+
if (created.ref === `refs/heads/${branch}` && created.object?.sha === snapshot.sourceRevision) return branch;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
const observed = await read();
|
|
376
|
+
if (observed?.ref === `refs/heads/${branch}` && observed.object?.sha === snapshot.sourceRevision) return branch;
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
throw Error('Epic branch creation could not be confirmed');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function runNativeEpic({ api, repository, owner, issue, run, journalPath, readArtifact, load, loadPolicy }) {
|
|
383
|
+
const snapshot = await prepareNativeEpic(api, repository, owner, issue, load);
|
|
384
|
+
await checkEpicLeaseConfiguration(api, snapshot.sourceRevision);
|
|
385
|
+
const authorize = () => authorizeNativeEpic(api, snapshot);
|
|
386
|
+
const journal = await createEpicJournal({ api, repository, owner, parent: issue, sourceRevision: snapshot.sourceRevision, defaultBranch: snapshot.defaultBranch, run, path: journalPath, authorize, ...(readArtifact ? { readArtifact } : {}) });
|
|
387
|
+
await ensureEpicBase(api, snapshot, authorize, head => verifyEpicBranchHistory(api, snapshot, journal, head, false, loadPolicy));
|
|
388
|
+
const host = {
|
|
389
|
+
...journal,
|
|
390
|
+
withLease: async (key, fn) => { if (key !== `${repository}:${issue}`) throw Error('Epic lease identity changed'); return fn(); },
|
|
391
|
+
findCandidates: correlation => findEpicCandidates(api, correlation),
|
|
392
|
+
authenticate: async (candidate, receipt) => candidate.user?.login === 'github-actions[bot]' && candidate.user.type === 'Bot' && receipt.resource?.id === candidate.id && receipt.resource?.number === candidate.number,
|
|
393
|
+
beforeWrite: authorize,
|
|
394
|
+
api: async (path, method = 'GET', body) => api(path, method, body, method === 'GET' && /^issues\/[1-9][0-9]*\/parent$/.test(path)),
|
|
395
|
+
};
|
|
396
|
+
return reconcileEpicChildren(snapshot.plan, { repository, parent: issue, base: `lighthouse/epic-${issue}` }, host);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function runEpicAction({ api, repository, owner, issue, run, journalPath, action, child, implementationRun, decision, readArtifact, load, loadPolicy, setDraft = (pr, draft) => setPullRequestDraft(process.env.GH_TOKEN, pr, draft) }) {
|
|
400
|
+
if (action === 'reconcile') return runNativeEpic({ api, repository, owner, issue, run, journalPath, readArtifact, load, loadPolicy });
|
|
401
|
+
if (!['register-pr', 'ready-child', 'accept-child', 'publish-aggregate', 'ready-aggregate'].includes(action) || !['publish-aggregate', 'ready-aggregate'].includes(action) && (!Number.isSafeInteger(child) || child < 1)) throw Error('Invalid epic action or child');
|
|
402
|
+
const snapshot = await prepareNativeEpic(api, repository, owner, issue, load);
|
|
403
|
+
await checkEpicLeaseConfiguration(api, snapshot.sourceRevision);
|
|
404
|
+
const authorize = () => authorizeNativeEpic(api, snapshot);
|
|
405
|
+
const journal = await createEpicJournal({ api, repository, owner, parent: issue, sourceRevision: snapshot.sourceRevision, defaultBranch: snapshot.defaultBranch, run, path: journalPath, authorize, ...(readArtifact ? { readArtifact } : {}) });
|
|
406
|
+
if (action === 'publish-aggregate') return publishEpicAggregate({ api, repository, owner, snapshot, journal, authorize, load, loadPolicy, readArtifact });
|
|
407
|
+
if (action === 'ready-aggregate') return readyEpicAggregate({ api, repository, owner, snapshot, journal, authorize, load, loadPolicy, readArtifact, setDraft });
|
|
408
|
+
if (action === 'register-pr') {
|
|
409
|
+
const current = await authenticateEpicChild({ api, repository, owner, child, load, readArtifact });
|
|
410
|
+
if (current.parent !== issue) throw Error('Epic child belongs to another parent');
|
|
411
|
+
return reconcileEpicPullRequest({ api, repository, owner, child, runId: implementationRun, journal, authorize, load, ...(readArtifact ? { readArtifact } : {}) });
|
|
412
|
+
}
|
|
413
|
+
if (action === 'ready-child') return readyEpicPullRequest({ api, repository, owner, parent: issue, child, number: decision?.pr, head: decision?.head, base: decision?.base, journal, authorize, load, loadPolicy, readArtifact, setDraft });
|
|
414
|
+
if (!decision || !Number.isSafeInteger(decision.pr) || !Number.isSafeInteger(decision.review) || !/^[a-f0-9]{40}$/.test(decision.head ?? '') || !/^[a-f0-9]{40}$/.test(decision.base ?? '')) throw Error('Epic acceptance needs a PR, review, head and base');
|
|
415
|
+
const current = await authenticateEpicChild({ api, repository, owner, child, load, readArtifact, allowClosed: true });
|
|
416
|
+
if (current.parent !== issue) throw Error('Epic child belongs to another parent');
|
|
417
|
+
const childIssue = await api(`issues/${child}`), marker = readEpicMarker(childIssue.body);
|
|
418
|
+
const correlation = epicCorrelation({ repository, parent: issue, slice: current.slice, planDigest: current.planDigest, base: current.base, dependencies: marker.dependencies, kind: 'pull-request' });
|
|
419
|
+
const pr = await api(`pulls/${decision.pr}`), resource = { id: pr.id, number: pr.number };
|
|
420
|
+
const authorizeDecision = async actual => {
|
|
421
|
+
if (!isDeepStrictEqual(actual, decision) || await authorize() !== true) return false;
|
|
422
|
+
const reviews = await list(api, `pulls/${decision.pr}/reviews`);
|
|
423
|
+
const latest = reviews.filter(review => review.user?.login === owner && review.user.type === 'User' && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)).sort((a, b) => b.id - a.id)[0];
|
|
424
|
+
return latest?.id === decision.review && latest.state === 'APPROVED' && latest.commit_id === decision.head;
|
|
425
|
+
};
|
|
426
|
+
const observe = () => observeEpicMerge(api, { repository, owner, child, epic: current, decision }, {
|
|
427
|
+
authenticateChild: () => authenticateEpicChild({ api, repository, owner, child, load, readArtifact }),
|
|
428
|
+
authenticatePullRequest: fresh => authenticateEpicPullRequest({ api, repository, owner, child, pr: fresh, load, readArtifact, journal }),
|
|
429
|
+
authorizeDecision,
|
|
430
|
+
...(loadPolicy ? { loadPolicy } : {}),
|
|
431
|
+
});
|
|
432
|
+
const verifyChecks = binding => verifyEpicChecks(api, { repository, sourceRevision: current.sourceRevision, base: current.base, candidate: binding.candidate, loadPolicy });
|
|
433
|
+
const result = await mergeEpicChild(api, { correlation, resource }, { journal, observe, authorize: () => authorizeDecision(decision), verifyChecks });
|
|
434
|
+
if (await authorizeDecision(decision) !== true) throw Error('Epic acceptance authority changed before child completion');
|
|
435
|
+
const fresh = await api(`issues/${child}`);
|
|
436
|
+
if (fresh.state === 'open') {
|
|
437
|
+
await authenticateEpicChild({ api, repository, owner, child, load, readArtifact });
|
|
438
|
+
try { await api(`issues/${child}`, 'PATCH', { state: 'closed', state_reason: 'completed' }); }
|
|
439
|
+
catch (error) { if ((await api(`issues/${child}`)).state !== 'closed') throw error; }
|
|
440
|
+
} else if (fresh.state !== 'closed') throw Error('Epic child completion is uncertain');
|
|
441
|
+
return { ...result, child, parent: 'open' };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function main() {
|
|
445
|
+
const env = process.env, repository = env.GITHUB_REPOSITORY, owner = env.GITHUB_REPOSITORY_OWNER, issue = Number(env.LIGHTHOUSE_ISSUE);
|
|
446
|
+
if (env.GITHUB_EVENT_NAME !== 'workflow_dispatch' || env.GITHUB_ACTOR !== owner || env.GITHUB_TRIGGERING_ACTOR !== owner || !/^[1-9][0-9]*$/.test(env.LIGHTHOUSE_ISSUE ?? '') || !Number.isSafeInteger(issue)) throw Error('Epic execution requires an explicit owner request');
|
|
447
|
+
const api = async (path, method = 'GET', body, optional = false) => {
|
|
448
|
+
const response = await fetch(`https://api.github.com/repos/${repository}${path ? `/${path}` : ''}`, { method, headers: { authorization: `Bearer ${env.GH_TOKEN}`, accept: 'application/vnd.github+json', 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) });
|
|
449
|
+
if (optional && response.status === 404) return null;
|
|
450
|
+
if (!response.ok) throw Error(`GitHub ${method} ${path}: ${response.status}`);
|
|
451
|
+
return response.status === 204 ? null : response.json();
|
|
452
|
+
};
|
|
453
|
+
const run = await api(`actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`);
|
|
454
|
+
const dir = join(env.RUNNER_TEMP, `lighthouse-epic-${env.GITHUB_RUN_ID}-${issue}`);
|
|
455
|
+
await mkdir(dir, { recursive: true });
|
|
456
|
+
const inputs = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8')).inputs ?? {};
|
|
457
|
+
const positive = value => /^[1-9][0-9]*$/.test(value ?? '') && Number.isSafeInteger(Number(value));
|
|
458
|
+
const action = inputs.action || 'reconcile';
|
|
459
|
+
if (['register-pr', 'ready-child', 'accept-child'].includes(action) && !positive(inputs.child) || action === 'register-pr' && !positive(inputs.run) || ['ready-child', 'accept-child'].includes(action) && !positive(inputs['pull-request']) || action === 'accept-child' && !positive(inputs.review)) throw Error('Epic action inputs must use canonical positive numbers');
|
|
460
|
+
const result = await runEpicAction({ api, repository, owner, issue, run, journalPath: join(dir, 'journal.json'), action, ...(inputs.child ? { child: Number(inputs.child) } : {}), ...(inputs.run ? { implementationRun: inputs.run } : {}), ...(['ready-child', 'accept-child'].includes(action) ? { decision: { pr: Number(inputs['pull-request']), ...(action === 'accept-child' ? { review: Number(inputs.review) } : {}), head: inputs.head, base: inputs.base } } : {}) });
|
|
461
|
+
console.log(JSON.stringify(result));
|
|
462
|
+
if (!['reconciled', 'linked', 'ready', 'accepted', 'published'].includes(result.status)) throw Error('Epic action stopped; inspect the retained journal before continuing');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(error => { console.error(error.message); process.exitCode = 1; });
|
package/src/epic.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { buildDecompositionPlan } from './decompose.mjs';
|
|
3
|
+
import { validateEstimate } from './request-triage.mjs';
|
|
4
|
+
|
|
5
|
+
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
6
|
+
const sha = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
|
|
7
|
+
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
8
|
+
|
|
9
|
+
// Refinement proposes work; neither a valid plan nor its digest is authority to execute.
|
|
10
|
+
export function assessDecomposition(output) {
|
|
11
|
+
const errors = validateEstimate(output?.estimate);
|
|
12
|
+
if (output?.estimate?.status !== 'confirmed') errors.push('refinement estimate must be confirmed');
|
|
13
|
+
if (errors.length) return { status: 'invalid', required: false, errors };
|
|
14
|
+
if (output.estimate.points <= 8) return { status: 'not-required', required: false, errors: [] };
|
|
15
|
+
const split = output.decomposition;
|
|
16
|
+
if (text(split?.noSafeSplitReason) && text(split?.question)) return { status: 'blocked', required: true, reason: split.noSafeSplitReason, question: split.question, errors: [] };
|
|
17
|
+
const plan = buildDecompositionPlan(split);
|
|
18
|
+
return { status: plan.valid ? 'planned' : 'invalid', required: true, plan, errors: plan.errors.map(error => error.message) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function epicPlanDigest(plan) {
|
|
22
|
+
const checked = buildDecompositionPlan(plan);
|
|
23
|
+
if (!plan?.valid || !checked.valid) throw Error('A valid decomposition plan is required');
|
|
24
|
+
const idNumber = checked.parent.id.match(/^(?:GH-)?([1-9]\d*)$/)?.[1];
|
|
25
|
+
const number = checked.parent.number ?? idNumber;
|
|
26
|
+
const canonical = { schemaVersion: 1, parent: { id: checked.parent.id, title: checked.parent.title, ...(number === undefined ? {} : { number: String(number) }) }, slices: [...checked.slices].sort((a, b) => a.id.localeCompare(b.id)) };
|
|
27
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Input is a trusted host observation, not data parsed from issue/PR markers.
|
|
31
|
+
// Consumers must refresh it under their parent lease immediately before effects.
|
|
32
|
+
export function assessEpic(plan, observation) {
|
|
33
|
+
const errors = [];
|
|
34
|
+
const checked = buildDecompositionPlan(plan);
|
|
35
|
+
if (!plan?.valid || !checked.valid) errors.push('valid non-empty decomposition plan required');
|
|
36
|
+
if (observation?.schemaVersion !== 1 || observation?.authenticated !== true) errors.push('authenticated version 1 host observation required');
|
|
37
|
+
if (!sha(observation?.baseSha) || !text(observation?.epicBranch) || !text(observation?.defaultBranch) || observation.epicBranch === observation.defaultBranch) errors.push('dedicated epic branch and current base SHA required');
|
|
38
|
+
const idNumber = typeof checked.parent?.id === 'string' ? checked.parent.id.match(/^(?:GH-)?([1-9]\d*)$/)?.[1] : undefined;
|
|
39
|
+
const explicitNumber = checked.parent?.number;
|
|
40
|
+
if (explicitNumber !== undefined && (!positive(explicitNumber) || idNumber && String(explicitNumber) !== idNumber)) errors.push('parent issue identity is inconsistent');
|
|
41
|
+
const parentNumber = explicitNumber ?? idNumber;
|
|
42
|
+
if (!parentNumber) errors.push('numeric parent issue identity required');
|
|
43
|
+
if (parentNumber && observation?.epicBranch !== `lighthouse/epic-${parentNumber}`) errors.push('epic branch does not match the parent issue');
|
|
44
|
+
if (!positive(observation?.capacity) || !Array.isArray(observation?.children)) errors.push('positive capacity and child observations required');
|
|
45
|
+
if (plan?.valid && checked.valid && observation?.planDigest !== epicPlanDigest(plan)) errors.push('observation does not match the immutable plan');
|
|
46
|
+
const children = Array.isArray(observation?.children) ? observation.children : [];
|
|
47
|
+
const ids = new Set();
|
|
48
|
+
for (const child of children) {
|
|
49
|
+
if (!child || !plan?.slices?.some(slice => slice.id === child.id) || ids.has(child.id)) errors.push('unknown or duplicate child observation');
|
|
50
|
+
ids.add(child?.id);
|
|
51
|
+
}
|
|
52
|
+
if (errors.length) return { schemaVersion: 1, status: 'invalid', children: [], aggregate: 'blocked', errors };
|
|
53
|
+
const byId = new Map(children.map(child => [child.id, child]));
|
|
54
|
+
const accepted = child => child?.authenticated === true && child?.state === 'accepted' && (child.questions === undefined || Array.isArray(child.questions) && child.questions.length === 0) && child?.pr?.merged === true && child.pr.base === observation.epicBranch && sha(child.pr.head) && child.acceptance?.head === child.pr.head && sha(child.acceptance?.baseSha) && child.acceptance?.checks === 'passed' && child.acceptance?.ownerDecision === true && child.acceptance?.included === true;
|
|
55
|
+
let slots = Math.max(0, observation.capacity - children.filter(child => child?.state === 'running').length);
|
|
56
|
+
const results = checked.order.map(id => {
|
|
57
|
+
const slice = plan.slices.find(item => item.id === id), child = byId.get(id);
|
|
58
|
+
const result = { id, state: 'blocked', dependencies: [...slice.dependencies] };
|
|
59
|
+
if (!child) return { ...result, state: 'uncreated' };
|
|
60
|
+
if (child.authenticated !== true) return { ...result, state: 'uncertain', reason: 'child identity is not authenticated' };
|
|
61
|
+
if (!['ready', 'running', 'accepted', 'cancelled', 'uncertain', 'blocked'].includes(child.state)) return { ...result, state: 'uncertain', reason: 'child state is closed or unrecognized' };
|
|
62
|
+
if (['cancelled', 'uncertain'].includes(child.state)) return { ...result, state: child.state };
|
|
63
|
+
if (child.state === 'running') return { ...result, state: 'running' };
|
|
64
|
+
if (child.questions !== undefined && (!Array.isArray(child.questions) || child.questions.some(question => !text(question)))) return { ...result, state: 'uncertain', reason: 'child questions are malformed' };
|
|
65
|
+
if (child.questions?.length) return { ...result, state: 'waiting-answer', questions: child.questions };
|
|
66
|
+
if (child.state === 'blocked') return { ...result, state: 'blocked' };
|
|
67
|
+
if (accepted(child)) return { ...result, state: 'accepted' };
|
|
68
|
+
if (child.state === 'accepted') return { ...result, state: 'uncertain', reason: 'accepted outcome lacks merge and decision evidence' };
|
|
69
|
+
if (child.triage !== 'passed') return { ...result, state: 'needs-triage' };
|
|
70
|
+
if (child.refinement !== 'passed') return { ...result, state: child.authorized?.refine === true ? 'needs-refinement' : 'waiting-refinement-authorization' };
|
|
71
|
+
const waiting = slice.dependencies.filter(dependency => !accepted(byId.get(dependency)));
|
|
72
|
+
if (waiting.length) return { ...result, state: 'waiting-dependencies', waiting };
|
|
73
|
+
if (child.pr) {
|
|
74
|
+
if (child.pr.state !== 'open' || child.pr.merged !== false) return { ...result, state: 'uncertain', reason: 'child PR is not confirmed open and unmerged' };
|
|
75
|
+
if (child.pr.base !== observation.epicBranch) return { ...result, state: 'wrong-base' };
|
|
76
|
+
if (!sha(child.pr.head) || child.evidence?.head !== child.pr.head || child.evidence?.baseSha !== observation.baseSha || child.evidence?.checks !== 'passed') return { ...result, state: 'needs-revalidation' };
|
|
77
|
+
if (child.ownerDecision?.head !== child.pr.head || child.ownerDecision?.baseSha !== observation.baseSha || child.ownerDecision?.approved !== true) return { ...result, state: 'waiting-owner' };
|
|
78
|
+
return { ...result, state: 'merge-eligible' };
|
|
79
|
+
}
|
|
80
|
+
if (child.authorized?.implement !== true) return { ...result, state: 'waiting-implementation-authorization' };
|
|
81
|
+
if (!slots) return { ...result, state: 'waiting-capacity' };
|
|
82
|
+
slots--;
|
|
83
|
+
return { ...result, state: 'implementation-eligible' };
|
|
84
|
+
});
|
|
85
|
+
return { schemaVersion: 1, status: 'assessed', children: results, aggregate: results.every(child => child.state === 'accepted') ? 'publication-eligible' : 'waiting-children', parent: 'open', errors: [] };
|
|
86
|
+
}
|