Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 143 additions & 24 deletions scripts/pr_file_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"]
Expand All @@ -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.")
Expand All @@ -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)
Expand All @@ -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]] = {}
Expand All @@ -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
Expand All @@ -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__":
Expand Down
Loading