Team Ai
Apppublic

evalstate/diffusers-pr-api

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
duplicate_prs.py703 linesDownload Raw Back to app
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