Skip to content

fix: prevent has-conflicts label flapping when mergeable is None#1000

Open
rnetser wants to merge 7 commits intomainfrom
fix/has-conflicts-label-flapping
Open

fix: prevent has-conflicts label flapping when mergeable is None#1000
rnetser wants to merge 7 commits intomainfrom
fix/has-conflicts-label-flapping

Conversation

@rnetser
Copy link
Collaborator

@rnetser rnetser commented Feb 26, 2026

Summary

Fix has-conflicts label flapping caused by GitHub returning stale or unknown mergeable status after base branch changes.

Flow

1. Poll mergeable (30s timeout, 5s interval via TimeoutSampler)
2. If mergeable=False → add has-conflicts, exit
3. If mergeable=None (timeout) → skip has-conflicts update, continue to needs-rebase
4. If mergeable=True → remove has-conflicts if present, continue to needs-rebase

Root Cause

  • pull_request.mergeable returns None while GitHub computes merge status
  • Code treated None as "no conflicts" (None is FalseFalse), removing has-conflicts label
  • Another bot later sees mergeable=False and re-adds it → flapping

Changes

  • Poll mergeable with TimeoutSampler (30s/5s) wrapped in asyncio.to_thread() until definitive True/False
  • Guard has-conflicts label update with if mergeable is not None: — unknown status preserves current label
  • Exit early on confirmed conflicts (no needs-rebase check needed)
  • Re-raise asyncio.CancelledError, catch TimeoutExpiredError specifically

Test plan

  • All 1358 tests pass
  • 90.73% coverage
  • ruff check + format clean

When GitHub is still computing merge status, pull_request.mergeable
returns None. Previously this was treated as "no conflicts", causing
the has-conflicts label to be incorrectly removed and then re-added
once GitHub finished computing — resulting in label flapping.

Now polls mergeable using TimeoutSampler (wait_timeout=15, sleep=3)
wrapped in asyncio.to_thread() until GitHub returns a definitive
True/False. Only skips the label update if still None after retries.

Closes #999
@myakove-bot
Copy link
Collaborator

Report bugs in Issues

Welcome! 🎉

This pull request will be automatically processed with the following features:

🔄 Automatic Actions

  • Reviewer Assignment: Reviewers are automatically assigned based on the OWNERS file in the repository root
  • Size Labeling: PR size labels (XS, S, M, L, XL, XXL) are automatically applied based on changes
  • Issue Creation: Disabled for this repository
  • Pre-commit Checks: pre-commit runs automatically if .pre-commit-config.yaml exists
  • Branch Labeling: Branch-specific labels are applied to track the target branch
  • Auto-verification: Auto-verified users have their PRs automatically marked as verified
  • Labels: All label categories are enabled (default configuration)

📋 Available Commands

PR Status Management

  • /wip - Mark PR as work in progress (adds WIP: prefix to title)
  • /wip cancel - Remove work in progress status
  • /hold - Block PR merging (approvers only)
  • /hold cancel - Unblock PR merging
  • /verified - Mark PR as verified
  • /verified cancel - Remove verification status
  • /reprocess - Trigger complete PR workflow reprocessing (useful if webhook failed or configuration changed)
  • /regenerate-welcome - Regenerate this welcome message

Review & Approval

  • /lgtm - Approve changes (looks good to me)
  • /approve - Approve PR (approvers only)
  • /automerge - Enable automatic merging when all requirements are met (maintainers and approvers only)
  • /assign-reviewers - Assign reviewers based on OWNERS file
  • /assign-reviewer @username - Assign specific reviewer
  • /check-can-merge - Check if PR meets merge requirements

Testing & Validation

  • /retest tox - Run Python test suite with tox
  • /retest build-container - Rebuild and test container image
  • /retest python-module-install - Test Python package installation
  • /retest pre-commit - Run pre-commit hooks and checks
  • /retest conventional-title - Validate commit message format
  • /retest all - Run all available tests

