feat: harden Clownfish replacement PR flow

This commit is contained in:
Peter Steinberger 2026-04-29 04:23:27 +01:00
parent 6acdbf9eff
commit 0624869fcf
No known key found for this signature in database
11 changed files with 283 additions and 36 deletions

View File

@ -60,8 +60,8 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}
CLOWNFISH_ALLOWED_OWNER: ${{ vars.CLOWNFISH_ALLOWED_OWNER || 'openclaw' }}
CLOWNFISH_ALLOW_EXECUTE: ${{ (inputs.mode == 'execute' || inputs.mode == 'autonomous') && (vars.CLOWNFISH_ALLOW_EXECUTE || '1') || '0' }}
CLOWNFISH_ALLOW_FIX_PR: ${{ (inputs.mode == 'execute' || inputs.mode == 'autonomous') && (vars.CLOWNFISH_ALLOW_FIX_PR || '1') || '0' }}
CLOWNFISH_ALLOW_EXECUTE: ${{ (inputs.mode == 'execute' || inputs.mode == 'autonomous') && vars.CLOWNFISH_ALLOW_EXECUTE == '1' && '1' || '0' }}
CLOWNFISH_ALLOW_FIX_PR: ${{ (inputs.mode == 'execute' || inputs.mode == 'autonomous') && vars.CLOWNFISH_ALLOW_FIX_PR == '1' && '1' || '0' }}
CLOWNFISH_ALLOW_MERGE: ${{ (inputs.mode == 'execute' || inputs.mode == 'autonomous') && (vars.CLOWNFISH_ALLOW_MERGE || '0') || '0' }}
CLOWNFISH_HYDRATE_CLUSTER_REFS: ${{ vars.CLOWNFISH_HYDRATE_CLUSTER_REFS || '1' }}
CLOWNFISH_HYDRATE_COMMENTS: ${{ vars.CLOWNFISH_HYDRATE_COMMENTS || '1' }}
@ -147,7 +147,12 @@ jobs:
env:
GH_TOKEN: ${{ steps.app_token.outputs.token || secrets.CLOWNFISH_READ_GH_TOKEN || github.token }}
run: |
args=("${{ inputs.job }}" --mode "${{ inputs.mode }}")
worker_mode="${{ inputs.mode }}"
if [ "$worker_mode" != "plan" ] && [ "${CLOWNFISH_ALLOW_EXECUTE}" != "1" ]; then
echo "CLOWNFISH_ALLOW_EXECUTE is not explicitly 1; rendering plan-only output for requested $worker_mode run"
worker_mode="plan"
fi
args=("${{ inputs.job }}" --mode "$worker_mode")
if [ "${{ inputs.dry_run }}" = "true" ]; then
args+=(--dry-run)
fi
@ -187,8 +192,8 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}
CLOWNFISH_ALLOWED_OWNER: ${{ vars.CLOWNFISH_ALLOWED_OWNER || 'openclaw' }}
CLOWNFISH_ALLOW_EXECUTE: ${{ needs.cluster.outputs.allow_execute || '1' }}
CLOWNFISH_ALLOW_FIX_PR: ${{ needs.cluster.outputs.allow_fix_pr || '1' }}
CLOWNFISH_ALLOW_EXECUTE: ${{ needs.cluster.outputs.allow_execute == '1' && '1' || '0' }}
CLOWNFISH_ALLOW_FIX_PR: ${{ needs.cluster.outputs.allow_fix_pr == '1' && '1' || '0' }}
CLOWNFISH_ALLOW_MERGE: ${{ needs.cluster.outputs.allow_merge || '0' }}
CLOWNFISH_CODEX_REASONING_EFFORT: ${{ vars.CLOWNFISH_CODEX_REASONING_EFFORT || 'medium' }}
CLOWNFISH_CODEX_REVIEW_ATTEMPTS: ${{ vars.CLOWNFISH_CODEX_REVIEW_ATTEMPTS || '2' }}

View File

