Files

380 lines
18 KiB
Markdown

---
status: current
system: integrations
requirements:
- REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-001
- REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-002
created: 2026-08-24
owners:
- kandev
---
# GitHub PR Merge Queue System Design
## Purpose and boundaries
The integration system owns the GitHub merge operation and the normalized
merge-queue state. The task system consumes a bounded pull-request summary. The
UI renders the integration state but does not own a second queue contract.
This design covers the complete vertical path. It includes the GitHub clients,
storage, task projection, HTTP response, frontend types, React surfaces, and
desktop and mobile behavior.
## Requirement mapping
| Requirement | Design sections |
| --- | --- |
| `REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-001` | [Components](#components-and-responsibilities), [Data](#data-and-contracts), [Control flow](#control-flow), [Failure](#failure-and-recovery), [Presentation](#presentation-and-responsive-behavior) |
| `REQ-INTEGRATIONS-GITHUB-PR-MERGE-QUEUE-002` | [Data](#data-and-contracts), [Automatic merge](#automatic-merge), [Explicit retry](#explicit-retry), [Task-row projection](#task-row-automation-projection), [Failure](#failure-and-recovery), [Persistence](#persistence), [Presentation](#presentation-and-responsive-behavior) |
## Components and responsibilities
### GitHub provider boundary
- `apps/backend/internal/github/client.go` defines the typed merge outcome.
- `gh_client.go` and `pat_client.go` call GitHub's asynchronous merge endpoint.
- The clients bind an automatic request to an expected head and pace pending
status reads within the request context.
- `graphql.go` reads and normalizes `mergeQueueEntry` state and metadata.
### Integration service and storage
- `service_pr.go` routes the merge request through workspace-scoped write
credentials and invalidates stale pull-request data after success.
- `controller.go` exposes the existing merge endpoint and returns the typed
`merged` or `queued` result.
- `service_pr_watch.go` applies authoritative queue observations and publishes
the normal pull-request update.
- `models.go` owns the normalized `PRStatus` and persisted `TaskPR` fields.
- `store.go` owns the `github_task_prs` schema and all queue-field write paths.
- The CI automation store owns a durable per-pull-request merge-attempt journal.
- The automation service exposes an explicit retry command for one linked pull
request. The controller does not call the merge provider directly.
### Task projection
- `apps/backend/internal/task/statussummary` reduces full pull-request state to
a bounded task status, including aggregate active auto-fix and auto-merge
flags.
- `apps/backend/internal/backendapp/status_summary_adapter.go` supplies the
stored queue and per-pull-request automation state during live updates and
restart hydration.
### Web application
- `apps/web/lib/api/domains/github-pr-api.ts` types the merge result.
- `apps/web/lib/types/github.ts` defines the queue-state and `TaskPR` fields.
- `pr-merge-button.tsx` controls eligibility, submission, feedback, and refresh.
- `pr-merge-queue-status.tsx` normalizes queue presentation and duration copy.
- `pr-ci-automation-controls.tsx` maps a typed automatic-merge error to the
explicit retry command. Loading errors keep their separate refresh action.
- `pr-task-icon.tsx` overlays bounded automation indicators on the task-row
pull-request icon and lazily hydrates per-pull-request details for its
pointer, keyboard, or touch disclosure.
- The task icon, status chip, summary, and detail components reuse the same
automation labels and per-pull-request option matching.
## Data and contracts
`github_task_prs` stores the last authoritative queue observation:
- `merge_queue_state TEXT NOT NULL DEFAULT ''` stores the normalized GitHub
state. Supported values are `queued`, `awaiting_checks`, `mergeable`,
`unmergeable`, and `locked`.
- `merge_queue_position INTEGER NULL` stores GitHub's one-based position.
- `merge_queue_estimated_time_to_merge_seconds INTEGER NULL` stores an optional
non-negative duration.
An empty state with null metadata means that no active queue entry is stored.
The same three fields appear in the `TaskPR` HTTP, boot, and WebSocket payloads.
The bounded task projection reduces active membership to `queued`.
`TaskStatusSummary.pull_request` also carries two optional aggregate booleans:
- `auto_fix_enabled` is true when at least one active linked pull request has
auto-fix enabled.
- `auto_merge_enabled` is true when at least one active linked pull request has
auto-merge enabled.
These fields are task-row decorations. They do not replace the per-pull-request
automation options, authorize automation, or carry an unbounded option list.
The existing merge endpoint remains:
```http
PUT /api/v1/github/prs/:owner/:repo/:number/merge?workspace_id=:workspaceId
Content-Type: application/json
{"merge_method":"squash"}
```
`merge_method` is optional. It accepts `merge`, `squash`, or `rebase`. A
successful response is one of these objects:
```json
{"status":"merged"}
{"status":"queued"}
```
The provider request uses `merge_action=default`. GitHub therefore selects the
allowed direct or queued path.
`Client.MergePR` accepts a request value with the merge method and an optional
expected head SHA. Automatic merge requests require a non-empty expected head.
The clients send it as GitHub's `sha` field. A different provider head fails the
request instead of merging a pull-request revision that Kandev did not review.
The manual merge action keeps its existing behavior until its separate contract
requires head binding.
GitHub returns the merge status at the response root. It returns the polling
UUID and failure message inside `details`. The provider clients use
`details.uuid` to poll a pending request and `details.message` to report a
failed request. A pending response without `details.uuid` is invalid.
The clients also decode `details.expected_head_sha` for error diagnostics.
`github_task_ci_pr_state` keeps the automatic attempt journal for each linked
pull request:
- `last_merge_signature` stores the readiness state that authorized an attempt.
- `last_merge_attempt_at` stores when Kandev reserved the attempt.
- `last_queue_attempt_head_sha` stores the head accepted by GitHub or observed
in the active queue.
- `last_merge_result` stores `in_flight`, `failed`, or `accepted`.
- `last_error_kind` identifies `auto_merge` errors separately from loading,
auto-fix, and other automation errors.
The readiness signature includes the task, repository, pull request, pull-request
lifecycle state, head SHA, check conclusion, mergeability, review decision,
review count, required-review presence and value, pending-check count, and
unresolved-thread count. Including lifecycle state prevents a closed pull
request from reusing an attempt reserved while it was open, including after a
later reopen.
The explicit retry endpoint is:
```http
POST /api/v1/github/tasks/:taskId/ci-automation/retry-merge
Content-Type: application/json
{"repository_id":"repo-id","pr_number":3117}
```
The endpoint returns `202 Accepted` after it durably authorizes a new evaluation.
This response does not claim that GitHub accepted or completed the merge.
## Control flow
### Merge request
1. The frontend derives eligibility from the current pull-request state.
2. The user activates the existing merge action.
3. The service selects workspace-scoped personal-write credentials.
4. The provider starts or resumes GitHub's asynchronous merge request.
5. The provider polls a pending request until GitHub returns a terminal result.
6. The controller returns `merged` or `queued` to the frontend.
7. The frontend shows outcome-specific feedback and requests a state refresh.
Only GitHub's `enqueued` result maps to `queued`. An already merged pull request
maps to `merged`. A provider failure remains retryable.
### Automatic merge
1. The evaluator refreshes the exact linked pull request.
2. It requires fresh state, a non-empty head SHA, and all readiness gates.
3. It computes the complete readiness signature.
4. Before the unchanged-signature guard, it consumes a pending one-shot retry
authorization for this exact repository and pull request, if one exists.
That authorization bypasses only the unchanged-signature guard. The
evaluator still requires fresh state, the current head, and every readiness
gate. Without an authorization, an unchanged `in_flight`, `failed`, or
`accepted` signature stops the automatic path.
5. It reserves an `in_flight` attempt in storage before any provider side
effect.
6. It calls the provider with the exact observed head SHA.
7. It records `failed` or `accepted` from the provider result. An accepted
result clears a prior `auto_merge` error for that pull request.
If the reservation cannot be stored, the evaluator does not call GitHub. A
changed readiness signature can authorize one later automatic attempt after all
gates pass.
### Pending polling
The provider waits at least one second between pending-status reads. The wait
observes cancellation and the existing two-minute request budget. A conflict
response resumes the request with its returned UUID. A missing, expired, or
unknown request fails with a diagnostic error.
### Explicit retry
The retry command authorizes one new evaluation for the exact repository and
pull-request number. It applies only to a failed attempt or to an expired
`in_flight` attempt. Expiration is an explicit transition:
`expired in_flight -> failed -> authorized retry`. A retry cannot be authorized
while the original attempt is still `in_flight`, and it does not bypass checks,
reviews, mergeability, thread, or head requirements.
The service persists the retry authorization and publishes the normal
automation evaluation event. The evaluator then refreshes the pull request and
runs the full gate sequence. The HTTP handler never calls GitHub directly.
### Task-row automation projection
The authoritative pull-request loader reads linked pull requests and their
per-pull-request automation options in bounded batches. It supplies the two
switches with each internal pull-request observation. The summary derives each
aggregate flag from open pull requests only.
The status-summary projector subscribes to `github.task_ci_options.updated`.
It refreshes the task's authoritative pull-request observations before it
publishes a complete replacement summary. Existing pull-request update events
use the same observations, so a merge, close, unlink, or option change removes
obsolete indicators without browser-side polling.
The task row reads only `status_summary`. It does not mount
`useTaskCIAutomationOptions` for every visible task and does not issue an HTTP
request per row. When the user opens the icon disclosure, one deduplicated lazy
request hydrates the full pull-request records and per-pull-request automation
options needed for detailed text.
### Queue observation
The batched GraphQL selection reads this field:
```graphql
mergeQueueEntry { state position estimatedTimeToMerge }
```
`graphql.go` records whether the response observed queue membership. This guard
distinguishes an authoritative null from REST and `gh pr view` responses that
cannot read queue data.
`SyncTaskPR` atomically replaces the queue state, position, and estimate after
an authoritative GraphQL observation. It preserves all three fields after a
queue-unaware read. It clears them after an authoritative null, merge, or close.
The update then publishes `github.task_pr.updated` through the existing path.
An active queue entry reconciles the attempt journal to `accepted`. A merged
state does the same. Either observation clears only an error whose kind is
`auto_merge`. It preserves unrelated automation errors.
## State transitions
- An open pull request with no observed queue entry is not queued.
- A non-null authoritative entry creates or updates active queue membership.
- A later authoritative null removes active queue membership.
- A merged or closed state clears queue metadata and wins presentation priority.
- An active queue or merged observation marks the matching attempt accepted.
- A queue-unaware or failed read preserves the last complete observation.
- An unknown future non-empty state remains active and uses generic UI copy.
## Failure and recovery
Missing credentials, permission errors, provider validation errors, rate limits,
and transport errors return a non-success response. These errors do not change
the stored pull-request state or show a successful outcome.
An unknown successful provider result fails closed. Kandev does not claim that
the pull request merged or entered the queue.
A failed GraphQL queue read preserves the last complete observation. The next
successful authoritative sync updates or clears it. This behavior also protects
queue state during restart recovery and mixed GraphQL, REST, and CLI refreshes.
An automatic provider failure records `failed`, stores an `auto_merge` error,
and blocks automatic repetition for the unchanged readiness signature. A changed
signature or an explicit retry can rearm the attempt.
After a process interruption, Kandev first reconciles a stored `in_flight`
attempt with authoritative queue and merged state. If neither state appears
before the attempt deadline, Kandev marks the attempt failed and exposes the
explicit retry. It does not resubmit the unchanged attempt automatically.
## Persistence
The queue fields use additive SQLite columns in `github_task_prs`. Fresh schema,
migration, create, replace, restore, and update paths round-trip the complete
entry. Existing rows start with no queue entry and converge after a successful
GraphQL status sync.
The attempt journal uses additive columns in `github_task_ci_pr_state`. A
startup migration classifies only recognized stable legacy merge-error prefixes
as `auto_merge`. An unknown or empty error kind is never cleared as an automatic
merge error.
Reservation, result, error-kind, and retry-authorization changes use storage
transactions. The existing per-PR singleflight remains an optimization. Durable
reservation is the correctness boundary across concurrent events and restarts.
The estimate remains a duration. Kandev does not convert it to a durable target
timestamp because GitHub can change the estimate on each observation.
## Security
The merge action uses the active workspace's personal-write GitHub route. It
requires the same pull-request permissions as GitHub's merge API. Kandev does
not bypass repository rules or elevate the user.
The retry endpoint uses the same task and workspace authorization. It accepts
only the exact pull request that is linked to the task.
## Presentation and responsive behavior
An active queue entry uses `#966600`. Terminal states take precedence. Queue
membership takes precedence over other non-terminal icon states.
The task hover summary, desktop status popover, phone drawer, and pull-request
detail panel show localized state and position. Estimates below one minute use a
localized sub-minute label. Estimates of one minute or more round up to a
localized whole-minute duration. Missing estimates omit estimate text instead
of inventing a value. Unknown provider states use generic queue copy.
Desktop and mobile share state derivation and actions. Mobile uses the existing
Review surface and status drawer. The action has a touch-sized target, does not
depend on hover, and does not create document-level horizontal overflow.
The task-row pull-request icon is the nearest shipped status-summary exemplar.
It overlays a small yellow dot at the top-left for active auto-fix and a small
purple dot at the top-right for active auto-merge. A contrasting surface ring
keeps both dots distinct from every pull-request status color. The dots are
supplementary; accessible text names each enabled setting.
Fine-pointer hover and keyboard focus extend the existing pull-request tooltip
with an Automation section. The section identifies enabled settings by active
pull request when per-pull-request options differ. A coarse-pointer activation
uses the existing compact task status drawer pattern and a touch-sized icon
target. Both presentations reuse the same lazy data and view model. The task
row remains the primary navigation target, and the automation disclosure does
not change settings.
An `auto_merge` error shows Retry on desktop and mobile for the selected pull
request. Other stored automation errors show Refresh because repeating a merge
would not address them. Loading errors keep their existing retry of the state
request.
Mobile keeps the existing inset status drawer and its single scroll owner. The
shared error row has a target of at least 44 by 44 CSS pixels. This change does
not add a drawer, dialog, or navigation surface.
## Verification evidence
Focused backend coverage exists in the GitHub client, GraphQL conversion,
storage, synchronization, and task-status packages. Frontend unit coverage
exists for merge eligibility, merge outcomes, queue formatting, and status
priority. Playwright coverage exists in:
- `apps/web/e2e/tests/pr/pr-merge-queue.spec.ts`
- `apps/web/e2e/tests/pr/mobile-pr-merge-queue.spec.ts`
- `apps/web/e2e/tests/pr/pr-status-badge.spec.ts`
- `apps/web/e2e/tests/pr/mobile-pr-sidebar-automation-indicators.spec.ts`
## Related delivery records
- [Bind automatic merge attempts to the reviewed head](../../../decisions/2026-08-28-bind-github-auto-merge-attempts-to-reviewed-head.md)
- [Separate task summary and session stream traffic](../../../decisions/2026-08-01-separate-task-summary-session-stream-traffic.md)
- [Automatic merge reliability plan](../../../plans/github-auto-merge-reliability/plan.md)
- [Sidebar automation indicators plan](../../../plans/github-sidebar-automation-indicators/plan.md)
- [Queue-status visibility plan](../../../plans/github-pr-merge-queue-status/plan.md)
- [Original queue-aware merge action plan](../../../plans/github-pr-merge-queue/plan.md)