pull-request-fixer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pull_request_fixer/__init__.py +13 -0
- pull_request_fixer/_version.py +34 -0
- pull_request_fixer/cli.py +1059 -0
- pull_request_fixer/exceptions.py +66 -0
- pull_request_fixer/github_client.py +330 -0
- pull_request_fixer/graphql_queries.py +310 -0
- pull_request_fixer/models.py +70 -0
- pull_request_fixer/pr_fixer.py +37 -0
- pull_request_fixer/pr_scanner.py +423 -0
- pull_request_fixer/progress_tracker.py +483 -0
- pull_request_fixer/py.typed +0 -0
- pull_request_fixer-0.1.0.dist-info/METADATA +451 -0
- pull_request_fixer-0.1.0.dist-info/RECORD +16 -0
- pull_request_fixer-0.1.0.dist-info/WHEEL +4 -0
- pull_request_fixer-0.1.0.dist-info/entry_points.txt +2 -0
- pull_request_fixer-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2025 The Linux Foundation
|
|
3
|
+
|
|
4
|
+
"""Command-line interface for pull-request-fixer."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.logging import RichHandler
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .github_client import GitHubClient
|
|
19
|
+
from .pr_scanner import PRScanner
|
|
20
|
+
from .progress_tracker import ProgressTracker
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def version_callback(ctx: typer.Context, value: bool) -> None:
|
|
26
|
+
"""Print version and exit."""
|
|
27
|
+
if value:
|
|
28
|
+
console.print(f"pull-request-fixer version {__version__}")
|
|
29
|
+
ctx.exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def setup_logging(
|
|
33
|
+
log_level: str = "INFO", quiet: bool = False, verbose: bool = False
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Configure logging with Rich handler."""
|
|
36
|
+
if quiet:
|
|
37
|
+
log_level = "ERROR"
|
|
38
|
+
elif verbose:
|
|
39
|
+
log_level = "DEBUG"
|
|
40
|
+
|
|
41
|
+
logging.basicConfig(
|
|
42
|
+
level=log_level,
|
|
43
|
+
format="%(message)s",
|
|
44
|
+
handlers=[
|
|
45
|
+
RichHandler(console=console, show_time=False, show_path=False)
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Silence httpx INFO logs to prevent Rich display interruption
|
|
50
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_target(target: str) -> tuple[str, str]:
|
|
54
|
+
"""Parse target to determine if it's an organization or a specific PR URL.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
target: Organization name, GitHub URL, or PR URL
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (type, value) where:
|
|
61
|
+
- type is "org" or "pr"
|
|
62
|
+
- value is organization name for "org", or PR URL for "pr"
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
parse_target("myorg") -> ("org", "myorg")
|
|
66
|
+
parse_target("https://github.com/myorg") -> ("org", "myorg")
|
|
67
|
+
parse_target("https://github.com/owner/repo/pull/123") -> ("pr", "https://github.com/owner/repo/pull/123")
|
|
68
|
+
"""
|
|
69
|
+
# Remove trailing slash
|
|
70
|
+
target = target.rstrip("/")
|
|
71
|
+
|
|
72
|
+
# Check if it's a PR URL
|
|
73
|
+
if "/pull/" in target or "/pulls/" in target:
|
|
74
|
+
# It's a specific PR URL
|
|
75
|
+
return ("pr", target)
|
|
76
|
+
|
|
77
|
+
# Check if it's a GitHub URL
|
|
78
|
+
if "github.com" in target:
|
|
79
|
+
# Extract org from URL: https://github.com/ORG or https://github.com/ORG/...
|
|
80
|
+
parts = target.split("github.com/")
|
|
81
|
+
if len(parts) > 1:
|
|
82
|
+
# Get the part after github.com/
|
|
83
|
+
path = parts[1]
|
|
84
|
+
# Split by / and take first part (the org)
|
|
85
|
+
org = path.split("/")[0]
|
|
86
|
+
return ("org", org)
|
|
87
|
+
|
|
88
|
+
# Not a URL, return as organization
|
|
89
|
+
return ("org", target)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_pr_info_from_url(pr_url: str) -> tuple[str, str, int] | None:
|
|
93
|
+
"""Extract owner, repo, and PR number from a PR URL.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
pr_url: GitHub PR URL
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Tuple of (owner, repo, pr_number) or None if invalid
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
extract_pr_info_from_url("https://github.com/owner/repo/pull/123")
|
|
103
|
+
-> ("owner", "repo", 123)
|
|
104
|
+
"""
|
|
105
|
+
# Match pattern: https://github.com/OWNER/REPO/pull(s)/NUMBER
|
|
106
|
+
match = re.match(
|
|
107
|
+
r"https?://github\.com/([^/]+)/([^/]+)/pulls?/(\d+)", pr_url
|
|
108
|
+
)
|
|
109
|
+
if match:
|
|
110
|
+
owner = match.group(1)
|
|
111
|
+
repo = match.group(2)
|
|
112
|
+
pr_number = int(match.group(3))
|
|
113
|
+
return (owner, repo, pr_number)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Create Typer app
|
|
118
|
+
app = typer.Typer(
|
|
119
|
+
name="pull-request-fixer",
|
|
120
|
+
help="Fix pull requests with GitHub integration",
|
|
121
|
+
add_completion=False,
|
|
122
|
+
rich_markup_mode="rich",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main(
|
|
127
|
+
target: str = typer.Argument(
|
|
128
|
+
None,
|
|
129
|
+
help="GitHub organization name/URL or PR URL (e.g., 'myorg', 'https://github.com/myorg', or 'https://github.com/owner/repo/pull/123')",
|
|
130
|
+
),
|
|
131
|
+
token: str | None = typer.Option(
|
|
132
|
+
None,
|
|
133
|
+
"--token",
|
|
134
|
+
"-t",
|
|
135
|
+
help="GitHub token (or set GITHUB_TOKEN env var)",
|
|
136
|
+
envvar="GITHUB_TOKEN",
|
|
137
|
+
),
|
|
138
|
+
fix_title: bool = typer.Option(
|
|
139
|
+
False,
|
|
140
|
+
"--fix-title",
|
|
141
|
+
help="Fix PR title to match first commit message subject",
|
|
142
|
+
),
|
|
143
|
+
fix_body: bool = typer.Option(
|
|
144
|
+
False,
|
|
145
|
+
"--fix-body",
|
|
146
|
+
help="Fix PR body to match first commit message body (excluding trailers)",
|
|
147
|
+
),
|
|
148
|
+
include_drafts: bool = typer.Option(
|
|
149
|
+
False,
|
|
150
|
+
"--include-drafts",
|
|
151
|
+
help="Include draft PRs in scan",
|
|
152
|
+
),
|
|
153
|
+
blocked_only: bool = typer.Option(
|
|
154
|
+
False,
|
|
155
|
+
"--blocked-only",
|
|
156
|
+
help="Only process PRs that are blocked/unmergeable (failing checks, conflicts, etc.)",
|
|
157
|
+
),
|
|
158
|
+
dry_run: bool = typer.Option(
|
|
159
|
+
False,
|
|
160
|
+
"--dry-run",
|
|
161
|
+
help="Preview changes without applying them",
|
|
162
|
+
),
|
|
163
|
+
workers: int = typer.Option(
|
|
164
|
+
4,
|
|
165
|
+
"--workers",
|
|
166
|
+
"-j",
|
|
167
|
+
min=1,
|
|
168
|
+
max=32,
|
|
169
|
+
help="Number of parallel workers (default: 4)",
|
|
170
|
+
),
|
|
171
|
+
verbose: bool = typer.Option(
|
|
172
|
+
False,
|
|
173
|
+
"--verbose",
|
|
174
|
+
"-v",
|
|
175
|
+
help="Enable verbose output",
|
|
176
|
+
),
|
|
177
|
+
quiet: bool = typer.Option(
|
|
178
|
+
False,
|
|
179
|
+
"--quiet",
|
|
180
|
+
"-q",
|
|
181
|
+
help="Suppress all output except errors",
|
|
182
|
+
),
|
|
183
|
+
log_level: str = typer.Option(
|
|
184
|
+
"INFO",
|
|
185
|
+
"--log-level",
|
|
186
|
+
help="Set logging level",
|
|
187
|
+
),
|
|
188
|
+
_version: bool = typer.Option(
|
|
189
|
+
False,
|
|
190
|
+
"--version",
|
|
191
|
+
callback=version_callback,
|
|
192
|
+
is_eager=True,
|
|
193
|
+
help="Show version and exit",
|
|
194
|
+
),
|
|
195
|
+
) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Pull request fixer - automatically fix PR titles and bodies from commit messages.
|
|
198
|
+
|
|
199
|
+
Can process either:
|
|
200
|
+
- An entire organization: Scans for all blocked pull requests
|
|
201
|
+
- A specific PR: Processes only that pull request
|
|
202
|
+
|
|
203
|
+
Examples:
|
|
204
|
+
pull-request-fixer myorg --fix-title --fix-body
|
|
205
|
+
pull-request-fixer https://github.com/myorg --fix-title --dry-run
|
|
206
|
+
pull-request-fixer https://github.com/owner/repo/pull/123 --fix-title
|
|
207
|
+
pull-request-fixer myorg --fix-title --workers 8 --verbose
|
|
208
|
+
"""
|
|
209
|
+
# If no target provided, show help
|
|
210
|
+
if target is None:
|
|
211
|
+
console.print("Error: Missing required argument 'TARGET'.") # type: ignore[unreachable]
|
|
212
|
+
console.print()
|
|
213
|
+
console.print("Usage: pull-request-fixer [OPTIONS] TARGET")
|
|
214
|
+
console.print()
|
|
215
|
+
console.print("TARGET can be:")
|
|
216
|
+
console.print(" - Organization name: myorg")
|
|
217
|
+
console.print(" - Organization URL: https://github.com/myorg")
|
|
218
|
+
console.print(
|
|
219
|
+
" - Specific PR URL: https://github.com/owner/repo/pull/123"
|
|
220
|
+
)
|
|
221
|
+
console.print()
|
|
222
|
+
console.print("Run 'pull-request-fixer --help' for more information.")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
setup_logging(log_level=log_level, quiet=quiet, verbose=verbose)
|
|
226
|
+
|
|
227
|
+
# Validate that at least one fix option is enabled
|
|
228
|
+
if not fix_title and not fix_body:
|
|
229
|
+
console.print(
|
|
230
|
+
"[yellow]Warning:[/yellow] No fix options specified. "
|
|
231
|
+
"Use --fix-title and/or --fix-body to enable fixes."
|
|
232
|
+
)
|
|
233
|
+
console.print()
|
|
234
|
+
console.print("Available options:")
|
|
235
|
+
console.print(
|
|
236
|
+
" --fix-title Fix PR title to match first commit subject"
|
|
237
|
+
)
|
|
238
|
+
console.print(" --fix-body Fix PR body to match first commit body")
|
|
239
|
+
console.print()
|
|
240
|
+
console.print(
|
|
241
|
+
"Example: pull-request-fixer myorg --fix-title --fix-body"
|
|
242
|
+
)
|
|
243
|
+
raise typer.Exit(1)
|
|
244
|
+
|
|
245
|
+
if not token:
|
|
246
|
+
console.print(
|
|
247
|
+
"[red]Error:[/red] GitHub token required. "
|
|
248
|
+
"Provide --token or set GITHUB_TOKEN environment variable"
|
|
249
|
+
)
|
|
250
|
+
raise typer.Exit(1)
|
|
251
|
+
|
|
252
|
+
# Parse target to determine if it's an org or a specific PR
|
|
253
|
+
target_type, target_value = parse_target(target)
|
|
254
|
+
|
|
255
|
+
if target_type == "pr":
|
|
256
|
+
# Process single PR
|
|
257
|
+
asyncio.run(
|
|
258
|
+
process_single_pr(
|
|
259
|
+
pr_url=target_value,
|
|
260
|
+
token=token,
|
|
261
|
+
fix_title=fix_title,
|
|
262
|
+
fix_body=fix_body,
|
|
263
|
+
dry_run=dry_run,
|
|
264
|
+
quiet=quiet,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
# Scan organization
|
|
269
|
+
asyncio.run(
|
|
270
|
+
scan_and_fix_organization(
|
|
271
|
+
org=target_value,
|
|
272
|
+
token=token,
|
|
273
|
+
fix_title=fix_title,
|
|
274
|
+
fix_body=fix_body,
|
|
275
|
+
include_drafts=include_drafts,
|
|
276
|
+
blocked_only=blocked_only,
|
|
277
|
+
dry_run=dry_run,
|
|
278
|
+
workers=workers,
|
|
279
|
+
quiet=quiet,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def process_single_pr(
|
|
285
|
+
pr_url: str,
|
|
286
|
+
token: str,
|
|
287
|
+
fix_title: bool,
|
|
288
|
+
fix_body: bool,
|
|
289
|
+
dry_run: bool,
|
|
290
|
+
quiet: bool,
|
|
291
|
+
) -> None:
|
|
292
|
+
"""Process a single PR by URL.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
pr_url: GitHub PR URL
|
|
296
|
+
token: GitHub token
|
|
297
|
+
fix_title: Whether to fix PR title
|
|
298
|
+
fix_body: Whether to fix PR body
|
|
299
|
+
dry_run: Whether to preview without applying changes
|
|
300
|
+
quiet: Whether to suppress output
|
|
301
|
+
"""
|
|
302
|
+
if not quiet:
|
|
303
|
+
console.print(f"🔍 Processing PR: {pr_url}")
|
|
304
|
+
fixes = []
|
|
305
|
+
if fix_title:
|
|
306
|
+
fixes.append("title")
|
|
307
|
+
if fix_body:
|
|
308
|
+
fixes.append("body")
|
|
309
|
+
console.print(f"🔧 Will fix: {', '.join(fixes)}")
|
|
310
|
+
if dry_run:
|
|
311
|
+
console.print("🏃 Dry run mode: no changes will be applied")
|
|
312
|
+
console.print()
|
|
313
|
+
|
|
314
|
+
# Extract PR info from URL
|
|
315
|
+
pr_info = extract_pr_info_from_url(pr_url)
|
|
316
|
+
if not pr_info:
|
|
317
|
+
console.print(f"[red]Error:[/red] Invalid PR URL: {pr_url}")
|
|
318
|
+
console.print()
|
|
319
|
+
console.print("Expected format: https://github.com/owner/repo/pull/123")
|
|
320
|
+
raise typer.Exit(1)
|
|
321
|
+
|
|
322
|
+
owner, repo_name, pr_number = pr_info
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
async with GitHubClient(token) as client: # type: ignore[attr-defined]
|
|
326
|
+
# Fetch PR data
|
|
327
|
+
if not quiet:
|
|
328
|
+
console.print("📥 Fetching pull request metadata...")
|
|
329
|
+
|
|
330
|
+
endpoint = f"/repos/{owner}/{repo_name}/pulls/{pr_number}"
|
|
331
|
+
pr_data_response = await client._request("GET", endpoint)
|
|
332
|
+
|
|
333
|
+
if not pr_data_response or not isinstance(pr_data_response, dict):
|
|
334
|
+
console.print("[red]Error:[/red] Could not fetch PR data")
|
|
335
|
+
raise typer.Exit(1)
|
|
336
|
+
|
|
337
|
+
# Process the PR
|
|
338
|
+
semaphore = asyncio.Semaphore(1) # Single PR, no parallelism needed
|
|
339
|
+
result = await process_pr(
|
|
340
|
+
client=client,
|
|
341
|
+
owner=owner,
|
|
342
|
+
repo_name=repo_name,
|
|
343
|
+
pr_data=pr_data_response,
|
|
344
|
+
fix_title=fix_title,
|
|
345
|
+
fix_body=fix_body,
|
|
346
|
+
dry_run=dry_run,
|
|
347
|
+
quiet=quiet,
|
|
348
|
+
semaphore=semaphore,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if not quiet:
|
|
352
|
+
console.print()
|
|
353
|
+
if result:
|
|
354
|
+
if dry_run:
|
|
355
|
+
console.print(
|
|
356
|
+
"[green]✅ [DRY RUN] Would fix this PR[/green]"
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
console.print(
|
|
360
|
+
"[green]✅ Pull request updated successfully[/green]"
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
console.print(
|
|
364
|
+
"[yellow]ℹ️ No changes needed or applied[/yellow]"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
except Exception as e:
|
|
368
|
+
console.print(f"[red]Error processing PR:[/red] {e}")
|
|
369
|
+
if not quiet:
|
|
370
|
+
import traceback
|
|
371
|
+
|
|
372
|
+
console.print("[dim]" + traceback.format_exc() + "[/dim]")
|
|
373
|
+
raise typer.Exit(1) from e
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def scan_and_fix_organization(
|
|
377
|
+
org: str,
|
|
378
|
+
token: str,
|
|
379
|
+
fix_title: bool,
|
|
380
|
+
fix_body: bool,
|
|
381
|
+
include_drafts: bool,
|
|
382
|
+
blocked_only: bool,
|
|
383
|
+
dry_run: bool,
|
|
384
|
+
workers: int,
|
|
385
|
+
quiet: bool,
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Scan organization for PRs needing fixes and fix them.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
org: Organization name
|
|
391
|
+
token: GitHub token
|
|
392
|
+
fix_title: Whether to fix PR titles
|
|
393
|
+
fix_body: Whether to fix PR bodies
|
|
394
|
+
include_drafts: Whether to include draft PRs
|
|
395
|
+
blocked_only: Whether to only process blocked/unmergeable PRs
|
|
396
|
+
dry_run: Whether to preview without applying changes
|
|
397
|
+
workers: Number of parallel workers
|
|
398
|
+
quiet: Whether to suppress output
|
|
399
|
+
"""
|
|
400
|
+
if not quiet:
|
|
401
|
+
console.print(f"🔍 Scanning organization: {org}")
|
|
402
|
+
fixes = []
|
|
403
|
+
if fix_title:
|
|
404
|
+
fixes.append("titles")
|
|
405
|
+
if fix_body:
|
|
406
|
+
fixes.append("bodies")
|
|
407
|
+
console.print(f"🔧 Will fix: {', '.join(fixes)}")
|
|
408
|
+
if blocked_only:
|
|
409
|
+
console.print("🚫 Filtering to blocked/unmergeable PRs only")
|
|
410
|
+
if dry_run:
|
|
411
|
+
console.print("🏃 Dry run mode: no changes will be applied")
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
async with GitHubClient(token) as client: # type: ignore[attr-defined]
|
|
415
|
+
# Validate token before proceeding
|
|
416
|
+
try:
|
|
417
|
+
is_valid, username, scopes = await client.validate_token()
|
|
418
|
+
if not quiet:
|
|
419
|
+
console.print(f"✓ Token validated for user: {username}")
|
|
420
|
+
if scopes:
|
|
421
|
+
# Only check scopes if we were able to retrieve them
|
|
422
|
+
if "repo" not in scopes and "public_repo" not in scopes:
|
|
423
|
+
console.print(
|
|
424
|
+
"[yellow]⚠️ Warning: Token may not have required 'repo' scope[/yellow]"
|
|
425
|
+
)
|
|
426
|
+
if blocked_only and "read:org" not in scopes:
|
|
427
|
+
console.print(
|
|
428
|
+
"[yellow]⚠️ Warning: Token may not have 'read:org' scope needed for status checks[/yellow]"
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
# GitHub Actions tokens don't report scopes via /user endpoint
|
|
432
|
+
console.print(
|
|
433
|
+
"[dim]Note: Unable to verify token scopes (expected for GitHub Actions tokens)[/dim]"
|
|
434
|
+
)
|
|
435
|
+
console.print()
|
|
436
|
+
except Exception as e:
|
|
437
|
+
console.print(f"[red]✗ Token validation failed: {e}[/red]")
|
|
438
|
+
console.print(
|
|
439
|
+
"[yellow]Hint: Ensure GITHUB_TOKEN has 'repo' and 'read:org' scopes and access to the organization[/yellow]"
|
|
440
|
+
)
|
|
441
|
+
raise typer.Exit(1) from e
|
|
442
|
+
|
|
443
|
+
# Create progress tracker for visual feedback
|
|
444
|
+
progress_tracker = (
|
|
445
|
+
None if quiet else ProgressTracker(org, show_pr_stats=True)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
scanner = PRScanner(
|
|
449
|
+
client,
|
|
450
|
+
progress_tracker=progress_tracker,
|
|
451
|
+
max_repo_tasks=workers,
|
|
452
|
+
max_page_tasks=workers * 2,
|
|
453
|
+
)
|
|
454
|
+
# Collect blocked PRs
|
|
455
|
+
blocked_prs: list[tuple[str, str, dict[str, Any]]] = []
|
|
456
|
+
|
|
457
|
+
# Note: progress_tracker.start() is called by scanner after counting repos
|
|
458
|
+
try:
|
|
459
|
+
async for (
|
|
460
|
+
owner,
|
|
461
|
+
repo_name,
|
|
462
|
+
pr_data,
|
|
463
|
+
) in scanner.scan_organization(
|
|
464
|
+
org, include_drafts=include_drafts
|
|
465
|
+
):
|
|
466
|
+
# Store blocked PR info
|
|
467
|
+
blocked_prs.append((owner, repo_name, pr_data))
|
|
468
|
+
|
|
469
|
+
except Exception as scan_error:
|
|
470
|
+
if progress_tracker:
|
|
471
|
+
progress_tracker.stop()
|
|
472
|
+
console.print(
|
|
473
|
+
f"\n[yellow]⚠️ Scanning interrupted: {scan_error}[/yellow]"
|
|
474
|
+
)
|
|
475
|
+
console.print("[yellow]Processing PRs found so far...[/yellow]")
|
|
476
|
+
|
|
477
|
+
# Stop progress tracker
|
|
478
|
+
if progress_tracker:
|
|
479
|
+
progress_tracker.stop()
|
|
480
|
+
|
|
481
|
+
if not blocked_prs:
|
|
482
|
+
console.print("\n[green]✅ No blocked PRs found![/green]")
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
if not quiet:
|
|
486
|
+
console.print(
|
|
487
|
+
f"\n🔍 Checking {len(blocked_prs)} blocked pull request{'s' if len(blocked_prs) != 1 else ''}...\n"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Phase 4: Process PRs in parallel using semaphore for concurrency control
|
|
491
|
+
semaphore = asyncio.Semaphore(workers)
|
|
492
|
+
tasks = []
|
|
493
|
+
|
|
494
|
+
for owner, repo_name, pr_data in blocked_prs:
|
|
495
|
+
task = process_pr(
|
|
496
|
+
client=client,
|
|
497
|
+
owner=owner,
|
|
498
|
+
repo_name=repo_name,
|
|
499
|
+
pr_data=pr_data,
|
|
500
|
+
fix_title=fix_title,
|
|
501
|
+
fix_body=fix_body,
|
|
502
|
+
dry_run=dry_run,
|
|
503
|
+
quiet=quiet,
|
|
504
|
+
semaphore=semaphore,
|
|
505
|
+
)
|
|
506
|
+
tasks.append(asyncio.create_task(task))
|
|
507
|
+
|
|
508
|
+
# Wait for all processing to complete
|
|
509
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
510
|
+
|
|
511
|
+
# Process results and output
|
|
512
|
+
success_count = 0
|
|
513
|
+
failed_count = 0
|
|
514
|
+
|
|
515
|
+
if not quiet:
|
|
516
|
+
for result in results:
|
|
517
|
+
if isinstance(result, Exception):
|
|
518
|
+
failed_count += 1
|
|
519
|
+
console.print(
|
|
520
|
+
f"[red]❌ Failed to update pull request: {result}[/red]"
|
|
521
|
+
)
|
|
522
|
+
elif isinstance(result, dict):
|
|
523
|
+
status = result.get("status")
|
|
524
|
+
pr_id = result.get("pr_id", "unknown")
|
|
525
|
+
|
|
526
|
+
if status == "success":
|
|
527
|
+
success_count += 1
|
|
528
|
+
if not dry_run:
|
|
529
|
+
# Show successful update with Previous/Updated labels
|
|
530
|
+
console.print(f"[green]✅ {pr_id}[/green]")
|
|
531
|
+
title_info = result.get("title")
|
|
532
|
+
if title_info:
|
|
533
|
+
console.print(
|
|
534
|
+
f" Previous: {title_info['previous']}"
|
|
535
|
+
)
|
|
536
|
+
console.print(
|
|
537
|
+
f" Updated: {title_info['updated']}"
|
|
538
|
+
)
|
|
539
|
+
elif status == "failed":
|
|
540
|
+
failed_count += 1
|
|
541
|
+
error_msg = result.get("error", "Unknown error")
|
|
542
|
+
console.print(
|
|
543
|
+
f"[red]❌ Failed to update pull request: {pr_id}[/red]"
|
|
544
|
+
)
|
|
545
|
+
if error_msg != "Unknown error":
|
|
546
|
+
console.print(f" Error: {error_msg}")
|
|
547
|
+
# Skip output for "no_change" status
|
|
548
|
+
else:
|
|
549
|
+
# Count results when quiet mode is on
|
|
550
|
+
for result in results:
|
|
551
|
+
if isinstance(result, Exception):
|
|
552
|
+
failed_count += 1
|
|
553
|
+
elif isinstance(result, dict):
|
|
554
|
+
status = result.get("status")
|
|
555
|
+
if status == "success":
|
|
556
|
+
success_count += 1
|
|
557
|
+
elif status == "failed":
|
|
558
|
+
failed_count += 1
|
|
559
|
+
|
|
560
|
+
# Summary
|
|
561
|
+
if not quiet:
|
|
562
|
+
console.print()
|
|
563
|
+
if dry_run:
|
|
564
|
+
console.print(
|
|
565
|
+
f"☑️ {success_count} blocked pull request{'s' if success_count != 1 else ''} need attention"
|
|
566
|
+
)
|
|
567
|
+
else:
|
|
568
|
+
console.print(
|
|
569
|
+
f"[green]✅ Updated pull requests: {success_count}[/green]"
|
|
570
|
+
)
|
|
571
|
+
if failed_count > 0:
|
|
572
|
+
console.print(
|
|
573
|
+
f"[red]❌ Failed updates: {failed_count}[/red]"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
except Exception as e:
|
|
577
|
+
console.print(f"[red]Error scanning organization:[/red] {e}")
|
|
578
|
+
if not quiet:
|
|
579
|
+
import traceback
|
|
580
|
+
|
|
581
|
+
console.print("[dim]" + traceback.format_exc() + "[/dim]")
|
|
582
|
+
raise typer.Exit(1) from e
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
async def process_pr(
|
|
586
|
+
client: GitHubClient,
|
|
587
|
+
owner: str,
|
|
588
|
+
repo_name: str,
|
|
589
|
+
pr_data: dict[str, Any],
|
|
590
|
+
fix_title: bool,
|
|
591
|
+
fix_body: bool,
|
|
592
|
+
dry_run: bool,
|
|
593
|
+
quiet: bool,
|
|
594
|
+
semaphore: asyncio.Semaphore,
|
|
595
|
+
) -> dict[str, Any]:
|
|
596
|
+
"""Process a single PR to fix title and/or body.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
client: GitHub API client
|
|
600
|
+
owner: Repository owner
|
|
601
|
+
repo_name: Repository name
|
|
602
|
+
pr_data: PR data from scanner
|
|
603
|
+
fix_title: Whether to fix title
|
|
604
|
+
fix_body: Whether to fix body
|
|
605
|
+
dry_run: Whether this is a dry run
|
|
606
|
+
quiet: Whether to suppress output
|
|
607
|
+
semaphore: Semaphore for concurrency control
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
Dict with status: 'success', 'failed', 'no_change', and optional details
|
|
611
|
+
"""
|
|
612
|
+
async with semaphore:
|
|
613
|
+
pr_number: int | None = pr_data.get("number")
|
|
614
|
+
pr_title = pr_data.get("title", "")
|
|
615
|
+
pr_id = f"{owner}/{repo_name}#{pr_number}"
|
|
616
|
+
|
|
617
|
+
if pr_number is None:
|
|
618
|
+
return {
|
|
619
|
+
"status": "failed",
|
|
620
|
+
"pr_id": pr_id,
|
|
621
|
+
"error": "PR number not found",
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
try:
|
|
625
|
+
# Get first commit info
|
|
626
|
+
commit_info = await get_first_commit_info(
|
|
627
|
+
client, owner, repo_name, pr_number
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
if not commit_info:
|
|
631
|
+
return {
|
|
632
|
+
"status": "failed",
|
|
633
|
+
"pr_id": pr_id,
|
|
634
|
+
"error": "Could not retrieve commit info",
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
commit_subject = commit_info.get("subject", "").strip()
|
|
638
|
+
commit_body = commit_info.get("body", "").strip()
|
|
639
|
+
|
|
640
|
+
changes_needed = False
|
|
641
|
+
title_result = None
|
|
642
|
+
body_result = None
|
|
643
|
+
|
|
644
|
+
# Check if title needs fixing
|
|
645
|
+
if fix_title and commit_subject and commit_subject != pr_title:
|
|
646
|
+
changes_needed = True
|
|
647
|
+
if dry_run:
|
|
648
|
+
if not quiet:
|
|
649
|
+
console.print(f"🔄 {pr_id}")
|
|
650
|
+
console.print(f" Current: {pr_title}")
|
|
651
|
+
console.print(f" Fixed: {commit_subject}")
|
|
652
|
+
title_result = {
|
|
653
|
+
"success": True,
|
|
654
|
+
"previous": pr_title,
|
|
655
|
+
"updated": commit_subject,
|
|
656
|
+
}
|
|
657
|
+
else:
|
|
658
|
+
# Update PR title (silently during processing)
|
|
659
|
+
success = await update_pr_title(
|
|
660
|
+
client, owner, repo_name, pr_number, commit_subject
|
|
661
|
+
)
|
|
662
|
+
title_result = {
|
|
663
|
+
"success": success,
|
|
664
|
+
"previous": pr_title,
|
|
665
|
+
"updated": commit_subject,
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
# Check if body needs fixing
|
|
669
|
+
if fix_body and commit_body:
|
|
670
|
+
# Get current PR body
|
|
671
|
+
current_body = pr_data.get("body", "").strip()
|
|
672
|
+
|
|
673
|
+
if commit_body != current_body:
|
|
674
|
+
changes_needed = True
|
|
675
|
+
if dry_run:
|
|
676
|
+
if not quiet:
|
|
677
|
+
console.print(f"🔄 {pr_id}")
|
|
678
|
+
console.print(" Would update body")
|
|
679
|
+
console.print(
|
|
680
|
+
f" Length: {len(commit_body)} chars"
|
|
681
|
+
)
|
|
682
|
+
body_result = {"success": True}
|
|
683
|
+
else:
|
|
684
|
+
# Update PR body (silently during processing)
|
|
685
|
+
success = await update_pr_body(
|
|
686
|
+
client, owner, repo_name, pr_number, commit_body
|
|
687
|
+
)
|
|
688
|
+
body_result = {"success": success}
|
|
689
|
+
|
|
690
|
+
if not changes_needed:
|
|
691
|
+
return {"status": "no_change", "pr_id": pr_id}
|
|
692
|
+
|
|
693
|
+
# Create a comment on the PR if changes were made (not in dry-run)
|
|
694
|
+
if not dry_run and (title_result or body_result):
|
|
695
|
+
changes_made = []
|
|
696
|
+
if title_result and title_result.get("success"):
|
|
697
|
+
changes_made.append("title")
|
|
698
|
+
if body_result and body_result.get("success"):
|
|
699
|
+
changes_made.append("body")
|
|
700
|
+
|
|
701
|
+
if changes_made:
|
|
702
|
+
await create_pr_comment(
|
|
703
|
+
client, owner, repo_name, pr_number, changes_made
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# Determine overall status
|
|
707
|
+
has_success = (title_result and title_result.get("success")) or (
|
|
708
|
+
body_result and body_result.get("success")
|
|
709
|
+
)
|
|
710
|
+
has_failure = (
|
|
711
|
+
title_result and not title_result.get("success")
|
|
712
|
+
) or (body_result and not body_result.get("success"))
|
|
713
|
+
|
|
714
|
+
if has_failure:
|
|
715
|
+
status = "failed"
|
|
716
|
+
elif has_success:
|
|
717
|
+
status = "success"
|
|
718
|
+
else:
|
|
719
|
+
status = "no_change"
|
|
720
|
+
|
|
721
|
+
return {
|
|
722
|
+
"status": status,
|
|
723
|
+
"pr_id": pr_id,
|
|
724
|
+
"title": title_result,
|
|
725
|
+
"body": body_result,
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
except Exception as e:
|
|
729
|
+
return {"status": "failed", "pr_id": pr_id, "error": str(e)}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
async def get_first_commit_info(
|
|
733
|
+
client: GitHubClient,
|
|
734
|
+
owner: str,
|
|
735
|
+
repo: str,
|
|
736
|
+
pr_number: int,
|
|
737
|
+
) -> dict[str, str] | None:
|
|
738
|
+
"""Get the first commit's message from a PR.
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
client: GitHub API client
|
|
742
|
+
owner: Repository owner
|
|
743
|
+
repo: Repository name
|
|
744
|
+
pr_number: PR number
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Dict with 'subject' and 'body' keys, or None if error
|
|
748
|
+
"""
|
|
749
|
+
try:
|
|
750
|
+
# Get commits for the PR
|
|
751
|
+
endpoint = f"/repos/{owner}/{repo}/pulls/{pr_number}/commits"
|
|
752
|
+
response = await client._request("GET", endpoint)
|
|
753
|
+
|
|
754
|
+
if not response or not isinstance(response, list) or len(response) == 0:
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
# Get first commit
|
|
758
|
+
first_commit = response[0]
|
|
759
|
+
commit_data = first_commit.get("commit", {})
|
|
760
|
+
message = commit_data.get("message", "")
|
|
761
|
+
|
|
762
|
+
# Parse commit message into subject and body
|
|
763
|
+
subject, body = parse_commit_message(message)
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
"subject": subject,
|
|
767
|
+
"body": body,
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
except Exception as e:
|
|
771
|
+
console.print(f"[red]Error getting commit info: {e}[/red]")
|
|
772
|
+
return None
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def parse_commit_message(message: str) -> tuple[str, str]:
|
|
776
|
+
"""Parse a commit message into subject and body.
|
|
777
|
+
|
|
778
|
+
Removes trailers like 'Signed-off-by:', 'Co-authored-by:', etc.
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
message: Full commit message
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
Tuple of (subject, body) where body has trailers removed
|
|
785
|
+
"""
|
|
786
|
+
lines = message.split("\n")
|
|
787
|
+
|
|
788
|
+
if not lines:
|
|
789
|
+
return "", ""
|
|
790
|
+
|
|
791
|
+
# First line is the subject
|
|
792
|
+
subject = lines[0].strip()
|
|
793
|
+
|
|
794
|
+
# Rest is body (skip empty line after subject if present)
|
|
795
|
+
body_lines = lines[1:]
|
|
796
|
+
|
|
797
|
+
# Skip leading empty lines
|
|
798
|
+
while body_lines and not body_lines[0].strip():
|
|
799
|
+
body_lines.pop(0)
|
|
800
|
+
|
|
801
|
+
# Remove trailers from the end
|
|
802
|
+
# Common trailer patterns
|
|
803
|
+
trailer_patterns = [
|
|
804
|
+
r"^Signed-off-by:",
|
|
805
|
+
r"^Co-authored-by:",
|
|
806
|
+
r"^Reviewed-by:",
|
|
807
|
+
r"^Tested-by:",
|
|
808
|
+
r"^Acked-by:",
|
|
809
|
+
r"^Cc:",
|
|
810
|
+
r"^Reported-by:",
|
|
811
|
+
r"^Suggested-by:",
|
|
812
|
+
r"^Fixes:",
|
|
813
|
+
r"^See-also:",
|
|
814
|
+
r"^Link:",
|
|
815
|
+
r"^Bug:",
|
|
816
|
+
r"^Change-Id:",
|
|
817
|
+
]
|
|
818
|
+
|
|
819
|
+
# Find where trailers start (from the end)
|
|
820
|
+
trailer_start_idx = len(body_lines)
|
|
821
|
+
|
|
822
|
+
for i in range(len(body_lines) - 1, -1, -1):
|
|
823
|
+
line = body_lines[i].strip()
|
|
824
|
+
|
|
825
|
+
# Empty line before trailers is ok
|
|
826
|
+
if not line:
|
|
827
|
+
continue
|
|
828
|
+
|
|
829
|
+
# Check if this line is a trailer
|
|
830
|
+
is_trailer = False
|
|
831
|
+
for pattern in trailer_patterns:
|
|
832
|
+
if re.match(pattern, line, re.IGNORECASE):
|
|
833
|
+
is_trailer = True
|
|
834
|
+
break
|
|
835
|
+
|
|
836
|
+
if is_trailer:
|
|
837
|
+
# This line and everything after is a trailer
|
|
838
|
+
trailer_start_idx = i
|
|
839
|
+
else:
|
|
840
|
+
# Found a non-trailer, non-empty line, stop looking
|
|
841
|
+
break
|
|
842
|
+
|
|
843
|
+
# Get body without trailers
|
|
844
|
+
body_lines = body_lines[:trailer_start_idx]
|
|
845
|
+
|
|
846
|
+
# Remove trailing empty lines
|
|
847
|
+
while body_lines and not body_lines[-1].strip():
|
|
848
|
+
body_lines.pop()
|
|
849
|
+
|
|
850
|
+
body = "\n".join(body_lines).strip()
|
|
851
|
+
|
|
852
|
+
return subject, body
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
async def update_pr_title(
|
|
856
|
+
client: GitHubClient,
|
|
857
|
+
owner: str,
|
|
858
|
+
repo: str,
|
|
859
|
+
pr_number: int,
|
|
860
|
+
new_title: str,
|
|
861
|
+
) -> bool:
|
|
862
|
+
"""Update a PR's title.
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
client: GitHub API client
|
|
866
|
+
owner: Repository owner
|
|
867
|
+
repo: Repository name
|
|
868
|
+
pr_number: PR number
|
|
869
|
+
new_title: New title to set
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
True if successful, False otherwise
|
|
873
|
+
"""
|
|
874
|
+
try:
|
|
875
|
+
endpoint = f"/repos/{owner}/{repo}/pulls/{pr_number}"
|
|
876
|
+
data = {"title": new_title}
|
|
877
|
+
|
|
878
|
+
response = await client._request("PATCH", endpoint, json=data)
|
|
879
|
+
|
|
880
|
+
# If successful, trigger re-run of failed checks
|
|
881
|
+
if response is not None:
|
|
882
|
+
await rerun_failed_checks(client, owner, repo, pr_number)
|
|
883
|
+
|
|
884
|
+
return response is not None
|
|
885
|
+
|
|
886
|
+
except Exception as e:
|
|
887
|
+
console.print(f"[red]Error updating PR title: {e}[/red]")
|
|
888
|
+
return False
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
async def update_pr_body(
|
|
892
|
+
client: GitHubClient,
|
|
893
|
+
owner: str,
|
|
894
|
+
repo: str,
|
|
895
|
+
pr_number: int,
|
|
896
|
+
new_body: str,
|
|
897
|
+
) -> bool:
|
|
898
|
+
"""Update a PR's body.
|
|
899
|
+
|
|
900
|
+
Args:
|
|
901
|
+
client: GitHub API client
|
|
902
|
+
owner: Repository owner
|
|
903
|
+
repo: Repository name
|
|
904
|
+
pr_number: PR number
|
|
905
|
+
new_body: New body to set
|
|
906
|
+
|
|
907
|
+
Returns:
|
|
908
|
+
True if successful, False otherwise
|
|
909
|
+
"""
|
|
910
|
+
try:
|
|
911
|
+
endpoint = f"/repos/{owner}/{repo}/pulls/{pr_number}"
|
|
912
|
+
data = {"body": new_body}
|
|
913
|
+
|
|
914
|
+
response = await client._request("PATCH", endpoint, json=data)
|
|
915
|
+
|
|
916
|
+
# If successful, trigger re-run of failed checks
|
|
917
|
+
if response is not None:
|
|
918
|
+
await rerun_failed_checks(client, owner, repo, pr_number)
|
|
919
|
+
|
|
920
|
+
return response is not None
|
|
921
|
+
|
|
922
|
+
except Exception as e:
|
|
923
|
+
console.print(f"[red]Error updating PR body: {e}[/red]")
|
|
924
|
+
return False
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
async def rerun_failed_checks(
|
|
928
|
+
client: GitHubClient,
|
|
929
|
+
owner: str,
|
|
930
|
+
repo: str,
|
|
931
|
+
pr_number: int,
|
|
932
|
+
) -> bool:
|
|
933
|
+
"""Re-run failed checks on a PR after updates.
|
|
934
|
+
|
|
935
|
+
This function attempts to trigger a re-run of failed checks by:
|
|
936
|
+
1. Getting the head SHA of the PR
|
|
937
|
+
2. Finding failed check runs for that SHA
|
|
938
|
+
3. Re-requesting each failed check run
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
client: GitHub API client
|
|
942
|
+
owner: Repository owner
|
|
943
|
+
repo: Repository name
|
|
944
|
+
pr_number: PR number
|
|
945
|
+
"""
|
|
946
|
+
try:
|
|
947
|
+
# Get PR to find head SHA
|
|
948
|
+
pr_endpoint = f"/repos/{owner}/{repo}/pulls/{pr_number}"
|
|
949
|
+
pr_data_response = await client._request("GET", pr_endpoint)
|
|
950
|
+
|
|
951
|
+
if not pr_data_response or not isinstance(pr_data_response, dict):
|
|
952
|
+
return False
|
|
953
|
+
|
|
954
|
+
head_sha = pr_data_response.get("head", {}).get("sha")
|
|
955
|
+
if not head_sha:
|
|
956
|
+
return False
|
|
957
|
+
|
|
958
|
+
# Get check runs for this commit
|
|
959
|
+
checks_endpoint = f"/repos/{owner}/{repo}/commits/{head_sha}/check-runs"
|
|
960
|
+
checks_data_response = await client._request("GET", checks_endpoint)
|
|
961
|
+
|
|
962
|
+
if not checks_data_response or not isinstance(
|
|
963
|
+
checks_data_response, dict
|
|
964
|
+
):
|
|
965
|
+
return False
|
|
966
|
+
|
|
967
|
+
check_runs = checks_data_response.get("check_runs", [])
|
|
968
|
+
|
|
969
|
+
# Find failed or cancelled check runs
|
|
970
|
+
failed_runs = [
|
|
971
|
+
run
|
|
972
|
+
for run in check_runs
|
|
973
|
+
if run.get("conclusion")
|
|
974
|
+
in ["failure", "cancelled", "timed_out", "action_required"]
|
|
975
|
+
and run.get("status") == "completed"
|
|
976
|
+
]
|
|
977
|
+
|
|
978
|
+
# Re-run each failed check
|
|
979
|
+
for run in failed_runs:
|
|
980
|
+
run_id = run.get("id")
|
|
981
|
+
if run_id:
|
|
982
|
+
try:
|
|
983
|
+
rerun_endpoint = (
|
|
984
|
+
f"/repos/{owner}/{repo}/check-runs/{run_id}/rerequest"
|
|
985
|
+
)
|
|
986
|
+
await client._request("POST", rerun_endpoint)
|
|
987
|
+
except Exception:
|
|
988
|
+
# Silently ignore errors - not all checks support re-run
|
|
989
|
+
pass
|
|
990
|
+
|
|
991
|
+
return True
|
|
992
|
+
except Exception:
|
|
993
|
+
# Silently ignore errors - re-running checks is best-effort
|
|
994
|
+
return False
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
async def create_pr_comment(
|
|
998
|
+
client: GitHubClient,
|
|
999
|
+
owner: str,
|
|
1000
|
+
repo: str,
|
|
1001
|
+
pr_number: int,
|
|
1002
|
+
changes_made: list[str],
|
|
1003
|
+
) -> None:
|
|
1004
|
+
"""Create a comment on the PR summarizing the fixes applied.
|
|
1005
|
+
|
|
1006
|
+
Args:
|
|
1007
|
+
client: GitHub API client
|
|
1008
|
+
owner: Repository owner
|
|
1009
|
+
repo: Repository name
|
|
1010
|
+
pr_number: PR number
|
|
1011
|
+
changes_made: List of changes made (e.g., ["title", "body"])
|
|
1012
|
+
"""
|
|
1013
|
+
try:
|
|
1014
|
+
# Build the comment body
|
|
1015
|
+
lines = [
|
|
1016
|
+
"## 🛠️ Pull Request Fixer",
|
|
1017
|
+
"",
|
|
1018
|
+
"Automatically fixed pull request metadata:",
|
|
1019
|
+
]
|
|
1020
|
+
|
|
1021
|
+
# Add specific fixes
|
|
1022
|
+
if "title" in changes_made:
|
|
1023
|
+
lines.append(
|
|
1024
|
+
"- **Pull request title** updated to match first commit"
|
|
1025
|
+
)
|
|
1026
|
+
if "body" in changes_made:
|
|
1027
|
+
lines.append(
|
|
1028
|
+
"- **Pull request body** updated to match commit message"
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
lines.extend(
|
|
1032
|
+
[
|
|
1033
|
+
"",
|
|
1034
|
+
"---",
|
|
1035
|
+
"*This fix was automatically applied by "
|
|
1036
|
+
"[pull-request-fixer](https://github.com/lfit/pull-request-fixer)*",
|
|
1037
|
+
]
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
comment_body = "\n".join(lines)
|
|
1041
|
+
|
|
1042
|
+
# Create the comment
|
|
1043
|
+
endpoint = f"/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
|
1044
|
+
data = {"body": comment_body}
|
|
1045
|
+
|
|
1046
|
+
await client._request("POST", endpoint, json=data)
|
|
1047
|
+
|
|
1048
|
+
except Exception:
|
|
1049
|
+
# Silently ignore errors - commenting is best-effort
|
|
1050
|
+
pass
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def cli() -> None:
|
|
1054
|
+
"""CLI entry point."""
|
|
1055
|
+
typer.run(main)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
if __name__ == "__main__":
|
|
1059
|
+
cli()
|