@ -43,8 +43,8 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
CODEX_CLI_VERSION: ${{ vars.CLOWNFISH_CODEX_CLI_VERSION || '0.125.0' }}
CLOWNFISH_ALLOWED_OWNER: ${{ vars.CLOWNFISH_ALLOWED_OWNER || 'openclaw' }}
CLOWNFISH_ALLOW_EXECUTE: ${{ vars.CLOWNFISH_ALLOW_EXECUTE || '1' }}
CLOWNFISH_ALLOW_FIX_PR: ${{ vars.CLOWNFISH_ALLOW_FIX_PR || '1' }}
CLOWNFISH_ALLOW_EXECUTE: ${{ vars.CLOWNFISH_ALLOW_EXECUTE == '1' && '1' || '0' }}
CLOWNFISH_ALLOW_FIX_PR: ${{ vars.CLOWNFISH_ALLOW_FIX_PR == '1' && '1' || '0' }}
CLOWNFISH_ALLOW_MERGE: "0"
CLOWNFISH_CODEX_REASONING_EFFORT: ${{ vars.CLOWNFISH_CODEX_REASONING_EFFORT || 'medium' }}
CLOWNFISH_CODEX_REVIEW_ATTEMPTS: ${{ vars.CLOWNFISH_CODEX_REVIEW_ATTEMPTS || '2' }}
@ -160,7 +160,7 @@ jobs:
- name: Execute credited fix artifact
id: execute
if: ${{ steps.prepare.outputs.should_repair == 'true' }}
if: ${{ steps.prepare.outputs.should_repair == 'true' && env.CLOWNFISH_ALLOW_EXECUTE == '1' && env.CLOWNFISH_ALLOW_FIX_PR == '1' }}
continue-on-error: true
timeout-minutes: 35
env:
@ -170,7 +170,7 @@ jobs:
run: npm run execute-fix -- "${{ steps.prepare.outputs.job_path }}" "${{ steps.prepare.outputs.result_path }}"
- name: Post-flight finalize fix PRs
if: ${{ steps.prepare.outputs.should_repair == 'true' && steps.execute.outcome == 'success' }}
if: ${{ steps.prepare.outputs.should_repair == 'true' && steps.execute.outcome == 'success' && env.CLOWNFISH_ALLOW_EXECUTE == '1' && env.CLOWNFISH_ALLOW_FIX_PR == '1' }}
env:
GH_TOKEN: ${{ steps.app_token.outputs.token || secrets.CLOWNFISH_GH_TOKEN || github.token }}
run: npm run post-flight -- "${{ steps.prepare.outputs.job_path }}" "${{ steps.prepare.outputs.result_path }}"
@ -185,7 +185,7 @@ jobs:
--run-id "${{ github.run_id }}" \
--run-url "$RUN_URL" \
--head-sha "${{ github.sha }}" \
--conclusion "${{ steps.execute.outcome == 'success' && 'success' || 'failure' }}"
--conclusion "${{ steps.execute.outcome == 'success' && 'success' || steps.execute.outcome == 'failure' && 'failure' || 'skipped' }}"
- name: Finalize commit finding audit
if: always()
@ -194,7 +194,7 @@ jobs:
npm run commit-finding-intake -- finalize \
--audit-path "${{ steps.prepare.outputs.audit_path }}" \
--run-dir "${{ steps.prepare.outputs.run_dir }}" \
--status "${{ steps.prepare.outputs.should_repair == 'true' && steps.execute.outcome || steps.prepare.outputs.status }}"
--status "${{ steps.prepare.outputs.should_repair == 'true' && (steps.execute.outcome || 'skipped') || steps.prepare.outputs.status }}"
fi
- name: Commit intake ledger

View File

@ -48,8 +48,8 @@ jobs:
- name: Finalize open ProjectClownfish PRs
env:
GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN || github.token }}
CLOWNFISH_ALLOW_EXECUTE: ${{ inputs.execute && (vars.CLOWNFISH_ALLOW_EXECUTE || '1') || '0' }}
CLOWNFISH_ALLOW_FIX_PR: ${{ inputs.execute && (vars.CLOWNFISH_ALLOW_FIX_PR || '1') || '0' }}
CLOWNFISH_ALLOW_EXECUTE: ${{ inputs.execute && vars.CLOWNFISH_ALLOW_EXECUTE == '1' && '1' || '0' }}
CLOWNFISH_ALLOW_FIX_PR: ${{ inputs.execute && vars.CLOWNFISH_ALLOW_FIX_PR == '1' && '1' || '0' }}
run: |
args=(
--write-report
@ -58,8 +58,10 @@ jobs:
--runner "${{ inputs.runner }}"
--execution-runner "${{ inputs.execution_runner }}"
)
if [ "${{ inputs.execute }}" = "true" ]; then
if [ "${{ inputs.execute }}" = "true" ] && [ "${CLOWNFISH_ALLOW_EXECUTE}" = "1" ] && [ "${CLOWNFISH_ALLOW_FIX_PR}" = "1" ]; then
args+=(--execute)
elif [ "${{ inputs.execute }}" = "true" ]; then
echo "execution requested, but Clownfish execute/fix gates are not explicitly 1; writing report without dispatching repairs"
fi
npm run finalize-open-prs -- "${args[@]}"
npm run publish-result
@ -77,7 +79,7 @@ jobs:
exit 0
fi
git commit -m "chore: record open PR finalizer"
for attempt in 1 2 3; do
for _ in 1 2 3; do
if git push; then
exit 0
fi

View File

