Skip to content

Forge reconciliation

The pull request record stores forge state independently of issue closure. GitHub, GitLab and Bitbucket Cloud distinguish merged from closed without a merge. Feedback subscriptions report pr_merged or pr_closed, respectively. A merge is final even when a later webhook or an in-flight read is stale.

The refresh task runs every minute. Each tick claims at most 25 due records and makes at most 25 API reads, including attempts through another tracker and Bitbucket commit lookups. Provider authentication retries and redirects have their own transport limits. Open records re-arm after five minutes; records that did not fit the request budget retry on the next tick. Webhooks accelerate known records, including reopened PRs. Copilot PRs use this same record, refresh task and webhook delivery dedupe.

Legacy records without a known state are also refreshed, regardless of age. Each batch reserves half its slots for due known-state records before filling from the unobserved backlog, so backfill and webhook updates both progress. Unobserved records ignore stored ETags until a full response supplies state.

A 304 changes only refresh bookkeeping. A 429 defers the remaining claimed records on that forge according to Retry-After, with a one-minute floor and one-hour ceiling. This also applies when Bitbucket throttles the commit lookup used to expand an abbreviated head SHA. If that lookup fails for another reason, the row retries after five minutes before caching the new ETag or recording a terminal state. This prevents a later 304, or a terminal row leaving the schedule, from hiding the changed head.

Ingress claims a forge delivery ID before queue publication. A published claim rejects redelivery; an unpublished claim older than two minutes can be taken over. Publication and release match the original claim timestamp, so a late publisher cannot delete or acknowledge a newer holder's claim.

Bitbucket Cloud conditional reads

The Get a pull request API reference and its OpenAPI definition were checked on 2026-10-26. For GET /repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}, the definition lists 200, 401 and 404 responses, with no ETag, Last-Modified, conditional-request parameters or 304 contract. The documentation explicitly describes ETags on file reads and 304 on pipeline logs, so those endpoints' capabilities should not be assumed for PR reads.

An anonymous read of a public PR also returned 200 without ETag or Last-Modified. This is evidence of the fallback being necessary, not a guarantee about every Bitbucket deployment. Preloop sends If-None-Match only when a preceding response supplied an ETag. Without a validator, it explicitly falls back to plain reads. A 200 without an ETag clears an older stored validator. It never derives If-Modified-Since from updated_on or sends If-None-Match: *: the latter tests existence and can hide changes. If Bitbucket starts supplying validators, the existing conditional path accepts them and handles 304 without a provider change.

Acceptance evidence for #2017

Paths below are relative to backend/tests/. Each row is covered; the extra cases close gaps in the original #2045 tests.

Acceptance Test evidence
Merged vs closed on all three forges services/test_forge_reconciliation.py::test_each_forge_reports_merged_apart_from_closed (GitHub, GitLab, Bitbucket merged/declined/superseded cases)
304 on all three forges services/test_forge_reconciliation.py::test_github_conditional_read_sends_etag_and_reports_304, test_gitlab_conditional_read_sends_etag_and_reports_304, test_bitbucket_conditional_read_sends_etag_and_reports_304
429 on all three forges services/test_forge_reconciliation.py::test_github_throttling_carries_the_advised_wait, test_gitlab_throttling_carries_the_advised_wait, test_bitbucket_throttling_carries_the_advised_wait
Every decide() stop reason, unchanged except the split services/test_forge_reconciliation.py::test_decide_golden_table, test_golden_table_covers_every_stop_reason_decide_returns; includes computed timeout exhaustion and provider gate reasons
50 synthetic PRs stay within the bound models/crud/test_forge_reconciliation.py::test_fifty_open_pull_requests_stay_within_the_bound, test_fifty_prs_bound_includes_fallback_and_commit_reads (all three forges)
Dedupe and ordering models/crud/test_forge_reconciliation.py::test_ingress_queues_a_forge_delivery_once_and_releases_on_failure, test_an_unpublished_claim_is_taken_over_after_the_grace_period, test_late_publisher_cannot_mutate_a_taken_over_claim, test_merge_webhook_during_refresh_emits_only_once, test_existing_webhook_merge_survives_a_stale_refresh, test_a_webhook_mark_during_the_read_survives_the_record
Bitbucket capability fallback services/test_forge_reconciliation.py::test_bitbucket_pr_without_validators_degrades_to_plain_reads, test_bitbucket_uses_an_etag_only_when_the_server_supplies_one; models/crud/test_forge_reconciliation.py::test_plain_200_discards_an_obsolete_validator, test_failed_bitbucket_head_lookup_retries_before_caching_validator
Copilot shares the bound and dedupe services/test_copilot_pull_requests.py::test_copilot_webhooks_share_dedupe_worker_and_refresh_bound (50 webhook-created records)

The original issue's expected_artifact field was removed from the revised Delivery Report design, as recorded in #2045. It is not needed by the reconciliation acceptance criteria and is not introduced here.