Files
Python/scripts/pr_file_map.py
T
priya-sundaram-devandpre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> 00d9ebb4c0 Move DIRECTORY.md to its own section in pr_file_map.py (#15353)
* Move DIRECTORY.md to its own section in pr_file_map.py

DIRECTORY.md is auto-generated and touched by nearly every open PR, so it
dominated the 'possible merge conflicts' list and distracted maintainers.
Pull it out into a dedicated section at the very bottom of the report that
separates PRs whose only overlap is DIRECTORY.md (safe to 'accept both' in
the GitHub UI) from PRs that also collide on real source files.

Also render the Script path relative to the git root instead of an absolute
machine-specific path.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-09-15 23:54:51 +02:00

291 lines
11 KiB
Python

#!/usr/bin/env python3
"""
pr_file_map.py
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 (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
- "distinct files" counts each touched path once.
Only the distinct total equals `existing + missing`, since those are deduped.
Run status is also written to stderr with:
- Number of PRs from `get_open_prs()`
- Number of file touches from `get_pr_files()` and distinct files touched
- Number of existing and missing files
Requirements: gh (GitHub CLI), authenticated (`gh auth login`)
Usage:
./pr_file_map.py
./pr_file_map.py > report.md
"""
import json
import shutil
import subprocess
import sys
from collections import defaultdict
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:
result = subprocess.run( # noqa: S603
["gh", *args], # noqa: S607
capture_output=True,
text=True,
check=True,
)
except FileNotFoundError:
sys.exit("Error: 'gh' (GitHub CLI) is not installed or not in PATH.")
except subprocess.CalledProcessError as e:
sys.exit(f"Error running 'gh {' '.join(args)}':\n{e.stderr.strip()}")
return result.stdout
def check_gh_auth() -> None:
try:
subprocess.run(
["gh", "auth", "status"], # noqa: S607
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError:
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"]
)
return json.loads(raw)
def get_pr_files(pr_number: int) -> list[str]:
raw = run_gh(["pr", "view", str(pr_number), "--json", "files"])
data = json.loads(raw)
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.")
check_gh_auth()
prs = get_open_prs()
pr_count = len(prs)
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)
distinct_count = len(file_to_prs)
print(
f"File touches from get_pr_files(): {touch_count} "
f"across {distinct_count} distinct files",
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]] = {}
for path, pr_numbers in file_to_prs.items():
deduped = sorted(set(pr_numbers))
target = existing if Path(path).exists() else missing
target[path] = deduped
if len(deduped) > 1:
contested[path] = deduped
existing_count = len(existing)
missing_count = len(missing)
print(
f"Existing files: {existing_count}, Missing files: {missing_count}, "
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: `{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)}` "
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
print(
f"\n## `{len(contested)}` files touched by more than one open PR "
"(possible merge conflicts)\n"
)
if contested:
print("Coordinate, rebase, or land these together to avoid conflicts.\n")
# Hot spots first: most-contested files, then alphabetical.
for path in sorted(contested, key=lambda p: (-len(contested[p]), p)):
pr_list = " ".join(f"#{n}" for n in contested[path])
print(f"- `{path}` ({len(contested[path])} PRs): {pr_list}")
else:
print("_None -- no open PRs overlap on the same file._")
render_file_section("existing files", existing)
render_file_section("files not present in the working directory", missing)
# DIRECTORY.md section, kept at the very bottom on purpose.
render_directory_section(directory_prs, directory_only, directory_plus_other)
if __name__ == "__main__":
main()