@ -238,7 +238,7 @@ Each cluster job:
4. Runs Codex with repo-local policy prompts and JSON output schema in a read-only sandbox.
5. Writes structured run artifacts under `.projectclownfish/runs/`.
6. Reviews the worker artifact with deterministic safety checks.
7. Executes credited fix artifacts through `scripts/execute-fix-artifact.mjs` when the fix gate is open: repair a maintainer-editable contributor branch first, otherwise raise a narrow replacement PR and close the uneditable source PR after the replacement push succeeds.
7. Executes credited fix artifacts through `scripts/execute-fix-artifact.mjs` when the fix gate is open: repair a maintainer-editable contributor branch first, otherwise raise a narrow replacement PR, add non-bot source PR authors as replacement co-authors, and close the uneditable source PR after the replacement push succeeds.
8. Applies guarded close/comment and explicit merge actions through `scripts/apply-result.mjs`.
9. Publishes a sanitized result ledger back to this repo under `results/`, `jobs/openclaw/closed/`, `apply-report.json`, and this README dashboard.
@ -246,7 +246,7 @@ Codex does not receive a GitHub token during classification. The runner prefligh
Merge is deliberately harder than closeout. A merge action must include `merge_preflight` proving security clearance, resolved human comments, resolved review-bot findings, a passed Codex `/review`, addressed review findings, and clean validation commands. The fix executor runs an agentic edit/review loop before it writes a fix PR: edit, validate, Codex `/review`, address findings, revalidate, and resolve PR review threads when permitted. The applicator also checks live unresolved GitHub review threads immediately before merge.
Replacement fix work uses a recoverable target branch named `clownfish/<cluster-id>`. The executor resumes that branch if it already exists and pushes checkpoint commits after agent edits and review-fix edits, then opens or updates the PR only after validation and Codex `/review` pass. If `/review` still blocks the merge after retries, the run writes a blocked fix report and leaves the checkpoint branch recoverable instead of losing the patch.
Replacement fix work uses a recoverable target branch named `clownfish/<cluster-id>`. The executor resumes that branch if it already exists and pushes checkpoint commits after agent edits and review-fix edits, adding `Co-authored-by` trailers for non-bot source PR authors when a contributor PR is replaced. It then opens or updates the PR only after validation and Codex `/review` pass. If `/review` still blocks the merge after retries, the run writes a blocked fix report and leaves the checkpoint branch recoverable instead of losing the patch.
Runs for the same job path and mode are queued instead of running concurrently. The workflow uses Node 24, `blacksmith-4vcpu-ubuntu-2404` for cluster planning/review, and `blacksmith-16vcpu-ubuntu-2404` for fix/apply execution. Fix execution prepares the target checkout with Corepack and the target `pnpm` package manager before validation; the execution job caches Codex, npm, Corepack, and the target pnpm store. Fix validation is pinned to OpenClaw's fast changed-lane posture by default: `pnpm check:changed` plus diff checks are the hard local gate, and target validation commands normalize to `pnpm check:changed` unless `CLOWNFISH_TARGET_VALIDATION_MODE=strict` or `CLOWNFISH_STRICT_TARGET_VALIDATION=1` is explicitly set. Unrelated flaky main CI, broad `pnpm check`, full tests, live, docker, and e2e lanes do not block narrow ProjectClownfish fixes by default.
@ -414,11 +414,12 @@ The workflow needs:
- Codex/OpenAI authentication for model execution
- a read-only GitHub token for worker inspection
- a separate write-scoped GitHub token for the deterministic applicator
- execution gates that default on for execute/autonomous jobs: set `CLOWNFISH_ALLOW_EXECUTE=0` or `CLOWNFISH_ALLOW_FIX_PR=0` only when intentionally pausing live work
- execution gates that default closed: set `CLOWNFISH_ALLOW_EXECUTE=1` and `CLOWNFISH_ALLOW_FIX_PR=1` only for an intentional execution window; otherwise execute/autonomous dispatches render plan-only output and skip mutation steps
- merge is separately gated by `CLOWNFISH_ALLOW_MERGE`; it defaults to `0`, and merge-ready PRs are labeled `clownfish:human-review` and `clownfish:merge-ready` for a maintainer to merge manually
- optional `CLOWNFISH_CODEX_CLI_VERSION` variable to pin and refresh the cached Codex CLI
- optional `CLOWNFISH_MODEL` override for dispatch scripts; default Codex model is `gpt-5.5`
- optional `CLOWNFISH_MAX_LIVE_WORKERS` variable for dispatch/requeue/self-heal worker fan-out; default is `50`
- optional `CLOWNFISH_MAX_ACTIVE_PRS_PER_AREA` variable for replacement PR backpressure; default is `3` open Clownfish PRs per touched area, and `0` disables the area cap
- optional `CLOWNFISH_CODEX_TIMEOUT_MS` and `CLOWNFISH_FIX_CODEX_TIMEOUT_MS` variables; worker planning defaults to 30 minutes, while fix execution defaults to a 20 minute Codex budget inside the 30 minute build-PR step so timeout artifacts can be written
- optional `CLOWNFISH_CODEX_REVIEW_ATTEMPTS` and `CLOWNFISH_RESOLVE_REVIEW_THREADS` variables for agentic merge-prep review loops
- optional `CLOWNFISH_COMMENT_ROUTER_EXECUTE=1` to let the scheduled comment

