evalstate/diffusers-pr-api
0
1from __future__ import annotations2 3import json4import shutil5import subprocess6import tempfile7from datetime import UTC, datetime8from pathlib import Path, PurePosixPath9from typing import Any10 11from slop_farmer.config import RepoRef12from slop_farmer.data.parquet_io import read_json, write_json, write_text13from slop_farmer.reports.canonical_duplicate_pr import prepare_publish_artifacts, stage_run_bundle14from slop_farmer.reports.duplicate_prs import (15 DEFAULT_DUPLICATE_PR_MODEL,16 load_duplicate_pr_bundle,17 select_mergeable_duplicate_pr_cluster,18)19 20# Navigation:21# - run_duplicate_pr_merge(): end-to-end orchestration entrypoint22# - validate_codex_result(): structured result checks23# - git/gh/codex helpers24# - file-policy helpers25 26DEFAULT_RUNS_DIR = Path("runs/duplicate_prs")27DEFAULT_FILE_POLICY = "pure-loc"28FILE_POLICY_CHOICES = ("pure-loc", "allow-docs", "allow-any")29CODE_FILE_SUFFIXES = {30 ".c",31 ".cc",32 ".cpp",33 ".go",34 ".h",35 ".hpp",36 ".java",37 ".js",38 ".jsx",39 ".kt",40 ".m",41 ".mm",42 ".php",43 ".py",44 ".rb",45 ".rs",46 ".scala",47 ".sh",48 ".swift",49 ".ts",50 ".tsx",51}52DOC_FILE_SUFFIXES = {".md", ".mdx", ".rst", ".txt"}53DOC_DIRECTORY_NAMES = {"doc", "docs"}54DOC_FILE_PREFIXES = ("changelog", "readme", "news")55TEST_DIRECTORY_NAMES = {"test", "tests"}56 57 58# Merge orchestration59 60 61def run_duplicate_pr_merge(62 *,63 report_path: Path | None,64 snapshot_dir: Path | None,65 repo_dir: Path,66 cluster_id: str | None,67 fork_owner: str | None,68 fork_repo: str | None = None,69 upstream_repo: str | None = None,70 upstream_remote: str = "origin",71 fork_remote: str = "fork",72 file_policy: str = DEFAULT_FILE_POLICY,73 model: str = DEFAULT_DUPLICATE_PR_MODEL,74 runs_dir: Path = DEFAULT_RUNS_DIR,75) -> dict[str, Any]:76 if file_policy not in FILE_POLICY_CHOICES:77 raise ValueError(78 f"Unsupported file policy {file_policy!r}. "79 f"Expected one of: {', '.join(FILE_POLICY_CHOICES)}."80 )81 bundle = load_duplicate_pr_bundle(82 report_path=report_path,83 snapshot_dir=snapshot_dir,84 model=model,85 )86 effective_upstream_repo = _normalize_repo_slug(upstream_repo or bundle.repo)87 selected_cluster = select_mergeable_duplicate_pr_cluster(88 bundle,89 cluster_id=cluster_id,90 model=model,91 )92 93 _require_command("git")94 _require_command("gh")95 _require_command("codex")96 97 authenticated_user = _resolve_authenticated_github_user()98 fork_target = _resolve_fork_target(99 upstream_repo=effective_upstream_repo,100 fork_repo=fork_repo,101 fork_owner=fork_owner,102 authenticated_user=authenticated_user,103 )104 effective_fork_owner = fork_target.owner105 effective_fork_repo = fork_target.slug106 107 resolved_repo_dir = repo_dir.resolve()108 _validate_repo_checkout(109 resolved_repo_dir,110 expected_repo=effective_upstream_repo,111 remote_name=upstream_remote,112 )113 default_branch = _resolve_default_branch(effective_upstream_repo)114 115 run_dir = _create_run_dir(runs_dir)116 manifest = stage_run_bundle(117 bundle.report_path,118 run_dir,119 selected_cluster=selected_cluster,120 max_clusters=1,121 prompt_repo=effective_upstream_repo,122 prompt_default_branch=default_branch,123 prompt_file_policy_instruction=_file_policy_instruction(file_policy),124 )125 manifest_path = run_dir / "run-manifest.json"126 127 run_stamp = _utc_stamp()128 branch_name = f"codex/{selected_cluster['cluster_id']}-{run_stamp}"129 worktree_dir = run_dir / "worktree"130 131 _create_worktree(132 repo_dir=resolved_repo_dir,133 worktree_dir=worktree_dir,134 branch_name=branch_name,135 default_branch=default_branch,136 upstream_remote=upstream_remote,137 )138 _update_manifest(139 manifest_path,140 {141 "upstream_repo": effective_upstream_repo,142 "upstream_remote": upstream_remote,143 "default_branch": default_branch,144 "branch_name": branch_name,145 "worktree_dir": str(worktree_dir.resolve()),146 "fork_owner": effective_fork_owner,147 "fork_repo": effective_fork_repo,148 "fork_remote": fork_remote,149 "file_policy": file_policy,150 },151 )152 153 artifacts = manifest["artifacts"]154 result_path = Path(artifacts["result_path"])155 _run_codex_exec(156 worktree_dir=worktree_dir,157 run_dir=run_dir,158 prompt_path=Path(artifacts["prompt_path"]),159 schema_path=Path(artifacts["schema_path"]),160 result_path=result_path,161 )162 163 result = validate_codex_result(manifest_path, result_path)164 changed_paths = _validate_synthesized_branch(165 worktree_dir=worktree_dir,166 upstream_remote=upstream_remote,167 default_branch=default_branch,168 commit_message=result["commit_message"],169 file_policy=file_policy,170 )171 publish_metadata = prepare_publish_artifacts(manifest_path, result_path)172 173 fork_repo = _ensure_fork_repo(174 upstream_repo=effective_upstream_repo,175 fork_repo=effective_fork_repo,176 authenticated_user=authenticated_user,177 )178 _ensure_fork_remote(worktree_dir=worktree_dir, fork_repo=fork_repo, remote_name=fork_remote)179 _push_branch(worktree_dir=worktree_dir, branch_name=branch_name, remote_name=fork_remote)180 pr_url = _create_pull_request(181 upstream_repo=effective_upstream_repo,182 default_branch=default_branch,183 fork_owner=effective_fork_owner,184 branch_name=branch_name,185 title=publish_metadata["pr_title"],186 body_path=Path(publish_metadata["pr_body_path"]),187 )188 189 pr_url_path = Path(artifacts["pr_url_path"])190 write_text(pr_url.rstrip() + "\n", pr_url_path)191 _update_manifest(192 manifest_path,193 {194 "changed_paths": changed_paths,195 "pr_url": pr_url,196 },197 )198 199 publish_metadata_path = Path(artifacts["publish_metadata_path"])200 publish_metadata["pr_url"] = pr_url201 publish_metadata["changed_paths"] = changed_paths202 write_json(publish_metadata, publish_metadata_path)203 204 return {205 "cluster_id": selected_cluster["cluster_id"],206 "repo": effective_upstream_repo,207 "report_path": str(bundle.report_path),208 "run_dir": str(run_dir.resolve()),209 "worktree_dir": str(worktree_dir.resolve()),210 "branch_name": branch_name,211 "fork_repo": fork_repo,212 "fork_remote": fork_remote,213 "upstream_remote": upstream_remote,214 "file_policy": file_policy,215 "pr_url": pr_url,216 "changed_paths": changed_paths,217 }218 219 220def validate_codex_result(manifest_path: Path, result_path: Path) -> dict[str, Any]:221 manifest = read_json(manifest_path.resolve())222 result = json.loads(result_path.resolve().read_text(encoding="utf-8"))223 selected_cluster = manifest["selected_cluster"]224 225 if result.get("status") != "success":226 summary = str(result.get("summary") or "").strip()227 raise ValueError(228 "Codex did not synthesize a valid canonical PR." + (f" {summary}" if summary else "")229 )230 if result.get("cluster_id") != selected_cluster["cluster_id"]:231 raise ValueError("Codex result cluster_id does not match the selected cluster.")232 233 expected_source_pr_numbers = _ordered_ints(selected_cluster["source_pr_numbers"])234 actual_source_pr_numbers = _normalize_result_source_pr_numbers(235 expected_source_pr_numbers=expected_source_pr_numbers,236 raw_source_pr_numbers=result.get("source_pr_numbers"),237 )238 239 tests_run = [240 str(value).strip() for value in result.get("tests_run") or [] if str(value).strip()241 ]242 if not tests_run:243 raise ValueError("Codex result did not include any executed validation commands.")244 245 for field in ("commit_message", "pr_title", "summary"):246 if not str(result.get(field) or "").strip():247 raise ValueError(f"Codex result did not provide a {field.replace('_', ' ')}.")248 249 normalized = dict(result)250 normalized["source_pr_numbers"] = actual_source_pr_numbers251 normalized["tests_run"] = tests_run252 return normalized253 254 255# GitHub / git / Codex helpers256 257 258def _require_command(command_name: str) -> None:259 if shutil.which(command_name):260 return261 raise RuntimeError(f"Missing required command: {command_name}")262 263 264def _resolve_authenticated_github_user() -> str:265 try:266 _run_checked(["gh", "auth", "status"])267 except RuntimeError as exc:268 raise RuntimeError(269 "GitHub CLI authentication is invalid. Run `gh auth login` and retry."270 ) from exc271 login = _run_stdout(["gh", "api", "user", "--jq", ".login"]).strip()272 if not login:273 raise RuntimeError("Could not resolve the authenticated GitHub user from `gh api user`.")274 return login275 276 277def _normalize_repo_slug(raw: str) -> str:278 return RepoRef.parse(raw).slug279 280 281def _resolve_fork_target(282 *,283 upstream_repo: str,284 fork_repo: str | None,285 fork_owner: str | None,286 authenticated_user: str,287) -> RepoRef:288 if fork_repo is not None:289 return RepoRef.parse(fork_repo.strip())290 owner = (fork_owner or authenticated_user).strip()291 if not owner:292 raise RuntimeError("Could not resolve the GitHub fork owner.")293 upstream = RepoRef.parse(upstream_repo)294 return RepoRef(owner=owner, name=upstream.name)295 296 297def _validate_repo_checkout(repo_dir: Path, *, expected_repo: str, remote_name: str) -> None:298 if not repo_dir.exists():299 raise RuntimeError(f"Missing repo checkout: {repo_dir}")300 remote_url = _run_stdout(["git", "-C", str(repo_dir), "remote", "get-url", remote_name]).strip()301 actual_repo = _repo_slug_from_remote_url(remote_url)302 if actual_repo != expected_repo:303 raise RuntimeError(304 f"`--repo-dir` remote {remote_name!r} must point at {expected_repo}, "305 f"but resolves to {actual_repo or remote_url!r}."306 )307 308 309def _resolve_default_branch(repo: str) -> str:310 default_branch = _run_stdout(311 [312 "gh",313 "repo",314 "view",315 repo,316 "--json",317 "defaultBranchRef",318 "--jq",319 ".defaultBranchRef.name",320 ]321 ).strip()322 if not default_branch:323 raise RuntimeError(f"Could not resolve the default branch for {repo}.")324 return default_branch325 326 327def _create_run_dir(runs_dir: Path) -> Path:328 base_dir = runs_dir.resolve()329 base_dir.mkdir(parents=True, exist_ok=True)330 return Path(tempfile.mkdtemp(prefix=f"{_utc_stamp()}.", dir=base_dir))331 332 333def _create_worktree(334 *,335 repo_dir: Path,336 worktree_dir: Path,337 branch_name: str,338 default_branch: str,339 upstream_remote: str,340) -> None:341 _run_checked(["git", "-C", str(repo_dir), "fetch", upstream_remote, default_branch])342 _run_checked(343 [344 "git",345 "-C",346 str(repo_dir),347 "worktree",348 "add",349 "-B",350 branch_name,351 str(worktree_dir),352 f"{upstream_remote}/{default_branch}",353 ]354 )355 356 357def _run_codex_exec(358 *,359 worktree_dir: Path,360 run_dir: Path,361 prompt_path: Path,362 schema_path: Path,363 result_path: Path,364) -> None:365 prompt_text = prompt_path.read_text(encoding="utf-8")366 _run_checked(367 [368 "codex",369 "exec",370 "-C",371 str(worktree_dir),372 "--add-dir",373 str(run_dir),374 "--full-auto",375 "--output-schema",376 str(schema_path),377 "-o",378 str(result_path),379 "-",380 ],381 input_text=prompt_text,382 )383 if not result_path.exists():384 raise RuntimeError("Codex did not write a structured result.")385 386 387def _validate_synthesized_branch(388 *,389 worktree_dir: Path,390 upstream_remote: str,391 default_branch: str,392 commit_message: str,393 file_policy: str,394) -> list[str]:395 ahead_count = int(396 _run_stdout(397 [398 "git",399 "-C",400 str(worktree_dir),401 "rev-list",402 "--count",403 f"{upstream_remote}/{default_branch}..HEAD",404 ]405 )406 )407 if ahead_count != 1:408 raise RuntimeError(409 f"Synthesized branch must contain exactly one commit on top of "410 f"{upstream_remote}/{default_branch}; found {ahead_count}."411 )412 413 head_subject = _run_stdout(["git", "-C", str(worktree_dir), "log", "-1", "--pretty=%s"]).strip()414 if head_subject != commit_message:415 raise RuntimeError(416 f"Codex commit message {commit_message!r} does not match HEAD subject {head_subject!r}."417 )418 419 status_output = _run_stdout(["git", "-C", str(worktree_dir), "status", "--porcelain"])420 if status_output.strip():421 raise RuntimeError("Codex left uncommitted changes in the synthesis worktree.")422 423 changed_paths = [424 line.strip()425 for line in _run_stdout(426 [427 "git",428 "-C",429 str(worktree_dir),430 "diff",431 "--name-only",432 f"{upstream_remote}/{default_branch}..HEAD",433 ]434 ).splitlines()435 if line.strip()436 ]437 if not changed_paths:438 raise RuntimeError("The synthesized branch does not modify any files.")439 440 if file_policy == "pure-loc":441 disallowed_paths = [path for path in changed_paths if _is_doc_path(path)]442 if disallowed_paths:443 raise RuntimeError(444 "The synthesized branch touched non-LOC documentation paths: "445 + ", ".join(disallowed_paths)446 )447 unsupported_paths = [448 path for path in changed_paths if not _is_allowed_path(path, allow_docs=False)449 ]450 if unsupported_paths:451 raise RuntimeError(452 "The synthesized branch touched files outside implementation/test code paths: "453 + ", ".join(unsupported_paths)454 )455 elif file_policy == "allow-docs":456 unsupported_paths = [457 path for path in changed_paths if not _is_allowed_path(path, allow_docs=True)458 ]459 if unsupported_paths:460 raise RuntimeError(461 "The synthesized branch touched files outside implementation/test/documentation "462 "paths: " + ", ".join(unsupported_paths)463 )464 elif file_policy != "allow-any":465 raise RuntimeError(f"Unsupported file policy: {file_policy}")466 return changed_paths467 468 469def _ensure_fork_repo(470 *,471 upstream_repo: str,472 fork_repo: str,473 authenticated_user: str,474) -> str:475 fork_target = RepoRef.parse(fork_repo)476 try:477 _run_checked(["gh", "repo", "view", fork_repo, "--json", "nameWithOwner"])478 except RuntimeError:479 fork_command = [480 "gh",481 "repo",482 "fork",483 upstream_repo,484 "--clone=false",485 "--remote=false",486 "--fork-name",487 fork_target.name,488 ]489 if fork_target.owner != authenticated_user:490 fork_command.extend(["--org", fork_target.owner])491 _run_checked(fork_command)492 return fork_target.slug493 494 495def _ensure_fork_remote(*, worktree_dir: Path, fork_repo: str, remote_name: str) -> None:496 fork_url = f"https://github.com/{fork_repo}.git"497 try:498 existing_url = _run_stdout(499 ["git", "-C", str(worktree_dir), "remote", "get-url", remote_name]500 ).strip()501 except RuntimeError:502 _run_checked(503 [504 "git",505 "-C",506 str(worktree_dir),507 "remote",508 "add",509 remote_name,510 fork_url,511 ]512 )513 return514 if existing_url != fork_url:515 raise RuntimeError(516 f"Existing `{remote_name}` remote points to {existing_url}, expected {fork_url}."517 )518 519 520def _push_branch(*, worktree_dir: Path, branch_name: str, remote_name: str) -> None:521 _run_checked(["git", "-C", str(worktree_dir), "push", "-u", remote_name, branch_name])522 523 524def _create_pull_request(525 *,526 upstream_repo: str,527 default_branch: str,528 fork_owner: str,529 branch_name: str,530 title: str,531 body_path: Path,532) -> str:533 return _run_stdout(534 [535 "gh",536 "pr",537 "create",538 "--repo",539 upstream_repo,540 "--base",541 default_branch,542 "--head",543 f"{fork_owner}:{branch_name}",544 "--title",545 title,546 "--body-file",547 str(body_path),548 ]549 ).strip()550 551 552def _repo_slug_from_remote_url(url: str) -> str:553 normalized = url.strip()554 if not normalized:555 return ""556 for prefix in (557 "https://github.com/",558 "http://github.com/",559 "ssh://git@github.com/",560 "git://github.com/",561 ):562 if normalized.startswith(prefix):563 normalized = normalized[len(prefix) :]564 break565 if normalized.startswith("git@github.com:"):566 normalized = normalized.split(":", 1)[1]567 normalized = normalized.rstrip("/")568 if normalized.endswith(".git"):569 normalized = normalized[:-4]570 return normalized571 572 573# File-policy helpers574 575 576def _is_doc_path(path: str) -> bool:577 pure_path = PurePosixPath(path)578 lowered_parts = [part.lower() for part in pure_path.parts]579 lowered_name = pure_path.name.lower()580 if pure_path.suffix.lower() in DOC_FILE_SUFFIXES:581 return True582 if any(part in DOC_DIRECTORY_NAMES for part in lowered_parts):583 return True584 return lowered_name.startswith(DOC_FILE_PREFIXES)585 586 587def _is_allowed_path(path: str, *, allow_docs: bool) -> bool:588 pure_path = PurePosixPath(path)589 if _is_test_path(pure_path):590 return True591 if allow_docs and _is_doc_path(path):592 return True593 return pure_path.suffix.lower() in CODE_FILE_SUFFIXES594 595 596def _file_policy_instruction(file_policy: str) -> str:597 if file_policy == "pure-loc":598 return (599 "Do not touch README files, changelogs, markdown docs, prose-only files, "600 "or commentary artifacts. Fail instead of submitting a noisy branch."601 )602 if file_policy == "allow-docs":603 return (604 "Documentation and markdown changes are allowed only when they are necessary "605 "for the same fix. Keep them minimal and subordinate to the code patch."606 )607 if file_policy == "allow-any":608 return (609 "Non-code file changes are allowed when they are required for the same fix, "610 "but keep the patch as small and focused as possible."611 )612 raise ValueError(f"Unsupported file policy: {file_policy}")613 614 615def _is_test_path(path: PurePosixPath) -> bool:616 lowered_parts = [part.lower() for part in path.parts]617 lowered_name = path.name.lower()618 lowered_stem = path.stem.lower()619 if any(part in TEST_DIRECTORY_NAMES for part in lowered_parts):620 return True621 return lowered_name.startswith("test_") or lowered_stem.endswith("_test")622 623 624def _run_checked(625 args: list[str],626 *,627 input_text: str | None = None,628) -> subprocess.CompletedProcess[str]:629 try:630 return subprocess.run(631 args,632 input=input_text,633 text=True,634 capture_output=True,635 check=True,636 )637 except subprocess.CalledProcessError as exc:638 detail = (exc.stderr or exc.stdout or "").strip()639 message = f"Command failed: {' '.join(args)}"640 if detail:641 message = f"{message}: {detail}"642 raise RuntimeError(message) from exc643 644 645def _run_stdout(args: list[str]) -> str:646 return _run_checked(args).stdout647 648 649def _update_manifest(manifest_path: Path, updates: dict[str, Any]) -> None:650 manifest = read_json(manifest_path)651 manifest.update(updates)652 write_json(manifest, manifest_path)653 654 655def _utc_stamp() -> str:656 return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")657 658 659def _ordered_ints(values: Any) -> list[int]:660 ordered: list[int] = []661 seen: set[int] = set()662 for value in values or []:663 number = _coerce_int(value)664 if number is None or number in seen:665 continue666 ordered.append(number)667 seen.add(number)668 return ordered669 670 671def _normalize_result_source_pr_numbers(672 *,673 expected_source_pr_numbers: list[int],674 raw_source_pr_numbers: Any,675) -> list[int]:676 actual_source_pr_numbers = _ordered_ints(raw_source_pr_numbers)677 if len(actual_source_pr_numbers) < 2:678 raise ValueError(679 "Codex result must reference at least two open source PRs from the selected cluster."680 )681 682 expected_source_pr_set = set(expected_source_pr_numbers)683 unknown_source_pr_numbers = [684 number for number in actual_source_pr_numbers if number not in expected_source_pr_set685 ]686 if unknown_source_pr_numbers:687 raise ValueError(688 "Codex result source_pr_numbers included PRs outside the selected open PR set: "689 + ", ".join(str(number) for number in unknown_source_pr_numbers)690 )691 692 actual_source_pr_set = set(actual_source_pr_numbers)693 return [number for number in expected_source_pr_numbers if number in actual_source_pr_set]694 695 696def _coerce_int(value: Any) -> int | None:697 if value is None:698 return None699 try:700 return int(value)701 except (TypeError, ValueError):702 return None703 