Container Operations

  • /build-and-push-container - Build and push container image (tagged with PR number)
    • Supports additional build arguments: /build-and-push-container --build-arg KEY=value

Cherry-pick Operations

  • /cherry-pick <branch> - Schedule cherry-pick to target branch when PR is merged
    • Multiple branches: /cherry-pick branch1 branch2 branch3

Label Management

  • /<label-name> - Add a label to the PR
  • /<label-name> cancel - Remove a label from the PR

✅ Merge Requirements

This PR will be automatically approved when the following conditions are met:

  1. Approval: /approve from at least one approver
  2. LGTM Count: Minimum 1 /lgtm from reviewers
  3. Status Checks: All required status checks must pass
  4. No Blockers: No WIP, hold, conflict labels
  5. Verified: PR must be marked as verified (if verification is enabled)

📊 Review Process

Approvers and Reviewers

Approvers:

  • myakove
  • rnetser

Reviewers:

  • myakove
  • rnetser
Available Labels
  • hold
  • verified
  • wip
  • lgtm
  • approve
  • automerge

💡 Tips

  • WIP Status: Use /wip when your PR is not ready for review
  • Verification: The verified label is automatically removed on each new commit
  • Cherry-picking: Cherry-pick labels are processed when the PR is merged
  • Container Builds: Container images are automatically tagged with the PR number
  • Permission Levels: Some commands require approver permissions
  • Auto-verified Users: Certain users have automatic verification and merge privileges

For more information, please refer to the project documentation or contact the maintainers.

@qodo-code-review
Copy link

Review Summary by Qodo

Prevent has-conflicts label flapping when mergeable is None

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Prevent has-conflicts label flapping by polling mergeable status
• Use TimeoutSampler to wait for GitHub's merge computation
• Wrap polling in asyncio.to_thread to avoid blocking event loop
• Skip label updates only when mergeable remains None after retries
Diagram
flowchart LR
  A["PR webhook received"] --> B["Check mergeable status"]
  B --> C{mergeable is None?}
  C -->|Yes| D["Poll with TimeoutSampler<br/>15s timeout, 3s interval"]
  D --> E{Got definitive<br/>True/False?}
  E -->|Yes| F["Update labels based<br/>on merge state"]
  E -->|No| G["Skip label update<br/>return early"]
  C -->|No| F
  F --> H["Complete"]
  G --> H
Loading

Grey Divider

File Changes

1. webhook_server/libs/handlers/pull_request_handler.py 🐞 Bug fix +30/-0

Add mergeable status polling to prevent label flapping

• Added import for TimeoutSampler from timeout_sampler package
• Implemented polling logic when mergeable is None to wait for GitHub's computation
• Polls for up to 15 seconds with 3-second intervals using TimeoutSampler
• Wraps polling in asyncio.to_thread() to prevent blocking the event loop
• Returns early without updating labels if mergeable remains None after retries

webhook_server/libs/handlers/pull_request_handler.py


2. webhook_server/tests/test_pull_request_handler.py 🧪 Tests +44/-16

Add tests for mergeable None polling behavior

• Updated existing test to verify no labels are modified when mergeable is None after retries
• Added new test case to ensure has-conflicts label is preserved when mergeable is None
• Both tests mock TimeoutSampler to simulate timeout scenarios
• Tests verify that neither add nor remove label operations are called in timeout cases

webhook_server/tests/test_pull_request_handler.py


Grey Divider

Qodo Logo

@qodo-code-review
Copy link

qodo-code-review bot commented Feb 26, 2026

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Broad except Exception catches cancellation📘 Rule violation ⛯ Reliability
Description
The new polling code catches Exception and does not re-raise asyncio.CancelledError, which can
break task cancellation and shutdown behavior. It also logs without a traceback (using
logger.warning), reducing debuggability when unexpected errors occur.
Code

webhook_server/libs/handlers/pull_request_handler.py[R987-995]

