Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions scripts/github-mutate.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { appendFileSync, readFileSync } from "node:fs";
import { appendFileSync, existsSync, readFileSync } from "node:fs";

import { runGitHubCommandWithRetry } from "./lib/github-retry.mjs";
import { executeMutationDocument } from "./lib/mutation-document-execution.mjs";
Expand Down Expand Up @@ -38,18 +38,43 @@ export function mutationRunner(command, argv, options) {
});
}

function completedKeysFromAudit(auditPath) {
if (!auditPath || !existsSync(auditPath)) return [];
const keys = [];
for (const line of readFileSync(auditPath, "utf8").split(/\r?\n/)) {
if (!line.trim()) continue;
try {
const receipt = JSON.parse(line);
if (
receipt?.operationKey &&
(receipt.status === "succeeded" || receipt.status === "already_applied")
) {
keys.push(String(receipt.operationKey));
}
} catch {
// Older audit lines may be whole-batch JSON; skip unreadable receipts.
}
}
return keys;
}

try {
const args = parseArgs(process.argv.slice(2));
const document = JSON.parse(readFileSync(args.requestPath, "utf8"));
const output = executeMutationDocument({
document,
execute: args.execute,
runner: mutationRunner,
dependencies: {
completedOperationKeys: completedKeysFromAudit(args.auditPath),
onReceipt(receipt) {
if (!args.auditPath) return;
appendFileSync(args.auditPath, `${JSON.stringify(receipt)}\n`, "utf8");
},
},
});
if (args.auditPath) {
appendFileSync(args.auditPath, `${JSON.stringify(output)}\n`, "utf8");
}
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
if (output?.partialFailure) process.exitCode = 2;
} catch (error) {
console.error(String(error?.message || error));
process.exit(2);
Expand Down
25 changes: 10 additions & 15 deletions scripts/lib/authority-head-refresh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ function fetchLiveHead({ request, runner }) {
* Refresh the `expectedHead` and bind `authorityBranch` for every PR-scoped
* operation against live GitHub state before the authorization prompt.
* Operations without a PR head binding are returned unchanged. A failed read
* fails closed before any approval prompt.
* fails closed before any approval prompt. A supplied expected head is verified
* and never replaced; head movement requires regenerating the operation.
*
* Returns `{ requests, refreshed }` where `refreshed` contains only operations
* whose expected head moved. Branch identity is nevertheless bound to every
* PR-scoped output request.
* Returns `{ requests, refreshed }` where `refreshed` is empty on success.
* Branch identity is bound to every PR-scoped output request.
*/
export function refreshExpectedHeads({
requests = [],
Expand All @@ -98,21 +98,16 @@ export function refreshExpectedHeads({
const request = requests[index];
if (!headRefreshCandidate(request)) continue;
const observed = fetchLiveHead({ request, runner });
if (String(observed.head).toLowerCase() !== String(request.expectedHead).toLowerCase()) {
throw new Error(
`expected_head_mismatch: expected ${request.expectedHead}, observed ${observed.head}`,
);
}
output[index] = {
...output[index],
expectedHead: observed.head,
expectedHead: request.expectedHead,
authorityBranch: observed.branch,
};
if (String(observed.head).toLowerCase() !== String(request.expectedHead).toLowerCase()) {
refreshed.push({
index,
pr: request.pr,
repo: request.repo,
from: String(request.expectedHead),
to: observed.head,
branch: observed.branch,
});
}
}
return { requests: output, refreshed };
}
4 changes: 2 additions & 2 deletions scripts/lib/base-health-live.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { spawnSync } from "node:child_process";
import { boundedSpawnSync } from "./subprocess-policy.mjs";
import { collectPaginated } from "./github-pagination.mjs";
import { snapshotIntegritySha256 } from "./snapshot-schema.mjs";

function defaultRunGh(args) {
const result = spawnSync("gh", args, {
const result = boundedSpawnSync("gh", args, {
encoding: "utf8",
maxBuffer: 50 * 1024 * 1024,
});
Expand Down
10 changes: 8 additions & 2 deletions scripts/lib/delivery-workflow-controller.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,14 @@ export function createDeliveryWorkflowController(options = {}) {
headSha = next.headSha || null;
changed = true;
}
if (Object.hasOwn(next, "issue")) issue = next.issue;
if (Object.hasOwn(next, "pr")) pr = next.pr;
if (Object.hasOwn(next, "issue") && next.issue !== issue) {
issue = next.issue;
changed = true;
}
if (Object.hasOwn(next, "pr") && next.pr !== pr) {
pr = next.pr;
changed = true;
}
if (changed) {
stateGeneration += 1;
attempts.noProgressSteps = 0;
Expand Down
6 changes: 2 additions & 4 deletions scripts/lib/eval-contracts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { basename, join, relative, sep } from "node:path";

import { routeShippingGithubPrompt } from "./skill-router.mjs";
import { ROUTABLE_WORKFLOWS, routeShippingGithubPrompt } from "./skill-router.mjs";
import { KNOWN_LENS_IDS, KNOWN_SECURITY_SURFACE_IDS, planReviewScope } from "./review-scope.mjs";
import {
ASSERTION_TO_PROBE,
Expand Down Expand Up @@ -88,9 +88,7 @@ function inferredWorkflow(item) {
if (item.expected_workflow !== undefined) return item.expected_workflow;
if (!["must-trigger", "routing"].includes(item.category)) return undefined;
const workflows = (item.expected_resources || []).filter((resource) =>
/^references\/(fix-pr-bots|watch-pr|re-review-pr|research-issue|create-pr-for-issue|full-review-pr|security-review|status|merge-pr|supersede-pr|overtake-pr)\.md$/.test(
resource,
),
ROUTABLE_WORKFLOWS.includes(resource),
);
return workflows.length === 1 ? workflows[0] : undefined;
}
Expand Down
4 changes: 2 additions & 2 deletions scripts/lib/github-lifecycle-mutation-broker.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";

import { classifyAuthority } from "./authority-grant.mjs";
Expand All @@ -14,6 +13,7 @@ import {
validateLifecycleMutation,
verifyLifecycleMutation,
} from "./lifecycle-mutations.mjs";
import { boundedSpawnSync } from "./subprocess-policy.mjs";

const ACTIONS = new Set([
"push_code",
Expand Down Expand Up @@ -207,7 +207,7 @@ export function planLifecycleMutationRequest(
export function executeLifecycleMutationRequest({
request,
execute = false,
runner = (command, args, options) => spawnSync(command, args, options),
runner = boundedSpawnSync,
authorityPublicKey = null,
requireTrustedAuthority = false,
authorityNow,
Expand Down
28 changes: 23 additions & 5 deletions scripts/lib/github-mutation-broker.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";

import { classifyAuthority } from "./authority-grant.mjs";
Expand All @@ -9,6 +8,7 @@ import {
import { authorizeMutation } from "./mutation-policy.mjs";
import { evaluateHeadBranchCleanup } from "./merge-branch-cleanup.mjs";
import { classifyMergeOutcome, readMergeState } from "./merge-outcome.mjs";
import { boundedSpawnSync } from "./subprocess-policy.mjs";

const PR_ACTIONS = new Set([
"post_review",
Expand Down Expand Up @@ -732,7 +732,7 @@ export function planMutationRequest(
export function executeMutationRequest({
request,
execute = false,
runner = (command, args, options) => spawnSync(command, args, options),
runner = boundedSpawnSync,
authorityPublicKey = null,
requireTrustedAuthority = false,
authorityNow,
Expand All @@ -756,12 +756,28 @@ export function executeMutationRequest({
const commentEditTarget = verifyOwnCommentTarget({ request: plan.request, runner });
const mergeState = readMergeState({ request: plan.request, runner });
const preMergeOutcome = classifyMergeOutcome(mergeState);
if (preMergeOutcome) {
if (preMergeOutcome === "merged") {
return {
...plan,
executed: false,
status: "already_applied",
outcome: preMergeOutcome === "merged" ? "already_merged" : preMergeOutcome,
outcome: "already_merged",
observedHead,
observedBase: retargetState?.observedBase ?? null,
threadTarget,
commentEditTarget,
existingMutation: null,
idempotencyClaim: null,
stdout: "",
verification: mergeState,
};
}
if (preMergeOutcome === "queued" || preMergeOutcome === "auto_merge_enabled") {
return {
...plan,
executed: false,
status: "not_merged",
outcome: preMergeOutcome,
observedHead,
observedBase: retargetState?.observedBase ?? null,
threadTarget,
Expand Down Expand Up @@ -861,7 +877,9 @@ export function executeMutationRequest({
if (plan.request.action === "merge_pr") {
verification = readMergeState({ request: plan.request, runner });
outcome = classifyMergeOutcome(verification);
if (!outcome) throw new Error("merge_outcome_unverified");
if (outcome !== "merged") {
throw new Error(`merge_outcome_unverified:${outcome || "unknown"}`);
}
} else if (REVIEW_THREAD_ACTIONS.has(plan.request.action)) {
verification = verifyReviewThreadTarget({ request: plan.request, runner });
if (verification.isResolved !== true) {
Expand Down
2 changes: 2 additions & 0 deletions scripts/lib/live-snapshot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export function captureLiveSnapshot({
repo,
pr,
maxAgeSeconds = 300,
expectedHead = null,
runner = boundedSpawnSync,
} = {}) {
const result = runner(
Expand All @@ -177,6 +178,7 @@ export function captureLiveSnapshot({
snapshot,
repo,
pr,
expectedHead,
maxAgeSeconds,
requireComplete: false,
});
Expand Down
55 changes: 49 additions & 6 deletions scripts/lib/mutation-document-execution.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,61 @@ export function executeMutationDocument({
}
}

const completedKeys = new Set(
Array.isArray(deps.completedOperationKeys) ? deps.completedOperationKeys : [],
);
const results = [];
let partialFailure = false;

for (const request of requests) {
results.push(
deps.executeMutationWithAuthority({
const operationKey =
request.idempotencyKey ||
[request.action, request.repo, request.pr ?? request.issue ?? ""].join(":");
if (completedKeys.has(operationKey)) {
const skipped = {
action: request.action,
status: "already_applied",
outcome: "already_completed",
skipped: true,
operationKey,
};
results.push(skipped);
deps.onReceipt?.(skipped);
continue;
}

try {
const result = deps.executeMutationWithAuthority({
request,
execute,
runner,
env: effectiveEnv,
readFile,
}),
);
});
const receipt = { ...result, operationKey };
results.push(receipt);
deps.onReceipt?.(receipt);
if (receipt?.status === "succeeded" || receipt?.status === "already_applied") {
completedKeys.add(operationKey);
}
} catch (error) {
partialFailure = true;
const failed = {
action: request.action,
status: "failed",
error: String(error?.message || error),
operationKey,
};
results.push(failed);
deps.onReceipt?.(failed);
break;
}
}

if (normalized.singular) {
const only = results[0];
if (only?.status === "failed") throw new Error(only.error);
return only;
}
if (normalized.singular) return results[0];
return { batch: true, results };
return { batch: true, results, partialFailure };
}
Loading