View File

@ -150,12 +150,18 @@ It can:
- run Codex `/review`
- address Codex review findings
- open or update the target PR
- preserve contributor credit in PR body and closeout comments
- preserve contributor credit in co-author trailers, PR body, and closeout comments
The executor prepares a temporary checkout of the target repo. Codex edits that
checkout without GitHub credentials. The deterministic executor commits,
pushes, opens PRs, and comments using the GitHub token.
When replacing a meaningful contributor PR, the executor fetches the source PR
author, skips bot authors, adds `Co-authored-by` trailers to replacement
checkpoint commits, records carried-forward credit in the replacement PR body,
and says in the source close comment that the contribution is carried forward
rather than rejected.
Generated Clownfish PRs are marked by:
- branch prefix: `clownfish/`
@ -167,6 +173,13 @@ Clownfish app author has more than 10 active PRs. That is a target-repo policy
interaction, not evidence that the generated PR is invalid. Reduce or land the
active Clownfish queue before reopening those PRs.
Replacement PR creation also has a per-area backpressure guard. Before opening a
new `clownfish/*` replacement branch, `execute-fix-artifact` groups the proposed
`likely_files` into touched areas such as `extensions/discord`, `src/core`, or
`docs`, reads open Clownfish PRs in the target repo, and blocks if the same area
already has `CLOWNFISH_MAX_ACTIVE_PRS_PER_AREA` open Clownfish PRs. The default
limit is `3`; set it to `0` only for a deliberately uncapped execution window.
## ClawSweeper Commit Findings
Workflow: `.github/workflows/commit-finding-intake.yml`
@ -405,8 +418,10 @@ PR directly.
Important gates:
- `CLOWNFISH_ALLOW_EXECUTE`: allows deterministic write lanes.
- `CLOWNFISH_ALLOW_EXECUTE`: allows deterministic write lanes. Workflows treat
any value except literal `1` as closed.
- `CLOWNFISH_ALLOW_FIX_PR`: allows branch repair and replacement PR creation.
Workflows treat any value except literal `1` as closed.
- `CLOWNFISH_ALLOW_MERGE`: allows Clownfish to merge. Keep this `0` unless a
maintainer explicitly opens it.
- `CLOWNFISH_COMMENT_ROUTER_EXECUTE`: lets scheduled comment routing post
@ -418,6 +433,8 @@ Important defaults:
- `CLOWNFISH_CODEX_REASONING_EFFORT`: model reasoning effort; use `xhigh` for
difficult repair work.
- `CLOWNFISH_MAX_LIVE_WORKERS`: dispatch capacity guard.
- `CLOWNFISH_MAX_ACTIVE_PRS_PER_AREA`: replacement PR area backpressure; default
is `3` open Clownfish PRs per touched area, and `0` disables the cap.
- `CLOWNFISH_TARGET_VALIDATION_MODE`: changed-only validation by default.
- `CLOWNFISH_RESOLVE_REVIEW_THREADS`: lets fix execution resolve threads after
it addresses them.

View File

@ -60,14 +60,27 @@ npm run create-job -- --from-report ../clawsweeper/records/openclaw-openclaw/ite
The generated job defaults to `mode: autonomous`, `allow_fix_pr: true`,
`allow_instant_close: false`, `allow_merge: false`, and
`require_fix_before_close: true`. Commit and push the new job file, then
dispatch it:
`require_fix_before_close: true`. `close_duplicate` actions can still consolidate
duplicate threads, but `close_fixed_by_candidate` waits for a merged candidate
fix unless a maintainer explicitly sets `allow_unmerged_fix_close: true`.
Commit and push the new job file, then dispatch it:
```bash
npm run validate:job -- jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md
npm run dispatch -- jobs/openclaw/inbox/clawsweeper-openclaw-openclaw-123.md --mode autonomous
```
To ask for a replacement PR from an existing useful but uneditable source PR,
make the prompt explicit:
```md
Treat #123 as useful source work. If the branch cannot be safely updated
because it is uneditable, stale, draft-only, or unsafe, create a narrow
Clownfish replacement PR instead of waiting. Preserve the source PR author as
co-author, credit the source PR in the replacement PR body, and close only that
source PR after the replacement PR is opened.
```
Keep `CLOWNFISH_ALLOW_MERGE=0` unless a human explicitly opens the merge gate.
## Manual Fix PR From Commit Finding
@ -116,6 +129,8 @@ It only applies closure actions when all of these are true:
- GitHub still reports the same `updated_at`;
- the target is open and not maintainer-authored.
- the target is not security-sensitive.
- `close_fixed_by_candidate` has a merged candidate fix unless
`allow_unmerged_fix_close: true` was set by a maintainer.
The applicator writes an idempotency marker into the close comment before closing. Re-runs skip already-applied comments/closures instead of posting twice.
@ -146,7 +161,7 @@ npm run dispatch -- jobs/openclaw/cluster-*.md --mode plan --runner blacksmith-4
The workflow uses Node 24 and logs Codex in with `OPENAI_API_KEY`, while also passing `CODEX_API_KEY` to `codex exec`. Set `CODEX_API_KEY` to the same value unless you intentionally separate CI auth.
Codex runs in a read-only sandbox for classification and receives no GitHub token. GitHub read access is scoped to deterministic preflight scripts. For reviewed fix artifacts, `execute-fix-artifact` gives Codex a temporary target checkout without GitHub credentials, then the deterministic executor commits, pushes, opens the replacement PR, and closes uneditable source PRs only after the replacement exists. Remaining write access is scoped to `apply-result`.
Codex runs in a read-only sandbox for classification and receives no GitHub token. GitHub read access is scoped to deterministic preflight scripts. For reviewed fix artifacts, `execute-fix-artifact` gives Codex a temporary target checkout without GitHub credentials, then the deterministic executor commits, pushes, opens the replacement PR, and closes uneditable source PRs only after the replacement exists. When a replacement carries contributor work forward, non-bot source PR authors are added as `Co-authored-by` trailers and named in the replacement PR body and source close comment. Remaining write access is scoped to `apply-result`.
Runs for the same job path and mode share a concurrency group. Different cluster jobs can still run in parallel.

