fix: execute credited fix artifacts
This commit is contained in:
parent
fb5d06b2bd
commit
61980c6202
@ -61,6 +61,9 @@ find /tmp/projectclownfish-check-RUN_ID -name result.json -print -quit |
|
||||
|
||||
find /tmp/projectclownfish-check-RUN_ID -name apply-report.json -print -quit |
|
||||
xargs jq '{totals:{executed:([.actions[]? | select(.status=="executed")]|length),blocked:([.actions[]? | select(.status=="blocked")]|length),skipped:([.actions[]? | select(.status=="skipped")]|length),planned:([.actions[]? | select(.status=="planned")]|length)}}'
|
||||
|
||||
find /tmp/projectclownfish-check-RUN_ID -name fix-execution-report.json -print -quit |
|
||||
xargs jq '{status,actions}'
|
||||
```
|
||||
|
||||
If review fails, inspect the failure class before doing anything else:
|
||||
@ -79,6 +82,7 @@ Use repo scripts and prompts as the control plane:
|
||||
- `instructions/low-signal-prs.md`: opt-in manual backlog cleanup policy for random docs churn, blank-template PRs, test-only spam, third-party capability PRs that belong on ClawHub, risky infra drive-bys, and dirty branches.
|
||||
- `scripts/review-results.mjs`: deterministic artifact gate.
|
||||
- `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/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.
|
||||
@ -142,6 +146,7 @@ node scripts/import-ghcrawl-clusters.mjs --from-ghcrawl --limit 40 \
|
||||
--suffix autonomous-smoke \
|
||||
--allow-instant-close \
|
||||
--allow-merge \
|
||||
--allow-fix-pr \
|
||||
--allow-post-merge-close
|
||||
```
|
||||
|
||||
@ -237,7 +242,9 @@ Say "safe to ramp" only when all are true:
|
||||
- no worker result uses `executed`;
|
||||
- no close action targets a closed item;
|
||||
- applicator executed only planned duplicate/superseded/fixed-by-candidate close actions or guarded clean merge actions;
|
||||
- useful contributor PRs were either repaired when maintainer-editable or have a replacement fix artifact with source PR credit before superseded closeout;
|
||||
- `CLOWNFISH_ALLOW_EXECUTE` is back to `0`;
|
||||
- `CLOWNFISH_ALLOW_FIX_PR` is back to `0`;
|
||||
- active runs are expected and on the intended SHA;
|
||||
- artifacts are downloaded or easy to retrieve by run URL.
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ OPENAI_API_KEY=
|
||||
CODEX_API_KEY=
|
||||
CLOWNFISH_ALLOWED_OWNER=openclaw
|
||||
CLOWNFISH_ALLOW_EXECUTE=0
|
||||
CLOWNFISH_ALLOW_FIX_PR=0
|
||||
CLOWNFISH_HYDRATE_CLUSTER_REFS=1
|
||||
CLOWNFISH_HYDRATE_COMMENTS=1
|
||||
CLOWNFISH_MAX_COMMENTS_PER_ITEM=30
|
||||
|
||||
7
.github/workflows/cluster-worker.yml
vendored
7
.github/workflows/cluster-worker.yml
vendored
@ -48,6 +48,7 @@ jobs:
|
||||
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 || '0' }}
|
||||
CLOWNFISH_ALLOW_FIX_PR: ${{ (inputs.mode == 'execute' || inputs.mode == 'autonomous') && vars.CLOWNFISH_ALLOW_FIX_PR || '0' }}
|
||||
CLOWNFISH_HYDRATE_CLUSTER_REFS: ${{ vars.CLOWNFISH_HYDRATE_CLUSTER_REFS || '1' }}
|
||||
CLOWNFISH_HYDRATE_COMMENTS: ${{ vars.CLOWNFISH_HYDRATE_COMMENTS || '1' }}
|
||||
CLOWNFISH_MAX_COMMENTS_PER_ITEM: ${{ vars.CLOWNFISH_MAX_COMMENTS_PER_ITEM || '30' }}
|
||||
@ -121,6 +122,12 @@ jobs:
|
||||
npm run review-results -- .projectclownfish/runs
|
||||
fi
|
||||
|
||||
- name: Execute credited fix artifact
|
||||
if: ${{ (inputs.mode == 'execute' || inputs.mode == 'autonomous') && !inputs.dry_run && vars.CLOWNFISH_ALLOW_FIX_PR == '1' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CLOWNFISH_GH_TOKEN }}
|
||||
run: npm run execute-fix -- "${{ inputs.job }}" --latest
|
||||
|
||||
- name: Apply safe closure actions
|
||||
if: ${{ (inputs.mode == 'execute' || inputs.mode == 'autonomous') && !inputs.dry_run }}
|
||||
env:
|
||||
|
||||
17
README.md
17
README.md
@ -115,10 +115,11 @@ 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. Applies guarded close/comment and explicit merge actions through `scripts/apply-result.mjs`.
|
||||
8. Publishes a sanitized result ledger back to this repo under `results/`, `closed/`, `apply-report.json`, and this README dashboard.
|
||||
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.
|
||||
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/`, `closed/`, `apply-report.json`, and this README dashboard.
|
||||
|
||||
Codex does not receive a GitHub token. The runner preflights GitHub state before model execution, then Codex receives those artifacts and returns JSON only. The applicator re-fetches the target item, checks `updated_at`, blocks unsafe closeouts, writes idempotent close comments, closes supported duplicate/superseded/fixed-by-candidate actions, and can squash-merge explicitly allowed clean PR actions.
|
||||
Codex does not receive a GitHub token during classification. The runner preflights GitHub state before model execution, then Codex receives those artifacts and returns JSON only. When a reviewed fix artifact is executed, Codex gets a temporary target checkout without GitHub credentials; the deterministic executor owns commit, push, PR creation, and source-PR closeout. The applicator re-fetches the target item, checks `updated_at`, blocks unsafe closeouts, writes idempotent close comments, closes supported duplicate/superseded/fixed-by-candidate actions, and can squash-merge explicitly allowed clean PR actions.
|
||||
|
||||
Runs for the same job path and mode are queued instead of running concurrently. The workflow uses Node 24 and `ubuntu-latest` for ClawSweeper parity; other hosted runners are opt-in.
|
||||
|
||||
@ -128,9 +129,10 @@ Full worker prompts, Codex transcripts, and raw artifacts stay in GitHub Actions
|
||||
|
||||
- `plan`: produces recommendations only.
|
||||
- `execute`: can apply reviewed safe close and explicit clean merge actions from structured JSON.
|
||||
- `autonomous`: adds live cluster preflight and fix-artifact generation. It may recommend and drive a canonical fix path, but direct mutation still goes through the applicator.
|
||||
- `autonomous`: adds live cluster preflight and fix-artifact generation. It may recommend and drive a canonical fix path; direct mutation still goes through the fix executor and applicator gates.
|
||||
- `needs_human`: any unclear canonical choice, stale cluster state, failing checks, conflict, broad fix, or independent report should land here.
|
||||
- Automated reviewer feedback must be cleared during autonomous PR work. Greptile, Codex, Asile, CodeRabbit, Copilot, and similar bot comments must be addressed, proven non-actionable, or escalated before any merge or post-merge closeout recommendation.
|
||||
- Repair ladder: make the useful contributor PR mergeable when its branch is maintainer-editable; otherwise replace it with a narrow credited fix PR plan, close/supersede the uneditable PR only after that replacement path is explicit, and carry contributor credit into the PR body and changelog plan.
|
||||
|
||||
## Local Run
|
||||
|
||||
@ -154,11 +156,14 @@ npm run import-low-signal -- --limit 20 --batch-size 5 --mode autonomous --sort
|
||||
|
||||
# Stage the next largest active ghcrawl clusters, skipping already-imported and
|
||||
# security-sensitive clusters by default.
|
||||
npm run import-ghcrawl -- --from-ghcrawl --limit 40 --mode autonomous --suffix autonomous-smoke --allow-instant-close --allow-merge --allow-post-merge-close
|
||||
npm run import-ghcrawl -- --from-ghcrawl --limit 40 --mode autonomous --suffix autonomous-smoke --allow-instant-close --allow-merge --allow-fix-pr --allow-post-merge-close
|
||||
|
||||
# Find failed cluster jobs that have not been superseded by a later success.
|
||||
npm run self-heal
|
||||
|
||||
# Execute a reviewed fix artifact locally. Requires both execution gates and a write token.
|
||||
CLOWNFISH_ALLOW_EXECUTE=1 CLOWNFISH_ALLOW_FIX_PR=1 npm run execute-fix -- jobs/openclaw/cluster-example.md --latest --dry-run
|
||||
|
||||
# Retry failed jobs once. This briefly opens the execution gate, waits for the
|
||||
# dispatched workers to start, records the self-heal ledger, and closes the gate.
|
||||
npm run self-heal -- --execute --open-execute-window --max-jobs 5 --runner ubuntu-latest
|
||||
@ -181,7 +186,7 @@ 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
|
||||
- an execution gate that defaults off
|
||||
- execution gates that default off: `CLOWNFISH_ALLOW_EXECUTE` for all mutations and `CLOWNFISH_ALLOW_FIX_PR` for branch repair/replacement PRs
|
||||
- optional `CLOWNFISH_CODEX_CLI_VERSION` variable to pin and refresh the cached Codex CLI
|
||||
|
||||
Keep exact secret names, token scopes, and execution-window procedures in private operations docs or repository settings notes. Do not put token values or live operational credentials in job files.
|
||||
|
||||
@ -20,8 +20,9 @@
|
||||
6. Require `npm run review-results -- <artifact-dir>` to pass before promotion.
|
||||
7. Change selected jobs to `mode: execute` or `mode: autonomous`.
|
||||
8. Set repo variable `CLOWNFISH_ALLOW_EXECUTE=1` only for the execution window.
|
||||
9. Dispatch execute/autonomous jobs for reviewed clusters only. Workers still return JSON; `apply-result` performs safe GitHub mutations afterward.
|
||||
10. Reset `CLOWNFISH_ALLOW_EXECUTE=0`.
|
||||
9. Set `CLOWNFISH_ALLOW_FIX_PR=1` only when reviewed fix artifacts are allowed to repair branches or open credited replacement PRs.
|
||||
10. Dispatch execute/autonomous jobs for reviewed clusters only. Workers still return JSON; `execute-fix-artifact` owns branch repair/replacement PR creation, and `apply-result` performs remaining safe GitHub mutations afterward.
|
||||
11. Reset `CLOWNFISH_ALLOW_EXECUTE=0` and `CLOWNFISH_ALLOW_FIX_PR=0`.
|
||||
|
||||
## Security Boundary
|
||||
|
||||
@ -78,7 +79,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 and receives no GitHub token. GitHub read access is scoped to deterministic preflight scripts; write access is scoped only 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. 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.
|
||||
|
||||
@ -93,7 +94,7 @@ Minimum useful permissions depend on action tier:
|
||||
- `CLOWNFISH_READ_GH_TOKEN`: metadata, issues read, pull requests read, contents read; do not use a broad PAT here
|
||||
- `CLOWNFISH_GH_TOKEN`: issues write, pull requests write
|
||||
- merge: contents write and pull requests write
|
||||
- fix PRs: contents write
|
||||
- fix PRs: contents write, pull requests write, issues write
|
||||
|
||||
Do not put tokens in job files. Codex receives no GitHub token; the read token is scoped to preflight, and the write token is scoped to the deterministic apply step.
|
||||
|
||||
|
||||
@ -27,4 +27,11 @@ Never close:
|
||||
- assigned work in progress;
|
||||
- contributor PR with useful code that should be merged or credited.
|
||||
|
||||
Useful contributor PR replacement exception:
|
||||
|
||||
- close or supersede the PR only after the run has a concrete replacement fix plan or PR path;
|
||||
- the close comment must say why ProjectClownfish cannot safely update or land the branch;
|
||||
- the comment must name the replacement path and state that the contributor will be credited;
|
||||
- the fix artifact must include the contributor username, original PR URL, validation plan, and changelog attribution when the fix is user-facing.
|
||||
|
||||
Auto-closure payloads must include `target_updated_at`. The applicator will re-fetch live GitHub state and skip the close if the target changed after review.
|
||||
|
||||
@ -29,8 +29,10 @@ For multiple PRs:
|
||||
For fix work:
|
||||
|
||||
- only create a fix PR when the job allows `fix` or `raise_pr`;
|
||||
- first try to make the best useful contributor PR landable when `maintainer_can_modify` is true: address review and bot findings, narrow the diff, rebase, validate, then merge if clean;
|
||||
- if `maintainer_can_modify` is false or the branch cannot be safely repaired, do not force it. Close/supersede only after creating a replacement fix plan that credits the contributor and original PR URL;
|
||||
- keep the patch tiny;
|
||||
- refactor only when it makes the fix narrower or removes review blockers;
|
||||
- inspect review-bot findings before final validation and include the addressed/blocked result in evidence;
|
||||
- run the repo's narrow tests;
|
||||
- include links to the cluster and canonical issue.
|
||||
- include links to the cluster, canonical issue, source PR, and credited author in the replacement PR body and changelog plan.
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"import-low-signal": "node scripts/import-ghcrawl-low-signal-prs.mjs",
|
||||
"worker": "node scripts/run-worker.mjs",
|
||||
"apply-result": "node scripts/apply-result.mjs",
|
||||
"execute-fix": "node scripts/execute-fix-artifact.mjs",
|
||||
"dispatch": "node scripts/dispatch-jobs.mjs",
|
||||
"self-heal": "node scripts/self-heal-failed-runs.mjs",
|
||||
"publish-result": "node scripts/publish-result.mjs",
|
||||
|
||||
@ -23,7 +23,11 @@ Before drive mode:
|
||||
- already merged PR/commit on `main`;
|
||||
- open PR that is mergeable or repairable;
|
||||
- new fix PR needed because the bug is real and no viable PR exists.
|
||||
7. Do not emit closure actions until the canonical path is explicit. If the cluster is over-broad, split it into subfamilies in the action matrix and use `keep_related`/`keep_independent` for clear non-targets instead of making the whole result `needs_human`.
|
||||
7. For each useful open contributor PR, choose the repair path before merge or close:
|
||||
- if `pull_request.maintainer_can_modify` is true and the diff is narrow enough, plan to update that PR branch, address review/bot findings, rebase, run checks, then emit `merge_canonical` only after it is clean;
|
||||
- if `maintainer_can_modify` is false, the branch is unsafe, or the PR contains broad/unrelated churn, do not merge it. Emit a replacement `build_fix_artifact` / `open_fix_pr` plan that preserves the contributor's credit in `credit_notes`, PR body, and changelog plan;
|
||||
- when replacing a useful contributor PR, emit a `close_superseded` comment that says ProjectClownfish cannot safely update that branch, will carry the narrow fix forward separately, and will credit the contributor by username and PR URL.
|
||||
8. Do not emit closure actions until the canonical path is explicit. If the cluster is over-broad, split it into subfamilies in the action matrix and use `keep_related`/`keep_independent` for clear non-targets instead of making the whole result `needs_human`.
|
||||
|
||||
Low-signal PR cleanup:
|
||||
|
||||
@ -47,6 +51,8 @@ Fix artifact actions:
|
||||
|
||||
- If no viable canonical PR exists and the bug still appears real from the artifact, emit `fix_needed` plus `build_fix_artifact` even when the current job cannot open the fix PR. Do not escalate solely because `allow_fix_pr` is false.
|
||||
- The `build_fix_artifact` action must include affected surfaces, likely files, linked issues/PRs, validation commands, changelog requirement, credit notes, and a PR title/body plan.
|
||||
- If replacing a contributor PR, `fix_artifact.credit_notes` must name the original author and PR URL, `pr_body` must explain the borrowed/credited idea, and `changelog_required` should be true when the resulting fix is user-facing.
|
||||
- The fix plan must be narrow: list only the files expected to change, focused tests, review-bot findings to address, and the exact branch/PR that could not be updated if applicable.
|
||||
- If a target checkout is unavailable or unsafe, do not pretend to patch. Return the artifact and mark only implementation as blocked; keep classification decisions non-mutating when possible.
|
||||
|
||||
Merge and post-merge close:
|
||||
|
||||
@ -9,7 +9,7 @@ Security-sensitive clusters are out of scope. If a title, body, label, review, c
|
||||
For each target action, include:
|
||||
|
||||
- `target`: issue/PR ref like `#123`
|
||||
- `action`: one of `keep_canonical`, `keep_related`, `keep_independent`, `keep_closed`, `merge_candidate`, `fix_needed`, `needs_human`, `close_duplicate`, `close_superseded`, `close_fixed_by_candidate`, or `close_low_signal`
|
||||
- `action`: one of `keep_canonical`, `keep_related`, `keep_independent`, `keep_closed`, `merge_candidate`, `merge_canonical`, `fix_needed`, `build_fix_artifact`, `open_fix_pr`, `needs_human`, `close_duplicate`, `close_superseded`, `close_fixed_by_candidate`, or `close_low_signal`
|
||||
- `classification`: one of `canonical`, `duplicate`, `related`, `superseded`, `independent`, `fixed_by_candidate`, `low_signal`, or `needs_human`
|
||||
- `target_kind`: `issue` or `pull_request`
|
||||
- `target_updated_at`: the live GitHub `updatedAt`/`updated_at` value you fetched for the target
|
||||
|
||||
@ -238,6 +238,28 @@
|
||||
},
|
||||
"pr_body": {
|
||||
"type": "string"
|
||||
},
|
||||
"source_prs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"repair_strategy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"repair_contributor_branch",
|
||||
"replace_uneditable_branch",
|
||||
"new_fix_pr",
|
||||
"already_fixed_on_main",
|
||||
"needs_human"
|
||||
]
|
||||
},
|
||||
"branch_update_blockers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,6 +203,8 @@ function applyCloseAction({
|
||||
if (canonical === target || candidateFix === target) {
|
||||
return { ...base, status: "blocked", reason: "target cannot close against itself" };
|
||||
}
|
||||
const replacementCloseoutBlock = validateReplacementCloseout({ result, actionName, target });
|
||||
if (replacementCloseoutBlock) return { ...base, status: "blocked", reason: replacementCloseoutBlock };
|
||||
|
||||
const live = fetchIssue(result.repo, target);
|
||||
const kind = live.pull_request ? "pull_request" : "issue";
|
||||
@ -419,6 +421,15 @@ function validateMergedCandidateFix(repo, candidateFix) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateReplacementCloseout({ result, actionName, target }) {
|
||||
if (!["close_superseded", "close_fixed_by_candidate", "post_merge_close"].includes(actionName)) return "";
|
||||
const fixArtifact = result.fix_artifact;
|
||||
if (fixArtifact?.repair_strategy !== "replace_uneditable_branch") return "";
|
||||
const sourceTargets = new Set((fixArtifact.source_prs ?? []).map((ref) => normalizeIssueRef(ref, result.repo)));
|
||||
if (!sourceTargets.has(target)) return "";
|
||||
return "replacement PR closeout is handled by execute-fix after the replacement branch is pushed";
|
||||
}
|
||||
|
||||
function validateMergeablePullRequest({ pullRequest, view }) {
|
||||
if (pullRequest.state !== "open") return `pull request is ${pullRequest.state}`;
|
||||
if (pullRequest.draft || view.isDraft) return "pull request is draft";
|
||||
|
||||
464
scripts/execute-fix-artifact.mjs
Normal file
464
scripts/execute-fix-artifact.mjs
Normal file
@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { assertAllowedOwner, hasSecuritySignalText, parseArgs, parseJob, repoRoot, validateJob } from "./lib.mjs";
|
||||
|
||||
const FIX_ACTIONS = new Set(["fix_needed", "build_fix_artifact", "open_fix_pr"]);
|
||||
const REPAIR_STRATEGIES = new Set(["repair_contributor_branch", "replace_uneditable_branch", "new_fix_pr"]);
|
||||
const DEFAULT_BASE_BRANCH = "main";
|
||||
|
||||
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_FIX_DRY_RUN === "1");
|
||||
const model = String(args.model ?? process.env.CLOWNFISH_MODEL ?? "gpt-5.4");
|
||||
const codexTimeoutMs = Number(process.env.CLOWNFISH_FIX_CODEX_TIMEOUT_MS ?? 45 * 60 * 1000);
|
||||
|
||||
if (!jobPath) {
|
||||
console.error("usage: node scripts/execute-fix-artifact.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 jobErrors = validateJob(job);
|
||||
if (jobErrors.length > 0) {
|
||||
console.error(jobErrors.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 fix execution: job frontmatter mode is not execute or autonomous");
|
||||
}
|
||||
if (process.env.CLOWNFISH_ALLOW_EXECUTE !== "1") {
|
||||
throw new Error("refusing fix execution: CLOWNFISH_ALLOW_EXECUTE must be 1");
|
||||
}
|
||||
if (process.env.CLOWNFISH_ALLOW_FIX_PR !== "1") {
|
||||
throw new Error("refusing fix execution: CLOWNFISH_ALLOW_FIX_PR must be 1");
|
||||
}
|
||||
if (!job.frontmatter.allowed_actions.includes("fix") || !job.frontmatter.allowed_actions.includes("raise_pr")) {
|
||||
throw new Error("refusing fix execution: job must allow fix and raise_pr");
|
||||
}
|
||||
if ((job.frontmatter.blocked_actions ?? []).includes("fix") || job.frontmatter.allow_fix_pr !== true) {
|
||||
throw new Error("refusing fix execution: fix is blocked by job frontmatter");
|
||||
}
|
||||
|
||||
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 (result.mode !== job.frontmatter.mode) {
|
||||
throw new Error(`result mode ${result.mode} does not match job mode ${job.frontmatter.mode}`);
|
||||
}
|
||||
|
||||
const plannedFixActions = (result.actions ?? []).filter(
|
||||
(action) => FIX_ACTIONS.has(String(action.action ?? "")) && action.status === "planned",
|
||||
);
|
||||
const report = {
|
||||
repo: result.repo,
|
||||
cluster_id: result.cluster_id,
|
||||
dry_run: dryRun,
|
||||
result_path: path.relative(repoRoot(), resultPath),
|
||||
executed_at: new Date().toISOString(),
|
||||
actions: [],
|
||||
};
|
||||
|
||||
if (plannedFixActions.length === 0) {
|
||||
report.status = "skipped";
|
||||
report.reason = "no planned fix actions";
|
||||
writeReport(report, resultPath);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const fixArtifact = validateFixArtifact(result.fix_artifact);
|
||||
if (hasSecuritySignalText(job.raw, result.summary, fixArtifact, plannedFixActions)) {
|
||||
throw new Error("refusing fix execution: security-sensitive signal detected");
|
||||
}
|
||||
|
||||
const workRoot =
|
||||
typeof args["work-dir"] === "string"
|
||||
? path.resolve(args["work-dir"])
|
||||
: fs.mkdtempSync(path.join(os.tmpdir(), "projectclownfish-fix-"));
|
||||
const targetDir =
|
||||
typeof args["target-dir"] === "string"
|
||||
? path.resolve(args["target-dir"])
|
||||
: path.join(workRoot, result.repo.replace("/", "-"));
|
||||
|
||||
ensureTargetCheckout(result.repo, targetDir);
|
||||
setupGitIdentity(targetDir);
|
||||
|
||||
let outcome;
|
||||
if (fixArtifact.repair_strategy === "repair_contributor_branch") {
|
||||
try {
|
||||
outcome = executeRepairBranch({ fixArtifact, targetDir });
|
||||
} catch (error) {
|
||||
report.actions.push({
|
||||
action: "repair_contributor_branch",
|
||||
status: "failed",
|
||||
reason: error.message,
|
||||
});
|
||||
outcome = executeReplacementBranch({ fixArtifact, targetDir, supersedeSources: true, fallbackReason: error.message });
|
||||
}
|
||||
} else {
|
||||
outcome = executeReplacementBranch({
|
||||
fixArtifact,
|
||||
targetDir,
|
||||
supersedeSources: fixArtifact.repair_strategy === "replace_uneditable_branch",
|
||||
});
|
||||
}
|
||||
|
||||
report.status = outcome.status;
|
||||
report.actions.push(outcome);
|
||||
writeReport(report, resultPath);
|
||||
|
||||
function executeRepairBranch({ fixArtifact, targetDir }) {
|
||||
const sourcePr = firstSourcePullRequest(fixArtifact);
|
||||
const pull = fetchPullRequest(sourcePr.number);
|
||||
if (pull.state !== "open") throw new Error(`source PR #${sourcePr.number} is ${pull.state}`);
|
||||
if (pull.maintainer_can_modify !== true) throw new Error(`source PR #${sourcePr.number} has maintainer_can_modify=false`);
|
||||
if (!pull.head?.repo?.full_name || !pull.head?.ref) throw new Error(`source PR #${sourcePr.number} is missing head repo/ref`);
|
||||
|
||||
const branch = safeBranchName(`projectclownfish/repair-${result.cluster_id}-${sourcePr.number}`);
|
||||
run("git", ["fetch", `https://github.com/${pull.head.repo.full_name}.git`, `${pull.head.ref}:${branch}`], { cwd: targetDir });
|
||||
run("git", ["checkout", branch], { cwd: targetDir });
|
||||
|
||||
const commit = editValidateCommit({ fixArtifact, targetDir, branch, mode: "repair" });
|
||||
if (dryRun) {
|
||||
return {
|
||||
action: "repair_contributor_branch",
|
||||
status: "planned",
|
||||
target: sourcePr.url,
|
||||
commit,
|
||||
};
|
||||
}
|
||||
|
||||
ghAuthSetupGit(targetDir);
|
||||
run("git", ["push", `https://github.com/${pull.head.repo.full_name}.git`, `HEAD:${pull.head.ref}`], { cwd: targetDir });
|
||||
const comment = [
|
||||
"ProjectClownfish pushed a narrow repair to this branch so the original contributor path can stay canonical.",
|
||||
"",
|
||||
`Source PR: ${sourcePr.url}`,
|
||||
`Validation: ${fixArtifact.validation_commands.join("; ")}`,
|
||||
"Contributor credit is preserved in the branch history and PR context.",
|
||||
].join("\n");
|
||||
run("gh", ["pr", "comment", String(sourcePr.number), "--repo", result.repo, "--body", comment], { cwd: targetDir, env: ghEnv() });
|
||||
return {
|
||||
action: "repair_contributor_branch",
|
||||
status: "pushed",
|
||||
target: sourcePr.url,
|
||||
head_repo: pull.head.repo.full_name,
|
||||
head_ref: pull.head.ref,
|
||||
commit,
|
||||
};
|
||||
}
|
||||
|
||||
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 branch = safeBranchName(`projectclownfish/${result.cluster_id}-fix`);
|
||||
run("git", ["checkout", "-B", branch, `origin/${baseBranch}`], { cwd: targetDir });
|
||||
|
||||
const commit = editValidateCommit({ fixArtifact, targetDir, branch, mode: "replacement", fallbackReason });
|
||||
const body = replacementPrBody(fixArtifact, fallbackReason);
|
||||
if (dryRun) {
|
||||
return {
|
||||
action: "open_fix_pr",
|
||||
status: "planned",
|
||||
branch,
|
||||
commit,
|
||||
supersede_sources: supersedeSources ? fixArtifact.source_prs ?? [] : [],
|
||||
};
|
||||
}
|
||||
|
||||
ghAuthSetupGit(targetDir);
|
||||
run("git", ["push", "--force-with-lease", "origin", `HEAD:${branch}`], { cwd: targetDir });
|
||||
const bodyPath = path.join(workRoot, "replacement-pr-body.md");
|
||||
fs.writeFileSync(bodyPath, body);
|
||||
const prUrl =
|
||||
findOpenPullRequestForBranch(branch, targetDir) ||
|
||||
run(
|
||||
"gh",
|
||||
["pr", "create", "--repo", result.repo, "--base", baseBranch, "--head", branch, "--title", fixArtifact.pr_title, "--body-file", bodyPath],
|
||||
{ cwd: targetDir, env: ghEnv() },
|
||||
).trim();
|
||||
|
||||
if (supersedeSources) {
|
||||
for (const source of fixArtifact.source_prs ?? []) {
|
||||
const parsed = parsePullRequestUrl(source);
|
||||
if (!parsed || parsed.repo !== result.repo) continue;
|
||||
const comment = [
|
||||
"ProjectClownfish could not safely update this branch, so it opened a narrow replacement PR instead.",
|
||||
"",
|
||||
`Replacement PR: ${prUrl}`,
|
||||
`Source PR: ${source}`,
|
||||
"Contributor credit is preserved in the replacement PR body and changelog plan.",
|
||||
].join("\n");
|
||||
run("gh", ["pr", "comment", String(parsed.number), "--repo", result.repo, "--body", comment], {
|
||||
cwd: targetDir,
|
||||
env: ghEnv(),
|
||||
});
|
||||
run("gh", ["pr", "close", String(parsed.number), "--repo", result.repo], { cwd: targetDir, env: ghEnv() });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: "open_fix_pr",
|
||||
status: "opened",
|
||||
pr_url: prUrl,
|
||||
branch,
|
||||
commit,
|
||||
superseded_sources: supersedeSources ? fixArtifact.source_prs ?? [] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function editValidateCommit({ fixArtifact, targetDir, branch, mode, fallbackReason }) {
|
||||
const prompt = buildFixPrompt({ fixArtifact, branch, mode, fallbackReason });
|
||||
const summaryPath = path.join(workRoot, `${mode}-codex-summary.md`);
|
||||
const codexResult = spawnSync(
|
||||
"codex",
|
||||
[
|
||||
"exec",
|
||||
"--cd",
|
||||
targetDir,
|
||||
"--model",
|
||||
model,
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"-c",
|
||||
'approval_policy="never"',
|
||||
"--output-last-message",
|
||||
summaryPath,
|
||||
"--ephemeral",
|
||||
"--json",
|
||||
"-",
|
||||
],
|
||||
{
|
||||
cwd: targetDir,
|
||||
input: prompt,
|
||||
encoding: "utf8",
|
||||
env: codexEnv(),
|
||||
timeout: codexTimeoutMs,
|
||||
},
|
||||
);
|
||||
fs.writeFileSync(path.join(workRoot, `${mode}-codex.jsonl`), codexResult.stdout ?? "");
|
||||
if (codexResult.stderr) fs.writeFileSync(path.join(workRoot, `${mode}-codex.stderr.log`), codexResult.stderr);
|
||||
if (codexResult.error?.code === "ETIMEDOUT") {
|
||||
throw new Error(`Codex fix worker timed out after ${codexTimeoutMs}ms`);
|
||||
}
|
||||
if (codexResult.status !== 0) {
|
||||
throw new Error(codexResult.stderr || codexResult.stdout || "Codex fix worker failed");
|
||||
}
|
||||
|
||||
const status = run("git", ["status", "--porcelain"], { cwd: targetDir }).trim();
|
||||
if (!status) throw new Error("Codex produced no target repo changes");
|
||||
|
||||
runAllowedValidationCommands(fixArtifact.validation_commands, targetDir);
|
||||
run("git", ["diff", "--check"], { cwd: targetDir });
|
||||
run("git", ["add", "--all"], { cwd: targetDir });
|
||||
run("git", ["commit", "-m", fixArtifact.pr_title], { cwd: targetDir });
|
||||
return run("git", ["rev-parse", "HEAD"], { cwd: targetDir }).trim();
|
||||
}
|
||||
|
||||
function buildFixPrompt({ fixArtifact, branch, mode, fallbackReason }) {
|
||||
return [
|
||||
"You are editing the target repository for ProjectClownfish.",
|
||||
"",
|
||||
"Rules:",
|
||||
"- make the narrowest code change that satisfies the fix artifact;",
|
||||
"- stay inside likely_files unless the repo proves a nearby support file/test is required;",
|
||||
"- preserve contributor credit in changelog/docs when the fix is user-facing;",
|
||||
"- address review-bot concerns named in the artifact;",
|
||||
"- do not commit, push, open PRs, close PRs, or call gh;",
|
||||
"- do not touch security-sensitive code unless the artifact explicitly proves this is non-security work.",
|
||||
"",
|
||||
`Mode: ${mode}`,
|
||||
`Branch: ${branch}`,
|
||||
fallbackReason ? `Fallback reason: ${fallbackReason}` : "",
|
||||
"",
|
||||
"Fix artifact:",
|
||||
"```json",
|
||||
JSON.stringify(fixArtifact, null, 2),
|
||||
"```",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function replacementPrBody(fixArtifact, fallbackReason) {
|
||||
const lines = [
|
||||
fixArtifact.pr_body.trim(),
|
||||
"",
|
||||
"ProjectClownfish replacement details:",
|
||||
`- Cluster: ${result.cluster_id}`,
|
||||
`- Source PRs: ${(fixArtifact.source_prs ?? []).join(", ") || "none"}`,
|
||||
`- Credit: ${fixArtifact.credit_notes.join("; ")}`,
|
||||
`- Validation: ${fixArtifact.validation_commands.join("; ")}`,
|
||||
];
|
||||
if (fallbackReason) lines.push(`- Repair fallback: ${fallbackReason}`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function validateFixArtifact(fixArtifact) {
|
||||
if (!fixArtifact || typeof fixArtifact !== "object") {
|
||||
throw new Error("fix execution requires fix_artifact");
|
||||
}
|
||||
for (const key of ["summary", "pr_title", "pr_body"]) {
|
||||
if (typeof fixArtifact[key] !== "string" || !fixArtifact[key].trim()) {
|
||||
throw new Error(`fix_artifact.${key} is required`);
|
||||
}
|
||||
}
|
||||
for (const key of ["affected_surfaces", "likely_files", "linked_refs", "validation_commands", "credit_notes"]) {
|
||||
if (!Array.isArray(fixArtifact[key]) || fixArtifact[key].length === 0) {
|
||||
throw new Error(`fix_artifact.${key} must be a non-empty list`);
|
||||
}
|
||||
}
|
||||
if (typeof fixArtifact.changelog_required !== "boolean") {
|
||||
throw new Error("fix_artifact.changelog_required must be boolean");
|
||||
}
|
||||
if (!REPAIR_STRATEGIES.has(fixArtifact.repair_strategy)) {
|
||||
throw new Error("fix_artifact.repair_strategy is not executable");
|
||||
}
|
||||
if (fixArtifact.repair_strategy !== "new_fix_pr" && (!Array.isArray(fixArtifact.source_prs) || fixArtifact.source_prs.length === 0)) {
|
||||
throw new Error("repair/replacement fix_artifact must list source_prs");
|
||||
}
|
||||
return fixArtifact;
|
||||
}
|
||||
|
||||
function ensureTargetCheckout(repo, targetDir) {
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
||||
run("gh", ["repo", "clone", repo, targetDir, "--", "--depth=1"], { cwd: repoRoot(), env: ghEnv() });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(path.join(targetDir, ".git"))) {
|
||||
throw new Error(`target dir is not a git checkout: ${targetDir}`);
|
||||
}
|
||||
const status = run("git", ["status", "--porcelain"], { cwd: targetDir }).trim();
|
||||
if (status) throw new Error(`target checkout has uncommitted changes: ${targetDir}`);
|
||||
}
|
||||
|
||||
function setupGitIdentity(cwd) {
|
||||
run("git", ["config", "user.name", process.env.CLOWNFISH_GIT_USER_NAME ?? "projectclownfish"], { cwd });
|
||||
run("git", ["config", "user.email", process.env.CLOWNFISH_GIT_USER_EMAIL ?? "projectclownfish@users.noreply.github.com"], { cwd });
|
||||
}
|
||||
|
||||
function runAllowedValidationCommands(commands, cwd) {
|
||||
for (const command of commands) {
|
||||
const parts = parseAllowedValidationCommand(command);
|
||||
run(parts[0], parts.slice(1), { cwd });
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedValidationCommand(command) {
|
||||
const text = String(command ?? "").trim();
|
||||
if (!text) throw new Error("empty validation command");
|
||||
if (/[`$;&|<>()[\]{}*?~]/.test(text)) {
|
||||
throw new Error(`unsafe validation command: ${text}`);
|
||||
}
|
||||
const parts = text.split(/\s+/);
|
||||
if (!["pnpm", "npm", "node", "git"].includes(parts[0])) {
|
||||
throw new Error(`unsupported validation command: ${text}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function firstSourcePullRequest(fixArtifact) {
|
||||
for (const source of fixArtifact.source_prs ?? []) {
|
||||
const parsed = parsePullRequestUrl(source);
|
||||
if (parsed && parsed.repo === result.repo) return parsed;
|
||||
}
|
||||
throw new Error("fix_artifact.source_prs must include a source PR in the target repo");
|
||||
}
|
||||
|
||||
function parsePullRequestUrl(value) {
|
||||
const match = String(value ?? "").match(/^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)/);
|
||||
if (!match) return null;
|
||||
return { repo: match[1], number: Number(match[2]), url: `https://github.com/${match[1]}/pull/${match[2]}` };
|
||||
}
|
||||
|
||||
function fetchPullRequest(number) {
|
||||
return JSON.parse(run("gh", ["api", `repos/${result.repo}/pulls/${number}`], { cwd: repoRoot(), env: ghEnv() }));
|
||||
}
|
||||
|
||||
function findOpenPullRequestForBranch(branch, cwd) {
|
||||
return run(
|
||||
"gh",
|
||||
["pr", "list", "--repo", result.repo, "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url // \"\""],
|
||||
{ cwd, env: ghEnv() },
|
||||
).trim();
|
||||
}
|
||||
|
||||
function safeBranchName(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9/_-]+/g, "-")
|
||||
.replace(/\/+/g, "/")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
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 writeReport(report, resultPath) {
|
||||
const reportPath =
|
||||
typeof args.report === "string"
|
||||
? path.resolve(args.report)
|
||||
: path.join(path.dirname(resultPath), "fix-execution-report.json");
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
function ghAuthSetupGit(cwd) {
|
||||
run("gh", ["auth", "setup-git"], { cwd, env: ghEnv() });
|
||||
}
|
||||
|
||||
function ghEnv() {
|
||||
return { ...process.env, GH_TOKEN: process.env.CLOWNFISH_GH_TOKEN || process.env.GH_TOKEN };
|
||||
}
|
||||
|
||||
function codexEnv() {
|
||||
const env = { ...process.env };
|
||||
delete env.GH_TOKEN;
|
||||
delete env.GITHUB_TOKEN;
|
||||
delete env.CLOWNFISH_GH_TOKEN;
|
||||
delete env.CLOWNFISH_READ_GH_TOKEN;
|
||||
delete env.CLOWNFISH_CODEX_GH_TOKEN;
|
||||
return env;
|
||||
}
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
const child = spawnSync(command, commandArgs, {
|
||||
cwd: options.cwd,
|
||||
env: options.env ?? process.env,
|
||||
input: options.input,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (child.status !== 0) {
|
||||
const detail = child.stderr || child.stdout || `${command} exited ${child.status}`;
|
||||
throw new Error(detail.trim());
|
||||
}
|
||||
return child.stdout ?? "";
|
||||
}
|
||||
@ -13,6 +13,7 @@ const mode = String(args.mode ?? "plan");
|
||||
const suffix = typeof args.suffix === "string" ? args.suffix : "";
|
||||
const allowInstantClose = Boolean(args["allow-instant-close"]);
|
||||
const allowMerge = Boolean(args["allow-merge"]);
|
||||
const allowFixPr = Boolean(args["allow-fix-pr"]);
|
||||
const allowPostMergeClose = Boolean(args["allow-post-merge-close"] || allowMerge);
|
||||
const skipExisting = args["skip-existing"] !== "false";
|
||||
const skipSecurity = args["include-security"] !== true && args["skip-security"] !== "false";
|
||||
@ -26,7 +27,7 @@ if (clusterIds.length === 0 && fromGhcrawl) {
|
||||
}
|
||||
|
||||
if (clusterIds.length === 0) {
|
||||
console.error("usage: node scripts/import-ghcrawl-clusters.mjs <cluster-id> [...] [--from-ghcrawl] [--limit N] [--repo owner/repo] [--db path] [--out dir] [--mode plan|autonomous] [--suffix name] [--allow-instant-close] [--allow-merge] [--allow-post-merge-close]");
|
||||
console.error("usage: node scripts/import-ghcrawl-clusters.mjs <cluster-id> [...] [--from-ghcrawl] [--limit N] [--repo owner/repo] [--db path] [--out dir] [--mode plan|autonomous] [--suffix name] [--allow-instant-close] [--allow-merge] [--allow-fix-pr] [--allow-post-merge-close]");
|
||||
process.exit(2);
|
||||
}
|
||||
if (!["plan", "execute", "autonomous"].includes(mode)) {
|
||||
@ -111,11 +112,12 @@ for (const clusterId of clusterIds) {
|
||||
" - label",
|
||||
" - close",
|
||||
...(allowMerge ? [" - merge"] : []),
|
||||
...(allowFixPr ? [" - fix", " - raise_pr"] : []),
|
||||
"blocked_actions:",
|
||||
" - force_push",
|
||||
" - bypass_checks",
|
||||
...(allowMerge ? [] : [" - merge"]),
|
||||
" - fix",
|
||||
...(allowFixPr ? [] : [" - fix"]),
|
||||
"require_human_for:",
|
||||
" - security_sensitive",
|
||||
" - failing_checks",
|
||||
@ -133,7 +135,7 @@ for (const clusterId of clusterIds) {
|
||||
...(mode === "autonomous" || mode === "execute"
|
||||
? [
|
||||
`allow_instant_close: ${allowInstantClose ? "true" : "false"}`,
|
||||
"allow_fix_pr: false",
|
||||
`allow_fix_pr: ${allowFixPr ? "true" : "false"}`,
|
||||
`allow_merge: ${allowMerge ? "true" : "false"}`,
|
||||
`allow_post_merge_close: ${allowPostMergeClose ? "true" : "false"}`,
|
||||
]
|
||||
|
||||
@ -199,7 +199,9 @@ function hydrateItem(repo, number) {
|
||||
base_ref: pullRequest.base?.ref,
|
||||
head_ref: pullRequest.head?.ref,
|
||||
head_repo: pullRequest.head?.repo?.full_name,
|
||||
head_repo_owner: pullRequest.head?.repo?.owner?.login,
|
||||
head_sha: pullRequest.head?.sha,
|
||||
maintainer_can_modify: pullRequest.maintainer_can_modify,
|
||||
requested_reviewers: (pullRequest.requested_reviewers ?? []).map((reviewer) => reviewer.login).filter(Boolean),
|
||||
requested_teams: (pullRequest.requested_teams ?? []).map((team) => team.slug ?? team.name).filter(Boolean),
|
||||
additions: pullRequest.additions,
|
||||
@ -282,7 +284,9 @@ function summarizeItem(item, job) {
|
||||
base_ref: item.pull_request.base_ref,
|
||||
head_ref: item.pull_request.head_ref,
|
||||
head_repo: item.pull_request.head_repo,
|
||||
head_repo_owner: item.pull_request.head_repo_owner,
|
||||
head_sha: item.pull_request.head_sha,
|
||||
maintainer_can_modify: item.pull_request.maintainer_can_modify,
|
||||
requested_reviewers: item.pull_request.requested_reviewers,
|
||||
requested_teams: item.pull_request.requested_teams,
|
||||
changed_files: item.pull_request.changed_files,
|
||||
@ -345,7 +349,7 @@ function buildFixArtifact(plan, job) {
|
||||
: "Disabled by job frontmatter.",
|
||||
canonical_fix:
|
||||
job.frontmatter.allow_fix_pr === true
|
||||
? "If no viable canonical PR exists and the bug still reproduces, emit fix_needed plus a build_fix_artifact action with files, tests, changelog, and PR plan."
|
||||
? "If no viable canonical PR exists, first repair a useful contributor PR when maintainer_can_modify is true. If it is false or unsafe, emit fix_needed plus build_fix_artifact/open_fix_pr with narrow files, tests, changelog, and credit plan."
|
||||
: "Worker may identify canonical fixes but must not plan a fix PR.",
|
||||
merge:
|
||||
job.frontmatter.allow_merge === true
|
||||
@ -365,6 +369,7 @@ function buildFixArtifact(plan, job) {
|
||||
"address each actionable review-bot finding or mark the item needs_human with the unresolved blocker",
|
||||
"show canonical URL or explain needs_human",
|
||||
"include targeted tests and changelog plan for fix artifacts",
|
||||
"if replacing a contributor PR, include source PR credit and the exact close comment that says ProjectClownfish will preserve attribution",
|
||||
"include full GitHub URLs in closure rationale",
|
||||
],
|
||||
};
|
||||
|
||||
@ -12,6 +12,13 @@ const CLOSE_ACTIONS = new Set([
|
||||
"post_merge_close",
|
||||
]);
|
||||
const MERGE_ACTIONS = new Set(["merge_candidate", "merge_canonical"]);
|
||||
const FIX_REPAIR_STRATEGIES = new Set([
|
||||
"repair_contributor_branch",
|
||||
"replace_uneditable_branch",
|
||||
"new_fix_pr",
|
||||
"already_fixed_on_main",
|
||||
"needs_human",
|
||||
]);
|
||||
const MUTATING_ACTIONS = new Set([
|
||||
"close",
|
||||
"close_duplicate",
|
||||
@ -81,13 +88,18 @@ function reviewResult(resultPath) {
|
||||
if (!result.repo) failures.push("result.repo is required");
|
||||
if (!result.cluster_id) failures.push("result.cluster_id is required");
|
||||
if (!result.mode) failures.push("result.mode is required");
|
||||
if (!plan) failures.push("missing cluster-plan.json preflight artifact");
|
||||
const actions = Array.isArray(result.actions) ? result.actions : [];
|
||||
if (!plan && actions.length > 0) {
|
||||
failures.push("missing cluster-plan.json preflight artifact");
|
||||
} else if (!plan) {
|
||||
warnings.push("missing cluster-plan.json preflight artifact for actionless result");
|
||||
}
|
||||
if (result.status === "executed") {
|
||||
failures.push("worker result status must not be executed; only the applicator records execution");
|
||||
}
|
||||
|
||||
const actions = Array.isArray(result.actions) ? result.actions : [];
|
||||
const closeActions = [];
|
||||
const fixActions = [];
|
||||
for (const action of actions) {
|
||||
const name = String(action.action ?? "");
|
||||
actionCounts[name] = (actionCounts[name] ?? 0) + 1;
|
||||
@ -135,6 +147,15 @@ function reviewResult(resultPath) {
|
||||
} else if (!canonicalRef && !candidateRef) {
|
||||
failures.push(`${target} close action missing canonical/duplicate/candidate target`);
|
||||
}
|
||||
if (
|
||||
item?.kind === "pull_request" &&
|
||||
["close_superseded", "close_fixed_by_candidate", "post_merge_close"].includes(name)
|
||||
) {
|
||||
const comment = String(action.comment ?? "");
|
||||
if (!/\bcredit|attribut|thanks @|thank you @|source PR\b/i.test(comment)) {
|
||||
failures.push(`${target} PR closeout comment must preserve contributor credit`);
|
||||
}
|
||||
}
|
||||
if (canonicalRef) {
|
||||
const canonicalItem = itemByRef.get(canonicalRef);
|
||||
if (!canonicalItem) failures.push(`${target} close action canonical ${canonicalRef} missing preflight item`);
|
||||
@ -146,6 +167,9 @@ function reviewResult(resultPath) {
|
||||
if (candidateRef === normalizeRef(target)) failures.push(`${target} close action candidate points at itself`);
|
||||
}
|
||||
}
|
||||
if (["fix_needed", "build_fix_artifact", "open_fix_pr"].includes(name)) {
|
||||
fixActions.push(action);
|
||||
}
|
||||
if (MERGE_ACTIONS.has(name)) {
|
||||
if (!item) failures.push(`${target} merge action missing preflight item`);
|
||||
if (item && item.state !== "open") failures.push(`${target} merge action targets ${item.state} item`);
|
||||
@ -155,6 +179,10 @@ function reviewResult(resultPath) {
|
||||
}
|
||||
}
|
||||
|
||||
if (fixActions.length > 0) {
|
||||
validateFixArtifact(result.fix_artifact, failures);
|
||||
}
|
||||
|
||||
if (result.canonical) {
|
||||
const canonicalRef = normalizeRef(result.canonical);
|
||||
const canonical = itemByRef.get(canonicalRef);
|
||||
@ -189,6 +217,41 @@ function reviewResult(resultPath) {
|
||||
};
|
||||
}
|
||||
|
||||
function validateFixArtifact(fixArtifact, failures) {
|
||||
if (!fixArtifact || typeof fixArtifact !== "object") {
|
||||
failures.push("fix action requires fix_artifact");
|
||||
return;
|
||||
}
|
||||
for (const key of ["summary", "pr_title", "pr_body"]) {
|
||||
if (typeof fixArtifact[key] !== "string" || fixArtifact[key].trim() === "") {
|
||||
failures.push(`fix_artifact.${key} is required`);
|
||||
}
|
||||
}
|
||||
for (const key of ["affected_surfaces", "likely_files", "linked_refs", "validation_commands", "credit_notes"]) {
|
||||
if (!Array.isArray(fixArtifact[key]) || fixArtifact[key].length === 0) {
|
||||
failures.push(`fix_artifact.${key} must be a non-empty list`);
|
||||
}
|
||||
}
|
||||
if (typeof fixArtifact.changelog_required !== "boolean") {
|
||||
failures.push("fix_artifact.changelog_required must be boolean");
|
||||
}
|
||||
if (!FIX_REPAIR_STRATEGIES.has(fixArtifact.repair_strategy)) {
|
||||
failures.push("fix_artifact.repair_strategy is required");
|
||||
}
|
||||
if (fixArtifact.repair_strategy === "replace_uneditable_branch") {
|
||||
if (!Array.isArray(fixArtifact.source_prs) || fixArtifact.source_prs.length === 0) {
|
||||
failures.push("replacement fix artifact must list source_prs");
|
||||
}
|
||||
if (!Array.isArray(fixArtifact.branch_update_blockers) || fixArtifact.branch_update_blockers.length === 0) {
|
||||
failures.push("replacement fix artifact must list branch_update_blockers");
|
||||
}
|
||||
const creditText = [...(fixArtifact.credit_notes ?? []), fixArtifact.pr_body ?? ""].join("\n");
|
||||
if (!/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/.test(creditText)) {
|
||||
failures.push("replacement fix artifact credit must include original PR URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readSiblingJson(runDir, filename) {
|
||||
const direct = path.join(runDir, filename);
|
||||
if (fs.existsSync(direct)) return JSON.parse(fs.readFileSync(direct, "utf8"));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user