cbb1cb8154
* feat(github): enhance PR merge readiness checks with branch state validation - Added support for checking if a PR branch is behind the base branch, introducing a new warning state for "Branch Out of Date." - Updated the verdict generation logic to classify this state as a soft blocker (NEEDS_REVISION) rather than a hard blocker. - Enhanced the merge readiness interface to include an `isBehind` property for better frontend integration. - Updated relevant services and handlers to accommodate the new branch state checks, ensuring accurate feedback during PR reviews. This improves the user experience by providing clearer guidance on necessary actions for PRs that are not up to date with the base branch. * fix: address PR feedback for branch-behind detection - Fix HIGH: Handle MERGE_WITH_CHANGES verdict when branch is behind - Fix MEDIUM: Extract duplicated reasoning strings to shared constants (BRANCH_BEHIND_BLOCKER_MSG, BRANCH_BEHIND_REASONING in models.py) - Fix LOW: Remove unreachable dead code for branch-behind checks in orchestrator.py and parallel_orchestrator_reviewer.py - Consolidate low-severity suggestions note into the active branch-behind path Co-authored-by: CodeRabbit <coderabbit@users.noreply.github.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1433 lines
53 KiB
Python
1433 lines
53 KiB
Python
"""
|
|
GitHub Automation Orchestrator
|
|
==============================
|
|
|
|
Main coordinator for all GitHub automation workflows:
|
|
- PR Review: AI-powered code review
|
|
- Issue Triage: Classification and labeling
|
|
- Issue Auto-Fix: Automatic spec creation and execution
|
|
|
|
This is a STANDALONE system - does not modify existing task execution pipeline.
|
|
|
|
REFACTORED: Service layer architecture - orchestrator delegates to specialized services.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
try:
|
|
# When imported as part of package
|
|
from .bot_detection import BotDetector
|
|
from .context_gatherer import PRContext, PRContextGatherer
|
|
from .gh_client import GHClient
|
|
from .models import (
|
|
BRANCH_BEHIND_BLOCKER_MSG,
|
|
BRANCH_BEHIND_REASONING,
|
|
AICommentTriage,
|
|
AICommentVerdict,
|
|
AutoFixState,
|
|
GitHubRunnerConfig,
|
|
MergeVerdict,
|
|
PRReviewFinding,
|
|
PRReviewResult,
|
|
ReviewCategory,
|
|
ReviewSeverity,
|
|
StructuralIssue,
|
|
TriageResult,
|
|
)
|
|
from .permissions import GitHubPermissionChecker
|
|
from .rate_limiter import RateLimiter
|
|
from .services import (
|
|
AutoFixProcessor,
|
|
BatchProcessor,
|
|
PRReviewEngine,
|
|
TriageEngine,
|
|
)
|
|
except (ImportError, ValueError, SystemError):
|
|
# When imported directly (runner.py adds github dir to path)
|
|
from bot_detection import BotDetector
|
|
from context_gatherer import PRContext, PRContextGatherer
|
|
from gh_client import GHClient
|
|
from models import (
|
|
BRANCH_BEHIND_BLOCKER_MSG,
|
|
BRANCH_BEHIND_REASONING,
|
|
AICommentTriage,
|
|
AICommentVerdict,
|
|
AutoFixState,
|
|
GitHubRunnerConfig,
|
|
MergeVerdict,
|
|
PRReviewFinding,
|
|
PRReviewResult,
|
|
ReviewCategory,
|
|
ReviewSeverity,
|
|
StructuralIssue,
|
|
TriageResult,
|
|
)
|
|
from permissions import GitHubPermissionChecker
|
|
from rate_limiter import RateLimiter
|
|
from services import (
|
|
AutoFixProcessor,
|
|
BatchProcessor,
|
|
PRReviewEngine,
|
|
TriageEngine,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ProgressCallback:
|
|
"""Callback for progress updates."""
|
|
|
|
phase: str
|
|
progress: int # 0-100
|
|
message: str
|
|
issue_number: int | None = None
|
|
pr_number: int | None = None
|
|
|
|
|
|
class GitHubOrchestrator:
|
|
"""
|
|
Orchestrates all GitHub automation workflows.
|
|
|
|
This is a thin coordinator that delegates to specialized service classes:
|
|
- PRReviewEngine: Multi-pass code review
|
|
- TriageEngine: Issue classification
|
|
- AutoFixProcessor: Automatic issue fixing
|
|
- BatchProcessor: Batch issue processing
|
|
|
|
Usage:
|
|
orchestrator = GitHubOrchestrator(
|
|
project_dir=Path("/path/to/project"),
|
|
config=config,
|
|
)
|
|
|
|
# Review a PR
|
|
result = await orchestrator.review_pr(pr_number=123)
|
|
|
|
# Triage issues
|
|
results = await orchestrator.triage_issues(issue_numbers=[1, 2, 3])
|
|
|
|
# Auto-fix an issue
|
|
state = await orchestrator.auto_fix_issue(issue_number=456)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
project_dir: Path,
|
|
config: GitHubRunnerConfig,
|
|
progress_callback: Callable[[ProgressCallback], None] | None = None,
|
|
):
|
|
self.project_dir = Path(project_dir)
|
|
self.config = config
|
|
self.progress_callback = progress_callback
|
|
|
|
# GitHub directory for storing state
|
|
self.github_dir = self.project_dir / ".auto-claude" / "github"
|
|
self.github_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Initialize GH client with timeout protection
|
|
self.gh_client = GHClient(
|
|
project_dir=self.project_dir,
|
|
default_timeout=30.0,
|
|
max_retries=3,
|
|
enable_rate_limiting=True,
|
|
repo=config.repo,
|
|
)
|
|
|
|
# Initialize bot detector for preventing infinite loops
|
|
self.bot_detector = BotDetector(
|
|
state_dir=self.github_dir,
|
|
bot_token=config.bot_token,
|
|
review_own_prs=config.review_own_prs,
|
|
)
|
|
|
|
# Initialize permission checker for auto-fix authorization
|
|
self.permission_checker = GitHubPermissionChecker(
|
|
gh_client=self.gh_client,
|
|
repo=config.repo,
|
|
allowed_roles=config.auto_fix_allowed_roles,
|
|
allow_external_contributors=config.allow_external_contributors,
|
|
)
|
|
|
|
# Initialize rate limiter singleton
|
|
self.rate_limiter = RateLimiter.get_instance()
|
|
|
|
# Initialize service layer
|
|
self.pr_review_engine = PRReviewEngine(
|
|
project_dir=self.project_dir,
|
|
github_dir=self.github_dir,
|
|
config=self.config,
|
|
progress_callback=self.progress_callback,
|
|
)
|
|
|
|
self.triage_engine = TriageEngine(
|
|
project_dir=self.project_dir,
|
|
github_dir=self.github_dir,
|
|
config=self.config,
|
|
progress_callback=self.progress_callback,
|
|
)
|
|
|
|
self.autofix_processor = AutoFixProcessor(
|
|
github_dir=self.github_dir,
|
|
config=self.config,
|
|
permission_checker=self.permission_checker,
|
|
progress_callback=self.progress_callback,
|
|
)
|
|
|
|
self.batch_processor = BatchProcessor(
|
|
project_dir=self.project_dir,
|
|
github_dir=self.github_dir,
|
|
config=self.config,
|
|
progress_callback=self.progress_callback,
|
|
)
|
|
|
|
def _report_progress(
|
|
self,
|
|
phase: str,
|
|
progress: int,
|
|
message: str,
|
|
issue_number: int | None = None,
|
|
pr_number: int | None = None,
|
|
) -> None:
|
|
"""Report progress to callback if set."""
|
|
if self.progress_callback:
|
|
self.progress_callback(
|
|
ProgressCallback(
|
|
phase=phase,
|
|
progress=progress,
|
|
message=message,
|
|
issue_number=issue_number,
|
|
pr_number=pr_number,
|
|
)
|
|
)
|
|
|
|
# =========================================================================
|
|
# GitHub API Helpers
|
|
# =========================================================================
|
|
|
|
async def _fetch_pr_data(self, pr_number: int) -> dict:
|
|
"""Fetch PR data from GitHub API via gh CLI."""
|
|
return await self.gh_client.pr_get(pr_number)
|
|
|
|
async def _fetch_pr_diff(self, pr_number: int) -> str:
|
|
"""Fetch PR diff from GitHub."""
|
|
return await self.gh_client.pr_diff(pr_number)
|
|
|
|
async def _fetch_issue_data(self, issue_number: int) -> dict:
|
|
"""Fetch issue data from GitHub API via gh CLI."""
|
|
return await self.gh_client.issue_get(issue_number)
|
|
|
|
async def _fetch_open_issues(self, limit: int = 200) -> list[dict]:
|
|
"""Fetch all open issues from the repository (up to 200)."""
|
|
return await self.gh_client.issue_list(state="open", limit=limit)
|
|
|
|
async def _post_pr_review(
|
|
self,
|
|
pr_number: int,
|
|
body: str,
|
|
event: str = "COMMENT",
|
|
) -> int:
|
|
"""Post a review to a PR."""
|
|
return await self.gh_client.pr_review(
|
|
pr_number=pr_number,
|
|
body=body,
|
|
event=event.lower(),
|
|
)
|
|
|
|
async def _post_issue_comment(self, issue_number: int, body: str) -> None:
|
|
"""Post a comment to an issue."""
|
|
await self.gh_client.issue_comment(issue_number, body)
|
|
|
|
async def _add_issue_labels(self, issue_number: int, labels: list[str]) -> None:
|
|
"""Add labels to an issue."""
|
|
await self.gh_client.issue_add_labels(issue_number, labels)
|
|
|
|
async def _remove_issue_labels(self, issue_number: int, labels: list[str]) -> None:
|
|
"""Remove labels from an issue."""
|
|
await self.gh_client.issue_remove_labels(issue_number, labels)
|
|
|
|
async def _post_ai_triage_replies(
|
|
self, pr_number: int, triages: list[AICommentTriage]
|
|
) -> None:
|
|
"""Post replies to AI tool comments based on triage results."""
|
|
for triage in triages:
|
|
if not triage.response_comment:
|
|
continue
|
|
|
|
# Skip trivial verdicts
|
|
if triage.verdict == AICommentVerdict.TRIVIAL:
|
|
continue
|
|
|
|
try:
|
|
# Post as inline comment reply
|
|
await self.gh_client.pr_comment_reply(
|
|
pr_number=pr_number,
|
|
comment_id=triage.comment_id,
|
|
body=triage.response_comment,
|
|
)
|
|
print(
|
|
f"[AI TRIAGE] Posted reply to {triage.tool_name} comment {triage.comment_id}",
|
|
flush=True,
|
|
)
|
|
except Exception as e:
|
|
print(
|
|
f"[AI TRIAGE] Failed to post reply to comment {triage.comment_id}: {e}",
|
|
flush=True,
|
|
)
|
|
|
|
# =========================================================================
|
|
# PR REVIEW WORKFLOW
|
|
# =========================================================================
|
|
|
|
async def review_pr(
|
|
self, pr_number: int, force_review: bool = False
|
|
) -> PRReviewResult:
|
|
"""
|
|
Perform AI-powered review of a pull request.
|
|
|
|
Args:
|
|
pr_number: The PR number to review
|
|
force_review: If True, bypass the "already reviewed" check and force a new review.
|
|
Useful for re-validating a PR or testing the review system.
|
|
|
|
Returns:
|
|
PRReviewResult with findings and overall assessment
|
|
"""
|
|
print(
|
|
f"[DEBUG orchestrator] review_pr() called for PR #{pr_number}", flush=True
|
|
)
|
|
|
|
self._report_progress(
|
|
"gathering_context",
|
|
10,
|
|
f"Gathering context for PR #{pr_number}...",
|
|
pr_number=pr_number,
|
|
)
|
|
|
|
try:
|
|
# Gather PR context
|
|
print("[DEBUG orchestrator] Creating context gatherer...", flush=True)
|
|
gatherer = PRContextGatherer(
|
|
self.project_dir, pr_number, repo=self.config.repo
|
|
)
|
|
|
|
print("[DEBUG orchestrator] Gathering PR context...", flush=True)
|
|
pr_context = await gatherer.gather()
|
|
print(
|
|
f"[DEBUG orchestrator] Context gathered: {pr_context.title} "
|
|
f"({len(pr_context.changed_files)} files, {len(pr_context.related_files)} related)",
|
|
flush=True,
|
|
)
|
|
|
|
# Bot detection check
|
|
pr_data = {"author": {"login": pr_context.author}}
|
|
should_skip, skip_reason = self.bot_detector.should_skip_pr_review(
|
|
pr_number=pr_number,
|
|
pr_data=pr_data,
|
|
commits=pr_context.commits,
|
|
)
|
|
|
|
# Allow forcing a review to bypass "already reviewed" check
|
|
if should_skip and force_review and "Already reviewed" in skip_reason:
|
|
print(
|
|
f"[BOT DETECTION] Force review requested - bypassing: {skip_reason}",
|
|
flush=True,
|
|
)
|
|
should_skip = False
|
|
|
|
if should_skip:
|
|
print(
|
|
f"[BOT DETECTION] Skipping PR #{pr_number}: {skip_reason}",
|
|
flush=True,
|
|
)
|
|
|
|
# If skipping because "Already reviewed", return the existing review
|
|
# instead of creating a new empty "skipped" result
|
|
if "Already reviewed" in skip_reason:
|
|
existing_review = PRReviewResult.load(self.github_dir, pr_number)
|
|
if existing_review:
|
|
print(
|
|
"[BOT DETECTION] Returning existing review (no new commits)",
|
|
flush=True,
|
|
)
|
|
# Don't overwrite - return the existing review as-is
|
|
# The frontend will see "no new commits" via the newCommitsCheck
|
|
return existing_review
|
|
|
|
# For other skip reasons (bot-authored, cooling off), create a skip result
|
|
result = PRReviewResult(
|
|
pr_number=pr_number,
|
|
repo=self.config.repo,
|
|
success=True,
|
|
findings=[],
|
|
summary=f"Skipped review: {skip_reason}",
|
|
overall_status="comment",
|
|
)
|
|
await result.save(self.github_dir)
|
|
return result
|
|
|
|
self._report_progress(
|
|
"analyzing", 30, "Running multi-pass review...", pr_number=pr_number
|
|
)
|
|
|
|
# Delegate to PR Review Engine
|
|
print("[DEBUG orchestrator] Running multi-pass review...", flush=True)
|
|
(
|
|
findings,
|
|
structural_issues,
|
|
ai_triages,
|
|
quick_scan,
|
|
) = await self.pr_review_engine.run_multi_pass_review(pr_context)
|
|
print(
|
|
f"[DEBUG orchestrator] Multi-pass review complete: "
|
|
f"{len(findings)} findings, {len(structural_issues)} structural, {len(ai_triages)} AI triages",
|
|
flush=True,
|
|
)
|
|
|
|
self._report_progress(
|
|
"generating",
|
|
70,
|
|
"Generating verdict and summary...",
|
|
pr_number=pr_number,
|
|
)
|
|
|
|
# Check CI status (comprehensive - includes workflows awaiting approval)
|
|
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
|
|
|
# Log CI status with awaiting approval info
|
|
awaiting = ci_status.get("awaiting_approval", 0)
|
|
pending_without_awaiting = ci_status.get("pending", 0) - awaiting
|
|
ci_log_parts = [
|
|
f"{ci_status.get('passing', 0)} passing",
|
|
f"{ci_status.get('failing', 0)} failing",
|
|
]
|
|
if pending_without_awaiting > 0:
|
|
ci_log_parts.append(f"{pending_without_awaiting} pending")
|
|
if awaiting > 0:
|
|
ci_log_parts.append(f"{awaiting} awaiting approval")
|
|
print(
|
|
f"[orchestrator] CI status: {', '.join(ci_log_parts)}",
|
|
flush=True,
|
|
)
|
|
if awaiting > 0:
|
|
print(
|
|
f"[orchestrator] ⚠️ {awaiting} workflow(s) from fork need maintainer approval to run",
|
|
flush=True,
|
|
)
|
|
|
|
# Generate verdict (includes CI status and merge conflict check)
|
|
verdict, verdict_reasoning, blockers = self._generate_verdict(
|
|
findings,
|
|
structural_issues,
|
|
ai_triages,
|
|
ci_status,
|
|
has_merge_conflicts=pr_context.has_merge_conflicts,
|
|
merge_state_status=pr_context.merge_state_status,
|
|
)
|
|
print(
|
|
f"[DEBUG orchestrator] Verdict: {verdict.value} - {verdict_reasoning}",
|
|
flush=True,
|
|
)
|
|
|
|
# Calculate risk assessment
|
|
risk_assessment = self._calculate_risk_assessment(
|
|
pr_context, findings, structural_issues
|
|
)
|
|
|
|
# Map verdict to overall_status for backward compatibility
|
|
if verdict == MergeVerdict.BLOCKED:
|
|
overall_status = "request_changes"
|
|
elif verdict == MergeVerdict.NEEDS_REVISION:
|
|
overall_status = "request_changes"
|
|
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
|
overall_status = "comment"
|
|
else:
|
|
overall_status = "approve"
|
|
|
|
# Generate summary
|
|
summary = self._generate_enhanced_summary(
|
|
verdict=verdict,
|
|
verdict_reasoning=verdict_reasoning,
|
|
blockers=blockers,
|
|
findings=findings,
|
|
structural_issues=structural_issues,
|
|
ai_triages=ai_triages,
|
|
risk_assessment=risk_assessment,
|
|
ci_status=ci_status,
|
|
)
|
|
|
|
# Get HEAD SHA for follow-up review tracking
|
|
head_sha = self.bot_detector.get_last_commit_sha(pr_context.commits)
|
|
|
|
# Get file blob SHAs for rebase-resistant follow-up reviews
|
|
# Blob SHAs persist across rebases - same content = same blob SHA
|
|
file_blobs: dict[str, str] = {}
|
|
try:
|
|
pr_files = await self.gh_client.get_pr_files(pr_number)
|
|
for file in pr_files:
|
|
filename = file.get("filename", "")
|
|
blob_sha = file.get("sha", "")
|
|
if filename and blob_sha:
|
|
file_blobs[filename] = blob_sha
|
|
print(
|
|
f"[Review] Captured {len(file_blobs)} file blob SHAs for follow-up tracking",
|
|
flush=True,
|
|
)
|
|
except Exception as e:
|
|
print(
|
|
f"[Review] Warning: Could not capture file blobs: {e}", flush=True
|
|
)
|
|
|
|
# Create result
|
|
result = PRReviewResult(
|
|
pr_number=pr_number,
|
|
repo=self.config.repo,
|
|
success=True,
|
|
findings=findings,
|
|
summary=summary,
|
|
overall_status=overall_status,
|
|
verdict=verdict,
|
|
verdict_reasoning=verdict_reasoning,
|
|
blockers=blockers,
|
|
risk_assessment=risk_assessment,
|
|
structural_issues=structural_issues,
|
|
ai_comment_triages=ai_triages,
|
|
quick_scan_summary=quick_scan,
|
|
# Track the commit SHA for follow-up reviews
|
|
reviewed_commit_sha=head_sha,
|
|
# Track file blobs for rebase-resistant follow-up reviews
|
|
reviewed_file_blobs=file_blobs,
|
|
)
|
|
|
|
# Post review if configured
|
|
if self.config.auto_post_reviews:
|
|
self._report_progress(
|
|
"posting", 90, "Posting review to GitHub...", pr_number=pr_number
|
|
)
|
|
review_id = await self._post_pr_review(
|
|
pr_number=pr_number,
|
|
body=self._format_review_body(result),
|
|
event=overall_status.upper(),
|
|
)
|
|
result.review_id = review_id
|
|
|
|
# Post AI triage replies
|
|
if ai_triages:
|
|
self._report_progress(
|
|
"posting",
|
|
95,
|
|
"Posting AI triage replies...",
|
|
pr_number=pr_number,
|
|
)
|
|
await self._post_ai_triage_replies(pr_number, ai_triages)
|
|
|
|
# Save result
|
|
await result.save(self.github_dir)
|
|
|
|
# Note: PR review memory is now saved by the Electron app after the review completes
|
|
# This ensures memory is saved to the embedded LadybugDB managed by the app
|
|
|
|
# Mark as reviewed (head_sha already fetched above)
|
|
if head_sha:
|
|
self.bot_detector.mark_reviewed(pr_number, head_sha)
|
|
|
|
self._report_progress(
|
|
"complete", 100, "Review complete!", pr_number=pr_number
|
|
)
|
|
return result
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
# Log full exception details for debugging
|
|
error_details = f"{type(e).__name__}: {e}"
|
|
full_traceback = traceback.format_exc()
|
|
print(
|
|
f"[ERROR orchestrator] PR review failed for #{pr_number}: {error_details}",
|
|
flush=True,
|
|
)
|
|
print(f"[ERROR orchestrator] Full traceback:\n{full_traceback}", flush=True)
|
|
|
|
result = PRReviewResult(
|
|
pr_number=pr_number,
|
|
repo=self.config.repo,
|
|
success=False,
|
|
error=f"{error_details}\n\nTraceback:\n{full_traceback}",
|
|
)
|
|
await result.save(self.github_dir)
|
|
return result
|
|
|
|
async def followup_review_pr(self, pr_number: int) -> PRReviewResult:
|
|
"""
|
|
Perform a focused follow-up review of a PR.
|
|
|
|
Only reviews:
|
|
- Changes since last review (new commits)
|
|
- Whether previous findings are resolved
|
|
- New comments from contributors and AI bots
|
|
|
|
Args:
|
|
pr_number: The PR number to review
|
|
|
|
Returns:
|
|
PRReviewResult with follow-up analysis
|
|
|
|
Raises:
|
|
ValueError: If no previous review exists for this PR
|
|
"""
|
|
print(
|
|
f"[DEBUG orchestrator] followup_review_pr() called for PR #{pr_number}",
|
|
flush=True,
|
|
)
|
|
|
|
# Load previous review
|
|
previous_review = PRReviewResult.load(self.github_dir, pr_number)
|
|
|
|
if not previous_review:
|
|
raise ValueError(
|
|
f"No previous review found for PR #{pr_number}. Run initial review first."
|
|
)
|
|
|
|
if not previous_review.reviewed_commit_sha:
|
|
raise ValueError(
|
|
f"Previous review for PR #{pr_number} doesn't have commit SHA. "
|
|
"Re-run initial review with the updated system."
|
|
)
|
|
|
|
self._report_progress(
|
|
"gathering_context",
|
|
10,
|
|
f"Gathering follow-up context for PR #{pr_number}...",
|
|
pr_number=pr_number,
|
|
)
|
|
|
|
try:
|
|
# Import here to avoid circular imports at module level
|
|
try:
|
|
from .context_gatherer import FollowupContextGatherer
|
|
from .services.followup_reviewer import FollowupReviewer
|
|
except (ImportError, ValueError, SystemError):
|
|
from context_gatherer import FollowupContextGatherer
|
|
from services.followup_reviewer import FollowupReviewer
|
|
|
|
# Gather follow-up context
|
|
gatherer = FollowupContextGatherer(
|
|
self.project_dir,
|
|
pr_number,
|
|
previous_review,
|
|
)
|
|
followup_context = await gatherer.gather()
|
|
|
|
# Check if context gathering failed
|
|
if followup_context.error:
|
|
print(
|
|
f"[Followup] Context gathering failed: {followup_context.error}",
|
|
flush=True,
|
|
)
|
|
# Return an error result instead of silently returning incomplete data
|
|
result = PRReviewResult(
|
|
pr_number=pr_number,
|
|
repo=self.config.repo,
|
|
success=False,
|
|
findings=[],
|
|
summary=f"Follow-up review failed: {followup_context.error}",
|
|
overall_status="comment",
|
|
verdict=MergeVerdict.NEEDS_REVISION,
|
|
verdict_reasoning=f"Context gathering failed: {followup_context.error}",
|
|
error=followup_context.error,
|
|
reviewed_commit_sha=followup_context.current_commit_sha
|
|
or previous_review.reviewed_commit_sha,
|
|
is_followup_review=True,
|
|
)
|
|
await result.save(self.github_dir)
|
|
return result
|
|
|
|
# Check if there are changes to review (commits OR files via blob comparison)
|
|
# After a rebase/force-push, commits_since_review will be empty (commit
|
|
# SHAs are rewritten), but files_changed_since_review will contain files
|
|
# that actually changed content based on blob SHA comparison.
|
|
has_commits = bool(followup_context.commits_since_review)
|
|
has_file_changes = bool(followup_context.files_changed_since_review)
|
|
|
|
if not has_commits and not has_file_changes:
|
|
base_sha = previous_review.reviewed_commit_sha[:8]
|
|
print(
|
|
f"[Followup] No changes since last review at {base_sha}",
|
|
flush=True,
|
|
)
|
|
# Return a result indicating no changes
|
|
no_change_summary = (
|
|
"No new commits since last review. Previous findings still apply."
|
|
)
|
|
result = PRReviewResult(
|
|
pr_number=pr_number,
|
|
repo=self.config.repo,
|
|
success=True,
|
|
findings=previous_review.findings,
|
|
summary=no_change_summary,
|
|
overall_status=previous_review.overall_status,
|
|
verdict=previous_review.verdict,
|
|
verdict_reasoning="No changes since last review.",
|
|
reviewed_commit_sha=followup_context.current_commit_sha
|
|
or previous_review.reviewed_commit_sha,
|
|
is_followup_review=True,
|
|
unresolved_findings=[f.id for f in previous_review.findings],
|
|
)
|
|
await result.save(self.github_dir)
|
|
return result
|
|
|
|
# Build progress message based on what changed
|
|
if has_commits:
|
|
num_commits = len(followup_context.commits_since_review)
|
|
change_desc = f"{num_commits} new commits"
|
|
else:
|
|
# Rebase detected - files changed but no trackable commits
|
|
num_files = len(followup_context.files_changed_since_review)
|
|
change_desc = f"{num_files} files (rebase detected)"
|
|
|
|
self._report_progress(
|
|
"analyzing",
|
|
30,
|
|
f"Analyzing {change_desc}...",
|
|
pr_number=pr_number,
|
|
)
|
|
|
|
# Fetch CI status BEFORE calling reviewer so AI can factor it into verdict
|
|
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
|
followup_context.ci_status = ci_status
|
|
|
|
# Use parallel orchestrator for follow-up if enabled
|
|
if self.config.use_parallel_orchestrator:
|
|
print(
|
|
"[AI] Using parallel orchestrator for follow-up review (SDK subagents)...",
|
|
flush=True,
|
|
)
|
|
try:
|
|
from .services.parallel_followup_reviewer import (
|
|
ParallelFollowupReviewer,
|
|
)
|
|
except (ImportError, ValueError, SystemError):
|
|
from services.parallel_followup_reviewer import (
|
|
ParallelFollowupReviewer,
|
|
)
|
|
|
|
reviewer = ParallelFollowupReviewer(
|
|
project_dir=self.project_dir,
|
|
github_dir=self.github_dir,
|
|
config=self.config,
|
|
progress_callback=lambda p: self._report_progress(
|
|
p.phase if hasattr(p, "phase") else p.get("phase", "analyzing"),
|
|
p.progress if hasattr(p, "progress") else p.get("progress", 50),
|
|
p.message
|
|
if hasattr(p, "message")
|
|
else p.get("message", "Reviewing..."),
|
|
pr_number=pr_number,
|
|
),
|
|
)
|
|
result = await reviewer.review(followup_context)
|
|
else:
|
|
# Fall back to sequential follow-up reviewer
|
|
reviewer = FollowupReviewer(
|
|
project_dir=self.project_dir,
|
|
github_dir=self.github_dir,
|
|
config=self.config,
|
|
progress_callback=lambda p: self._report_progress(
|
|
p.get("phase", "analyzing"),
|
|
p.get("progress", 50),
|
|
p.get("message", "Reviewing..."),
|
|
pr_number=pr_number,
|
|
),
|
|
)
|
|
result = await reviewer.review_followup(followup_context)
|
|
|
|
# Fallback: ensure CI failures block merge even if AI didn't factor it in
|
|
# (CI status was already passed to AI via followup_context.ci_status)
|
|
failed_checks = followup_context.ci_status.get("failed_checks", [])
|
|
if failed_checks:
|
|
print(
|
|
f"[Followup] CI checks failing: {failed_checks}",
|
|
flush=True,
|
|
)
|
|
# Override verdict if CI is failing
|
|
if result.verdict in (
|
|
MergeVerdict.READY_TO_MERGE,
|
|
MergeVerdict.MERGE_WITH_CHANGES,
|
|
):
|
|
result.verdict = MergeVerdict.BLOCKED
|
|
result.verdict_reasoning = (
|
|
f"Blocked: {len(failed_checks)} CI check(s) failing. "
|
|
"Fix CI before merge."
|
|
)
|
|
result.overall_status = "request_changes"
|
|
# Add CI failures to blockers
|
|
for check_name in failed_checks:
|
|
if f"CI Failed: {check_name}" not in result.blockers:
|
|
result.blockers.append(f"CI Failed: {check_name}")
|
|
# Update summary to reflect CI status
|
|
ci_warning = (
|
|
f"\n\n**⚠️ CI Status:** {len(failed_checks)} check(s) failing: "
|
|
f"{', '.join(failed_checks)}"
|
|
)
|
|
if ci_warning not in result.summary:
|
|
result.summary += ci_warning
|
|
|
|
# Save result
|
|
await result.save(self.github_dir)
|
|
|
|
# Note: PR review memory is now saved by the Electron app after the review completes
|
|
# This ensures memory is saved to the embedded LadybugDB managed by the app
|
|
|
|
# Mark as reviewed with new commit SHA
|
|
if result.reviewed_commit_sha:
|
|
self.bot_detector.mark_reviewed(pr_number, result.reviewed_commit_sha)
|
|
|
|
self._report_progress(
|
|
"complete", 100, "Follow-up review complete!", pr_number=pr_number
|
|
)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
result = PRReviewResult(
|
|
pr_number=pr_number,
|
|
repo=self.config.repo,
|
|
success=False,
|
|
error=str(e),
|
|
is_followup_review=True,
|
|
)
|
|
await result.save(self.github_dir)
|
|
return result
|
|
|
|
def _generate_verdict(
|
|
self,
|
|
findings: list[PRReviewFinding],
|
|
structural_issues: list[StructuralIssue],
|
|
ai_triages: list[AICommentTriage],
|
|
ci_status: dict | None = None,
|
|
has_merge_conflicts: bool = False,
|
|
merge_state_status: str = "",
|
|
) -> tuple[MergeVerdict, str, list[str]]:
|
|
"""
|
|
Generate merge verdict based on all findings, CI status, and merge conflicts.
|
|
|
|
Blocks on:
|
|
- Merge conflicts (must be resolved before merging)
|
|
- Verification failures
|
|
- Redundancy issues
|
|
- Failing CI checks
|
|
|
|
Warns on (NEEDS_REVISION):
|
|
- Branch behind base (out of date)
|
|
"""
|
|
blockers = []
|
|
ci_status = ci_status or {}
|
|
is_branch_behind = merge_state_status == "BEHIND"
|
|
|
|
# CRITICAL: Merge conflicts block merging - check first
|
|
if has_merge_conflicts:
|
|
blockers.append(
|
|
"Merge Conflicts: PR has conflicts with base branch that must be resolved"
|
|
)
|
|
# Branch behind base is a warning, not a hard blocker
|
|
elif is_branch_behind:
|
|
blockers.append(BRANCH_BEHIND_BLOCKER_MSG)
|
|
|
|
# Count by severity
|
|
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
|
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
|
|
medium = [f for f in findings if f.severity == ReviewSeverity.MEDIUM]
|
|
low = [f for f in findings if f.severity == ReviewSeverity.LOW]
|
|
|
|
# NEW: Verification failures are ALWAYS blockers (even if not critical severity)
|
|
verification_failures = [
|
|
f for f in findings if f.category == ReviewCategory.VERIFICATION_FAILED
|
|
]
|
|
|
|
# NEW: High severity redundancy issues are blockers
|
|
redundancy_issues = [
|
|
f
|
|
for f in findings
|
|
if f.category == ReviewCategory.REDUNDANCY
|
|
and f.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH)
|
|
]
|
|
|
|
# Security findings are always blockers
|
|
security_critical = [
|
|
f for f in critical if f.category == ReviewCategory.SECURITY
|
|
]
|
|
|
|
# Structural blockers
|
|
structural_blockers = [
|
|
s
|
|
for s in structural_issues
|
|
if s.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH)
|
|
]
|
|
|
|
# AI comments marked critical
|
|
ai_critical = [t for t in ai_triages if t.verdict == AICommentVerdict.CRITICAL]
|
|
|
|
# Build blockers list with NEW categories first
|
|
# CI failures block merging
|
|
failed_checks = ci_status.get("failed_checks", [])
|
|
for check_name in failed_checks:
|
|
blockers.append(f"CI Failed: {check_name}")
|
|
|
|
# Workflows awaiting approval block merging (fork PRs)
|
|
awaiting_approval = ci_status.get("awaiting_approval", 0)
|
|
if awaiting_approval > 0:
|
|
blockers.append(
|
|
f"Workflows Pending: {awaiting_approval} workflow(s) awaiting maintainer approval"
|
|
)
|
|
|
|
# NEW: Verification failures block merging
|
|
for f in verification_failures:
|
|
note = f" - {f.verification_note}" if f.verification_note else ""
|
|
blockers.append(f"Verification Failed: {f.title} ({f.file}:{f.line}){note}")
|
|
|
|
# NEW: Redundancy issues block merging
|
|
for f in redundancy_issues:
|
|
redundant_ref = (
|
|
f" (duplicates {f.redundant_with})" if f.redundant_with else ""
|
|
)
|
|
blockers.append(f"Redundancy: {f.title} ({f.file}:{f.line}){redundant_ref}")
|
|
|
|
# Existing blocker categories
|
|
for f in security_critical:
|
|
blockers.append(f"Security: {f.title} ({f.file}:{f.line})")
|
|
for f in critical:
|
|
if (
|
|
f not in security_critical
|
|
and f not in verification_failures
|
|
and f not in redundancy_issues
|
|
):
|
|
blockers.append(f"Critical: {f.title} ({f.file}:{f.line})")
|
|
for s in structural_blockers:
|
|
blockers.append(f"Structure: {s.title}")
|
|
for t in ai_critical:
|
|
summary = (
|
|
t.original_comment[:50] + "..."
|
|
if len(t.original_comment) > 50
|
|
else t.original_comment
|
|
)
|
|
blockers.append(f"{t.tool_name}: {summary}")
|
|
|
|
# Determine verdict with merge conflicts, CI, verification and redundancy checks
|
|
if blockers:
|
|
# Merge conflicts are the highest priority blocker
|
|
if has_merge_conflicts:
|
|
verdict = MergeVerdict.BLOCKED
|
|
reasoning = (
|
|
"Blocked: PR has merge conflicts with base branch. "
|
|
"Resolve conflicts before merge."
|
|
)
|
|
# CI failures are always blockers
|
|
elif failed_checks:
|
|
verdict = MergeVerdict.BLOCKED
|
|
reasoning = (
|
|
f"Blocked: {len(failed_checks)} CI check(s) failing. "
|
|
"Fix CI before merge."
|
|
)
|
|
# Workflows awaiting approval block merging
|
|
elif awaiting_approval > 0:
|
|
verdict = MergeVerdict.BLOCKED
|
|
reasoning = (
|
|
f"Blocked: {awaiting_approval} workflow(s) awaiting approval. "
|
|
"Approve workflows on GitHub to run CI checks."
|
|
)
|
|
# NEW: Prioritize verification failures
|
|
elif verification_failures:
|
|
verdict = MergeVerdict.BLOCKED
|
|
reasoning = (
|
|
f"Blocked: Cannot verify {len(verification_failures)} claim(s) in PR. "
|
|
"Evidence required before merge."
|
|
)
|
|
elif security_critical:
|
|
verdict = MergeVerdict.BLOCKED
|
|
reasoning = (
|
|
f"Blocked by {len(security_critical)} security vulnerabilities"
|
|
)
|
|
elif redundancy_issues:
|
|
verdict = MergeVerdict.BLOCKED
|
|
reasoning = (
|
|
f"Blocked: {len(redundancy_issues)} redundant implementation(s) detected. "
|
|
"Remove duplicates before merge."
|
|
)
|
|
elif len(critical) > 0:
|
|
verdict = MergeVerdict.BLOCKED
|
|
reasoning = f"Blocked by {len(critical)} critical issues"
|
|
# Branch behind is a soft blocker - NEEDS_REVISION, not BLOCKED
|
|
elif is_branch_behind:
|
|
verdict = MergeVerdict.NEEDS_REVISION
|
|
reasoning = BRANCH_BEHIND_REASONING
|
|
if low:
|
|
reasoning += f" {len(low)} non-blocking suggestion(s) to consider."
|
|
else:
|
|
verdict = MergeVerdict.NEEDS_REVISION
|
|
reasoning = f"{len(blockers)} issues must be addressed"
|
|
elif high or medium:
|
|
# High and Medium severity findings block merge
|
|
verdict = MergeVerdict.NEEDS_REVISION
|
|
total = len(high) + len(medium)
|
|
reasoning = f"{total} issue(s) must be addressed ({len(high)} required, {len(medium)} recommended)"
|
|
if low:
|
|
reasoning += f", {len(low)} suggestions"
|
|
elif low:
|
|
# Only Low severity suggestions - safe to merge (non-blocking)
|
|
verdict = MergeVerdict.READY_TO_MERGE
|
|
reasoning = (
|
|
f"No blocking issues. {len(low)} non-blocking suggestion(s) to consider"
|
|
)
|
|
else:
|
|
verdict = MergeVerdict.READY_TO_MERGE
|
|
reasoning = "No blocking issues found"
|
|
|
|
return verdict, reasoning, blockers
|
|
|
|
def _calculate_risk_assessment(
|
|
self,
|
|
context: PRContext,
|
|
findings: list[PRReviewFinding],
|
|
structural_issues: list[StructuralIssue],
|
|
) -> dict:
|
|
"""Calculate risk assessment for the PR."""
|
|
total_changes = context.total_additions + context.total_deletions
|
|
|
|
# Complexity
|
|
if total_changes > 500:
|
|
complexity = "high"
|
|
elif total_changes > 200:
|
|
complexity = "medium"
|
|
else:
|
|
complexity = "low"
|
|
|
|
# Security impact
|
|
security_findings = [
|
|
f for f in findings if f.category == ReviewCategory.SECURITY
|
|
]
|
|
if any(f.severity == ReviewSeverity.CRITICAL for f in security_findings):
|
|
security_impact = "critical"
|
|
elif any(f.severity == ReviewSeverity.HIGH for f in security_findings):
|
|
security_impact = "medium"
|
|
elif security_findings:
|
|
security_impact = "low"
|
|
else:
|
|
security_impact = "none"
|
|
|
|
# Scope coherence
|
|
scope_issues = [
|
|
s
|
|
for s in structural_issues
|
|
if s.issue_type in ("feature_creep", "scope_creep")
|
|
]
|
|
if any(
|
|
s.severity in (ReviewSeverity.CRITICAL, ReviewSeverity.HIGH)
|
|
for s in scope_issues
|
|
):
|
|
scope_coherence = "poor"
|
|
elif scope_issues:
|
|
scope_coherence = "mixed"
|
|
else:
|
|
scope_coherence = "good"
|
|
|
|
return {
|
|
"complexity": complexity,
|
|
"security_impact": security_impact,
|
|
"scope_coherence": scope_coherence,
|
|
}
|
|
|
|
def _generate_enhanced_summary(
|
|
self,
|
|
verdict: MergeVerdict,
|
|
verdict_reasoning: str,
|
|
blockers: list[str],
|
|
findings: list[PRReviewFinding],
|
|
structural_issues: list[StructuralIssue],
|
|
ai_triages: list[AICommentTriage],
|
|
risk_assessment: dict,
|
|
ci_status: dict | None = None,
|
|
) -> str:
|
|
"""Generate enhanced summary with verdict, risk, and actionable next steps."""
|
|
verdict_emoji = {
|
|
MergeVerdict.READY_TO_MERGE: "✅",
|
|
MergeVerdict.MERGE_WITH_CHANGES: "🟡",
|
|
MergeVerdict.NEEDS_REVISION: "🟠",
|
|
MergeVerdict.BLOCKED: "🔴",
|
|
}
|
|
|
|
# Generate bottom line for quick scanning
|
|
bottom_line = self._generate_bottom_line(
|
|
verdict=verdict,
|
|
ci_status=ci_status,
|
|
blockers=blockers,
|
|
findings=findings,
|
|
)
|
|
|
|
lines = [
|
|
f"### Merge Verdict: {verdict_emoji.get(verdict, '⚪')} {verdict.value.upper().replace('_', ' ')}",
|
|
"",
|
|
f"> {bottom_line}",
|
|
"",
|
|
verdict_reasoning,
|
|
"",
|
|
"### Risk Assessment",
|
|
"| Factor | Level | Notes |",
|
|
"|--------|-------|-------|",
|
|
f"| Complexity | {risk_assessment['complexity'].capitalize()} | Based on lines changed |",
|
|
f"| Security Impact | {risk_assessment['security_impact'].capitalize()} | Based on security findings |",
|
|
f"| Scope Coherence | {risk_assessment['scope_coherence'].capitalize()} | Based on structural review |",
|
|
"",
|
|
]
|
|
|
|
# Blockers
|
|
if blockers:
|
|
lines.append("### 🚨 Blocking Issues (Must Fix)")
|
|
for blocker in blockers:
|
|
lines.append(f"- {blocker}")
|
|
lines.append("")
|
|
|
|
# Findings summary
|
|
if findings:
|
|
by_severity = {}
|
|
for f in findings:
|
|
severity = f.severity.value
|
|
if severity not in by_severity:
|
|
by_severity[severity] = []
|
|
by_severity[severity].append(f)
|
|
|
|
lines.append("### Findings Summary")
|
|
for severity in ["critical", "high", "medium", "low"]:
|
|
if severity in by_severity:
|
|
count = len(by_severity[severity])
|
|
lines.append(f"- **{severity.capitalize()}**: {count} issue(s)")
|
|
lines.append("")
|
|
|
|
# Structural issues
|
|
if structural_issues:
|
|
lines.append("### 🏗️ Structural Issues")
|
|
for issue in structural_issues[:5]:
|
|
lines.append(f"- **{issue.title}**: {issue.description}")
|
|
if len(structural_issues) > 5:
|
|
lines.append(f"- ... and {len(structural_issues) - 5} more")
|
|
lines.append("")
|
|
|
|
# AI triages summary
|
|
if ai_triages:
|
|
critical_ai = [
|
|
t for t in ai_triages if t.verdict == AICommentVerdict.CRITICAL
|
|
]
|
|
important_ai = [
|
|
t for t in ai_triages if t.verdict == AICommentVerdict.IMPORTANT
|
|
]
|
|
if critical_ai or important_ai:
|
|
lines.append("### 🤖 AI Tool Comments Review")
|
|
if critical_ai:
|
|
lines.append(f"- **Critical**: {len(critical_ai)} validated issues")
|
|
if important_ai:
|
|
lines.append(
|
|
f"- **Important**: {len(important_ai)} recommended fixes"
|
|
)
|
|
lines.append("")
|
|
|
|
lines.append("---")
|
|
lines.append("_Generated by Auto Claude PR Review_")
|
|
|
|
return "\n".join(lines)
|
|
|
|
def _generate_bottom_line(
|
|
self,
|
|
verdict: MergeVerdict,
|
|
ci_status: dict | None,
|
|
blockers: list[str],
|
|
findings: list[PRReviewFinding],
|
|
) -> str:
|
|
"""Generate a one-line summary for quick scanning at the top of the review."""
|
|
# Check CI status
|
|
ci = ci_status or {}
|
|
pending_ci = ci.get("pending", 0)
|
|
failing_ci = ci.get("failing", 0)
|
|
awaiting_approval = ci.get("awaiting_approval", 0)
|
|
|
|
# Count blocking findings and issues
|
|
blocking_findings = [
|
|
f for f in findings if f.severity.value in ("critical", "high", "medium")
|
|
]
|
|
code_blockers = [
|
|
b for b in blockers if "CI" not in b and "Merge Conflict" not in b
|
|
]
|
|
has_merge_conflicts = any("Merge Conflict" in b for b in blockers)
|
|
|
|
# Determine the bottom line based on verdict and context
|
|
if verdict == MergeVerdict.READY_TO_MERGE:
|
|
return (
|
|
"**✅ Ready to merge** - All checks passing, no blocking issues found."
|
|
)
|
|
|
|
elif verdict == MergeVerdict.BLOCKED:
|
|
if has_merge_conflicts:
|
|
return "**🔴 Blocked** - Merge conflicts must be resolved before merge."
|
|
elif failing_ci > 0:
|
|
return f"**🔴 Blocked** - {failing_ci} CI check(s) failing. Fix CI before merge."
|
|
elif awaiting_approval > 0:
|
|
return "**🔴 Blocked** - Awaiting maintainer approval for fork PR workflow."
|
|
elif blocking_findings:
|
|
return f"**🔴 Blocked** - {len(blocking_findings)} critical/high/medium issue(s) must be fixed."
|
|
else:
|
|
return "**🔴 Blocked** - Critical issues must be resolved before merge."
|
|
|
|
elif verdict == MergeVerdict.NEEDS_REVISION:
|
|
# Key insight: distinguish "waiting on CI" from "needs code fixes"
|
|
# Check code issues FIRST before checking pending CI
|
|
if blocking_findings:
|
|
return f"**🟠 Needs revision** - {len(blocking_findings)} issue(s) require attention."
|
|
elif code_blockers:
|
|
return f"**🟠 Needs revision** - {len(code_blockers)} structural/other issue(s) require attention."
|
|
elif pending_ci > 0:
|
|
# Only show "Ready once CI passes" when no code issues exist
|
|
return f"**⏳ Ready once CI passes** - {pending_ci} check(s) pending, no blocking code issues."
|
|
else:
|
|
return "**🟠 Needs revision** - See details below."
|
|
|
|
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
|
if pending_ci > 0:
|
|
return (
|
|
"**🟡 Can merge once CI passes** - Minor suggestions, no blockers."
|
|
)
|
|
else:
|
|
return "**🟡 Can merge** - Minor suggestions noted, no blockers."
|
|
|
|
return "**📝 Review complete** - See details below."
|
|
|
|
def _format_review_body(self, result: PRReviewResult) -> str:
|
|
"""Format the review body for posting to GitHub."""
|
|
return result.summary
|
|
|
|
# =========================================================================
|
|
# ISSUE TRIAGE WORKFLOW
|
|
# =========================================================================
|
|
|
|
async def triage_issues(
|
|
self,
|
|
issue_numbers: list[int] | None = None,
|
|
apply_labels: bool = False,
|
|
) -> list[TriageResult]:
|
|
"""
|
|
Triage issues to detect duplicates, spam, and feature creep.
|
|
|
|
Args:
|
|
issue_numbers: Specific issues to triage, or None for all open issues
|
|
apply_labels: Whether to apply suggested labels to GitHub
|
|
|
|
Returns:
|
|
List of TriageResult for each issue
|
|
"""
|
|
self._report_progress("fetching", 10, "Fetching issues...")
|
|
|
|
# Fetch issues
|
|
if issue_numbers:
|
|
issues = []
|
|
for num in issue_numbers:
|
|
issues.append(await self._fetch_issue_data(num))
|
|
else:
|
|
issues = await self._fetch_open_issues()
|
|
|
|
if not issues:
|
|
return []
|
|
|
|
results = []
|
|
total = len(issues)
|
|
|
|
for i, issue in enumerate(issues):
|
|
progress = 20 + int(60 * (i / total))
|
|
self._report_progress(
|
|
"analyzing",
|
|
progress,
|
|
f"Analyzing issue #{issue['number']}...",
|
|
issue_number=issue["number"],
|
|
)
|
|
|
|
# Delegate to triage engine
|
|
result = await self.triage_engine.triage_single_issue(issue, issues)
|
|
results.append(result)
|
|
|
|
# Apply labels if requested
|
|
if apply_labels and (result.labels_to_add or result.labels_to_remove):
|
|
try:
|
|
await self._add_issue_labels(issue["number"], result.labels_to_add)
|
|
await self._remove_issue_labels(
|
|
issue["number"], result.labels_to_remove
|
|
)
|
|
except Exception as e:
|
|
print(f"Failed to apply labels to #{issue['number']}: {e}")
|
|
|
|
# Save result
|
|
await result.save(self.github_dir)
|
|
|
|
self._report_progress("complete", 100, f"Triaged {len(results)} issues")
|
|
return results
|
|
|
|
# =========================================================================
|
|
# AUTO-FIX WORKFLOW
|
|
# =========================================================================
|
|
|
|
async def auto_fix_issue(
|
|
self,
|
|
issue_number: int,
|
|
trigger_label: str | None = None,
|
|
) -> AutoFixState:
|
|
"""
|
|
Automatically fix an issue by creating a spec and running the build pipeline.
|
|
|
|
Args:
|
|
issue_number: The issue number to fix
|
|
trigger_label: Label that triggered this auto-fix (for permission checks)
|
|
|
|
Returns:
|
|
AutoFixState tracking the fix progress
|
|
|
|
Raises:
|
|
PermissionError: If the user who added the trigger label isn't authorized
|
|
"""
|
|
# Fetch issue data
|
|
issue = await self._fetch_issue_data(issue_number)
|
|
|
|
# Delegate to autofix processor
|
|
return await self.autofix_processor.process_issue(
|
|
issue_number=issue_number,
|
|
issue=issue,
|
|
trigger_label=trigger_label,
|
|
)
|
|
|
|
async def get_auto_fix_queue(self) -> list[AutoFixState]:
|
|
"""Get all issues in the auto-fix queue."""
|
|
return await self.autofix_processor.get_queue()
|
|
|
|
async def check_auto_fix_labels(
|
|
self, verify_permissions: bool = True
|
|
) -> list[dict]:
|
|
"""
|
|
Check for issues with auto-fix labels and return their details.
|
|
|
|
Args:
|
|
verify_permissions: Whether to verify who added the trigger label
|
|
|
|
Returns:
|
|
List of dicts with issue_number, trigger_label, and authorized status
|
|
"""
|
|
issues = await self._fetch_open_issues()
|
|
return await self.autofix_processor.check_labeled_issues(
|
|
all_issues=issues,
|
|
verify_permissions=verify_permissions,
|
|
)
|
|
|
|
async def check_new_issues(self) -> list[dict]:
|
|
"""
|
|
Check for NEW issues that aren't already in the auto-fix queue.
|
|
|
|
Returns:
|
|
List of dicts with just the issue number: [{"number": 123}, ...]
|
|
"""
|
|
# Get all open issues
|
|
issues = await self._fetch_open_issues()
|
|
|
|
# Get current queue to filter out issues already being processed
|
|
queue = await self.get_auto_fix_queue()
|
|
queued_issue_numbers = {state.issue_number for state in queue}
|
|
|
|
# Return just the issue numbers (not full issue objects to avoid huge JSON)
|
|
new_issues = [
|
|
{"number": issue["number"]}
|
|
for issue in issues
|
|
if issue["number"] not in queued_issue_numbers
|
|
]
|
|
|
|
return new_issues
|
|
|
|
# =========================================================================
|
|
# BATCH AUTO-FIX WORKFLOW
|
|
# =========================================================================
|
|
|
|
async def batch_and_fix_issues(
|
|
self,
|
|
issue_numbers: list[int] | None = None,
|
|
) -> list:
|
|
"""
|
|
Batch similar issues and create combined specs for each batch.
|
|
|
|
Args:
|
|
issue_numbers: Specific issues to batch, or None for all open issues
|
|
|
|
Returns:
|
|
List of IssueBatch objects that were created
|
|
"""
|
|
# Fetch issues
|
|
if issue_numbers:
|
|
issues = []
|
|
for num in issue_numbers:
|
|
issue = await self._fetch_issue_data(num)
|
|
issues.append(issue)
|
|
else:
|
|
issues = await self._fetch_open_issues()
|
|
|
|
# Delegate to batch processor
|
|
return await self.batch_processor.batch_and_fix_issues(
|
|
issues=issues,
|
|
fetch_issue_callback=self._fetch_issue_data,
|
|
)
|
|
|
|
async def analyze_issues_preview(
|
|
self,
|
|
issue_numbers: list[int] | None = None,
|
|
max_issues: int = 200,
|
|
) -> dict:
|
|
"""
|
|
Analyze issues and return a PREVIEW of proposed batches without executing.
|
|
|
|
Args:
|
|
issue_numbers: Specific issues to analyze, or None for all open issues
|
|
max_issues: Maximum number of issues to analyze (default 200)
|
|
|
|
Returns:
|
|
Dict with proposed batches and statistics for user review
|
|
"""
|
|
# Fetch issues
|
|
if issue_numbers:
|
|
issues = []
|
|
for num in issue_numbers[:max_issues]:
|
|
issue = await self._fetch_issue_data(num)
|
|
issues.append(issue)
|
|
else:
|
|
issues = await self._fetch_open_issues(limit=max_issues)
|
|
|
|
# Delegate to batch processor
|
|
return await self.batch_processor.analyze_issues_preview(
|
|
issues=issues,
|
|
max_issues=max_issues,
|
|
)
|
|
|
|
async def approve_and_execute_batches(
|
|
self,
|
|
approved_batches: list[dict],
|
|
) -> list:
|
|
"""
|
|
Execute approved batches after user review.
|
|
|
|
Args:
|
|
approved_batches: List of batch dicts from analyze_issues_preview
|
|
|
|
Returns:
|
|
List of created IssueBatch objects
|
|
"""
|
|
return await self.batch_processor.approve_and_execute_batches(
|
|
approved_batches=approved_batches,
|
|
)
|
|
|
|
async def get_batch_status(self) -> dict:
|
|
"""Get status of all batches."""
|
|
return await self.batch_processor.get_batch_status()
|
|
|
|
async def process_pending_batches(self) -> int:
|
|
"""Process all pending batches."""
|
|
return await self.batch_processor.process_pending_batches()
|