View File

@ -456,6 +456,7 @@ function validateClosePolicy({ job, actionName }) {
function validateFixFirstClose({ job, result, actionName, classification, candidateFix }) {
if (job.frontmatter.require_fix_before_close !== true) return "";
if (["close_low_signal", "post_merge_close"].includes(actionName)) return "";
if (classification === "duplicate") return "";
const priorMerge = report.actions.some(
(entry) => MERGE_ACTIONS.has(entry.action) && entry.status === "executed",
@ -466,6 +467,10 @@ function validateFixFirstClose({ job, result, actionName, classification, candid
return "";
}
if (classification === "fixed_by_candidate" && job.frontmatter.allow_unmerged_fix_close !== true) {
return "fixed_by_candidate close requires a merged fix PR unless allow_unmerged_fix_close: true";
}
const fixReport = readFixExecutionReport(result);
const fixLanded = (fixReport?.actions ?? []).some((entry) =>
["open_fix_pr", "repair_contributor_branch"].includes(String(entry.action ?? "")) &&
@ -473,7 +478,7 @@ function validateFixFirstClose({ job, result, actionName, classification, candid
);
if (fixLanded) return "";
return "close requires ProjectClownfish fix PR opened/pushed or merge executed first";
return "close requires Clownfish fix PR opened/pushed, merged candidate fix, or merge executed first";
}
function isMergedCandidateFix(repo, candidateFix) {

View File

@ -168,6 +168,7 @@ cluster_refs: []
allow_instant_close: false
allow_fix_pr: true
allow_merge: false
allow_unmerged_fix_close: false
allow_post_merge_close: false
require_fix_before_close: false
security_policy: central_security_only

View File

@ -140,6 +140,7 @@ ${refs.map((ref) => ` - ${ref}`).join("\n")}
allow_instant_close: false
allow_fix_pr: true
allow_merge: false
allow_unmerged_fix_close: false
allow_post_merge_close: true
require_fix_before_close: true
security_policy: central_security_only

View File

@ -33,6 +33,7 @@ const installTargetDeps = process.env.CLOWNFISH_INSTALL_TARGET_DEPS !== "0";
const allowBroadFixArtifacts = process.env.CLOWNFISH_ALLOW_BROAD_FIX_ARTIFACTS === "1";
const maxAutonomousFixFiles = Math.max(1, Number(process.env.CLOWNFISH_MAX_AUTONOMOUS_FIX_FILES ?? 8));
const maxAutonomousFixSurfaces = Math.max(1, Number(process.env.CLOWNFISH_MAX_AUTONOMOUS_FIX_SURFACES ?? 4));
const maxActivePrsPerArea = Number(process.env.CLOWNFISH_MAX_ACTIVE_PRS_PER_AREA ?? 3);
const strictTargetValidation =
process.env.CLOWNFISH_STRICT_TARGET_VALIDATION === "1" ||
String(process.env.CLOWNFISH_TARGET_VALIDATION_MODE ?? "changed-only") === "strict";
@ -409,8 +410,19 @@ function prepareFallbackReplacementCheckout(sourceTargetDir) {
function executeReplacementBranch({ fixArtifact, targetDir, supersedeSources, fallbackReason }) {
const baseBranch = String(process.env.CLOWNFISH_FIX_BASE_BRANCH ?? DEFAULT_BASE_BRANCH);
run("git", ["fetch", "origin", baseBranch], { cwd: targetDir });
const contributorCredits = sourceContributorCredits({ fixArtifact, targetDir });
const branch = replacementBranchName(result.cluster_id);
const areaCapacityBlock = validateActivePrAreaCapacity({ fixArtifact, targetDir, branch });
if (areaCapacityBlock) {
return {
action: "open_fix_pr",
status: "blocked",
branch,
repair_strategy: fixArtifact.repair_strategy,
...areaCapacityBlock,
};
}
run("git", ["fetch", "origin", baseBranch], { cwd: targetDir });
const branchState = checkoutRecoverableReplacementBranch({ targetDir, branch, baseBranch });
if (branchState.resumed) rebaseRecoverableReplacementBranch({ targetDir, branch, baseBranch, fixArtifact });
prepareTargetToolchain(targetDir);
@ -423,13 +435,14 @@ function executeReplacementBranch({ fixArtifact, targetDir, supersedeSources, fa
mode: "replacement",
fallbackReason,
baseBranch,
contributorCredits,
allowExistingChanges: branchState.resumed && branchHasBaseDiff({ targetDir, baseBranch }),
pushCheckpoint: dryRun ? null : () => pushRecoverableBranch({ targetDir, branch }),
});
if (refreshValidatedBranchBase({ targetDir, branch, baseBranch })) {
prep.commit = currentHead(targetDir);
}
const body = replacementPrBody(fixArtifact, fallbackReason);
const body = replacementPrBody(fixArtifact, fallbackReason, contributorCredits);
if (dryRun) {
return {
action: "open_fix_pr",
@ -440,6 +453,7 @@ function executeReplacementBranch({ fixArtifact, targetDir, supersedeSources, fa
checkpoint_commits: prep.checkpoint_commits,
merge_preflight: prep.merge_preflight,
supersede_sources: supersedeSources ? fixArtifact.source_prs ?? [] : [],
contributor_credit: contributorCredits.map(publicContributorCredit),
};
}
@ -468,7 +482,9 @@ function executeReplacementBranch({ fixArtifact, targetDir, supersedeSources, fa
for (const source of supersededSources) {
const parsed = parsePullRequestUrl(source);
if (!parsed || parsed.repo !== result.repo) continue;
supersededSourceActions.push(closeSupersededSourcePr({ source, parsed, replacementPrUrl: prUrl, targetDir }));
supersededSourceActions.push(
closeSupersededSourcePr({ source, parsed, replacementPrUrl: prUrl, targetDir, contributorCredits }),
);
}
}
@ -484,10 +500,11 @@ function executeReplacementBranch({ fixArtifact, targetDir, supersedeSources, fa
review_threads: threadResolution,
superseded_sources: supersededSources,
superseded_source_actions: supersededSourceActions,
contributor_credit: contributorCredits.map(publicContributorCredit),
};
}
function closeSupersededSourcePr({ source, parsed, replacementPrUrl, targetDir }) {
function closeSupersededSourcePr({ source, parsed, replacementPrUrl, targetDir, contributorCredits }) {
const base = { source, pr: `#${parsed.number}`, action: "close_superseded_source" };
const view = fetchSourcePullRequestView({ repo: result.repo, number: parsed.number, targetDir });
if (view.mergedAt || view.state === "MERGED") {
@ -497,12 +514,14 @@ function closeSupersededSourcePr({ source, parsed, replacementPrUrl, targetDir }
return { ...base, status: "skipped", reason: "already closed" };
}
const carriedCredit = sourceCreditLines({ source, contributorCredits });
const comment = [
"ProjectClownfish could not safely update this branch, so it opened a narrow replacement PR instead.",
"Clownfish could not safely update this branch, so it opened a narrow replacement PR instead.",
"",
`Replacement PR: ${replacementPrUrl}`,
`Source PR: ${source}`,
"Contributor credit is preserved in the replacement PR body and changelog plan.",
...carriedCredit,
"Closing this PR to keep review focused on the replacement. The contribution is carried forward, not discarded.",
].join("\n");
run("gh", ["pr", "comment", String(parsed.number), "--repo", result.repo, "--body", comment], {
cwd: targetDir,
@ -514,7 +533,15 @@ function closeSupersededSourcePr({ source, parsed, replacementPrUrl, targetDir }
env: ghEnv(),
encoding: "utf8",
});
if (closed.status === 0) return { ...base, status: "executed", reason: "closed in favor of replacement PR" };
if (closed.status === 0) {
return {
...base,
status: "executed",
reason: "closed in favor of credited replacement PR",
replacement_pr: replacementPrUrl,
contributor_credit: contributorCredits.map(publicContributorCredit),
};
}
const detail = `${closed.stderr ?? ""}\n${closed.stdout ?? ""}`.trim();
if (/already merged|can't be closed because it was already merged/i.test(detail)) {
@ -538,7 +565,7 @@ function ensurePullRequestOpen({ number, targetDir }) {
function fetchSourcePullRequestView({ repo, number, targetDir }) {
return JSON.parse(
run("gh", ["pr", "view", String(number), "--repo", repo, "--json", "state,mergedAt,title,url"], {
run("gh", ["pr", "view", String(number), "--repo", repo, "--json", "author,state,mergedAt,title,url"], {
cwd: targetDir,
env: ghEnv(),
}),
@ -552,6 +579,7 @@ function editValidatePrepareMerge({
mode,
fallbackReason,
baseBranch = DEFAULT_BASE_BRANCH,
contributorCredits = [],
allowExistingChanges = false,
pushCheckpoint = null,
}) {
@ -620,6 +648,7 @@ function editValidatePrepareMerge({
const firstCheckpoint = commitCheckpointIfNeeded({
targetDir,
message: fixArtifact.pr_title,
trailers: mode === "replacement" ? coAuthorTrailers(contributorCredits) : [],
});
if (firstCheckpoint) {
checkpointCommits.push(firstCheckpoint);
@ -635,6 +664,7 @@ function editValidatePrepareMerge({
const checkpoint = commitCheckpointIfNeeded({
targetDir,
message: `fix(clownfish): address review for ${result.cluster_id} (${attempt})`,
trailers: mode === "replacement" ? coAuthorTrailers(contributorCredits) : [],
});
if (checkpoint) {
checkpointCommits.push(checkpoint);
@ -645,6 +675,7 @@ function editValidatePrepareMerge({
const finalCheckpoint = commitCheckpointIfNeeded({
targetDir,
message: `fix(clownfish): finalize ${result.cluster_id}`,
trailers: mode === "replacement" ? coAuthorTrailers(contributorCredits) : [],
});
if (finalCheckpoint) {
checkpointCommits.push(finalCheckpoint);
@ -1240,20 +1271,186 @@ function codexReviewSchemaPath() {
return schemaPath;
}
function replacementPrBody(fixArtifact, fallbackReason) {
function replacementPrBody(fixArtifact, fallbackReason, contributorCredits = []) {
const creditLines = contributorCredits.map((credit) => `- ${publicCreditLabel(credit)} from ${credit.sources.join(", ")}`);
const lines = [
fixArtifact.pr_body.trim(),
"",
"ProjectClownfish replacement details:",
"Clownfish replacement details:",
`- Cluster: ${result.cluster_id}`,
`- Source PRs: ${(fixArtifact.source_prs ?? []).join(", ") || "none"}`,
`- Credit: ${fixArtifact.credit_notes.join("; ")}`,
...(creditLines.length > 0 ? ["", "Carried-forward contributor credit:", ...creditLines] : []),
`- Validation: ${fixArtifact.validation_commands.join("; ")}`,
];
if (fallbackReason) lines.push(`- Repair fallback: ${fallbackReason}`);
return `${lines.join("\n")}\n`;
}
function sourceContributorCredits({ fixArtifact, targetDir }) {
const byLogin = new Map();
for (const source of fixArtifact.source_prs ?? []) {
const parsed = parsePullRequestUrl(source);
if (!parsed || parsed.repo !== result.repo) continue;
const view = fetchSourcePullRequestView({ repo: result.repo, number: parsed.number, targetDir });
const login = String(view.author?.login ?? "").trim();
if (!login || view.author?.is_bot || isBotLogin(login)) continue;
const key = login.toLowerCase();
const existing = byLogin.get(key) ?? {
login,
name: safeTrailerName(login, login),
email: `${login}@users.noreply.github.com`,
sources: [],
};
const user = fetchGitHubUser(login, targetDir);
if (user) {
existing.name = safeTrailerName(user.name || user.login || login, login);
existing.email = `${user.id}+${user.login}@users.noreply.github.com`;
}
existing.sources = uniqueStrings([...existing.sources, parsed.url]);
byLogin.set(key, existing);
}
return [...byLogin.values()];
}
function fetchGitHubUser(login, targetDir) {
try {
const user = JSON.parse(run("gh", ["api", `users/${login}`], { cwd: targetDir, env: ghEnv() }));
if (!user?.id || !user?.login) return null;
return user;
} catch {
return null;
}
}
function coAuthorTrailers(contributorCredits) {
return contributorCredits.map((credit) => `Co-authored-by: ${credit.name} <${credit.email}>`);
}
function publicContributorCredit(credit) {
return {
login: credit.login,
name: credit.name,
sources: credit.sources,
co_authored_by: `Co-authored-by: ${credit.name} <${credit.email}>`,
};
}
function publicCreditLabel(credit) {
return `@${credit.login} (${credit.name || credit.login})`;
}
function safeTrailerName(value, fallback = "Contributor") {
const name = String(value ?? "")
.replace(/[<>\r\n]/g, "")
.trim();
return name || fallback;
}
function isBotLogin(login) {
return /\[bot\]$|bot$/i.test(String(login ?? ""));
}
function sourceCreditLines({ source, contributorCredits }) {
const matching = contributorCredits.filter((credit) => credit.sources.includes(source));
if (matching.length === 0) {
return ["Contributor credit is preserved in the replacement PR body and changelog plan."];
}
return matching.map((credit) => `Credit preserved: @${credit.login} is carried forward as co-author in the replacement PR.`);
}
function validateActivePrAreaCapacity({ fixArtifact, targetDir, branch }) {
if (!Number.isFinite(maxActivePrsPerArea) || maxActivePrsPerArea < 1) return null;
const areas = affectedAreasForFiles(fixArtifact.likely_files ?? []);
if (areas.length === 0) return null;
let activePrs;
try {
activePrs = listOpenClownfishPrAreas({ targetDir }).filter((pull) => pull.branch !== branch);
} catch (error) {
return {
code: "active_area_pr_cap_unverified",
reason: `could not verify active Clownfish PR area capacity: ${compactText(error.message, 500)}`,
areas,
max_active_prs_per_area: maxActivePrsPerArea,
};
}
const blockedAreas = areas
.map((area) => ({
area,
active: activePrs.filter((pull) => pull.areas.includes(area)),
}))
.filter((entry) => entry.active.length >= maxActivePrsPerArea);
if (blockedAreas.length === 0) return null;
const first = blockedAreas[0];
return {
code: "active_area_pr_cap",
reason: `active Clownfish PR cap reached for ${first.area}: ${first.active.length}/${maxActivePrsPerArea} open PRs`,
areas,
max_active_prs_per_area: maxActivePrsPerArea,
active_area_prs: first.active.slice(0, 10).map((pull) => ({
pr: `#${pull.number}`,
url: pull.url,
title: pull.title,
branch: pull.branch,
areas: pull.areas,
})),
};
}
function listOpenClownfishPrAreas({ targetDir }) {
const pulls = JSON.parse(
run(
"gh",
["pr", "list", "--repo", result.repo, "--state", "open", "--limit", "500", "--json", "number,title,url,headRefName,labels"],
{ cwd: targetDir, env: ghEnv() },
),
);
return pulls
.filter((pull) => {
const branch = String(pull.headRefName ?? "");
const labels = (pull.labels ?? []).map((label) => String(label.name ?? label));
return branch.startsWith("clownfish/") || labels.includes("clownfish");
})
.map((pull) => {
const files = fetchPullRequestFilePaths({ targetDir, number: pull.number });
return {
number: pull.number,
title: pull.title,
url: pull.url,
branch: String(pull.headRefName ?? ""),
areas: affectedAreasForFiles(files),
};
});
}
function fetchPullRequestFilePaths({ targetDir, number }) {
const view = JSON.parse(
run("gh", ["pr", "view", String(number), "--repo", result.repo, "--json", "files"], {
cwd: targetDir,
env: ghEnv(),
}),
);
return (view.files ?? []).map((file) => String(file.path ?? "")).filter(Boolean);
}
function affectedAreasForFiles(files) {
return uniqueStrings(files.map(affectedAreaForFile).filter(Boolean));
}
function affectedAreaForFile(file) {
const normalized = String(file ?? "").replaceAll("\\", "/").replace(/^\.\/+/, "");
if (!normalized || normalized.includes("*")) return "";
const parts = normalized.split("/").filter(Boolean);
if (parts.length === 0) return "";
if (["apps", "extensions", "packages"].includes(parts[0]) && parts[1]) return `${parts[0]}/${parts[1]}`;
if (parts[0] === "src" && parts[1]) return `src/${parts[1]}`;
if (parts[0] === "test" || parts[0] === "tests") return parts[0];
if (parts[0] === "docs" || parts[0] === ".github" || parts[0] === "scripts") return parts[0];
return parts[0];
}
function supersededReplacementSources(fixArtifact) {
if (Array.isArray(fixArtifact.supersede_source_prs) && fixArtifact.supersede_source_prs.length > 0) {
return fixArtifact.supersede_source_prs.filter((source) => parsePullRequestUrl(source)?.repo === result.repo);
@ -2087,10 +2284,12 @@ function fetchDeeperHistory({ targetDir, baseBranch }) {
run("git", ["fetch", "origin", `${baseBranch}:refs/remotes/origin/${baseBranch}`], { cwd: targetDir });
}
function commitCheckpointIfNeeded({ targetDir, message }) {
function commitCheckpointIfNeeded({ targetDir, message, trailers = [] }) {
if (!run("git", ["status", "--porcelain"], { cwd: targetDir }).trim()) return "";
run("git", ["add", "--all"], { cwd: targetDir });
run("git", ["commit", "-m", message], { cwd: targetDir });
const args = ["commit", "-m", message];
for (const trailer of uniqueStrings(trailers)) args.push("-m", trailer);
run("git", args, { cwd: targetDir });
return run("git", ["rev-parse", "HEAD"], { cwd: targetDir }).trim();
}

View File

@ -331,6 +331,7 @@ export function validateJob(job) {
"allow_low_signal_pr_close",
"allow_fix_pr",
"allow_merge",
"allow_unmerged_fix_close",
"allow_post_merge_close",
"allow_broad_fix_artifacts",
"require_fix_before_close",