Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 76 additions & 6 deletions scripts/check-pr-conflicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
Check PR conflicts and manage conflict labels.

This script checks pull requests for merge conflicts and automatically
adds/removes a conflict label based on the mergeable state.
adds/removes a conflict label based on the mergeable state and detection
of conflict markers in the PR diff (for Mergify backport PRs).
"""

import os
import sys
import time
import json
import subprocess
import re
from typing import List, Dict, Optional, Any
from enum import Enum

Expand Down Expand Up @@ -181,19 +184,80 @@ def wait_for_mergeable_state(api: GitHubAPI, pr_number: int, max_retries: int, r
return pr_details


def has_conflicts(mergeable: Optional[bool], mergeable_state: str) -> bool:
def get_pr_diff(owner: str, repo: str, pr_number: int) -> Optional[str]:
"""
Check if PR has conflicts based on mergeable state.
Get PR diff using gh CLI.

Returns the diff output, or None if the command fails.
Requires gh CLI to be available and GITHUB_TOKEN to be set.
"""
try:
result = subprocess.run(
["gh", "pr", "diff", str(pr_number), "--repo", f"{owner}/{repo}"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
return result.stdout
else:
log_warning(f" Could not get PR diff: {result.stderr.strip()}")
return None
except FileNotFoundError:
log_warning(" gh CLI not found - cannot check for conflict markers")
return None
except subprocess.TimeoutExpired:
log_warning(" Timeout getting PR diff")
return None
except Exception as e:
log_warning(f" Error getting PR diff: {e}")
return None


def has_conflict_markers(diff_content: str) -> bool:
"""
Check if diff contains unresolved conflict markers.

Detects markers used by git:
- <<<<<<< (start of conflict)
- ======= (separator between versions)
- >>>>>>> (end of conflict)

This catches Mergify backport PRs where conflicts are committed as text.
"""
if not diff_content:
return False

# Pattern to detect git conflict markers in diff
# We look for lines starting with + (added in the diff) containing conflict markers
lines = diff_content.split('\n')
for line in lines:
# Check for conflict markers (they appear in diff as +<<<<<<<, etc.)
if re.search(r'^\+.*?<<<<<<<|^\+.*?=======|^\+.*?>>>>>>>', line):
return True

return False


def has_conflicts(mergeable: Optional[bool], mergeable_state: str, has_markers: bool = False) -> bool:
"""
Check if PR has conflicts based on mergeable state and conflict markers.

According to GitHub API:
- mergeable: false + mergeable_state: "dirty" = has merge conflicts
- mergeable_state: "behind" = behind base branch but mergeable (no conflicts)
- mergeable_state: "blocked" = blocked by branch protection (not a conflict)
- mergeable_state: "unstable" = failing checks (not a conflict)

We only mark PRs with actual merge conflicts (dirty state).
Additionally, checks for committed conflict markers (<<<<<<<, =======, >>>>>>>) which
can appear in Mergify backport PRs where cherry-pick conflicts are committed as text.

Returns True if:
1. GitHub detects merge conflicts (dirty state), OR
2. Conflict markers are found in the diff (Mergify backport case)
"""
return mergeable is False and mergeable_state == MERGEABLE_STATES["DIRTY"]
github_has_conflicts = mergeable is False and mergeable_state == MERGEABLE_STATES["DIRTY"]
return github_has_conflicts or has_markers


def process_pr(api: GitHubAPI, pr: Dict[str, Any], conflict_label: str, max_retries: int, retry_delay: int) -> None:
Expand All @@ -209,9 +273,15 @@ def process_pr(api: GitHubAPI, pr: Dict[str, Any], conflict_label: str, max_retr
mergeable_state = pr_details.get("mergeable_state", "")
current_labels = [label["name"] for label in pr_details.get("labels", [])]
has_conflict_label = conflict_label in current_labels
pr_has_conflicts = has_conflicts(mergeable, mergeable_state)

# Check for conflict markers in the diff (for Mergify backport PRs)
diff_content = get_pr_diff(api.owner, api.repo, pr_number)
has_markers = has_conflict_markers(diff_content) if diff_content else False

pr_has_conflicts = has_conflicts(mergeable, mergeable_state, has_markers)

log_info(f" Mergeable: {mergeable}, State: {mergeable_state}")
log_info(f" Conflict markers in diff: {has_markers}")
log_info(f" Has conflicts: {pr_has_conflicts}, Has label: {has_conflict_label}")

if pr_has_conflicts and not has_conflict_label:
Expand Down