+                try:
+                    mergeable = await asyncio.to_thread(_poll_mergeable)
+                except Exception:
+                    self.logger.warning(
+                        f"{self.log_prefix} PR mergeable status still None after retries, skipping label update"
+                    )
+                    if self.ctx:
+                        self.ctx.complete_step("label_merge_state", mergeable_unknown=True)
+                    return
Evidence
PR Compliance ID 14 requires using logger.exception for exceptions and explicitly re-raising
asyncio.CancelledError; the added code catches all Exception types and only emits a warning with
no traceback before returning. This also conflicts with PR Compliance ID 3 by effectively swallowing
unexpected exceptions without sufficient context for debugging.

Rule 3: Generic: Robust Error Handling and Edge Case Management
CLAUDE.md
webhook_server/libs/handlers/pull_request_handler.py[987-995]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The polling block catches `Exception` and returns, which can swallow `asyncio.CancelledError` and violates the logging requirement to use `logger.exception(...)` for exceptions.
## Issue Context
This code runs in an async handler; cancellations must propagate correctly to keep shutdown/task cancellation reliable. Unexpected exceptions during polling should include a traceback for debugging.
## Fix Focus Areas
- webhook_server/libs/handlers/pull_request_handler.py[987-995]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@coderabbitai
Copy link

coderabbitai bot commented Feb 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds polling logic to resolve None mergeable states in pull requests, using TimeoutSampler to wait up to 15 seconds with 3-second intervals. If the value remains unresolved after timeout, the step is marked as mergeable_unknown and labeling is skipped. Comprehensive test coverage validates timeout scenarios and conflict resolution based on polling results.

Changes

Cohort / File(s) Summary
Polling Implementation
webhook_server/libs/handlers/pull_request_handler.py
Adds TimeoutSampler-based polling when mergeable is None, retrying every 3 seconds up to 15 seconds total. On timeout, logs warning and marks step as mergeable_unknown, exiting early without label updates. Preserves existing behavior when a definitive value is obtained.
Test Coverage
webhook_server/tests/test_pull_request_handler.py
Expands test suite with 4 new test scenarios: timeout with no existing labels, timeout with existing conflicts label, polling resolving to mergeable state, and polling resolving to conflicted state. Patches TimeoutSampler behavior to drive retry and timeout scenarios.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

The implementation introduces new polling logic with exception handling in a critical code path, and the test suite expansion requires careful validation that all timeout and polling scenarios are properly covered.

Possibly related issues

Possibly related PRs

Suggested labels

verified, can-be-merged

