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.
@@ -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()