Skip to content

Commit a85d442

Browse files
irskepclaude
andauthored
Add merge conflict detection for pull requests (#12)
When a PR has merge conflicts, GitHub doesn't run workflows, but cimonitor previously didn't report this. Now cimonitor detects and reports merge conflicts using the GitHub API's mergeable state. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 038772d commit a85d442

File tree

8 files changed

+200
-23
lines changed

8 files changed

+200
-23
lines changed

.claude/commands/submit.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Submit changes
2+
3+
1. `mise run format && mise run lint`, then fix issues
4+
2. `mise run tests`, then fix issues
5+
3. Review changes with `git status` and `git diff`, checking for commits vs the merge-base of origin/main, as well as unstaged cahnges, and staged changes
6+
4. Stage and commit changes:
7+
8+
```bash
9+
git add . # or an appropriate set of files
10+
git commit -m "$(cat <<'EOF'
11+
[Your commit message here]
12+
13+
🤖 Generated with [Claude Code](https://claude.ai/code)
14+
15+
Co-Authored-By: Claude <noreply@anthropic.com>
16+
EOF
17+
)"
18+
```
19+
20+
Avoid unnecessary lists in your commit message. Avoid adding filler to lists to make them longer.
21+
5. `git push -u origin HEAD`
22+
6. Create a PR:
23+
24+
```bash
25+
gh pr create --title "[PR Title]" --body "$(cat <<'EOF'
26+
<message>
27+
28+
🤖 Generated with [Claude Code](https://claude.ai/code)
29+
EOF
30+
)"
31+
```
32+
33+
Avoid unnecessary lists in the PR description. PR descriptions do not have a minimum length, just do what's appropriate. Adding fluff and over-emphasizing minor points makes you seem less intelligent.
34+
7. Monitor CI with `uv run cimonitor watch --pr=<pr-number>`

.claude/commands/update.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
1. 1. `mise run format && mise run lint`, then fix issues
2+
2. `mise run tests`, then fix issues
3+
3. Review changes with `git status` and `git diff`, checking for commits vs origin/{branch}, as well as unstaged cahnges, and staged changes
4+
4. Stage and commit changes:
5+
6+
```bash
7+
git add . # or an appropriate set of files
8+
git commit -m "$(cat <<'EOF'
9+
[Your commit message here]
10+
11+
🤖 Generated with [Claude Code](https://claude.ai/code)
12+
13+
Co-Authored-By: Claude <noreply@anthropic.com>
14+
EOF
15+
)"
16+
```
17+
18+
Avoid unnecessary lists in your commit message. Avoid adding filler to lists to make them longer.
19+
5. `git push -u origin HEAD`
20+
6. Edit the existing open pull request using the `gh` command
21+
7. Monitor CI with `uv run cimonitor watch --pr=<pr-number>`

cimonitor/cli.py

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def get_target_info(fetcher, repo, branch, commit, pr, verbose=False):
146146
click.echo(f"Branch: {current_branch}")
147147
click.echo(f"Latest commit: {commit_sha}")
148148

149-
return owner, repo_name, commit_sha, target_description
149+
return owner, repo_name, commit_sha, target_description, pr_number
150150

151151

152152
@click.group(invoke_without_command=True)
@@ -167,12 +167,19 @@ def status(repo, branch, commit, pr, verbose):
167167
try:
168168
validate_target_options(branch, commit, pr)
169169
fetcher = GitHubCIFetcher()
170-
owner, repo_name, commit_sha, target_description = get_target_info(
170+
owner, repo_name, commit_sha, target_description, pr_number = get_target_info(
171171
fetcher, repo, branch, commit, pr, verbose
172172
)
173173

174174
# Get CI status using business logic
175-
ci_status = get_ci_status(fetcher, owner, repo_name, commit_sha, target_description)
175+
ci_status = get_ci_status(
176+
fetcher, owner, repo_name, commit_sha, target_description, pr_number
177+
)
178+
179+
# Check for merge conflicts first - only show if there are actual conflicts
180+
if ci_status.merge_conflict_info and _has_merge_conflicts(ci_status.merge_conflict_info):
181+
_handle_merge_conflict_status(ci_status)
182+
return
176183

177184
# Early return for no failures
178185
if not ci_status.has_failures:
@@ -207,7 +214,7 @@ def logs(repo, branch, commit, pr, verbose, raw, job_id, show_groups, step_filte
207214
try:
208215
validate_target_options(branch, commit, pr)
209216
fetcher = GitHubCIFetcher()
210-
owner, repo_name, commit_sha, target_description = get_target_info(
217+
owner, repo_name, commit_sha, target_description, pr_number = get_target_info(
211218
fetcher, repo, branch, commit, pr, verbose
212219
)
213220

@@ -249,7 +256,7 @@ def watch(repo, branch, commit, pr, verbose, until_complete, until_fail, retry):
249256
_validate_watch_options(until_complete, until_fail, retry)
250257

251258
fetcher = GitHubCIFetcher()
252-
owner, repo_name, commit_sha, target_description = get_target_info(
259+
owner, repo_name, commit_sha, target_description, pr_number = get_target_info(
253260
fetcher, repo, branch, commit, pr, verbose
254261
)
255262

@@ -587,6 +594,74 @@ def _display_step_status_summary(step_status):
587594
click.echo(f" {icon} {step_name} ({conclusion})")
588595

589596

597+
def _has_merge_conflicts(merge_info):
598+
"""Check if the merge info indicates actual merge conflicts."""
599+
if not merge_info:
600+
return False
601+
602+
mergeable = merge_info.get("mergeable")
603+
mergeable_state = merge_info.get("mergeable_state")
604+
605+
# These states indicate merge conflicts or blocking issues
606+
return (
607+
mergeable is False
608+
or mergeable_state == "dirty"
609+
or mergeable_state == "blocked"
610+
or merge_info.get("state") == "closed"
611+
or (mergeable is None and mergeable_state == "unknown")
612+
)
613+
614+
615+
def _handle_merge_conflict_status(ci_status):
616+
"""Handle and display merge conflict status information."""
617+
merge_info = ci_status.merge_conflict_info
618+
if not merge_info:
619+
return
620+
621+
mergeable = merge_info.get("mergeable")
622+
mergeable_state = merge_info.get("mergeable_state")
623+
state = merge_info.get("state")
624+
draft = merge_info.get("draft", False)
625+
626+
# Check for merge conflicts (dirty state indicates conflicts)
627+
if mergeable is False or mergeable_state == "dirty":
628+
click.echo(f"🚫 {ci_status.target_description} has merge conflicts!")
629+
click.echo("💡 No workflows will run until merge conflicts are resolved.")
630+
click.echo(f"🔍 Mergeable state: {mergeable_state}")
631+
if merge_info.get("base_ref"):
632+
click.echo(f"📋 Base branch: {merge_info['base_ref']}")
633+
click.echo("🛠️ Please resolve conflicts and push updates to trigger workflows.")
634+
return
635+
636+
# Check for other blocking states
637+
if mergeable_state == "blocked":
638+
click.echo(f"🔒 {ci_status.target_description} is blocked from merging.")
639+
click.echo(
640+
"💡 This may be due to required status checks, reviews, or other branch protection rules."
641+
)
642+
if not ci_status.has_failures:
643+
click.echo("✅ No CI failures detected, but merge is blocked by repository settings.")
644+
return
645+
646+
if state == "closed":
647+
click.echo(f"🚪 {ci_status.target_description} is closed.")
648+
return
649+
650+
if draft:
651+
click.echo(f"📝 {ci_status.target_description} is in draft state.")
652+
if not ci_status.has_failures:
653+
click.echo("✅ No CI failures detected for this draft PR.")
654+
return
655+
656+
# Handle unknown mergeable state
657+
if mergeable is None and mergeable_state == "unknown":
658+
click.echo(f"❓ {ci_status.target_description} merge status is still being computed.")
659+
click.echo(
660+
"💡 GitHub is still calculating merge conflicts. Please wait a moment and try again."
661+
)
662+
return
663+
664+
590665
def _display_failed_jobs_status(fetcher, owner, repo_name, ci_status):
591666
"""Display failed jobs status with step details."""
592667
click.echo(

cimonitor/fetcher.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,30 @@ def get_pr_head_sha(self, owner: str, repo: str, pr_number: int) -> str:
201201
except requests.RequestException as e:
202202
raise ValueError(f"Failed to get PR {pr_number} head SHA: {e}")
203203

204+
def get_pr_merge_status(self, owner: str, repo: str, pr_number: int) -> dict[str, Any]:
205+
"""Get merge status information for a pull request.
206+
207+
Returns:
208+
Dictionary with mergeable, mergeable_state, and other PR status info
209+
"""
210+
url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}"
211+
212+
try:
213+
response = requests.get(url, headers=self.headers)
214+
response.raise_for_status()
215+
pr_data = response.json()
216+
217+
return {
218+
"mergeable": pr_data.get("mergeable"),
219+
"mergeable_state": pr_data.get("mergeable_state"),
220+
"state": pr_data.get("state"),
221+
"draft": pr_data.get("draft", False),
222+
"base_ref": pr_data.get("base", {}).get("ref"),
223+
"head_ref": pr_data.get("head", {}).get("ref"),
224+
}
225+
except requests.RequestException as e:
226+
raise ValueError(f"Failed to get PR {pr_number} merge status: {e}")
227+
204228
def get_branch_head_sha(self, owner: str, repo: str, branch_name: str) -> str:
205229
"""Get the head commit SHA for a branch."""
206230
url = f"https://api.github.com/repos/{owner}/{repo}/branches/{branch_name}"

cimonitor/services.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def __init__(self, failed_check_runs: list[dict[str, Any]], target_description:
2020
self.failed_check_runs = failed_check_runs
2121
self.target_description = target_description
2222
self.has_failures = len(failed_check_runs) > 0
23+
self.merge_conflict_info: dict[str, Any] | None = None
2324

2425

2526
class WorkflowStepInfo:
@@ -43,7 +44,12 @@ def __init__(self, name: str, html_url: str, conclusion: str):
4344

4445

4546
def get_ci_status(
46-
fetcher: GitHubCIFetcher, owner: str, repo_name: str, commit_sha: str, target_description: str
47+
fetcher: GitHubCIFetcher,
48+
owner: str,
49+
repo_name: str,
50+
commit_sha: str,
51+
target_description: str,
52+
pr_number: int | None = None,
4753
) -> CIStatusResult:
4854
"""Get CI status for a commit with detailed job information.
4955
@@ -53,12 +59,24 @@ def get_ci_status(
5359
repo_name: Repository name
5460
commit_sha: Target commit SHA
5561
target_description: Human-readable description of target
62+
pr_number: Pull request number (optional)
5663
5764
Returns:
5865
CIStatusResult with failed jobs and details
5966
"""
6067
failed_check_runs = fetcher.find_failed_jobs_in_latest_run(owner, repo_name, commit_sha)
61-
return CIStatusResult(failed_check_runs, target_description)
68+
result = CIStatusResult(failed_check_runs, target_description)
69+
70+
# Check for merge conflicts if this is a PR
71+
if pr_number:
72+
try:
73+
merge_status = fetcher.get_pr_merge_status(owner, repo_name, pr_number)
74+
result.merge_conflict_info = merge_status
75+
except ValueError:
76+
# If we can't get merge status, don't fail the whole operation
77+
pass
78+
79+
return result
6280

6381

6482
def get_job_details_for_status(

tests/test_cli_refactored.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def test_status_command_no_failures(
3030
"""Test status command with no failures."""
3131
# Setup mocks
3232
mock_fetcher_class.return_value = Mock()
33-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
33+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
3434
mock_get_ci_status.return_value = CIStatusResult([], "test branch")
3535

3636
result = runner.invoke(cli, ["status"])
@@ -50,7 +50,7 @@ def test_status_command_with_failures(
5050
# Setup mocks
5151
mock_fetcher = Mock()
5252
mock_fetcher_class.return_value = mock_fetcher
53-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
53+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
5454

5555
failed_runs = [{"name": "Test Job", "conclusion": "failure", "html_url": "https://example.com"}]
5656
mock_get_ci_status.return_value = CIStatusResult(failed_runs, "test branch")
@@ -77,7 +77,7 @@ def test_logs_command_no_failures(
7777
"""Test logs command with no failures."""
7878
# Setup mocks
7979
mock_fetcher_class.return_value = Mock()
80-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
80+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
8181
mock_get_job_logs.return_value = {
8282
"type": "filtered_logs",
8383
"target_description": "test branch",
@@ -100,7 +100,7 @@ def test_logs_command_with_specific_job(
100100
"""Test logs command with specific job ID."""
101101
# Setup mocks
102102
mock_fetcher_class.return_value = Mock()
103-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
103+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
104104
mock_get_job_logs.return_value = {
105105
"type": "specific_job",
106106
"job_info": {
@@ -127,7 +127,7 @@ def test_logs_command_raw(mock_get_job_logs, mock_get_target_info, mock_fetcher_
127127
"""Test logs command with raw flag."""
128128
# Setup mocks
129129
mock_fetcher_class.return_value = Mock()
130-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
130+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
131131
mock_get_job_logs.return_value = {
132132
"type": "raw_logs",
133133
"failed_jobs": [{"job": {"name": "Test Job", "id": 123}, "logs": "raw log content"}],
@@ -149,7 +149,7 @@ def test_logs_command_filtered(mock_get_job_logs, mock_get_target_info, mock_fet
149149
"""Test logs command with filtered output."""
150150
# Setup mocks
151151
mock_fetcher_class.return_value = Mock()
152-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
152+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
153153
mock_get_job_logs.return_value = {
154154
"type": "filtered_logs",
155155
"target_description": "test branch",
@@ -196,7 +196,7 @@ def test_watch_command_success(
196196
"""Test watch command with successful completion."""
197197
# Setup mocks
198198
mock_fetcher_class.return_value = Mock()
199-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
199+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
200200
mock_watch_ci_status.return_value = {
201201
"status": "success",
202202
"continue_watching": False,
@@ -220,7 +220,7 @@ def test_watch_command_failure(
220220
"""Test watch command with failure."""
221221
# Setup mocks
222222
mock_fetcher_class.return_value = Mock()
223-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
223+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
224224
mock_watch_ci_status.return_value = {
225225
"status": "failed",
226226
"continue_watching": False,
@@ -243,7 +243,7 @@ def test_watch_command_initial_wait_for_no_runs(
243243
"""Test watch command waits 10 seconds on initial 'no_runs' status."""
244244
# Setup mocks
245245
mock_fetcher_class.return_value = Mock()
246-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
246+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
247247

248248
# First call returns no_runs, second call (after wait) returns success
249249
mock_watch_ci_status.side_effect = [
@@ -279,7 +279,7 @@ def test_watch_command_no_runs_after_wait(
279279
"""Test watch command shows proper message when no runs found after initial wait."""
280280
# Setup mocks
281281
mock_fetcher_class.return_value = Mock()
282-
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch")
282+
mock_get_target_info.return_value = ("owner", "repo", "abc123", "test branch", None)
283283

284284
# Both calls return no_runs (persists after wait)
285285
mock_watch_ci_status.return_value = {

0 commit comments

Comments
 (0)