Suggested reviewers

  • myakove
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: preventing label flapping when mergeable is None, matching the core change in the pull request.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/has-conflicts-label-flapping

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@webhook_server/libs/handlers/pull_request_handler.py`:
- Around line 975-982: The polling function _poll_mergeable currently calls
repository.get_pull(pr_number).mergeable directly; change it to use the unified
API on the webhook object (self.github_webhook.unified_api) to fetch the pull
and its mergeable status inside TimeoutSampler (replace the lambda that calls
repository.get_pull with one that calls
self.github_webhook.unified_api.<appropriate_method>(pr_number) and reads
.mergeable or returns the unified API result), ensuring all GitHub operations in
_poll_mergeable and the surrounding handler use self.github_webhook.unified_api
rather than direct repository/PyGithub access.
- Around line 970-997: The poll function _poll_mergeable can return None but the
code only handles exceptions; after awaiting asyncio.to_thread(_poll_mergeable)
add an explicit guard: if mergeable is None then log a warning (same message
used in the except block), call self.ctx.complete_step("label_merge_state",
mergeable_unknown=True) when self.ctx is set, and return early to avoid
computing has_conflicts and clearing the conflict label; this ensures mergeable
is only used when not None.

In `@webhook_server/tests/test_pull_request_handler.py`:
- Around line 2322-2380: Add tests that cover the success polling path of
label_pull_request_by_merge_state: create one test where
mock_pull_request.mergeable starts as None and the patched TimeoutSampler yields
a definitive True (mergeable) and assert that _add_label/_remove_label are
called appropriately, and a second (or parametrized) test where TimeoutSampler
yields False (not mergeable) and assert the opposite labeling behavior; patch
pull_request_handler.labels_handler.pull_request_labels_names, _add_label and
_remove_label like the existing tests and patch
"webhook_server.libs.handlers.pull_request_handler.TimeoutSampler" to return an
iterator/sequence of values (first raising no result then returning True/False)
so the handler observes mergeable change and proceeds to call the right label
methods.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f9adcca and b066f2b.

📒 Files selected for processing (2)
  • webhook_server/libs/handlers/pull_request_handler.py
  • webhook_server/tests/test_pull_request_handler.py

- Catch TimeoutExpiredError specifically instead of broad Exception
- Re-raise asyncio.CancelledError to preserve task cancellation
- Use logger.exception() for unexpected errors (includes traceback)
- Add post-poll None guard to prevent label flapping on edge cases
- Add tests for polling resolving to True (mergeable) and False (conflicts)
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
webhook_server/libs/handlers/pull_request_handler.py (1)

974-982: ⚠️ Potential issue | 🟠 Major

HIGH: Polling still uses direct PyGithub access in handler code.

Line 981 calls repository.get_pull(pr_number).mergeable directly from the handler path. This bypasses the handler API boundary and can drift behavior (auth/retry/logging) from other handler operations that go through unified_api.

Please route the polling fetch through self.github_webhook.unified_api instead of self.github_webhook.repository.

#!/bin/bash
set -euo pipefail

# Verify direct pull fetches from handlers
rg -nP --type=py '\brepository\.get_pull\s*\(' webhook_server/libs/handlers

# Inspect existing unified_api pull-related calls in handlers
rg -nP --type=py '\bunified_api\.[A-Za-z_]*pull[A-Za-z_]*\s*\(' webhook_server/libs/handlers

Expected result: no direct repository.get_pull(...) in handler GitHub operation paths.

As per coding guidelines: "Handlers must use self.github_webhook.unified_api for all GitHub operations, not direct PyGithub calls".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webhook_server/libs/handlers/pull_request_handler.py` around lines 974 - 982,
The polling currently calls repository.get_pull(pr_number).mergeable directly
inside _poll_mergeable/TimeoutSampler which bypasses the unified API; change the
lambda to call the unified API method on self.github_webhook.unified_api that
returns the PR (or its mergeable status) instead of using repository.get_pull,
e.g. invoke the existing unified_api pull-fetch method (use the same method name
used elsewhere on unified_api for pull retrieval) and read its .mergeable field
inside _poll_mergeable so all GitHub operations flow through
self.github_webhook.unified_api.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@webhook_server/libs/handlers/pull_request_handler.py`:
- Around line 974-982: The polling currently calls
repository.get_pull(pr_number).mergeable directly inside
_poll_mergeable/TimeoutSampler which bypasses the unified API; change the lambda
to call the unified API method on self.github_webhook.unified_api that returns
the PR (or its mergeable status) instead of using repository.get_pull, e.g.
invoke the existing unified_api pull-fetch method (use the same method name used
elsewhere on unified_api for pull retrieval) and read its .mergeable field
inside _poll_mergeable so all GitHub operations flow through
self.github_webhook.unified_api.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b066f2b and e6c64a4.

📒 Files selected for processing (2)
  • webhook_server/libs/handlers/pull_request_handler.py
  • webhook_server/tests/test_pull_request_handler.py

@rnetser
Copy link
Collaborator Author

rnetser commented Feb 26, 2026

/build-and-push-container

@myakove-bot
Copy link
Collaborator

New container for ghcr.io/myk-org/github-webhook-server:pr-1000 published

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
webhook_server/libs/handlers/pull_request_handler.py (1)

1001-1008: ⚠️ Potential issue | 🟠 Major

HIGH: Use unified_api for polling fetch instead of direct repository access.

Line 1007 uses repository.get_pull(pr_number).mergeable directly inside handler logic. That bypasses the handler abstraction and makes API access inconsistent in this layer; route this through self.github_webhook.unified_api instead.

As per coding guidelines: "Handler implementations must use self.github_webhook.unified_api for all GitHub API operations".

#!/bin/bash
# Verify direct pull-fetch calls in handlers and discover unified_api pull helpers.

echo "== Direct repository.get_pull usage in handlers =="
rg -nP --type=py -C2 '\brepository\.get_pull\s*\(' webhook_server/libs/handlers

echo
echo "== unified_api usage in libs =="
rg -nP --type=py -C2 '\bunified_api\.' webhook_server/libs

echo
echo "== Candidate unified_api pull-related methods =="
rg -nP --type=py -C2 'def\s+.*pull.*\(' webhook_server/libs/github_api.py
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webhook_server/libs/handlers/pull_request_handler.py` around lines 1001 -
1008, The handler currently polls mergeability by calling
repository.get_pull(pr_number).mergeable inside _poll_mergeable (via
TimeoutSampler), which bypasses the handler abstraction; change _poll_mergeable
to call the unified API instead (use self.github_webhook.unified_api's
pull-fetch helper — e.g., a get_pull or fetch_pull_mergeable method) and read
the mergeable value from that response; if no suitable unified_api helper
exists, add a small wrapper on self.github_webhook.unified_api (e.g.,
unified_api.get_pull(pr_number) or unified_api.fetch_pull(pr_number)) and use
that in _poll_mergeable so all GitHub access goes through
self.github_webhook.unified_api rather than repository.get_pull.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@webhook_server/libs/handlers/pull_request_handler.py`:
- Around line 1024-1028: The inner except block in pull_request_handler.py
currently both logs the error and calls self.ctx.fail_step, which duplicates the
outer handler’s failure recording; remove the self.ctx.fail_step(...) call from
the inner except block (keep the existing self.logger.exception(...) and
re-raise) so only the outer exception handler records step failure; ensure any
references to traceback.format_exc() or the ctx guard (if self.ctx) are deleted
along with the fail_step call.

---

Duplicate comments:
In `@webhook_server/libs/handlers/pull_request_handler.py`:
- Around line 1001-1008: The handler currently polls mergeability by calling
repository.get_pull(pr_number).mergeable inside _poll_mergeable (via
TimeoutSampler), which bypasses the handler abstraction; change _poll_mergeable
to call the unified API instead (use self.github_webhook.unified_api's
pull-fetch helper — e.g., a get_pull or fetch_pull_mergeable method) and read
the mergeable value from that response; if no suitable unified_api helper
exists, add a small wrapper on self.github_webhook.unified_api (e.g.,
unified_api.get_pull(pr_number) or unified_api.fetch_pull(pr_number)) and use
that in _poll_mergeable so all GitHub access goes through
self.github_webhook.unified_api rather than repository.get_pull.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e6c64a4 and 15e7640.

📒 Files selected for processing (2)
  • webhook_server/libs/handlers/pull_request_handler.py
  • webhook_server/tests/test_pull_request_handler.py

The inner except block's ctx.fail_step() duplicated the outer
method-level handler's failure recording. Let the outer handler
own step-failure recording for a single source of truth.
coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 1, 2026
@rnetser
Copy link
Collaborator Author

rnetser commented Mar 2, 2026

/build-and-push-container

@myakove-bot
Copy link
Collaborator

New container for ghcr.io/myk-org/github-webhook-server:pr-1000 published

Poll mergeable status using TimeoutSampler (30s timeout, 5s interval)
when GitHub returns None. Guard has-conflicts update with mergeable
None check to prevent removing label on unknown state. Exit early
when conflicts confirmed (skip needs-rebase check).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants