@juangcarmona/lighthouse-sdlc 0.17.1 → 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/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 +49 -12
- 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/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.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
package/src/opencode-execute.mjs
CHANGED
|
@@ -37,6 +37,22 @@ export async function snapshotSource(root, destination) {
|
|
|
37
37
|
await mkdir(dirname(target), { recursive: true }); await copyFile(source, target); await chmod(target, stat.mode & 0o111 ? 0o555 : 0o444);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
export async function installProductEvidence(input, source, expectedBaseline) {
|
|
41
|
+
const stat = await lstat(input);
|
|
42
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('ProductShape evidence directory is unavailable or unsafe');
|
|
43
|
+
const names = await readdir(input);
|
|
44
|
+
if (!names.includes('index.json') || names.length > 513 || names.some(name => name !== 'index.json' && !/^[A-Z0-9][A-Z0-9-]{0,159}\.json$/.test(name))) throw Error('ProductShape evidence inventory is invalid');
|
|
45
|
+
const index = JSON.parse(await readFile(join(input, 'index.json'), 'utf8'));
|
|
46
|
+
if (index.schemaVersion !== 1 || index.source !== '@prodshape/core' || index.version !== '0.25.0' || expectedBaseline && index.baselineDigest !== expectedBaseline || !Array.isArray(index.artifacts) || index.artifacts.length !== names.length - 1 || new Set(index.artifacts.map(item => item.report)).size !== index.artifacts.length || index.artifacts.some(item => !names.includes(item.report) || item.report !== `${item.id}.json`)) throw Error('ProductShape evidence index is invalid');
|
|
47
|
+
let bytes = 0;
|
|
48
|
+
const destination = join(source, '.lighthouse-product-evidence');
|
|
49
|
+
await mkdir(destination, { mode: 0o755 });
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const file = join(input, name), item = await lstat(file);
|
|
52
|
+
if (!item.isFile() || item.isSymbolicLink() || item.size > 512 * 1024 || (bytes += item.size) > 17 * 1024 * 1024) throw Error('ProductShape evidence is unsafe or exceeds bounds');
|
|
53
|
+
await copyFile(file, join(destination, name)); await chmod(join(destination, name), 0o444);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
40
56
|
export function acquireEngineSlot(path, seconds = 240) {
|
|
41
57
|
if (!isAbsolute(path ?? '')) throw Error('Operator engine lock must be an absolute path');
|
|
42
58
|
const fd = openSync(path, 'a', 0o600);
|
|
@@ -90,6 +106,10 @@ async function main() {
|
|
|
90
106
|
if (cancelled) throw Error('Execution cancelled before agent startup');
|
|
91
107
|
console.log('Engine slot acquired; starting isolated OpenCode');
|
|
92
108
|
await snapshotSource(env.GITHUB_WORKSPACE, source);
|
|
109
|
+
if (request.worker === 'product-change') {
|
|
110
|
+
if (!/^[a-f0-9]{64}$/.test(request.baselineDigest ?? '')) throw Error('ProductShape evidence lacks its authenticated baseline');
|
|
111
|
+
await installProductEvidence(join(runDir, 'product-evidence'), source, request.baselineDigest);
|
|
112
|
+
}
|
|
93
113
|
baseline = implementing ? await sourceFiles(source) : null;
|
|
94
114
|
trackedSource = baseline ? new Map(baseline) : null;
|
|
95
115
|
if (implementing) {
|
package/src/pr-state-actions.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { checkImplementationPullRequest } from './proposal-review.mjs';
|
|
3
|
-
import { authorizedCiCallback, projectImplementationPrState, reconcileImplementationPr, scheduledImplementationPulls, setPullRequestDraft } from './pr-state.mjs';
|
|
3
|
+
import { authorizedCiCallback, productOnlyCheckpoint, isStaleProductDecision, projectImplementationPrState, reconcileImplementationPr, scheduledImplementationPulls, setPullRequestDraft } from './pr-state.mjs';
|
|
4
|
+
import { handoff } from './handoff.mjs';
|
|
5
|
+
import { productProposalProof } from './product-change.mjs';
|
|
4
6
|
|
|
5
7
|
const apiFor = (repo, token) => async (path, method = 'GET', body, optional = false) => {
|
|
6
8
|
const response = await fetch(`https://api.github.com/repos/${repo}${path ? `/${path}` : ''}`, {
|
|
@@ -69,14 +71,27 @@ async function main() {
|
|
|
69
71
|
let target;
|
|
70
72
|
try { target = checkImplementationPullRequest({ ...pr, state: 'open', merged: false }, repo); }
|
|
71
73
|
catch {
|
|
72
|
-
|
|
74
|
+
if (productProposalProof(pr)) continue;
|
|
75
|
+
const product = productOnlyCheckpoint(pr);
|
|
76
|
+
if (product) {
|
|
77
|
+
if (!isStaleProductDecision(pr) || product.identity.repository !== repo || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo || pr.state !== 'open') continue;
|
|
78
|
+
const issue = await api(`issues/${product.identity.issue}`);
|
|
79
|
+
if (issue.state !== 'open') continue;
|
|
80
|
+
if (!pr.draft) await setPullRequestDraft(env.GH_TOKEN, pr, true);
|
|
81
|
+
await projectImplementationPrState(api, issue.number, true);
|
|
82
|
+
const notice = `<!-- lighthouse-stale-product:${pr.number}:${pr.head.sha} -->`;
|
|
83
|
+
if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'product-change', happened: `The product decision for PR #${pr.number} no longer matches its current head.`, action: 'Workflow maintainer: revalidate the Product Change and request a new decision for the current PR.', next: 'Product apply and parent continuation remain stopped until current evidence and approval are available.', link: `[Open PR #${pr.number}](${pr.html_url})`, details: notice }) });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const match = pr.head.ref.match(/^lighthouse\/(?:implement|propose)-([1-9][0-9]*)-[a-f0-9]{12}$/);
|
|
87
|
+
if (pr.head.ref.startsWith('lighthouse/propose-') && !pr.body?.includes('<!-- lighthouse-product-implementation:')) continue;
|
|
73
88
|
if (!match || pr.user?.login !== 'github-actions[bot]' || pr.head.repo?.full_name !== repo || pr.base.repo?.full_name !== repo || pr.state !== 'open') continue;
|
|
74
89
|
const issue = await api(`issues/${match[1]}`);
|
|
75
90
|
if (issue.state !== 'open') continue;
|
|
76
91
|
if (!pr.draft) await setPullRequestDraft(env.GH_TOKEN, pr, true);
|
|
77
92
|
await projectImplementationPrState(api, issue.number, true);
|
|
78
93
|
const notice = `<!-- lighthouse-pr-link-changed:${pr.number}:${pr.head.sha} -->`;
|
|
79
|
-
if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: `PR #${pr.number} is back in draft because its Lighthouse link changed
|
|
94
|
+
if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} is back in draft because its Lighthouse link changed.`, action: 'The PR owner must restore the link in the PR description.', next: 'The workflow can recheck the PR after the link is restored.', link: `[Open PR](${pr.html_url})`, details: notice }) });
|
|
80
95
|
continue;
|
|
81
96
|
}
|
|
82
97
|
const issue = await api(`issues/${target.issue}`);
|
|
@@ -84,23 +99,22 @@ async function main() {
|
|
|
84
99
|
if (pr.state !== 'open' || pr.merged) {
|
|
85
100
|
for (const name of ['pr-draft', 'pr-ready']) if (issue.labels.some(label => label.name === name)) await api(`issues/${issue.number}/labels/${name}`, 'DELETE', undefined, true);
|
|
86
101
|
if (!pr.merged) {
|
|
87
|
-
await api(`issues/${issue.number}/labels`, 'POST', { labels: ['waiting-review'] });
|
|
88
102
|
const notice = `<!-- lighthouse-pr-closed:${pr.number}:${pr.head.sha} -->`;
|
|
89
|
-
if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: `PR #${pr.number} was closed without merging
|
|
103
|
+
if (!(await commentsFor(api, issue.number)).some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(notice))) await api(`issues/${issue.number}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} was closed without merging.`, action: 'The owner can reopen the PR or request a new implementation.', next: 'No implementation resumes until an authorized request is made.', link: `[Open PR](${pr.html_url})`, details: notice }) });
|
|
90
104
|
}
|
|
91
105
|
continue;
|
|
92
106
|
}
|
|
93
107
|
const comments = await commentsFor(api, target.issue);
|
|
94
108
|
if (event.action === 'converted_to_draft') {
|
|
95
109
|
const hold = `<!-- lighthouse-pr-hold:${pr.number}:${pr.head.sha} -->`;
|
|
96
|
-
if (!comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(hold))) await api(`issues/${target.issue}/comments`, 'POST', { body: `PR #${pr.number} is a draft by owner choice. [Open PR](${pr.html_url}).\n\n${hold}` });
|
|
97
110
|
await projectImplementationPrState(api, target.issue, true);
|
|
111
|
+
if (!comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(hold))) await api(`issues/${target.issue}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} is a draft by owner choice.`, action: 'The PR owner can mark it ready when appropriate.', next: 'Automatic readiness remains on hold until the owner changes the PR state.', link: `[Open PR](${pr.html_url})`, details: hold }) });
|
|
98
112
|
continue;
|
|
99
113
|
}
|
|
100
114
|
const hold = comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`<!-- lighthouse-pr-hold:${pr.number}:${pr.head.sha} -->`));
|
|
101
115
|
const auto = (['workflow_run', 'schedule'].includes(env.GITHUB_EVENT_NAME) || ciCallback) && !hold;
|
|
102
116
|
const result = await reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks(profile), (current, draft) => setPullRequestDraft(env.GH_TOKEN, current, draft), { auto });
|
|
103
|
-
if (event.action === 'ready_for_review' && result.status === 'draft') await api(`issues/${target.issue}/comments`, 'POST', { body: `PR #${pr.number} remains a draft: ${result.reason}. [Open PR](${pr.html_url})
|
|
117
|
+
if (event.action === 'ready_for_review' && result.status === 'draft') await api(`issues/${target.issue}/comments`, 'POST', { body: handoff({ status: 'blocked', stage: 'implement', happened: `PR #${pr.number} remains a draft: ${result.reason}.`, action: 'The PR owner should resolve the stated blocker.', next: 'The workflow rechecks readiness when the relevant checks or PR state change.', link: `[Open PR](${pr.html_url})` }) });
|
|
104
118
|
}
|
|
105
119
|
}
|
|
106
120
|
main().catch(error => { console.error(`::error::${error.message}`); process.exitCode = 1; });
|
package/src/pr-state.mjs
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import { checkImplementationPullRequest } from './proposal-review.mjs';
|
|
2
2
|
import { checkCommit } from './completion.mjs';
|
|
3
|
+
import { validateProductCheckpoint, productProposalProof } from './product-change.mjs';
|
|
3
4
|
|
|
4
5
|
const marker = /<!-- lighthouse-pr-state:(\{[^\n]*\}) -->/;
|
|
5
6
|
|
|
7
|
+
export function productOnlyCheckpoint(pr) {
|
|
8
|
+
if (pr?.body?.includes('<!-- lighthouse-product-implementation:') || productProposalProof(pr)) return null;
|
|
9
|
+
const matches = [...(pr?.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
|
|
10
|
+
if (matches.length !== 1) return null;
|
|
11
|
+
try {
|
|
12
|
+
const checkpoint = JSON.parse(matches[0][1]);
|
|
13
|
+
const branch = pr.head?.ref?.match(/^lighthouse\/(?:implement|propose|product-change)-([1-9][0-9]*)-[a-f0-9]{12}$/);
|
|
14
|
+
if (validateProductCheckpoint(checkpoint).length || !branch || Number(branch[1]) !== checkpoint.identity.issue || checkpoint.identity.repository !== pr.head?.repo?.full_name || checkpoint.identity.repository !== pr.base?.repo?.full_name || !Number.isSafeInteger(pr.number)) return null;
|
|
15
|
+
if (checkpoint.state === 'waiting-product-decision' && checkpoint.effectIds.length === 1 && checkpoint.effectIds[0] === `pr:${pr.number}:${checkpoint.identity.head}`) return checkpoint;
|
|
16
|
+
if (checkpoint.state === 'applied' && checkpoint.effectIds.some(id => /^apply:[a-f0-9]{64}:/.test(id) && id.endsWith(`:${checkpoint.identity.head}`))) return checkpoint;
|
|
17
|
+
return null;
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
export const isProductOnlyPullRequest = pr => !!productOnlyCheckpoint(pr);
|
|
21
|
+
export const isStaleProductDecision = pr => { const checkpoint = productOnlyCheckpoint(pr); return !!checkpoint && checkpoint.identity.head !== pr.head?.sha; };
|
|
22
|
+
|
|
6
23
|
export async function scheduledImplementationPulls(api) {
|
|
7
24
|
const numbers = [];
|
|
8
25
|
for (let page = 1; page <= 10; page++) {
|
|
9
26
|
const pulls = await api(`pulls?state=open&per_page=100&page=${page}`);
|
|
10
|
-
numbers.push(...pulls.filter(pr => /^lighthouse\/implement-[1-9][0-9]*-[a-f0-9]{12}$/.test(pr.head?.ref ?? '') && pr.user?.login === 'github-actions[bot]').map(pr => pr.number));
|
|
27
|
+
numbers.push(...pulls.filter(pr => /^lighthouse\/(?:implement|propose|product-change)-[1-9][0-9]*-[a-f0-9]{12}$/.test(pr.head?.ref ?? '') && pr.user?.login === 'github-actions[bot]' && !productProposalProof(pr) && (isProductOnlyPullRequest(pr) ? isStaleProductDecision(pr) : pr.head.ref.startsWith('lighthouse/implement-') || pr.body?.includes('<!-- lighthouse-product-implementation:'))).map(pr => pr.number));
|
|
11
28
|
if (pulls.length < 100) return numbers;
|
|
12
29
|
}
|
|
13
30
|
throw Error('Open PR history exceeds reconciliation bound');
|
|
@@ -3,10 +3,25 @@ import { dirname, join } from 'node:path';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
7
|
import { validateProductChangeFiles, validateProductCheckpoint, validateProductWorkspace, loadProductBridge, productReviewBinding, parseProductDecision, verifyProductDecision, authorizeProductApply, createProductApplyReceipt, productModelDigest, productModelDirectory } from './product-change.mjs';
|
|
7
|
-
import { checkImplementationPullRequest } from './proposal-review.mjs';
|
|
8
|
+
import { checkImplementationPullRequest, checkProposal } from './proposal-review.mjs';
|
|
9
|
+
import { handoff } from './handoff.mjs';
|
|
10
|
+
|
|
11
|
+
const marker = '<!-- lighthouse-product-change-';
|
|
12
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
export function preparePendingProductCandidate(product) {
|
|
14
|
+
if (product.providerValidation?.status !== 'pending') return product;
|
|
15
|
+
validateProductChangeFiles(product.files, { changeName: product.changeName });
|
|
16
|
+
const path = `openspec/changes/${product.changeName}/.openspec.yaml`;
|
|
17
|
+
const metadata = product.files.find(file => file.path === path);
|
|
18
|
+
const flags = [...metadata.content.matchAll(/^\s*skip_specs\s*:\s*(.*?)\s*$/gm)];
|
|
19
|
+
if (flags.length > 1 || flags.length === 1 && !/^true(?:\s+#.*)?$/.test(flags[0][1])) throw Error('Product Change schema pin has conflicting skip_specs metadata');
|
|
20
|
+
if (flags.length) return product;
|
|
21
|
+
if (product.proposalDigest !== undefined) throw Error('Cannot normalize a Product Change with a supplied proposal digest');
|
|
22
|
+
return { ...product, files: product.files.map(file => file.path === path ? { ...file, content: `${file.content.trimEnd()}\nskip_specs: true\n` } : file) };
|
|
23
|
+
}
|
|
8
24
|
|
|
9
|
-
const marker = '<!-- lighthouse-product-change:';
|
|
10
25
|
export async function validateProductCandidate(root, changeName, files, bridge) {
|
|
11
26
|
const summary = validateProductChangeFiles(files, { changeName });
|
|
12
27
|
const temp = await mkdtemp(join(tmpdir(), 'lighthouse-product-change-'));
|
|
@@ -38,6 +53,13 @@ export async function validateProductCandidate(root, changeName, files, bridge)
|
|
|
38
53
|
} finally { await rm(temp, { recursive: true, force: true }); }
|
|
39
54
|
}
|
|
40
55
|
|
|
56
|
+
export function bindValidatedProductCandidate(product, validation, baselineDigest) {
|
|
57
|
+
if (validation.provider !== 'passed' || validation.openSpec !== 'passed' || !/^[a-f0-9]{64}$/.test(validation.digest ?? '') || !Number.isSafeInteger(validation.diagnosticCount) || validation.diagnosticCount < 0 || validation.changeId !== product.changeId || validation.baselineDigest !== baselineDigest || product.baselineDigest !== baselineDigest || product.proposalDigest !== undefined && product.proposalDigest !== validation.digest) throw Error('Product Change candidate or baseline changed during validation');
|
|
58
|
+
if (product.providerValidation?.status === 'passed' && product.providerValidation.diagnosticCount !== validation.diagnosticCount) throw Error('Product Change provider validation evidence does not match the host result');
|
|
59
|
+
if (!['pending', 'passed'].includes(product.providerValidation?.status)) throw Error('Product Change provider validation state is invalid');
|
|
60
|
+
return { ...product, proposalDigest: validation.digest, providerValidation: { status: 'passed', diagnosticCount: validation.diagnosticCount } };
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
export async function loadProductCandidateFromPullRequest(api, repository, pr, changeName) {
|
|
42
64
|
if (pr?.state !== 'open' || pr.merged || pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.head?.repo?.full_name !== repository || pr.base?.repo?.full_name !== repository) throw Error('Product Change review needs an open bot-owned PR in this repository');
|
|
43
65
|
const changed = await api(`pulls/${pr.number}/files?per_page=100`);
|
|
@@ -53,7 +75,7 @@ export async function loadProductCandidateFromPullRequest(api, repository, pr, c
|
|
|
53
75
|
return { files, ...checked };
|
|
54
76
|
}
|
|
55
77
|
|
|
56
|
-
export async function productModelDigestFromApi(api, revision) {
|
|
78
|
+
export async function productModelDigestFromApi(api, revision, overrides = []) {
|
|
57
79
|
const tree = await api(`git/trees/${revision}?recursive=1`);
|
|
58
80
|
if (tree.truncated || !Array.isArray(tree.tree) || tree.tree.length > 10000) throw Error('Accepted product model tree exceeds the API bound');
|
|
59
81
|
let modelRoot = 'docs/product/model';
|
|
@@ -66,27 +88,37 @@ export async function productModelDigestFromApi(api, revision) {
|
|
|
66
88
|
if (!safeRelative(modelRoot) || modelRoot.split('/').some(part => part.startsWith('.'))) throw Error('ProductShape product-root is unsafe');
|
|
67
89
|
const entries = tree.tree.filter(item => item.path.startsWith(`${modelRoot}/`) && item.type === 'blob').sort((a, b) => a.path.localeCompare(b.path));
|
|
68
90
|
if (!entries.length || entries.length > 512) throw Error('Accepted product model file list is empty or exceeds its bound');
|
|
91
|
+
const changed = new Map(overrides.filter(file => file.path.startsWith(`${modelRoot}/`)).map(file => [file.path, file.deleted ? null : Buffer.from(file.content, 'utf8')]));
|
|
69
92
|
const rows = [];
|
|
70
93
|
let bytes = 0;
|
|
71
94
|
for (const entry of entries) {
|
|
72
95
|
if (entry.mode !== '100644' && entry.mode !== '100755' || entry.size > 65536) throw Error(`Accepted product model path is not a bounded regular file: ${entry.path}`);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
96
|
+
if (changed.has(entry.path) && changed.get(entry.path) === null) { changed.delete(entry.path); continue; }
|
|
97
|
+
const blob = changed.has(entry.path) ? null : await api(`git/blobs/${entry.sha}`);
|
|
98
|
+
if (blob && (blob.encoding !== 'base64' || blob.size > 65536)) throw Error(`Accepted product artifact exceeds its bound: ${entry.path}`);
|
|
99
|
+
const content = changed.get(entry.path) ?? Buffer.from(blob.content, 'base64');
|
|
100
|
+
changed.delete(entry.path);
|
|
76
101
|
bytes += content.length;
|
|
77
102
|
if (bytes > 8 * 1024 * 1024) throw Error('Accepted product model exceeds its aggregate bound');
|
|
78
103
|
rows.push(`${entry.path.slice(`${modelRoot}/`.length)}\0${createHash('sha256').update(content).digest('hex')}\n`);
|
|
79
104
|
}
|
|
80
|
-
|
|
105
|
+
for (const [path, content] of changed) {
|
|
106
|
+
if (content === null) throw Error(`Product apply cannot delete a missing model file: ${path}`);
|
|
107
|
+
bytes += content.length;
|
|
108
|
+
if (content.length > 65536 || bytes > 8 * 1024 * 1024) throw Error('Applied product model exceeds its aggregate bound');
|
|
109
|
+
rows.push(`${path.slice(`${modelRoot}/`.length)}\0${createHash('sha256').update(content).digest('hex')}\n`);
|
|
110
|
+
}
|
|
111
|
+
if (rows.length > 512) throw Error('Applied product model file list exceeds its bound');
|
|
112
|
+
return { digest: createHash('sha256').update(rows.sort((a, b) => a.split('\0')[0].localeCompare(b.split('\0')[0])).join('')).digest('hex'), modelRoot };
|
|
81
113
|
}
|
|
82
114
|
|
|
83
|
-
function reviewText({ repository, snapshot, head, changeId, changeName, files, candidate, validation, runUrl, approvalKey }) {
|
|
115
|
+
export function reviewText({ repository, snapshot, head, changeId, changeName, files, candidate, validation, runUrl, approvalKey, prUrl }) {
|
|
84
116
|
const manifest = files.find(file => file.path.endsWith('/product-change/change.md'))?.content ?? '';
|
|
85
|
-
const sections = [...manifest.matchAll(/^## (.+?)\n([\s\S]*?)(?=^##
|
|
117
|
+
const sections = [...manifest.matchAll(/^## (.+?)\n([\s\S]*?)(?=^## |(?![\s\S]))/gm)].map(match => `### ${match[1]}\n${match[2].trim()}`).join('\n\n');
|
|
86
118
|
const artifacts = files.filter(file => file.path.includes('/proposed/')).map(file => `- [${file.path}](https://github.com/${repository}/blob/${head}/${file.path})`).join('\n');
|
|
87
119
|
const areas = candidate.affectedAreas?.map(area => `- ${area}`).join('\n') || '- See the change manifest.';
|
|
88
120
|
const decisions = candidate.decisions?.map(decision => `- ${decision}`).join('\n') || '- Review the proposed behavior in the change manifest.';
|
|
89
|
-
return
|
|
121
|
+
return handoff({ status: 'review', stage: snapshot.stage, happened: `Product decision: ${changeId} was prepared for [issue #${snapshot.issue}](https://github.com/${repository}/issues/${snapshot.issue}).\n\n### Affected product areas\n${areas}\n\n### Decisions and trade-offs\n${decisions}\n\n### Product Change\n${sections}\n\n### Proposed artifacts\n${artifacts || 'No artifact links were produced.'}\n\nValidation: ProductShape overlay and OpenSpec strict validation passed (${validation.provider}, ${validation.openSpec}).`, action: `Owner: review the proposal on this PR. Approve this exact product delta in a new comment with \`/product-change approve ${changeName} ${approvalKey}\`. To request a revision, put \`/product-change request-changes ${changeName} ${approvalKey}\` on the first line of a new comment and describe the change on the next line.`, next: 'Approval starts trusted ProductShape apply. Implementation, PR merge, release and deployment require their own authorization.', link: `[Review the branch diff](https://github.com/${repository}/compare/${snapshot.defaultBranch}...${head}) · [Product Change PR](${prUrl})`, details: `[Preparation run](${runUrl})` }).replace(/<!--\s*lighthouse-/gi, '<!-- lighthouse-');
|
|
90
122
|
}
|
|
91
123
|
|
|
92
124
|
export async function publishProductChangePullRequest(api, repository, snapshot, candidate, { validation, runUrl, beforeWrite }) {
|
|
@@ -94,14 +126,15 @@ export async function publishProductChangePullRequest(api, repository, snapshot,
|
|
|
94
126
|
if (files.changeId !== candidate.changeId || files.digest !== candidate.proposalDigest || validation.provider !== 'passed' || validation.openSpec !== 'passed') throw Error('Product Change candidate validation evidence is stale');
|
|
95
127
|
const pulls = await api('pulls?state=open&per_page=100');
|
|
96
128
|
if (pulls.length >= 100) throw Error('Open pull request list exceeds bounds');
|
|
97
|
-
const issuePulls = pulls.filter(pr => pr.head?.ref?.startsWith(`lighthouse/implement-${snapshot.issue}-`) || pr.head?.ref?.startsWith(`lighthouse/product-change-${snapshot.issue}-`));
|
|
129
|
+
const issuePulls = pulls.filter(pr => pr.head?.ref?.startsWith(`lighthouse/implement-${snapshot.issue}-`) || pr.head?.ref?.startsWith(`lighthouse/product-change-${snapshot.issue}-`) || pr.head?.ref?.startsWith(`lighthouse/propose-${snapshot.issue}-`));
|
|
98
130
|
if (issuePulls.length > 1) throw Error('Issue has multiple eligible delivery PRs; owner review is required');
|
|
99
131
|
let pr = issuePulls[0] ? await api(`pulls/${issuePulls[0].number}`) : null;
|
|
100
132
|
let base, branch, parent;
|
|
101
133
|
if (pr) {
|
|
102
|
-
if (pr.head.ref.startsWith(`lighthouse/implement-${snapshot.issue}-`) && pr.body?.includes('<!-- lighthouse-product-change:')) {
|
|
134
|
+
if ((pr.head.ref.startsWith(`lighthouse/implement-${snapshot.issue}-`) || pr.head.ref.startsWith(`lighthouse/propose-${snapshot.issue}-`)) && (pr.body?.includes('<!-- lighthouse-product-change:') || pr.body?.includes('<!-- lighthouse-product-change-review:'))) {
|
|
103
135
|
if (pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head?.repo?.full_name !== repository) throw Error('Existing Product Change PR is not a current bot-owned PR');
|
|
104
136
|
} else if (pr.head.ref.startsWith(`lighthouse/implement-${snapshot.issue}-`)) checkImplementationPullRequest(pr, repository);
|
|
137
|
+
else if (pr.head.ref.startsWith(`lighthouse/propose-${snapshot.issue}-`)) checkProposal(pr, repository);
|
|
105
138
|
else if (pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.state !== 'open' || pr.merged || !pr.body?.includes('<!-- lighthouse-product-change:')) throw Error('Existing issue PR is not an eligible Lighthouse Product Change draft');
|
|
106
139
|
if (pr.base?.ref !== snapshot.defaultBranch || pr.head?.repo?.full_name !== repository) throw Error('Existing issue PR targets an unexpected repository or base');
|
|
107
140
|
if (!pr.draft) throw Error('Product Change cannot modify a PR already submitted for review');
|
|
@@ -112,6 +145,7 @@ export async function publishProductChangePullRequest(api, repository, snapshot,
|
|
|
112
145
|
parent = snapshot.sourceRevision;
|
|
113
146
|
base = await api(`git/commits/${parent}`);
|
|
114
147
|
}
|
|
148
|
+
if (snapshot.productParent?.pullRequest && (!pr || pr.number !== snapshot.productParent.pullRequest.number || snapshot.productEntry !== 'feedback' && pr.head.sha !== snapshot.productParent.pullRequest.head || pr.head.ref !== snapshot.productParent.pullRequest.branch)) throw Error('Parent issue PR changed before Product Change publication');
|
|
115
149
|
const tree = await api('git/trees', 'POST', { base_tree: base.tree.sha, tree: candidate.files.map(file => ({ path: file.path, mode: '100644', type: 'blob', content: file.content })) });
|
|
116
150
|
const commit = await api('git/commits', 'POST', { message: `docs: prepare product change for #${snapshot.issue}`, tree: tree.sha, parents: [parent] });
|
|
117
151
|
await beforeWrite();
|
|
@@ -128,39 +162,37 @@ export async function publishProductChangePullRequest(api, repository, snapshot,
|
|
|
128
162
|
}
|
|
129
163
|
const repositoryInfo = await api('');
|
|
130
164
|
const policyDigest = snapshot.policyDigest;
|
|
131
|
-
const binding = productReviewBinding({ repository, issue: snapshot.issue, pullRequest: pr.number, changeName: candidate.changeName, changeId: candidate.changeId, proposalDigest: candidate.proposalDigest, baselineDigest: candidate.baselineDigest, policyDigest, authorityDigest: snapshot.authorizationDigest, head: pr.head.sha });
|
|
165
|
+
const binding = productReviewBinding({ repository, issue: snapshot.issue, pullRequest: pr.number, changeName: candidate.changeName, changeId: candidate.changeId, proposalDigest: candidate.proposalDigest, baselineDigest: candidate.baselineDigest, policyDigest, authorityDigest: snapshot.authorizationDigest, head: pr.head.sha, ...(snapshot.productParent ? { parentDigest: digest(JSON.stringify(snapshot.productParent)) } : {}) });
|
|
132
166
|
const checkpoint = {
|
|
133
167
|
schemaVersion: 1, kind: 'checkpoint',
|
|
134
168
|
identity: { repository, issue: snapshot.issue, parentRunId: String(snapshot.dispatch?.routerRunId ?? snapshot.workflowRunId), authorizationDigest: snapshot.authorizationDigest, policyDigest, workflowRevision: snapshot.workflowRevision, head: pr.head.sha },
|
|
135
169
|
requestDigest: snapshot.dispatch?.digest ?? snapshot.taskDigest,
|
|
136
170
|
changeName: candidate.changeName, changeId: candidate.changeId, proposalDigest: candidate.proposalDigest, baselineDigest: candidate.baselineDigest,
|
|
137
|
-
state: 'waiting-product-decision', effectIds: [`pr:${pr.number}:${pr.head.sha}`]
|
|
171
|
+
state: 'waiting-product-decision', effectIds: [`pr:${pr.number}:${pr.head.sha}`], ...(snapshot.productParent ? { parent: snapshot.productParent } : {})
|
|
138
172
|
};
|
|
139
173
|
const checkpointErrors = validateProductCheckpoint(checkpoint);
|
|
140
174
|
if (checkpointErrors.length) throw Error(checkpointErrors.join('; '));
|
|
141
|
-
const body = reviewText({ repository, snapshot, head: pr.head.sha, changeId: candidate.changeId, changeName: candidate.changeName, files: candidate.files, candidate, validation, runUrl, approvalKey: binding.key });
|
|
175
|
+
const body = reviewText({ repository, snapshot, head: pr.head.sha, changeId: candidate.changeId, changeName: candidate.changeName, files: candidate.files, candidate, validation, runUrl, approvalKey: binding.key, prUrl: pr.html_url });
|
|
142
176
|
const reviewMarker = `${marker}review:${JSON.stringify(binding)} -->`;
|
|
143
177
|
const checkpointMarker = `${marker}checkpoint:${JSON.stringify(checkpoint)} -->`;
|
|
144
178
|
let prBody = pr.body ?? '';
|
|
145
|
-
prBody =
|
|
146
|
-
? prBody.replace(/<!-- lighthouse-product-change
|
|
179
|
+
prBody = /<!-- lighthouse-product-change(?:-review|:review):/.test(prBody)
|
|
180
|
+
? prBody.replace(/<!-- lighthouse-product-change(?:-review|:review):\{[^\n]*\} -->/g, reviewMarker)
|
|
147
181
|
: `${prBody}\n\n${reviewMarker}`;
|
|
148
|
-
prBody =
|
|
149
|
-
? prBody.replace(/<!-- lighthouse-product-change
|
|
182
|
+
prBody = /<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):/.test(prBody)
|
|
183
|
+
? prBody.replace(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):\{[^\n]*\} -->/g, checkpointMarker)
|
|
150
184
|
: `${prBody}\n${checkpointMarker}`;
|
|
151
185
|
await api(`pulls/${pr.number}`, 'PATCH', { body: prBody });
|
|
152
|
-
const previous = await api(`issues/${pr.number}/comments?per_page=100`);
|
|
153
|
-
const notice = previous.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`Product decision: ${candidate.changeId}`));
|
|
154
|
-
if (notice) await api(`issues/comments/${notice.id}`, 'PATCH', { body });
|
|
155
|
-
else await api(`issues/${pr.number}/comments`, 'POST', { body });
|
|
156
|
-
const issueComments = await api(`issues/${snapshot.issue}/comments?per_page=100`);
|
|
157
|
-
const issueNotice = issueComments.find(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`<!-- lighthouse-product-change-link:${candidate.proposalDigest} -->`));
|
|
158
|
-
const issueBody = `A draft Product Change is ready in [PR #${pr.number}](${pr.html_url}). Review the proposed behavior, affected product areas, decisions, trade-offs and validation evidence there. Approve the identified product proposal or request changes in that PR. Approval starts trusted ProductShape apply; this idea entry does not start implementation. PR merge remains separate.\n\n<!-- lighthouse-product-change-link:${candidate.proposalDigest} -->`;
|
|
159
|
-
if (issueNotice) await api(`issues/comments/${issueNotice.id}`, 'PATCH', { body: issueBody });
|
|
160
|
-
else await api(`issues/${snapshot.issue}/comments`, 'POST', { body: issueBody });
|
|
161
186
|
return { pullRequest: pr, binding, checkpoint, body, repositoryDefaultBranch: repositoryInfo.default_branch };
|
|
162
187
|
}
|
|
163
188
|
|
|
189
|
+
export function checkProductDryRun(preview, changeId) {
|
|
190
|
+
if (preview.outcome !== 'dry-run' || preview.plan?.changeId && preview.plan.changeId !== changeId) {
|
|
191
|
+
const codes = (preview.plan?.diagnostics ?? []).filter(item => item.severity === 'error' && /^[A-Z][A-Z0-9]{0,31}$/.test(item.code ?? '')).map(item => item.code).slice(0, 10);
|
|
192
|
+
throw Error(`ProductShape dry-run does not match the reviewed change${codes.length ? `: ${[...new Set(codes)].join(', ')}` : ''}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
164
196
|
export async function applyApprovedProductChange({ root, changeName, checkpoint, approval, currentBinding, currentIdentity, bridge, headAfter, resultingModelDigest, reportBeforeWrite, publish }) {
|
|
165
197
|
const intent = authorizeProductApply({ checkpoint, approval, currentBinding, currentIdentity });
|
|
166
198
|
const provider = bridge ?? await loadProductBridge();
|
|
@@ -170,7 +202,7 @@ export async function applyApprovedProductChange({ root, changeName, checkpoint,
|
|
|
170
202
|
await writeFile(manifest, original.replace(/^status:\s*draft\s*$/m, 'status: approved'));
|
|
171
203
|
await validateProductWorkspace(root, changeName, provider);
|
|
172
204
|
const preview = await provider.applyOpenSpecProductChange(root, changeName, { dryRun: true });
|
|
173
|
-
|
|
205
|
+
checkProductDryRun(preview, checkpoint.changeId);
|
|
174
206
|
const applied = await provider.applyOpenSpecProductChange(root, changeName, { reportBeforeWrite });
|
|
175
207
|
if (applied.outcome !== 'applied' || applied.change?.id !== checkpoint.changeId) throw Error('ProductShape apply was refused or returned a different change');
|
|
176
208
|
if ((applied.resultingModel?.diagnostics ?? []).some(item => item.severity === 'error')) throw Error('ProductShape resulting model has validation errors');
|
|
@@ -185,13 +217,22 @@ export async function applyApprovedProductChange({ root, changeName, checkpoint,
|
|
|
185
217
|
export async function collectProductApplyFiles(root, changeName, baseRevision) {
|
|
186
218
|
const prefix = `openspec/changes/${changeName}/product-change/change.md`;
|
|
187
219
|
const modelRoot = await productModelDirectory(root);
|
|
188
|
-
const
|
|
220
|
+
const options = { cwd: root, encoding: 'utf8', maxBuffer: 128000 };
|
|
221
|
+
const changed = execFileSync('git', ['diff', '--name-only', '-z', '--no-renames', baseRevision, '--'], options).split('\0').filter(Boolean);
|
|
222
|
+
const added = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z', '--', '.', ':(exclude).lighthouse-harness', ':(exclude).lighthouse-harness/**'], options).split('\0').filter(Boolean);
|
|
223
|
+
const paths = [...new Set([...changed, ...added])].sort();
|
|
189
224
|
if (!paths.includes(prefix) || paths.length > 520 || paths.some(path => path !== prefix && !path.startsWith(`${modelRoot}/`))) throw Error('Trusted apply wrote outside the Product Change manifest and accepted model');
|
|
190
225
|
const files = [];
|
|
191
226
|
let bytes = 0;
|
|
192
227
|
for (const path of paths) {
|
|
193
228
|
const absolute = join(root, path);
|
|
194
|
-
|
|
229
|
+
let stat;
|
|
230
|
+
try { stat = await import('node:fs/promises').then(fs => fs.lstat(absolute)); }
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (error.code !== 'ENOENT' || path === prefix) throw error;
|
|
233
|
+
files.push({ path, deleted: true });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
195
236
|
if (!stat.isFile() || stat.isSymbolicLink()) throw Error(`Trusted apply produced a non-regular path: ${path}`);
|
|
196
237
|
if (stat.size > 65536) throw Error(`Trusted apply artifact exceeds its bound: ${path}`);
|
|
197
238
|
const content = await readFile(absolute, 'utf8');
|
|
@@ -210,9 +251,11 @@ export function validateProductApplyFiles(files, changeName, modelRoot = 'docs/p
|
|
|
210
251
|
if (!Array.isArray(files) || !files.length || files.length > 520) throw Error('Product apply output file list is empty or exceeds its bound');
|
|
211
252
|
const seen = new Set(); let bytes = 0;
|
|
212
253
|
for (const file of files) {
|
|
213
|
-
|
|
254
|
+
const deletion = file?.deleted === true && Object.keys(file).sort().join(',') === 'deleted,path';
|
|
255
|
+
if (!file || !deletion && (Object.keys(file).sort().join(',') !== 'content,path' || typeof file.content !== 'string') || typeof file.path !== 'string' || seen.has(file.path)) throw Error('Invalid or duplicated Product apply output file');
|
|
214
256
|
if (file.path !== manifestPath && !file.path.startsWith(`${modelRoot}/`)) throw Error(`Product apply output escaped the accepted model: ${file.path}`);
|
|
215
|
-
if (!safeRelative(file.path) || file.content.includes('\0')) throw Error(`Unsafe Product apply output: ${file.path}`);
|
|
257
|
+
if (!safeRelative(file.path) || deletion && file.path === manifestPath || !deletion && file.content.includes('\0')) throw Error(`Unsafe Product apply output: ${file.path}`);
|
|
258
|
+
if (deletion) { seen.add(file.path); continue; }
|
|
216
259
|
const size = Buffer.byteLength(file.content);
|
|
217
260
|
if (size > 65536) throw Error(`Product apply output exceeds its file bound: ${file.path}`);
|
|
218
261
|
bytes += size;
|
|
@@ -226,32 +269,64 @@ export function validateProductApplyFiles(files, changeName, modelRoot = 'docs/p
|
|
|
226
269
|
|
|
227
270
|
const safeRelative = path => !path.startsWith('/') && !path.includes('\\') && !path.split('/').some(part => !part || part === '.' || part === '..');
|
|
228
271
|
|
|
229
|
-
export
|
|
272
|
+
export function productApplyHandoff({ stage, receipt, pullRequest, url, affectedCitations, parent }) {
|
|
273
|
+
return handoff({ status: 'completed', stage, happened: `Product Change ${receipt.changeId} was applied to the provider-validated model on PR #${pullRequest}.\n\nAffected citations:\n${(affectedCitations ?? []).map(item => `- \`${item.document ?? item.file ?? item.id ?? 'consumer'}\`: ${item.status ?? 'refresh required'}`).join('\n') || '- None reported.'}`, action: 'Owner: review the applied model and affected citations on the linked PR.', next: parent ? 'The workflow will recheck the existing parent authorization before requesting its continuation. Merge remains separate.' : 'Merge and any further delivery require their own authorization.', link: `[Review PR #${pullRequest}](${url})`, details: `<!-- lighthouse-product-apply:${JSON.stringify(receipt)} -->` });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function observeUpdatedProductPull(api, number, expectedHead, wait = ms => new Promise(resolve => setTimeout(resolve, ms))) {
|
|
277
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
278
|
+
const pr = await api(`pulls/${number}`);
|
|
279
|
+
if (pr.state !== 'open' || pr.merged) throw Error('Product PR closed while confirming the applied head');
|
|
280
|
+
if (pr.head?.sha === expectedHead) return pr;
|
|
281
|
+
if (attempt < 19) await wait(1000);
|
|
282
|
+
}
|
|
283
|
+
throw Error('Product PR head update could not be confirmed; reconcile publication before retrying');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function advanceProductRef(api, branch, head) {
|
|
287
|
+
try {
|
|
288
|
+
await api(`git/refs/heads/${branch}`, 'PATCH', { sha: head, force: false });
|
|
289
|
+
} catch (error) {
|
|
290
|
+
const current = await api(`git/ref/heads/${branch}`, 'GET', undefined, true).catch(() => null);
|
|
291
|
+
if (current?.object?.sha !== head) throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function publishProductApply(api, repository, owner, dispatch, result, beforeWrite, applyRequest) {
|
|
230
296
|
const approvalInfo = dispatch.request.productApproval;
|
|
231
297
|
if (dispatch.request.worker !== 'apply-product-change' || !approvalInfo || !result || result.head !== approvalInfo.head) throw Error('Product apply result does not match its authenticated dispatch');
|
|
232
298
|
const files = validateProductApplyFiles(result.files, approvalInfo.changeName, result.modelRoot);
|
|
233
299
|
const fresh = await loadProductApproval(api, { eventName: dispatch.request.eventName, event: dispatch.request.event, owner, repository, root: process.cwd() });
|
|
234
|
-
if (!fresh || fresh.head !== approvalInfo.head || fresh.pullRequest !== approvalInfo.pullRequest || fresh.approval.reviewKey !== approvalInfo.reviewKey || fresh.checkpoint.changeId !== result.receipt
|
|
300
|
+
if (!fresh || fresh.head !== approvalInfo.head || fresh.pullRequest !== approvalInfo.pullRequest || fresh.approval.reviewKey !== approvalInfo.reviewKey || fresh.checkpoint.changeId !== result.receipt?.changeId) throw Error('Product approval or reviewed branch changed before publication');
|
|
301
|
+
const approvalFields = value => { const { observedAt, ...rest } = value ?? {}; return rest; };
|
|
302
|
+
if (!applyRequest || applyRequest.repository !== repository || applyRequest.owner !== owner || applyRequest.issue !== fresh.issue || applyRequest.pullRequest !== fresh.pullRequest || applyRequest.head !== fresh.head || applyRequest.changeName !== fresh.changeName || !isDeepStrictEqual(applyRequest.checkpoint, fresh.checkpoint) || !isDeepStrictEqual(applyRequest.binding, fresh.binding) || !isDeepStrictEqual(approvalFields(applyRequest.approval), approvalFields(fresh.approval))) throw Error('Product apply request no longer matches authenticated approval');
|
|
303
|
+
const intent = authorizeProductApply({ checkpoint: applyRequest.checkpoint, approval: applyRequest.approval, currentBinding: applyRequest.binding, currentIdentity: applyRequest.checkpoint.identity });
|
|
304
|
+
const expectedReceipt = createProductApplyReceipt({ intent, approval: applyRequest.approval, checkpoint: applyRequest.checkpoint, headAfter: fresh.head, resultingModelDigest: result.resultingModelDigest, validated: true, appliedAt: result.receipt?.appliedAt });
|
|
305
|
+
if (result.providerOutcome !== 'applied' || !isDeepStrictEqual(result.receipt, expectedReceipt)) throw Error('Product apply receipt does not match authenticated provider output');
|
|
235
306
|
if (result.receipt.resultingModelDigest !== result.resultingModelDigest || result.receipt.baselineDigest !== fresh.checkpoint.baselineDigest || result.receipt.proposalDigest !== fresh.checkpoint.proposalDigest) throw Error('Product apply receipt does not match the approved candidate');
|
|
307
|
+
const projected = await productModelDigestFromApi(api, fresh.head, files);
|
|
308
|
+
if (projected.modelRoot !== result.modelRoot || projected.digest !== result.resultingModelDigest) throw Error('Published files do not reproduce the provider-validated product model');
|
|
236
309
|
const pr = await api(`pulls/${fresh.pullRequest}`);
|
|
237
310
|
if (pr.head.sha !== fresh.head || pr.state !== 'open' || pr.merged || pr.head.repo?.full_name !== repository) throw Error('Product PR head changed before apply publication');
|
|
238
311
|
const commit = await api(`git/commits/${fresh.head}`);
|
|
239
|
-
const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: files.map(file =>
|
|
312
|
+
const tree = await api('git/trees', 'POST', { base_tree: commit.tree.sha, tree: files.map(file => file.deleted ? { path: file.path, mode: '100644', type: 'blob', sha: null } : { path: file.path, mode: '100644', type: 'blob', content: file.content }) });
|
|
240
313
|
const created = await api('git/commits', 'POST', { message: `docs: apply product change ${fresh.checkpoint.changeId}`, tree: tree.sha, parents: [fresh.head] });
|
|
314
|
+
if (!/^[a-f0-9]{40}$/.test(created?.sha ?? '')) throw Error('Created product commit has no valid identity');
|
|
241
315
|
await beforeWrite(fresh);
|
|
242
|
-
await api
|
|
243
|
-
const updated = await api
|
|
316
|
+
await advanceProductRef(api, pr.head.ref, created.sha);
|
|
317
|
+
const updated = await observeUpdatedProductPull(api, fresh.pullRequest, created.sha);
|
|
244
318
|
const receipt = { ...result.receipt, headAfter: updated.head.sha };
|
|
245
319
|
let prBody = updated.body ?? '';
|
|
246
320
|
const checkpoint = { ...fresh.checkpoint, state: 'applied', identity: { ...fresh.checkpoint.identity, head: updated.head.sha }, effectIds: [...new Set([...fresh.checkpoint.effectIds, `apply:${receipt.intentId}:${updated.head.sha}`])].slice(0, 32) };
|
|
247
|
-
validateProductCheckpoint(checkpoint);
|
|
248
|
-
|
|
321
|
+
const checkpointErrors = validateProductCheckpoint(checkpoint);
|
|
322
|
+
if (checkpointErrors.length) throw Error(checkpointErrors.join('; '));
|
|
323
|
+
prBody = prBody.replace(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):\{[^\n]*\} -->/g, `${marker}checkpoint:${JSON.stringify(checkpoint)} -->`);
|
|
249
324
|
await api(`pulls/${fresh.pullRequest}`, 'PATCH', { body: prBody });
|
|
250
|
-
const issueBody =
|
|
325
|
+
const issueBody = productApplyHandoff({ stage: dispatch.request.stage, receipt, pullRequest: fresh.pullRequest, url: updated.html_url, affectedCitations: result.affectedCitations, parent: checkpoint.parent });
|
|
326
|
+
if (checkpoint.parent && (await api(`issues/${fresh.issue}`)).labels?.some(label => label.name === 'waiting-review')) await api(`issues/${fresh.issue}/labels/waiting-review`, 'DELETE');
|
|
251
327
|
const comments = await api(`issues/${fresh.issue}/comments?per_page=100`);
|
|
252
328
|
const prior = comments.find(comment => comment.user?.login === 'github-actions[bot]' && [...(comment.body ?? '').matchAll(/<!-- lighthouse-product-apply:(\{[^\n]*\}) -->/g)].some(match => JSON.parse(match[1]).intentId === receipt.intentId));
|
|
253
|
-
if (prior) await api(`issues
|
|
254
|
-
else await api(`issues/${fresh.issue}/comments`, 'POST', { body: issueBody });
|
|
329
|
+
if (!prior) await api(`issues/${fresh.issue}/comments`, 'POST', { body: issueBody });
|
|
255
330
|
return { pullRequest: updated, receipt, checkpoint };
|
|
256
331
|
}
|
|
257
332
|
|
|
@@ -271,6 +346,35 @@ export function productApprovalFromGitHub({ eventName, event, owner, repository,
|
|
|
271
346
|
return verifyProductDecision({ decision, actor, actorType, owner, source, submitted, currentBinding, observedBinding: currentBinding, decisionId });
|
|
272
347
|
}
|
|
273
348
|
|
|
349
|
+
export function checkCurrentProductDecision({ eventName, event, current, repository, number }) {
|
|
350
|
+
const original = eventName === 'issue_comment' ? event.comment : event.review;
|
|
351
|
+
if (!original || !current || !/^[1-9][0-9]*$/.test(String(original.id ?? '')) || String(current.id) !== String(original.id) || current.body !== original.body || current.user?.login !== original.user?.login || current.user?.type !== original.user?.type) throw Error('Product decision was edited, deleted or replaced');
|
|
352
|
+
if (eventName === 'issue_comment') {
|
|
353
|
+
if (current.issue_url !== `https://api.github.com/repos/${repository}/issues/${number}` || !original.updated_at || current.updated_at !== original.updated_at) throw Error('Product decision comment changed or belongs to another PR');
|
|
354
|
+
} else if (current.pull_request_url !== `https://api.github.com/repos/${repository}/pulls/${number}` || !original.submitted_at || current.submitted_at !== original.submitted_at || typeof current.state !== 'string' || typeof original.state !== 'string' || current.state.toUpperCase() !== original.state.toUpperCase() || current.state.toUpperCase() === 'DISMISSED') throw Error('Product decision review changed or was dismissed');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function checkNoLaterProductObjection(api, number, owner, decision, originalTime) {
|
|
358
|
+
const started = Date.parse(originalTime ?? '');
|
|
359
|
+
if (!Number.isFinite(started)) throw Error('Product decision time is unavailable');
|
|
360
|
+
const comments = await (await import('./stage-dispatch.mjs')).issueComments(api, number);
|
|
361
|
+
const reviews = [];
|
|
362
|
+
for (let page = 1; page <= 10; page++) {
|
|
363
|
+
const rows = await api(`pulls/${number}/reviews?per_page=100&page=${page}`);
|
|
364
|
+
if (!Array.isArray(rows)) throw Error('Product review history is unavailable');
|
|
365
|
+
reviews.push(...rows);
|
|
366
|
+
if (rows.length < 100) break;
|
|
367
|
+
if (page === 10) throw Error('Product review history exceeds bounds');
|
|
368
|
+
}
|
|
369
|
+
for (const entry of [...comments, ...reviews]) {
|
|
370
|
+
if (entry.user?.login !== owner || entry.user?.type !== 'User') continue;
|
|
371
|
+
const next = parseProductDecision(entry.body);
|
|
372
|
+
if (!next || next.changeName !== decision.changeName || next.reviewKey !== decision.reviewKey || !['request-changes', 'answer'].includes(next.action)) continue;
|
|
373
|
+
const at = Date.parse(entry.submitted_at ?? entry.created_at ?? '');
|
|
374
|
+
if (!Number.isFinite(at) || at >= started) throw Error('A later owner decision requires product review before apply');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
274
378
|
export async function loadProductApproval(api, { eventName, event, owner, repository, root }) {
|
|
275
379
|
let body, actor, actorType, source, decisionId;
|
|
276
380
|
if (eventName === 'issue_comment' && event.action === 'created' && event.issue?.pull_request) {
|
|
@@ -281,19 +385,23 @@ export async function loadProductApproval(api, { eventName, event, owner, reposi
|
|
|
281
385
|
const decision = parseProductDecision(body);
|
|
282
386
|
if (!decision || !['approve', 'request-changes', 'answer'].includes(decision.action)) return null;
|
|
283
387
|
const number = event.pull_request?.number ?? event.issue?.number;
|
|
388
|
+
if (!Number.isSafeInteger(number) || number < 1 || !/^[1-9][0-9]*$/.test(decisionId)) throw Error('Product decision identity is invalid');
|
|
389
|
+
const decisionPath = eventName === 'issue_comment' ? `issues/comments/${decisionId}` : `pulls/${number}/reviews/${decisionId}`;
|
|
390
|
+
checkCurrentProductDecision({ eventName, event, current: await api(decisionPath), repository, number });
|
|
391
|
+
if (decision.action === 'approve') await checkNoLaterProductObjection(api, number, owner, decision, eventName === 'issue_comment' ? event.comment.created_at : event.review.submitted_at);
|
|
284
392
|
const pr = await api(`pulls/${number}`);
|
|
285
393
|
if (pr.user?.login !== 'github-actions[bot]' || pr.user?.type !== 'Bot' || pr.state !== 'open' || pr.merged || pr.head?.repo?.full_name !== repository || pr.base?.repo?.full_name !== repository) throw Error('Product approval target is not a current Lighthouse PR');
|
|
286
|
-
const markers = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change
|
|
394
|
+
const markers = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-review|:review):(\{[^\n]*\}) -->/g)];
|
|
287
395
|
if (markers.length !== 1) throw Error('PR has no unique Product Change review binding');
|
|
288
396
|
const raw = JSON.parse(markers[0][1]);
|
|
289
397
|
const { key, ...fields } = raw;
|
|
290
398
|
const bound = productReviewBinding(fields);
|
|
291
399
|
if (bound.key !== key || bound.head !== pr.head.sha || bound.pullRequest !== pr.number || bound.repository !== repository) throw Error('Product Change review binding is stale or belongs to another PR');
|
|
292
|
-
const checkpoints = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change
|
|
400
|
+
const checkpoints = [...(pr.body ?? '').matchAll(/<!-- lighthouse-product-change(?:-checkpoint|:checkpoint):(\{[^\n]*\}) -->/g)];
|
|
293
401
|
if (checkpoints.length !== 1) throw Error('PR has no unique Product Change checkpoint');
|
|
294
402
|
const checkpoint = JSON.parse(checkpoints[0][1]);
|
|
295
403
|
const checkpointErrors = validateProductCheckpoint(checkpoint);
|
|
296
|
-
if (checkpointErrors.length || checkpoint.state !== 'waiting-product-decision' || checkpoint.changeName !== bound.changeName || checkpoint.changeId !== bound.changeId || checkpoint.proposalDigest !== bound.proposalDigest || checkpoint.baselineDigest !== bound.baselineDigest || checkpoint.identity.head !== pr.head.sha || checkpoint.identity.issue !== bound.issue || checkpoint.identity.repository !== repository || checkpoint.effectIds.length !== 1 || checkpoint.effectIds[0] !== `pr:${pr.number}:${pr.head.sha}`) throw Error('Product Change checkpoint is stale or invalid');
|
|
404
|
+
if (checkpointErrors.length || (checkpoint.parent ? digest(JSON.stringify(checkpoint.parent)) !== bound.parentDigest : bound.parentDigest !== undefined) || checkpoint.state !== 'waiting-product-decision' || checkpoint.changeName !== bound.changeName || checkpoint.changeId !== bound.changeId || checkpoint.proposalDigest !== bound.proposalDigest || checkpoint.baselineDigest !== bound.baselineDigest || checkpoint.identity.head !== pr.head.sha || checkpoint.identity.issue !== bound.issue || checkpoint.identity.repository !== repository || checkpoint.effectIds.length !== 1 || checkpoint.effectIds[0] !== `pr:${pr.number}:${pr.head.sha}`) throw Error('Product Change checkpoint is stale or invalid');
|
|
297
405
|
if (decision.changeName !== bound.changeName || decision.reviewKey !== bound.key) throw Error('Product approval action does not identify the current Product Change');
|
|
298
406
|
const candidate = await loadProductCandidateFromPullRequest(api, repository, pr, bound.changeName);
|
|
299
407
|
const repositoryInfo = await api('');
|
|
@@ -303,6 +411,10 @@ export async function loadProductApproval(api, { eventName, event, owner, reposi
|
|
|
303
411
|
const verified = verifyProductDecision({ decision, actor, actorType, owner, source, submitted: true, currentBinding: bound, observedBinding: bound, decisionId });
|
|
304
412
|
const issue = await api(`issues/${bound.issue}`);
|
|
305
413
|
if (issue.state !== 'open' || issue.labels?.some(label => ['future', 'human-working', 'agent-working'].includes(label.name))) throw Error('Product apply is blocked by the current issue state or reservation');
|
|
414
|
+
if (checkpoint.parent) {
|
|
415
|
+
const comments = await (await import('./stage-dispatch.mjs')).issueComments(api, bound.issue);
|
|
416
|
+
await (await import('./stage-transition.mjs')).verifyProductParent(api, repository, owner, checkpoint.parent, issue, comments, defaultHead);
|
|
417
|
+
}
|
|
306
418
|
const feedback = decision.action === 'approve' ? null : { action: decision.action, changeName: decision.changeName, reviewKey: decision.reviewKey, text: decision.detail, sourceCommentId: decisionId, source: 'pull-request' };
|
|
307
419
|
return { ...(decision.action === 'approve' ? { approval: verified } : { feedback }), binding: bound, checkpoint, issue: bound.issue, pullRequest: pr.number, head: pr.head.sha, changeName: bound.changeName, candidate, modelRoot: accepted.modelRoot };
|
|
308
420
|
}
|