Pull requests and tickets
Two dashboard views answer questions the request list does not: which pull requests are open, and where do they stand? and what happened under this ticket? Both read from what the platform already records. This document covers the recorded pull-request lifecycle, the two list APIs, and how visibility is applied to every row and every aggregate.
1. Pull-request lifecycle
Section titled “1. Pull-request lifecycle”Every pull request the platform opens has one PullRequest row, created when the host accepts the
PR: by the createOrUpdatePullRequest activity for a workflow’s PR step, and by runAgentTask for
the draft PR of an Agent Run with deliver=draft_pr. The row links to a ledger row
(ActiveWorkflow): the workflow step prefers the row of its own execution and falls back to any
ledger row of the same request when the execution keeps none; the Agent Run step requires its own
row (it refuses to run without one) and links that. The row records:
| Column | Meaning |
|---|---|
status |
OPEN, MERGED, or CLOSED (closed without merging) — PULL_REQUEST_STATES in @auto-swe/shared/lib/pullRequest |
isDraft |
whether the PR is a draft; set from how the worker opened it, then followed from the host |
title |
the title as opened, capped at 300 characters |
openedAt / mergedAt / closedAt |
when the PR was opened, merged, and closed (a merge sets both of the last two) |
ciStatus, headSha |
the CI verdict for the current head, unchanged by this lifecycle |
A re-push looks first at the latest PR on the executing workflow’s own ledger row, whatever its
status (openedAt, then id, descending). If it is OPEN, it is updated. A close is a decision, so
if it was closed without merging, createOrUpdatePullRequest fails non-retryably with
PR_CLOSED_BY_REVIEWER instead of opening a replacement, before any other PR is considered. If it
is merged, or the execution has no PR, the newest OPEN PR of the same request and repository is
adopted: a request can have several ledger rows (re-runs, and scheduled fires, which share one
standing request and one branch). When the executing workflow has a ledger row of its own, an
adopted PR moves to it, because the CI webhook signals the workflow a PR’s row names and an execution
that is no longer waiting, or a concurrent one, cannot use that signal; the earlier execution’s view
loses the link. A new run of the request has its own ledger
row, is not stopped by an earlier run’s close, and opens a new PR; the old row stays as history.
An Agent Run is a new workflow each time, so the close guard does not apply to it: it opens one PR
per run, on its own branch, and inserts one row for it. The row is only ever inserted, never
updated: the host has just opened a new PR, so an existing row with the same (repository, number)
belongs to a different one. A failure to write it, including the unique index on (repository, number)
refusing a collision, is logged and recorded on the run as a pr.record_failed activity event, and
does not fail the run, because the branch is already pushed and the PR open.
1.1 Webhook events
Section titled “1.1 Webhook events”POST /api/v1/webhooks/git follows the host’s pull_request events for rows that already exist:
| Event | Effect |
|---|---|
closed, merged |
OPEN or CLOSED → MERGED, mergedAt and closedAt set, the workflow signalled and the merge side effects run (evals label, Slack, tracker) |
closed, not merged |
OPEN → CLOSED, closedAt set. The workflow is not signalled |
reopened |
CLOSED → OPEN, closedAt cleared. A merged PR is never reopened |
ready_for_review / converted_to_draft |
isDraft set to false / true |
edited |
title refreshed from the payload when it carries one |
Every other action is ignored. The handler updates the row matched by (repository, PR number) and never creates one: a pull request opened outside the platform has no row and is not recorded.
The rest of the contract is the same as for every webhook delivery:
- The delivery is bound to the host its secret proved, exactly as for a merge, so a payload naming a repository on another host changes nothing (see repositories.md).
- Every status transition is guarded on the row’s current status (merge from
OPENorCLOSED, close fromOPEN, reopen fromCLOSED), so a redelivered event finds the row already changed and does nothing.MERGEDis terminal: the transition into it is the claim, and only the delivery that wins it signals the workflow, so a redelivered merge cannot run the merge side effects twice. A failed signal restores the row’s previous status. Draft and title updates are plain idempotent sets, not status-guarded. - The title is host text, untrusted: it is trimmed and capped on write, and every surface renders it as plain text.
2. GET /api/v1/pull-requests
Section titled “2. GET /api/v1/pull-requests”Newest first, paginated like GET /work-requests (limit, offset; meta.total).
| Parameter | Values | Default |
|---|---|---|
state |
OPEN, MERGED, CLOSED, all |
all |
draft |
any, draft, ready |
any |
repoId |
a repository id | any |
ticket |
substring of the external ticket id, case-insensitive | any |
scope |
MINE (requests the caller made) or TEAM (everything visible) |
MINE |
teamId |
narrow to a team’s repositories, including ones shared with it | any |
A row carries the repository (id, org, name), prNumber, url, title, status,
isDraft, ciStatus, openedAt/mergedAt/closedAt, and — from the ledger row the PR belongs
to — ticketId, workRequestId, costUsd and latestRun (id, status).
url is derived in one place, pullRequestUrl() in @auto-swe/shared/lib/pullRequest, from the
repository’s own web base (so a repository on another host links to that host) or, when the
repository names none, the instance’s configured GitHub base (resolveGitHubBaseUrl(), so a
GHE-primary deployment links to its own host, not github.com), and is null unless that base is an
http(s) address. The gateway derives it for every surface — the ticket and request lists and the
workflow API (pullRequests[].url), which the workflow page reads — so the browser never guesses
the host.
Visibility. A row is listed when the caller may reach the PR’s repository
(reachableConnections, so a shared team counts and the permission gate applies); an ADMIN sees
every row. The ledger-derived fields are read only when the ledger row is on that same repository,
and latestRun only when the caller may see the run (buildWorkflowRunVisibilityFilter), so a
visible PR never discloses a run, request or cost the caller could not list themselves. total
counts the same filtered set.
3. GET /api/v1/pull-requests/outcomes
Section titled “3. GET /api/v1/pull-requests/outcomes”The delivery metric: of the pull requests opened in a window, how many were merged and how fast. The cohort is the PRs opened in the window, with each one’s outcome read as of now.
| Parameter | Values | Default |
|---|---|---|
window |
1 to 365 days, whole UTC days ending today | 30 |
since, until |
an inclusive UTC day range (YYYY-MM-DD), together; replaces window |
none |
scope |
MINE (requests the caller made) or TEAM (everything visible) |
TEAM |
teamId |
narrow to a team’s repositories, including ones shared with it | any |
repoId |
a repository id | any |
The response data holds window (start, end, days), current for the window and
previous for the same number of days directly before it (null when previousComplete is false),
previousComplete, isTruncated, and
byRepository: the ten repositories with the most PRs opened, each with its own summary, plus
byTemplate: PR outcomes grouped by the persisted PullRequest.templateId and template name.
Rows without a template id appear in the null-id “Unattributed” bucket. A summary
carries opened, merged, closedUnmerged, open, mergeRate (merged over merged plus closed
unmerged; null while none has resolved), timeToMergeMs (p50, p95 and count of merged PRs
with a valid open-to-merge time), ciStatusCounts and draftCount. The summaries are computed by
summarizePullRequestOutcomes() in @auto-swe/shared/lib/pullRequestOutcomes.
Both outcomes routes accept an optional templateId UUID in addition to repoId; the export
includes template_id and template_name columns.
Visibility is the list’s: an ADMIN sees every row, anyone else the PRs of repositories they may
reach (reachableConnections). The read takes at most 10,000 PR rows after filters, newest first.
When that cap cuts into the window, isTruncated is true and current is computed from the rows read; when it
cuts into the previous window only, previousComplete is false and previous is null.
3.1 GET /api/v1/pull-requests/outcomes/export
Section titled “3.1 GET /api/v1/pull-requests/outcomes/export”The same pull requests as a file for a spreadsheet or BI tool: one row per PR opened in the
window (no previous window), newest first. It takes the outcomes read’s window, since/until,
scope, teamId, repoId and templateId, plus format: csv (default) or ndjson. Visibility is the
outcomes read’s, from the same filter function, so an export reaches no row the summary would not.
ENGINEER and above may call it.
Columns (CSV header, and the keys of each NDJSON object): pull_request_id, repository_org,
repository_name, pr_number, status, is_draft, ci_status, opened_at, merged_at,
closed_at, time_to_merge_ms (merged PRs only), template_id, template_name. Titles and
tickets are not exported. A text cell
that starts with =, +, -, @, a tab or a carriage return is prefixed with ' so a
spreadsheet does not run it as a formula (csvCell() in gateway/src/lib/csv.ts, shared with the
audit-log export); numbers and booleans are written as they are.
The file stops at 50,000 rows. X-Export-Row-Limit carries the cap and X-Export-Truncated is
true when more rows matched. The count and the first batch are read before any byte is sent, so a
database failure at that point is a plain 500. The assistant may not call it (bulkExport in the
assistant policy).
4. GET /api/v1/tickets
Section titled “4. GET /api/v1/tickets”Groups everything filed under one externalTicketId. The grouping, the ordering and the paging are
done in the database (groupBy over the matching requests), so a call reads one page of groups, not
every request. Groups are ordered by the creation time of their newest matching request, newest
first, then by ticket id; meta.total is the number of groups. Each page’s aggregates are then read
for that page’s tickets only.
| Parameter | Values | Default |
|---|---|---|
includeAutomated |
true / false |
false |
search |
substring of the ticket id, the request description or its launch label | none |
scope, teamId |
as for pull requests | MINE |
search, scope and teamId choose which tickets are listed. The figures for a listed ticket
cover all of its visible requests, not only the ones the filter matched, so searching for a request
does not shrink its ticket’s cost or run counts.
A group carries the ticket id, title, status and url, requestCount, runCounts by run
status, the latestRun, the pullRequests (repository, number, state, draft, link), the summed
costUsd, latestWorkRequestId and lastActivityAt (the creation time of the group’s newest
matching request; a later run of an old request does not move it).
Automated work is hidden by default. The ids these launches file are correlation keys, not
tickets, so unless includeAutomated=true the list leaves them out, and a listed ticket’s requests,
runs, cost and pull requests leave them out too:
- requests with an agent run (a run of a template whose origin is the agent-run origin);
- requests with a Channel Assistant or Channel Task run, or any run in a Slack channel;
- requests whose ticket id the platform generated (
RunInput.ticketIsSynthetic): a template launch that named no ticket and filed the request’s own id, a PRD run (PRD-<id>), a scheduled work request (<prefix>-SCHED-<id>), a channel task (slack-<channel>-<thread>), and a PRD story given a generated id because the tracker did not assign one. A tracker- or user-supplied id is never flagged. Agent runs and channel tasks are flagged as well as recognised by their template or channel.
A request flagged ticketIsSynthetic names no tracker issue, so none of the tracker syncs (PR opened,
workflow started, CI verdict, merge, workflow finished) is attempted for it, and neither is the
knowledge-base PR link write-back, which would otherwise search the knowledge base for a page
matching the generated id. The write-back also requires the flag to have been read and found false,
so an unreadable flag skips it while the tracker sync is still attempted. A postTicketComment
step in such a run posts nothing and returns posted: false with a reason. A Channel Assistant
turn files no request of its own, so there is nothing to flag. The Slack merge note and
the merge evaluation row do not depend on the tracker and still run.
On the GitHub tracker every read and write — a postTicketComment comment, the syncs above (which
post comments, post the pull-request link as a comment and close the issue when the run completes),
the issues a PRD run creates for its epics and stories, and the ticket fetched into a request’s
ContextSnapshot — stays in a repository the run targets, on the host the tracker is configured
for, or in one an operator listed in github.trackerIssueRepositories. A GitHub ticket id
(owner/repo#123) names its own repository and is supplied by whoever launched the run, so the id
is checked first against the run’s repositories: its connection when that is a git repository, and
the git repositories its launch was authorized against (knowledgeRepoSources). At submit time the
repository is the one the launcher selected, which the route has already checked for membership.
Owner and repository compare case-insensitively; the host is the tracker’s base URL
(api.github.com when it has none) against the repository’s API and web URLs (the instance’s GitHub
URL when it has none), with github.com and api.github.com one host, and <t>.ghe.com and
api.<t>.ghe.com one host. A bare issue number names no repository: a write refuses it, and a read
resolves it against the run’s own repository and checks that.
A deployment that keeps its tickets in a separate issues repository (org/issues#5 while the run
targets org/app) lists org/issues in github.trackerIssueRepositories
(configuration.md §9). A ticket id that
names a listed repository is then in scope for reads and writes alike. The setting is ADMIN-only and
resolves at the team and organization of the run’s own repository, never from anything a launcher
supplies. Listed entries are on the tracker’s host by definition, so the host comparison applies
only to a run’s repositories; the provider still calls only the tracker’s own base URL. A bare issue
number never resolves against the list. A setting that cannot be read is an empty list, so the
check fails closed. A run with no git repository connection has no team to resolve at and gets no
list. A PRD run’s project key
(owner/repo) is checked the same way, as the issue <key>#1; without a project key the
admin-configured default is used and is not checked. A write that fails the check is skipped and
logged with a fixed line, and a PRD item that is refused counts as a failed tracker item. A read
that fails the check happens before any request and is treated as a ticket that was not found: no
ticket is fetched or stored, the submission still succeeds and the run continues from its
description, and a fixed line is logged with ids only. Jira and Linear ids and project keys name no
repository and are not checked.
The check runs on the repository the ticket id names, and the request then stays there. The
provider follows a redirect only while its target is still under
/repos/<owner>/<repo>/ of the checked repository (case-insensitive), and a fetched issue whose
repository_url names another repository is discarded. Reads and writes therefore never reach a
repository other than the one checked. GitHub answers a renamed repository or a transferred issue
with a redirect outside that path, which is not followed: a read treats the ticket as unavailable
(no ticket is fetched or stored), and a write (comment, close, pull-request link, issue creation) is
skipped.
Visibility is per row. A request is in scope when the caller requested it, when the caller can
see one of its runs (buildWorkflowRunVisibilityFilter, the rule GET /workflow-runs/requests
lists by), or when the caller reaches a repository it has a ledger row on or targets. Within a
group:
- runs are counted through the run rule;
- ledger rows (cost) and pull requests count only on repositories the caller can reach;
- an ADMIN sees everything.
The cost and PRs of a cross-repo epic therefore split by team: a member of one repository’s team
sees that repository’s child, not the epic’s own ledger row, not another repository’s child. A
teamId filter matches a request only through a repository the caller can reach (a ledger row, the
request’s target, or a run the caller sees whose repository it is), so it can never reveal that a
ticket touches a team’s repository the caller cannot reach, even when the caller sees one of its runs
through a template. A ticket with no visible request does
not appear or count toward meta.total.
Tracker text. title and status are the tracker’s answer when the newest visible request that
has one was submitted (ContextSnapshot.rawTicketData). They are untrusted text, capped, and
rendered as plain text. url is returned only for an http(s) address; anything else is null.
5. Dashboard
Section titled “5. Dashboard”The Work group of the sidebar lists Pull requests (/pull-requests) and Tickets
(/tickets). Filters live in the address, so a filtered list can be bookmarked and survives a
reload.
- Pull requests — a table with the state (Open, Draft, Merged, Closed) and CI badges, the ticket (linked to its request), the latest run, cost and age. Filters: scope, state (Open unless the address says otherwise), draft, repository, ticket. The PR links to the host in a new tab.
- Pull request outcomes — a card on Platform analytics (
/admin/analytics) for the page’s date range: merge rate, median and 95th-percentile time to merge, PRs opened (with merged, closed unmerged, open and draft counts), each against the previous window, and the ten repositories with the most PRs opened. A banner says when the row cap cut into the window, and when it cut into the previous one so no comparison is shown. The card loads and fails on its own. - Tickets — a table with the tracker status, request and run counts, the latest run, PR count, cost and last activity. A “Hide automated runs” checkbox is on by default. A row expands to the latest request and its pull requests with their states.
Titles, ticket ids and tracker text render as plain text; a ticket link is followed only for an http(s) address.
Limitations
Section titled “Limitations”-
Pull-request attribution is stored when a worker creates or updates a PR and is backfilled from repository-matched active workflows.
WorkflowRun.workflowIdis unique, so the run is resolved by that exact workflow id rather than by recency. Rows with no matching run keep null attribution and appear as “Unattributed”; reporting reads the stored attribution rather than searching recent runs. For non-admin outcomes and exports, a template name is included only when the template passes the same team-membership visibility filter asGET /workflow-templates; otherwise the PR is included in “Unattributed” with no template name. -
With the GitHub tracker, comments, pull-request links, issue closes and PRD issue creation go only to a repository the run targets, on the tracker’s host, or one listed in
github.trackerIssueRepositories, and a ticket is read only from one. The tracker has no configured default repository, so a ticket id naming any other repository, a repository on another host, or a bare issue number gets no write:postTicketCommentreturnsposted: false, and the sync is skipped. A PRD run’s project key outside the run’s repositories and the list creates no issue. -
A ticket already on a
ContextSnapshotis used as stored; the scope check applies only when the ticket is fetched. -
A listed issues repository must be on the tracker’s own host and is listed by an admin; there is no per-request or per-launcher way to name one. A
TEAMorORGANIZATIONvalue applies to every run on that team’s repositories, including runs by other teams on a repository shared with it, because the list resolves at the repository’s owning team; shares and membership decide that audience. -
A renamed repository or a transferred issue is not followed, so its ticket is unavailable and its writes are skipped until the id names the current repository.
-
A GitHub ticket id on a run with no git repository (no connection, no authorized repository) is never fetched, even a bare number.
-
Outcomes group PRs by the time they were opened and judge them as of now, so a recent cohort under-reports merges that have not happened yet and its merge rate rises as its PRs resolve.
-
An outcomes read takes at most 10,000 PRs; beyond that the figures cover only the newest ones read, and
isTruncatedorpreviousCompletesays so. -
Outcomes are grouped by workflow template, but not by agent. The dashboard card on
/admin/analyticsfollows that page’s date range and offers a repository filter. -
An export that fails after its first byte cannot change its status: the transfer is aborted and a client sees a failed download, not a clean short file. Rows already sent are not marked.
-
The export is a snapshot read in batches of 500 by cursor, not a transaction, so a PR whose state changes while it streams can appear in its old or new state.
-
The count behind
X-Export-Truncatedand the first batch are separate reads, not one transaction, so the header can disagree with the body when rows are added or removed between them. A row deleted between two batches is the cursor for the next, and the file ends there early, with no error andX-Export-Truncatedunchanged. -
The outcomes read and its export filter on
openedAt; the standalone index supports this cohort query. The analytics approval-latency read filtersworkflow_human_stepsonrequested_at, which is indexed. -
scopedefaults toTEAMhere and toMINEon the list, so the same caller sees different counts from the two endpoints unless one is set explicitly. -
A closed draft counts as
closedUnmerged; the merge rate does not separate drafts from ready PRs. -
A PR closed by a reviewer is not replaced by a re-push in the same workflow; the run fails until the PR is reopened or the request is run again.
-
Pull requests opened outside the platform are not listed: a webhook only updates a row the platform created.
-
Review decisions (approvals, requested changes) are not tracked; the state is open, merged or closed, plus draft.
-
A close without a merge is recorded but does not signal the workflow, so a run waiting on a human merge keeps waiting until it times out or is cancelled.
-
Close and reopen events are applied in arrival order. If a
reopenedis lost, or arrives before theclosedit follows, the row readsCLOSEDfor a PR that is open until the next merge or close event corrects it. A merge is still recorded from either state. Because the close guard reads that row, a lostreopenedalso makes a re-push in that workflow fail withPR_CLOSED_BY_REVIEWERalthough the PR is open on the host. -
The close guard finds a PR through the execution’s own ledger row linked to its request. An execution with no such row (a self-registered ledger row with no request, an unmatched scheduled anchor) is not covered and may open a new PR after a close, and an open PR it adopts stays linked to its earlier execution, whose workflow receives the CI signal.
-
Two executions of one request are kept apart by two checks that each read the ledger and then act, with no lock between them. A retry is refused with
409 WORKFLOW_ALREADY_EXISTSwhile any ledger row of the request is not terminal, a scheduled fire’s and a template-launched run’s included (a retry of awf-run is refused while that run is in flight). A scheduled fire is skipped (reasonrequest-in-flight) while a row of the request that is not a fire is not terminal; a retry can last days awaiting a merge, and every fire in that time is skipped, each tick shown as a refused execution with at most one audit row an hour (itsafterJson.eventisfire-skipped), and the schedule resumes on the first tick after the retry ends. The schedule’s own overlap policy keeps fires apart from each other. -
A ledger row is only a claim, and one can be left non-terminal with no run row for the reaper to find (a launch that failed after its ledger write, a workflow terminated before its first activity). So before either check refuses, the row is confirmed with Temporal: a finished or absent workflow has its row closed with the status Temporal reports (
COMPLETED,TIMED_OUT,CANCELLEDfor a terminated or cancelled one, elseFAILED) and does not block; a row written in the last five minutes is trusted without asking; and when Temporal cannot be asked, or does not answer within three seconds, the row blocks and the 409 or the skip says it could not be confirmed. The retry’s workflow id allocator refuses on any non-terminal row of the ticket’seng-family without asking, so a retry confirms the row the allocator names the same way (including one of another request) and allocates again once it is closed. Rows of the ticket’s family under a legacy id spelling are confirmed the same way, since the allocator names whichever row it refuses on. A retry and a fire that both read before either has written its ledger row can still overlap, and the second to reach the PR step then adopts and re-links the other’s open PR as described above. -
A run ended by a
terminatenode with statusSKIPPEDcloses its ledger row asCOMPLETED, the run keepingSKIPPED, so it never reads as in flight. -
Adopting an open PR of the request is by request and repository only; no head branch is stored. After a branch prefix change, or a missed close webhook, the adopted PR may have a different head branch than the one the run pushed.
-
A ticket’s title, status and link are a snapshot from submit time, and exist only when a tracker is configured; they do not follow later edits in the tracker.
-
meta.totalfor tickets is the number of groups, which the database counts by listing them; a deployment with a very large number of distinct tickets pays for that on each read. -
A repository’s PR number is unique per connection row, but the webhook lookups match a repository by owner and name (and host), which can cover more than one connection row. The lookups order by
openedAtthenid, newest first, so the newest row wins. If an older row still holds a PR number after a repository was repointed or recreated, a new Agent Run PR with that number is not tracked (the insert is refused and traced), and the older row is never altered. -
A launch that names its own ticket label is indistinguishable from a real ticket and is not treated as automated.
-
A requester who reaches none of a request’s repositories sees the ticket and its runs but not its cost or pull requests, which are judged by repository.
-
A ticket whose requests are all visible only through a team-owned template is listed too, as in
GET /workflow-runs/requests. -
The list APIs are not exposed through the CLI or the MCP endpoint.
© 2026 Jorge Barnaby
Source-available under FSL-1.1-MIT