diff --git a/scripts/pr_file_map.py b/scripts/pr_file_map.py index d384de75e12f..f9ea85012d4c 100644 --- a/scripts/pr_file_map.py +++ b/scripts/pr_file_map.py @@ -5,15 +5,24 @@ Lists all open pull requests in the current directory's git repo (via `gh`) and, for each file touched by any open PR, which PR number(s) touch it. -Output is GitHub-flavored Markdown that includes this script's path, the -current UTC datetime, and summary counts for open PRs, file touches, distinct -files, and existing/missing files. It highlights files touched by more than one -open PR first (the likely merge-conflict hot spots when landing PRs), then -renders a sorted list of files that currently exist in the working directory, -each with its modifying PR numbers, followed by a separate section for files -referenced by open PRs but that do not exist in the working directory (e.g. +Output is GitHub-flavored Markdown that includes this script's path (relative to +the git root), the current UTC datetime, and summary counts for open PRs, file +touches, distinct files, and existing/missing files. It highlights files touched +by more than one open PR first (the likely merge-conflict hot spots when landing +PRs), then renders a sorted list of files that currently exist in the working +directory, each with its modifying PR numbers, followed by a separate section for +files referenced by open PRs but that do not exist in the working directory (e.g. deleted, renamed, or on a branch not checked out locally). +`DIRECTORY.md` is treated specially and reported in its own section at the very +bottom. It is auto-generated, so nearly every PR touches it and it would +otherwise dominate the "possible merge conflicts" list and distract busy +maintainers. A merge conflict caused only by `DIRECTORY.md` is trivial to clear: +choose __accept both__ in the GitHub UI. The bottom section therefore separates +the PRs whose only overlap with other open PRs is `DIRECTORY.md` (safe to accept +both) from those that also overlap on real source files (which need a genuine +review or rebase). + Two file totals are reported because they answer different questions: - "file touches" counts every (PR, file) pair, so a file edited by three open PRs contributes three touches; and @@ -40,6 +49,10 @@ from datetime import UTC, datetime from pathlib import Path +# Auto-generated index of the repo. Almost every PR touches it, so a merge +# conflict here is expected and is resolved with "accept both" in the GitHub UI. +DIRECTORY_FILE = "DIRECTORY.md" + def run_gh(args: list[str]) -> str: try: @@ -68,6 +81,34 @@ def check_gh_auth() -> None: sys.exit("Error: gh is not authenticated. Run 'gh auth login' first.") +def git_root() -> Path | None: + """Return the repository root, or None if not inside a git work tree.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], # noqa: S607 + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + return None + except subprocess.CalledProcessError: + return None + root = result.stdout.strip() + return Path(root) if root else None + + +def script_display_path() -> Path: + """This script's path relative to the git root (falls back to absolute).""" + script_path = Path(__file__).resolve() + if (root := git_root()) is not None: + try: + return script_path.relative_to(root.resolve()) + except ValueError: + pass + return script_path + + def get_open_prs() -> list[dict]: raw = run_gh( ["pr", "list", "--state", "open", "--limit", "1000", "--json", "number,title"] @@ -81,6 +122,76 @@ def get_pr_files(pr_number: int) -> list[str]: return [f["path"] for f in data.get("files", [])] +def split_directory_conflicts( + directory_prs: list[int], + pr_to_files: dict[int, list[str]], + contested: dict[str, list[int]], +) -> tuple[list[int], list[tuple[int, list[str]]]]: + """Split PRs touching DIRECTORY.md by whether it is their only overlap. + + Returns (directory_only, directory_plus_other) where directory_only lists + PRs whose sole collision with other open PRs is DIRECTORY.md (safe to + "accept both"), and directory_plus_other pairs each remaining PR with the + other contested files it touches (a real review/rebase is needed). + """ + directory_only: list[int] = [] + directory_plus_other: list[tuple[int, list[str]]] = [] + for pr_number in directory_prs: + other_contested = sorted( + path + for path in pr_to_files.get(pr_number, []) + if path != DIRECTORY_FILE and path in contested + ) + if other_contested: + directory_plus_other.append((pr_number, other_contested)) + else: + directory_only.append(pr_number) + return directory_only, directory_plus_other + + +def render_file_section(title: str, files: dict[str, list[int]]) -> None: + """Render a Markdown section listing files and the PR numbers touching them.""" + print(f"\n## `{len(files)}` {title}\n") + if not files: + print("_None._") + return + for path in sorted(files): + pr_list = " ".join(f"#{n}" for n in files[path]) + print(f"- `{path}`: {pr_list}") + + +def render_directory_section( + directory_prs: list[int], + directory_only: list[int], + directory_plus_other: list[tuple[int, list[str]]], +) -> None: + """Render the bottom DIRECTORY.md section (kept last on purpose).""" + print(f"\n## `{len(directory_prs)}` open PRs touch `{DIRECTORY_FILE}`\n") + if not directory_prs: + print(f"_None -- no open PR modifies `{DIRECTORY_FILE}`._") + return + print( + f"`{DIRECTORY_FILE}` is auto-generated, so nearly every PR touches it. " + "A merge conflict caused only by this file is cleared by choosing " + "__accept both__ in the GitHub UI -- no rebase needed.\n" + ) + print( + f"### `{len(directory_only)}` PRs whose only overlap is " + f"`{DIRECTORY_FILE}` (safe to accept both)\n" + ) + print(" ".join(f"#{n}" for n in directory_only) if directory_only else "_None._") + print( + f"\n### `{len(directory_plus_other)}` PRs that also overlap on other " + "files (need a review or rebase)\n" + ) + if not directory_plus_other: + print("_None._") + return + for pr_number, files in directory_plus_other: + file_list = ", ".join(f"`{path}`" for path in files) + print(f"- #{pr_number}: also touches {file_list}") + + def main() -> None: if shutil.which("gh") is None: sys.exit("Error: 'gh' (GitHub CLI) is not installed or not in PATH.") @@ -92,11 +203,13 @@ def main() -> None: print(f"PR count from get_open_prs(): {pr_count}", file=sys.stderr) file_to_prs: dict[str, list[int]] = defaultdict(list) + pr_to_files: dict[int, list[str]] = {} touch_count = 0 # every (PR, file) pair; a file may be touched by many PRs for pr in prs: pr_number = pr["number"] pr_files = get_pr_files(pr_number) + pr_to_files[pr_number] = pr_files touch_count += len(pr_files) for path in pr_files: file_to_prs[path].append(pr_number) @@ -107,6 +220,10 @@ def main() -> None: file=sys.stderr, ) + # Pull DIRECTORY.md out so it does not dominate the contested/existing lists; + # it gets its own section at the very bottom. + directory_prs = sorted(set(file_to_prs.pop(DIRECTORY_FILE, []))) + existing: dict[str, list[int]] = {} missing: dict[str, list[int]] = {} contested: dict[str, list[int]] = {} @@ -121,18 +238,30 @@ def main() -> None: missing_count = len(missing) print( f"Existing files: {existing_count}, Missing files: {missing_count}, " - f"Contested files: {len(contested)}", + f"Contested files: {len(contested)} (excluding {DIRECTORY_FILE}), " + f"PRs touching {DIRECTORY_FILE}: {len(directory_prs)}", file=sys.stderr, ) + # Of the PRs that touch DIRECTORY.md, separate those whose only overlap with + # other open PRs is DIRECTORY.md itself (safe "accept both") from those that + # also collide on real source files (need a genuine review or rebase). + directory_only, directory_plus_other = split_directory_conflicts( + directory_prs, pr_to_files, contested + ) + # --- Render GitHub-flavored Markdown --- print("# Open Pull Request File Map\n") - print(f"- Script: `{Path(__file__).resolve()}`") + print(f"- Script: `{script_display_path()}`") print(f"- Generated (UTC): `{datetime.now(UTC).isoformat()}`") print(f"- Number of PRs: `{pr_count}`") print(f"- File touches (PR x file): `{touch_count}`") print(f"- Distinct files touched: `{distinct_count}`") - print(f"- Files touched by more than one PR: `{len(contested)}`") + print( + f"- Files touched by more than one PR: `{len(contested)}` " + f"(excluding `{DIRECTORY_FILE}`)" + ) + print(f"- Open PRs touching `{DIRECTORY_FILE}`: `{len(directory_prs)}`") if pr_count == 0: print("\nNo open pull requests found.") return @@ -150,21 +279,11 @@ def main() -> None: else: print("_None -- no open PRs overlap on the same file._") - print(f"\n## `{existing_count}` existing files\n") - if existing: - for path in sorted(existing): - pr_list = " ".join(f"#{n}" for n in existing[path]) - print(f"- `{path}`: {pr_list}") - else: - print("_None._") + render_file_section("existing files", existing) + render_file_section("files not present in the working directory", missing) - print(f"\n## `{missing_count}` files not present in the working directory\n") - if missing: - for path in sorted(missing): - pr_list = " ".join(f"#{n}" for n in missing[path]) - print(f"- `{path}`: {pr_list}") - else: - print("_None._") + # DIRECTORY.md section, kept at the very bottom on purpose. + render_directory_section(directory_prs, directory_only, directory_plus_other) if __name__ == "__main__":