@juangcarmona/lighthouse-sdlc 0.14.0 → 0.15.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 +8 -4
- package/capabilities.json +3 -0
- package/completion.md +1 -1
- package/model-profiles.md +101 -0
- package/package.json +8 -1
- package/request-triage.md +30 -0
- package/schemas/estimate.schema.json +23 -0
- package/schemas/request-analysis.schema.json +37 -0
- package/schemas/triage.schema.json +1 -0
- package/skills/concise-results/SKILL.md +10 -0
- package/skills/refine/SKILL.md +1 -1
- package/skills/triage/SKILL.md +3 -3
- package/src/completion.mjs +1 -1
- package/src/host-contract.mjs +3 -0
- package/src/implementation.mjs +6 -1
- package/src/install-workflows.mjs +11 -5
- package/src/integration-evidence.mjs +1 -1
- package/src/issue-actions.mjs +62 -18
- package/src/model-profiles.mjs +58 -0
- package/src/opencode-execute.mjs +17 -4
- package/src/pr-state-actions.mjs +106 -0
- package/src/pr-state.mjs +75 -0
- package/src/profile-delegation.mjs +111 -0
- package/src/proposal.mjs +1 -1
- package/src/request-triage.mjs +42 -0
- package/src/stage-dispatch.mjs +21 -5
- package/src/stage-transition.mjs +10 -0
- package/src/triage.mjs +4 -0
- package/templates/change-request.yml +24 -0
- package/workflows/lighthouse-sdlc-apply-review.md +1 -0
- package/workflows/lighthouse-sdlc-refine.md +5 -0
- package/workflows/lighthouse-sdlc-router.yml +37 -4
- package/workflows/lighthouse-sdlc-triage.md +174 -28
package/src/issue-actions.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { refinementAfterRun } from './stage-transition.mjs';
|
|
1
|
+
import { refinementAfterRun, signalRefinementContinuation } from './stage-transition.mjs';
|
|
2
2
|
import { dispatchId, dispatchArtifact, recordDispatch, sendDispatch, admitDispatch, checkDispatchClaim, finishDispatch, completedEvent, statusText, stages } from './stage-dispatch.mjs';
|
|
3
3
|
import { readActionsJson } from './actions-artifact.mjs';
|
|
4
4
|
import { deliveryAfterChecks, dispatchDeliveryChecks } from './delivery-events.mjs';
|
|
@@ -10,6 +10,8 @@ import { feedbackKey, feedbackWorker, checkProposal, reviewThread, reviewTarget,
|
|
|
10
10
|
import { loadImplementation, loadImplementationSeed, changesDigest, validateChanges, validateCheckReceipt, createImplementationPullRequest } from './implementation.mjs';
|
|
11
11
|
import { digest, validateStageEnvelope } from './host-contract.mjs';
|
|
12
12
|
import { loadRefinement, loadStageReport, prepareProposal, proposalRoot, createProposalPullRequest } from './proposal.mjs';
|
|
13
|
+
import { eligibleIssueAuthor } from './request-triage.mjs';
|
|
14
|
+
import { projectImplementationPrState, reconcileImplementationPr, setPullRequestDraft } from './pr-state.mjs';
|
|
13
15
|
|
|
14
16
|
export const repositoryApiUrl = (repository, path = '') => `https://api.github.com/repos/${repository}${path ? `/${path}` : ''}`;
|
|
15
17
|
|
|
@@ -18,7 +20,7 @@ export function pendingReply(event, comments) {
|
|
|
18
20
|
if (!report || /<!-- lighthouse-proposal-review:|\[Review the implementation/.test(report.body) || !/^Outcome: blocked$/m.test(report.body) || !event.comment?.body?.trim() || event.comment.created_at <= report.created_at) return null;
|
|
19
21
|
const declared = report.body.match(/<!-- lighthouse-stage:([a-z]+):blocked -->/);
|
|
20
22
|
const stage = declared?.[1] ?? (/^## Proposal: blocked candidate$/m.test(report.body) ? 'propose' : /^## Refinement:/m.test(report.body) ? 'refine' : null);
|
|
21
|
-
return ['refine', 'propose', 'implement'].includes(stage) ? { stage, report } : null;
|
|
23
|
+
return ['triage', 'refine', 'propose', 'implement'].includes(stage) ? { stage, report } : null;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function routeIssue(event, actor, owner, eventName, comments = []) {
|
|
@@ -27,13 +29,13 @@ export function routeIssue(event, actor, owner, eventName, comments = []) {
|
|
|
27
29
|
if (labels.includes('future') && (event.label?.name === 'implement' || event.inputs?.stage === 'implement')) return { run: false, reason: 'Implementation paused by future label' };
|
|
28
30
|
if (labels.some(x => ['human-working', 'agent-working'].includes(x))) return { run: false, reason: 'Issue reserved' };
|
|
29
31
|
if (eventName === 'issues' && event.action === 'unlabeled' && event.label?.name === 'future' && labels.includes('implement')) return { run: true, stage: 'implement', issue: event.issue.number, reason: 'Owner resumed implementation' };
|
|
30
|
-
if (eventName === 'issues' && event.action === 'labeled' && ['refine', 'propose', 'implement'].includes(event.label?.name)) return { run: true, stage: event.label.name, issue: event.issue.number, reason: `Owner requested ${event.label.name}` };
|
|
32
|
+
if (eventName === 'issues' && event.action === 'labeled' && ['triage', 'refine', 'propose', 'implement'].includes(event.label?.name)) return { run: true, stage: event.label.name, issue: event.issue.number, reason: `Owner requested ${event.label.name}` };
|
|
31
33
|
if (eventName === 'issue_comment' && event.action === 'created' && event.comment?.user?.login === owner && event.comment.user.type !== 'Bot' && labels.some(x => ['needs-info', 'waiting-review'].includes(x))) {
|
|
32
34
|
const pending = pendingReply(event, comments);
|
|
33
35
|
if (pending) return { run: true, stage: pending.stage, issue: event.issue.number, reason: `Owner answered pending ${pending.stage}` };
|
|
34
36
|
}
|
|
35
37
|
if (eventName === 'workflow_dispatch' && ['integrate', 'review'].includes(event.inputs?.stage) && (!/^[1-9][0-9]*$/.test(event.inputs?.pull_request ?? '') || !/^[a-f0-9]{40}$/.test(event.inputs?.head ?? ''))) return { run: false, reason: 'Completion needs a PR and accepted full head SHA' };
|
|
36
|
-
if (eventName === 'workflow_dispatch' && ['refine', 'propose', 'implement', 'integrate', 'review'].includes(event.inputs?.stage ?? 'refine') && /^[1-9][0-9]*$/.test(event.inputs?.issue ?? '')) return { run: true, stage: event.inputs.stage ?? 'refine', issue: Number(event.inputs.issue), reason: 'Owner requested manual stage' };
|
|
38
|
+
if (eventName === 'workflow_dispatch' && ['triage', 'refine', 'propose', 'implement', 'integrate', 'review'].includes(event.inputs?.stage ?? 'refine') && /^[1-9][0-9]*$/.test(event.inputs?.issue ?? '')) return { run: true, stage: event.inputs.stage ?? 'refine', issue: Number(event.inputs.issue), reason: 'Owner requested manual stage' };
|
|
37
39
|
return { run: false, reason: 'No eligible stage event' };
|
|
38
40
|
}
|
|
39
41
|
export function checkHandoff(request, envelope, issue, answers, revision, runId) {
|
|
@@ -135,7 +137,7 @@ async function main(command) {
|
|
|
135
137
|
if (!route.run || route.issue !== issueNumber || !(env.LIGHTHOUSE_ENABLED_STAGES ?? stages.join(',')).split(',').includes(stage) || env.LIGHTHOUSE_STAGE && env.LIGHTHOUSE_STAGE !== stage) throw Error('Stage dispatch is not authorized');
|
|
136
138
|
const issue = await api(issuePath), answers = ownerAnswers(await comments());
|
|
137
139
|
const metadata = await api(''), revision = (await api(`commits/${metadata.default_branch}`)).sha;
|
|
138
|
-
if (issue
|
|
140
|
+
if (!eligibleIssueAuthor(issue, owner, stage) || issue.state !== 'open' && stage !== 'review' || issue.pull_request || issue.labels.some(x => x.name === 'human-working' || x.name === 'agent-working' && !queue)) throw Error('Issue not eligible for dispatch');
|
|
139
141
|
if (handoff) {
|
|
140
142
|
const prior = await readActionsJson(api, repo, route.predecessor.runId, `lighthouse-input-${issueNumber}-${route.predecessor.attempt}`, 'request.json');
|
|
141
143
|
const result = await readActionsJson(api, repo, route.predecessor.runId, `lighthouse-result-${issueNumber}-${route.predecessor.attempt}`, 'envelope.json');
|
|
@@ -173,7 +175,7 @@ async function main(command) {
|
|
|
173
175
|
if (handoff && !after) route = { run: env.GITHUB_ACTOR === owner && !reviewTarget(event, env.GITHUB_EVENT_NAME) && ['issues', 'issue_comment', 'workflow_dispatch'].includes(env.GITHUB_EVENT_NAME), stage: 'implement', issue: issueNumber };
|
|
174
176
|
if (!route.run || issueNumber !== route.issue || env.GITHUB_TRIGGERING_ACTOR !== owner) throw Error('Stage not authorized');
|
|
175
177
|
const issue = await api(issuePath);
|
|
176
|
-
if (issue
|
|
178
|
+
if (!eligibleIssueAuthor(issue, owner, route.stage) || issue.state !== 'open' || issue.pull_request || issue.labels.some(x => ['agent-working', 'human-working'].includes(x.name))) throw Error('Issue not eligible');
|
|
177
179
|
const repository = await api('');
|
|
178
180
|
const baseRevision = (await api(`commits/${repository.default_branch}`)).sha;
|
|
179
181
|
const revision = { sha: implementationReview ? implementationReview.head : baseRevision };
|
|
@@ -204,7 +206,16 @@ async function main(command) {
|
|
|
204
206
|
}
|
|
205
207
|
const stage = route.stage;
|
|
206
208
|
await output('artifact_prefix', stage === 'implement' ? 'implement-' : '');
|
|
207
|
-
let predecessor, implementation, seed, planningReference;
|
|
209
|
+
let predecessor, implementation, seed, planningReference, triage;
|
|
210
|
+
if (stage === 'refine' || issue.user.login !== owner && !proposalReview && !implementationReview && ['propose', 'implement'].includes(stage)) {
|
|
211
|
+
const report = [...all].reverse().find(c => c.user?.login === 'github-actions[bot]' && c.user.type === 'Bot' && c.body.includes('<!-- lighthouse-stage:triage:passed -->'));
|
|
212
|
+
if (!report && issue.user.login !== owner) throw Error('External request requires authenticated passed triage before later work');
|
|
213
|
+
if (report) {
|
|
214
|
+
triage = await loadStageReport(api, repo, report, { owner, issue: issueNumber });
|
|
215
|
+
const errors = validateStageEnvelope(triage.envelope, triage.request);
|
|
216
|
+
if (errors.length || triage.request.stage !== 'triage' || triage.envelope.result.status !== 'passed' || triage.request.sourceRevision !== revision.sha || triage.request.originalRequest !== (issue.body ?? '')) throw Error('Triage evidence is invalid or stale; request triage again');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
208
219
|
if (stage === 'implement') {
|
|
209
220
|
if (handoff) {
|
|
210
221
|
const prior = JSON.parse(await readFile(join(env.RUNNER_TEMP, 'refinement-handoff', 'request.json'), 'utf8'));
|
|
@@ -213,6 +224,7 @@ async function main(command) {
|
|
|
213
224
|
checkHandoff(prior, result, issue, answers, revision.sha, dispatch?.request.predecessor?.runId ?? dispatch?.request.routerRunId ?? env.GITHUB_RUN_ID);
|
|
214
225
|
}
|
|
215
226
|
if (issue.labels.some(x => x.name === 'future')) throw Error('Implementation paused by future label');
|
|
227
|
+
if (issue.user.login !== owner && !handoff && !implementationReview) await loadRefinement(api, repo, all, { owner, issue: issueNumber, sourceRevision: revision.sha, taskDigest: taskDigest(issue, answers) });
|
|
216
228
|
implementation = await loadImplementation(api, issueNumber, owner, baseRevision, repository.default_branch);
|
|
217
229
|
seed = implementationReview ? null : await loadImplementationSeed(api, implementation.policy, revision.sha);
|
|
218
230
|
const pulls = await api('pulls?state=open&per_page=100');
|
|
@@ -237,12 +249,16 @@ async function main(command) {
|
|
|
237
249
|
const id = digest(`${repo}:${issueNumber}:${stage}:${env.GITHUB_RUN_ID}:${env.GITHUB_RUN_ATTEMPT}`);
|
|
238
250
|
await mkdir(dir, { recursive: true });
|
|
239
251
|
const snapshot = { autoImplement: env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true', ...(dispatch ? { workflowRevision: env.GITHUB_SHA, dispatch: { commentId: dispatch.commentId, requestId: dispatch.request.id, digest: dispatch.receipt.digest } } : {}), ...(planningReference ? { planningReference } : {}), ...(implementationReview ? { implementationReview, baseRevision } : {}), ...(proposalReview ? { proposalReview } : {}), runId: id, issue: issueNumber, stage, title: issue.title, defaultBranch: repository.default_branch, sourceRevision: revision.sha, taskDigest: taskDigest(issue, answers), workflowRunId: env.GITHUB_RUN_ID, ...(predecessor ? { refinement: predecessor } : {}), ...(continuation ? { replyTo: continuation.request.runId, commentId: event.comment.id } : {}), ...(implementation ? { implementation } : {}), ...(seed ? { seedDigest: changesDigest(seed) } : {}) };
|
|
252
|
+
if (stage === 'triage') snapshot.originalRequest = issue.body ?? '';
|
|
253
|
+
if (triage) snapshot.triageEstimate = triage.envelope.result.output.estimate;
|
|
240
254
|
await output('source_revision', revision.sha);
|
|
241
255
|
await writeFile(join(dir, 'request.json'), JSON.stringify(snapshot));
|
|
242
256
|
if (seed) await writeFile(join(dir, 'seed.json'), JSON.stringify(seed));
|
|
243
257
|
await writeFile(join(dir, 'issue.json'), JSON.stringify({ ...issue, ownerAnswers: answers.map(c => ({ body: c.body, url: c.html_url, author: c.user.login })) }));
|
|
244
258
|
const contract = fileURLToPath(new URL('./host-contract.mjs', import.meta.url));
|
|
245
259
|
const context = JSON.parse(execFileSync(process.execPath, [contract, 'context', join(dir, 'request.json'), join(dir, 'issue.json')], { encoding: 'utf8' }));
|
|
260
|
+
if (triage) context.triage = triage.envelope.result.output;
|
|
261
|
+
if (stage === 'triage') context.contract.rules += ' Preserve the exact issue body in output.requestAnalysis.originalRequest. Return output.estimate and output.requestAnalysis following /harness/request-triage.md. Stop at triaged; never authorize or dispatch refinement.';
|
|
246
262
|
if (predecessor || proposalReview) {
|
|
247
263
|
if (predecessor) context.predecessor = predecessor;
|
|
248
264
|
if (proposalReview) {
|
|
@@ -273,7 +289,12 @@ async function main(command) {
|
|
|
273
289
|
await writeFile(join(dir, 'context.json'), JSON.stringify(context));
|
|
274
290
|
await api(`${issuePath}/labels`, 'POST', { labels: ['agent-working'] });
|
|
275
291
|
await writeFile(join(dir, 'reserved'), id);
|
|
276
|
-
for (const label of
|
|
292
|
+
for (const label of ['needs-info', 'waiting-review', ...(stage === 'refine' || stage === 'implement' ? ['ready'] : [])]) if (issue.labels.some(x => x.name === label)) await api(`${issuePath}/labels/${label}`, 'DELETE');
|
|
293
|
+
if (stage === 'implement' && implementationReview) {
|
|
294
|
+
const pr = await api(`pulls/${implementationReview.number}`);
|
|
295
|
+
if (!pr.draft) await setPullRequestDraft(env.GH_TOKEN, pr, true);
|
|
296
|
+
await projectImplementationPrState(api, issueNumber, true);
|
|
297
|
+
}
|
|
277
298
|
await summary(`### ${stage} #${issueNumber}\nSource: \`${revision.sha}\`\n\nOwner request verified; reservation acquired.\n`);
|
|
278
299
|
if (continuation) {
|
|
279
300
|
const marker = `<!-- lighthouse-reply:${event.comment.id} -->`;
|
|
@@ -290,6 +311,13 @@ async function main(command) {
|
|
|
290
311
|
}
|
|
291
312
|
if (command === 'cleanup') { try { await readFile(join(dir, 'reserved')); } catch { return; } }
|
|
292
313
|
const snapshot = JSON.parse(await readFile(join(dir, 'request.json'), 'utf8'));
|
|
314
|
+
if (command === 'continue-refinement') {
|
|
315
|
+
await readFile(join(dir, 'published'));
|
|
316
|
+
const envelope = JSON.parse(await readFile(join(dir, 'envelope.json'), 'utf8'));
|
|
317
|
+
const sent = await signalRefinementContinuation(api, snapshot, envelope);
|
|
318
|
+
await summary(sent ? 'Implementation continuation requested; the router will verify the completed refinement.' : 'No implementation continuation requested.');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
293
321
|
if (snapshot.dispatch) env.LIGHTHOUSE_AUTO_IMPLEMENT = String(snapshot.autoImplement === true);
|
|
294
322
|
if (command === 'validate') {
|
|
295
323
|
const value = JSON.parse(await readFile(join(dir, 'envelope.json'), 'utf8'));
|
|
@@ -315,7 +343,7 @@ async function main(command) {
|
|
|
315
343
|
const marker = `<!-- lighthouse-actions:${snapshot.runId} -->`;
|
|
316
344
|
const existing = (await comments()).find(c => c.user.type === 'Bot' && c.body.includes(marker));
|
|
317
345
|
const runUrl = `https://github.com/${repo}/actions/runs/${env.GITHUB_RUN_ID}`;
|
|
318
|
-
let proposalLink = '';
|
|
346
|
+
let proposalLink = '', prStateProof = '';
|
|
319
347
|
if (snapshot.stage === 'propose' && value.result.status === 'passed') {
|
|
320
348
|
const files = await prepareProposal(issueNumber, value.result.output.files);
|
|
321
349
|
const recheck = async () => {
|
|
@@ -352,24 +380,27 @@ async function main(command) {
|
|
|
352
380
|
}
|
|
353
381
|
if (pulls.length >= 100 || pulls.some(pr => pr.number !== snapshot.implementationReview?.number && pr.head.ref.startsWith(`lighthouse/implement-${issueNumber}-`))) throw Error('Existing implementation PR requires review');
|
|
354
382
|
};
|
|
355
|
-
const
|
|
383
|
+
const candidateComplete = value.result.status === 'passed' && checksPassed;
|
|
356
384
|
const checks = receipt.checks.map(c => `- \`${c.command.join(' ')}\`: exit ${c.exitCode}`).join('\n');
|
|
357
|
-
const pr = await createImplementationPullRequest(api, snapshot, changes,
|
|
385
|
+
const pr = await createImplementationPullRequest(api, snapshot, changes, value.effect.body, runUrl, true, recheck);
|
|
386
|
+
prStateProof = `<!-- lighthouse-pr-state:${JSON.stringify({ pr: pr.number, head: pr.head.sha, complete: candidateComplete, run: Number(env.GITHUB_RUN_ID) })} -->`;
|
|
387
|
+
await writeFile(join(dir, 'published-pr.json'), JSON.stringify({ number: pr.number, head: pr.head.sha }));
|
|
358
388
|
try { await dispatchDeliveryChecks(api, snapshot.baseRevision ?? snapshot.sourceRevision, pr); }
|
|
359
389
|
catch (error) { value.effect.body += '\n\nThe PR was saved, but CI could not start. See the run for details.'; await summary(`CI dispatch failed: ${error.message}`); }
|
|
360
390
|
if (snapshot.implementationReview) await api(`issues/${pr.number}/comments`, 'POST', { body: `Updated the PR with your feedback. [Checks and evidence](${runUrl}).\n\n${snapshot.implementationReview.receipt}` });
|
|
361
|
-
proposalLink = `\n\n[
|
|
391
|
+
proposalLink = `\n\n[Implementation PR #${pr.number}](${pr.html_url}) is a draft. ${candidateComplete ? 'Required CI will decide when it becomes ready for review.' : 'Address the blocker in the run before review.'}`;
|
|
362
392
|
await summary(`### Implementation PR\n[Review #${pr.number}](${pr.html_url})\n\nHost checks:\n${checks}`);
|
|
363
393
|
}
|
|
364
394
|
}
|
|
365
395
|
if (snapshot.proposalReview && value.result.status === 'blocked') await api(`issues/${snapshot.proposalReview.number}/comments`, 'POST', { body: `Proposal revision is blocked. ${value.effect.body}\n\n[Execution](${runUrl}). Implementation has not started.` });
|
|
366
|
-
const continuationHint = snapshot.proposalReview ? `\n\nContinue the review in [PR #${snapshot.proposalReview.number}](${snapshot.proposalReview.url}).\n<!-- lighthouse-proposal-review:${snapshot.proposalReview.number} -->` : value.result.status === 'blocked' && ['refine', 'propose', 'implement'].includes(snapshot.stage) && !proposalLink ? `\n\
|
|
396
|
+
const continuationHint = snapshot.proposalReview ? `\n\nContinue the review in [PR #${snapshot.proposalReview.number}](${snapshot.proposalReview.url}).\n<!-- lighthouse-proposal-review:${snapshot.proposalReview.number} -->` : value.result.status === 'blocked' && ['triage', 'refine', 'propose', 'implement'].includes(snapshot.stage) && !proposalLink ? `\n\nThe owner can reply here to continue ${snapshot.stage}.` : '';
|
|
367
397
|
const next = snapshot.stage === 'refine' && value.result.status === 'passed' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true' ? publicationIssue.labels.some(x => x.name === 'future') ? '\n\n⏸️ Implementation is paused by `future`.' : '\n\nRefinement complete. Implementation is next; use `future` to pause it.' : '';
|
|
368
|
-
const body = `## ${statusText(snapshot.stage, value.result.status === 'blocked' ? 'waiting' : 'completed')}\n\n${snapshot.stage === 'propose' && value.result.status === 'passed' ? 'Proposal ready. Review it in the linked PR; comment there to request changes.' : value.effect.body}${proposalLink}${continuationHint}${next}\n\n[Follow progress](${runUrl})\n\n<details><summary>Execution details</summary>\n\nOutcome: ${value.result.status}\n\n[Workflow run](${runUrl}) · source \`${snapshot.sourceRevision}\`\n\n${marker}\n<!-- lighthouse-stage:${snapshot.stage}:${value.result.status} -->\n</details>`;
|
|
398
|
+
const body = `## ${statusText(snapshot.stage, value.result.status === 'blocked' ? 'waiting' : 'completed')}\n\n${snapshot.stage === 'propose' && value.result.status === 'passed' ? 'Proposal ready. Review it in the linked PR; comment there to request changes.' : value.effect.body}${proposalLink}${continuationHint}${next}\n\n[Follow progress](${runUrl})\n\n<details><summary>Execution details</summary>\n\nOutcome: ${value.result.status}\n\n[Workflow run](${runUrl}) · source \`${snapshot.sourceRevision}\`\n\n${marker}\n${prStateProof}\n<!-- lighthouse-stage:${snapshot.stage}:${value.result.status} -->\n</details>`;
|
|
369
399
|
const report = existing ?? await api(`${issuePath}/comments`, 'POST', { body });
|
|
370
400
|
const handoff = snapshot.stage === 'refine' && value.result.status === 'passed' && env.LIGHTHOUSE_AUTO_IMPLEMENT === 'true';
|
|
371
|
-
const label = ['propose', 'implement'].includes(snapshot.stage) ? 'waiting-review' : value.result.status === 'passed' ? 'ready' : 'needs-info';
|
|
372
|
-
|
|
401
|
+
const label = snapshot.stage === 'implement' && prStateProof ? 'pr-draft' : ['propose', 'implement'].includes(snapshot.stage) ? 'waiting-review' : value.result.status === 'passed' ? snapshot.stage === 'triage' ? 'triaged' : 'ready' : 'needs-info';
|
|
402
|
+
if (label === 'pr-draft') await projectImplementationPrState(api, issueNumber, true);
|
|
403
|
+
else await api(`${issuePath}/labels`, 'POST', { labels: [label] });
|
|
373
404
|
const fresh = await api(issuePath);
|
|
374
405
|
if (fresh.labels.some(x => x.name === snapshot.stage)) await api(`${issuePath}/labels/${snapshot.stage}`, 'DELETE');
|
|
375
406
|
if (handoff) {
|
|
@@ -377,19 +408,32 @@ async function main(command) {
|
|
|
377
408
|
await output('implement', String(!fresh.labels.some(x => x.name === 'future')));
|
|
378
409
|
}
|
|
379
410
|
await writeFile(join(dir, 'published'), report.html_url);
|
|
380
|
-
await summary(`### Published\n[Issue #${issueNumber}](${report.html_url}) · **${label}**\n\n${['propose', 'implement'].includes(snapshot.stage) ? 'Review the PR or blocker report; no next stage is authorized.' : label === 'needs-info' ?
|
|
411
|
+
await summary(`### Published\n[Issue #${issueNumber}](${report.html_url}) · **${label}**\n\n${['propose', 'implement'].includes(snapshot.stage) ? 'Review the PR or blocker report; no next stage is authorized.' : label === 'needs-info' ? `The owner can answer the question to resume ${snapshot.stage}.` : snapshot.stage === 'triage' ? 'Triage complete. The owner decides whether to request refinement; no next stage has started.' : handoff ? 'Refinement complete. Implementation follows automatically unless paused with future.' : 'Refinement complete. Implementation is available when the repository enables the implement worker.'}`);
|
|
381
412
|
console.log(`Published ${label}: ${report.html_url}`); return;
|
|
382
413
|
}
|
|
383
414
|
if (command === 'cleanup') {
|
|
384
415
|
try { await readFile(join(dir, 'reserved')); } catch { return; }
|
|
385
416
|
const issue = await api(issuePath);
|
|
386
417
|
if (issue.labels.some(x => x.name === 'agent-working')) await api(`${issuePath}/labels/agent-working`, 'DELETE');
|
|
387
|
-
|
|
418
|
+
let published = false;
|
|
419
|
+
try { await readFile(join(dir, 'published')); published = true; }
|
|
388
420
|
catch {
|
|
389
421
|
if (snapshot.proposalReview) await api(`issues/${snapshot.proposalReview.number}/comments`, 'POST', { body: `Proposal update did not complete. Your feedback is retained. [See execution](https://github.com/${repo}/actions/runs/${env.GITHUB_RUN_ID}).` });
|
|
390
422
|
if (snapshot.replyTo) await api(`${issuePath}/labels`, 'POST', { labels: [snapshot.stage === 'refine' ? 'needs-info' : 'waiting-review'] });
|
|
391
423
|
await summary('### Execution incomplete\nNo completed publication. Inspect the failed step; the pending decision remains unfulfilled.');
|
|
392
424
|
}
|
|
425
|
+
if (published && snapshot.stage === 'implement') {
|
|
426
|
+
try {
|
|
427
|
+
const saved = JSON.parse(await readFile(join(dir, 'published-pr.json'), 'utf8'));
|
|
428
|
+
const pr = await api(`pulls/${saved.number}`);
|
|
429
|
+
if (pr.state === 'open' && pr.head.sha === saved.head) {
|
|
430
|
+
const profileFile = await api(`contents/.lighthouse/github.json?ref=${snapshot.defaultBranch}`);
|
|
431
|
+
const profile = JSON.parse(Buffer.from(profileFile.content, 'base64').toString());
|
|
432
|
+
const required = profile.requiredChecks.map(check => ({ context: check.context, app: check.integrationId }));
|
|
433
|
+
await reconcileImplementationPr(api, repo, pr, await api(issuePath), await comments(), required, (current, draft) => setPullRequestDraft(env.GH_TOKEN, current, draft), { auto: true });
|
|
434
|
+
}
|
|
435
|
+
} catch (error) { await summary(`PR state will be retried after CI: ${error.message}.`); }
|
|
436
|
+
}
|
|
393
437
|
console.log('Execution reservation released'); return;
|
|
394
438
|
}
|
|
395
439
|
throw Error('Unknown issue workflow command');
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Selection is configuration, never authority or proof of provider access.
|
|
2
|
+
export const MODEL_PROFILES = Object.freeze(['plan', 'build', 'fast']);
|
|
3
|
+
export const REASONING_EFFORTS = Object.freeze(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
4
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
const own = (value, key) => Object.hasOwn(value ?? {}, key);
|
|
6
|
+
function keys(value, allowed, label) {
|
|
7
|
+
if (!record(value) || Object.keys(value).some(key => !allowed.includes(key))) throw Error(`Invalid ${label}`);
|
|
8
|
+
}
|
|
9
|
+
function validateSettings(value, operator = false) {
|
|
10
|
+
keys(value, ['schemaVersion', 'profiles', 'workers', 'defaultProfile', ...(operator ? ['enabled'] : [])], 'model profile configuration');
|
|
11
|
+
if (value.schemaVersion !== 1) throw Error('Model profiles require schemaVersion 1');
|
|
12
|
+
if (operator && typeof value.enabled !== 'boolean') throw Error('Operator model profiles require explicit enabled');
|
|
13
|
+
if (own(value, 'defaultProfile') && !MODEL_PROFILES.includes(value.defaultProfile)) throw Error('Invalid default model profile');
|
|
14
|
+
if (own(value, 'profiles')) {
|
|
15
|
+
keys(value.profiles, MODEL_PROFILES, 'profile names');
|
|
16
|
+
for (const profile of Object.values(value.profiles)) {
|
|
17
|
+
keys(profile, ['model', 'reasoningEffort'], 'model profile settings');
|
|
18
|
+
if (own(profile, 'model') && !/^openai\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(profile.model)) throw Error('Profile model must be an OpenAI provider/model identifier without a variant');
|
|
19
|
+
if (own(profile, 'reasoningEffort') && !REASONING_EFFORTS.includes(profile.reasoningEffort)) throw Error('Invalid reasoning effort');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (own(value, 'workers')) {
|
|
23
|
+
if (!record(value.workers) || Object.entries(value.workers).some(([worker, profile]) => !/^[a-z][a-z0-9-]{0,63}$/.test(worker) || !MODEL_PROFILES.includes(profile))) throw Error('Invalid worker profile assignments');
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveModelProfile({ project = null, operator, worker, profile } = {}) {
|
|
29
|
+
if (!record(operator)) throw Error('Operator model configuration required');
|
|
30
|
+
if (project !== null) validateSettings(project);
|
|
31
|
+
const local = operator.modelProfiles;
|
|
32
|
+
if (local !== undefined) validateSettings(local, true);
|
|
33
|
+
if (!local?.enabled) {
|
|
34
|
+
if (profile !== undefined) throw Error('Delegated profiles require operator opt-in');
|
|
35
|
+
if (typeof operator.model !== 'string' || !operator.model.trim() || /[\0\r\n]/.test(operator.model)) throw Error('Legacy operator model required');
|
|
36
|
+
return { mode: 'legacy', profile: null, model: operator.model, reasoningEffort: null, engineModel: operator.model, source: 'operator.model', capabilityEvidence: 'not-checked-legacy' };
|
|
37
|
+
}
|
|
38
|
+
if (profile !== undefined && !MODEL_PROFILES.includes(profile)) throw Error('Invalid requested model profile');
|
|
39
|
+
const choices = [[profile, 'task.profile'], [own(local.workers, worker) ? local.workers[worker] : undefined, 'operator.workers'], [own(project?.workers, worker) ? project.workers[worker] : undefined, 'project.workers'], [local.defaultProfile, 'operator.defaultProfile'], [project?.defaultProfile, 'project.defaultProfile']];
|
|
40
|
+
const choice = choices.find(([name]) => name !== undefined);
|
|
41
|
+
if (!choice) throw Error('No model profile assigned to worker');
|
|
42
|
+
const [name, source] = choice;
|
|
43
|
+
const base = project?.profiles?.[name] ?? {}, override = local.profiles?.[name] ?? {};
|
|
44
|
+
const selected = { ...base, ...override };
|
|
45
|
+
if (!selected.model || !selected.reasoningEffort) throw Error(`Incomplete model profile: ${name}`);
|
|
46
|
+
const capability = operator.modelCapabilities?.[selected.model];
|
|
47
|
+
if (!record(capability) || capability.available !== true || capability.tools !== true || !Array.isArray(capability.reasoningEfforts) || !capability.reasoningEfforts.includes(selected.reasoningEffort)) throw Error(`Model or reasoning unavailable in operator capabilities: ${name}`);
|
|
48
|
+
return { mode: 'profile', profile: name, ...selected, engineModel: `${selected.model}#${selected.reasoningEffort}`, source, fields: { model: own(override, 'model') ? 'operator' : 'project', reasoningEffort: own(override, 'reasoningEffort') ? 'operator' : 'project' }, capabilityEvidence: 'operator-configuration-only' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function modelExecutionReceipt(selection) {
|
|
52
|
+
return {
|
|
53
|
+
requested: { profile: selection.profile, model: selection.model, reasoningEffort: selection.reasoningEffort },
|
|
54
|
+
resolved: selection,
|
|
55
|
+
providerReported: { model: null, reasoningEffort: null, status: 'unverified', reason: 'The current adapter has no authenticated provider-settings receipt' },
|
|
56
|
+
billing: { route: 'unchanged', actualCost: null, estimatedApiEquivalentCost: null, subscriptionUsage: null },
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/opencode-execute.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { prepareImplementationWorkspace, copyEvidence, runImplementationChecks }
|
|
|
9
9
|
import { validateStageEnvelope } from './host-contract.mjs';
|
|
10
10
|
import { formatOpenCodeEvent, progressText, boundedEventLines } from './opencode-progress.mjs';
|
|
11
11
|
import { selectEnvironment, startEnvironment, environmentArgs, environmentReceipt, redactEnvironmentLog } from './execution-environment.mjs';
|
|
12
|
+
import { resolveModelProfile, modelExecutionReceipt } from './model-profiles.mjs';
|
|
12
13
|
|
|
13
14
|
export function completionOutput(envelope, request) {
|
|
14
15
|
const errors = validateStageEnvelope(envelope, request);
|
|
@@ -52,6 +53,16 @@ async function main() {
|
|
|
52
53
|
const request = JSON.parse(await readFile(join(runDir, 'request.json'), 'utf8'));
|
|
53
54
|
// Sequential workers share the runner temp directory; only this stage's outputs may survive.
|
|
54
55
|
for (const file of ['envelope.json', 'runtime.json', 'changes.json', 'checks.json']) await rm(join(runDir, file), { force: true });
|
|
56
|
+
let projectModels = null;
|
|
57
|
+
try {
|
|
58
|
+
const directory = join(env.GITHUB_WORKSPACE, '.lighthouse'), directoryStat = await lstat(directory);
|
|
59
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw Error('Invalid project model configuration directory');
|
|
60
|
+
const path = join(directory, 'models.json'), stat = await lstat(path);
|
|
61
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65536) throw Error('Invalid project model configuration file');
|
|
62
|
+
projectModels = JSON.parse(await readFile(path, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
65
|
+
const modelSelection = resolveModelProfile({ project: projectModels, operator: config, worker: env.LIGHTHOUSE_WORKER ?? request.stage });
|
|
55
66
|
const selected = request.stage === 'implement' ? selectEnvironment(config, request.implementation?.environment) : null;
|
|
56
67
|
if (request.stage === 'implement') {
|
|
57
68
|
if (selected) config.implementationImage = selected.image;
|
|
@@ -91,24 +102,25 @@ async function main() {
|
|
|
91
102
|
const context = join(dir, 'context.json');
|
|
92
103
|
await copyFile(join(runDir, 'context.json'), context); await chmod(context, 0o444);
|
|
93
104
|
const prompt = join(dir, 'prompt.txt');
|
|
94
|
-
const completionBoundary = '\n\n# Completion in this custom engine adapter\nThe trusted host, outside this container, records the gh-aw terminal noop after validating /output/envelope.json. The safeoutputs CLI and /tmp/gh-aw/agent instructions describe host duties in this integration. Their absence inside the engine is expected, not an unavailable product check. Write the envelope and stop; do not install or invoke safeoutputs.\n';
|
|
105
|
+
const completionBoundary = '\n\n# Completion in this custom engine adapter\nThe trusted host, outside this container, records the gh-aw terminal noop after validating /output/envelope.json. The safeoutputs CLI and /tmp/gh-aw/agent instructions describe host duties in this integration. Their absence inside the engine is expected, not an unavailable product check. Read /harness/skills/concise-results/SKILL.md when writing effect.body. Write the envelope and stop; do not install or invoke safeoutputs.\n';
|
|
95
106
|
await writeFile(prompt, await readFile(env.GH_AW_PROMPT, 'utf8') + completionBoundary, { mode: 0o444 });
|
|
96
107
|
const harness = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
97
108
|
const pkg = JSON.parse(await readFile(join(harness, 'package.json'), 'utf8'));
|
|
98
|
-
await writeFile(join(runDir, 'runtime.json'), JSON.stringify({ package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, environment: environmentReceipt(selected), model:
|
|
109
|
+
await writeFile(join(runDir, 'runtime.json'), JSON.stringify({ package: { name: pkg.name, version: pkg.version }, harnessRevision: execFileSync('git', ['-C', harness, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), image: config.image, environment: environmentReceipt(selected), model: modelSelection.model, modelExecution: modelExecutionReceipt(modelSelection), engine: 'OpenCode 2.1.0', isolation: 'Docker; no AWF egress filtering', skills: await readdir(join(harness, 'skills')), sourceRevision: request.sourceRevision, seed: request.implementation?.policy.seed ?? null, seedDigest: request.seedDigest ?? null }));
|
|
99
110
|
const args = ['run', '--rm', '--init', '--name', name, '--user', `${config.uid}:${config.uid}`, '--cpus', '2', '--memory', implementing ? '4g' : '2g', '--pids-limit', implementing ? '256' : '128', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--read-only',
|
|
100
111
|
'--tmpfs', '/tmp:rw,exec,size=1g,mode=1777', '--tmpfs', `/home/engine/.cache:rw,size=1g,uid=${config.uid},gid=${config.uid}`, '--tmpfs', `/home/engine/.config:rw,size=16m,uid=${config.uid},gid=${config.uid}`, '--tmpfs', `/home/engine/.local/state:rw,size=64m,uid=${config.uid},gid=${config.uid}`,
|
|
101
112
|
'--mount', `type=bind,src=${source},dst=/workspace${implementing ? '' : ',readonly'}`, '--mount', `type=bind,src=${output},dst=/output`, '--mount', `type=bind,src=${context},dst=/input/context.json,readonly`, '--mount', `type=bind,src=${prompt},dst=/input/prompt.txt,readonly`, '--mount', `type=bind,src=${harness},dst=/harness,readonly`,
|
|
102
|
-
'--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, '--env', `ENGINE_SESSION=ses_${request.runId}`, '--env', `ENGINE_MODEL=${
|
|
113
|
+
'--mount', `type=volume,src=${config.authVolume},dst=/home/engine/.local/share/opencode`, '--env', `ENGINE_SESSION=ses_${request.runId}`, '--env', `ENGINE_MODEL=${modelSelection.engineModel}`, '--env', `ENGINE_STAGE=${request.stage}`, '--env', 'NPM_CONFIG_CACHE=/tmp/npm-cache', '--env', 'PLAYWRIGHT_BROWSERS_PATH=/workspace/.cache/ms-playwright', ...environmentArgs(runtime), config.image];
|
|
103
114
|
const eventsPath = join(env.RUNNER_TEMP, 'gh-aw', `opencode-events-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}.jsonl`);
|
|
104
115
|
await mkdir(dirname(eventsPath), { recursive: true });
|
|
105
116
|
await writeFile(eventsPath, '', { mode: 0o600 });
|
|
106
117
|
const started = Date.now();
|
|
107
|
-
let lastVisible = started;
|
|
118
|
+
let lastVisible = started, engineError = false;
|
|
108
119
|
const redact = text => redactLog(redactEnvironmentLog(runtime, text));
|
|
109
120
|
const report = text => { lastVisible = Date.now(); console.log(redact(text)); };
|
|
110
121
|
const eventLine = line => {
|
|
111
122
|
const redacted = redact(line);
|
|
123
|
+
try { if (JSON.parse(redacted).type === 'error') engineError = true; } catch {}
|
|
112
124
|
appendFileSync(eventsPath, redacted + '\n');
|
|
113
125
|
const visible = formatOpenCodeEvent(redacted);
|
|
114
126
|
if (visible) report(visible);
|
|
@@ -137,6 +149,7 @@ async function main() {
|
|
|
137
149
|
child.on('error', no); child.on('close', code => code === 0 && !cancelled ? ok() : no(Error('OpenCode failed or was cancelled')));
|
|
138
150
|
timer = setTimeout(cancel, implementing ? 1800000 : 600000);
|
|
139
151
|
});
|
|
152
|
+
if (engineError) throw Error('OpenCode reported an execution error; no model fallback attempted');
|
|
140
153
|
const result = join(output, 'envelope.json'), stat = await lstat(result);
|
|
141
154
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128000) throw Error('Invalid engine result file');
|
|
142
155
|
const envelope = JSON.parse(await readFile(result, 'utf8'));
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { checkImplementationPullRequest } from './proposal-review.mjs';
|
|
3
|
+
import { authorizedCiCallback, projectImplementationPrState, reconcileImplementationPr, scheduledImplementationPulls, setPullRequestDraft } from './pr-state.mjs';
|
|
4
|
+
|
|
5
|
+
const apiFor = (repo, token) => async (path, method = 'GET', body, optional = false) => {
|
|
6
|
+
const response = await fetch(`https://api.github.com/repos/${repo}${path ? `/${path}` : ''}`, {
|
|
7
|
+
method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
8
|
+
...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(30000),
|
|
9
|
+
});
|
|
10
|
+
if (optional && response.status === 404) return null;
|
|
11
|
+
if (!response.ok) throw Error(`GitHub ${method} ${path}: ${response.status}`);
|
|
12
|
+
return response.status === 204 ? null : response.json();
|
|
13
|
+
};
|
|
14
|
+
const commentsFor = async (api, issue) => {
|
|
15
|
+
const comments = [];
|
|
16
|
+
for (let page = 1; page <= 10; page++) {
|
|
17
|
+
const batch = await api(`issues/${issue}/comments?per_page=100&page=${page}`);
|
|
18
|
+
comments.push(...batch);
|
|
19
|
+
if (batch.length < 100) return comments;
|
|
20
|
+
}
|
|
21
|
+
throw Error('Issue comment history exceeds bound');
|
|
22
|
+
};
|
|
23
|
+
const requiredChecks = profile => {
|
|
24
|
+
const checks = profile.requiredChecks;
|
|
25
|
+
if (!Array.isArray(checks) || !checks.length || checks.length > 20 || checks.some(item => !item.context || item.integrationId !== null && !Number.isSafeInteger(item.integrationId))) throw Error('Invalid required-check profile');
|
|
26
|
+
return checks.map(item => ({ context: item.context, app: item.integrationId }));
|
|
27
|
+
};
|
|
28
|
+
async function main() {
|
|
29
|
+
const env = process.env, repo = env.GITHUB_REPOSITORY, owner = env.GITHUB_REPOSITORY_OWNER;
|
|
30
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo ?? '') || !env.GH_TOKEN) throw Error('Missing repository or token');
|
|
31
|
+
const api = apiFor(repo, env.GH_TOKEN);
|
|
32
|
+
const event = JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8'));
|
|
33
|
+
const metadata = await api('');
|
|
34
|
+
const config = await api(`contents/.lighthouse/github.json?ref=${metadata.default_branch}`, 'GET', undefined, true);
|
|
35
|
+
if (!config) return;
|
|
36
|
+
const profile = JSON.parse(Buffer.from(config.content, 'base64').toString());
|
|
37
|
+
if (profile.repository !== repo) throw Error('Repository profile differs from the event');
|
|
38
|
+
let candidates = [];
|
|
39
|
+
const ciCallback = env.GITHUB_EVENT_NAME === 'workflow_dispatch' && env.LIGHTHOUSE_CI_RECONCILE === 'true';
|
|
40
|
+
if (env.GITHUB_EVENT_NAME === 'pull_request') {
|
|
41
|
+
if (env.GITHUB_ACTOR !== owner || !['ready_for_review', 'converted_to_draft', 'closed', 'reopened'].includes(event.action)) return;
|
|
42
|
+
candidates = [event.pull_request?.number];
|
|
43
|
+
} else if (env.GITHUB_EVENT_NAME === 'workflow_run') {
|
|
44
|
+
const id = event.workflow_run?.id;
|
|
45
|
+
if (event.action !== 'completed' || !Number.isSafeInteger(id)) return;
|
|
46
|
+
const run = await api(`actions/runs/${id}`);
|
|
47
|
+
if (run.status !== 'completed' || run.conclusion !== 'success' || run.head_sha !== event.workflow_run.head_sha || run.repository?.full_name !== repo || !profile.completion?.postMergeWorkflows?.some(name => run.path === `.github/workflows/${name}`)) return;
|
|
48
|
+
const pulls = await api(`commits/${run.head_sha}/pulls?per_page=100`);
|
|
49
|
+
if (pulls.length >= 100) throw Error('PR lookup exceeds bound');
|
|
50
|
+
candidates = pulls.filter(pr => pr.head?.sha === run.head_sha).map(pr => pr.number);
|
|
51
|
+
} else if (env.GITHUB_EVENT_NAME === 'schedule') {
|
|
52
|
+
candidates = await scheduledImplementationPulls(api);
|
|
53
|
+
} else if (ciCallback) {
|
|
54
|
+
const id = Number(env.GITHUB_RUN_ID);
|
|
55
|
+
if (!Number.isSafeInteger(id) || !/^[a-f0-9]{40}$/.test(env.GITHUB_SHA ?? '')) throw Error('CI callback lacks a run or head');
|
|
56
|
+
const run = await api(`actions/runs/${id}`);
|
|
57
|
+
if (!authorizedCiCallback(run, { id, sha: env.GITHUB_SHA, repo, workflows: profile.completion?.postMergeWorkflows })) return;
|
|
58
|
+
const pulls = await api(`commits/${run.head_sha}/pulls?per_page=100`);
|
|
59
|
+
if (pulls.length >= 100) throw Error('PR lookup exceeds bound');
|
|
60
|
+
candidates = pulls.filter(pr => pr.head?.sha === run.head_sha).map(pr => pr.number);
|
|
61
|
+
} else return;
|
|
62
|
+
for (const number of candidates) {
|
|
63
|
+
if (!Number.isSafeInteger(number)) continue;
|
|
64
|
+
const pr = await api(`pulls/${number}`);
|
|
65
|
+
if (env.GITHUB_EVENT_NAME === 'pull_request' && pr.head.sha !== event.pull_request.head.sha) continue;
|
|
66
|
+
if (env.GITHUB_EVENT_NAME === 'workflow_run' && pr.head.sha !== event.workflow_run.head_sha) continue;
|
|
67
|
+
if (ciCallback && pr.head.sha !== env.GITHUB_SHA) continue;
|
|
68
|
+
if (pr.base.ref !== metadata.default_branch) continue;
|
|
69
|
+
let target;
|
|
70
|
+
try { target = checkImplementationPullRequest({ ...pr, state: 'open', merged: false }, repo); }
|
|
71
|
+
catch {
|
|
72
|
+
const match = pr.head.ref.match(/^lighthouse\/implement-([1-9][0-9]*)-[a-f0-9]{12}$/);
|
|
73
|
+
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
|
+
const issue = await api(`issues/${match[1]}`);
|
|
75
|
+
if (issue.state !== 'open') continue;
|
|
76
|
+
if (!pr.draft) await setPullRequestDraft(env.GH_TOKEN, pr, true);
|
|
77
|
+
await projectImplementationPrState(api, issue.number, true);
|
|
78
|
+
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. Restore the PR description before review. [Open PR](${pr.html_url}).\n\n${notice}` });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const issue = await api(`issues/${target.issue}`);
|
|
83
|
+
if (issue.state !== 'open') continue;
|
|
84
|
+
if (pr.state !== 'open' || pr.merged) {
|
|
85
|
+
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
|
+
if (!pr.merged) {
|
|
87
|
+
await api(`issues/${issue.number}/labels`, 'POST', { labels: ['waiting-review'] });
|
|
88
|
+
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. Reopen it or request a new implementation. [Open PR](${pr.html_url}).\n\n${notice}` });
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const comments = await commentsFor(api, target.issue);
|
|
94
|
+
if (event.action === 'converted_to_draft') {
|
|
95
|
+
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
|
+
await projectImplementationPrState(api, target.issue, true);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const hold = comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(`<!-- lighthouse-pr-hold:${pr.number}:${pr.head.sha} -->`));
|
|
101
|
+
const auto = (['workflow_run', 'schedule'].includes(env.GITHUB_EVENT_NAME) || ciCallback) && !hold;
|
|
102
|
+
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}).` });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
main().catch(error => { console.error(`::error::${error.message}`); process.exitCode = 1; });
|
package/src/pr-state.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { checkImplementationPullRequest } from './proposal-review.mjs';
|
|
2
|
+
import { checkCommit } from './completion.mjs';
|
|
3
|
+
|
|
4
|
+
const marker = /<!-- lighthouse-pr-state:(\{[^\n]*\}) -->/;
|
|
5
|
+
|
|
6
|
+
export async function scheduledImplementationPulls(api) {
|
|
7
|
+
const numbers = [];
|
|
8
|
+
for (let page = 1; page <= 10; page++) {
|
|
9
|
+
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));
|
|
11
|
+
if (pulls.length < 100) return numbers;
|
|
12
|
+
}
|
|
13
|
+
throw Error('Open PR history exceeds reconciliation bound');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function authorizedCiCallback(run, { id, sha, repo, workflows }) {
|
|
17
|
+
return Number.isSafeInteger(id) && /^[a-f0-9]{40}$/.test(sha ?? '') && run.id === id && run.event === 'workflow_dispatch' && run.head_sha === sha && run.repository?.full_name === repo && workflows?.some(name => run.path === `.github/workflows/${name}`) === true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function setPullRequestDraft(token, pr, draft) {
|
|
21
|
+
if (!pr.node_id) throw Error('PR lacks GraphQL identity');
|
|
22
|
+
const field = draft ? 'convertPullRequestToDraft' : 'markPullRequestReadyForReview';
|
|
23
|
+
const response = await fetch('https://api.github.com/graphql', {
|
|
24
|
+
method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ query: `mutation($id:ID!){${field}(input:{pullRequestId:$id}){pullRequest{id isDraft}}}`, variables: { id: pr.node_id } }),
|
|
26
|
+
signal: AbortSignal.timeout(30000),
|
|
27
|
+
});
|
|
28
|
+
const result = await response.json();
|
|
29
|
+
if (!response.ok || result.errors?.length || result.data?.[field]?.pullRequest?.isDraft !== draft) throw Error('GitHub could not change PR draft state');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function implementationPrProof(comments, pr) {
|
|
33
|
+
for (const comment of [...comments].reverse()) {
|
|
34
|
+
if (comment.user?.login !== 'github-actions[bot]' || comment.user.type !== 'Bot') continue;
|
|
35
|
+
const raw = comment.body?.match(marker)?.[1];
|
|
36
|
+
if (!raw) continue;
|
|
37
|
+
let proof;
|
|
38
|
+
try { proof = JSON.parse(raw); } catch { continue; }
|
|
39
|
+
if (proof.pr === pr.number && proof.head === pr.head.sha && typeof proof.complete === 'boolean' && /^[1-9][0-9]*$/.test(String(proof.run))) return proof;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function projectImplementationPrState(api, issue, draft) {
|
|
45
|
+
const current = await api(`issues/${issue}`);
|
|
46
|
+
if (current.state !== 'open') return;
|
|
47
|
+
const wanted = draft ? 'pr-draft' : 'pr-ready';
|
|
48
|
+
for (const label of ['ready', 'waiting-review', draft ? 'pr-ready' : 'pr-draft']) {
|
|
49
|
+
if (current.labels.some(item => item.name === label)) await api(`issues/${issue}/labels/${label}`, 'DELETE', undefined, true);
|
|
50
|
+
}
|
|
51
|
+
if (!current.labels.some(item => item.name === wanted)) await api(`issues/${issue}/labels`, 'POST', { labels: [wanted] });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function reconcileImplementationPr(api, repo, pr, issue, comments, requiredChecks, setDraft, { auto = false } = {}) {
|
|
55
|
+
const target = checkImplementationPullRequest(pr, repo);
|
|
56
|
+
if (target.issue !== issue.number || issue.state !== 'open') return { status: 'ignored', reason: 'Issue or PR changed' };
|
|
57
|
+
const proof = implementationPrProof(comments, pr);
|
|
58
|
+
const checks = await checkCommit(api, pr.head.sha, requiredChecks);
|
|
59
|
+
const reserved = issue.labels.some(label => ['agent-working', 'human-working'].includes(label.name));
|
|
60
|
+
const complete = !reserved && proof?.complete === true && checks.length > 0 && checks.every(check => check.status === 'passed');
|
|
61
|
+
if (pr.draft && auto && complete) {
|
|
62
|
+
try { await setDraft(pr, false); }
|
|
63
|
+
catch (error) { if ((await api(`pulls/${pr.number}`)).draft) throw error; }
|
|
64
|
+
await projectImplementationPrState(api, issue.number, false);
|
|
65
|
+
return { status: 'ready', reason: 'Candidate and required checks passed' };
|
|
66
|
+
}
|
|
67
|
+
if (!pr.draft && !complete) {
|
|
68
|
+
try { await setDraft(pr, true); }
|
|
69
|
+
catch (error) { if (!(await api(`pulls/${pr.number}`)).draft) throw error; }
|
|
70
|
+
await projectImplementationPrState(api, issue.number, true);
|
|
71
|
+
return { status: 'draft', reason: reserved ? 'Implementation or owner work is still active' : !proof?.complete ? 'Implementation is incomplete' : 'Required checks have not passed' };
|
|
72
|
+
}
|
|
73
|
+
await projectImplementationPrState(api, issue.number, pr.draft);
|
|
74
|
+
return { status: pr.draft ? 'draft' : 'ready', reason: pr.draft ? 'PR is a draft' : 'Candidate and required checks passed' };
|
|
75
|
+
}
|