380 lines
18 KiB
Markdown
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)
|