fix: add post-flight PR finalization
This commit is contained in:
parent
486de63af9
commit
be3a835251
@ -84,6 +84,7 @@ Use repo scripts and prompts as the control plane:
|
||||
- `scripts/plan-cluster.mjs`: what gets hydrated into the prompt.
|
||||
- `scripts/execute-fix-artifact.mjs`: deterministic branch repair/replacement PR gate.
|
||||
- `scripts/apply-result.mjs`: deterministic mutation gate.
|
||||
- `scripts/post-flight.mjs`: deterministic post-execution finalizer for ProjectClownfish fix PRs and post-merge closeouts.
|
||||
- `scripts/import-ghcrawl-low-signal-prs.mjs`: local ghcrawl open-PR scanner for opt-in low-signal cleanup jobs.
|
||||
- `.github/workflows/cluster-worker.yml`: runner behavior and env capture.
|
||||
|
||||
@ -102,6 +103,7 @@ Current autonomy posture:
|
||||
- Useful but uneditable or unsafe source PRs are replacement candidates, not human blockers. When a canonical PR is draft, stale, unmergeable, has `maintainer_can_modify=false`, or has broad unrelated churn, emit or execute `replace_uneditable_branch` with full source PR credit instead of waiting for a maintainer decision.
|
||||
- Fix execution should provide Codex actual repo-discovery context before editing; repeated "no target repo changes" means tune `scripts/execute-fix-artifact.mjs` before replaying more jobs. GitHub Actions may block Codex bwrap write/review sandboxes, so write-mode and review execution default to `danger-full-access` there after tokens are stripped from the Codex environment. A Codex write preflight must fail fast before the expensive repair loop if sandbox/auth/write access is broken; do not wait through multi-attempt edits to discover startup failures. Keep canary execution bounded: default worker timeout is 30 minutes, fix Codex timeout is 30 minutes, preflight timeout is 2 minutes, Codex model is `gpt-5.5`, and Codex reasoning effort is `medium`. Worker timeout/failure and exhausted `/review` attempts must write blocked artifacts and keep the workflow reporting path alive. Fix executor runs must copy Codex debug logs into the run artifact so timeout failures are inspectable.
|
||||
- Match OpenClaw's CI fast lane for fix validation. Use `blacksmith-4vcpu-ubuntu-2404` for cluster planning/review and `blacksmith-16vcpu-ubuntu-2404` for fix/apply execution. The executor sets `OPENCLAW_LOCAL_CHECK=0` and treats `pnpm check:changed` plus diff checks as the default hard gate. It normalizes target validation commands to `pnpm check:changed` unless `CLOWNFISH_TARGET_VALIDATION_MODE=strict` or `CLOWNFISH_STRICT_TARGET_VALIDATION=1` is explicitly set, so unrelated flaky main CI and broad suites do not block narrow ProjectClownfish fixes.
|
||||
- After fix execution, run post-flight finalization before the final closeout replay. Post-flight may merge only ProjectClownfish-opened/pushed fix PRs, only after merge preflight, security clearance, resolved review threads, and non-ignored checks are clean. Default ignored checks are `auto-response`, `Labeler`, and `Stale`; configure `CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS` rather than broadening the hard gate in code.
|
||||
- Prefer `keep_related`, `keep_independent`, `keep_closed`, `fix_needed`, `route_security`, and subcluster notes over blanket `needs_human`.
|
||||
- Use `needs_human` only for the exact maintainer decision still unresolved after hydrated evidence is reviewed.
|
||||
- Worker results must use one action per issue/PR ref. Never emit comma-separated action targets; related follow-up subclusters should be one `keep_related` action per ref or one cluster-scoped `fix_needed` action.
|
||||
@ -112,6 +114,7 @@ After tuning, run:
|
||||
node --check scripts/plan-cluster.mjs
|
||||
node --check scripts/import-ghcrawl-clusters.mjs
|
||||
node --check scripts/run-worker.mjs
|
||||
node --check scripts/post-flight.mjs
|
||||
npm run validate
|
||||
git diff --check
|
||||
```
|
||||
|
||||
13
.github/workflows/cluster-worker.yml
vendored
13
.github/workflows/cluster-worker.yml
vendored
@ -181,6 +181,7 @@ jobs:
|
||||
CLOWNFISH_RESOLVE_REVIEW_THREADS: ${{ vars.CLOWNFISH_RESOLVE_REVIEW_THREADS || '1' }}
|
||||
CLOWNFISH_MODEL: ${{ inputs.model || vars.CLOWNFISH_MODEL || 'gpt-5.5' }}
|
||||
CLOWNFISH_TARGET_VALIDATION_MODE: ${{ vars.CLOWNFISH_TARGET_VALIDATION_MODE || 'changed-only' }}
|
||||
CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS: ${{ vars.CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS || 'auto-response,Labeler,Stale' }}
|
||||
CLOWNFISH_GIT_USER_NAME: ${{ vars.CLOWNFISH_GIT_USER_NAME || 'projectclownfish' }}
|
||||
CLOWNFISH_GIT_USER_EMAIL: ${{ vars.CLOWNFISH_GIT_USER_EMAIL || 'projectclownfish@users.noreply.github.com' }}
|
||||
CODEX_CLI_VERSION: ${{ vars.CLOWNFISH_CODEX_CLI_VERSION || '0.125.0' }}
|
||||
@ -246,6 +247,18 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN }}
|
||||
run: npm run apply-result -- "${{ inputs.job }}" --latest
|
||||
|
||||
- name: Post-flight finalize fix PRs
|
||||
if: ${{ env.CLOWNFISH_ALLOW_EXECUTE == '1' && env.CLOWNFISH_ALLOW_FIX_PR == '1' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN }}
|
||||
run: npm run post-flight -- "${{ inputs.job }}" --latest
|
||||
|
||||
- name: Apply post-flight closeouts
|
||||
if: ${{ env.CLOWNFISH_ALLOW_EXECUTE == '1' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN }}
|
||||
run: npm run apply-result -- "${{ inputs.job }}" --latest
|
||||
|
||||
- name: Upload final worker artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"worker": "node scripts/run-worker.mjs",
|
||||
"apply-result": "node scripts/apply-result.mjs",
|
||||
"execute-fix": "node scripts/execute-fix-artifact.mjs",
|
||||
"post-flight": "node scripts/post-flight.mjs",
|
||||
"dispatch": "node scripts/dispatch-jobs.mjs",
|
||||
"requeue": "node scripts/requeue-job.mjs",
|
||||
"self-heal": "node scripts/self-heal-failed-runs.mjs",
|
||||
|
||||
386
scripts/post-flight.mjs
Normal file
386
scripts/post-flight.mjs
Normal file
@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { assertAllowedOwner, hasSecuritySignalText, parseArgs, parseJob, repoRoot, validateJob } from "./lib.mjs";
|
||||
|
||||
const PASSING_CHECK_CONCLUSIONS = new Set(["SUCCESS", "SKIPPED", "NEUTRAL"]);
|
||||
const CLEAN_MERGE_STATES = new Set(["CLEAN", "HAS_HOOKS"]);
|
||||
const FIX_PR_ACTIONS = new Set(["open_fix_pr", "repair_contributor_branch"]);
|
||||
const FIX_PR_READY_STATUSES = new Set(["opened", "pushed"]);
|
||||
const DEFAULT_IGNORED_CHECKS = ["auto-response", "Labeler", "Stale"];
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const jobPath = args._[0];
|
||||
const resultPathArg = args._[1];
|
||||
const latest = Boolean(args.latest);
|
||||
const dryRun = Boolean(args["dry-run"] || process.env.CLOWNFISH_POST_FLIGHT_DRY_RUN === "1");
|
||||
|
||||
if (!jobPath) {
|
||||
console.error("usage: node scripts/post-flight.mjs <job.md> [result.json] [--latest] [--dry-run]");
|
||||
process.exit(2);
|
||||
}
|
||||
if (!resultPathArg && !latest) {
|
||||
console.error("result path is required unless --latest is set");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const job = parseJob(jobPath);
|
||||
const errors = validateJob(job);
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
assertAllowedOwner(job.frontmatter.repo, process.env.CLOWNFISH_ALLOWED_OWNER);
|
||||
|
||||
if (!["execute", "autonomous"].includes(job.frontmatter.mode)) {
|
||||
throw new Error("refusing post-flight: job frontmatter mode is not execute or autonomous");
|
||||
}
|
||||
if (process.env.CLOWNFISH_ALLOW_EXECUTE !== "1") {
|
||||
throw new Error("refusing post-flight: CLOWNFISH_ALLOW_EXECUTE must be 1");
|
||||
}
|
||||
|
||||
const resultPath = resultPathArg ? path.resolve(resultPathArg) : findLatestResultPath();
|
||||
const result = JSON.parse(fs.readFileSync(resultPath, "utf8"));
|
||||
if (result.repo !== job.frontmatter.repo) {
|
||||
throw new Error(`result repo ${result.repo} does not match job repo ${job.frontmatter.repo}`);
|
||||
}
|
||||
if (result.cluster_id !== job.frontmatter.cluster_id) {
|
||||
throw new Error(`result cluster ${result.cluster_id} does not match job cluster ${job.frontmatter.cluster_id}`);
|
||||
}
|
||||
if (!["execute", "autonomous"].includes(result.mode)) {
|
||||
throw new Error(`refusing post-flight: result mode is ${result.mode}`);
|
||||
}
|
||||
|
||||
const fixReport = readSiblingJson(resultPath, "fix-execution-report.json");
|
||||
const report = {
|
||||
repo: result.repo,
|
||||
cluster_id: result.cluster_id,
|
||||
dry_run: dryRun,
|
||||
result_path: path.relative(repoRoot(), resultPath),
|
||||
post_flight_at: new Date().toISOString(),
|
||||
actions: [],
|
||||
};
|
||||
|
||||
if (!fixReport) {
|
||||
report.actions.push({ action: "post_flight", status: "skipped", reason: "no fix-execution-report.json" });
|
||||
writeReport(report, resultPath);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const action of fixReport.actions ?? []) {
|
||||
if (!FIX_PR_ACTIONS.has(String(action.action ?? ""))) continue;
|
||||
report.actions.push(finalizeFixPr(action));
|
||||
}
|
||||
|
||||
if (report.actions.length === 0) {
|
||||
report.actions.push({ action: "post_flight", status: "skipped", reason: "no ProjectClownfish fix PR actions to finalize" });
|
||||
}
|
||||
|
||||
writeReport(report, resultPath);
|
||||
|
||||
function finalizeFixPr(action) {
|
||||
const base = {
|
||||
action: "finalize_fix_pr",
|
||||
source_action: action.action,
|
||||
source_status: action.status,
|
||||
target: action.pr_url ?? action.target ?? null,
|
||||
};
|
||||
|
||||
if (!FIX_PR_READY_STATUSES.has(String(action.status ?? ""))) {
|
||||
return { ...base, status: "skipped", reason: `fix PR action status is ${action.status ?? "missing"}` };
|
||||
}
|
||||
|
||||
const parsed = parsePullRequestUrl(action.pr_url ?? action.target);
|
||||
if (!parsed || parsed.repo !== result.repo) {
|
||||
return { ...base, status: "blocked", reason: "fix PR URL is missing or outside target repo" };
|
||||
}
|
||||
|
||||
const policyBlock = validateMergePolicy();
|
||||
if (policyBlock) return { ...base, status: "blocked", pr: `#${parsed.number}`, reason: policyBlock };
|
||||
|
||||
const pull = fetchPullRequest(result.repo, parsed.number);
|
||||
const view = fetchPullRequestView(result.repo, parsed.number);
|
||||
const prBase = { ...base, pr: `#${parsed.number}`, title: view.title ?? pull.title ?? null };
|
||||
const mergedAt = pull.merged_at ?? view.mergedAt ?? null;
|
||||
if (mergedAt) {
|
||||
return {
|
||||
...prBase,
|
||||
status: "executed",
|
||||
reason: "already merged",
|
||||
merged_at: mergedAt,
|
||||
merge_commit_sha: pull.merge_commit_sha ?? view.mergeCommit?.oid ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const mergeBlock = validateMergeableFixPr({ pull, view, preflight: action.merge_preflight });
|
||||
if (mergeBlock) {
|
||||
return {
|
||||
...prBase,
|
||||
status: "blocked",
|
||||
reason: mergeBlock,
|
||||
mergeable: view.mergeable ?? null,
|
||||
merge_state_status: view.mergeStateStatus ?? null,
|
||||
review_decision: view.reviewDecision ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
...prBase,
|
||||
status: "planned",
|
||||
reason: "dry run",
|
||||
merge_method: "squash",
|
||||
};
|
||||
}
|
||||
|
||||
ghWithRetry(["pr", "merge", String(parsed.number), "--repo", result.repo, "--squash"]);
|
||||
const merged = fetchPullRequest(result.repo, parsed.number);
|
||||
return {
|
||||
...prBase,
|
||||
status: "executed",
|
||||
reason: "merged by ProjectClownfish post-flight",
|
||||
merged_at: merged.merged_at ?? null,
|
||||
merge_commit_sha: merged.merge_commit_sha ?? null,
|
||||
merge_method: "squash",
|
||||
};
|
||||
}
|
||||
|
||||
function validateMergePolicy() {
|
||||
if (!job.frontmatter.allowed_actions.includes("merge")) return "job does not allow merge";
|
||||
if ((job.frontmatter.blocked_actions ?? []).includes("merge")) return "merge is blocked by job frontmatter";
|
||||
if (job.frontmatter.allow_merge !== true) return "merge requires allow_merge: true";
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateMergeableFixPr({ pull, view, preflight }) {
|
||||
if (pull.state !== "open") return `pull request is ${pull.state}`;
|
||||
if (pull.draft || view.isDraft) return "pull request is draft";
|
||||
if (String(view.baseRefName ?? pull.base?.ref ?? "") !== "main") return "pull request base is not main";
|
||||
if (hasSecuritySignalText(pull.title, pull.body, pull.labels ?? [])) {
|
||||
return "security-sensitive PR requires central security triage";
|
||||
}
|
||||
if (view.mergeable !== "MERGEABLE") return `mergeable state is ${view.mergeable || "unknown"}`;
|
||||
if (!CLEAN_MERGE_STATES.has(String(view.mergeStateStatus ?? ""))) {
|
||||
return `merge state status is ${view.mergeStateStatus || "unknown"}`;
|
||||
}
|
||||
if (["CHANGES_REQUESTED", "REVIEW_REQUIRED"].includes(String(view.reviewDecision ?? ""))) {
|
||||
return `review decision is ${view.reviewDecision}`;
|
||||
}
|
||||
|
||||
const preflightBlock = validateMergePreflight(preflight);
|
||||
if (preflightBlock) return preflightBlock;
|
||||
|
||||
const threadBlock = validateResolvedReviewThreads(result.repo, pull.number);
|
||||
if (threadBlock) return threadBlock;
|
||||
|
||||
const checkBlock = validateStatusChecks(view.statusCheckRollup ?? []);
|
||||
if (checkBlock) return checkBlock;
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateMergePreflight(preflight) {
|
||||
if (!preflight || typeof preflight !== "object") return "merge_preflight is missing";
|
||||
if (preflight.security_status !== "cleared") return "security preflight is not cleared";
|
||||
if (!Array.isArray(preflight.security_evidence) || preflight.security_evidence.length === 0) {
|
||||
return "security preflight evidence is missing";
|
||||
}
|
||||
if (preflight.comments_status !== "resolved") return "review comments are not resolved";
|
||||
if (!Array.isArray(preflight.comments_evidence) || preflight.comments_evidence.length === 0) {
|
||||
return "review comments resolution evidence is missing";
|
||||
}
|
||||
if (preflight.bot_comments_status !== "resolved") return "review-bot comments are not resolved";
|
||||
if (!Array.isArray(preflight.bot_comments_evidence) || preflight.bot_comments_evidence.length === 0) {
|
||||
return "review-bot comment resolution evidence is missing";
|
||||
}
|
||||
if (!Array.isArray(preflight.validation_commands) || preflight.validation_commands.length === 0) {
|
||||
return "merge validation commands are missing";
|
||||
}
|
||||
const codexReview = preflight.codex_review;
|
||||
if (!codexReview || codexReview.command !== "/review") return "Codex /review preflight is missing";
|
||||
if (!["passed", "clean"].includes(codexReview.status)) return `Codex /review status is ${codexReview.status || "missing"}`;
|
||||
if (codexReview.findings_addressed !== true) return "Codex /review findings are not addressed";
|
||||
if (!Array.isArray(codexReview.evidence) || codexReview.evidence.length === 0) {
|
||||
return "Codex /review evidence is missing";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateStatusChecks(checks) {
|
||||
if (!Array.isArray(checks) || checks.length === 0) return "no PR checks found";
|
||||
const ignored = ignoredCheckNames();
|
||||
const blockers = [];
|
||||
for (const check of checks) {
|
||||
const name = String(check.name ?? check.context ?? "unknown check");
|
||||
if (ignored.has(name)) continue;
|
||||
const status = String(check.status ?? check.state ?? "").toUpperCase();
|
||||
const conclusion = String(check.conclusion ?? "").toUpperCase();
|
||||
if (status && !["COMPLETED", "SUCCESS"].includes(status)) {
|
||||
blockers.push(`${name}: ${status}`);
|
||||
continue;
|
||||
}
|
||||
if (conclusion && !PASSING_CHECK_CONCLUSIONS.has(conclusion)) {
|
||||
blockers.push(`${name}: ${conclusion}`);
|
||||
}
|
||||
}
|
||||
if (blockers.length > 0) return `checks are not clean: ${blockers.slice(0, 5).join(", ")}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function ignoredCheckNames() {
|
||||
const configured = String(process.env.CLOWNFISH_POST_FLIGHT_IGNORE_CHECKS ?? DEFAULT_IGNORED_CHECKS.join(","));
|
||||
return new Set(
|
||||
configured
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function validateResolvedReviewThreads(repo, number) {
|
||||
const [owner, name] = repo.split("/");
|
||||
const query = `
|
||||
query($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(first: 100) {
|
||||
pageInfo { hasNextPage }
|
||||
nodes {
|
||||
isResolved
|
||||
path
|
||||
line
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
url
|
||||
author { login }
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = ghJson([
|
||||
"api",
|
||||
"graphql",
|
||||
"-f",
|
||||
`owner=${owner}`,
|
||||
"-f",
|
||||
`name=${name}`,
|
||||
"-F",
|
||||
`number=${number}`,
|
||||
"-f",
|
||||
`query=${query}`,
|
||||
]);
|
||||
const threads = data?.data?.repository?.pullRequest?.reviewThreads;
|
||||
if (threads?.pageInfo?.hasNextPage) return "too many review threads to prove resolved";
|
||||
const unresolved = (threads?.nodes ?? []).filter((thread) => thread && !thread.isResolved);
|
||||
if (unresolved.length === 0) return "";
|
||||
const examples = unresolved
|
||||
.slice(0, 3)
|
||||
.map((thread) => thread.comments?.nodes?.[0]?.url ?? `${thread.path}:${thread.line ?? "?"}`);
|
||||
return `unresolved review threads remain: ${examples.join(", ")}`;
|
||||
}
|
||||
|
||||
function fetchPullRequest(repo, number) {
|
||||
return ghJson(["api", `repos/${repo}/pulls/${number}`]);
|
||||
}
|
||||
|
||||
function fetchPullRequestView(repo, number) {
|
||||
return ghJson([
|
||||
"pr",
|
||||
"view",
|
||||
String(number),
|
||||
"--repo",
|
||||
repo,
|
||||
"--json",
|
||||
[
|
||||
"baseRefName",
|
||||
"isDraft",
|
||||
"mergeable",
|
||||
"mergeCommit",
|
||||
"mergeStateStatus",
|
||||
"mergedAt",
|
||||
"reviewDecision",
|
||||
"state",
|
||||
"statusCheckRollup",
|
||||
"title",
|
||||
"updatedAt",
|
||||
"url",
|
||||
].join(","),
|
||||
]);
|
||||
}
|
||||
|
||||
function findLatestResultPath() {
|
||||
const runsRoot = path.join(repoRoot(), ".projectclownfish", "runs");
|
||||
if (!fs.existsSync(runsRoot)) throw new Error("no run directory exists");
|
||||
const candidates = [];
|
||||
for (const runName of fs.readdirSync(runsRoot)) {
|
||||
const candidate = path.join(runsRoot, runName, "result.json");
|
||||
if (fs.existsSync(candidate)) candidates.push({ path: candidate, mtimeMs: fs.statSync(candidate).mtimeMs });
|
||||
}
|
||||
candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
if (!candidates[0]) throw new Error("no result.json files found");
|
||||
return candidates[0].path;
|
||||
}
|
||||
|
||||
function readSiblingJson(resultPath, name) {
|
||||
const file = path.join(path.dirname(resultPath), name);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function writeReport(report, resultPath) {
|
||||
const reportPath = path.join(path.dirname(resultPath), "post-flight-report.json");
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
function parsePullRequestUrl(value) {
|
||||
const match = String(value ?? "").match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i);
|
||||
if (!match) return null;
|
||||
return { repo: match[1], number: Number(match[2]) };
|
||||
}
|
||||
|
||||
function ghJson(ghArgs) {
|
||||
const text = ghWithRetry(ghArgs);
|
||||
return JSON.parse(text || "null");
|
||||
}
|
||||
|
||||
function ghWithRetry(ghArgs, attempts = 6) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
return execFileSync("gh", ghArgs, {
|
||||
cwd: repoRoot(),
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!shouldRetryGh(error) || attempt === attempts - 1) throw error;
|
||||
sleepMs(Math.min(120_000, 10_000 * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function shouldRetryGh(error) {
|
||||
const stderr = String(error?.stderr ?? "");
|
||||
const message = `${error instanceof Error ? error.message : String(error)}\n${stderr}`;
|
||||
return (
|
||||
message.includes("was submitted too quickly") ||
|
||||
message.includes("secondary rate") ||
|
||||
message.includes("API rate limit exceeded")
|
||||
);
|
||||
}
|
||||
|
||||
function sleepMs(milliseconds) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
||||
}
|
||||
@ -16,6 +16,7 @@ const CLOSE_APPLICATOR_ACTIONS = new Set([
|
||||
]);
|
||||
const MERGE_APPLICATOR_ACTIONS = new Set(["merge_candidate", "merge_canonical"]);
|
||||
const APPLICATOR_ACTIONS = new Set([...CLOSE_APPLICATOR_ACTIONS, ...MERGE_APPLICATOR_ACTIONS]);
|
||||
const POST_FLIGHT_MERGE_ACTIONS = new Set(["finalize_fix_pr"]);
|
||||
const PR_INFO_CACHE = new Map();
|
||||
const ISSUE_INFO_CACHE = new Map();
|
||||
|
||||
@ -40,6 +41,7 @@ function publishResult(resultPath) {
|
||||
const runDir = path.dirname(resultPath);
|
||||
const result = readJson(resultPath);
|
||||
const applyReport = readSiblingJson(runDir, "apply-report.json") ?? { actions: [] };
|
||||
const postFlightReport = readSiblingJson(runDir, "post-flight-report.json") ?? { actions: [] };
|
||||
const clusterPlan = readSiblingJson(runDir, "cluster-plan.json");
|
||||
const runId = String(args["run-id"] ?? inferRunId(resultPath) ?? "");
|
||||
const metadata = runId ? metadataByRunId.get(runId) : undefined;
|
||||
@ -52,7 +54,10 @@ function publishResult(resultPath) {
|
||||
const repo = String(result.repo ?? "unknown/unknown");
|
||||
const owner = repo.split("/")[0] || "unknown";
|
||||
const clusterId = String(result.cluster_id ?? path.basename(runDir));
|
||||
const applyActions = (applyReport.actions ?? []).filter(isApplicatorAction);
|
||||
const applyActions = [
|
||||
...(applyReport.actions ?? []),
|
||||
...(postFlightReport.actions ?? []).filter(isPostFlightMergeAction).map(postFlightMergeToApplyAction),
|
||||
].filter(isApplicatorAction);
|
||||
const report = {
|
||||
repo,
|
||||
cluster_id: clusterId,
|
||||
@ -451,6 +456,25 @@ function isApplicatorAction(action) {
|
||||
return APPLICATOR_ACTIONS.has(String(action?.action ?? ""));
|
||||
}
|
||||
|
||||
function isPostFlightMergeAction(action) {
|
||||
return POST_FLIGHT_MERGE_ACTIONS.has(String(action?.action ?? "")) && action?.pr;
|
||||
}
|
||||
|
||||
function postFlightMergeToApplyAction(action) {
|
||||
return {
|
||||
target: action.pr,
|
||||
action: "merge_canonical",
|
||||
status: action.status,
|
||||
classification: "fix_pr",
|
||||
title: action.title ?? null,
|
||||
reason: action.reason ?? null,
|
||||
merged_at: action.merged_at ?? null,
|
||||
merge_commit_sha: action.merge_commit_sha ?? null,
|
||||
live_state: action.status === "executed" ? "merged" : null,
|
||||
live_updated_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sortNewestRecordFirst(left, right) {
|
||||
return String(right.published_at ?? "").localeCompare(String(left.published_at ?? ""));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user