diff --git a/docs/guide/bug-workflow.md b/docs/guide/bug-workflow.md index 253890eb..391bf50a 100644 --- a/docs/guide/bug-workflow.md +++ b/docs/guide/bug-workflow.md @@ -110,6 +110,9 @@ After plan approval, Forge: 8. **Human review** gate. 9. **Post-merge summary:** After merge, Forge posts a fix summary and release note to the Jira ticket. +!!! note "PR Merge Reconciliation" + A merged pull request is terminally reconciled by its repository namespace and pull request number, not by its head SHA. This design ensures that merge detection remains robust and unaffected by commit rebuilds, squashing, or rebase operations during review. + --- ## Comment Syntax diff --git a/docs/guide/feature-workflow.md b/docs/guide/feature-workflow.md index a50b587c..de0e19f9 100644 --- a/docs/guide/feature-workflow.md +++ b/docs/guide/feature-workflow.md @@ -188,6 +188,9 @@ The PR is now ready for human review. Merge when satisfied, or request changes t Once the PR is merged, Forge automatically completes the workflow by transitioning the Feature, all associated Tasks and Epics, and its parent Epic (if present) to **`Closed`** status in Jira. +!!! note "PR Merge Reconciliation" + A merged pull request is terminally reconciled by its repository namespace and pull request number, not by its head SHA. This design ensures that merge detection remains robust and unaffected by commit rebuilds, squashing, or rebase operations during review. + ## Q&A Mode At any approval gate, you can ask questions without triggering regeneration: diff --git a/docs/guide/task-workflow.md b/docs/guide/task-workflow.md index 5f5467c2..4e6c9423 100644 --- a/docs/guide/task-workflow.md +++ b/docs/guide/task-workflow.md @@ -153,6 +153,9 @@ If the agent classifies any review comments as *contested* (e.g., if they contra When the PR is merged, Forge marks the Task workflow complete. +!!! note "PR Merge Reconciliation" + A merged pull request is terminally reconciled by its repository namespace and pull request number, not by its head SHA. This design ensures that merge detection remains robust and unaffected by commit rebuilds, squashing, or rebase operations during review. + ## Comment Syntax At the triage and plan gates, Forge classifies comments by prefix: diff --git a/tests/unit/workflow/test_pr_state.py b/tests/unit/workflow/test_pr_state.py index 0aab124e..a98aa1b3 100644 --- a/tests/unit/workflow/test_pr_state.py +++ b/tests/unit/workflow/test_pr_state.py @@ -265,7 +265,9 @@ def test_save_with_unknown_number_keys_by_url() -> None: ) assert "acme/docs:https://github.com/acme/docs/pull/30" in saved["pull_requests"] - assert saved["pull_requests"]["acme/docs:https://github.com/acme/docs/pull/30"]["number"] is None + assert ( + saved["pull_requests"]["acme/docs:https://github.com/acme/docs/pull/30"]["number"] is None + ) def test_save_without_number_or_url_is_noop() -> None: @@ -359,18 +361,14 @@ def _legacy_state() -> dict: def test_event_targets_pull_request_matches_legacy_bare_repo_key() -> None: state = _legacy_state() - event = _event( - repo="acme/legacy", native_id=99, url="https://github.com/acme/legacy/pull/99" - ) + event = _event(repo="acme/legacy", native_id=99, url="https://github.com/acme/legacy/pull/99") assert event_targets_pull_request(state, event) def test_activate_pull_request_for_event_hydrates_from_legacy_bare_repo_key() -> None: state = _legacy_state() - event = _event( - repo="acme/legacy", native_id=99, url="https://github.com/acme/legacy/pull/99" - ) + event = _event(repo="acme/legacy", native_id=99, url="https://github.com/acme/legacy/pull/99") activated = activate_pull_request_for_event(state, event) @@ -381,9 +379,7 @@ def test_activate_pull_request_for_event_hydrates_from_legacy_bare_repo_key() -> def test_save_migrates_legacy_bare_repo_key_to_numbered_key() -> None: state = _legacy_state() - event = _event( - repo="acme/legacy", native_id=99, url="https://github.com/acme/legacy/pull/99" - ) + event = _event(repo="acme/legacy", native_id=99, url="https://github.com/acme/legacy/pull/99") activated = activate_pull_request_for_event(state, event) activated["ci_status"] = "passed" @@ -392,3 +388,35 @@ def test_save_migrates_legacy_bare_repo_key_to_numbered_key() -> None: assert "acme/legacy" not in saved["pull_requests"] assert saved["pull_requests"]["acme/legacy:99"]["ci_status"] == "passed" assert saved["pull_requests"]["acme/legacy:99"]["lifecycle_node"] == "ci_evaluator" + + +def test_reconciliation_is_independent_of_head_sha() -> None: + """A pull request is terminally reconciled by its repository and PR number, not by its head SHA.""" + # Create two events with different head_sha but the same repo and native_id + event_1 = _event( + repo="acme/payments", native_id=42, url="https://github.com/acme/payments/pull/42" + ) + # Set different head SHAs on the ChangeRequests + event_1.change_request.head_sha = "sha1" + + event_2 = _event( + repo="acme/payments", native_id=42, url="https://github.com/acme/payments/pull/42" + ) + event_2.change_request.head_sha = "sha2" + + # State with key based on repo and number + key = "acme/payments:42" + state = { + "pull_requests": {key: {"number": 42, "url": event_1.change_request.url, "merged": False}} + } + + # Both events target the exact same pull request record regardless of the head SHA + assert event_targets_pull_request(state, event_1) is True + assert event_targets_pull_request(state, event_2) is True + + # Activating either event works and targets the same record + activated_1 = activate_pull_request_for_event(state, event_1) + activated_2 = activate_pull_request_for_event(state, event_2) + + assert activated_1["current_pr_number"] == 42 + assert activated_2["current_pr_number"] == 42