forked from TheAlgorithms/Python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpr_file_map.py
More file actions
296 lines (251 loc) · 10.7 KB
/
Copy pathpr_file_map.py
File metadata and controls
296 lines (251 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
#!/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:
scripts/pr_file_map.py
scripts/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
DIRECTORY_FILE = "DIRECTORY.md"
# Open PRs to skip in the report, e.g. [123, 456, 789] ignores #123, #456, #789.
ignore_pull_request: set[int] = {15105, 15142, 15356}
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():
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"]
)
ignore = ignore_pull_request
return [pr for pr in json.loads(raw) if pr["number"] not in ignore]
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 ---
generated = f"{datetime.now(UTC):%d %b %Y at %H:%M} {UTC}"
print(f"# Open Pull Request File Map: {generated}\n")
print(f"- Script: `{script_display_path()}`")
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()