From ef721575bf0047528f76144b174b8a70ffdd5057 Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Tue, 3 Nov 2026 18:46:58 +0000 Subject: [PATCH 1/5] docs(merge-queue): choose a merge method per pull request Documents the "Allow choosing a merge method per pull request" setting, the /trunk merge --merge-method flag, the API's mergeMethod field and the GitLab wording, and drops the note that the merge method applies to the whole repository. Co-Authored-By: Claude Opus 5.5 --- .../administration/advanced-settings.mdx | 34 +++++++++++++++++-- merge-queue/reference/common-problems.mdx | 8 ++--- merge-queue/using-the-queue/api.mdx | 19 +++++++++++ merge-queue/using-the-queue/reference.mdx | 2 ++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/merge-queue/administration/advanced-settings.mdx b/merge-queue/administration/advanced-settings.mdx index a9a7f9f5..e707adf2 100644 --- a/merge-queue/administration/advanced-settings.mdx +++ b/merge-queue/administration/advanced-settings.mdx @@ -119,9 +119,9 @@ Choose how your PRs get merged into the target branch. Options are Squash (defau You can change your merge method at any time: 1. Navigate to the **Merge Queue** tab → **[repository]** → **Settings** -2. Find the **Merge Method** dropdown +2. Find the **Default merge method** dropdown 3. Select your preferred method: Squash, Merge Commit, or Rebase -4. The new method will apply to all PRs merged after the change +4. The new method will apply to all PRs merged after the change, except PRs that [chose their own method](#choosing-a-merge-method-per-pull-request) **Note:** Changing the merge method only affects future merges. PRs already merged will retain their original merge method. @@ -136,6 +136,36 @@ You can change your merge method at any time: The merge method is configured per repository, so different repositories in your organization can use different methods based on their needs. +### Choosing a merge method per pull request + +By default, every PR in a queue merges with the queue's merge method. To let the person who submits a PR choose a different method for that PR, turn on **Allow choosing a merge method per pull request**: + +1. Navigate to the **Merge Queue** tab → **[repository]** → **Settings** → **General** +2. Turn on the **Allow choosing a merge method per pull request** toggle + +The queue's merge method becomes the default: a PR that doesn't choose a method merges with it. Anyone who can submit PRs to the queue can choose a method. + +When the setting is on, you choose a method as you submit the PR: + +* **Trunk web app:** select a method from the **Merge method** menu next to the submit button. **Queue default** uses the queue's method. +* **Comment:** add `--merge-method` to the `/trunk merge` comment, with `squash`, `rebase`, or `merge` (a merge commit). For example, `/trunk merge --merge-method=rebase`. This works in GitHub comments and pull request reviews, and in GitLab comments. +* **API:** send `mergeMethod` when you submit or update a PR. See [Automate the queue with the API](../using-the-queue/api#merge-method). + +Submitting with a label or the merge checkbox always uses the queue's method. + +Trunk refuses the method, and says why, when: + +* The setting is off. +* The PR is already being merged and you choose a different method. +* The branch's GitHub rulesets don't allow that method. +* The provider can't merge that way. GitLab has no rebase. + +If you turn the setting off, every PR merges with the queue's method, including PRs that chose their own. If you turn it back on, those choices apply again. + +When Trunk merges a [stack](../using-the-queue/stacked-pull-requests), the method chosen on the PR you enqueued applies to it and to every PR below it that merges with it. + +**On GitLab**, the choices are **Squash** and **Don't squash**. **Don't squash** (`merge` in a comment, `mergeCommit` in the API) merges the merge request without squashing its commits, and your GitLab project's own merge method decides whether that creates a merge commit. + ### Custom merge commit titles You can override the merge commit title on a per-PR basis by adding a `merge-commit-title:` directive on its own line anywhere in the PR body: diff --git a/merge-queue/reference/common-problems.mdx b/merge-queue/reference/common-problems.mdx index 25e105f9..3156594f 100644 --- a/merge-queue/reference/common-problems.mdx +++ b/merge-queue/reference/common-problems.mdx @@ -29,11 +29,11 @@ og:title: "Trunk Merge Queue FAQ" - **Merge Commit** - Preserves all individual commits and creates a merge commit - **Rebase** - Replays commits on top of main for a linear history - The merge method is configured at the repository level in **Merge Queue** → **[your repository]** → **Settings** → **Merge Method**. All PRs merged through the queue will use the selected method. + The merge method is configured for each queue in **Merge Queue** → **[your repository]** → **Settings** → **Default merge method**. PRs merged through the queue use the selected method. - - **Note:** The merge method applies to the entire repository, not on a per-PR basis. See [Merge Method documentation](/merge-queue/administration/advanced-settings#merge-method) for detailed information on each option and how to configure your preference. - + To let a PR use a different method, turn on **Allow choosing a merge method per pull request**. Then whoever submits the PR can choose its method, for example with `/trunk merge --merge-method=rebase`. See [Choosing a merge method per pull request](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request). + + See [Merge Method documentation](/merge-queue/administration/advanced-settings#merge-method) for detailed information on each option. diff --git a/merge-queue/using-the-queue/api.mdx b/merge-queue/using-the-queue/api.mdx index 8b67ae44..28c7801c 100644 --- a/merge-queue/using-the-queue/api.mdx +++ b/merge-queue/using-the-queue/api.mdx @@ -17,6 +17,7 @@ Merge queues are GitHub-only. Install the Trunk GitHub App on the repository, an - **Mode**: `queue` mode (**Single** in the app) puts pull requests in one line, each tested on top of the ones ahead of it, several at a time. `graph` mode (**Parallel** in the app) gives unrelated pull requests separate lines, based on the [impacted targets](/merge-queue/optimizations/parallel-queues) CI uploads for each. - **Priority**: lower `priorityValue` numbers test first. The names are `urgent` (0), `high` (10), `medium` (100), and `low` (200). Send a number as `priorityValue` or a name as `priorityName`. Without either, a pull request gets `medium` (100). **`urgent` (or `priorityValue: 0`) restarts lower-priority pull requests that are already testing**, so reserve it for emergencies. See [Priority merging](/merge-queue/optimizations/priority-merging). - **Direct to queue**: `direct: true` sends a pull request to the queue before its branch protection is satisfied. See [Direct-to-Queue](/merge-queue/using-the-queue/direct-to-queue). +- **Merge method**: how Trunk merges a pull request: `squash`, `rebase`, or `mergeCommit`. A pull request uses the queue's `mergeMethod` unless it chose its own. See [Merge method](#merge-method). ## Authentication @@ -55,6 +56,7 @@ Its `201` response is the pull request; keep its `id` for polling. - It targets a different branch from the queue's. A stacked pull request may target another pull request's branch, as long as the stack ends at the queue's branch. - A repository ruleset can block it. - It's still in another of the repository's queues. Look up the pull request, read `queueEntry.mergeQueueId`, and dequeue it there first. + - It chose a [merge method](#merge-method) that isn't allowed. - A closed pull request is accepted but stays `notReady`. To wait for a pull request to merge, poll [`GET /v2/pull-requests/{id}`](/api/pullrequests/get-a-pull-request) every 30 to 60 seconds until `queueEntry.state` is `merged`, `failed`, or `cancelled`, or subscribe to [Merge Queue webhooks](/merge-queue/webhooks). The API itself has no webhooks. @@ -68,6 +70,23 @@ To wait for a pull request to merge, poll [`GET /v2/pull-requests/{id}`](/api/pu To find out why a pull request failed, read the latest transition in its [queue-entry history](/api/pullrequests/list-a-pull-requests-queue-entry-state-history) (`direction=newest`). The [failure reason reference](/merge-queue/using-the-queue/handle-failed-pull-requests#failure-reason-reference) explains each `reason`. When a required check failed, `failed.failureCausingChecks` names the checks, and `verificationRunId` leads to the [verification run](/api/mergequeueverificationruns/get-a-verification-run) and its `checks`. +## Merge method + +Every pull request merges with the queue's merge method, `config.mergeMethod`, unless it chose its own. A pull request can choose only when the queue's `config.isMergeMethodOverrideEnabled` is `true`. Turn it on in the queue's [settings](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request), or send `{ "isMergeMethodOverrideEnabled": true }` to [`PATCH /v2/merge-queues/{id}/config`](/api/mergequeues/update-a-merge-queues-configuration) with a key that has **Update Merge Queue settings**. + +To choose, send `mergeMethod` as `squash`, `rebase`, or `mergeCommit` when you submit: + +```bash +curl -X POST https://api.trunk.io/v2/merge-queues/xK9mP2nQ/pull-requests \ + -H "Authorization: Bearer $TRUNK_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ "number": 4901, "mergeMethod": "rebase" }' +``` + +To change it while the pull request is queued, send `mergeMethod` to [`PATCH /v2/pull-requests/{id}/queue-entry`](/api/pullrequests/update-a-queued-pull-requests-entry). Send `null` to go back to the queue's method; leave it out to keep the current choice. Choosing needs only the **Merge and cancel PRs** permission. `queueEntry.mergeMethod` reads the choice back, and is `null` when the queue's method applies. + +Trunk refuses a choice with `422 PR_NOT_ENQUEUEABLE`, and the reason in `message`, when the queue's setting is off, when the pull request is already being merged, when the branch's rulesets don't allow the method, or when the provider can't merge that way. On GitLab, `mergeCommit` means "don't squash", and `rebase` isn't available. + ## Queue order List order is not queue order. A queue's pull request list is ordered by when each pull request was first submitted to the queue, newest first, and a resubmission doesn't move it. For the order the queue will test pull requests in, use [`GET /v2/merge-queues/{id}/order`](/api/mergequeues/get-a-merge-queues-dispatch-order). Its lists carry only each pull request's `id`, `number`, `title`, `sha`, and `url`. For state or priority, match by `id` against `GET /v2/merge-queues/{id}/pull-requests?state=pending,testing,testsPassed,pendingFailure`. diff --git a/merge-queue/using-the-queue/reference.mdx b/merge-queue/using-the-queue/reference.mdx index cba1484b..8ae3aa57 100644 --- a/merge-queue/using-the-queue/reference.mdx +++ b/merge-queue/using-the-queue/reference.mdx @@ -24,6 +24,8 @@ og:title: "Submit and cancel PRs in Trunk Merge Queue" - **Submit:** `/trunk merge` - **Cancel:** `/trunk cancel` + + If your queue lets PRs [choose their own merge method](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request), add `--merge-method=squash`, `--merge-method=rebase`, or `--merge-method=merge` to `/trunk merge`. Apply a configured **enqueue label** to the pull request on GitHub to submit it automatically. See [Enqueueing by label](#enqueueing-by-label) below. From 03017eedabfa9623d237e9f34c80f21c935ec04d Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Tue, 3 Nov 2026 18:54:41 +0000 Subject: [PATCH 2/5] docs(merge-queue): review fixes on per-PR merge method Co-Authored-By: Claude Opus 5.5 --- merge-queue/administration/advanced-settings.mdx | 9 +++------ merge-queue/reference/common-problems.mdx | 2 +- merge-queue/using-the-queue/api.mdx | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/merge-queue/administration/advanced-settings.mdx b/merge-queue/administration/advanced-settings.mdx index e707adf2..c81f443a 100644 --- a/merge-queue/administration/advanced-settings.mdx +++ b/merge-queue/administration/advanced-settings.mdx @@ -118,7 +118,7 @@ Choose how your PRs get merged into the target branch. Options are Squash (defau You can change your merge method at any time: -1. Navigate to the **Merge Queue** tab → **[repository]** → **Settings** +1. Navigate to the **Merge Queue** tab → **[repository]** → **Settings** → **General** 2. Find the **Default merge method** dropdown 3. Select your preferred method: Squash, Merge Commit, or Rebase 4. The new method will apply to all PRs merged after the change, except PRs that [chose their own method](#choosing-a-merge-method-per-pull-request) @@ -134,7 +134,7 @@ You can change your merge method at any time: * **Repository Size**: Squash merging can help keep repository history more concise * **Existing Workflows**: Match your existing GitHub merge button preferences for consistency across your team -The merge method is configured per repository, so different repositories in your organization can use different methods based on their needs. +The merge method is configured per queue, so different queues in your organization can use different methods based on their needs. ### Choosing a merge method per pull request @@ -148,7 +148,7 @@ The queue's merge method becomes the default: a PR that doesn't choose a method When the setting is on, you choose a method as you submit the PR: * **Trunk web app:** select a method from the **Merge method** menu next to the submit button. **Queue default** uses the queue's method. -* **Comment:** add `--merge-method` to the `/trunk merge` comment, with `squash`, `rebase`, or `merge` (a merge commit). For example, `/trunk merge --merge-method=rebase`. This works in GitHub comments and pull request reviews, and in GitLab comments. +* **Comment:** add `--merge-method` to the `/trunk merge` comment, with `squash`, `rebase`, or `merge` (a merge commit). For example, `/trunk merge --merge-method=rebase`. This works in GitHub comments and pull request reviews. * **API:** send `mergeMethod` when you submit or update a PR. See [Automate the queue with the API](../using-the-queue/api#merge-method). Submitting with a label or the merge checkbox always uses the queue's method. @@ -158,14 +158,11 @@ Trunk refuses the method, and says why, when: * The setting is off. * The PR is already being merged and you choose a different method. * The branch's GitHub rulesets don't allow that method. -* The provider can't merge that way. GitLab has no rebase. If you turn the setting off, every PR merges with the queue's method, including PRs that chose their own. If you turn it back on, those choices apply again. When Trunk merges a [stack](../using-the-queue/stacked-pull-requests), the method chosen on the PR you enqueued applies to it and to every PR below it that merges with it. -**On GitLab**, the choices are **Squash** and **Don't squash**. **Don't squash** (`merge` in a comment, `mergeCommit` in the API) merges the merge request without squashing its commits, and your GitLab project's own merge method decides whether that creates a merge commit. - ### Custom merge commit titles You can override the merge commit title on a per-PR basis by adding a `merge-commit-title:` directive on its own line anywhere in the PR body: diff --git a/merge-queue/reference/common-problems.mdx b/merge-queue/reference/common-problems.mdx index 3156594f..d7439c85 100644 --- a/merge-queue/reference/common-problems.mdx +++ b/merge-queue/reference/common-problems.mdx @@ -29,7 +29,7 @@ og:title: "Trunk Merge Queue FAQ" - **Merge Commit** - Preserves all individual commits and creates a merge commit - **Rebase** - Replays commits on top of main for a linear history - The merge method is configured for each queue in **Merge Queue** → **[your repository]** → **Settings** → **Default merge method**. PRs merged through the queue use the selected method. + The merge method is configured for each queue in **Merge Queue** → **[your repository]** → **Settings** → **General** → **Default merge method**. PRs merged through the queue use the selected method. To let a PR use a different method, turn on **Allow choosing a merge method per pull request**. Then whoever submits the PR can choose its method, for example with `/trunk merge --merge-method=rebase`. See [Choosing a merge method per pull request](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request). diff --git a/merge-queue/using-the-queue/api.mdx b/merge-queue/using-the-queue/api.mdx index 28c7801c..6962836a 100644 --- a/merge-queue/using-the-queue/api.mdx +++ b/merge-queue/using-the-queue/api.mdx @@ -85,7 +85,7 @@ curl -X POST https://api.trunk.io/v2/merge-queues/xK9mP2nQ/pull-requests \ To change it while the pull request is queued, send `mergeMethod` to [`PATCH /v2/pull-requests/{id}/queue-entry`](/api/pullrequests/update-a-queued-pull-requests-entry). Send `null` to go back to the queue's method; leave it out to keep the current choice. Choosing needs only the **Merge and cancel PRs** permission. `queueEntry.mergeMethod` reads the choice back, and is `null` when the queue's method applies. -Trunk refuses a choice with `422 PR_NOT_ENQUEUEABLE`, and the reason in `message`, when the queue's setting is off, when the pull request is already being merged, when the branch's rulesets don't allow the method, or when the provider can't merge that way. On GitLab, `mergeCommit` means "don't squash", and `rebase` isn't available. +Trunk refuses a choice with `422 PR_NOT_ENQUEUEABLE`, and the reason in `message`, when the queue's setting is off, when the pull request is already being merged, or when the branch's rulesets don't allow the method. ## Queue order From 33ab0a9e348e8ee99b3d8b63be083f2d3457fc40 Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Tue, 3 Nov 2026 18:55:33 +0000 Subject: [PATCH 3/5] docs(api): sync the v2 OpenAPI spec Verbatim copy of trunk2 ts/apps/public-api/openapi-v2.json at origin/main 071a40ab9. Picks up isMergeMethodOverrideEnabled on the queue config, mergeMethod on submit and the queue-entry PATCH, and queueEntry.mergeMethod. GET/DELETE /v2/tests/{testId}/ticket and the two GET /v2/tests/{testId}/investigations operations are in the spec but not in the nav, so they aren't published. Co-Authored-By: Claude Opus 5.5 --- openapi-v2.json | 8238 +++++++++++++++++++++++++++-------------------- 1 file changed, 4714 insertions(+), 3524 deletions(-) diff --git a/openapi-v2.json b/openapi-v2.json index 7b01d591..e821e97f 100644 --- a/openapi-v2.json +++ b/openapi-v2.json @@ -68,6 +68,10 @@ "name": "tests", "x-group": "Tests" }, + { + "name": "testInvestigations", + "x-group": "Test Investigations" + }, { "name": "testUploads", "x-group": "Test Uploads" @@ -244,8 +248,8 @@ }, "bisectionConcurrency": { "type": "integer", - "description": "How many verification runs the engine may use in parallel when bisecting a failed batch. `0` means bisection gets no dedicated capacity and shares `concurrency`.", - "example": 0 + "description": "How many verification runs the engine may use in parallel when bisecting a failed batch, separate from `concurrency`. At least 1.", + "example": 5 }, "pendingFailureDepth": { "type": "integer", @@ -350,6 +354,11 @@ "mergeMethod": { "$ref": "#/components/schemas/MergeMethod" }, + "isMergeMethodOverrideEnabled": { + "type": "boolean", + "description": "Whether whoever submits a pull request may choose its merge method. An entry that chooses none merges with the queue's `mergeMethod`. While `false`, every entry merges with the queue's `mergeMethod`, and a submit that changes an entry's merge method is refused.", + "example": false + }, "testBranchConstructionMode": { "$ref": "#/components/schemas/TestBranchConstructionMode" }, @@ -381,6 +390,7 @@ "directMergeMode", "optimizationMode", "mergeMethod", + "isMergeMethodOverrideEnabled", "testBranchConstructionMode", "enqueueingLabel" ], @@ -486,11 +496,13 @@ "TEST_COLLECTION_NOT_FOUND", "TEST_NOT_FOUND", "TEST_UPLOAD_NOT_FOUND", + "INVESTIGATION_NOT_FOUND", "OUT_OF_RANGE", "TICKETING_NOT_CONFIGURED", "TICKETING_CREDENTIALS_INVALID", "TICKETING_SITE_REFUSED", "TICKET_NOT_FOUND", + "TICKET_NOT_LINKED", "TICKET_LINK_CONFLICT", "TEST_ALREADY_LINKED", "TICKET_FIELDS_REJECTED", @@ -527,9 +539,9 @@ }, "bisectionConcurrency": { "type": "integer", - "minimum": 0, + "minimum": 1, "maximum": 1000, - "description": "How many verification runs the engine may use in parallel when bisecting a failed batch. `0` means bisection gets no dedicated capacity and shares `concurrency`." + "description": "How many verification runs the engine may use in parallel when bisecting a failed batch, separate from `concurrency`. At least 1." }, "enqueueingLabel": { "type": "string", @@ -1097,6 +1109,18 @@ "description": "Whether the entry was sent directly to the queue past an unmet readiness condition (`direct` on submit or on `PATCH /v2/pull-requests/{id}/queue-entry`). It did not skip the queue's own tests.", "example": false }, + "mergeMethod": { + "anyOf": [ + { + "$ref": "#/components/schemas/MergeMethod" + }, + { + "type": "null" + } + ], + "description": "The merge method this pull request chose, on submit or on `PATCH /v2/pull-requests/{id}/queue-entry`. `null` means the queue's merge method applies. A choice applies only while the queue lets pull requests choose their own merge method; otherwise the queue's is used.", + "example": "rebase" + }, "verifiedByVerificationRunId": { "type": "string", "description": "The verification run whose pass the queue merged this entry on. Present only for an entry the queue itself merged straight out of `testsPassed`; absent for a force merge, a direct merge, a merge made on the provider, or any unmerged entry. For an optimistically merged entry it is the successor's run — the one that vouched. Read it with `GET /v2/merge-queue-verification-runs/{id}`.", @@ -1126,6 +1150,7 @@ "skipsTheLine", "allowBatching", "direct", + "mergeMethod", "readiness", "createdAt", "updatedAt" @@ -1367,6 +1392,19 @@ }, "direct": { "type": "boolean" + }, + "mergeMethod": { + "anyOf": [ + { + "type": "string", + "enum": ["mergeCommit", "squash", "rebase"] + }, + { + "type": "null" + } + ], + "description": "How to merge this pull request: one of `mergeCommit`, `squash`, or `rebase` (see the `MergeMethod` schema). Omit it, or send `null`, to use the queue's merge method. Choosing one is refused with `PR_NOT_ENQUEUEABLE` unless the queue lets pull requests choose their own merge method, and when the branch's rules forbid the method or the repository's provider does not support it.", + "example": "rebase" } }, "required": ["number"], @@ -1448,9 +1486,9 @@ }, "bisectionConcurrency": { "type": "integer", - "minimum": 0, + "minimum": 1, "maximum": 1000, - "description": "How many verification runs the engine may use in parallel when bisecting a failed batch. `0` means bisection gets no dedicated capacity and shares `concurrency`." + "description": "How many verification runs the engine may use in parallel when bisecting a failed batch, separate from `concurrency`. At least 1." }, "pendingFailureDepth": { "type": "integer", @@ -1560,6 +1598,10 @@ "enum": ["mergeCommit", "squash", "rebase"], "description": "One of `mergeCommit`, `squash`, or `rebase`. See the `MergeMethod` schema." }, + "isMergeMethodOverrideEnabled": { + "type": "boolean", + "description": "Whether whoever submits a pull request may choose its merge method. An entry that chooses none merges with the queue's `mergeMethod`. While `false`, every entry merges with the queue's `mergeMethod`, and a submit that changes an entry's merge method is refused." + }, "testBranchConstructionMode": { "type": "string", "enum": ["renameTempBranch", "pushNewBranchFromTemp"], @@ -2117,10 +2159,22 @@ "type": "boolean", "description": "Whether the submission allowed the entry to be batched.", "example": true + }, + "mergeMethod": { + "anyOf": [ + { + "$ref": "#/components/schemas/MergeMethod" + }, + { + "type": "null" + } + ], + "description": "The merge method the pull request had chosen after this submission. `null` means none was chosen, so the queue's merge method applies.", + "example": "rebase" } }, - "required": ["allowBatching"], - "description": "How the pull request was submitted: the priority and batching it was submitted with. Present on `notReady` transitions." + "required": ["allowBatching", "mergeMethod"], + "description": "How the pull request was submitted: the priority, batching and merge method it was submitted with. Present on `notReady` transitions." }, "PendingTransitionDetails": { "type": "object", @@ -2648,6 +2702,19 @@ "type": "boolean", "description": "Whether this entry may be tested in a batch with others. `false` gives it a test run of its own. Sending this field at all needs the **Skip batching** permission. Omit to leave the entry's current setting alone.", "example": false + }, + "mergeMethod": { + "anyOf": [ + { + "type": "string", + "enum": ["mergeCommit", "squash", "rebase"] + }, + { + "type": "null" + } + ], + "description": "How to merge this pull request: one of `mergeCommit`, `squash`, or `rebase` (see the `MergeMethod` schema). `null` clears it, so the queue's merge method applies. Omit to leave it unchanged. Sending this field at all needs the **Merge and cancel PRs** permission. A change is refused with `PR_NOT_ENQUEUEABLE` unless the queue lets pull requests choose their own merge method, while the pull request is being merged, and when the branch's rules forbid the method or the repository's provider does not support it.", + "example": "rebase" } }, "additionalProperties": false, @@ -2738,7 +2805,8 @@ "required-check", "merge-queue-is-required", "paths-filter", - "draft-pull-request" + "draft-pull-request", + "impact-reach" ], "description": "Which independent signal produced a contribution to a job's verdict. These identifiers are also what `ignoreSignals` accepts.", "example": "historical-pass-rate" @@ -2851,6 +2919,14 @@ "changedFiles": { "$ref": "#/components/schemas/ChangedFiles" }, + "impacts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlanImpact" + }, + "maxItems": 8, + "description": "How much of the codebase the change affects, by each measure your own build or dependency tool computes, one entry per source. Jobs with the impact reach signal turned on always run a change that affects everything, lean toward running a broad change and lean slightly toward skipping a narrow one. Omit it when your tool computed nothing; sending nothing leaves every verdict as it is." + }, "workflowPath": { "type": "string", "description": "From `github.workflow_ref`; identifies the workflow whose jobs are in scope.", @@ -2982,6 +3058,31 @@ "description": "How the file changed. The values GitHub reports, so its `status` can be passed through. `changed` (a type change, such as a file becoming a symlink) is read as `modified`.", "example": "modified" }, + "PlanImpact": { + "type": "object", + "properties": { + "source": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$", + "description": "The measure, such as `tainted-packages` or `test-files`: lowercase letters, digits, `.`, `_` and `-`, at most 64 characters. Trunk sets each job's maximum per source, so keep a source's name and measure the same on every request.", + "example": "tainted-packages" + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 10000000, + "description": "How many units your build tool says the change affects: packages, targets, test files or tests. Required unless `all` is true, and ignored when it is. `0` means nothing is affected, not unknown.", + "example": 734 + }, + "all": { + "type": "boolean", + "default": false, + "description": "The tool says the change affects everything, or could not work out what is affected. Never send a large `count` in its place.", + "example": false + } + }, + "required": ["source"] + }, "BuildkitePlanRequest": { "type": "object", "properties": { @@ -3060,6 +3161,14 @@ "changedFiles": { "$ref": "#/components/schemas/ChangedFiles" }, + "impacts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlanImpact" + }, + "maxItems": 8, + "description": "How much of the codebase the change affects, by each measure your own build or dependency tool computes, one entry per source. Jobs with the impact reach signal turned on always run a change that affects everything, lean toward running a broad change and lean slightly toward skipping a narrow one. Omit it when your tool computed nothing; sending nothing leaves every verdict as it is." + }, "buildkiteOrganizationSlug": { "type": "string", "minLength": 1, @@ -3816,9 +3925,15 @@ "type": "string", "description": "The provider's status name, as last synced.", "example": "In Progress" + }, + "deletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "When Trunk found that the ticketing provider no longer has this ticket; null while it exists. A deleted ticket stays linked until it is unlinked, and its `status` is the last one synced.", + "example": "2026-10-01T09:00:00.000Z" } }, - "required": ["id", "provider", "key", "url", "status"], + "required": ["id", "provider", "key", "url", "status", "deletedAt"], "description": "The ticket linked to a test, as Trunk last synced it. Reading a test never calls the ticketing provider." }, "TicketProvider": { @@ -4551,701 +4666,636 @@ "required": ["userId", "displayName"], "description": "The person whose change to the monitor caused the event." }, - "CreatedTestUpload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "The upload's id. The bundle uploaded to `uploadUrl` must carry it, with `createdAt`.", - "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" - }, - "status": { - "type": "string", - "enum": ["awaitingData"], - "description": "The bundle has not been uploaded yet.", - "example": "awaitingData" - }, - "uploadUrl": { - "type": "string", - "format": "uri", - "description": "A pre-signed URL to `PUT` the bundle to, without Trunk credentials. It expires at `expiresAt`.", - "example": "https://bundles.s3.us-west-2.amazonaws.com/20261006/0d9c3de1-14a0-4f68-a44a-2fbbcf0dcb2f/a1b2c3/f47ac10b-58cc-4372-a567-0e02b2c3d479.tar.zst?X-Amz-Signature=3f1b8c9e" - }, - "expiresAt": { - "type": "string", - "format": "date-time", - "description": "When `uploadUrl` stops accepting the bundle: at most an hour after `createdAt`, and at least 15 minutes.", - "example": "2026-10-06T17:00:00.000Z" - }, - "testCollectionUuid": { - "type": "string", - "format": "uuid", - "description": "The collection's internal UUID, which a test's `testId` is computed from (with `repositoryId` and the test's own id). Use it only for that: everywhere else a collection is addressed by its `id`.", - "example": "204b6c93-1c51-4e0f-a13c-e07b9bc60688" - }, - "repositoryId": { - "type": "string", - "format": "uuid", - "description": "The repository the tests are stored under. The nil UUID for an upload without a repository, or for a repository Trunk could not find or create.", - "example": "7a1f0e3d-2b4c-4d5e-8f90-123456789abc" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "When the upload was created.", - "example": "2026-10-06T16:00:00.000Z" - } - }, - "required": [ - "id", - "status", - "uploadUrl", - "expiresAt", - "testCollectionUuid", - "repositoryId", - "createdAt" - ], - "description": "A test-results upload, waiting for its bundle." - }, - "CreateTestUploadRequest": { + "InvestigationSummaryList": { "type": "object", "properties": { - "repo": { - "type": ["object", "null"], - "properties": { - "host": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "example": "github.com" - }, - "owner": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "example": "acme" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "example": "web" - } - }, - "required": ["host", "owner", "name"], - "description": "The repository the tests ran in, as the CLI reads it from git. `null` for an upload made without a repository (`--no-repo`)." - }, - "remoteUrls": { + "data": { "type": "array", "items": { - "type": "string", - "minLength": 1, - "maxLength": 2048 + "$ref": "#/components/schemas/InvestigationSummary" }, - "maxItems": 20, - "description": "The repository's git remote URLs. When present and Trunk does not know the repository yet, it is created.", - "example": ["https://github.com/acme/web.git"] + "maxItems": 100 }, - "clientVersion": { - "type": "string", - "maxLength": 256, - "description": "The uploading client's version.", - "example": "0.12.3" + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the last page. Pass it back verbatim as `cursor`; do not construct or inspect one.", + "example": "AQEKZm9vYmFy" }, - "externalId": { - "type": "string", - "maxLength": 256, - "description": "The CI job's own id, used to match the upload to its GitHub Actions job.", - "example": "45829103344" + "hasMore": { + "type": "boolean", + "description": "True when another page is available.", + "example": true + }, + "total": { + "type": "integer", + "description": "Total rows matching the filter. Present only when the request set `includeTotal=true`.", + "example": 1247 } }, - "required": ["repo"], - "description": "The repository the tests ran in, and the client uploading." + "required": ["data", "nextCursor", "hasMore"] }, - "TestUpload": { + "InvestigationSummary": { "type": "object", "properties": { "id": { "type": "string", - "format": "uuid", - "description": "The upload's id.", - "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" - }, - "testCollectionId": { - "type": "string", - "description": "The id of the test collection the upload belongs to.", - "example": "aB3xY9kQ" + "example": "5a7fef9e-c8cd-4eed-8a87-9494bdf96bed" }, - "repositoryId": { + "testId": { "type": "string", - "format": "uuid", - "description": "The repository the tests are stored under. The nil UUID for an upload without a repository.", - "example": "7a1f0e3d-2b4c-4d5e-8f90-123456789abc" + "description": "The investigated test's globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" }, "status": { - "$ref": "#/components/schemas/TestUploadStatus" + "$ref": "#/components/schemas/InvestigationStatus" }, - "defects": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TestUploadDefect" - }, - "maxItems": 1000, - "description": "The problems ingestion found, oldest first. Empty until the bundle has been read. At most 1000; past that, every `invalid` defect is kept before any `suboptimal` one." + "triggerType": { + "$ref": "#/components/schemas/InvestigationTriggerType" }, - "clientVersion": { - "type": "string", - "description": "The version of the client that built the bundle, as ingestion read it from the bundle. Empty until the bundle has been read. It is not the `clientVersion` sent on the create.", - "example": "trunk-analytics-cli 0.12.3" + "detectionEventId": { + "type": ["string", "null"], + "description": "The detection that started an `automatic` investigation.", + "example": "6b8f0aaf-d9de-4ffe-9b98-a5a5ceca7cfe" + }, + "factCount": { + "type": "integer", + "example": 4 + }, + "overallConfidence": { + "type": ["number", "null"], + "description": "The model's confidence in the summary, from 0 to 1. Null unless `completed`.", + "example": 0.75 + }, + "markdownSummary": { + "type": ["string", "null"], + "description": "The investigation's conclusion, in Markdown. Null unless `completed`.", + "example": "The test is flaky because it races the cache warm-up introduced in [this commit](https://github.com/acme/widgets/commit/9c58bf40e5c1)." + }, + "errorMessage": { + "type": ["string", "null"], + "description": "Why a `failed` or `skipped` investigation did not complete.", + "example": "The monthly investigation limit for this collection was reached" + }, + "actor": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationActor" + }, + { + "type": "null" + } + ], + "description": "Who started a `manual` or `mcp` investigation. Null for an `automatic` one." }, "createdAt": { "type": "string", "format": "date-time", - "description": "When the upload was created.", - "example": "2026-10-06T16:00:00.000Z" + "example": "2026-06-02T10:00:00.123Z" }, "updatedAt": { "type": "string", "format": "date-time", - "description": "The last time ingestion touched the upload. Each ingestion attempt moves it, even when `status` stays the same.", - "example": "2026-10-06T16:00:04.000Z" + "example": "2026-06-02T10:01:30.000Z" } }, "required": [ "id", - "testCollectionId", - "repositoryId", + "testId", "status", - "defects", - "clientVersion", + "triggerType", + "detectionEventId", + "factCount", + "overallConfidence", + "markdownSummary", + "errorMessage", + "actor", "createdAt", "updatedAt" - ], - "description": "A test-results upload: where it is, and what ingestion found." + ] }, - "TestUploadStatus": { + "InvestigationStatus": { "type": "string", - "enum": ["awaitingData", "received", "ingested", "skipped"], - "description": "Where an upload is. `awaitingData`: Trunk has not read the bundle. Past `expiresAt` on the create, it was never uploaded or could not be read. `received`: ingestion has started and not finished. Ingestion retries for about 10 minutes, so an upload still `received` with `updatedAt` more than 15 minutes old was not ingested and will not change. `ingested`: ingestion has recorded the upload. Its results and defects are written just after, so `defects` can still grow for a few seconds; each one moves `updatedAt`. If writing them fails, the upload stays `ingested` with its results incomplete. An `invalid` defect names a part of the bundle that was left out. `skipped`: it was read, but so many of its tests failed that Trunk treated the run as an infrastructure failure and stored no results.", - "example": "ingested" + "enum": ["completed", "failed", "skipped"], + "description": "`completed` produced a summary; `failed` errored; `skipped` was declined, for example by a monthly cap.", + "example": "completed" }, - "TestUploadDefect": { + "InvestigationTriggerType": { + "type": "string", + "enum": ["manual", "automatic", "mcp"], + "description": "`manual` from the web app, `automatic` from a detection, `mcp` from the MCP server.", + "example": "automatic" + }, + "InvestigationActor": { "type": "object", "properties": { - "type": { - "$ref": "#/components/schemas/TestUploadDefectType" - }, - "level": { - "$ref": "#/components/schemas/TestUploadDefectLevel" - }, - "source": { + "userId": { "type": "string", - "description": "Where in the upload the defect is, such as a report path.", - "example": "reports/junit.xml" + "example": "6f708192-a3b4-4c5d-8e9f-0a1b2c3d4e5f" }, - "message": { - "type": "string", - "description": "What is wrong.", - "example": "Test case `login` has no timestamp" + "displayName": { + "type": ["string", "null"], + "description": "Null when the user no longer exists.", + "example": "Ada Lovelace" } }, - "required": ["type", "level", "source", "message"], - "description": "A problem ingestion found in an upload." - }, - "TestUploadDefectType": { - "type": "string", - "enum": [ - "unspecified", - "junit", - "env", - "repo", - "bundleSize", - "reportLimit", - "meta", - "deprecatedCli" - ], - "description": "What part of the upload a defect is about.", - "example": "junit" - }, - "TestUploadDefectLevel": { - "type": "string", - "enum": ["suboptimal", "invalid"], - "description": "`invalid`: Trunk could not use the part of the bundle the defect names (`type`, `source`), and ingested the rest. `suboptimal`: that part was used, with less information than it could have had.", - "example": "suboptimal" + "required": ["userId", "displayName"] }, - "Ticket": { + "Investigation": { "type": "object", "properties": { "id": { "type": "string", - "description": "Trunk's id for the linked ticket.", - "example": "5d4c3b2a-1f0e-4a1b-8c9d-0e1f2a3b4c5d" - }, - "provider": { - "$ref": "#/components/schemas/TicketProvider" + "example": "5a7fef9e-c8cd-4eed-8a87-9494bdf96bed" }, - "key": { + "testId": { "type": "string", - "description": "The provider's human-readable key. Mutable on some providers (a Jira issue moved between projects gets a new one).", - "example": "TRUNK-1234" + "description": "The investigated test's globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" }, - "url": { - "type": "string", - "format": "uri", - "example": "https://acme.atlassian.net/browse/TRUNK-1234" + "status": { + "$ref": "#/components/schemas/InvestigationStatus" }, - "title": { - "type": "string", - "example": "[Flaky Test] suite::test_case_name" + "triggerType": { + "$ref": "#/components/schemas/InvestigationTriggerType" }, - "status": { - "type": "string", - "description": "The provider's status name, as last synced.", - "example": "In Progress" + "detectionEventId": { + "type": ["string", "null"], + "description": "The detection that started an `automatic` investigation.", + "example": "6b8f0aaf-d9de-4ffe-9b98-a5a5ceca7cfe" }, - "assignee": { + "factCount": { + "type": "integer", + "example": 4 + }, + "overallConfidence": { + "type": ["number", "null"], + "description": "The model's confidence in the summary, from 0 to 1. Null unless `completed`.", + "example": 0.75 + }, + "markdownSummary": { "type": ["string", "null"], - "description": "The provider's assignee display name; null when unassigned.", - "example": "Ada Lovelace" + "description": "The investigation's conclusion, in Markdown. Null unless `completed`.", + "example": "The test is flaky because it races the cache warm-up introduced in [this commit](https://github.com/acme/widgets/commit/9c58bf40e5c1)." }, - "syncedAt": { + "errorMessage": { + "type": ["string", "null"], + "description": "Why a `failed` or `skipped` investigation did not complete.", + "example": "The monthly investigation limit for this collection was reached" + }, + "actor": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvestigationActor" + }, + { + "type": "null" + } + ], + "description": "Who started a `manual` or `mcp` investigation. Null for an `automatic` one." + }, + "createdAt": { "type": "string", "format": "date-time", - "description": "When Trunk last read the ticket back from its provider.", - "example": "2026-09-15T14:45:01.000Z" + "example": "2026-06-02T10:00:00.123Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "example": "2026-06-02T10:01:30.000Z" + }, + "facts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvestigationFact" + }, + "maxItems": 100, + "description": "Ordered by impact, then confidence, highest first." + }, + "snapshotFailureTestRunIds": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 1000, + "description": "The failing runs the investigation was started from." } }, "required": [ "id", - "provider", - "key", - "url", - "title", + "testId", "status", - "assignee", - "syncedAt" + "triggerType", + "detectionEventId", + "factCount", + "overallConfidence", + "markdownSummary", + "errorMessage", + "actor", + "createdAt", + "updatedAt", + "facts", + "snapshotFailureTestRunIds" ] }, - "LinkTicketRequest": { + "InvestigationFact": { "type": "object", "properties": { - "externalTicketId": { + "type": { "type": "string", - "minLength": 1, - "maxLength": 2048, - "description": "The ticket to link, as its provider key (`TRUNK-123`, a Linear identifier, an Asana task gid) or as its browser URL — either form is accepted.", - "example": "TRUNK-1234" + "description": "What the fact is about, such as `ciLogs`, `gitBlame`, `failureMode`, `testPurpose` or `environmentConfig`. New types may be added.", + "example": "gitBlame" }, - "integrationId": { - "type": "string", - "format": "uuid", - "description": "Which of the organization's ticketing connections the ticket lives on. Defaults to the collection's default connection.", - "example": "2b3c4d5e-6f70-4a1b-8c9d-0e1f2a3b4c5d" - } - }, - "required": ["externalTicketId"], - "description": "The ticket to link to a test in a test collection." - }, - "CreateTicketRequest": { - "type": "object", - "properties": { - "title": { + "content": { "type": "string", - "minLength": 1, - "maxLength": 250, - "description": "The ticket's title. Defaults to the one the web app generates, such as `[Flaky Test] suite::test_case_name`.", - "example": "[Flaky Test] suite::test_case_name" + "description": "Markdown. Cited evidence is linked inline where it has a URL.", + "example": "The assertion began failing after [this commit](https://github.com/acme/widgets/commit/9c58bf40e5c1)." }, - "description": { - "type": "string", - "description": "The ticket's body, in Markdown. Defaults to the one the web app generates: the test's location, failure rate, owners and most common failure reasons, with a link back to the test in Trunk.", - "example": "See all details on the [Trunk Test Detail page](https://app.trunk.io/acme/flaky-tests/collections/ab12cd34/tests/bfeebcf4-0000-8000-8000-000000000001)" + "confidence": { + "type": "number", + "description": "The model's confidence in the fact, from 0 to 1.", + "example": 0.8 }, - "fields": { - "type": "object", - "additionalProperties": {}, - "description": "Provider fields to set on the ticket, keyed by the provider's field id (a Jira custom field id, `assignee`, a Linear project id), in the shape the provider takes. Merged over the default fields saved on the collection's ticketing connection; a field set here wins.", - "example": { - "assignee": "5b10ac8d82e05b22cc7d4ef5" - } + "impact": { + "type": "number", + "description": "How much the fact explains the flakiness, from 0 to 1.", + "example": 0.7 }, - "replaceExisting": { - "type": "boolean", - "description": "Replace a ticket already linked to the test. When false or absent, a test that has a ticket is refused with `TEST_ALREADY_LINKED`.", - "example": false + "citations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvestigationCitation" + }, + "maxItems": 100 } }, - "additionalProperties": false, - "description": "The ticket to create for a test in a test collection." + "required": ["type", "content", "confidence", "impact", "citations"] }, - "RepositoryList": { + "InvestigationCitation": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Repository" - }, - "maxItems": 100 + "kind": { + "$ref": "#/components/schemas/InvestigationCitationKind" }, - "nextCursor": { + "url": { "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null on the last page. Pass it back verbatim as `cursor`; do not construct or inspect one.", - "example": "AQEKZm9vYmFy" - }, - "hasMore": { - "type": "boolean", - "description": "True when another page is available.", - "example": true + "description": "Where the cited evidence is. Set for a commit or file on a GitHub repository; null otherwise.", + "example": "https://github.com/acme/widgets/commit/9c58bf40e5c1" }, - "total": { - "type": "integer", - "description": "Total rows matching the filter. Present only when the request set `includeTotal=true`.", - "example": 1247 + "testRunId": { + "type": ["string", "null"], + "description": "The cited run, for a `testRun` citation.", + "example": "1f9ddce9-b5f5-5676-8eef-770e11cf761f" } }, - "required": ["data", "nextCursor", "hasMore"] + "required": ["kind", "url", "testRunId"] }, - "Repository": { + "InvestigationCitationKind": { + "type": "string", + "enum": ["testRun", "testCase", "upload", "commit", "file", "ciRun"], + "example": "commit" + }, + "CreatedTestUpload": { "type": "object", "properties": { "id": { "type": "string", - "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" - }, - "provider": { - "$ref": "#/components/schemas/Provider" + "format": "uuid", + "description": "The upload's id. The bundle uploaded to `uploadUrl` must carry it, with `createdAt`.", + "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }, - "path": { + "status": { "type": "string", - "description": "The provider-side path, `owner/repo`.", - "example": "acme/widgets" - }, - "host": { - "type": ["string", "null"], - "description": "Null for a repository on the provider's default host.", - "example": "github.com" + "enum": ["awaitingData"], + "description": "The bundle has not been uploaded yet.", + "example": "awaitingData" }, - "url": { + "uploadUrl": { "type": "string", - "example": "https://github.com/acme/widgets" + "format": "uri", + "description": "A pre-signed URL to `PUT` the bundle to, without Trunk credentials. It expires at `expiresAt`.", + "example": "https://bundles.s3.us-west-2.amazonaws.com/20261006/0d9c3de1-14a0-4f68-a44a-2fbbcf0dcb2f/a1b2c3/f47ac10b-58cc-4372-a567-0e02b2c3d479.tar.zst?X-Amz-Signature=3f1b8c9e" }, - "defaultBranch": { - "type": ["string", "null"], - "description": "Null until Trunk has synced it.", - "example": "main" + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "When `uploadUrl` stops accepting the bundle: at most an hour after `createdAt`, and at least 15 minutes.", + "example": "2026-10-06T17:00:00.000Z" }, - "isPrivate": { - "type": ["boolean", "null"], - "description": "Null until Trunk has synced it.", - "example": true + "testCollectionUuid": { + "type": "string", + "format": "uuid", + "description": "The collection's internal UUID, which a test's `testId` is computed from (with `repositoryId` and the test's own id). Use it only for that: everywhere else a collection is addressed by its `id`.", + "example": "204b6c93-1c51-4e0f-a13c-e07b9bc60688" }, - "createdAt": { + "repositoryId": { "type": "string", - "format": "date-time", - "example": "2026-01-14T09:12:00.000Z" + "format": "uuid", + "description": "The repository the tests are stored under. The nil UUID for an upload without a repository, or for a repository Trunk could not find or create.", + "example": "7a1f0e3d-2b4c-4d5e-8f90-123456789abc" }, - "updatedAt": { + "createdAt": { "type": "string", "format": "date-time", - "example": "2026-07-20T16:44:10.000Z" + "description": "When the upload was created.", + "example": "2026-10-06T16:00:00.000Z" } }, "required": [ "id", - "provider", - "path", - "host", - "url", - "defaultBranch", - "isPrivate", - "createdAt", - "updatedAt" - ] - }, - "Provider": { - "type": "string", - "enum": ["github", "gitlab", "bitbucket", "unknown"], - "description": "The hosting provider. `unknown` is a repository Trunk has indexed without resolving a provider.", - "example": "github" + "status", + "uploadUrl", + "expiresAt", + "testCollectionUuid", + "repositoryId", + "createdAt" + ], + "description": "A test-results upload, waiting for its bundle." }, - "CiJobRunList": { + "CreateTestUploadRequest": { "type": "object", "properties": { - "data": { + "repo": { + "type": ["object", "null"], + "properties": { + "host": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "example": "github.com" + }, + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "example": "acme" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "example": "web" + } + }, + "required": ["host", "owner", "name"], + "description": "The repository the tests ran in, as the CLI reads it from git. `null` for an upload made without a repository (`--no-repo`)." + }, + "remoteUrls": { "type": "array", "items": { - "$ref": "#/components/schemas/CiJobRun" + "type": "string", + "minLength": 1, + "maxLength": 2048 }, - "maxItems": 100 - }, - "nextCursor": { - "type": ["string", "null"], - "description": "Opaque cursor for the next page, or null on the last page. Pass it back verbatim as `cursor`; do not construct or inspect one.", - "example": "AQEKZm9vYmFy" + "maxItems": 20, + "description": "The repository's git remote URLs. When present and Trunk does not know the repository yet, it is created.", + "example": ["https://github.com/acme/web.git"] }, - "hasMore": { - "type": "boolean", - "description": "True when another page is available.", - "example": true + "clientVersion": { + "type": "string", + "maxLength": 256, + "description": "The uploading client's version.", + "example": "0.12.3" }, - "total": { - "type": "integer", - "description": "Total rows matching the filter. Present only when the request set `includeTotal=true`.", - "example": 1247 + "externalId": { + "type": "string", + "maxLength": 256, + "description": "The CI job's own id, used to match the upload to its GitHub Actions job.", + "example": "45829103344" } }, - "required": ["data", "nextCursor", "hasMore"] + "required": ["repo"], + "description": "The repository the tests ran in, and the client uploading." }, - "CiJobRun": { + "TestUpload": { "type": "object", "properties": { "id": { "type": "string", - "description": "Opaque. Identifies the job's latest attempt.", - "example": "1a289f57705150d7~q1790685507000" + "format": "uuid", + "description": "The upload's id.", + "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }, - "commitSha": { + "testCollectionId": { "type": "string", - "example": "a0de7a77e8d758a346f83ed19209a5a25814bb17" + "description": "The id of the test collection the upload belongs to.", + "example": "aB3xY9kQ" }, - "name": { + "repositoryId": { "type": "string", - "example": "test (2/2)" - }, - "workflowName": { - "type": ["string", "null"], - "example": "CI" - }, - "workflowRunId": { - "type": ["string", "null"], - "description": "The `CiWorkflowRun.id` of the job's run.", - "example": "5e1d2c3b4a596877" - }, - "attempt": { - "type": "integer", - "example": 1 - }, - "attemptCount": { - "type": "integer", - "description": "Every attempt of the job, the latest included.", - "example": 2 - }, - "failedAttemptCount": { - "type": "integer", - "example": 1 + "format": "uuid", + "description": "The repository the tests are stored under. The nil UUID for an upload without a repository.", + "example": "7a1f0e3d-2b4c-4d5e-8f90-123456789abc" }, "status": { - "$ref": "#/components/schemas/CiJobStatus" - }, - "conclusion": { - "anyOf": [ - { - "$ref": "#/components/schemas/CiConclusion" - }, - { - "type": "null" - } - ], - "description": "Set when `status` is `completed`." - }, - "required": { - "type": ["boolean", "null"], - "description": "Whether the job is a required check on the pull request's base branch or its merge queue. Null when the list is read by commit, with no pull request to say which branch.", - "example": true + "$ref": "#/components/schemas/TestUploadStatus" }, - "queuedAt": { - "type": ["string", "null"], - "format": "date-time", - "example": "2026-09-29T12:37:00.000Z" + "defects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TestUploadDefect" + }, + "maxItems": 1000, + "description": "The problems ingestion found, oldest first. Empty until the bundle has been read. At most 1000; past that, every `invalid` defect is kept before any `suboptimal` one." }, - "startedAt": { - "type": ["string", "null"], - "format": "date-time", - "example": "2026-09-29T12:38:27.000Z" + "clientVersion": { + "type": "string", + "description": "The version of the client that built the bundle, as ingestion read it from the bundle. Empty until the bundle has been read. It is not the `clientVersion` sent on the create.", + "example": "trunk-analytics-cli 0.12.3" }, - "finishedAt": { - "type": ["string", "null"], + "createdAt": { + "type": "string", "format": "date-time", - "example": "2026-09-29T12:44:03.000Z" - }, - "queuedMs": { - "type": ["integer", "null"], - "example": 87000 - }, - "durationMs": { - "type": ["integer", "null"], - "example": 336000 + "description": "When the upload was created.", + "example": "2026-10-06T16:00:00.000Z" }, "updatedAt": { "type": "string", "format": "date-time", - "example": "2026-09-29T12:44:03.000Z" - }, - "url": { - "type": ["string", "null"], - "example": "https://github.com/acme/widgets/actions/runs/36498062451/job/1" - }, - "failure": { - "anyOf": [ - { - "$ref": "#/components/schemas/CiJobFailure" - }, - { - "type": "null" - } - ], - "description": "Set when `conclusion` is `failure` or `timedOut`." + "description": "The last time ingestion touched the upload. Each ingestion attempt moves it, even when `status` stays the same.", + "example": "2026-10-06T16:00:04.000Z" } }, "required": [ "id", - "commitSha", - "name", - "workflowName", - "workflowRunId", - "attempt", - "attemptCount", - "failedAttemptCount", + "testCollectionId", + "repositoryId", "status", - "conclusion", - "required", - "queuedAt", - "startedAt", - "finishedAt", - "queuedMs", - "durationMs", - "updatedAt", - "url", - "failure" + "defects", + "clientVersion", + "createdAt", + "updatedAt" ], - "description": "The latest attempt of one CI job on a commit. Named apart from Dynamic CI's `CiJob`, which is a job's definition rather than a run of it. A job is a workflow run and a rendered job name, so each matrix leg is its own job." - }, - "CiJobStatus": { - "type": "string", - "enum": ["queued", "running", "completed", "abandoned"], - "description": "`abandoned` is a job whose queue or run never reported an end, past the 25-hour open-span cap.", - "example": "completed" + "description": "A test-results upload: where it is, and what ingestion found." }, - "CiConclusion": { + "TestUploadStatus": { "type": "string", - "enum": ["success", "failure", "timedOut", "cancelled", "skipped"], - "example": "failure" + "enum": ["awaitingData", "received", "ingested", "skipped"], + "description": "Where an upload is. `awaitingData`: Trunk has not read the bundle. Past `expiresAt` on the create, it was never uploaded or could not be read. `received`: ingestion has started and not finished. Ingestion retries for about 10 minutes, so an upload still `received` with `updatedAt` more than 15 minutes old was not ingested and will not change. `ingested`: ingestion has recorded the upload. Its results and defects are written just after, so `defects` can still grow for a few seconds; each one moves `updatedAt`. If writing them fails, the upload stays `ingested` with its results incomplete. An `invalid` defect names a part of the bundle that was left out. `skipped`: it was read, but so many of its tests failed that Trunk treated the run as an infrastructure failure and stored no results.", + "example": "ingested" }, - "CiJobFailure": { + "TestUploadDefect": { "type": "object", "properties": { - "failingStep": { - "type": ["string", "null"], - "example": "Typecheck" + "type": { + "$ref": "#/components/schemas/TestUploadDefectType" }, - "analysis": { - "$ref": "#/components/schemas/CiFailureAnalysis" + "level": { + "$ref": "#/components/schemas/TestUploadDefectLevel" }, - "logExcerpt": { - "anyOf": [ - { - "$ref": "#/components/schemas/CiLogExcerpt" - }, - { - "type": "null" - } - ] + "source": { + "type": "string", + "description": "Where in the upload the defect is, such as a report path.", + "example": "reports/junit.xml" }, - "cluster": { - "anyOf": [ - { - "$ref": "#/components/schemas/CiFailureClusterRef" - }, - { - "type": "null" - } - ], - "description": "Other failures with the same cause. Null until the hourly clustering run assigns this one." - }, - "annotationCount": { - "type": "integer", - "description": "Read them with `GET /v2/ci-jobs/{id}/annotations`.", - "example": 0 + "message": { + "type": "string", + "description": "What is wrong.", + "example": "Test case `login` has no timestamp" } }, - "required": [ - "failingStep", - "analysis", - "logExcerpt", - "cluster", - "annotationCount" - ] + "required": ["type", "level", "source", "message"], + "description": "A problem ingestion found in an upload." }, - "CiFailureAnalysis": { + "TestUploadDefectType": { + "type": "string", + "enum": [ + "unspecified", + "junit", + "env", + "repo", + "bundleSize", + "reportLimit", + "meta", + "deprecatedCli" + ], + "description": "What part of the upload a defect is about.", + "example": "junit" + }, + "TestUploadDefectLevel": { + "type": "string", + "enum": ["suboptimal", "invalid"], + "description": "`invalid`: Trunk could not use the part of the bundle the defect names (`type`, `source`), and ingested the rest. `suboptimal`: that part was used, with less information than it could have had.", + "example": "suboptimal" + }, + "Ticket": { "type": "object", "properties": { - "state": { - "$ref": "#/components/schemas/CiAnalysisState" + "id": { + "type": "string", + "description": "Trunk's id for the linked ticket.", + "example": "5d4c3b2a-1f0e-4a1b-8c9d-0e1f2a3b4c5d" }, - "summaryShort": { - "type": ["string", "null"], - "example": "Frontend TypeScript checks failed" + "provider": { + "$ref": "#/components/schemas/TicketProvider" }, - "summary": { + "key": { + "type": "string", + "description": "The provider's human-readable key. Mutable on some providers (a Jira issue moved between projects gets a new one).", + "example": "TRUNK-1234" + }, + "url": { + "type": "string", + "format": "uri", + "example": "https://acme.atlassian.net/browse/TRUNK-1234" + }, + "title": { + "type": "string", + "example": "[Flaky Test] suite::test_case_name" + }, + "status": { + "type": "string", + "description": "The provider's status name, as last synced.", + "example": "In Progress" + }, + "assignee": { "type": ["string", "null"], - "example": "Frontend TypeScript checks failed on a type mismatch" + "description": "The provider's assignee display name; null when unassigned.", + "example": "Ada Lovelace" }, - "rootCause": { + "syncedAt": { + "type": "string", + "format": "date-time", + "description": "When Trunk last read the ticket back from its provider.", + "example": "2026-09-15T14:45:01.000Z" + }, + "deletedAt": { "type": ["string", "null"], - "example": "A string is passed where a number is expected." + "format": "date-time", + "description": "When Trunk found that the ticketing provider no longer has this ticket; null while it exists. A deleted ticket stays linked until it is unlinked, and its `status` is the last one synced.", + "example": "2026-10-01T09:00:00.000Z" } }, - "required": ["state", "summaryShort", "summary", "rootCause"] - }, - "CiAnalysisState": { - "type": "string", - "enum": ["pending", "ready", "unavailable"], - "description": "`pending` for 15 minutes after a failed job finished with no analysis yet. `unavailable` after that: the organization is not enabled, AI features are off, or the analysis failed.", - "example": "ready" + "required": [ + "id", + "provider", + "key", + "url", + "title", + "status", + "assignee", + "syncedAt", + "deletedAt" + ] }, - "CiLogExcerpt": { + "LinkTicketRequest": { "type": "object", "properties": { - "startLine": { - "type": "integer", - "example": 69 - }, - "endLine": { - "type": "integer", - "example": 71 + "externalTicketId": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "description": "The ticket to link, as its provider key (`TRUNK-123`, a Linear identifier, an Asana task gid) or as its browser URL — either form is accepted.", + "example": "TRUNK-1234" }, - "text": { - "type": ["string", "null"], - "description": "The stored reduced excerpt, which outlives the 30-day log. Null when the failure has no analysis. Read the lines with `GET /v2/ci-jobs/{id}/log-lines`.", - "example": "error TS2322: Type 'string' is not assignable to type 'number'." + "integrationId": { + "type": "string", + "format": "uuid", + "description": "Which of the organization's ticketing connections the ticket lives on. Defaults to the collection's default connection.", + "example": "2b3c4d5e-6f70-4a1b-8c9d-0e1f2a3b4c5d" } }, - "required": ["startLine", "endLine", "text"], - "description": "The failure's lines in the job's log, 1-based and inclusive." + "required": ["externalTicketId"], + "description": "The ticket to link to a test in a test collection." }, - "CiFailureClusterRef": { + "CreateTicketRequest": { "type": "object", "properties": { - "id": { + "title": { "type": "string", - "example": "0894d6a25f35e0786faac29a" + "minLength": 1, + "maxLength": 250, + "description": "The ticket's title. Defaults to the one the web app generates, such as `[Flaky Test] suite::test_case_name`.", + "example": "[Flaky Test] suite::test_case_name" }, - "occurrences": { - "type": "integer", - "example": 6 + "description": { + "type": "string", + "description": "The ticket's body, in Markdown. Defaults to the one the web app generates: the test's location, failure rate, owners and most common failure reasons, with a link back to the test in Trunk.", + "example": "See all details on the [Trunk Test Detail page](https://app.trunk.io/acme/flaky-tests/collections/ab12cd34/tests/bfeebcf4-0000-8000-8000-000000000001)" + }, + "fields": { + "type": "object", + "additionalProperties": {}, + "description": "Provider fields to set on the ticket, keyed by the provider's field id (a Jira custom field id, `assignee`, a Linear project id), in the shape the provider takes. Merged over the default fields saved on the collection's ticketing connection; a field set here wins.", + "example": { + "assignee": "5b10ac8d82e05b22cc7d4ef5" + } + }, + "replaceExisting": { + "type": "boolean", + "description": "Replace a ticket already linked to the test. When false or absent, a test that has a ticket is refused with `TEST_ALREADY_LINKED`.", + "example": false } }, - "required": ["id", "occurrences"] + "additionalProperties": false, + "description": "The ticket to create for a test in a test collection." }, - "CiWorkflowRunList": { + "RepositoryList": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CiWorkflowRun" + "$ref": "#/components/schemas/Repository" }, "maxItems": 100 }, @@ -5267,93 +5317,76 @@ }, "required": ["data", "nextCursor", "hasMore"] }, - "CiWorkflowRun": { + "Repository": { "type": "object", "properties": { "id": { "type": "string", - "example": "5e1d2c3b4a596877" + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" }, - "commitSha": { - "type": "string", - "example": "a0de7a77e8d758a346f83ed19209a5a25814bb17" + "provider": { + "$ref": "#/components/schemas/Provider" }, - "runId": { + "path": { "type": "string", - "description": "The provider's run id: a GitHub run, or a Buildkite build number.", - "example": "36498062451" - }, - "name": { - "type": ["string", "null"], - "example": "CI" + "description": "The provider-side path, `owner/repo`.", + "example": "acme/widgets" }, - "path": { + "host": { "type": ["string", "null"], - "example": ".github/workflows/ci.yml" + "description": "Null for a repository on the provider's default host.", + "example": "github.com" }, - "attempt": { - "type": "integer", - "example": 1 + "url": { + "type": "string", + "example": "https://github.com/acme/widgets" }, - "status": { - "$ref": "#/components/schemas/CiWorkflowRunStatus" + "defaultBranch": { + "type": ["string", "null"], + "description": "Null until Trunk has synced it.", + "example": "main" }, - "conclusion": { - "anyOf": [ - { - "$ref": "#/components/schemas/CiConclusion" - }, - { - "type": "null" - } - ] + "isPrivate": { + "type": ["boolean", "null"], + "description": "Null until Trunk has synced it.", + "example": true }, - "startedAt": { + "createdAt": { "type": "string", "format": "date-time", - "example": "2026-09-29T12:36:55.000Z" + "example": "2026-01-14T09:12:00.000Z" }, - "finishedAt": { - "type": ["string", "null"], + "updatedAt": { + "type": "string", "format": "date-time", - "example": "2026-09-29T12:58:10.000Z" - }, - "url": { - "type": ["string", "null"], - "example": "https://github.com/acme/widgets/actions/runs/36498062451" - }, - "jobs": { - "$ref": "#/components/schemas/CiJobCounts" + "example": "2026-07-20T16:44:10.000Z" } }, "required": [ "id", - "commitSha", - "runId", - "name", + "provider", "path", - "attempt", - "status", - "conclusion", - "startedAt", - "finishedAt", + "host", "url", - "jobs" - ], - "description": "The latest attempt of one workflow run, with its jobs counted." + "defaultBranch", + "isPrivate", + "createdAt", + "updatedAt" + ] }, - "CiWorkflowRunStatus": { + "Provider": { "type": "string", - "enum": ["running", "completed"], - "example": "completed" - }, - "CiLogLineList": { + "enum": ["github", "gitlab", "bitbucket", "unknown"], + "description": "The hosting provider. `unknown` is a repository Trunk has indexed without resolving a provider.", + "example": "github" + }, + "CiJobRunList": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CiLogLine" + "$ref": "#/components/schemas/CiJobRun" }, "maxItems": 100 }, @@ -5375,44 +5408,254 @@ }, "required": ["data", "nextCursor", "hasMore"] }, - "CiLogLine": { + "CiJobRun": { "type": "object", "properties": { - "lineNumber": { + "id": { + "type": "string", + "description": "Opaque. Identifies the job's latest attempt.", + "example": "1a289f57705150d7~q1790685507000" + }, + "commitSha": { + "type": "string", + "example": "a0de7a77e8d758a346f83ed19209a5a25814bb17" + }, + "name": { + "type": "string", + "example": "test (2/2)" + }, + "workflowName": { + "type": ["string", "null"], + "example": "CI" + }, + "workflowRunId": { + "type": ["string", "null"], + "description": "The `CiWorkflowRun.id` of the job's run.", + "example": "5e1d2c3b4a596877" + }, + "attempt": { "type": "integer", - "example": 70 + "example": 1 }, - "timestamp": { + "attemptCount": { + "type": "integer", + "description": "Every attempt of the job, the latest included.", + "example": 2 + }, + "failedAttemptCount": { + "type": "integer", + "example": 1 + }, + "status": { + "$ref": "#/components/schemas/CiJobStatus" + }, + "conclusion": { + "anyOf": [ + { + "$ref": "#/components/schemas/CiConclusion" + }, + { + "type": "null" + } + ], + "description": "Set when `status` is `completed`." + }, + "required": { + "type": ["boolean", "null"], + "description": "Whether the job is a required check on the pull request's base branch or its merge queue. Null when the list is read by commit, with no pull request to say which branch.", + "example": true + }, + "queuedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-09-29T12:37:00.000Z" + }, + "startedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-09-29T12:38:27.000Z" + }, + "finishedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-09-29T12:44:03.000Z" + }, + "queuedMs": { + "type": ["integer", "null"], + "example": 87000 + }, + "durationMs": { + "type": ["integer", "null"], + "example": 336000 + }, + "updatedAt": { "type": "string", "format": "date-time", - "example": "2026-09-29T12:43:58.120Z" + "example": "2026-09-29T12:44:03.000Z" }, - "severity": { - "$ref": "#/components/schemas/CiLogSeverity" + "url": { + "type": ["string", "null"], + "example": "https://github.com/acme/widgets/actions/runs/36498062451/job/1" }, - "step": { + "failure": { + "anyOf": [ + { + "$ref": "#/components/schemas/CiJobFailure" + }, + { + "type": "null" + } + ], + "description": "Set when `conclusion` is `failure` or `timedOut`." + } + }, + "required": [ + "id", + "commitSha", + "name", + "workflowName", + "workflowRunId", + "attempt", + "attemptCount", + "failedAttemptCount", + "status", + "conclusion", + "required", + "queuedAt", + "startedAt", + "finishedAt", + "queuedMs", + "durationMs", + "updatedAt", + "url", + "failure" + ], + "description": "The latest attempt of one CI job on a commit. Named apart from Dynamic CI's `CiJob`, which is a job's definition rather than a run of it. A job is a workflow run and a rendered job name, so each matrix leg is its own job." + }, + "CiJobStatus": { + "type": "string", + "enum": ["queued", "running", "completed", "abandoned"], + "description": "`abandoned` is a job whose queue or run never reported an end, past the 25-hour open-span cap.", + "example": "completed" + }, + "CiConclusion": { + "type": "string", + "enum": ["success", "failure", "timedOut", "cancelled", "skipped"], + "example": "failure" + }, + "CiJobFailure": { + "type": "object", + "properties": { + "failingStep": { "type": ["string", "null"], "example": "Typecheck" }, - "text": { - "type": "string", - "example": "src/app.ts(3,1): error TS2322" + "analysis": { + "$ref": "#/components/schemas/CiFailureAnalysis" + }, + "logExcerpt": { + "anyOf": [ + { + "$ref": "#/components/schemas/CiLogExcerpt" + }, + { + "type": "null" + } + ] + }, + "cluster": { + "anyOf": [ + { + "$ref": "#/components/schemas/CiFailureClusterRef" + }, + { + "type": "null" + } + ], + "description": "Other failures with the same cause. Null until the hourly clustering run assigns this one." + }, + "annotationCount": { + "type": "integer", + "description": "Read them with `GET /v2/ci-jobs/{id}/annotations`.", + "example": 0 } }, - "required": ["lineNumber", "timestamp", "severity", "step", "text"] + "required": [ + "failingStep", + "analysis", + "logExcerpt", + "cluster", + "annotationCount" + ] }, - "CiLogSeverity": { + "CiFailureAnalysis": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/CiAnalysisState" + }, + "summaryShort": { + "type": ["string", "null"], + "example": "Frontend TypeScript checks failed" + }, + "summary": { + "type": ["string", "null"], + "example": "Frontend TypeScript checks failed on a type mismatch" + }, + "rootCause": { + "type": ["string", "null"], + "example": "A string is passed where a number is expected." + } + }, + "required": ["state", "summaryShort", "summary", "rootCause"] + }, + "CiAnalysisState": { "type": "string", - "enum": ["debug", "info", "warning", "error"], - "example": "error" + "enum": ["pending", "ready", "unavailable"], + "description": "`pending` for 15 minutes after a failed job finished with no analysis yet. `unavailable` after that: the organization is not enabled, AI features are off, or the analysis failed.", + "example": "ready" }, - "CiAnnotationList": { + "CiLogExcerpt": { + "type": "object", + "properties": { + "startLine": { + "type": "integer", + "example": 69 + }, + "endLine": { + "type": "integer", + "example": 71 + }, + "text": { + "type": ["string", "null"], + "description": "The stored reduced excerpt, which outlives the 30-day log. Null when the failure has no analysis. Read the lines with `GET /v2/ci-jobs/{id}/log-lines`.", + "example": "error TS2322: Type 'string' is not assignable to type 'number'." + } + }, + "required": ["startLine", "endLine", "text"], + "description": "The failure's lines in the job's log, 1-based and inclusive." + }, + "CiFailureClusterRef": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "0894d6a25f35e0786faac29a" + }, + "occurrences": { + "type": "integer", + "example": 6 + } + }, + "required": ["id", "occurrences"] + }, + "CiWorkflowRunList": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CiAnnotation" + "$ref": "#/components/schemas/CiWorkflowRun" }, "maxItems": 100 }, @@ -5434,12 +5677,179 @@ }, "required": ["data", "nextCursor", "hasMore"] }, - "CiAnnotation": { + "CiWorkflowRun": { "type": "object", "properties": { - "index": { - "type": "integer", - "example": 0 + "id": { + "type": "string", + "example": "5e1d2c3b4a596877" + }, + "commitSha": { + "type": "string", + "example": "a0de7a77e8d758a346f83ed19209a5a25814bb17" + }, + "runId": { + "type": "string", + "description": "The provider's run id: a GitHub run, or a Buildkite build number.", + "example": "36498062451" + }, + "name": { + "type": ["string", "null"], + "example": "CI" + }, + "path": { + "type": ["string", "null"], + "example": ".github/workflows/ci.yml" + }, + "attempt": { + "type": "integer", + "example": 1 + }, + "status": { + "$ref": "#/components/schemas/CiWorkflowRunStatus" + }, + "conclusion": { + "anyOf": [ + { + "$ref": "#/components/schemas/CiConclusion" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "type": "string", + "format": "date-time", + "example": "2026-09-29T12:36:55.000Z" + }, + "finishedAt": { + "type": ["string", "null"], + "format": "date-time", + "example": "2026-09-29T12:58:10.000Z" + }, + "url": { + "type": ["string", "null"], + "example": "https://github.com/acme/widgets/actions/runs/36498062451" + }, + "jobs": { + "$ref": "#/components/schemas/CiJobCounts" + } + }, + "required": [ + "id", + "commitSha", + "runId", + "name", + "path", + "attempt", + "status", + "conclusion", + "startedAt", + "finishedAt", + "url", + "jobs" + ], + "description": "The latest attempt of one workflow run, with its jobs counted." + }, + "CiWorkflowRunStatus": { + "type": "string", + "enum": ["running", "completed"], + "example": "completed" + }, + "CiLogLineList": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CiLogLine" + }, + "maxItems": 100 + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the last page. Pass it back verbatim as `cursor`; do not construct or inspect one.", + "example": "AQEKZm9vYmFy" + }, + "hasMore": { + "type": "boolean", + "description": "True when another page is available.", + "example": true + }, + "total": { + "type": "integer", + "description": "Total rows matching the filter. Present only when the request set `includeTotal=true`.", + "example": 1247 + } + }, + "required": ["data", "nextCursor", "hasMore"] + }, + "CiLogLine": { + "type": "object", + "properties": { + "lineNumber": { + "type": "integer", + "example": 70 + }, + "timestamp": { + "type": "string", + "format": "date-time", + "example": "2026-09-29T12:43:58.120Z" + }, + "severity": { + "$ref": "#/components/schemas/CiLogSeverity" + }, + "step": { + "type": ["string", "null"], + "example": "Typecheck" + }, + "text": { + "type": "string", + "example": "src/app.ts(3,1): error TS2322" + } + }, + "required": ["lineNumber", "timestamp", "severity", "step", "text"] + }, + "CiLogSeverity": { + "type": "string", + "enum": ["debug", "info", "warning", "error"], + "example": "error" + }, + "CiAnnotationList": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CiAnnotation" + }, + "maxItems": 100 + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Opaque cursor for the next page, or null on the last page. Pass it back verbatim as `cursor`; do not construct or inspect one.", + "example": "AQEKZm9vYmFy" + }, + "hasMore": { + "type": "boolean", + "description": "True when another page is available.", + "example": true + }, + "total": { + "type": "integer", + "description": "Total rows matching the filter. Present only when the request set `includeTotal=true`.", + "example": 1247 + } + }, + "required": ["data", "nextCursor", "hasMore"] + }, + "CiAnnotation": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "example": 0 }, "path": { "type": "string", @@ -5671,56 +6081,820 @@ { "schema": { "type": "string", - "minLength": 1, - "description": "Only the queue for this target branch. Requires `repositoryId`.", - "example": "main" + "minLength": 1, + "description": "Only the queue for this target branch. Requires `repositoryId`.", + "example": "main" + }, + "required": false, + "description": "Only the queue for this target branch. Requires `repositoryId`.", + "name": "targetBranch", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A page of merge queues", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeQueueList" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } + } + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "mergeQueues.create", + "tags": ["mergeQueues"], + "summary": "Create a merge queue", + "description": "Requires the **Update Merge Queue settings** permission. Creates a merge queue for a repository and target branch. At most one queue exists per pair, so a second create returns `QUEUE_ALREADY_EXISTS`.\n\n**This operation is not atomic.** `bisectionConcurrency` and `enqueueingLabel` are applied by a second write, so **an error response does not guarantee that no queue was created** — if the first write succeeded and the second failed, the queue exists with those two fields at their defaults and this call still returns the second write's error. Do not blindly retry: a retry returns `409 QUEUE_ALREADY_EXISTS`. Instead read the queue back (`GET /v2/merge-queues?repositoryId=…&targetBranch=…`) and, if it exists, finish the configuration with `PATCH /v2/merge-queues/{id}/config`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMergeQueueRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created merge queue", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeQueue" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + } + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, + "404": { + "description": "`REPOSITORY_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "REPOSITORY_NOT_FOUND": { + "summary": "REPOSITORY_NOT_FOUND", + "value": { + "error": { + "code": "REPOSITORY_NOT_FOUND", + "message": "Repository not found" + } + } + } + } + } + } + }, + "409": { + "description": "`QUEUE_ALREADY_EXISTS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "QUEUE_ALREADY_EXISTS": { + "summary": "QUEUE_ALREADY_EXISTS", + "value": { + "error": { + "code": "QUEUE_ALREADY_EXISTS", + "message": "A merge queue already exists for this repository and target branch" + } + } + } + } + } + } + }, + "422": { + "description": "`MERGE_QUEUE_NOT_CREATABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "MERGE_QUEUE_NOT_CREATABLE": { + "summary": "MERGE_QUEUE_NOT_CREATABLE", + "value": { + "error": { + "code": "MERGE_QUEUE_NOT_CREATABLE", + "message": "Branch does not exist in your repo" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } + } + } + } + }, + "/v2/merge-queues/metrics": { + "get": { + "operationId": "mergeQueues.organizationMetrics", + "tags": ["mergeQueues"], + "summary": "Get Prometheus metrics for every merge queue", + "description": "Returns Prometheus metrics for every merge queue in your organization, in the text exposition format, for a Prometheus scrape job. `repositoryId` narrows them to one repository's queues, across all its target branches, and is a 404 when your organization has no such repository. The metrics match v1's `getMergeQueueMetrics`.", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "Only this repository's queues.", + "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + "required": false, + "description": "Only this repository's queues.", + "name": "repositoryId", + "in": "query" + } + ], + "responses": { + "200": { + "description": "The organization's merge-queue metrics", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PrometheusExposition" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + } + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, + "404": { + "description": "`REPOSITORY_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "REPOSITORY_NOT_FOUND": { + "summary": "REPOSITORY_NOT_FOUND", + "value": { + "error": { + "code": "REPOSITORY_NOT_FOUND", + "message": "Repository not found" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + } + } + } + }, + "/v2/merge-queues/{id}/metrics": { + "get": { + "operationId": "mergeQueues.metrics", + "tags": ["mergeQueues"], + "summary": "Get Prometheus metrics for a merge queue", + "description": "Returns Prometheus metrics for one merge queue, in the text exposition format, for a Prometheus scrape job. The series and labels are those of `GET /v2/merge-queues/metrics`, restricted to this queue.", + "parameters": [ + { + "schema": { + "type": "string", + "description": "The merge queue's id.", + "example": "xK9mP2nQ" + }, + "required": true, + "description": "The merge queue's id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The merge queue's metrics", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PrometheusExposition" + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "404": { + "description": "`MERGE_QUEUE_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "MERGE_QUEUE_NOT_FOUND": { + "summary": "MERGE_QUEUE_NOT_FOUND", + "value": { + "error": { + "code": "MERGE_QUEUE_NOT_FOUND", + "message": "Merge queue not found" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + } + } + } + }, + "/v2/merge-queues/{id}": { + "get": { + "operationId": "mergeQueues.get", + "tags": ["mergeQueues"], + "summary": "Get a merge queue", + "description": "Returns one merge queue, including its full configuration object.", + "parameters": [ + { + "schema": { + "type": "string", + "description": "The merge queue's id.", + "example": "xK9mP2nQ" }, - "required": false, - "description": "Only the queue for this target branch. Requires `repositoryId`.", - "name": "targetBranch", - "in": "query" + "required": true, + "description": "The merge queue's id.", + "name": "id", + "in": "path" } ], "responses": { "200": { - "description": "A page of merge queues", + "description": "The merge queue", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MergeQueueList" + "$ref": "#/components/schemas/MergeQueue" } } } }, - "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "VALIDATION_FAILED": { - "summary": "VALIDATION_FAILED", + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", "value": { "error": { - "code": "VALIDATION_FAILED", - "message": "Invalid input: expected string, received undefined" + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" } } }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", "value": { "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "404": { + "description": "`MERGE_QUEUE_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "MERGE_QUEUE_NOT_FOUND": { + "summary": "MERGE_QUEUE_NOT_FOUND", + "value": { + "error": { + "code": "MERGE_QUEUE_NOT_FOUND", + "message": "Merge queue not found" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" } } } } } } + } + } + }, + "delete": { + "operationId": "mergeQueues.delete", + "tags": ["mergeQueues"], + "summary": "Delete a merge queue", + "description": "Requires the **Update Merge Queue settings** permission. **Destructive and not reversible.** Deletes a merge queue. The queue must be empty — drain or dequeue its pull requests first, or the call returns `QUEUE_HAS_ACTIVE_ENTRIES`. Historical queue entries and verification runs stay readable by id afterwards.", + "parameters": [ + { + "schema": { + "type": "string", + "description": "The merge queue's id.", + "example": "xK9mP2nQ" + }, + "required": true, + "description": "The merge queue's id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "204": { + "description": "The queue was deleted" }, "401": { "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", @@ -5773,6 +6947,48 @@ } } }, + "404": { + "description": "`MERGE_QUEUE_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "MERGE_QUEUE_NOT_FOUND": { + "summary": "MERGE_QUEUE_NOT_FOUND", + "value": { + "error": { + "code": "MERGE_QUEUE_NOT_FOUND", + "message": "Merge queue not found" + } + } + } + } + } + } + }, + "409": { + "description": "`QUEUE_HAS_ACTIVE_ENTRIES`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "QUEUE_HAS_ACTIVE_ENTRIES": { + "summary": "QUEUE_HAS_ACTIVE_ENTRIES", + "value": { + "error": { + "code": "QUEUE_HAS_ACTIVE_ENTRIES", + "message": "This merge queue still has active pull requests; drain or dequeue them first" + } + } + } + } + } + } + }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -5814,55 +7030,60 @@ } } } - } - } - }, - "post": { - "operationId": "mergeQueues.create", - "tags": ["mergeQueues"], - "summary": "Create a merge queue", - "description": "Requires the **Update Merge Queue settings** permission. Creates a merge queue for a repository and target branch. At most one queue exists per pair, so a second create returns `QUEUE_ALREADY_EXISTS`.\n\n**This operation is not atomic.** `bisectionConcurrency` and `enqueueingLabel` are applied by a second write, so **an error response does not guarantee that no queue was created** — if the first write succeeded and the second failed, the queue exists with those two fields at their defaults and this call still returns the second write's error. Do not blindly retry: a retry returns `409 QUEUE_ALREADY_EXISTS`. Instead read the queue back (`GET /v2/merge-queues?repositoryId=…&targetBranch=…`) and, if it exists, finish the configuration with `PATCH /v2/merge-queues/{id}/config`.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMergeQueueRequest" - } - } - } - }, - "responses": { - "201": { - "description": "The created merge queue", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MergeQueue" - } - } - } }, - "400": { - "description": "`VALIDATION_FAILED`", + "502": { + "description": "`UPSTREAM_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "VALIDATION_FAILED": { - "summary": "VALIDATION_FAILED", + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", "value": { "error": { - "code": "VALIDATION_FAILED", - "message": "Invalid input: expected string, received undefined" + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" } } } } } } + } + } + } + }, + "/v2/merge-queues/{id}/order": { + "get": { + "operationId": "mergeQueues.order", + "tags": ["mergeQueues"], + "summary": "Get a merge queue's dispatch order", + "description": "Returns the queue's batches with a verification run, what is admitted and waiting in the order the engine will consider it, and what has not been admitted yet.\n\n**A batch in `batches` is not necessarily still testing** — one that has passed, is merging, or is holding others back after a failure still has a run and is still listed. Read its `state`.\n\n**The order is one of consideration, not of execution, and array position is not a wait estimate.** `predictedBatch` repeats across every pull request predicted to start testing together, and in `graph` mode several predicted batches commonly start at once — so the pull requests ahead of yours may all start with it, and may sit on unrelated branches that never block yours. `directlyAhead` is what actually precedes a pull request; use it rather than counting.\n\nIt is a point-in-time prediction. A new submission, a priority change or a failure regroups it, and four things outside this response still gate dispatch: `queueState: paused` stops it entirely, the queue's testing-concurrency cap bounds how many start, a batch whose predecessors have no usable test branch is skipped over rather than blocking, and bisections dispatch on their own budget.\n\nPull requests that are not ready are deliberately absent — they are not admitted, so they hold no position. Read one through `GET /v2/pull-requests/{id}`.\n\nUnpaginated: the engine caps its own graph, so the admitted set is bounded. `awaitingAdmission` is not, and is truncated.", + "parameters": [ + { + "schema": { + "type": "string", + "description": "The merge queue's id.", + "example": "xK9mP2nQ" + }, + "required": true, + "description": "The merge queue's id.", + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The queue's dispatch order", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MergeQueueOrder" + } + } + } }, "401": { "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", @@ -5916,61 +7137,19 @@ } }, "404": { - "description": "`REPOSITORY_NOT_FOUND`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "REPOSITORY_NOT_FOUND": { - "summary": "REPOSITORY_NOT_FOUND", - "value": { - "error": { - "code": "REPOSITORY_NOT_FOUND", - "message": "Repository not found" - } - } - } - } - } - } - }, - "409": { - "description": "`QUEUE_ALREADY_EXISTS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "QUEUE_ALREADY_EXISTS": { - "summary": "QUEUE_ALREADY_EXISTS", - "value": { - "error": { - "code": "QUEUE_ALREADY_EXISTS", - "message": "A merge queue already exists for this repository and target branch" - } - } - } - } - } - } - }, - "422": { - "description": "`MERGE_QUEUE_NOT_CREATABLE`", + "description": "`MERGE_QUEUE_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "MERGE_QUEUE_NOT_CREATABLE": { - "summary": "MERGE_QUEUE_NOT_CREATABLE", + "MERGE_QUEUE_NOT_FOUND": { + "summary": "MERGE_QUEUE_NOT_FOUND", "value": { "error": { - "code": "MERGE_QUEUE_NOT_CREATABLE", - "message": "Branch does not exist in your repo" + "code": "MERGE_QUEUE_NOT_FOUND", + "message": "Merge queue not found" } } } @@ -6044,33 +7223,44 @@ } } }, - "/v2/merge-queues/metrics": { + "/v2/merge-queues/{id}/graph": { "get": { - "operationId": "mergeQueues.organizationMetrics", + "operationId": "mergeQueues.graph", "tags": ["mergeQueues"], - "summary": "Get Prometheus metrics for every merge queue", - "description": "Returns Prometheus metrics for every merge queue in your organization, in the text exposition format, for a Prometheus scrape job. `repositoryId` narrows them to one repository's queues, across all its target branches, and is a 404 when your organization has no such repository. The metrics match v1's `getMergeQueueMetrics`.", + "summary": "Get a merge queue's dependency graph", + "description": "Returns the queue's dependency graph: which pull requests the engine considers related, and why.\n\n**This does not tell you what runs next.** The engine schedules on a priority that cascades backwards through the graph and on a pending-failure gate, and neither is in this payload — `priorityValue` here is each pull request's own value, not the cascaded one. For dispatch order use `GET /v2/merge-queues/{id}/order`.\n\nEdges are transitively reduced, so an indirect dependency appears through the node between its endpoints rather than as its own edge. They mean overlapping impacted targets, not a stacked-branch relationship.\n\nOnly pull requests the engine has admitted to its graph appear: anything past the graph's soft cap, or not yet ready, is absent. `includeImpactedTargets` adds the target lists that explain each node and edge; it is off by default because populating it costs a lookup per distinct commit.", "parameters": [ { "schema": { "type": "string", - "format": "uuid", - "description": "Only this repository's queues.", - "example": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + "description": "The merge queue's id.", + "example": "xK9mP2nQ" + }, + "required": true, + "description": "The merge queue's id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Include the impacted-target lists on nodes and edges. Off by default: resolving them costs one lookup per distinct commit in the graph.", + "example": false }, "required": false, - "description": "Only this repository's queues.", - "name": "repositoryId", + "description": "Include the impacted-target lists on nodes and edges. Off by default: resolving them costs one lookup per distinct commit in the graph.", + "name": "includeImpactedTargets", "in": "query" } ], "responses": { "200": { - "description": "The organization's merge-queue metrics", + "description": "The queue's dependency graph", "content": { - "text/plain": { + "application/json": { "schema": { - "$ref": "#/components/schemas/PrometheusExposition" + "$ref": "#/components/schemas/MergeGraph" } } } @@ -6148,19 +7338,19 @@ } }, "404": { - "description": "`REPOSITORY_NOT_FOUND`", + "description": "`MERGE_QUEUE_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "REPOSITORY_NOT_FOUND": { - "summary": "REPOSITORY_NOT_FOUND", + "MERGE_QUEUE_NOT_FOUND": { + "summary": "MERGE_QUEUE_NOT_FOUND", "value": { "error": { - "code": "REPOSITORY_NOT_FOUND", - "message": "Repository not found" + "code": "MERGE_QUEUE_NOT_FOUND", + "message": "Merge queue not found" } } } @@ -6209,16 +7399,37 @@ } } } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } } } } }, - "/v2/merge-queues/{id}/metrics": { + "/v2/merge-queues/{id}/testing-results": { "get": { - "operationId": "mergeQueues.metrics", + "operationId": "mergeQueues.listTestingResults", "tags": ["mergeQueues"], - "summary": "Get Prometheus metrics for a merge queue", - "description": "Returns Prometheus metrics for one merge queue, in the text exposition format, for a Prometheus scrape job. The series and labels are those of `GET /v2/merge-queues/metrics`, restricted to this queue.", + "summary": "List a merge queue's completed testing", + "description": "Lists the stretches of testing the queue finished between `since` and `until`, newest first. One entry per pull request per attempt: a pull request appears several times if its testing restarted, and a batched run appears once per pull request it tested. Testing still in progress is not included. Pass an entry's `verificationRunId` to `GET /v2/merge-queue-verification-runs/{id}` for the run's checks and tested pull requests.\n\nThe range may span at most 45 days, which is also how long results are kept. When `until` is omitted it is fixed at the time of the first page, so later pages are not shifted by testing that finishes while you page.", "parameters": [ { "schema": { @@ -6230,15 +7441,106 @@ "description": "The merge queue's id.", "name": "id", "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only testing that ended at or after this instant (ISO-8601). At most 45 days before `until`.", + "example": "2026-09-01T00:00:00Z" + }, + "required": true, + "description": "Only testing that ended at or after this instant (ISO-8601). At most 45 days before `until`.", + "name": "since", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only testing that ended at or before this instant (ISO-8601). Defaults to the time of the first page.", + "example": "2026-09-08T00:00:00Z" + }, + "required": false, + "description": "Only testing that ended at or before this instant (ISO-8601). Defaults to the time of the first page.", + "name": "until", + "in": "query" } ], "responses": { "200": { - "description": "The merge queue's metrics", + "description": "A page of testing results", "content": { - "text/plain": { + "application/json": { "schema": { - "$ref": "#/components/schemas/PrometheusExposition" + "$ref": "#/components/schemas/TestingResultList" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } + } } } } @@ -6251,21 +7553,42 @@ "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "AUTHENTICATION_REQUIRED": { - "summary": "AUTHENTICATION_REQUIRED", - "value": { - "error": { - "code": "AUTHENTICATION_REQUIRED", - "message": "No credential was supplied" - } - } - }, - "INVALID_TOKEN": { - "summary": "INVALID_TOKEN", + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", "value": { "error": { - "code": "INVALID_TOKEN", - "message": "The supplied token failed verification" + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" } } } @@ -6335,16 +7658,37 @@ } } } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } } } } }, - "/v2/merge-queues/{id}": { - "get": { - "operationId": "mergeQueues.get", + "/v2/merge-queues/{id}/pull-requests": { + "post": { + "operationId": "mergeQueues.submitPullRequest", "tags": ["mergeQueues"], - "summary": "Get a merge queue", - "description": "Returns one merge queue, including its full configuration object.", + "summary": "Submit a pull request to a merge queue", + "description": "Requires the **Merge and cancel PRs** permission, plus **Set priority on PRs** to send `priorityValue` or `priorityName`, **Skip batching** to send `allowBatching: false`, and **Merge PRs with direct to queue** to send `direct: true`. Choosing a `mergeMethod` needs no extra permission. Lower `priorityValue` numbers go first, from 0 to 255. The named priorities are `urgent` (0), `high` (10), `medium` (100, the default), and `low` (200). An `urgent` entry, or `priorityValue: 0`, restarts lower-priority entries that are already testing when it enters the queue (once it is ready), along with the entries tested on top of them.\n\nEnqueues a pull request. A pull request already enqueued in this queue is refused with `PR_ALREADY_ENQUEUED` rather than having its priority rewritten — change a live entry through `POST /v2/pull-requests/{id}/reprioritize` or `PATCH /v2/pull-requests/{id}/queue-entry`. `readiness.hasImpactedTargets` may be absent on the 201 if the post-write lookup failed; the enqueue still landed, so read the pull request again rather than retrying.\n\n`Idempotency-Key` is not honored. If a request times out, resolve the pull request by repository and number and inspect its queue entry before retrying.", "parameters": [ { "schema": { @@ -6358,13 +7702,44 @@ "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitPullRequestRequest" + } + } + } + }, "responses": { - "200": { - "description": "The merge queue", + "201": { + "description": "The enqueued pull request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MergeQueue" + "$ref": "#/components/schemas/PullRequest" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + } } } } @@ -6399,6 +7774,27 @@ } } }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, "404": { "description": "`MERGE_QUEUE_NOT_FOUND`", "content": { @@ -6420,6 +7816,48 @@ } } }, + "409": { + "description": "`PR_ALREADY_ENQUEUED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "PR_ALREADY_ENQUEUED": { + "summary": "PR_ALREADY_ENQUEUED", + "value": { + "error": { + "code": "PR_ALREADY_ENQUEUED", + "message": "This pull request is already enqueued in this merge queue" + } + } + } + } + } + } + }, + "422": { + "description": "`PR_NOT_ENQUEUEABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "PR_NOT_ENQUEUEABLE": { + "summary": "PR_NOT_ENQUEUEABLE", + "value": { + "error": { + "code": "PR_NOT_ENQUEUEABLE", + "message": "This pull request cannot be enqueued" + } + } + } + } + } + } + }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -6461,14 +7899,35 @@ } } } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } } } }, - "delete": { - "operationId": "mergeQueues.delete", + "get": { + "operationId": "mergeQueues.listPullRequests", "tags": ["mergeQueues"], - "summary": "Delete a merge queue", - "description": "Requires the **Update Merge Queue settings** permission. **Destructive and not reversible.** Deletes a merge queue. The queue must be empty — drain or dequeue its pull requests first, or the call returns `QUEUE_HAS_ACTIVE_ENTRIES`. Historical queue entries and verification runs stay readable by id afterwards.", + "summary": "List a merge queue's pull requests", + "description": "Lists the pull requests in a queue, **most recently enqueued first**.\n\n**This is not queue order** — do not infer position from this list's index. For the order the engine will consider, use `GET /v2/merge-queues/{id}/order`. `state` accepts a comma-separated list of queue-entry states. `readiness.hasImpactedTargets` is omitted on this list read, because resolving it costs a storage lookup per entry; read a single pull request to get it. `sort` and `direction` are not configurable yet: each accepts only the value this list already uses (`createdAt`, `desc`) and rejects anything else with `400`. Neither is ignored, so a client cannot silently receive an ordering it did not ask for.\n\n`concludedAfter` and `concludedBefore` keep pull requests that reached `merged`, `failed` or `cancelled` inside that window, both bounds inclusive — v1's `since`/`until`. That is a fact about the entry's history, not its current state: an entry that failed in the window and was later resubmitted still matches, so pair them with `state` to filter on where it is now.", "parameters": [ { "schema": { @@ -6480,35 +7939,127 @@ "description": "The merge queue's id.", "name": "id", "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Comma-separated queue-entry states, e.g. `testing,pending`.", + "example": "testing,pending" + }, + "required": false, + "description": "Comma-separated queue-entry states, e.g. `testing,pending`.", + "name": "state", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Only pull requests opened by this author.", + "example": "octocat" + }, + "required": false, + "description": "Only pull requests opened by this author.", + "name": "author", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only entries that concluded at or after this instant (ISO-8601).", + "example": "2026-09-01T00:00:00Z" + }, + "required": false, + "description": "Only entries that concluded at or after this instant (ISO-8601).", + "name": "concludedAfter", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only entries that concluded at or before this instant (ISO-8601).", + "example": "2026-09-08T00:00:00Z" + }, + "required": false, + "description": "Only entries that concluded at or before this instant (ISO-8601).", + "name": "concludedBefore", + "in": "query" } ], "responses": { - "204": { - "description": "The queue was deleted" + "200": { + "description": "A page of pull requests", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullRequestList" + } + } + } }, - "401": { - "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "400": { + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "AUTHENTICATION_REQUIRED": { - "summary": "AUTHENTICATION_REQUIRED", + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", "value": { "error": { - "code": "AUTHENTICATION_REQUIRED", - "message": "No credential was supplied" + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" } } }, - "INVALID_TOKEN": { - "summary": "INVALID_TOKEN", + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", "value": { "error": { - "code": "INVALID_TOKEN", - "message": "The supplied token failed verification" + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." } } } @@ -6516,20 +8067,29 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", "value": { "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" } } } @@ -6558,27 +8118,6 @@ } } }, - "409": { - "description": "`QUEUE_HAS_ACTIVE_ENTRIES`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "QUEUE_HAS_ACTIVE_ENTRIES": { - "summary": "QUEUE_HAS_ACTIVE_ENTRIES", - "value": { - "error": { - "code": "QUEUE_HAS_ACTIVE_ENTRIES", - "message": "This merge queue still has active pull requests; drain or dequeue them first" - } - } - } - } - } - } - }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -6620,37 +8159,16 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } } }, - "/v2/merge-queues/{id}/order": { - "get": { - "operationId": "mergeQueues.order", + "/v2/merge-queues/{id}/switch-modes": { + "post": { + "operationId": "mergeQueues.switchModes", "tags": ["mergeQueues"], - "summary": "Get a merge queue's dispatch order", - "description": "Returns the queue's batches with a verification run, what is admitted and waiting in the order the engine will consider it, and what has not been admitted yet.\n\n**A batch in `batches` is not necessarily still testing** — one that has passed, is merging, or is holding others back after a failure still has a run and is still listed. Read its `state`.\n\n**The order is one of consideration, not of execution, and array position is not a wait estimate.** `predictedBatch` repeats across every pull request predicted to start testing together, and in `graph` mode several predicted batches commonly start at once — so the pull requests ahead of yours may all start with it, and may sit on unrelated branches that never block yours. `directlyAhead` is what actually precedes a pull request; use it rather than counting.\n\nIt is a point-in-time prediction. A new submission, a priority change or a failure regroups it, and four things outside this response still gate dispatch: `queueState: paused` stops it entirely, the queue's testing-concurrency cap bounds how many start, a batch whose predecessors have no usable test branch is skipped over rather than blocking, and bisections dispatch on their own budget.\n\nPull requests that are not ready are deliberately absent — they are not admitted, so they hold no position. Read one through `GET /v2/pull-requests/{id}`.\n\nUnpaginated: the engine caps its own graph, so the admitted set is bounded. `awaitingAdmission` is not, and is truncated.", + "summary": "Switch a merge queue's mode", + "description": "Requires the **Update Merge Queue settings** permission. Starts a conversion between `queue` and `graph` mode. Switching to the mode the queue is already in is an idempotent no-op. A queue already mid-conversion is refused with `MODE_CHANGE_IN_PROGRESS`. The response carries the state the merge engine reported after the write, so a started conversion reports `switchingModes`.", "parameters": [ { "schema": { @@ -6664,13 +8182,44 @@ "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchModesRequest" + } + } + } + }, "responses": { "200": { - "description": "The queue's dispatch order", + "description": "The queue's post-write state", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MergeQueueOrder" + "$ref": "#/components/schemas/MergeQueueStateChange" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + } } } } @@ -6747,6 +8296,27 @@ } } }, + "409": { + "description": "`MODE_CHANGE_IN_PROGRESS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "MODE_CHANGE_IN_PROGRESS": { + "summary": "MODE_CHANGE_IN_PROGRESS", + "value": { + "error": { + "code": "MODE_CHANGE_IN_PROGRESS", + "message": "This merge queue is switching modes; retry once the transition completes" + } + } + } + } + } + } + }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -6813,12 +8383,12 @@ } } }, - "/v2/merge-queues/{id}/graph": { - "get": { - "operationId": "mergeQueues.graph", + "/v2/merge-queues/{id}/pause": { + "post": { + "operationId": "mergeQueues.pause", "tags": ["mergeQueues"], - "summary": "Get a merge queue's dependency graph", - "description": "Returns the queue's dependency graph: which pull requests the engine considers related, and why.\n\n**This does not tell you what runs next.** The engine schedules on a priority that cascades backwards through the graph and on a pending-failure gate, and neither is in this payload — `priorityValue` here is each pull request's own value, not the cascaded one. For dispatch order use `GET /v2/merge-queues/{id}/order`.\n\nEdges are transitively reduced, so an indirect dependency appears through the node between its endpoints rather than as its own edge. They mean overlapping impacted targets, not a stacked-branch relationship.\n\nOnly pull requests the engine has admitted to its graph appear: anything past the graph's soft cap, or not yet ready, is absent. `includeImpactedTargets` adds the target lists that explain each node and edge; it is off by default because populating it costs a lookup per distinct commit.", + "summary": "Pause a merge queue", + "description": "Requires the **Update Merge Queue settings** permission. **Disruptive — affects every entry in the queue.** Stops the queue testing or merging anything further; entries already in it stay enqueued and resume when the queue is resumed. `Idempotency-Key` is not honored; if the outcome is unknown, read the queue's state before retrying. The request body is ignored.", "parameters": [ { "schema": { @@ -6830,27 +8400,15 @@ "description": "The merge queue's id.", "name": "id", "in": "path" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Include the impacted-target lists on nodes and edges. Off by default: resolving them costs one lookup per distinct commit in the graph.", - "example": false - }, - "required": false, - "description": "Include the impacted-target lists on nodes and edges. Off by default: resolving them costs one lookup per distinct commit in the graph.", - "name": "includeImpactedTargets", - "in": "query" } ], "responses": { "200": { - "description": "The queue's dependency graph", + "description": "The queue's post-write state", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MergeGraph" + "$ref": "#/components/schemas/MergeQueueStateChange" } } } @@ -7004,109 +8562,48 @@ "error": { "code": "UPSTREAM_ERROR", "message": "An upstream service did not respond" - } - } - } - } - } - } - } - } - } - }, - "/v2/merge-queues/{id}/testing-results": { - "get": { - "operationId": "mergeQueues.listTestingResults", - "tags": ["mergeQueues"], - "summary": "List a merge queue's completed testing", - "description": "Lists the stretches of testing the queue finished between `since` and `until`, newest first. One entry per pull request per attempt: a pull request appears several times if its testing restarted, and a batched run appears once per pull request it tested. Testing still in progress is not included. Pass an entry's `verificationRunId` to `GET /v2/merge-queue-verification-runs/{id}` for the run's checks and tested pull requests.\n\nThe range may span at most 45 days, which is also how long results are kept. When `until` is omitted it is fixed at the time of the first page, so later pages are not shifted by testing that finishes while you page.", - "parameters": [ - { - "schema": { - "type": "string", - "description": "The merge queue's id.", - "example": "xK9mP2nQ" - }, - "required": true, - "description": "The merge queue's id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, + } + } + } + } + } + } + } + } + } + }, + "/v2/merge-queues/{id}/resume": { + "post": { + "operationId": "mergeQueues.resume", + "tags": ["mergeQueues"], + "summary": "Resume a merge queue", + "description": "Requires the **Update Merge Queue settings** permission. Returns a paused or draining queue to `running`. `Idempotency-Key` is not honored; if the outcome is unknown, read the queue's state before retrying. The request body is ignored.", + "parameters": [ { "schema": { "type": "string", - "format": "date-time", - "description": "Only testing that ended at or after this instant (ISO-8601). At most 45 days before `until`.", - "example": "2026-09-01T00:00:00Z" + "description": "The merge queue's id.", + "example": "xK9mP2nQ" }, "required": true, - "description": "Only testing that ended at or after this instant (ISO-8601). At most 45 days before `until`.", - "name": "since", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date-time", - "description": "Only testing that ended at or before this instant (ISO-8601). Defaults to the time of the first page.", - "example": "2026-09-08T00:00:00Z" - }, - "required": false, - "description": "Only testing that ended at or before this instant (ISO-8601). Defaults to the time of the first page.", - "name": "until", - "in": "query" + "description": "The merge queue's id.", + "name": "id", + "in": "path" } ], "responses": { "200": { - "description": "A page of testing results", + "description": "The queue's post-write state", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestingResultList" + "$ref": "#/components/schemas/MergeQueueStateChange" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -7121,15 +8618,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } } } } @@ -7273,12 +8761,12 @@ } } }, - "/v2/merge-queues/{id}/pull-requests": { + "/v2/merge-queues/{id}/drain": { "post": { - "operationId": "mergeQueues.submitPullRequest", + "operationId": "mergeQueues.drain", "tags": ["mergeQueues"], - "summary": "Submit a pull request to a merge queue", - "description": "Requires the **Merge and cancel PRs** permission, plus **Set priority on PRs** to send `priorityValue` or `priorityName`, **Skip batching** to send `allowBatching: false`, and **Merge PRs with direct to queue** to send `direct: true`. Lower `priorityValue` numbers go first, from 0 to 255. The named priorities are `urgent` (0), `high` (10), `medium` (100, the default), and `low` (200). An `urgent` entry, or `priorityValue: 0`, restarts lower-priority entries that are already testing when it enters the queue (once it is ready), along with the entries tested on top of them.\n\nEnqueues a pull request. A pull request already enqueued in this queue is refused with `PR_ALREADY_ENQUEUED` rather than having its priority rewritten — change a live entry through `POST /v2/pull-requests/{id}/reprioritize` or `PATCH /v2/pull-requests/{id}/queue-entry`. `readiness.hasImpactedTargets` may be absent on the 201 if the post-write lookup failed; the enqueue still landed, so read the pull request again rather than retrying.\n\n`Idempotency-Key` is not honored. If a request times out, resolve the pull request by repository and number and inspect its queue entry before retrying.", + "summary": "Drain a merge queue", + "description": "Requires the **Update Merge Queue settings** permission. **Disruptive — affects every entry in the queue.** Finishes the entries already in the queue but accepts no new submissions. `Idempotency-Key` is not honored; if the outcome is unknown, read the queue's state before retrying. The request body is ignored.", "parameters": [ { "schema": { @@ -7292,23 +8780,13 @@ "in": "path" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubmitPullRequestRequest" - } - } - } - }, "responses": { - "201": { - "description": "The enqueued pull request", + "200": { + "description": "The queue's post-write state", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PullRequest" + "$ref": "#/components/schemas/MergeQueueStateChange" } } } @@ -7406,48 +8884,6 @@ } } }, - "409": { - "description": "`PR_ALREADY_ENQUEUED`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "PR_ALREADY_ENQUEUED": { - "summary": "PR_ALREADY_ENQUEUED", - "value": { - "error": { - "code": "PR_ALREADY_ENQUEUED", - "message": "This pull request is already enqueued in this merge queue" - } - } - } - } - } - } - }, - "422": { - "description": "`PR_NOT_ENQUEUEABLE`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "PR_NOT_ENQUEUEABLE": { - "summary": "PR_NOT_ENQUEUEABLE", - "value": { - "error": { - "code": "PR_NOT_ENQUEUEABLE", - "message": "This pull request cannot be enqueued" - } - } - } - } - } - } - }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -7466,169 +8902,96 @@ } } } - } - } - }, - "500": { - "description": "`INTERNAL_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INTERNAL_ERROR": { - "summary": "INTERNAL_ERROR", - "value": { - "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" - } - } - } - } - } - } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } - } - } - }, - "get": { - "operationId": "mergeQueues.listPullRequests", - "tags": ["mergeQueues"], - "summary": "List a merge queue's pull requests", - "description": "Lists the pull requests in a queue, **most recently enqueued first**.\n\n**This is not queue order** — do not infer position from this list's index. For the order the engine will consider, use `GET /v2/merge-queues/{id}/order`. `state` accepts a comma-separated list of queue-entry states. `readiness.hasImpactedTargets` is omitted on this list read, because resolving it costs a storage lookup per entry; read a single pull request to get it. `sort` and `direction` are not configurable yet: each accepts only the value this list already uses (`createdAt`, `desc`) and rejects anything else with `400`. Neither is ignored, so a client cannot silently receive an ordering it did not ask for.\n\n`concludedAfter` and `concludedBefore` keep pull requests that reached `merged`, `failed` or `cancelled` inside that window, both bounds inclusive — v1's `since`/`until`. That is a fact about the entry's history, not its current state: an entry that failed in the window and was later resubmitted still matches, so pair them with `state` to filter on where it is now.", - "parameters": [ - { - "schema": { - "type": "string", - "description": "The merge queue's id.", - "example": "xK9mP2nQ" - }, - "required": true, - "description": "The merge queue's id.", - "name": "id", - "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Comma-separated queue-entry states, e.g. `testing,pending`.", - "example": "testing,pending" - }, - "required": false, - "description": "Comma-separated queue-entry states, e.g. `testing,pending`.", - "name": "state", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Only pull requests opened by this author.", - "example": "octocat" - }, - "required": false, - "description": "Only pull requests opened by this author.", - "name": "author", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date-time", - "description": "Only entries that concluded at or after this instant (ISO-8601).", - "example": "2026-09-01T00:00:00Z" - }, - "required": false, - "description": "Only entries that concluded at or after this instant (ISO-8601).", - "name": "concludedAfter", - "in": "query" + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } + } + } + } + }, + "/v2/merge-queues/{id}/config": { + "patch": { + "operationId": "mergeQueues.updateConfig", + "tags": ["mergeQueues"], + "summary": "Update a merge queue's configuration", + "description": "Requires the **Update Merge Queue settings** permission. Applies a JSON Merge Patch to the queue's configuration: an absent key leaves that setting unchanged, and an unknown key is rejected rather than silently dropped. Send `requiredStatuses: []` or `batchingRules: []` to clear those lists.\n\n**`requiredStatuses` is a merge-protection control, not a tuning knob**: it decides what must pass before an entry may merge (clearing it falls back to `.trunk/trunk.yaml` and the branch's protection rules). Treat access to this endpoint as security-relevant. Refused with `MODE_CHANGE_IN_PROGRESS` while the queue is converting between modes, and with `ALLOWED_BOT_SUBMITTERS_FROZEN` for a body carrying the removed `allowedBotSubmitters`: bots are approved on the Bots page of the organization's settings.", + "parameters": [ { "schema": { "type": "string", - "format": "date-time", - "description": "Only entries that concluded at or before this instant (ISO-8601).", - "example": "2026-09-08T00:00:00Z" + "description": "The merge queue's id.", + "example": "xK9mP2nQ" }, - "required": false, - "description": "Only entries that concluded at or before this instant (ISO-8601).", - "name": "concludedBefore", - "in": "query" + "required": true, + "description": "The merge queue's id.", + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMergeQueueConfigRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of pull requests", + "description": "The updated merge queue", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PullRequestList" + "$ref": "#/components/schemas/MergeQueue" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -7643,15 +9006,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } } } } @@ -7687,6 +9041,27 @@ } } }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, "404": { "description": "`MERGE_QUEUE_NOT_FOUND`", "content": { @@ -7708,6 +9083,48 @@ } } }, + "409": { + "description": "`MODE_CHANGE_IN_PROGRESS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "MODE_CHANGE_IN_PROGRESS": { + "summary": "MODE_CHANGE_IN_PROGRESS", + "value": { + "error": { + "code": "MODE_CHANGE_IN_PROGRESS", + "message": "This merge queue is switching modes; retry once the transition completes" + } + } + } + } + } + } + }, + "422": { + "description": "`ALLOWED_BOT_SUBMITTERS_FROZEN`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ALLOWED_BOT_SUBMITTERS_FROZEN": { + "summary": "ALLOWED_BOT_SUBMITTERS_FROZEN", + "value": { + "error": { + "code": "ALLOWED_BOT_SUBMITTERS_FROZEN", + "message": "allowedBotSubmitters has been removed. Bots are approved on the Bots page of the organization's settings instead." + } + } + } + } + } + } + }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -7749,49 +9166,50 @@ } } } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } } } } }, - "/v2/merge-queues/{id}/switch-modes": { - "post": { - "operationId": "mergeQueues.switchModes", - "tags": ["mergeQueues"], - "summary": "Switch a merge queue's mode", - "description": "Requires the **Update Merge Queue settings** permission. Starts a conversion between `queue` and `graph` mode. Switching to the mode the queue is already in is an idempotent no-op. A queue already mid-conversion is refused with `MODE_CHANGE_IN_PROGRESS`. The response carries the state the merge engine reported after the write, so a started conversion reports `switchingModes`.", - "parameters": [ - { - "schema": { - "type": "string", - "description": "The merge queue's id.", - "example": "xK9mP2nQ" - }, - "required": true, - "description": "The merge queue's id.", - "name": "id", - "in": "path" - } - ], + "/v2/impacted-targets": { + "put": { + "operationId": "impactedTargets.upload", + "tags": ["impactedTargets"], + "summary": "Upload a pull request's impacted targets", + "description": "Requires the **Merge and cancel PRs** permission, or a GitHub Actions login for the run's own repository. Records which build targets a pull request's head sha impacts, for queues that gate on impacted targets. Address the pull request by exactly one of `number` or `branch`, and declare impact with exactly one of `impactedTargets` or `impactsAllTargets: true`. An empty `impactedTargets` array is a valid upload meaning the sha impacts nothing — it satisfies the queue's has-impacted-targets gate, because that gate asks whether CI reported, not whether it reported a non-zero result. Supported only for GitHub and GitLab repositories; anything else returns `IMPACTED_TARGETS_UNSUPPORTED`. Answers 202: the targets are stored, but readiness is re-evaluated asynchronously, so a 202 does not mean readiness has changed yet.\n\nA token from a GitHub Actions run may upload only for its own repository. One from a fork pull request's run is bound to that pull request and its head sha: address the pull request by `number`, and send that sha, or the upload is refused with `INSUFFICIENT_PERMISSIONS`. It is also refused unless the repository has a merge queue whose GitHub settings allow impacted targets from fork pull requests.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SwitchModesRequest" + "$ref": "#/components/schemas/UploadImpactedTargetsRequest" } } } }, "responses": { - "200": { - "description": "The queue's post-write state", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MergeQueueStateChange" - } - } - } + "202": { + "description": "The targets were accepted" }, "400": { "description": "`VALIDATION_FAILED`", @@ -7866,19 +9284,28 @@ } }, "404": { - "description": "`MERGE_QUEUE_NOT_FOUND`", + "description": "`REPOSITORY_NOT_FOUND` | `PR_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "MERGE_QUEUE_NOT_FOUND": { - "summary": "MERGE_QUEUE_NOT_FOUND", + "REPOSITORY_NOT_FOUND": { + "summary": "REPOSITORY_NOT_FOUND", "value": { "error": { - "code": "MERGE_QUEUE_NOT_FOUND", - "message": "Merge queue not found" + "code": "REPOSITORY_NOT_FOUND", + "message": "Repository not found" + } + } + }, + "PR_NOT_FOUND": { + "summary": "PR_NOT_FOUND", + "value": { + "error": { + "code": "PR_NOT_FOUND", + "message": "Pull request not found" } } } @@ -7886,20 +9313,20 @@ } } }, - "409": { - "description": "`MODE_CHANGE_IN_PROGRESS`", + "422": { + "description": "`IMPACTED_TARGETS_UNSUPPORTED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "MODE_CHANGE_IN_PROGRESS": { - "summary": "MODE_CHANGE_IN_PROGRESS", + "IMPACTED_TARGETS_UNSUPPORTED": { + "summary": "IMPACTED_TARGETS_UNSUPPORTED", "value": { "error": { - "code": "MODE_CHANGE_IN_PROGRESS", - "message": "This merge queue is switching modes; retry once the transition completes" + "code": "IMPACTED_TARGETS_UNSUPPORTED", + "message": "Impacted targets are supported only for GitHub and GitLab repositories" } } } @@ -7973,53 +9400,32 @@ } } }, - "/v2/merge-queues/{id}/pause": { - "post": { - "operationId": "mergeQueues.pause", - "tags": ["mergeQueues"], - "summary": "Pause a merge queue", - "description": "Requires the **Update Merge Queue settings** permission. **Disruptive — affects every entry in the queue.** Stops the queue testing or merging anything further; entries already in it stay enqueued and resume when the queue is resumed. `Idempotency-Key` is not honored; if the outcome is unknown, read the queue's state before retrying. The request body is ignored.", + "/v2/merge-queue-verification-runs/{id}": { + "get": { + "operationId": "mergeQueueVerificationRuns.get", + "tags": ["mergeQueueVerificationRuns"], + "summary": "Get a verification run", + "description": "Returns one merge-queue verification run, including its normalized CI checks, the pull requests it tested, and the impacted targets uploaded for its commits.", "parameters": [ { "schema": { "type": "string", - "description": "The merge queue's id.", - "example": "xK9mP2nQ" + "description": "The verification run's id.", + "example": "a1b2c3d4-e5f6-4718-9a0b-1c2d3e4f5061" }, "required": true, - "description": "The merge queue's id.", + "description": "The verification run's id.", "name": "id", "in": "path" } ], "responses": { "200": { - "description": "The queue's post-write state", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MergeQueueStateChange" - } - } - } - }, - "400": { - "description": "`VALIDATION_FAILED`", + "description": "The verification run", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "VALIDATION_FAILED": { - "summary": "VALIDATION_FAILED", - "value": { - "error": { - "code": "VALIDATION_FAILED", - "message": "Invalid input: expected string, received undefined" - } - } - } + "$ref": "#/components/schemas/VerificationRun" } } } @@ -8054,41 +9460,20 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, "404": { - "description": "`MERGE_QUEUE_NOT_FOUND`", + "description": "`VERIFICATION_RUN_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "MERGE_QUEUE_NOT_FOUND": { - "summary": "MERGE_QUEUE_NOT_FOUND", + "VERIFICATION_RUN_NOT_FOUND": { + "summary": "VERIFICATION_RUN_NOT_FOUND", "value": { "error": { - "code": "MERGE_QUEUE_NOT_FOUND", - "message": "Merge queue not found" + "code": "VERIFICATION_RUN_NOT_FOUND", + "message": "Verification run not found" } } } @@ -8137,37 +9522,16 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } } }, - "/v2/merge-queues/{id}/resume": { - "post": { - "operationId": "mergeQueues.resume", + "/v2/merge-queues/{id}/verification-runs": { + "get": { + "operationId": "mergeQueues.listVerificationRuns", "tags": ["mergeQueues"], - "summary": "Resume a merge queue", - "description": "Requires the **Update Merge Queue settings** permission. Returns a paused or draining queue to `running`. `Idempotency-Key` is not honored; if the outcome is unknown, read the queue's state before retrying. The request body is ignored.", + "summary": "List a merge queue's verification runs", + "description": "Lists the queue's verification runs, newest first. `sort` and `direction` are not configurable yet: sending either with a non-default value is rejected with `400` rather than ignored, so a client cannot silently receive an ordering it did not ask for.", "parameters": [ { "schema": { @@ -8179,21 +9543,86 @@ "description": "The merge queue's id.", "name": "id", "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/VerificationRunStatus" + }, + { + "description": "One verification-run status. Sending any other value — including a comma-separated list, which is not supported yet — is a `400`." + } + ] + }, + "required": false, + "description": "One verification-run status. Sending any other value — including a comma-separated list, which is not supported yet — is a `400`.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only runs created strictly after this instant (ISO-8601, UTC).", + "example": "2026-07-24T20:26:04Z" + }, + "required": false, + "description": "Only runs created strictly after this instant (ISO-8601, UTC).", + "name": "createdAfter", + "in": "query" } ], "responses": { "200": { - "description": "The queue's post-write state", + "description": "A page of verification runs", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MergeQueueStateChange" + "$ref": "#/components/schemas/VerificationRunList" } } } }, "400": { - "description": "`VALIDATION_FAILED`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", "content": { "application/json": { "schema": { @@ -8208,6 +9637,15 @@ "message": "Invalid input: expected string, received undefined" } } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } } } } @@ -8243,27 +9681,6 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, "404": { "description": "`MERGE_QUEUE_NOT_FOUND`", "content": { @@ -8326,63 +9743,96 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } } }, - "/v2/merge-queues/{id}/drain": { - "post": { - "operationId": "mergeQueues.drain", - "tags": ["mergeQueues"], - "summary": "Drain a merge queue", - "description": "Requires the **Update Merge Queue settings** permission. **Disruptive — affects every entry in the queue.** Finishes the entries already in the queue but accepts no new submissions. `Idempotency-Key` is not honored; if the outcome is unknown, read the queue's state before retrying. The request body is ignored.", + "/v2/pull-requests/{id}/verification-runs": { + "get": { + "operationId": "pullRequests.listVerificationRuns", + "tags": ["pullRequests"], + "summary": "List a pull request's verification runs", + "description": "Lists every verification run that tested this pull request, newest first — including a run it shared with others, which is reported here for each pull request in the batch. `sort` and `direction` are not configurable yet: each accepts only the value this list already uses (`createdAt`, `desc`) and rejects anything else with `400`. `createdAfter` is not served on this endpoint at all and is rejected with `400` whatever the value; the queue's own verification-run list does serve it.", "parameters": [ { "schema": { - "type": "string", - "description": "The merge queue's id.", - "example": "xK9mP2nQ" + "type": "string", + "format": "uuid", + "description": "The pull request's id.", + "example": "0f8fad5b-d9cb-469f-a165-70867728950e" + }, + "required": true, + "description": "The pull request's id.", + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false }, - "required": true, - "description": "The merge queue's id.", - "name": "id", - "in": "path" + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/VerificationRunStatus" + }, + { + "description": "One verification-run status. Sending any other value — including a comma-separated list, which is not supported yet — is a `400`." + } + ] + }, + "required": false, + "description": "One verification-run status. Sending any other value — including a comma-separated list, which is not supported yet — is a `400`.", + "name": "status", + "in": "query" } ], "responses": { "200": { - "description": "The queue's post-write state", + "description": "A page of verification runs", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MergeQueueStateChange" + "$ref": "#/components/schemas/VerificationRunList" } } } }, "400": { - "description": "`VALIDATION_FAILED`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", "content": { "application/json": { "schema": { @@ -8397,6 +9847,15 @@ "message": "Invalid input: expected string, received undefined" } } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } } } } @@ -8432,41 +9891,20 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, "404": { - "description": "`MERGE_QUEUE_NOT_FOUND`", + "description": "`PR_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "MERGE_QUEUE_NOT_FOUND": { - "summary": "MERGE_QUEUE_NOT_FOUND", + "PR_NOT_FOUND": { + "summary": "PR_NOT_FOUND", "value": { "error": { - "code": "MERGE_QUEUE_NOT_FOUND", - "message": "Merge queue not found" + "code": "PR_NOT_FOUND", + "message": "Pull request not found" } } } @@ -8515,73 +9953,93 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } } }, - "/v2/merge-queues/{id}/config": { - "patch": { - "operationId": "mergeQueues.updateConfig", - "tags": ["mergeQueues"], - "summary": "Update a merge queue's configuration", - "description": "Requires the **Update Merge Queue settings** permission. Applies a JSON Merge Patch to the queue's configuration: an absent key leaves that setting unchanged, and an unknown key is rejected rather than silently dropped. Send `requiredStatuses: []` or `batchingRules: []` to clear those lists.\n\n**`requiredStatuses` is a merge-protection control, not a tuning knob**: it decides what must pass before an entry may merge (clearing it falls back to `.trunk/trunk.yaml` and the branch's protection rules). Treat access to this endpoint as security-relevant. Refused with `MODE_CHANGE_IN_PROGRESS` while the queue is converting between modes, and with `ALLOWED_BOT_SUBMITTERS_FROZEN` for a body carrying the removed `allowedBotSubmitters`: bots are approved on the Bots page of the organization's settings.", + "/v2/pull-requests/{id}/queue-entry-history": { + "get": { + "operationId": "pullRequests.queueEntryHistory", + "tags": ["pullRequests"], + "summary": "List a pull request's queue-entry state history", + "description": "Returns the state transitions of this pull request's queue entry. Defaults to **oldest first** — the one list in this API that does, because it is an append-only log a reader replays forward — so finding the CURRENT transition means paging to the end, and `hasMore: false` is how you know you are there. Send `direction=newest` for v1's `includeTimeline` order, where the current transition is the first row of the first page. A pull request that was never enqueued is a 200 with an empty page, not a 404.", "parameters": [ { "schema": { "type": "string", - "description": "The merge queue's id.", - "example": "xK9mP2nQ" + "format": "uuid", + "description": "The pull request's id.", + "example": "0f8fad5b-d9cb-469f-a165-70867728950e" }, "required": true, - "description": "The merge queue's id.", + "description": "The pull request's id.", "name": "id", "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["oldest", "newest"], + "default": "oldest", + "description": "Order of the transitions within a timeline. `oldest` (the default) reads the log forward; `newest` matches v1's `includeTimeline` order, so the current transition is the first row rather than the last.", + "example": "oldest" + }, + "required": false, + "description": "Order of the transitions within a timeline. `oldest` (the default) reads the log forward; `newest` matches v1's `includeTimeline` order, so the current transition is the first row rather than the last.", + "name": "direction", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMergeQueueConfigRequest" - } - } - } - }, "responses": { "200": { - "description": "The updated merge queue", + "description": "A page of state transitions", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MergeQueue" + "$ref": "#/components/schemas/QueueEntryHistoryList" } } } }, "400": { - "description": "`VALIDATION_FAILED`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", "content": { "application/json": { "schema": { @@ -8596,76 +10054,13 @@ "message": "Invalid input: expected string, received undefined" } } - } - } - } - } - }, - "401": { - "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "AUTHENTICATION_REQUIRED": { - "summary": "AUTHENTICATION_REQUIRED", - "value": { - "error": { - "code": "AUTHENTICATION_REQUIRED", - "message": "No credential was supplied" - } - } }, - "INVALID_TOKEN": { - "summary": "INVALID_TOKEN", - "value": { - "error": { - "code": "INVALID_TOKEN", - "message": "The supplied token failed verification" - } - } - } - } - } - } - }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, - "404": { - "description": "`MERGE_QUEUE_NOT_FOUND`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "MERGE_QUEUE_NOT_FOUND": { - "summary": "MERGE_QUEUE_NOT_FOUND", + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", "value": { "error": { - "code": "MERGE_QUEUE_NOT_FOUND", - "message": "Merge queue not found" + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." } } } @@ -8673,20 +10068,29 @@ } } }, - "409": { - "description": "`MODE_CHANGE_IN_PROGRESS`", + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "MODE_CHANGE_IN_PROGRESS": { - "summary": "MODE_CHANGE_IN_PROGRESS", + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", "value": { "error": { - "code": "MODE_CHANGE_IN_PROGRESS", - "message": "This merge queue is switching modes; retry once the transition completes" + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" } } } @@ -8694,20 +10098,20 @@ } } }, - "422": { - "description": "`ALLOWED_BOT_SUBMITTERS_FROZEN`", + "404": { + "description": "`PR_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "ALLOWED_BOT_SUBMITTERS_FROZEN": { - "summary": "ALLOWED_BOT_SUBMITTERS_FROZEN", + "PR_NOT_FOUND": { + "summary": "PR_NOT_FOUND", "value": { "error": { - "code": "ALLOWED_BOT_SUBMITTERS_FROZEN", - "message": "allowedBotSubmitters has been removed. Bots are approved on the Bots page of the organization's settings instead." + "code": "PR_NOT_FOUND", + "message": "Pull request not found" } } } @@ -8756,50 +10160,40 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } } }, - "/v2/impacted-targets": { - "put": { - "operationId": "impactedTargets.upload", - "tags": ["impactedTargets"], - "summary": "Upload a pull request's impacted targets", - "description": "Requires the **Merge and cancel PRs** permission, or a GitHub Actions login for the run's own repository. Records which build targets a pull request's head sha impacts, for queues that gate on impacted targets. Address the pull request by exactly one of `number` or `branch`, and declare impact with exactly one of `impactedTargets` or `impactsAllTargets: true`. An empty `impactedTargets` array is a valid upload meaning the sha impacts nothing — it satisfies the queue's has-impacted-targets gate, because that gate asks whether CI reported, not whether it reported a non-zero result. Supported only for GitHub and GitLab repositories; anything else returns `IMPACTED_TARGETS_UNSUPPORTED`. Answers 202: the targets are stored, but readiness is re-evaluated asynchronously, so a 202 does not mean readiness has changed yet.\n\nA token from a GitHub Actions run may upload only for its own repository. One from a fork pull request's run is bound to that pull request and its head sha: address the pull request by `number`, and send that sha, or the upload is refused with `INSUFFICIENT_PERMISSIONS`.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadImpactedTargetsRequest" - } - } + "/v2/pull-requests/{id}/queue-entry-estimate": { + "get": { + "operationId": "pullRequests.queueEntryEstimate", + "tags": ["pullRequests"], + "summary": "Estimate when a queued pull request will merge", + "description": "Returns a live estimate for this pull request's queue entry: when it is expected to merge, when it starts and finishes testing, and how many pull requests it is waiting behind. An entry in `pendingFailure` instead reports when that failure is expected to resolve (`kind: pendingFailureResolution`) and which pull requests it is waiting on.\n\nThe estimate is computed on each request by simulating the queue forward, so it changes as the queue moves. Read it when the answer is needed rather than in a tight loop: each call runs a fresh simulation, and like the other pull-request reads it is rate-limited per caller. When no estimate can be given — a queue with too little test history, a pull request that is not ready yet, a paused queue — the response says why (`status: unavailable`, `reason`).\n\nNot every queue serves estimates yet: one that does not answers 404 `ESTIMATE_NOT_ENABLED`.", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "The pull request's id.", + "example": "0f8fad5b-d9cb-469f-a165-70867728950e" + }, + "required": true, + "description": "The pull request's id.", + "name": "id", + "in": "path" } - }, + ], "responses": { - "202": { - "description": "The targets were accepted" + "200": { + "description": "The entry's estimate, or why there is none", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueueEntryEstimate" + } + } + } }, "400": { "description": "`VALIDATION_FAILED`", @@ -8874,175 +10268,28 @@ } }, "404": { - "description": "`REPOSITORY_NOT_FOUND` | `PR_NOT_FOUND`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "REPOSITORY_NOT_FOUND": { - "summary": "REPOSITORY_NOT_FOUND", - "value": { - "error": { - "code": "REPOSITORY_NOT_FOUND", - "message": "Repository not found" - } - } - }, - "PR_NOT_FOUND": { - "summary": "PR_NOT_FOUND", - "value": { - "error": { - "code": "PR_NOT_FOUND", - "message": "Pull request not found" - } - } - } - } - } - } - }, - "422": { - "description": "`IMPACTED_TARGETS_UNSUPPORTED`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "IMPACTED_TARGETS_UNSUPPORTED": { - "summary": "IMPACTED_TARGETS_UNSUPPORTED", - "value": { - "error": { - "code": "IMPACTED_TARGETS_UNSUPPORTED", - "message": "Impacted targets are supported only for GitHub and GitLab repositories" - } - } - } - } - } - } - }, - "429": { - "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "RATE_LIMIT_EXCEEDED": { - "summary": "RATE_LIMIT_EXCEEDED", - "value": { - "error": { - "code": "RATE_LIMIT_EXCEEDED", - "message": "Rate limit exceeded; retry with backoff" - } - } - } - } - } - } - }, - "500": { - "description": "`INTERNAL_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INTERNAL_ERROR": { - "summary": "INTERNAL_ERROR", - "value": { - "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" - } - } - } - } - } - } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } - } - } - } - }, - "/v2/merge-queue-verification-runs/{id}": { - "get": { - "operationId": "mergeQueueVerificationRuns.get", - "tags": ["mergeQueueVerificationRuns"], - "summary": "Get a verification run", - "description": "Returns one merge-queue verification run, including its normalized CI checks, the pull requests it tested, and the impacted targets uploaded for its commits.", - "parameters": [ - { - "schema": { - "type": "string", - "description": "The verification run's id.", - "example": "a1b2c3d4-e5f6-4718-9a0b-1c2d3e4f5061" - }, - "required": true, - "description": "The verification run's id.", - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "The verification run", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VerificationRun" - } - } - } - }, - "401": { - "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "description": "`PR_NOT_FOUND` | `ESTIMATE_NOT_ENABLED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, - "examples": { - "AUTHENTICATION_REQUIRED": { - "summary": "AUTHENTICATION_REQUIRED", + "examples": { + "PR_NOT_FOUND": { + "summary": "PR_NOT_FOUND", "value": { "error": { - "code": "AUTHENTICATION_REQUIRED", - "message": "No credential was supplied" + "code": "PR_NOT_FOUND", + "message": "Pull request not found" } } }, - "INVALID_TOKEN": { - "summary": "INVALID_TOKEN", + "ESTIMATE_NOT_ENABLED": { + "summary": "ESTIMATE_NOT_ENABLED", "value": { "error": { - "code": "INVALID_TOKEN", - "message": "The supplied token failed verification" + "code": "ESTIMATE_NOT_ENABLED", + "message": "Estimates are not enabled for this pull request's merge queue" } } } @@ -9050,20 +10297,20 @@ } } }, - "404": { - "description": "`VERIFICATION_RUN_NOT_FOUND`", + "409": { + "description": "`PR_NOT_IN_ACTIVE_STATE`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "VERIFICATION_RUN_NOT_FOUND": { - "summary": "VERIFICATION_RUN_NOT_FOUND", + "PR_NOT_IN_ACTIVE_STATE": { + "summary": "PR_NOT_IN_ACTIVE_STATE", "value": { "error": { - "code": "VERIFICATION_RUN_NOT_FOUND", - "message": "Verification run not found" + "code": "PR_NOT_IN_ACTIVE_STATE", + "message": "This pull request is not in an active queue state" } } } @@ -9112,28 +10359,38 @@ } } } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } } } } }, - "/v2/merge-queues/{id}/verification-runs": { + "/v2/queue-entry-history": { "get": { - "operationId": "mergeQueues.listVerificationRuns", - "tags": ["mergeQueues"], - "summary": "List a merge queue's verification runs", - "description": "Lists the queue's verification runs, newest first. `sort` and `direction` are not configurable yet: sending either with a non-default value is rejected with `400` rather than ignored, so a client cannot silently receive an ordering it did not ask for.", + "operationId": "pullRequests.queueEntryHistoryBatch", + "tags": ["pullRequests"], + "summary": "List queue-entry state history for several pull requests", + "description": "Returns up to 50 pull requests' complete queue-entry timelines in one response — the batch form of `GET /v2/pull-requests/{id}/queue-entry-history`, and the equivalent of v1's `getSubmittedPullRequests` with `includeTimeline`.\n\n`limit` counts **pull requests**, not transitions, and every group carries that pull request's whole timeline (oldest-first by default; send `direction=newest` for v1's `includeTimeline` order), so one call answers for the whole set and each group is complete on arrival. A page's byte size therefore follows the data rather than a fixed row count: for a pull request with an exceptionally long timeline, page within it using the single-pull-request endpoint instead.\n\nAn id naming a pull request in another organization, or one that was never enqueued, comes back as an **empty group** rather than an error — this route never 404s, so diff the ids you asked for against the groups you got.", "parameters": [ - { - "schema": { - "type": "string", - "description": "The merge queue's id.", - "example": "xK9mP2nQ" - }, - "required": true, - "description": "The merge queue's id.", - "name": "id", - "in": "path" - }, { "schema": { "type": "integer", @@ -9173,40 +10430,36 @@ }, { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/VerificationRunStatus" - }, - { - "description": "One verification-run status. Sending any other value — including a comma-separated list, which is not supported yet — is a `400`." - } - ] + "type": "string", + "enum": ["oldest", "newest"], + "default": "oldest", + "description": "Order of the transitions within a timeline. `oldest` (the default) reads the log forward; `newest` matches v1's `includeTimeline` order, so the current transition is the first row rather than the last.", + "example": "oldest" }, "required": false, - "description": "One verification-run status. Sending any other value — including a comma-separated list, which is not supported yet — is a `400`.", - "name": "status", + "description": "Order of the transitions within a timeline. `oldest` (the default) reads the log forward; `newest` matches v1's `includeTimeline` order, so the current transition is the first row rather than the last.", + "name": "direction", "in": "query" }, { "schema": { "type": "string", - "format": "date-time", - "description": "Only runs created strictly after this instant (ISO-8601, UTC).", - "example": "2026-07-24T20:26:04Z" + "description": "Comma-separated pull request ids, at most 50 — v1's own batch ceiling. A member that is not a uuid is a 400 naming it; a repeated id is read once. Ids are normalized to lowercase in the response (and for paging), so diff against the lowercased set you asked for.", + "example": "6f1d2c3b-4a59-4e6f-8a7b-9c0d1e2f3a4b,7a2e3d4c-5b60-4f71-9b8c-0d1e2f3a4b5c" }, - "required": false, - "description": "Only runs created strictly after this instant (ISO-8601, UTC).", - "name": "createdAfter", + "required": true, + "description": "Comma-separated pull request ids, at most 50 — v1's own batch ceiling. A member that is not a uuid is a 400 naming it; a repeated id is read once. Ids are normalized to lowercase in the response (and for paging), so diff against the lowercased set you asked for.", + "name": "pullRequestIds", "in": "query" } ], "responses": { "200": { - "description": "A page of verification runs", + "description": "A page of pull requests, each with its timeline", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationRunList" + "$ref": "#/components/schemas/BatchQueueEntryHistoryList" } } } @@ -9271,20 +10524,20 @@ } } }, - "404": { - "description": "`MERGE_QUEUE_NOT_FOUND`", + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "MERGE_QUEUE_NOT_FOUND": { - "summary": "MERGE_QUEUE_NOT_FOUND", + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", "value": { "error": { - "code": "MERGE_QUEUE_NOT_FOUND", - "message": "Merge queue not found" + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" } } } @@ -9337,12 +10590,12 @@ } } }, - "/v2/pull-requests/{id}/verification-runs": { - "get": { - "operationId": "pullRequests.listVerificationRuns", + "/v2/pull-requests/{id}/reprioritize": { + "post": { + "operationId": "pullRequests.reprioritize", "tags": ["pullRequests"], - "summary": "List a pull request's verification runs", - "description": "Lists every verification run that tested this pull request, newest first — including a run it shared with others, which is reported here for each pull request in the batch. `sort` and `direction` are not configurable yet: each accepts only the value this list already uses (`createdAt`, `desc`) and rejects anything else with `400`. `createdAfter` is not served on this endpoint at all and is rejected with `400` whatever the value; the queue's own verification-run list does serve it.", + "summary": "Reprioritize a queued pull request", + "description": "Requires the **Set priority on PRs** permission. Lower `priorityValue` numbers go first, from 0 to 255. The named priorities are `urgent` (0), `high` (10), `medium` (100, the default), and `low` (200). Raising an entry that is already queued doesn't restart anything: the new priority applies the next time the entry is placed in the queue. An entry still `notReady` set to `urgent` restarts lower-priority entries already testing when it enters the queue.\n\nChanges a live queue entry's priority. Exactly one of `priorityValue` or `priorityName` is required. `priorityValue: 0` is the write path for skipping the line. The entry keeps its merge method. The pull request must have a non-terminal queue entry, or the call returns `PR_NOT_IN_ACTIVE_STATE`.\n\n`Idempotency-Key` is not honored. If a request times out, read the pull request's queue entry before retrying.", "parameters": [ { "schema": { @@ -9355,95 +10608,115 @@ "description": "The pull request's id.", "name": "id", "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/VerificationRunStatus" - }, - { - "description": "One verification-run status. Sending any other value — including a comma-separated list, which is not supported yet — is a `400`." - } - ] - }, - "required": false, - "description": "One verification-run status. Sending any other value — including a comma-separated list, which is not supported yet — is a `400`.", - "name": "status", - "in": "query" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReprioritizeRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of verification runs", + "description": "The updated pull request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VerificationRunList" + "$ref": "#/components/schemas/PullRequest" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + } + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } } } } }, - "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "404": { + "description": "`PR_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "VALIDATION_FAILED": { - "summary": "VALIDATION_FAILED", - "value": { - "error": { - "code": "VALIDATION_FAILED", - "message": "Invalid input: expected string, received undefined" - } - } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", + "PR_NOT_FOUND": { + "summary": "PR_NOT_FOUND", "value": { "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." + "code": "PR_NOT_FOUND", + "message": "Pull request not found" } } } @@ -9451,29 +10724,29 @@ } } }, - "401": { - "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "409": { + "description": "`PR_NOT_IN_ACTIVE_STATE` | `PR_ALREADY_ENQUEUED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "AUTHENTICATION_REQUIRED": { - "summary": "AUTHENTICATION_REQUIRED", + "PR_NOT_IN_ACTIVE_STATE": { + "summary": "PR_NOT_IN_ACTIVE_STATE", "value": { "error": { - "code": "AUTHENTICATION_REQUIRED", - "message": "No credential was supplied" + "code": "PR_NOT_IN_ACTIVE_STATE", + "message": "This pull request is not in an active queue state" } } }, - "INVALID_TOKEN": { - "summary": "INVALID_TOKEN", + "PR_ALREADY_ENQUEUED": { + "summary": "PR_ALREADY_ENQUEUED", "value": { "error": { - "code": "INVALID_TOKEN", - "message": "The supplied token failed verification" + "code": "PR_ALREADY_ENQUEUED", + "message": "This pull request is already enqueued in this merge queue" } } } @@ -9481,20 +10754,20 @@ } } }, - "404": { - "description": "`PR_NOT_FOUND`", + "422": { + "description": "`PR_NOT_ENQUEUEABLE`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_NOT_FOUND": { - "summary": "PR_NOT_FOUND", + "PR_NOT_ENQUEUEABLE": { + "summary": "PR_NOT_ENQUEUEABLE", "value": { "error": { - "code": "PR_NOT_FOUND", - "message": "Pull request not found" + "code": "PR_NOT_ENQUEUEABLE", + "message": "This pull request cannot be enqueued" } } } @@ -9543,16 +10816,37 @@ } } } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } } } } }, - "/v2/pull-requests/{id}/queue-entry-history": { - "get": { - "operationId": "pullRequests.queueEntryHistory", + "/v2/pull-requests/{id}/queue-entry": { + "patch": { + "operationId": "pullRequests.updateQueueEntry", "tags": ["pullRequests"], - "summary": "List a pull request's queue-entry state history", - "description": "Returns the state transitions of this pull request's queue entry. Defaults to **oldest first** — the one list in this API that does, because it is an append-only log a reader replays forward — so finding the CURRENT transition means paging to the end, and `hasMore: false` is how you know you are there. Send `direction=newest` for v1's `includeTimeline` order, where the current transition is the first row of the first page. A pull request that was never enqueued is a 200 with an empty page, not a 404.", + "summary": "Update a queued pull request's entry", + "description": "Requires **Set priority on PRs** to send `priorityValue` or `priorityName`, **Skip batching** to send `allowBatching`, **Merge PRs with direct to queue** to send `direct`, and **Merge and cancel PRs** to send `mergeMethod`. Lower `priorityValue` numbers go first, from 0 to 255. The named priorities are `urgent` (0), `high` (10), `medium` (100, the default), and `low` (200). Raising an entry that is already queued doesn't restart anything: the new priority applies the next time the entry is placed in the queue. An entry still `notReady` set to `urgent` restarts lower-priority entries already testing when it enters the queue.\n\nPatches a live queue entry; an omitted field leaves that attribute unchanged. `priorityValue` and `priorityName` are mutually exclusive. `skipsTheLine` is not settable — it is derived from `priorityValue === 0`, so send that instead. `direct` sends the entry directly to the queue past unmet readiness (it needs the **Merge PRs with direct to queue** permission, and a signed-in user also needs repository-admin permission). `allowBatching: false` gives the entry a test run of its own. `mergeMethod` chooses how this pull request is merged, and `null` returns it to the queue's merge method.", "parameters": [ { "schema": { @@ -9565,71 +10859,31 @@ "description": "The pull request's id.", "name": "id", "in": "path" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["oldest", "newest"], - "default": "oldest", - "description": "Order of the transitions within a timeline. `oldest` (the default) reads the log forward; `newest` matches v1's `includeTimeline` order, so the current transition is the first row rather than the last.", - "example": "oldest" - }, - "required": false, - "description": "Order of the transitions within a timeline. `oldest` (the default) reads the log forward; `newest` matches v1's `includeTimeline` order, so the current transition is the first row rather than the last.", - "name": "direction", - "in": "query" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateQueueEntryRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of state transitions", + "description": "The updated pull request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueueEntryHistoryList" + "$ref": "#/components/schemas/PullRequest" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -9644,15 +10898,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } } } } @@ -9688,6 +10933,27 @@ } } }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, "404": { "description": "`PR_NOT_FOUND`", "content": { @@ -9709,6 +10975,57 @@ } } }, + "409": { + "description": "`PR_NOT_IN_ACTIVE_STATE` | `PR_ALREADY_ENQUEUED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "PR_NOT_IN_ACTIVE_STATE": { + "summary": "PR_NOT_IN_ACTIVE_STATE", + "value": { + "error": { + "code": "PR_NOT_IN_ACTIVE_STATE", + "message": "This pull request is not in an active queue state" + } + } + }, + "PR_ALREADY_ENQUEUED": { + "summary": "PR_ALREADY_ENQUEUED", + "value": { + "error": { + "code": "PR_ALREADY_ENQUEUED", + "message": "This pull request is already enqueued in this merge queue" + } + } + } + } + } + } + }, + "422": { + "description": "`PR_NOT_ENQUEUEABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "PR_NOT_ENQUEUEABLE": { + "summary": "PR_NOT_ENQUEUEABLE", + "value": { + "error": { + "code": "PR_NOT_ENQUEUEABLE", + "message": "This pull request cannot be enqueued" + } + } + } + } + } + } + }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -9721,8 +11038,29 @@ "summary": "RATE_LIMIT_EXCEEDED", "value": { "error": { - "code": "RATE_LIMIT_EXCEEDED", - "message": "Rate limit exceeded; retry with backoff" + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" } } } @@ -9730,20 +11068,20 @@ } } }, - "500": { - "description": "`INTERNAL_ERROR`", + "502": { + "description": "`UPSTREAM_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "INTERNAL_ERROR": { - "summary": "INTERNAL_ERROR", + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", "value": { "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" } } } @@ -9754,12 +11092,12 @@ } } }, - "/v2/pull-requests/{id}/queue-entry-estimate": { - "get": { - "operationId": "pullRequests.queueEntryEstimate", + "/v2/pull-requests/{id}/dequeue": { + "post": { + "operationId": "pullRequests.dequeue", "tags": ["pullRequests"], - "summary": "Estimate when a queued pull request will merge", - "description": "Returns a live estimate for this pull request's queue entry: when it is expected to merge, when it starts and finishes testing, and how many pull requests it is waiting behind. An entry in `pendingFailure` instead reports when that failure is expected to resolve (`kind: pendingFailureResolution`) and which pull requests it is waiting on.\n\nThe estimate is computed on each request by simulating the queue forward, so it changes as the queue moves. Read it when the answer is needed rather than in a tight loop: each call runs a fresh simulation, and like the other pull-request reads it is rate-limited per caller. When no estimate can be given — a queue with too little test history, a pull request that is not ready yet, a paused queue — the response says why (`status: unavailable`, `reason`).\n\nNot every queue serves estimates yet: one that does not answers 404 `ESTIMATE_NOT_ENABLED`.", + "summary": "Dequeue a pull request", + "description": "Requires the **Merge and cancel PRs** permission. **Disruptive.** Removes a pull request from its merge queue, abandoning any in-flight verification. The entry must be non-terminal, or the call returns `PR_NOT_IN_ACTIVE_STATE`. `Idempotency-Key` is not honored; if a request times out, read the pull request's queue entry before retrying. The request body is ignored.", "parameters": [ { "schema": { @@ -9776,11 +11114,11 @@ ], "responses": { "200": { - "description": "The entry's estimate, or why there is none", + "description": "The updated pull request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QueueEntryEstimate" + "$ref": "#/components/schemas/PullRequest" } } } @@ -9858,7 +11196,7 @@ } }, "404": { - "description": "`PR_NOT_FOUND` | `ESTIMATE_NOT_ENABLED`", + "description": "`PR_NOT_FOUND`", "content": { "application/json": { "schema": { @@ -9873,15 +11211,6 @@ "message": "Pull request not found" } } - }, - "ESTIMATE_NOT_ENABLED": { - "summary": "ESTIMATE_NOT_ENABLED", - "value": { - "error": { - "code": "ESTIMATE_NOT_ENABLED", - "message": "Estimates are not enabled for this pull request's merge queue" - } - } } } } @@ -9974,88 +11303,39 @@ } } }, - "/v2/queue-entry-history": { - "get": { - "operationId": "pullRequests.queueEntryHistoryBatch", + "/v2/pull-requests/{id}/restart-tests": { + "post": { + "operationId": "pullRequests.restartTests", "tags": ["pullRequests"], - "summary": "List queue-entry state history for several pull requests", - "description": "Returns up to 50 pull requests' complete queue-entry timelines in one response — the batch form of `GET /v2/pull-requests/{id}/queue-entry-history`, and the equivalent of v1's `getSubmittedPullRequests` with `includeTimeline`.\n\n`limit` counts **pull requests**, not transitions, and every group carries that pull request's whole timeline (oldest-first by default; send `direction=newest` for v1's `includeTimeline` order), so one call answers for the whole set and each group is complete on arrival. A page's byte size therefore follows the data rather than a fixed row count: for a pull request with an exceptionally long timeline, page within it using the single-pull-request endpoint instead.\n\nAn id naming a pull request in another organization, or one that was never enqueued, comes back as an **empty group** rather than an error — this route never 404s, so diff the ids you asked for against the groups you got.", + "summary": "Restart a queued pull request's tests", + "description": "Requires the **Merge and cancel PRs** permission. **Disruptive.** Abandons the entry's in-flight verification and starts a new one, discarding CI progress. Only possible while the entry is testing or in pending-failure; other live states are refused with `PR_TESTS_NOT_RESTARTABLE`.\n\nThis endpoint does not diagnose anything — to find out *why* an entry keeps being re-tested, read `GET /v2/pull-requests/{id}/queue-entry-history` and inspect each transition's `reason`. `Idempotency-Key` is not yet honored, so a retry after a timeout starts a second restart; read the entry back instead of retrying. The request body is ignored.", "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["oldest", "newest"], - "default": "oldest", - "description": "Order of the transitions within a timeline. `oldest` (the default) reads the log forward; `newest` matches v1's `includeTimeline` order, so the current transition is the first row rather than the last.", - "example": "oldest" - }, - "required": false, - "description": "Order of the transitions within a timeline. `oldest` (the default) reads the log forward; `newest` matches v1's `includeTimeline` order, so the current transition is the first row rather than the last.", - "name": "direction", - "in": "query" - }, { "schema": { "type": "string", - "description": "Comma-separated pull request ids, at most 50 — v1's own batch ceiling. A member that is not a uuid is a 400 naming it; a repeated id is read once. Ids are normalized to lowercase in the response (and for paging), so diff against the lowercased set you asked for.", - "example": "6f1d2c3b-4a59-4e6f-8a7b-9c0d1e2f3a4b,7a2e3d4c-5b60-4f71-9b8c-0d1e2f3a4b5c" + "format": "uuid", + "description": "The pull request's id.", + "example": "0f8fad5b-d9cb-469f-a165-70867728950e" }, "required": true, - "description": "Comma-separated pull request ids, at most 50 — v1's own batch ceiling. A member that is not a uuid is a 400 naming it; a repeated id is read once. Ids are normalized to lowercase in the response (and for paging), so diff against the lowercased set you asked for.", - "name": "pullRequestIds", - "in": "query" + "description": "The pull request's id.", + "name": "id", + "in": "path" } ], "responses": { "200": { - "description": "A page of pull requests, each with its timeline", + "description": "The updated pull request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchQueueEntryHistoryList" + "$ref": "#/components/schemas/PullRequest" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -10070,15 +11350,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } } } } @@ -10135,20 +11406,20 @@ } } }, - "429": { - "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "404": { + "description": "`PR_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "RATE_LIMIT_EXCEEDED": { - "summary": "RATE_LIMIT_EXCEEDED", + "PR_NOT_FOUND": { + "summary": "PR_NOT_FOUND", "value": { "error": { - "code": "RATE_LIMIT_EXCEEDED", - "message": "Rate limit exceeded; retry with backoff" + "code": "PR_NOT_FOUND", + "message": "Pull request not found" } } } @@ -10156,85 +11427,62 @@ } } }, - "500": { - "description": "`INTERNAL_ERROR`", + "409": { + "description": "`PR_NOT_IN_ACTIVE_STATE`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "INTERNAL_ERROR": { - "summary": "INTERNAL_ERROR", + "PR_NOT_IN_ACTIVE_STATE": { + "summary": "PR_NOT_IN_ACTIVE_STATE", "value": { "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" + "code": "PR_NOT_IN_ACTIVE_STATE", + "message": "This pull request is not in an active queue state" } } } } } } - } - } - } - }, - "/v2/pull-requests/{id}/reprioritize": { - "post": { - "operationId": "pullRequests.reprioritize", - "tags": ["pullRequests"], - "summary": "Reprioritize a queued pull request", - "description": "Requires the **Set priority on PRs** permission. Lower `priorityValue` numbers go first, from 0 to 255. The named priorities are `urgent` (0), `high` (10), `medium` (100, the default), and `low` (200). Raising an entry that is already queued doesn't restart anything: the new priority applies the next time the entry is placed in the queue. An entry still `notReady` set to `urgent` restarts lower-priority entries already testing when it enters the queue.\n\nChanges a live queue entry's priority. Exactly one of `priorityValue` or `priorityName` is required. `priorityValue: 0` is the write path for skipping the line. The pull request must have a non-terminal queue entry, or the call returns `PR_NOT_IN_ACTIVE_STATE`.\n\n`Idempotency-Key` is not honored. If a request times out, read the pull request's queue entry before retrying.", - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "The pull request's id.", - "example": "0f8fad5b-d9cb-469f-a165-70867728950e" - }, - "required": true, - "description": "The pull request's id.", - "name": "id", - "in": "path" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReprioritizeRequest" - } - } - } - }, - "responses": { - "200": { - "description": "The updated pull request", + }, + "422": { + "description": "`PR_TESTS_NOT_RESTARTABLE`", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PullRequest" + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "PR_TESTS_NOT_RESTARTABLE": { + "summary": "PR_TESTS_NOT_RESTARTABLE", + "value": { + "error": { + "code": "PR_TESTS_NOT_RESTARTABLE", + "message": "This pull request's tests cannot be restarted in its current state" + } + } + } } } } }, - "400": { - "description": "`VALIDATION_FAILED`", + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "VALIDATION_FAILED": { - "summary": "VALIDATION_FAILED", + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", "value": { "error": { - "code": "VALIDATION_FAILED", - "message": "Invalid input: expected string, received undefined" + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" } } } @@ -10242,71 +11490,101 @@ } } }, - "401": { - "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "500": { + "description": "`INTERNAL_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "AUTHENTICATION_REQUIRED": { - "summary": "AUTHENTICATION_REQUIRED", + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", "value": { "error": { - "code": "AUTHENTICATION_REQUIRED", - "message": "No credential was supplied" + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" } } - }, - "INVALID_TOKEN": { - "summary": "INVALID_TOKEN", + } + } + } + } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", "value": { "error": { - "code": "INVALID_TOKEN", - "message": "The supplied token failed verification" + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" } } } } } } - }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", + } + } + } + }, + "/v2/dynamic-ci/generate-plan": { + "post": { + "operationId": "dynamicCi.generatePlan", + "tags": ["dynamicCi"], + "summary": "Generate a CI skip plan", + "description": "Returns a per-job verdict for one workflow run: which jobs the change makes it safe to skip, and the evidence behind each decision. Normally called once per run by the [`trunk-io/dynamic-ci`](https://github.com/trunk-io/dynamic-ci) action, which maps the verdicts onto job outputs.\n\n**Fail open.** A job absent from `jobs` must be run — that is the contract, not an error. Treat any non-200 the same way.\n\nSend the changed files as `changedFiles` when you can compute them. Without it, Trunk fetches the diff between `baseSha` and `commitSha` through your GitHub App installation, which needs both commits on GitHub.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CiPlanRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The plan", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } + "$ref": "#/components/schemas/CiPlan" } } } }, - "404": { - "description": "`PR_NOT_FOUND`", + "400": { + "description": "`VALIDATION_FAILED` | `CI_HOST_UNSUPPORTED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_NOT_FOUND": { - "summary": "PR_NOT_FOUND", + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", "value": { "error": { - "code": "PR_NOT_FOUND", - "message": "Pull request not found" + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + }, + "CI_HOST_UNSUPPORTED": { + "summary": "CI_HOST_UNSUPPORTED", + "value": { + "error": { + "code": "CI_HOST_UNSUPPORTED", + "message": "Dynamic CI does not support the repository host gitlab.example.com" } } } @@ -10314,29 +11592,29 @@ } } }, - "409": { - "description": "`PR_NOT_IN_ACTIVE_STATE` | `PR_ALREADY_ENQUEUED`", + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_NOT_IN_ACTIVE_STATE": { - "summary": "PR_NOT_IN_ACTIVE_STATE", + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", "value": { "error": { - "code": "PR_NOT_IN_ACTIVE_STATE", - "message": "This pull request is not in an active queue state" + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" } } }, - "PR_ALREADY_ENQUEUED": { - "summary": "PR_ALREADY_ENQUEUED", + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", "value": { "error": { - "code": "PR_ALREADY_ENQUEUED", - "message": "This pull request is already enqueued in this merge queue" + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" } } } @@ -10344,20 +11622,20 @@ } } }, - "422": { - "description": "`PR_NOT_ENQUEUEABLE`", + "404": { + "description": "`REPOSITORY_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_NOT_ENQUEUEABLE": { - "summary": "PR_NOT_ENQUEUEABLE", + "REPOSITORY_NOT_FOUND": { + "summary": "REPOSITORY_NOT_FOUND", "value": { "error": { - "code": "PR_NOT_ENQUEUEABLE", - "message": "This pull request cannot be enqueued" + "code": "REPOSITORY_NOT_FOUND", + "message": "Repository not found" } } } @@ -10431,49 +11709,35 @@ } } }, - "/v2/pull-requests/{id}/queue-entry": { - "patch": { - "operationId": "pullRequests.updateQueueEntry", - "tags": ["pullRequests"], - "summary": "Update a queued pull request's entry", - "description": "Requires **Set priority on PRs** to send `priorityValue` or `priorityName`, **Skip batching** to send `allowBatching`, and **Merge PRs with direct to queue** to send `direct`. Lower `priorityValue` numbers go first, from 0 to 255. The named priorities are `urgent` (0), `high` (10), `medium` (100, the default), and `low` (200). Raising an entry that is already queued doesn't restart anything: the new priority applies the next time the entry is placed in the queue. An entry still `notReady` set to `urgent` restarts lower-priority entries already testing when it enters the queue.\n\nPatches a live queue entry; an omitted field leaves that attribute unchanged. `priorityValue` and `priorityName` are mutually exclusive. `skipsTheLine` is not settable — it is derived from `priorityValue === 0`, so send that instead. `direct` sends the entry directly to the queue past unmet readiness (it needs the **Merge PRs with direct to queue** permission, and a signed-in user also needs repository-admin permission). `allowBatching: false` gives the entry a test run of its own.", - "parameters": [ - { - "schema": { - "type": "string", - "format": "uuid", - "description": "The pull request's id.", - "example": "0f8fad5b-d9cb-469f-a165-70867728950e" - }, - "required": true, - "description": "The pull request's id.", - "name": "id", - "in": "path" - } - ], + "/v2/dynamic-ci/generate-buildkite-plan": { + "post": { + "operationId": "dynamicCi.generateBuildkitePlan", + "tags": ["dynamicCi"], + "summary": "Generate a CI skip plan for a Buildkite pipeline", + "description": "As [`dynamicCi.generatePlan`](#tag/dynamicCi/operation/dynamicCi.generatePlan), for a Buildkite pipeline. Identical request and response but for the workflow identity: Buildkite has no workflow file, so the pipeline is named by the organization and pipeline slugs its agent exports, and `jobKeys` are step `key:` values.\n\n**Fail open.** A job absent from `jobs` must be run — that is the contract, not an error. Treat any non-200 the same way.\n\nSend the changed files as `changedFiles` when you can compute them. Without it, Trunk fetches the diff between `baseSha` and `commitSha` through your GitHub App installation, which needs both commits on GitHub.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateQueueEntryRequest" + "$ref": "#/components/schemas/BuildkitePlanRequest" } } } }, "responses": { "200": { - "description": "The updated pull request", + "description": "The plan", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PullRequest" + "$ref": "#/components/schemas/CiPlan" } } } }, "400": { - "description": "`VALIDATION_FAILED`", + "description": "`VALIDATION_FAILED` | `CI_HOST_UNSUPPORTED`", "content": { "application/json": { "schema": { @@ -10488,6 +11752,15 @@ "message": "Invalid input: expected string, received undefined" } } + }, + "CI_HOST_UNSUPPORTED": { + "summary": "CI_HOST_UNSUPPORTED", + "value": { + "error": { + "code": "CI_HOST_UNSUPPORTED", + "message": "Dynamic CI does not support the repository host gitlab.example.com" + } + } } } } @@ -10523,41 +11796,20 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, "404": { - "description": "`PR_NOT_FOUND`", + "description": "`REPOSITORY_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_NOT_FOUND": { - "summary": "PR_NOT_FOUND", + "REPOSITORY_NOT_FOUND": { + "summary": "REPOSITORY_NOT_FOUND", "value": { "error": { - "code": "PR_NOT_FOUND", - "message": "Pull request not found" + "code": "REPOSITORY_NOT_FOUND", + "message": "Repository not found" } } } @@ -10565,29 +11817,20 @@ } } }, - "409": { - "description": "`PR_NOT_IN_ACTIVE_STATE` | `PR_ALREADY_ENQUEUED`", + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_NOT_IN_ACTIVE_STATE": { - "summary": "PR_NOT_IN_ACTIVE_STATE", - "value": { - "error": { - "code": "PR_NOT_IN_ACTIVE_STATE", - "message": "This pull request is not in an active queue state" - } - } - }, - "PR_ALREADY_ENQUEUED": { - "summary": "PR_ALREADY_ENQUEUED", + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", "value": { "error": { - "code": "PR_ALREADY_ENQUEUED", - "message": "This pull request is already enqueued in this merge queue" + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" } } } @@ -10595,20 +11838,20 @@ } } }, - "422": { - "description": "`PR_NOT_ENQUEUEABLE`", + "500": { + "description": "`INTERNAL_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_NOT_ENQUEUEABLE": { - "summary": "PR_NOT_ENQUEUEABLE", + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", "value": { "error": { - "code": "PR_NOT_ENQUEUEABLE", - "message": "This pull request cannot be enqueued" + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" } } } @@ -10616,105 +11859,120 @@ } } }, - "429": { - "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "502": { + "description": "`UPSTREAM_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "RATE_LIMIT_EXCEEDED": { - "summary": "RATE_LIMIT_EXCEEDED", + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", "value": { "error": { - "code": "RATE_LIMIT_EXCEEDED", - "message": "Rate limit exceeded; retry with backoff" + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" } } } } } } + } + } + } + }, + "/v2/dynamic-ci/pr-jobs": { + "get": { + "operationId": "dynamicCi.listPrJobs", + "tags": ["dynamicCi"], + "summary": "List the jobs Dynamic CI sees", + "description": "The workflows that have run on a pull request in this repository recently, with the jobs each defines. Use it to find the `workflowId` and `jobId` to force with `dynamicCi.setForceOverrides`.\n\n`jobKey` is `null` for a job whose key Trunk has not resolved — it can never be skipped, and is listed so that is visible rather than silently missing.", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "type": "string", + "default": "github.com", + "description": "`github.com` is the only supported value today.", + "example": "github.com" + }, + "required": false, + "description": "`github.com` is the only supported value today.", + "name": "host", + "in": "query" }, - "500": { - "description": "`INTERNAL_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INTERNAL_ERROR": { - "summary": "INTERNAL_ERROR", - "value": { - "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" - } - } - } - } - } - } + { + "schema": { + "type": "string", + "minLength": 1, + "example": "acme" + }, + "required": true, + "name": "owner", + "in": "query" }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } - } - } - } - }, - "/v2/pull-requests/{id}/dequeue": { - "post": { - "operationId": "pullRequests.dequeue", - "tags": ["pullRequests"], - "summary": "Dequeue a pull request", - "description": "Requires the **Merge and cancel PRs** permission. **Disruptive.** Removes a pull request from its merge queue, abandoning any in-flight verification. The entry must be non-terminal, or the call returns `PR_NOT_IN_ACTIVE_STATE`. `Idempotency-Key` is not honored; if a request times out, read the pull request's queue entry before retrying. The request body is ignored.", - "parameters": [ { "schema": { "type": "string", - "format": "uuid", - "description": "The pull request's id.", - "example": "0f8fad5b-d9cb-469f-a165-70867728950e" + "minLength": 1, + "example": "widgets" }, "required": true, - "description": "The pull request's id.", - "name": "id", - "in": "path" + "name": "name", + "in": "query" } ], "responses": { "200": { - "description": "The updated pull request", + "description": "A page of workflows", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PullRequest" + "$ref": "#/components/schemas/CiWorkflowList" } } } }, "400": { - "description": "`VALIDATION_FAILED`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `CI_HOST_UNSUPPORTED`", "content": { "application/json": { "schema": { @@ -10729,6 +11987,24 @@ "message": "Invalid input: expected string, received undefined" } } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } + }, + "CI_HOST_UNSUPPORTED": { + "summary": "CI_HOST_UNSUPPORTED", + "value": { + "error": { + "code": "CI_HOST_UNSUPPORTED", + "message": "Dynamic CI does not support the repository host gitlab.example.com" + } + } } } } @@ -10764,62 +12040,20 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, "404": { - "description": "`PR_NOT_FOUND`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "PR_NOT_FOUND": { - "summary": "PR_NOT_FOUND", - "value": { - "error": { - "code": "PR_NOT_FOUND", - "message": "Pull request not found" - } - } - } - } - } - } - }, - "409": { - "description": "`PR_NOT_IN_ACTIVE_STATE`", + "description": "`REPOSITORY_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_NOT_IN_ACTIVE_STATE": { - "summary": "PR_NOT_IN_ACTIVE_STATE", + "REPOSITORY_NOT_FOUND": { + "summary": "REPOSITORY_NOT_FOUND", "value": { "error": { - "code": "PR_NOT_IN_ACTIVE_STATE", - "message": "This pull request is not in an active queue state" + "code": "REPOSITORY_NOT_FOUND", + "message": "Repository not found" } } } @@ -10868,64 +12102,110 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } } }, - "/v2/pull-requests/{id}/restart-tests": { - "post": { - "operationId": "pullRequests.restartTests", - "tags": ["pullRequests"], - "summary": "Restart a queued pull request's tests", - "description": "Requires the **Merge and cancel PRs** permission. **Disruptive.** Abandons the entry's in-flight verification and starts a new one, discarding CI progress. Only possible while the entry is testing or in pending-failure; other live states are refused with `PR_TESTS_NOT_RESTARTABLE`.\n\nThis endpoint does not diagnose anything — to find out *why* an entry keeps being re-tested, read `GET /v2/pull-requests/{id}/queue-entry-history` and inspect each transition's `reason`. `Idempotency-Key` is not yet honored, so a retry after a timeout starts a second restart; read the entry back instead of retrying. The request body is ignored.", + "/v2/dynamic-ci/force-overrides": { + "get": { + "operationId": "dynamicCi.listForceOverrides", + "tags": ["dynamicCi"], + "summary": "List a pull request's force overrides", + "description": "The forces currently active on a pull request. A force makes Dynamic CI run the jobs it covers whatever the signals say. `workflowId` and `jobId` are the ids `dynamicCi.listPrJobs` returns; revoked forces are retained for audit but not listed.", "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "format": "uuid", - "description": "The pull request's id.", - "example": "0f8fad5b-d9cb-469f-a165-70867728950e" + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "type": "string", + "default": "github.com", + "description": "`github.com` is the only supported value today.", + "example": "github.com" + }, + "required": false, + "description": "`github.com` is the only supported value today.", + "name": "host", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "example": "acme" + }, + "required": true, + "name": "owner", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "example": "widgets" + }, + "required": true, + "name": "name", + "in": "query" + }, + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "example": 4213 }, "required": true, - "description": "The pull request's id.", - "name": "id", - "in": "path" + "name": "prNumber", + "in": "query" } ], "responses": { "200": { - "description": "The updated pull request", + "description": "A page of active forces", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PullRequest" + "$ref": "#/components/schemas/ForceOverrideList" } } } }, "400": { - "description": "`VALIDATION_FAILED`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `CI_HOST_UNSUPPORTED`", "content": { "application/json": { "schema": { @@ -10940,6 +12220,24 @@ "message": "Invalid input: expected string, received undefined" } } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } + }, + "CI_HOST_UNSUPPORTED": { + "summary": "CI_HOST_UNSUPPORTED", + "value": { + "error": { + "code": "CI_HOST_UNSUPPORTED", + "message": "Dynamic CI does not support the repository host gitlab.example.com" + } + } } } } @@ -10975,83 +12273,20 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, "404": { - "description": "`PR_NOT_FOUND`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "PR_NOT_FOUND": { - "summary": "PR_NOT_FOUND", - "value": { - "error": { - "code": "PR_NOT_FOUND", - "message": "Pull request not found" - } - } - } - } - } - } - }, - "409": { - "description": "`PR_NOT_IN_ACTIVE_STATE`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "PR_NOT_IN_ACTIVE_STATE": { - "summary": "PR_NOT_IN_ACTIVE_STATE", - "value": { - "error": { - "code": "PR_NOT_IN_ACTIVE_STATE", - "message": "This pull request is not in an active queue state" - } - } - } - } - } - } - }, - "422": { - "description": "`PR_TESTS_NOT_RESTARTABLE`", + "description": "`REPOSITORY_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "PR_TESTS_NOT_RESTARTABLE": { - "summary": "PR_TESTS_NOT_RESTARTABLE", + "REPOSITORY_NOT_FOUND": { + "summary": "REPOSITORY_NOT_FOUND", "value": { "error": { - "code": "PR_TESTS_NOT_RESTARTABLE", - "message": "This pull request's tests cannot be restarted in its current state" + "code": "REPOSITORY_NOT_FOUND", + "message": "Repository not found" } } } @@ -11100,57 +12335,27 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } - } - }, - "/v2/dynamic-ci/generate-plan": { - "post": { - "operationId": "dynamicCi.generatePlan", + }, + "put": { + "operationId": "dynamicCi.setForceOverrides", "tags": ["dynamicCi"], - "summary": "Generate a CI skip plan", - "description": "Returns a per-job verdict for one workflow run: which jobs the change makes it safe to skip, and the evidence behind each decision. Normally called once per run by the [`trunk-io/dynamic-ci`](https://github.com/trunk-io/dynamic-ci) action, which maps the verdicts onto job outputs.\n\n**Fail open.** A job absent from `jobs` must be run — that is the contract, not an error. Treat any non-200 the same way.\n\nSend the changed files as `changedFiles` when you can compute them. Without it, Trunk fetches the diff between `baseSha` and `commitSha` through your GitHub App installation, which needs both commits on GitHub.", + "summary": "Replace a pull request's force overrides", + "description": "Sets the complete list of forces active on a pull request. **This replaces the set, it does not add to it** — a force you omit is revoked, and an empty list clears them all. Send the desired end state, not a delta.\n\nScopes are addressed by the `workflowId` and `jobId` that `dynamicCi.listPrJobs` returns. An already-active force is left alone, so re-sending an unchanged list changes nothing. Forces are applied the next time `dynamicCi.generatePlan` runs for this pull request; this does not re-run anything itself.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CiPlanRequest" + "$ref": "#/components/schemas/SetForceOverridesRequest" } } } }, "responses": { - "200": { - "description": "The plan", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CiPlan" - } - } - } + "204": { + "description": "The forces were replaced" }, "400": { "description": "`VALIDATION_FAILED` | `CI_HOST_UNSUPPORTED`", @@ -11213,7 +12418,7 @@ } }, "404": { - "description": "`REPOSITORY_NOT_FOUND`", + "description": "`REPOSITORY_NOT_FOUND` | `CI_SCOPE_NOT_FOUND`", "content": { "application/json": { "schema": { @@ -11228,6 +12433,15 @@ "message": "Repository not found" } } + }, + "CI_SCOPE_NOT_FOUND": { + "summary": "CI_SCOPE_NOT_FOUND", + "value": { + "error": { + "code": "CI_SCOPE_NOT_FOUND", + "message": "A workflow or job in the request does not belong to this repository" + } + } } } } @@ -11274,60 +12488,80 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } } }, - "/v2/dynamic-ci/generate-buildkite-plan": { - "post": { - "operationId": "dynamicCi.generateBuildkitePlan", - "tags": ["dynamicCi"], - "summary": "Generate a CI skip plan for a Buildkite pipeline", - "description": "As [`dynamicCi.generatePlan`](#tag/dynamicCi/operation/dynamicCi.generatePlan), for a Buildkite pipeline. Identical request and response but for the workflow identity: Buildkite has no workflow file, so the pipeline is named by the organization and pipeline slugs its agent exports, and `jobKeys` are step `key:` values.\n\n**Fail open.** A job absent from `jobs` must be run — that is the contract, not an error. Treat any non-200 the same way.\n\nSend the changed files as `changedFiles` when you can compute them. Without it, Trunk fetches the diff between `baseSha` and `commitSha` through your GitHub App installation, which needs both commits on GitHub.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BuildkitePlanRequest" - } - } + "/v2/test-collections": { + "get": { + "operationId": "testCollections.list", + "tags": ["testCollections"], + "summary": "List test collections", + "description": "Lists the test collections in your organization, newest first. Every other flaky-tests endpoint takes a collection's `id` from here, either as a filter or in a request body.", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Case-insensitive substring match on the name.", + "example": "backend" + }, + "required": false, + "description": "Case-insensitive substring match on the name.", + "name": "q", + "in": "query" } - }, + ], "responses": { "200": { - "description": "The plan", + "description": "A page of test collections", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CiPlan" + "$ref": "#/components/schemas/TestCollectionList" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `CI_HOST_UNSUPPORTED`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", "content": { "application/json": { "schema": { @@ -11343,12 +12577,12 @@ } } }, - "CI_HOST_UNSUPPORTED": { - "summary": "CI_HOST_UNSUPPORTED", + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", "value": { "error": { - "code": "CI_HOST_UNSUPPORTED", - "message": "Dynamic CI does not support the repository host gitlab.example.com" + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." } } } @@ -11386,20 +12620,20 @@ } } }, - "404": { - "description": "`REPOSITORY_NOT_FOUND`", + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "REPOSITORY_NOT_FOUND": { - "summary": "REPOSITORY_NOT_FOUND", + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", "value": { "error": { - "code": "REPOSITORY_NOT_FOUND", - "message": "Repository not found" + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" } } } @@ -11448,121 +12682,37 @@ } } } - }, - "502": { - "description": "`UPSTREAM_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", - "value": { - "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" - } - } - } - } - } - } } } - } - }, - "/v2/dynamic-ci/pr-jobs": { - "get": { - "operationId": "dynamicCi.listPrJobs", - "tags": ["dynamicCi"], - "summary": "List the jobs Dynamic CI sees", - "description": "The workflows that have run on a pull request in this repository recently, with the jobs each defines. Use it to find the `workflowId` and `jobId` to force with `dynamicCi.setForceOverrides`.\n\n`jobKey` is `null` for a job whose key Trunk has not resolved — it can never be skipped, and is listed so that is visible rather than silently missing.", - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "type": "string", - "default": "github.com", - "description": "`github.com` is the only supported value today.", - "example": "github.com" - }, - "required": false, - "description": "`github.com` is the only supported value today.", - "name": "host", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "example": "acme" - }, - "required": true, - "name": "owner", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "example": "widgets" - }, - "required": true, - "name": "name", - "in": "query" + }, + "post": { + "operationId": "testCollections.create", + "tags": ["testCollections"], + "summary": "Create a test collection", + "description": "Creates a test collection and returns it. Its `id` is server-assigned; `testCount` is 0 until the collection's first upload is ingested.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTestCollectionRequest" + } + } } - ], + }, "responses": { - "200": { - "description": "A page of workflows", + "201": { + "description": "The created test collection", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CiWorkflowList" + "$ref": "#/components/schemas/TestCollection" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `CI_HOST_UNSUPPORTED`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -11577,24 +12727,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } - }, - "CI_HOST_UNSUPPORTED": { - "summary": "CI_HOST_UNSUPPORTED", - "value": { - "error": { - "code": "CI_HOST_UNSUPPORTED", - "message": "Dynamic CI does not support the repository host gitlab.example.com" - } - } } } } @@ -11630,20 +12762,20 @@ } } }, - "404": { - "description": "`REPOSITORY_NOT_FOUND`", + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "REPOSITORY_NOT_FOUND": { - "summary": "REPOSITORY_NOT_FOUND", + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", "value": { "error": { - "code": "REPOSITORY_NOT_FOUND", - "message": "Repository not found" + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" } } } @@ -11682,120 +12814,53 @@ "examples": { "INTERNAL_ERROR": { "summary": "INTERNAL_ERROR", - "value": { - "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" - } - } - } - } - } - } - } - } - } - }, - "/v2/dynamic-ci/force-overrides": { - "get": { - "operationId": "dynamicCi.listForceOverrides", - "tags": ["dynamicCi"], - "summary": "List a pull request's force overrides", - "description": "The forces currently active on a pull request. A force makes Dynamic CI run the jobs it covers whatever the signals say. `workflowId` and `jobId` are the ids `dynamicCi.listPrJobs` returns; revoked forces are retained for audit but not listed.", - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "type": "string", - "default": "github.com", - "description": "`github.com` is the only supported value today.", - "example": "github.com" - }, - "required": false, - "description": "`github.com` is the only supported value today.", - "name": "host", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "example": "acme" - }, - "required": true, - "name": "owner", - "in": "query" - }, + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + } + } + } + }, + "/v2/test-collections/{id}": { + "get": { + "operationId": "testCollections.get", + "tags": ["testCollections"], + "summary": "Get a test collection", + "description": "Returns one test collection. `quarantineEnabled` and `autoQuarantineEnabled` are read on every call, so a CI run can check them before it uploads; `testCount` may lag by up to a minute. A collection in another organization is reported as `404`, not `403`.", + "parameters": [ { "schema": { "type": "string", - "minLength": 1, - "example": "widgets" - }, - "required": true, - "name": "name", - "in": "query" - }, - { - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "example": 4213 + "pattern": "^[0-9A-Za-z]{8}$", + "description": "The collection's id: 8 alphanumeric characters.", + "example": "aB3xY9kQ" }, "required": true, - "name": "prNumber", - "in": "query" + "description": "The collection's id: 8 alphanumeric characters.", + "name": "id", + "in": "path" } ], "responses": { "200": { - "description": "A page of active forces", + "description": "The test collection", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForceOverrideList" + "$ref": "#/components/schemas/TestCollection" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `CI_HOST_UNSUPPORTED`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -11810,24 +12875,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } - }, - "CI_HOST_UNSUPPORTED": { - "summary": "CI_HOST_UNSUPPORTED", - "value": { - "error": { - "code": "CI_HOST_UNSUPPORTED", - "message": "Dynamic CI does not support the repository host gitlab.example.com" - } - } } } } @@ -11864,19 +12911,19 @@ } }, "404": { - "description": "`REPOSITORY_NOT_FOUND`", + "description": "`TEST_COLLECTION_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "REPOSITORY_NOT_FOUND": { - "summary": "REPOSITORY_NOT_FOUND", + "TEST_COLLECTION_NOT_FOUND": { + "summary": "TEST_COLLECTION_NOT_FOUND", "value": { "error": { - "code": "REPOSITORY_NOT_FOUND", - "message": "Repository not found" + "code": "TEST_COLLECTION_NOT_FOUND", + "message": "Test collection not found" } } } @@ -11927,28 +12974,51 @@ } } } - }, - "put": { - "operationId": "dynamicCi.setForceOverrides", - "tags": ["dynamicCi"], - "summary": "Replace a pull request's force overrides", - "description": "Sets the complete list of forces active on a pull request. **This replaces the set, it does not add to it** — a force you omit is revoked, and an empty list clears them all. Send the desired end state, not a delta.\n\nScopes are addressed by the `workflowId` and `jobId` that `dynamicCi.listPrJobs` returns. An already-active force is left alone, so re-sending an unchanged list changes nothing. Forces are applied the next time `dynamicCi.generatePlan` runs for this pull request; this does not re-run anything itself.", + } + }, + "/v2/test-collections/{id}/quarantine-status": { + "post": { + "operationId": "testCollections.getQuarantineStatus", + "tags": ["testCollections"], + "summary": "Get quarantine status for a CI run", + "description": "**Internal.** This endpoint is intended for internal use by Trunk's analytics-cli and test-runner plugins. Use it at your own risk: it may change without notice.\n\nReports which tests are quarantined in a collection for one repository, so a CI run can ignore their failures. Send the run's failing tests as `testIdentifiers` to get an answer for each. This is a read sent as `POST` because the identifier list can be large. Requires the **Upload test results** permission. Quarantine changes reach this response within a minute.", + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^[0-9A-Za-z]{8}$", + "description": "The collection's id: 8 alphanumeric characters.", + "example": "aB3xY9kQ" + }, + "required": true, + "description": "The collection's id: 8 alphanumeric characters.", + "name": "id", + "in": "path" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetForceOverridesRequest" + "$ref": "#/components/schemas/QuarantineStatusRequest" } } } }, "responses": { - "204": { - "description": "The forces were replaced" + "200": { + "description": "The collection's quarantine status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuarantineStatus" + } + } + } }, "400": { - "description": "`VALIDATION_FAILED` | `CI_HOST_UNSUPPORTED`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -11963,15 +13033,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "CI_HOST_UNSUPPORTED": { - "summary": "CI_HOST_UNSUPPORTED", - "value": { - "error": { - "code": "CI_HOST_UNSUPPORTED", - "message": "Dynamic CI does not support the repository host gitlab.example.com" - } - } } } } @@ -12007,29 +13068,41 @@ } } }, - "404": { - "description": "`REPOSITORY_NOT_FOUND` | `CI_SCOPE_NOT_FOUND`", + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "REPOSITORY_NOT_FOUND": { - "summary": "REPOSITORY_NOT_FOUND", + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", "value": { "error": { - "code": "REPOSITORY_NOT_FOUND", - "message": "Repository not found" + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" } } - }, - "CI_SCOPE_NOT_FOUND": { - "summary": "CI_SCOPE_NOT_FOUND", + } + } + } + } + }, + "404": { + "description": "`TEST_COLLECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "TEST_COLLECTION_NOT_FOUND": { + "summary": "TEST_COLLECTION_NOT_FOUND", "value": { "error": { - "code": "CI_SCOPE_NOT_FOUND", - "message": "A workflow or job in the request does not belong to this repository" + "code": "TEST_COLLECTION_NOT_FOUND", + "message": "Test collection not found" } } } @@ -12082,24 +13155,36 @@ } } }, - "/v2/test-collections": { - "get": { - "operationId": "testCollections.list", + "/v2/test-collections/{id}/all-quarantined-tests": { + "post": { + "operationId": "testCollections.listAllQuarantinedTests", "tags": ["testCollections"], - "summary": "List test collections", - "description": "Lists the test collections in your organization, newest first. Every other flaky-tests endpoint takes a collection's `id` from here, either as a filter or in a request body.", + "summary": "List every quarantined test for a CI run", + "description": "**Internal.** This endpoint is intended for internal use by Trunk's analytics-cli and test-runner plugins. Use it at your own risk: it may change without notice.\n\nLists the `testId` of every quarantined test in a collection for one repository, in `testId` order, so a test runner can decide each failure itself as it happens. Name the repository the tests ran in as `repo` in the body, or `null` for an upload without a repository (`--no-repo`); the cursor and `limit` stay in the query. This is a read sent as `POST` so the repository travels in the body, as it does for `quarantine-status`. Every page also carries the collection's `testCollectionUuid`, the `repositoryId` the tests are stored under, and `quarantineEnabled`; while quarantining is off the list is empty. A runner computes each test's `testId` from `testCollectionUuid`, `repositoryId` and its own test id, and matches on it. A page holds up to 25,000 tests. Requires the **Upload test results** permission. Quarantine changes reach this list within a minute, so a test that joins or leaves the set while you page through it may be missed, or still listed, until your next pass.", "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^[0-9A-Za-z]{8}$", + "description": "The collection's id: 8 alphanumeric characters.", + "example": "aB3xY9kQ" + }, + "required": true, + "description": "The collection's id: 8 alphanumeric characters.", + "name": "id", + "in": "path" + }, { "schema": { "type": "integer", "minimum": 1, - "maximum": 100, + "maximum": 25000, "default": 25, - "description": "Page size, between 1 and 100.", + "description": "Page size, between 1 and 25000.", "example": 25 }, "required": false, - "description": "Page size, between 1 and 100.", + "description": "Page size, between 1 and 25000.", "name": "limit", "in": "query" }, @@ -12125,27 +13210,25 @@ "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", "name": "includeTotal", "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Case-insensitive substring match on the name.", - "example": "backend" - }, - "required": false, - "description": "Case-insensitive substring match on the name.", - "name": "q", - "in": "query" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllQuarantinedTestsRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of test collections", + "description": "A page of quarantined tests", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestCollectionList" + "$ref": "#/components/schemas/QuarantinedTestList" } } } @@ -12231,6 +13314,27 @@ } } }, + "404": { + "description": "`TEST_COLLECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "TEST_COLLECTION_NOT_FOUND": { + "summary": "TEST_COLLECTION_NOT_FOUND", + "value": { + "error": { + "code": "TEST_COLLECTION_NOT_FOUND", + "message": "Test collection not found" + } + } + } + } + } + } + }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -12274,29 +13378,59 @@ } } } - }, - "post": { - "operationId": "testCollections.create", - "tags": ["testCollections"], - "summary": "Create a test collection", - "description": "Creates a test collection and returns it. Its `id` is server-assigned; `testCount` is 0 until the collection's first upload is ingested.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTestCollectionRequest" - } - } + } + }, + "/v2/tests/{testId}": { + "get": { + "operationId": "tests.get", + "tags": ["tests"], + "summary": "Get a test", + "description": "Returns one test by its globally unique id, with the labels, quarantine override and active monitors currently applied to it. Ids come from list responses; they are opaque and not derivable client-side.\n\nA test in another organization is reported as `404`, not `403`, and so is a malformed id — a caller must not be able to probe which ids exist.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "The test's globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" + }, + "required": true, + "description": "The test's globally unique id.", + "name": "testId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["metrics"], + "description": "Set to `metrics` to embed this test's metrics for `period`.", + "example": "metrics" + }, + "required": false, + "description": "Set to `metrics` to embed this test's metrics for `period`.", + "name": "include", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["1d", "7d", "14d", "30d"], + "description": "Metrics window. Required with `include=metrics`.", + "example": "7d" + }, + "required": false, + "description": "Metrics window. Required with `include=metrics`.", + "name": "period", + "in": "query" } - }, + ], "responses": { - "201": { - "description": "The created test collection", + "200": { + "description": "The test", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestCollection" + "$ref": "#/components/schemas/Test" } } } @@ -12352,20 +13486,20 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", + "404": { + "description": "`TEST_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", + "TEST_NOT_FOUND": { + "summary": "TEST_NOT_FOUND", "value": { "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" + "code": "TEST_NOT_FOUND", + "message": "Test not found" } } } @@ -12418,197 +13552,272 @@ } } }, - "/v2/test-collections/{id}": { + "/v2/tests": { "get": { - "operationId": "testCollections.get", - "tags": ["testCollections"], - "summary": "Get a test collection", - "description": "Returns one test collection. `quarantineEnabled` and `autoQuarantineEnabled` are read on every call, so a CI run can check them before it uploads; `testCount` may lag by up to a minute. A collection in another organization is reported as `404`, not `403`.", + "operationId": "tests.list", + "tags": ["tests"], + "summary": "List and search tests", + "description": "Lists tests, by default across every test collection in your organization — pass `testCollectionId` to narrow to one. Each row carries the `id` to pass to the per-test endpoints.\n\n`q` searches name, file, class and parent as case-insensitive substrings. Several words are separate requirements, not a phrase — `q=flaky slow` returns tests matching both, in any position. It also accepts per-field qualifiers mixed freely with those words: `q=file:checkout flaky class:PaymentTest`. Qualifier keys are case-sensitive, and an unrecognized prefix is treated as literal text rather than guessed at.\n\nRows omit `quarantineOverride`, `activeMonitors`, `firstSeenAt` and `lastFailureAt` — fetch a single test for those.", "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Narrow to one collection, by its id. Omitted searches every collection in your organization.", + "example": "aB3xY9kQ" + }, + "required": false, + "description": "Narrow to one collection, by its id. Omitted searches every collection in your organization.", + "name": "testCollectionId", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Comma-separated statuses to include: `healthy`, `flaky`, `broken`.", + "example": "flaky,broken" + }, + "required": false, + "description": "Comma-separated statuses to include: `healthy`, `flaky`, `broken`.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Free-text search, with optional `file:` / `class:` / `parent:` / `name:` qualifiers.", + "example": "file:checkout flaky" + }, + "required": false, + "description": "Free-text search, with optional `file:` / `class:` / `parent:` / `name:` qualifiers.", + "name": "q", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Substring match on the test's file path.", + "example": "src/payments" + }, + "required": false, + "description": "Substring match on the test's file path.", + "name": "file", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Exact match on one of the test's code owners.", + "example": "@acme/platform" + }, + "required": false, + "description": "Exact match on one of the test's code owners.", + "name": "owner", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Exact match on the test's variant.", + "example": "ubuntu-22.04" + }, + "required": false, + "description": "Exact match on the test's variant.", + "name": "variant", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["metrics"], + "description": "Set to `metrics` to embed each test's metrics for `period` in the same response, instead of fetching them per test.", + "example": "metrics" + }, + "required": false, + "description": "Set to `metrics` to embed each test's metrics for `period` in the same response, instead of fetching them per test.", + "name": "include", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Only tests carrying this label, by name. Matched exactly, including case, since two labels may differ only in case. An unknown name returns an empty page, not an error.", + "example": "slow-on-prs" + }, + "required": false, + "description": "Only tests carrying this label, by name. Matched exactly, including case, since two labels may differ only in case. An unknown name returns an empty page, not an error.", + "name": "label", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Only tests currently linked to this ticket, by its Trunk id (`ticket.id` on a test). Spans every collection unless `testCollectionId` narrows it. An unknown id returns an empty page, not an error. Cannot be combined with `ticketKey`.", + "example": "5b0e8f8a-1c2d-4e3f-8a9b-0c1d2e3f4a5b" + }, + "required": false, + "description": "Only tests currently linked to this ticket, by its Trunk id (`ticket.id` on a test). Spans every collection unless `testCollectionId` narrows it. An unknown id returns an empty page, not an error. Cannot be combined with `ticketKey`.", + "name": "ticketId", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Only tests currently linked to the ticket with this provider key or URL. A convenience: a key can change (a Jira issue moved between projects) and two connections can share one, so prefer `ticketId`, and pass `integrationId` to narrow to one connection. An unknown key returns an empty page.", + "example": "TRUNK-12345" + }, + "required": false, + "description": "Only tests currently linked to the ticket with this provider key or URL. A convenience: a key can change (a Jira issue moved between projects) and two connections can share one, so prefer `ticketId`, and pass `integrationId` to narrow to one connection. An unknown key returns an empty page.", + "name": "ticketKey", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "The ticketing connection `ticketKey` is looked up in. Only valid with `ticketKey`.", + "example": "6c1f9a9b-2d3e-4f40-9b0c-1d2e3f4a5b6c" + }, + "required": false, + "description": "The ticketing connection `ticketKey` is looked up in. Only valid with `ticketKey`.", + "name": "integrationId", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["quarantined", "notQuarantined"], + "description": "Filter on effective quarantine state, derived from the test's status, its collection's quarantine settings and any per-test override.", + "example": "quarantined" + }, + "required": false, + "description": "Filter on effective quarantine state, derived from the test's status, its collection's quarantine settings and any per-test override.", + "name": "quarantineState", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "description": "Filter on whether the manual 'flag as flaky' mark is currently set.", + "example": true + }, + "required": false, + "description": "Filter on whether the manual 'flag as flaky' mark is currently set.", + "name": "markedFlaky", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "description": "Only tests that first became flaky inside `period`. Requires `period`.", + "example": true + }, + "required": false, + "description": "Only tests that first became flaky inside `period`. Requires `period`.", + "name": "newlyFlaky", + "in": "query" + }, { "schema": { "type": "string", - "pattern": "^[0-9A-Za-z]{8}$", - "description": "The collection's id: 8 alphanumeric characters.", - "example": "aB3xY9kQ" + "enum": ["1d", "7d", "14d", "30d"], + "description": "Metrics window for `include=metrics`, any metric `sort`, and `newlyFlaky`. Required by each of them.", + "example": "7d" }, - "required": true, - "description": "The collection's id: 8 alphanumeric characters.", - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "The test collection", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TestCollection" - } - } - } - }, - "400": { - "description": "`VALIDATION_FAILED`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "VALIDATION_FAILED": { - "summary": "VALIDATION_FAILED", - "value": { - "error": { - "code": "VALIDATION_FAILED", - "message": "Invalid input: expected string, received undefined" - } - } - } - } - } - } - }, - "401": { - "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "AUTHENTICATION_REQUIRED": { - "summary": "AUTHENTICATION_REQUIRED", - "value": { - "error": { - "code": "AUTHENTICATION_REQUIRED", - "message": "No credential was supplied" - } - } - }, - "INVALID_TOKEN": { - "summary": "INVALID_TOKEN", - "value": { - "error": { - "code": "INVALID_TOKEN", - "message": "The supplied token failed verification" - } - } - } - } - } - } - }, - "404": { - "description": "`TEST_COLLECTION_NOT_FOUND`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "TEST_COLLECTION_NOT_FOUND": { - "summary": "TEST_COLLECTION_NOT_FOUND", - "value": { - "error": { - "code": "TEST_COLLECTION_NOT_FOUND", - "message": "Test collection not found" - } - } - } - } - } - } + "required": false, + "description": "Metrics window for `include=metrics`, any metric `sort`, and `newlyFlaky`. Required by each of them.", + "name": "period", + "in": "query" }, - "429": { - "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "RATE_LIMIT_EXCEEDED": { - "summary": "RATE_LIMIT_EXCEEDED", - "value": { - "error": { - "code": "RATE_LIMIT_EXCEEDED", - "message": "Rate limit exceeded; retry with backoff" - } - } - } - } - } - } + { + "schema": { + "type": "string", + "enum": [ + "name", + "failureRate", + "durationP95", + "prsImpacted", + "lastRunAt", + "lastFailureAt" + ], + "default": "name", + "description": "Field to sort by. A metric sort requires `period`, and excludes tests with no comparable value in it — no runs at all for most metrics, and additionally no failure for `lastFailureAt`." + }, + "required": false, + "description": "Field to sort by. A metric sort requires `period`, and excludes tests with no comparable value in it — no runs at all for most metrics, and additionally no failure for `lastFailureAt`.", + "name": "sort", + "in": "query" }, - "500": { - "description": "`INTERNAL_ERROR`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INTERNAL_ERROR": { - "summary": "INTERNAL_ERROR", - "value": { - "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" - } - } - } - } - } - } - } - } - } - }, - "/v2/test-collections/{id}/quarantine-status": { - "post": { - "operationId": "testCollections.getQuarantineStatus", - "tags": ["testCollections"], - "summary": "Get quarantine status for a CI run", - "description": "**Internal.** This endpoint is intended for internal use by Trunk's analytics-cli and test-runner plugins. Use it at your own risk: it may change without notice.\n\nReports which tests are quarantined in a collection for one repository, so a CI run can ignore their failures. Send the run's failing tests as `testIdentifiers` to get an answer for each. This is a read sent as `POST` because the identifier list can be large. Requires the **Upload test results** permission. Quarantine changes reach this response within a minute.", - "parameters": [ { "schema": { "type": "string", - "pattern": "^[0-9A-Za-z]{8}$", - "description": "The collection's id: 8 alphanumeric characters.", - "example": "aB3xY9kQ" + "enum": ["asc", "desc"], + "description": "Sort direction. Defaults per sort: ascending for `name`, descending for every metric sort, which order worst-first." }, - "required": true, - "description": "The collection's id: 8 alphanumeric characters.", - "name": "id", - "in": "path" + "required": false, + "description": "Sort direction. Defaults per sort: ascending for `name`, descending for every metric sort, which order worst-first.", + "name": "direction", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QuarantineStatusRequest" - } - } - } - }, "responses": { "200": { - "description": "The collection's quarantine status", + "description": "A page of tests", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QuarantineStatus" + "$ref": "#/components/schemas/TestList" } } } }, "400": { - "description": "`VALIDATION_FAILED`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", "content": { "application/json": { "schema": { @@ -12623,6 +13832,15 @@ "message": "Invalid input: expected string, received undefined" } } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } } } } @@ -12679,27 +13897,6 @@ } } }, - "404": { - "description": "`TEST_COLLECTION_NOT_FOUND`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "TEST_COLLECTION_NOT_FOUND": { - "summary": "TEST_COLLECTION_NOT_FOUND", - "value": { - "error": { - "code": "TEST_COLLECTION_NOT_FOUND", - "message": "Test collection not found" - } - } - } - } - } - } - }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -12745,36 +13942,36 @@ } } }, - "/v2/test-collections/{id}/all-quarantined-tests": { - "post": { - "operationId": "testCollections.listAllQuarantinedTests", - "tags": ["testCollections"], - "summary": "List every quarantined test for a CI run", - "description": "**Internal.** This endpoint is intended for internal use by Trunk's analytics-cli and test-runner plugins. Use it at your own risk: it may change without notice.\n\nLists the `testId` of every quarantined test in a collection for one repository, in `testId` order, so a test runner can decide each failure itself as it happens. Name the repository the tests ran in as `repo` in the body, or `null` for an upload without a repository (`--no-repo`); the cursor and `limit` stay in the query. This is a read sent as `POST` so the repository travels in the body, as it does for `quarantine-status`. Every page also carries the collection's `testCollectionUuid`, the `repositoryId` the tests are stored under, and `quarantineEnabled`; while quarantining is off the list is empty. A runner computes each test's `testId` from `testCollectionUuid`, `repositoryId` and its own test id, and matches on it. A page holds up to 25,000 tests. Requires the **Upload test results** permission. Quarantine changes reach this list within a minute, so a test that joins or leaves the set while you page through it may be missed, or still listed, until your next pass.", + "/v2/tests/{testId}/status-history": { + "get": { + "operationId": "tests.listStatusHistory", + "tags": ["tests"], + "summary": "List a test's status transitions", + "description": "Returns the test's status transitions, newest first. A row is recorded only when the composite status changes, so `fromStatus` is the status it moved away from and the series has no repeats.\n\n`activeMonitors` is the set active immediately after each transition, as recorded at the time. Transitions predating this report an empty set.", "parameters": [ { "schema": { "type": "string", - "pattern": "^[0-9A-Za-z]{8}$", - "description": "The collection's id: 8 alphanumeric characters.", - "example": "aB3xY9kQ" + "minLength": 1, + "description": "The test's globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" }, "required": true, - "description": "The collection's id: 8 alphanumeric characters.", - "name": "id", + "description": "The test's globally unique id.", + "name": "testId", "in": "path" }, { "schema": { "type": "integer", "minimum": 1, - "maximum": 25000, + "maximum": 100, "default": 25, - "description": "Page size, between 1 and 25000.", + "description": "Page size, between 1 and 100.", "example": 25 }, "required": false, - "description": "Page size, between 1 and 25000.", + "description": "Page size, between 1 and 100.", "name": "limit", "in": "query" }, @@ -12800,25 +13997,39 @@ "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", "name": "includeTotal", "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only transitions at or after this time.", + "example": "2026-06-01T00:00:00.000Z" + }, + "required": false, + "description": "Only transitions at or after this time.", + "name": "startedAfter", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only transitions strictly before this time.", + "example": "2026-06-30T00:00:00.000Z" + }, + "required": false, + "description": "Only transitions strictly before this time.", + "name": "startedBefore", + "in": "query" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AllQuarantinedTestsRequest" - } - } - } - }, "responses": { "200": { - "description": "A page of quarantined tests", + "description": "A page of status transitions", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QuarantinedTestList" + "$ref": "#/components/schemas/StatusHistoryEntryList" } } } @@ -12883,41 +14094,20 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, "404": { - "description": "`TEST_COLLECTION_NOT_FOUND`", + "description": "`TEST_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "TEST_COLLECTION_NOT_FOUND": { - "summary": "TEST_COLLECTION_NOT_FOUND", + "TEST_NOT_FOUND": { + "summary": "TEST_NOT_FOUND", "value": { "error": { - "code": "TEST_COLLECTION_NOT_FOUND", - "message": "Test collection not found" + "code": "TEST_NOT_FOUND", + "message": "Test not found" } } } @@ -12970,12 +14160,12 @@ } } }, - "/v2/tests/{testId}": { + "/v2/tests/{testId}/test-runs": { "get": { - "operationId": "tests.get", + "operationId": "tests.listTestRuns", "tags": ["tests"], - "summary": "Get a test", - "description": "Returns one test by its globally unique id, with the labels, quarantine override and active monitors currently applied to it. Ids come from list responses; they are opaque and not derivable client-side.\n\nA test in another organization is reported as `404`, not `403`, and so is a malformed id — a caller must not be able to probe which ids exist.", + "summary": "List a test's runs", + "description": "Returns the individual runs recorded for a test, newest first, each with the branch, commit and CI job it came from.\n\n`branches` takes shell-style globs, where `*` matches any run of characters and `?` exactly one (`main,release-?.*`); `pullRequestOnly=true` selects pull-request runs instead. They are alternatives, so sending both is a `400` rather than a silent intersection.\n\n`startedAfter` and `startedBefore` bound when the run started, as your CI reported it.", "parameters": [ { "schema": { @@ -12989,44 +14179,128 @@ "name": "testId", "in": "path" }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, { "schema": { "type": "string", - "enum": ["metrics"], - "description": "Set to `metrics` to embed this test's metrics for `period`.", - "example": "metrics" + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" }, "required": false, - "description": "Set to `metrics` to embed this test's metrics for `period`.", - "name": "include", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", "in": "query" }, { "schema": { "type": "string", - "enum": ["1d", "7d", "14d", "30d"], - "description": "Metrics window. Required with `include=metrics`.", - "example": "7d" + "enum": ["success", "failure", "skipped"], + "description": "Only runs that finished this way.", + "example": "failure" }, "required": false, - "description": "Metrics window. Required with `include=metrics`.", - "name": "period", + "description": "Only runs that finished this way.", + "name": "conclusion", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Comma-separated branch globs, e.g. `main,release-*`. `*` matches any run of characters, `?` exactly one. Cannot be combined with `pullRequestOnly`.", + "example": "main,release-*" + }, + "required": false, + "description": "Comma-separated branch globs, e.g. `main,release-*`. `*` matches any run of characters, `?` exactly one. Cannot be combined with `pullRequestOnly`.", + "name": "branches", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "description": "Only runs on pull-request branches. Cannot be combined with `branches`.", + "example": true + }, + "required": false, + "description": "Only runs on pull-request branches. Cannot be combined with `branches`.", + "name": "pullRequestOnly", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": ["quarantined", "notQuarantined"], + "description": "Only runs recorded while the test was in this quarantine state.", + "example": "quarantined" + }, + "required": false, + "description": "Only runs recorded while the test was in this quarantine state.", + "name": "quarantineState", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only runs that started at or after this time.", + "example": "2026-06-01T00:00:00.000Z" + }, + "required": false, + "description": "Only runs that started at or after this time.", + "name": "startedAfter", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "date-time", + "description": "Only runs that started strictly before this time.", + "example": "2026-06-30T00:00:00.000Z" + }, + "required": false, + "description": "Only runs that started strictly before this time.", + "name": "startedBefore", "in": "query" } ], "responses": { "200": { - "description": "The test", + "description": "A page of test runs", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Test" + "$ref": "#/components/schemas/TestRunList" } } } }, "400": { - "description": "`VALIDATION_FAILED`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", "content": { "application/json": { "schema": { @@ -13041,6 +14315,15 @@ "message": "Invalid input: expected string, received undefined" } } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } } } } @@ -13142,12 +14425,12 @@ } } }, - "/v2/tests": { + "/v2/detection-events": { "get": { - "operationId": "tests.list", - "tags": ["tests"], - "summary": "List and search tests", - "description": "Lists tests, by default across every test collection in your organization — pass `testCollectionId` to narrow to one. Each row carries the `id` to pass to the per-test endpoints.\n\n`q` searches name, file, class and parent as case-insensitive substrings. Several words are separate requirements, not a phrase — `q=flaky slow` returns tests matching both, in any position. It also accepts per-field qualifiers mixed freely with those words: `q=file:checkout flaky class:PaymentTest`. Qualifier keys are case-sensitive, and an unrecognized prefix is treated as literal text rather than guessed at.\n\nRows omit `quarantineOverride`, `activeMonitors`, `firstSeenAt` and `lastFailureAt` — fetch a single test for those.", + "operationId": "detectionEvents.list", + "tags": ["detectionEvents"], + "summary": "List detection events", + "description": "Lists each verdict a monitor recorded on a test: `active` when it detected the condition it watches for, `inactive` when the test recovered or the monitor was disabled or deleted. By default it reads every test collection in your organization; pass `testCollectionId` to narrow to one.\n\nThe window is `detectedAfter` to `detectedBefore`, at most 30 days. `detectedBefore` defaults to now and `detectedAfter` to 30 days before it. A wider window is a `400 OUT_OF_RANGE`.\n\nAn unknown `monitorId` or `testId` returns an empty page. The 'flag as flaky' mark is not listed here: see a test's status history.", "parameters": [ { "schema": { @@ -13183,167 +14466,113 @@ }, "required": false, "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Narrow to one collection, by its id. Omitted searches every collection in your organization.", - "example": "aB3xY9kQ" - }, - "required": false, - "description": "Narrow to one collection, by its id. Omitted searches every collection in your organization.", - "name": "testCollectionId", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Comma-separated statuses to include: `healthy`, `flaky`, `broken`.", - "example": "flaky,broken" - }, - "required": false, - "description": "Comma-separated statuses to include: `healthy`, `flaky`, `broken`.", - "name": "status", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Free-text search, with optional `file:` / `class:` / `parent:` / `name:` qualifiers.", - "example": "file:checkout flaky" - }, - "required": false, - "description": "Free-text search, with optional `file:` / `class:` / `parent:` / `name:` qualifiers.", - "name": "q", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Substring match on the test's file path.", - "example": "src/payments" - }, - "required": false, - "description": "Substring match on the test's file path.", - "name": "file", + "name": "includeTotal", "in": "query" }, { "schema": { "type": "string", "minLength": 1, - "description": "Exact match on one of the test's code owners.", - "example": "@acme/platform" + "description": "Narrow to one collection, by its id. Omitted reads every collection in your organization.", + "example": "aB3xY9kQ" }, "required": false, - "description": "Exact match on one of the test's code owners.", - "name": "owner", + "description": "Narrow to one collection, by its id. Omitted reads every collection in your organization.", + "name": "testCollectionId", "in": "query" }, { "schema": { "type": "string", "minLength": 1, - "description": "Exact match on the test's variant.", - "example": "ubuntu-22.04" + "description": "Only events this monitor recorded.", + "example": "1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d" }, "required": false, - "description": "Exact match on the test's variant.", - "name": "variant", + "description": "Only events this monitor recorded.", + "name": "monitorId", "in": "query" }, { "schema": { "type": "string", - "enum": ["metrics"], - "description": "Set to `metrics` to embed each test's metrics for `period` in the same response, instead of fetching them per test.", - "example": "metrics" + "minLength": 1, + "description": "Comma-separated monitor types to include, e.g. `failureRate,passOnRetry`.", + "example": "failureRate,passOnRetry" }, "required": false, - "description": "Set to `metrics` to embed each test's metrics for `period` in the same response, instead of fetching them per test.", - "name": "include", + "description": "Comma-separated monitor types to include, e.g. `failureRate,passOnRetry`.", + "name": "monitorType", "in": "query" }, { "schema": { "type": "string", "minLength": 1, - "description": "Only tests carrying this label, by name. Matched exactly, including case, since two labels may differ only in case. An unknown name returns an empty page, not an error.", - "example": "slow-on-prs" + "description": "Only events on this test, by its globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" }, "required": false, - "description": "Only tests carrying this label, by name. Matched exactly, including case, since two labels may differ only in case. An unknown name returns an empty page, not an error.", - "name": "label", + "description": "Only events on this test, by its globally unique id.", + "name": "testId", "in": "query" }, { "schema": { "type": "string", - "enum": ["quarantined", "notQuarantined"], - "description": "Filter on effective quarantine state, derived from the test's status, its collection's quarantine settings and any per-test override.", - "example": "quarantined" + "enum": ["active", "inactive"], + "description": "Only events with this verdict.", + "example": "active" }, "required": false, - "description": "Filter on effective quarantine state, derived from the test's status, its collection's quarantine settings and any per-test override.", - "name": "quarantineState", + "description": "Only events with this verdict.", + "name": "monitorStatus", "in": "query" }, { "schema": { "type": "boolean", - "description": "Filter on whether the manual 'flag as flaky' mark is currently set.", + "description": "`true` for only the events that carry a classify action, `false` for only those that do not.", "example": true }, "required": false, - "description": "Filter on whether the manual 'flag as flaky' mark is currently set.", - "name": "markedFlaky", + "description": "`true` for only the events that carry a classify action, `false` for only those that do not.", + "name": "isClassificationEvent", "in": "query" }, { "schema": { - "type": "boolean", - "description": "Only tests that first became flaky inside `period`. Requires `period`.", - "example": true + "type": "string", + "format": "date-time", + "description": "Only events detected at or after this time.", + "example": "2026-06-01T00:00:00.000Z" }, "required": false, - "description": "Only tests that first became flaky inside `period`. Requires `period`.", - "name": "newlyFlaky", + "description": "Only events detected at or after this time.", + "name": "detectedAfter", "in": "query" }, { "schema": { "type": "string", - "enum": ["1d", "7d", "14d", "30d"], - "description": "Metrics window for `include=metrics`, any metric `sort`, and `newlyFlaky`. Required by each of them.", - "example": "7d" + "format": "date-time", + "description": "Only events detected strictly before this time.", + "example": "2026-06-30T00:00:00.000Z" }, "required": false, - "description": "Metrics window for `include=metrics`, any metric `sort`, and `newlyFlaky`. Required by each of them.", - "name": "period", + "description": "Only events detected strictly before this time.", + "name": "detectedBefore", "in": "query" }, { "schema": { "type": "string", - "enum": [ - "name", - "failureRate", - "durationP95", - "prsImpacted", - "lastRunAt", - "lastFailureAt" - ], - "default": "name", - "description": "Field to sort by. A metric sort requires `period`, and excludes tests with no comparable value in it — no runs at all for most metrics, and additionally no failure for `lastFailureAt`." + "enum": ["detectedAt"], + "default": "detectedAt", + "description": "Field to sort by." }, "required": false, - "description": "Field to sort by. A metric sort requires `period`, and excludes tests with no comparable value in it — no runs at all for most metrics, and additionally no failure for `lastFailureAt`.", + "description": "Field to sort by.", "name": "sort", "in": "query" }, @@ -13351,27 +14580,28 @@ "schema": { "type": "string", "enum": ["asc", "desc"], - "description": "Sort direction. Defaults per sort: ascending for `name`, descending for every metric sort, which order worst-first." + "default": "desc", + "description": "Sort direction. Newest first by default." }, "required": false, - "description": "Sort direction. Defaults per sort: ascending for `name`, descending for every metric sort, which order worst-first.", + "description": "Sort direction. Newest first by default.", "name": "direction", "in": "query" } ], "responses": { "200": { - "description": "A page of tests", + "description": "A page of detection events", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestList" + "$ref": "#/components/schemas/DetectionEventList" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `OUT_OF_RANGE`", "content": { "application/json": { "schema": { @@ -13395,6 +14625,15 @@ "message": "Invalid or expired cursor." } } + }, + "OUT_OF_RANGE": { + "summary": "OUT_OF_RANGE", + "value": { + "error": { + "code": "OUT_OF_RANGE", + "message": "The requested time window is wider than the endpoint allows" + } + } } } } @@ -13496,12 +14735,12 @@ } } }, - "/v2/tests/{testId}/status-history": { + "/v2/tests/{testId}/investigations": { "get": { - "operationId": "tests.listStatusHistory", - "tags": ["tests"], - "summary": "List a test's status transitions", - "description": "Returns the test's status transitions, newest first. A row is recorded only when the composite status changes, so `fromStatus` is the status it moved away from and the series has no repeats.\n\n`activeMonitors` is the set active immediately after each transition, as recorded at the time. Transitions predating this report an empty set.", + "operationId": "testInvestigations.list", + "tags": ["testInvestigations"], + "summary": "List a test's AI investigations", + "description": "Returns the AI investigations Trunk ran on a test, newest first. An investigation that was retried appears once, as its latest attempt, and the filters apply to that attempt.\n\nTrunk keeps investigations for 45 days. `createdAfter` and `createdBefore` bound when an investigation was created; when `createdAfter` is set, the window up to `createdBefore` (or now) may span at most 30 days.", "parameters": [ { "schema": { @@ -13552,44 +14791,68 @@ "name": "includeTotal", "in": "query" }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Comma-separated statuses to include: `completed`, `failed`, `skipped`.", + "example": "completed" + }, + "required": false, + "description": "Comma-separated statuses to include: `completed`, `failed`, `skipped`.", + "name": "status", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "description": "Comma-separated trigger types to include: `manual`, `automatic`, `mcp`.", + "example": "manual,mcp" + }, + "required": false, + "description": "Comma-separated trigger types to include: `manual`, `automatic`, `mcp`.", + "name": "triggerType", + "in": "query" + }, { "schema": { "type": "string", "format": "date-time", - "description": "Only transitions at or after this time.", + "description": "Only investigations created at or after this time.", "example": "2026-06-01T00:00:00.000Z" }, "required": false, - "description": "Only transitions at or after this time.", - "name": "startedAfter", + "description": "Only investigations created at or after this time.", + "name": "createdAfter", "in": "query" }, { "schema": { "type": "string", "format": "date-time", - "description": "Only transitions strictly before this time.", + "description": "Only investigations created strictly before this time.", "example": "2026-06-30T00:00:00.000Z" }, "required": false, - "description": "Only transitions strictly before this time.", - "name": "startedBefore", + "description": "Only investigations created strictly before this time.", + "name": "createdBefore", "in": "query" } ], "responses": { "200": { - "description": "A page of status transitions", + "description": "A page of investigations", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StatusHistoryEntryList" + "$ref": "#/components/schemas/InvestigationSummaryList" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `OUT_OF_RANGE`", "content": { "application/json": { "schema": { @@ -13613,6 +14876,15 @@ "message": "Invalid or expired cursor." } } + }, + "OUT_OF_RANGE": { + "summary": "OUT_OF_RANGE", + "value": { + "error": { + "code": "OUT_OF_RANGE", + "message": "The requested time window is wider than the endpoint allows" + } + } } } } @@ -13714,12 +14986,12 @@ } } }, - "/v2/tests/{testId}/test-runs": { + "/v2/tests/{testId}/investigations/{investigationId}": { "get": { - "operationId": "tests.listTestRuns", - "tags": ["tests"], - "summary": "List a test's runs", - "description": "Returns the individual runs recorded for a test, newest first, each with the branch, commit and CI job it came from.\n\n`branches` takes shell-style globs, where `*` matches any run of characters and `?` exactly one (`main,release-?.*`); `pullRequestOnly=true` selects pull-request runs instead. They are alternatives, so sending both is a `400` rather than a silent intersection.\n\n`startedAfter` and `startedBefore` bound when the run started, as your CI reported it.", + "operationId": "testInvestigations.get", + "tags": ["testInvestigations"], + "summary": "Get an AI investigation", + "description": "Returns one AI investigation of a test, at its latest attempt, with the facts it found and the evidence each cites.\n\nAn investigation of another test, or of a test you cannot read, is reported as `404`.", "parameters": [ { "schema": { @@ -13733,128 +15005,32 @@ "name": "testId", "in": "path" }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["success", "failure", "skipped"], - "description": "Only runs that finished this way.", - "example": "failure" - }, - "required": false, - "description": "Only runs that finished this way.", - "name": "conclusion", - "in": "query" - }, { "schema": { "type": "string", "minLength": 1, - "description": "Comma-separated branch globs, e.g. `main,release-*`. `*` matches any run of characters, `?` exactly one. Cannot be combined with `pullRequestOnly`.", - "example": "main,release-*" - }, - "required": false, - "description": "Comma-separated branch globs, e.g. `main,release-*`. `*` matches any run of characters, `?` exactly one. Cannot be combined with `pullRequestOnly`.", - "name": "branches", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "description": "Only runs on pull-request branches. Cannot be combined with `branches`.", - "example": true - }, - "required": false, - "description": "Only runs on pull-request branches. Cannot be combined with `branches`.", - "name": "pullRequestOnly", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["quarantined", "notQuarantined"], - "description": "Only runs recorded while the test was in this quarantine state.", - "example": "quarantined" - }, - "required": false, - "description": "Only runs recorded while the test was in this quarantine state.", - "name": "quarantineState", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date-time", - "description": "Only runs that started at or after this time.", - "example": "2026-06-01T00:00:00.000Z" + "description": "The investigation's id.", + "example": "5a7fef9e-c8cd-4eed-8a87-9494bdf96bed" }, - "required": false, - "description": "Only runs that started at or after this time.", - "name": "startedAfter", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date-time", - "description": "Only runs that started strictly before this time.", - "example": "2026-06-30T00:00:00.000Z" - }, - "required": false, - "description": "Only runs that started strictly before this time.", - "name": "startedBefore", - "in": "query" + "required": true, + "description": "The investigation's id.", + "name": "investigationId", + "in": "path" } ], "responses": { "200": { - "description": "A page of test runs", + "description": "The investigation", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestRunList" + "$ref": "#/components/schemas/Investigation" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -13869,15 +15045,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } } } } @@ -13914,7 +15081,7 @@ } }, "404": { - "description": "`TEST_NOT_FOUND`", + "description": "`TEST_NOT_FOUND` | `INVESTIGATION_NOT_FOUND`", "content": { "application/json": { "schema": { @@ -13929,6 +15096,15 @@ "message": "Test not found" } } + }, + "INVESTIGATION_NOT_FOUND": { + "summary": "INVESTIGATION_NOT_FOUND", + "value": { + "error": { + "code": "INVESTIGATION_NOT_FOUND", + "message": "Investigation not found" + } + } } } } @@ -13979,183 +15155,49 @@ } } }, - "/v2/detection-events": { - "get": { - "operationId": "detectionEvents.list", - "tags": ["detectionEvents"], - "summary": "List detection events", - "description": "Lists each verdict a monitor recorded on a test: `active` when it detected the condition it watches for, `inactive` when the test recovered or the monitor was disabled or deleted. By default it reads every test collection in your organization; pass `testCollectionId` to narrow to one.\n\nThe window is `detectedAfter` to `detectedBefore`, at most 30 days. `detectedBefore` defaults to now and `detectedAfter` to 30 days before it. A wider window is a `400 OUT_OF_RANGE`.\n\nAn unknown `monitorId` or `testId` returns an empty page. The 'flag as flaky' mark is not listed here: see a test's status history.", + "/v2/test-collections/{id}/uploads": { + "post": { + "operationId": "testCollections.createUpload", + "tags": ["testCollections"], + "summary": "Create a test-results upload", + "description": "**Internal.** This endpoint is intended for internal use by Trunk's analytics-cli and test-runner plugins. Use it at your own risk: it may change without notice.\n\nStarts an upload of one CI run's test results to a collection. `PUT` the results bundle to the returned `uploadUrl` before `expiresAt`. A repository Trunk does not know is created when `remoteUrls` is sent. Requires the **Upload test results** permission.", "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, { "schema": { "type": "string", - "minLength": 1, - "description": "Narrow to one collection, by its id. Omitted reads every collection in your organization.", + "pattern": "^[0-9A-Za-z]{8}$", + "description": "The collection's id: 8 alphanumeric characters.", "example": "aB3xY9kQ" }, - "required": false, - "description": "Narrow to one collection, by its id. Omitted reads every collection in your organization.", - "name": "testCollectionId", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Only events this monitor recorded.", - "example": "1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d" - }, - "required": false, - "description": "Only events this monitor recorded.", - "name": "monitorId", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Comma-separated monitor types to include, e.g. `failureRate,passOnRetry`.", - "example": "failureRate,passOnRetry" - }, - "required": false, - "description": "Comma-separated monitor types to include, e.g. `failureRate,passOnRetry`.", - "name": "monitorType", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Only events on this test, by its globally unique id.", - "example": "bfeebcf4-0000-8000-8000-000000000001" - }, - "required": false, - "description": "Only events on this test, by its globally unique id.", - "name": "testId", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["active", "inactive"], - "description": "Only events with this verdict.", - "example": "active" - }, - "required": false, - "description": "Only events with this verdict.", - "name": "monitorStatus", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "description": "`true` for only the events that carry a classify action, `false` for only those that do not.", - "example": true - }, - "required": false, - "description": "`true` for only the events that carry a classify action, `false` for only those that do not.", - "name": "isClassificationEvent", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date-time", - "description": "Only events detected at or after this time.", - "example": "2026-06-01T00:00:00.000Z" - }, - "required": false, - "description": "Only events detected at or after this time.", - "name": "detectedAfter", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "date-time", - "description": "Only events detected strictly before this time.", - "example": "2026-06-30T00:00:00.000Z" - }, - "required": false, - "description": "Only events detected strictly before this time.", - "name": "detectedBefore", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["detectedAt"], - "default": "detectedAt", - "description": "Field to sort by." - }, - "required": false, - "description": "Field to sort by.", - "name": "sort", - "in": "query" - }, - { - "schema": { - "type": "string", - "enum": ["asc", "desc"], - "default": "desc", - "description": "Sort direction. Newest first by default." - }, - "required": false, - "description": "Sort direction. Newest first by default.", - "name": "direction", - "in": "query" + "required": true, + "description": "The collection's id: 8 alphanumeric characters.", + "name": "id", + "in": "path" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTestUploadRequest" + } + } + } + }, "responses": { - "200": { - "description": "A page of detection events", + "201": { + "description": "The upload, waiting for its bundle", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DetectionEventList" + "$ref": "#/components/schemas/CreatedTestUpload" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `OUT_OF_RANGE`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -14170,24 +15212,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } - }, - "OUT_OF_RANGE": { - "summary": "OUT_OF_RANGE", - "value": { - "error": { - "code": "OUT_OF_RANGE", - "message": "The requested time window is wider than the endpoint allows" - } - } } } } @@ -14244,6 +15268,27 @@ } } }, + "404": { + "description": "`TEST_COLLECTION_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "TEST_COLLECTION_NOT_FOUND": { + "summary": "TEST_COLLECTION_NOT_FOUND", + "value": { + "error": { + "code": "TEST_COLLECTION_NOT_FOUND", + "message": "Test collection not found" + } + } + } + } + } + } + }, "429": { "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", "content": { @@ -14289,43 +15334,33 @@ } } }, - "/v2/test-collections/{id}/uploads": { - "post": { - "operationId": "testCollections.createUpload", - "tags": ["testCollections"], - "summary": "Create a test-results upload", - "description": "**Internal.** This endpoint is intended for internal use by Trunk's analytics-cli and test-runner plugins. Use it at your own risk: it may change without notice.\n\nStarts an upload of one CI run's test results to a collection. `PUT` the results bundle to the returned `uploadUrl` before `expiresAt`. A repository Trunk does not know is created when `remoteUrls` is sent. Requires the **Upload test results** permission.", + "/v2/test-uploads/{id}": { + "get": { + "operationId": "testUploads.get", + "tags": ["testUploads"], + "summary": "Get a test-results upload", + "description": "Returns where an upload is (waiting for its bundle, received, ingested or skipped) and the problems ingestion found in it. Requires read access to the upload's test collection.", "parameters": [ { "schema": { "type": "string", - "pattern": "^[0-9A-Za-z]{8}$", - "description": "The collection's id: 8 alphanumeric characters.", - "example": "aB3xY9kQ" + "format": "uuid", + "description": "The upload's id, as its create returned it.", + "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }, "required": true, - "description": "The collection's id: 8 alphanumeric characters.", + "description": "The upload's id, as its create returned it.", "name": "id", "in": "path" } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTestUploadRequest" - } - } - } - }, "responses": { - "201": { - "description": "The upload, waiting for its bundle", + "200": { + "description": "The upload", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreatedTestUpload" + "$ref": "#/components/schemas/TestUpload" } } } @@ -14381,41 +15416,20 @@ } } }, - "403": { - "description": "`INSUFFICIENT_PERMISSIONS`", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "INSUFFICIENT_PERMISSIONS": { - "summary": "INSUFFICIENT_PERMISSIONS", - "value": { - "error": { - "code": "INSUFFICIENT_PERMISSIONS", - "message": "The supplied credential is missing the required permission" - } - } - } - } - } - } - }, "404": { - "description": "`TEST_COLLECTION_NOT_FOUND`", + "description": "`TEST_UPLOAD_NOT_FOUND`: no such upload in your organization, or it is more than 45 days old. A new upload also reads as not found until its record is written, usually within a second of the create, so a `404` shortly after the create means \"not visible yet\", not a failure.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "TEST_COLLECTION_NOT_FOUND": { - "summary": "TEST_COLLECTION_NOT_FOUND", + "TEST_UPLOAD_NOT_FOUND": { + "summary": "TEST_UPLOAD_NOT_FOUND", "value": { "error": { - "code": "TEST_COLLECTION_NOT_FOUND", - "message": "Test collection not found" + "code": "TEST_UPLOAD_NOT_FOUND", + "message": "Test upload not found" } } } @@ -14468,33 +15482,33 @@ } } }, - "/v2/test-uploads/{id}": { + "/v2/tests/{testId}/ticket": { "get": { - "operationId": "testUploads.get", - "tags": ["testUploads"], - "summary": "Get a test-results upload", - "description": "Returns where an upload is (waiting for its bundle, received, ingested or skipped) and the problems ingestion found in it. Requires read access to the upload's test collection.", + "operationId": "tests.getTicket", + "tags": ["tests"], + "summary": "Get a test's linked ticket", + "description": "Returns the ticket linked to a test, with its title, assignee and when Trunk last synced it. A test with no linked ticket is `404 TICKET_NOT_LINKED`.\n\nThis is the only test read that calls the ticketing provider: a ticket Trunk has not synced in the last 15 minutes is read back from its provider first. If the provider does not answer within a few seconds, the ticket is returned as Trunk last synced it, with that older `syncedAt`.\n\nA ticket the provider no longer has stays linked, with `deletedAt` set and the last `status` synced; unlink it or link another ticket to replace it.\n\nA test in another organization is reported as `404 TEST_NOT_FOUND`, not `403`, and so is a malformed id.", "parameters": [ { "schema": { "type": "string", - "format": "uuid", - "description": "The upload's id, as its create returned it.", - "example": "f47ac10b-58cc-4372-a567-0e02b2c3d479" + "minLength": 1, + "description": "The test's globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" }, "required": true, - "description": "The upload's id, as its create returned it.", - "name": "id", + "description": "The test's globally unique id.", + "name": "testId", "in": "path" } ], "responses": { "200": { - "description": "The upload", + "description": "The linked ticket", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TestUpload" + "$ref": "#/components/schemas/Ticket" } } } @@ -14551,19 +15565,28 @@ } }, "404": { - "description": "`TEST_UPLOAD_NOT_FOUND`: no such upload in your organization, or it is more than 45 days old. A new upload also reads as not found until its record is written, usually within a second of the create, so a `404` shortly after the create means \"not visible yet\", not a failure.", + "description": "`TEST_NOT_FOUND` | `TICKET_NOT_LINKED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "TEST_UPLOAD_NOT_FOUND": { - "summary": "TEST_UPLOAD_NOT_FOUND", + "TEST_NOT_FOUND": { + "summary": "TEST_NOT_FOUND", "value": { "error": { - "code": "TEST_UPLOAD_NOT_FOUND", - "message": "Test upload not found" + "code": "TEST_NOT_FOUND", + "message": "Test not found" + } + } + }, + "TICKET_NOT_LINKED": { + "summary": "TICKET_NOT_LINKED", + "value": { + "error": { + "code": "TICKET_NOT_LINKED", + "message": "No ticket is linked to this test" } } } @@ -14614,9 +15637,7 @@ } } } - } - }, - "/v2/tests/{testId}/ticket": { + }, "post": { "operationId": "tests.linkTicket", "tags": ["tests"], @@ -14883,6 +15904,175 @@ } } } + }, + "delete": { + "operationId": "tests.unlinkTicket", + "tags": ["tests"], + "summary": "Unlink a test's ticket", + "description": "Removes the link between a test and its ticket. The ticket itself is not changed or closed in its provider. A test with no linked ticket is refused with `404 TICKET_NOT_LINKED`, so a retry after a successful unlink answers that rather than `204`.\n\nA test in another organization is reported as `404 TEST_NOT_FOUND`, not `403`, and so is a malformed id.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "The test's globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" + }, + "required": true, + "description": "The test's globally unique id.", + "name": "testId", + "in": "path" + } + ], + "responses": { + "204": { + "description": "The ticket was unlinked" + }, + "400": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + } + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, + "404": { + "description": "`TEST_NOT_FOUND` | `TICKET_NOT_LINKED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "TEST_NOT_FOUND": { + "summary": "TEST_NOT_FOUND", + "value": { + "error": { + "code": "TEST_NOT_FOUND", + "message": "Test not found" + } + } + }, + "TICKET_NOT_LINKED": { + "summary": "TICKET_NOT_LINKED", + "value": { + "error": { + "code": "TICKET_NOT_LINKED", + "message": "No ticket is linked to this test" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + } + } } }, "/v2/tests/{testId}/create-ticket": { From 882067d33fc62575da88e7039ce60b6becd4e1ed Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Wed, 4 Nov 2026 14:58:35 +0000 Subject: [PATCH 4/5] docs(merge-queue): where a chosen merge method shows; refusals at merge; re-sync the v2 spec Co-Authored-By: Claude Opus 5.5 --- .../administration/advanced-settings.mdx | 4 + openapi-v2.json | 1254 +++++++++++++++-- 2 files changed, 1106 insertions(+), 152 deletions(-) diff --git a/merge-queue/administration/advanced-settings.mdx b/merge-queue/administration/advanced-settings.mdx index c81f443a..bc91d2cd 100644 --- a/merge-queue/administration/advanced-settings.mdx +++ b/merge-queue/administration/advanced-settings.mdx @@ -153,12 +153,16 @@ When the setting is on, you choose a method as you submit the PR: Submitting with a label or the merge checkbox always uses the queue's method. +A chosen method shows on the submit button (for example, **Submit to merge queue · Rebase**) and as a **Merge method** badge on the PR's page in Trunk. While the setting is on, Trunk's comment on the PR says how it will merge the PR, for example "Trunk will merge this pull request by rebasing its commits (chosen for it)." + Trunk refuses the method, and says why, when: * The setting is off. * The PR is already being merged and you choose a different method. * The branch's GitHub rulesets don't allow that method. +If the repository refuses the method when Trunk merges the PR, Trunk removes the PR from the queue and says why. + If you turn the setting off, every PR merges with the queue's method, including PRs that chose their own. If you turn it back on, those choices apply again. When Trunk merges a [stack](../using-the-queue/stacked-pull-requests), the method chosen on the PR you enqueued applies to it and to every PR below it that merges with it. diff --git a/openapi-v2.json b/openapi-v2.json index e821e97f..5a3840dd 100644 --- a/openapi-v2.json +++ b/openapi-v2.json @@ -488,6 +488,14 @@ "PR_TESTS_NOT_RESTARTABLE", "ALLOWED_BOT_SUBMITTERS_FROZEN", "ESTIMATE_NOT_ENABLED", + "PR_RERUN_FEATURE_DISABLED", + "PR_RERUN_NOT_PENDING_FAILURE", + "PR_RERUN_QUEUE_DOES_NOT_KEEP_FAILED_RUNS", + "PR_RERUN_NOT_A_REQUIRED_CHECK_FAILURE", + "PR_RERUN_CHECK_NOT_RERUNNABLE", + "PR_RERUN_RUN_TORN_DOWN", + "PR_RERUN_HOLD_LIMIT_REACHED", + "PR_RERUN_FORGE_NOT_SUPPORTED", "PROVIDER_URL_NOT_FOUND", "CI_HOST_UNSUPPORTED", "CI_SCOPE_NOT_FOUND", @@ -498,6 +506,7 @@ "TEST_UPLOAD_NOT_FOUND", "INVESTIGATION_NOT_FOUND", "OUT_OF_RANGE", + "TEST_UNAVAILABLE", "TICKETING_NOT_CONFIGURED", "TICKETING_CREDENTIALS_INVALID", "TICKETING_SITE_REFUSED", @@ -2090,7 +2099,7 @@ }, "reason": { "type": ["string", "null"], - "description": "Why the entry entered this state. `null` for `testing` and `pendingFailure`, which carry no reason.\n\nThe values below are the vocabulary in use today, grouped by the state they accompany. **This is an open set** — it is the engine's own vocabulary and new values may appear within v2, so match on the values you care about and treat anything else as unclassified rather than failing closed.\n\n**Why an entry is waiting (`notReady`)** — `USER_REQUESTED`, `TRUNK_ADMIN_REQUESTED`, `API_REQUESTED`, `PR_PUSHED_TO` (a push to the pull request sent it back to wait on the new commit).\n\n**Why an entry (re)entered the line (`pending`)** — these are what distinguish a restart's cause:\n\n| Value | Meaning |\n| --- | --- |\n| `TESTS_RESTARTED_BY_USER` | A person restarted the tests. |\n| `TESTS_RESTARTED_BY_API_REQUEST` | Restarted through the API — e.g. `pullRequests.restartTests`. |\n| `MERGE_ITEM_NOW_READY` | A readiness condition became satisfied. |\n| `MANUAL_PUSH_TO_TARGET_BRANCH` | Someone pushed to the target branch, invalidating the tested state. |\n| `PREREQUISITE_MERGE_ITEM_FAILED` | An entry ahead of this one failed, so this one must re-test. |\n| `PREREQUISITE_MERGE_ITEM_CANCELLED` | An entry ahead was removed. |\n| `PREREQUISITE_MERGE_ITEM_MANUALLY_MERGED` | An entry ahead was merged outside the queue. |\n| `SKIP_THE_LINE_INTERRUPTED` | A higher-priority entry preempted this one. |\n| `BISECTION_REQUIRED` | A batch failed and the engine is bisecting to find the culprit. |\n| `BISECTION_TEST_RUN_PASSED` | A bisection run cleared this entry. |\n\n**Why verification passed (`testsPassed`)** — `TEST_RUN_PASSED`, `SUCCESSOR_TEST_RUN_PASSED`, `PASSED_BY_OPTIMIZATION`.\n\n**Why an entry failed (`failed`)** — `TEST_RUN_REQUIRED_STATUS_FAILED`, `TEST_RUN_TIMEOUT`, `TEST_RUN_FAILED_BY_OPTIMIZATION`, `START_TEST_RUN_MERGE_CONFLICT`, `START_TEST_RUN_RETRY_EXHAUSTED`, `START_TEST_RUN_DRAFT_PRS_NOT_ALLOWED`, `START_TEST_RUN_PERMISSIONS_BLOCK_TEST_BRANCH`, `START_TEST_RUN_NO_REQUIRED_STATUSES`, `START_TEST_RUN_CONFIG_NOT_FOUND`, `START_TEST_RUN_CONFIG_PARSING_FAILURE`, `START_TEST_RUN_CONFIG_BAD_VERSION`, `START_TEST_RUN_CONFIG_BAD_REQUIRED_STATUSES`, `START_TEST_RUN_CONFIG_MISSING_REQUIRED_STATUSES`, `START_TEST_RUN_INVALID_EVENT`, `PR_UPDATED_AT_MERGE_TIME`, `GITHUB_API_MERGE_PR_FAILED`, `GITHUB_API_DIRECT_MERGE_PR_FAILED`, `GITHUB_API_BRANCH_PROTECTION_UNMET`, `GITHUB_API_MERGE_ALREADY_IN_PROGRESS`, `GITHUB_API_RATE_LIMIT_EXCEEDED`, `GITHUB_API_REPEATED_SERVER_ERRORS`.\n\n**Why an entry was removed (`cancelled`)** — `USER_REQUESTED`, `TRUNK_ADMIN_REQUESTED`, `PR_CLOSED`, `PR_MERGED_INTO_OTHER_BRANCH`, `PR_PUSHED_TO` (older entries only; a push now returns an entry to `notReady`), `PR_BASE_BRANCH_CHANGED`, `PR_CONVERTED_TO_DRAFT`, `NOT_READY_TIMEOUT_EXPIRED`, `STACKED_PR_MEMBER_PUSHED_TO`, `STACKED_PR_MEMBER_CLOSED`, `STACKED_PR_MEMBER_MERGED`, `STACKED_PR_MEMBER_BASE_BRANCH_CHANGED`, `STACKED_PR_STACK_CHANGED`, `STACKED_PR_GROUP_CANCELLED_AT_MERGE_TIME`.\n\n**Why an entry merged (`merged`)** — `MERGE_GRAPH_MERGE`, `MERGE_GRAPH_DIRECT_MERGE`, `MANUAL_MERGE`.", + "description": "Why the entry entered this state. `null` for `testing` and `pendingFailure`, which carry no reason.\n\nThe values below are the vocabulary in use today, grouped by the state they accompany. **This is an open set** — it is the engine's own vocabulary and new values may appear within v2, so match on the values you care about and treat anything else as unclassified rather than failing closed.\n\n**Why an entry is waiting (`notReady`)** — `USER_REQUESTED`, `TRUNK_ADMIN_REQUESTED`, `API_REQUESTED`, `PR_PUSHED_TO` (a push to the pull request sent it back to wait on the new commit).\n\n**Why an entry (re)entered the line (`pending`)** — these are what distinguish a restart's cause:\n\n| Value | Meaning |\n| --- | --- |\n| `TESTS_RESTARTED_BY_USER` | A person restarted the tests. |\n| `TESTS_RESTARTED_BY_API_REQUEST` | Restarted through the API — e.g. `pullRequests.restartTests`. |\n| `MERGE_ITEM_NOW_READY` | A readiness condition became satisfied. |\n| `MANUAL_PUSH_TO_TARGET_BRANCH` | Someone pushed to the target branch, invalidating the tested state. |\n| `PREREQUISITE_MERGE_ITEM_FAILED` | An entry ahead of this one failed, so this one must re-test. |\n| `PREREQUISITE_MERGE_ITEM_CANCELLED` | An entry ahead was removed. |\n| `PREREQUISITE_MERGE_ITEM_MANUALLY_MERGED` | An entry ahead was merged outside the queue. |\n| `SKIP_THE_LINE_INTERRUPTED` | A higher-priority entry preempted this one. |\n| `BISECTION_REQUIRED` | A batch failed and the engine is bisecting to find the culprit. |\n| `BISECTION_TEST_RUN_PASSED` | A bisection run cleared this entry. |\n\n**Why verification passed (`testsPassed`)** — `TEST_RUN_PASSED`, `SUCCESSOR_TEST_RUN_PASSED`, `PASSED_BY_OPTIMIZATION`.\n\n**Why an entry failed (`failed`)** — `TEST_RUN_REQUIRED_STATUS_FAILED`, `TEST_RUN_TIMEOUT`, `TEST_RUN_FAILED_BY_OPTIMIZATION`, `START_TEST_RUN_MERGE_CONFLICT`, `START_TEST_RUN_RETRY_EXHAUSTED`, `START_TEST_RUN_DRAFT_PRS_NOT_ALLOWED`, `START_TEST_RUN_PERMISSIONS_BLOCK_TEST_BRANCH`, `START_TEST_RUN_NO_REQUIRED_STATUSES`, `START_TEST_RUN_CONFIG_NOT_FOUND`, `START_TEST_RUN_CONFIG_PARSING_FAILURE`, `START_TEST_RUN_CONFIG_BAD_VERSION`, `START_TEST_RUN_CONFIG_BAD_REQUIRED_STATUSES`, `START_TEST_RUN_CONFIG_MISSING_REQUIRED_STATUSES`, `START_TEST_RUN_INVALID_EVENT`, `PR_UPDATED_AT_MERGE_TIME`, `GITHUB_API_MERGE_PR_FAILED`, `GITHUB_API_DIRECT_MERGE_PR_FAILED`, `GITHUB_API_BRANCH_PROTECTION_UNMET`, `GITHUB_API_MERGE_ALREADY_IN_PROGRESS`, `GITHUB_API_RATE_LIMIT_EXCEEDED`, `GITHUB_API_REPEATED_SERVER_ERRORS`, `MERGE_METHOD_NOT_ALLOWED`.\n\n**Why an entry was removed (`cancelled`)** — `USER_REQUESTED`, `TRUNK_ADMIN_REQUESTED`, `PR_CLOSED`, `PR_MERGED_INTO_OTHER_BRANCH`, `PR_PUSHED_TO` (older entries only; a push now returns an entry to `notReady`), `PR_BASE_BRANCH_CHANGED`, `PR_CONVERTED_TO_DRAFT`, `NOT_READY_TIMEOUT_EXPIRED`, `STACKED_PR_MEMBER_PUSHED_TO`, `STACKED_PR_MEMBER_CLOSED`, `STACKED_PR_MEMBER_MERGED`, `STACKED_PR_MEMBER_BASE_BRANCH_CHANGED`, `STACKED_PR_STACK_CHANGED`, `STACKED_PR_GROUP_CANCELLED_AT_MERGE_TIME`.\n\n**Why an entry merged (`merged`)** — `MERGE_GRAPH_MERGE`, `MERGE_GRAPH_DIRECT_MERGE`, `MANUAL_MERGE`.", "example": "TEST_RUN_REQUIRED_STATUS_FAILED" }, "verificationRunId": { @@ -2302,6 +2311,9 @@ }, "maxItems": 10000, "description": "The required checks that failed. Populated when `eventualFailureReason` is `TEST_RUN_REQUIRED_STATUS_FAILED`." + }, + "rerun": { + "$ref": "#/components/schemas/PendingFailureRerun" } }, "required": ["waitingFor", "failureCausingChecks"], @@ -2329,6 +2341,79 @@ "required": ["name"], "description": "A required check whose failure failed a verification run. Names the check directly, so a consumer need not fetch the run to learn what broke." }, + "PendingFailureRerun": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time", + "description": "When the queue stops waiting for the re-run to start.", + "example": "2026-07-28T11:40:00.000Z" + }, + "requestedAt": { + "type": "string", + "format": "date-time", + "description": "When the re-run was requested.", + "example": "2026-07-28T11:10:00.000Z" + }, + "requestedBy": { + "$ref": "#/components/schemas/TransitionActor" + }, + "via": { + "$ref": "#/components/schemas/RerunVia" + }, + "mode": { + "$ref": "#/components/schemas/RerunMode" + }, + "switchedToNowAt": { + "type": "string", + "format": "date-time", + "description": "When a `wait` request was switched to `now`. Omitted when it was not.", + "example": "2026-07-28T11:15:00.000Z" + }, + "switchedToNowBy": { + "$ref": "#/components/schemas/TransitionActor" + }, + "checks": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 10000, + "description": "The failed required checks the request re-runs.", + "example": ["build"] + }, + "ending": { + "$ref": "#/components/schemas/RerunHoldEnding" + } + }, + "required": ["until", "requestedAt", "checks"], + "description": "A re-run of the failed checks, requested while the entry was in `pendingFailure`. `requestedBy` is omitted when no user made the request (an organization API token); `via` and `mode` are omitted when the recorded value is not one this version knows; `ending` is omitted while the queue is still waiting." + }, + "RerunVia": { + "type": "string", + "enum": ["web", "api"], + "description": "Where the re-run was requested: the Trunk web app or the API.", + "example": "api" + }, + "RerunMode": { + "type": "string", + "enum": ["now", "wait"], + "description": "`now`: stop the jobs still running in the failed checks' workflows and re-run them with the failed jobs. `wait`: re-run the failed jobs once those workflows finish.", + "example": "now" + }, + "RerunHoldEnding": { + "type": "string", + "enum": [ + "rerunStarted", + "expiredCiRunning", + "expiredNotStarted", + "forgeRefused", + "superseded" + ], + "description": "How the wait for the re-run ended. `rerunStarted`: the re-run began testing. `expiredCiRunning`: the time limit passed while the workflow was still running, so the re-run was never sent. `expiredNotStarted`: the re-run was sent but did not start in time. `forgeRefused`: the provider refused the re-run. `superseded`: something else moved the entry on first, such as a restart, a cancel or the entry passing.", + "example": "rerunStarted" + }, "FailedTransitionDetails": { "type": "object", "properties": { @@ -2724,6 +2809,65 @@ "required": ["priorityValue", "priorityName"] } }, + "RerunFailedChecksResult": { + "type": "object", + "properties": { + "pullRequest": { + "$ref": "#/components/schemas/PullRequest" + }, + "rerun": { + "$ref": "#/components/schemas/RerunHeld" + } + }, + "required": ["pullRequest", "rerun"], + "description": "The pull request after the request, and the re-run it is waiting for." + }, + "RerunHeld": { + "type": "object", + "properties": { + "until": { + "type": "string", + "format": "date-time", + "description": "When the queue stops waiting for the re-run to start. If it has not started by then, the pull request fails.", + "example": "2026-07-28T11:40:00.000Z" + }, + "alreadyHeld": { + "type": "boolean", + "description": "True when an earlier request for these checks was already waiting, so this request changed nothing.", + "example": false + }, + "switchedToNow": { + "type": "boolean", + "description": "True when this request switched a waiting `wait` request to `now`.", + "example": false + }, + "checks": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 10000, + "description": "The failed required checks being re-run.", + "example": ["build"] + }, + "requestedBy": { + "$ref": "#/components/schemas/TransitionActor" + } + }, + "required": ["until", "alreadyHeld", "switchedToNow", "checks"], + "description": "The accepted re-run request. `requestedBy` names the earlier requester when `alreadyHeld` is true and a user made that request." + }, + "RerunFailedChecksRequest": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/components/schemas/RerunMode" + } + }, + "required": ["mode"], + "additionalProperties": false, + "description": "`mode` is required: `now` stops the jobs still running in the failed checks' workflows and re-runs them with the failed jobs, and `wait` re-runs the failed jobs once those workflows finish. `now` on a request that is waiting switches it to `now`. Send the body as `application/json`; a missing body, or one without `mode`, is a `400`." + }, "CiPlan": { "type": "object", "properties": { @@ -5177,6 +5321,154 @@ "description": "`invalid`: Trunk could not use the part of the bundle the defect names (`type`, `source`), and ingested the rest. `suboptimal`: that part was used, with less information than it could have had.", "example": "suboptimal" }, + "SetQuarantineRequest": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/components/schemas/SetQuarantineMode" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Why the override was set, shown with it in the web app and on the test's `quarantineOverride`.", + "example": "Fails only on the Windows shard; tracked in TRUNK-1234." + } + }, + "required": ["mode"], + "additionalProperties": false, + "description": "The quarantine override to set on one test." + }, + "SetQuarantineMode": { + "type": "string", + "enum": ["alwaysQuarantine", "neverQuarantine", "clear"], + "description": "`alwaysQuarantine` quarantines the test whatever its status. `neverQuarantine` keeps it out of quarantine whatever its status. `clear` removes the override, so the collection's quarantine settings and the test's status decide again.", + "example": "neverQuarantine" + }, + "BulkResult": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BulkResultItem" + }, + "maxItems": 100, + "description": "One entry per requested item, in request order." + }, + "succeededCount": { + "type": "integer", + "example": 2 + }, + "unchangedCount": { + "type": "integer", + "example": 1 + }, + "failedCount": { + "type": "integer", + "example": 0 + } + }, + "required": [ + "results", + "succeededCount", + "unchangedCount", + "failedCount" + ], + "description": "The outcome of each item of a many-form request. A request that is refused as a whole answers a coded error instead, never a `BulkResult`." + }, + "BulkResultItem": { + "type": "object", + "properties": { + "testId": { + "type": "string", + "example": "bfeebcf4-0000-8000-8000-000000000001" + }, + "status": { + "$ref": "#/components/schemas/BulkItemStatus" + }, + "quarantineOverride": { + "anyOf": [ + { + "$ref": "#/components/schemas/QuarantineOverride" + }, + { + "type": "null" + } + ], + "description": "Quarantine: the test's override after the call, null when none is in force. Absent on a failed item." + }, + "error": { + "$ref": "#/components/schemas/BulkItemError" + } + }, + "required": ["testId", "status"] + }, + "BulkItemStatus": { + "type": "string", + "enum": ["succeeded", "unchanged", "failed"], + "description": "`succeeded` changed the test. `unchanged` found it already in the requested state and wrote nothing. `failed` carries an `error` and changed nothing that stayed.", + "example": "succeeded" + }, + "BulkItemError": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "TEST_NOT_FOUND" + }, + "message": { + "type": "string", + "example": "No test found with ID bfeebcf4-0000-8000-8000-000000000001" + }, + "param": { + "type": "string", + "description": "The request field the error is about.", + "example": "items[2].testId" + } + }, + "required": ["code", "message", "param"] + }, + "SetQuarantineManyRequest": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Why the override was set, shown with it in the web app and on the test's `quarantineOverride`.", + "example": "Fails only on the Windows shard; tracked in TRUNK-1234." + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SetQuarantineItem" + }, + "minItems": 1, + "maxItems": 100, + "description": "The tests to change, at most 100, each id once." + } + }, + "required": ["items"], + "additionalProperties": false, + "description": "Quarantine overrides to set on many tests, with one `reason` for all of them." + }, + "SetQuarantineItem": { + "type": "object", + "properties": { + "testId": { + "type": "string", + "minLength": 1, + "description": "The test's globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" + }, + "mode": { + "$ref": "#/components/schemas/SetQuarantineMode" + } + }, + "required": ["testId", "mode"], + "additionalProperties": false + }, "Ticket": { "type": "object", "properties": { @@ -11535,35 +11827,49 @@ } } }, - "/v2/dynamic-ci/generate-plan": { + "/v2/pull-requests/{id}/rerun-failed-checks": { "post": { - "operationId": "dynamicCi.generatePlan", - "tags": ["dynamicCi"], - "summary": "Generate a CI skip plan", - "description": "Returns a per-job verdict for one workflow run: which jobs the change makes it safe to skip, and the evidence behind each decision. Normally called once per run by the [`trunk-io/dynamic-ci`](https://github.com/trunk-io/dynamic-ci) action, which maps the verdicts onto job outputs.\n\n**Fail open.** A job absent from `jobs` must be run — that is the contract, not an error. Treat any non-200 the same way.\n\nSend the changed files as `changedFiles` when you can compute them. Without it, Trunk fetches the diff between `baseSha` and `commitSha` through your GitHub App installation, which needs both commits on GitHub.", + "operationId": "pullRequests.rerunFailedChecks", + "tags": ["pullRequests"], + "summary": "Re-run a pull request's failed checks", + "description": "Requires the **Merge and cancel PRs** permission. Re-runs the failed jobs of the GitHub Actions workflows behind the failed required checks, and keeps the pull request's place in the queue: it stays in `pendingFailure` until the re-run starts or `rerun.until` passes, whichever is first. If the re-run has not started by then, the pull request fails as it would have without the request. Only possible while the entry is in `pendingFailure` on a queue that keeps failed test runs; every refusal is a `422` with its own code.\n\n`mode` is required. **Disruptive** with `mode: now`: it stops the jobs still running in the failed checks' workflows. `mode: wait` lets them finish first. `now` on a request that is waiting switches it to `now` (`rerun.switchedToNow`). `Idempotency-Key` is not honored, but a repeat with the same `mode` is safe: it answers `rerun.alreadyHeld` and changes nothing.", + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "description": "The pull request's id.", + "example": "0f8fad5b-d9cb-469f-a165-70867728950e" + }, + "required": true, + "description": "The pull request's id.", + "name": "id", + "in": "path" + } + ], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CiPlanRequest" + "$ref": "#/components/schemas/RerunFailedChecksRequest" } } } }, "responses": { "200": { - "description": "The plan", + "description": "The pull request, and the re-run it is waiting for", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CiPlan" + "$ref": "#/components/schemas/RerunFailedChecksResult" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `CI_HOST_UNSUPPORTED`", + "description": "`VALIDATION_FAILED`", "content": { "application/json": { "schema": { @@ -11578,15 +11884,6 @@ "message": "Invalid input: expected string, received undefined" } } - }, - "CI_HOST_UNSUPPORTED": { - "summary": "CI_HOST_UNSUPPORTED", - "value": { - "error": { - "code": "CI_HOST_UNSUPPORTED", - "message": "Dynamic CI does not support the repository host gitlab.example.com" - } - } } } } @@ -11622,20 +11919,20 @@ } } }, - "404": { - "description": "`REPOSITORY_NOT_FOUND`", + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "REPOSITORY_NOT_FOUND": { - "summary": "REPOSITORY_NOT_FOUND", + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", "value": { "error": { - "code": "REPOSITORY_NOT_FOUND", - "message": "Repository not found" + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" } } } @@ -11643,20 +11940,29 @@ } } }, - "429": { - "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "404": { + "description": "`PR_NOT_FOUND` | `MERGE_QUEUE_NOT_FOUND`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "RATE_LIMIT_EXCEEDED": { - "summary": "RATE_LIMIT_EXCEEDED", + "PR_NOT_FOUND": { + "summary": "PR_NOT_FOUND", "value": { "error": { - "code": "RATE_LIMIT_EXCEEDED", - "message": "Rate limit exceeded; retry with backoff" + "code": "PR_NOT_FOUND", + "message": "Pull request not found" + } + } + }, + "MERGE_QUEUE_NOT_FOUND": { + "summary": "MERGE_QUEUE_NOT_FOUND", + "value": { + "error": { + "code": "MERGE_QUEUE_NOT_FOUND", + "message": "Merge queue not found" } } } @@ -11664,20 +11970,20 @@ } } }, - "500": { - "description": "`INTERNAL_ERROR`", + "409": { + "description": "`PR_NOT_IN_ACTIVE_STATE`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "INTERNAL_ERROR": { - "summary": "INTERNAL_ERROR", + "PR_NOT_IN_ACTIVE_STATE": { + "summary": "PR_NOT_IN_ACTIVE_STATE", "value": { "error": { - "code": "INTERNAL_ERROR", - "message": "An unexpected error occurred" + "code": "PR_NOT_IN_ACTIVE_STATE", + "message": "This pull request is not in an active queue state" } } } @@ -11685,20 +11991,146 @@ } } }, - "502": { - "description": "`UPSTREAM_ERROR`", + "422": { + "description": "`PR_RERUN_FEATURE_DISABLED` | `PR_RERUN_NOT_PENDING_FAILURE` | `PR_RERUN_QUEUE_DOES_NOT_KEEP_FAILED_RUNS` | `PR_RERUN_NOT_A_REQUIRED_CHECK_FAILURE` | `PR_RERUN_CHECK_NOT_RERUNNABLE` | `PR_RERUN_RUN_TORN_DOWN` | `PR_RERUN_HOLD_LIMIT_REACHED` | `PR_RERUN_FORGE_NOT_SUPPORTED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UPSTREAM_ERROR": { - "summary": "UPSTREAM_ERROR", + "PR_RERUN_FEATURE_DISABLED": { + "summary": "PR_RERUN_FEATURE_DISABLED", "value": { "error": { - "code": "UPSTREAM_ERROR", - "message": "An upstream service did not respond" + "code": "PR_RERUN_FEATURE_DISABLED", + "message": "You cannot re-run failed checks from Trunk on this queue." + } + } + }, + "PR_RERUN_NOT_PENDING_FAILURE": { + "summary": "PR_RERUN_NOT_PENDING_FAILURE", + "value": { + "error": { + "code": "PR_RERUN_NOT_PENDING_FAILURE", + "message": "You cannot re-run checks for this pull request now." + } + } + }, + "PR_RERUN_QUEUE_DOES_NOT_KEEP_FAILED_RUNS": { + "summary": "PR_RERUN_QUEUE_DOES_NOT_KEEP_FAILED_RUNS", + "value": { + "error": { + "code": "PR_RERUN_QUEUE_DOES_NOT_KEEP_FAILED_RUNS", + "message": "This queue does not allow re-runs. A queue admin can turn on \"Accept Manual Re-runs of Failed Required Checks\"." + } + } + }, + "PR_RERUN_NOT_A_REQUIRED_CHECK_FAILURE": { + "summary": "PR_RERUN_NOT_A_REQUIRED_CHECK_FAILURE", + "value": { + "error": { + "code": "PR_RERUN_NOT_A_REQUIRED_CHECK_FAILURE", + "message": "No required check failed. Use Restart tests." + } + } + }, + "PR_RERUN_CHECK_NOT_RERUNNABLE": { + "summary": "PR_RERUN_CHECK_NOT_RERUNNABLE", + "value": { + "error": { + "code": "PR_RERUN_CHECK_NOT_RERUNNABLE", + "message": "Trunk can re-run only GitHub Actions checks. Re-run `buildkite/ci` in its own CI, or use Restart tests." + } + } + }, + "PR_RERUN_RUN_TORN_DOWN": { + "summary": "PR_RERUN_RUN_TORN_DOWN", + "value": { + "error": { + "code": "PR_RERUN_RUN_TORN_DOWN", + "message": "These checks cannot be re-run now. Use Restart tests." + } + } + }, + "PR_RERUN_HOLD_LIMIT_REACHED": { + "summary": "PR_RERUN_HOLD_LIMIT_REACHED", + "value": { + "error": { + "code": "PR_RERUN_HOLD_LIMIT_REACHED", + "message": "These checks were already re-run two times. Use Restart tests." + } + } + }, + "PR_RERUN_FORGE_NOT_SUPPORTED": { + "summary": "PR_RERUN_FORGE_NOT_SUPPORTED", + "value": { + "error": { + "code": "PR_RERUN_FORGE_NOT_SUPPORTED", + "message": "Trunk can re-run checks only for GitHub repositories. Use Restart tests." + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" } } } @@ -11709,18 +12141,18 @@ } } }, - "/v2/dynamic-ci/generate-buildkite-plan": { + "/v2/dynamic-ci/generate-plan": { "post": { - "operationId": "dynamicCi.generateBuildkitePlan", + "operationId": "dynamicCi.generatePlan", "tags": ["dynamicCi"], - "summary": "Generate a CI skip plan for a Buildkite pipeline", - "description": "As [`dynamicCi.generatePlan`](#tag/dynamicCi/operation/dynamicCi.generatePlan), for a Buildkite pipeline. Identical request and response but for the workflow identity: Buildkite has no workflow file, so the pipeline is named by the organization and pipeline slugs its agent exports, and `jobKeys` are step `key:` values.\n\n**Fail open.** A job absent from `jobs` must be run — that is the contract, not an error. Treat any non-200 the same way.\n\nSend the changed files as `changedFiles` when you can compute them. Without it, Trunk fetches the diff between `baseSha` and `commitSha` through your GitHub App installation, which needs both commits on GitHub.", + "summary": "Generate a CI skip plan", + "description": "Returns a per-job verdict for one workflow run: which jobs the change makes it safe to skip, and the evidence behind each decision. Normally called once per run by the [`trunk-io/dynamic-ci`](https://github.com/trunk-io/dynamic-ci) action, which maps the verdicts onto job outputs.\n\n**Fail open.** A job absent from `jobs` must be run — that is the contract, not an error. Treat any non-200 the same way.\n\nSend the changed files as `changedFiles` when you can compute them. Without it, Trunk fetches the diff between `baseSha` and `commitSha` through your GitHub App installation, which needs both commits on GitHub.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BuildkitePlanRequest" + "$ref": "#/components/schemas/CiPlanRequest" } } } @@ -11883,96 +12315,35 @@ } } }, - "/v2/dynamic-ci/pr-jobs": { - "get": { - "operationId": "dynamicCi.listPrJobs", + "/v2/dynamic-ci/generate-buildkite-plan": { + "post": { + "operationId": "dynamicCi.generateBuildkitePlan", "tags": ["dynamicCi"], - "summary": "List the jobs Dynamic CI sees", - "description": "The workflows that have run on a pull request in this repository recently, with the jobs each defines. Use it to find the `workflowId` and `jobId` to force with `dynamicCi.setForceOverrides`.\n\n`jobKey` is `null` for a job whose key Trunk has not resolved — it can never be skipped, and is listed so that is visible rather than silently missing.", - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25, - "description": "Page size, between 1 and 100.", - "example": 25 - }, - "required": false, - "description": "Page size, between 1 and 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "example": "AQEKZm9vYmFy" - }, - "required": false, - "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", - "name": "cursor", - "in": "query" - }, - { - "schema": { - "type": "boolean", - "default": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "example": false - }, - "required": false, - "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", - "name": "includeTotal", - "in": "query" - }, - { - "schema": { - "type": "string", - "default": "github.com", - "description": "`github.com` is the only supported value today.", - "example": "github.com" - }, - "required": false, - "description": "`github.com` is the only supported value today.", - "name": "host", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "example": "acme" - }, - "required": true, - "name": "owner", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "example": "widgets" - }, - "required": true, - "name": "name", - "in": "query" + "summary": "Generate a CI skip plan for a Buildkite pipeline", + "description": "As [`dynamicCi.generatePlan`](#tag/dynamicCi/operation/dynamicCi.generatePlan), for a Buildkite pipeline. Identical request and response but for the workflow identity: Buildkite has no workflow file, so the pipeline is named by the organization and pipeline slugs its agent exports, and `jobKeys` are step `key:` values.\n\n**Fail open.** A job absent from `jobs` must be run — that is the contract, not an error. Treat any non-200 the same way.\n\nSend the changed files as `changedFiles` when you can compute them. Without it, Trunk fetches the diff between `baseSha` and `commitSha` through your GitHub App installation, which needs both commits on GitHub.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BuildkitePlanRequest" + } + } } - ], + }, "responses": { "200": { - "description": "A page of workflows", + "description": "The plan", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CiWorkflowList" + "$ref": "#/components/schemas/CiPlan" } } } }, "400": { - "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `CI_HOST_UNSUPPORTED`", + "description": "`VALIDATION_FAILED` | `CI_HOST_UNSUPPORTED`", "content": { "application/json": { "schema": { @@ -11988,15 +12359,6 @@ } } }, - "INVALID_CURSOR": { - "summary": "INVALID_CURSOR", - "value": { - "error": { - "code": "INVALID_CURSOR", - "message": "Invalid or expired cursor." - } - } - }, "CI_HOST_UNSUPPORTED": { "summary": "CI_HOST_UNSUPPORTED", "value": { @@ -12102,16 +12464,37 @@ } } } + }, + "502": { + "description": "`UPSTREAM_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "UPSTREAM_ERROR": { + "summary": "UPSTREAM_ERROR", + "value": { + "error": { + "code": "UPSTREAM_ERROR", + "message": "An upstream service did not respond" + } + } + } + } + } + } } } } }, - "/v2/dynamic-ci/force-overrides": { + "/v2/dynamic-ci/pr-jobs": { "get": { - "operationId": "dynamicCi.listForceOverrides", + "operationId": "dynamicCi.listPrJobs", "tags": ["dynamicCi"], - "summary": "List a pull request's force overrides", - "description": "The forces currently active on a pull request. A force makes Dynamic CI run the jobs it covers whatever the signals say. `workflowId` and `jobId` are the ids `dynamicCi.listPrJobs` returns; revoked forces are retained for audit but not listed.", + "summary": "List the jobs Dynamic CI sees", + "description": "The workflows that have run on a pull request in this repository recently, with the jobs each defines. Use it to find the `workflowId` and `jobId` to force with `dynamicCi.setForceOverrides`.\n\n`jobKey` is `null` for a job whose key Trunk has not resolved — it can never be skipped, and is listed so that is visible rather than silently missing.", "parameters": [ { "schema": { @@ -12181,25 +12564,15 @@ "required": true, "name": "name", "in": "query" - }, - { - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "example": 4213 - }, - "required": true, - "name": "prNumber", - "in": "query" } ], "responses": { "200": { - "description": "A page of active forces", + "description": "A page of workflows", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ForceOverrideList" + "$ref": "#/components/schemas/CiWorkflowList" } } } @@ -12337,7 +12710,240 @@ } } } - }, + } + }, + "/v2/dynamic-ci/force-overrides": { + "get": { + "operationId": "dynamicCi.listForceOverrides", + "tags": ["dynamicCi"], + "summary": "List a pull request's force overrides", + "description": "The forces currently active on a pull request. A force makes Dynamic CI run the jobs it covers whatever the signals say. `workflowId` and `jobId` are the ids `dynamicCi.listPrJobs` returns; revoked forces are retained for audit but not listed.", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25, + "description": "Page size, between 1 and 100.", + "example": 25 + }, + "required": false, + "description": "Page size, between 1 and 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "example": "AQEKZm9vYmFy" + }, + "required": false, + "description": "Opaque cursor from a previous response's `nextCursor`. A cursor is bound to the operation and filter it was minted for.", + "name": "cursor", + "in": "query" + }, + { + "schema": { + "type": "boolean", + "default": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "example": false + }, + "required": false, + "description": "Set true to include `total` in the response. Costs an extra COUNT query, so it is off by default.", + "name": "includeTotal", + "in": "query" + }, + { + "schema": { + "type": "string", + "default": "github.com", + "description": "`github.com` is the only supported value today.", + "example": "github.com" + }, + "required": false, + "description": "`github.com` is the only supported value today.", + "name": "host", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "example": "acme" + }, + "required": true, + "name": "owner", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "example": "widgets" + }, + "required": true, + "name": "name", + "in": "query" + }, + { + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "example": 4213 + }, + "required": true, + "name": "prNumber", + "in": "query" + } + ], + "responses": { + "200": { + "description": "A page of active forces", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForceOverrideList" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED` | `INVALID_CURSOR` | `CI_HOST_UNSUPPORTED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + }, + "INVALID_CURSOR": { + "summary": "INVALID_CURSOR", + "value": { + "error": { + "code": "INVALID_CURSOR", + "message": "Invalid or expired cursor." + } + } + }, + "CI_HOST_UNSUPPORTED": { + "summary": "CI_HOST_UNSUPPORTED", + "value": { + "error": { + "code": "CI_HOST_UNSUPPORTED", + "message": "Dynamic CI does not support the repository host gitlab.example.com" + } + } + } + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "404": { + "description": "`REPOSITORY_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "REPOSITORY_NOT_FOUND": { + "summary": "REPOSITORY_NOT_FOUND", + "value": { + "error": { + "code": "REPOSITORY_NOT_FOUND", + "message": "Repository not found" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + } + } + }, "put": { "operationId": "dynamicCi.setForceOverrides", "tags": ["dynamicCi"], @@ -15482,6 +16088,350 @@ } } }, + "/v2/tests/{testId}/quarantine": { + "post": { + "operationId": "tests.setQuarantine", + "tags": ["tests"], + "summary": "Set a test's quarantine override", + "description": "Requires the **Quarantine tests** permission.\n\nAlways quarantines a test, keeps it out of quarantine, or clears the override, whatever its monitors decided, and returns the test as it now stands. `neverQuarantine` and `clear` are how a test is taken out of quarantine; there is no separate endpoint. An override applies only while the test's collection has quarantining turned on, and turning that on is not offered here.\n\nA test already in the requested mode is left as it is, and `reason` is not recorded. A collection whose quarantine settings let only admins set overrides refuses anyone else with `INSUFFICIENT_PERMISSIONS`. An API token made on the API Keys page passes that check, since only an admin can make one or give it this permission; the organization's legacy API token does not, since every member can see it.\n\nA `503 TEST_UNAVAILABLE` means the override was saved but the test could not be read back in time; read it with `GET /v2/tests/{testId}` rather than treating the write as failed.\n\nThe override is recorded as set by the API token, or by the person whose token made the call. A change a person makes sends the `v2.test_case.quarantining_setting_changed` webhook; a change made with an API token does not yet.\n\n`Idempotency-Key` is not honored. The write is idempotent by state, so a request whose outcome is unknown (a `504` or a dropped connection) is safe to send again: a test already in the requested mode reports `unchanged`.\n\nA test in another organization is reported as `404`, not `403`, and so is a malformed id.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "description": "The test's globally unique id.", + "example": "bfeebcf4-0000-8000-8000-000000000001" + }, + "required": true, + "description": "The test's globally unique id.", + "name": "testId", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetQuarantineRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The test, with its new override", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Test" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + } + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, + "404": { + "description": "`TEST_NOT_FOUND`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "TEST_NOT_FOUND": { + "summary": "TEST_NOT_FOUND", + "value": { + "error": { + "code": "TEST_NOT_FOUND", + "message": "Test not found" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + }, + "503": { + "description": "`TEST_UNAVAILABLE`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "TEST_UNAVAILABLE": { + "summary": "TEST_UNAVAILABLE", + "value": { + "error": { + "code": "TEST_UNAVAILABLE", + "message": "The change was saved, but the test could not be read back; read it again" + } + } + } + } + } + } + } + } + } + }, + "/v2/tests/quarantine": { + "post": { + "operationId": "tests.setQuarantineMany", + "tags": ["tests"], + "summary": "Set quarantine overrides on many tests", + "description": "Requires the **Quarantine tests** permission.\n\nSets the quarantine override of each listed test, as `POST /v2/tests/{testId}/quarantine` does for one, and reports each test's outcome in request order. The tests may be in different collections. At most 100 items, and a test listed twice refuses the whole request with `VALIDATION_FAILED`.\n\nEach item either `succeeded`, was `unchanged` (already in that mode; `reason` is not recorded for it), or `failed` with an `error`: `TEST_NOT_FOUND` for an id that does not resolve to a test you can see (another organization's included), `INSUFFICIENT_PERMISSIONS` for a test whose collection lets only admins set overrides, and `INTERNAL_ERROR` for one that could not be saved, which wrote nothing and may be sent again. One item failing does not undo the others. A caller without the permission is refused as a whole with `403`.\n\nThe override is recorded as set by the API token, or by the person whose token made the call. A change a person makes sends the `v2.test_case.quarantining_setting_changed` webhook; a change made with an API token does not yet.\n\n`Idempotency-Key` is not honored. The write is idempotent by state, so a request whose outcome is unknown (a `504` or a dropped connection) is safe to send again: a test already in the requested mode reports `unchanged`.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetQuarantineManyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Each test's outcome, in request order", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkResult" + } + } + } + }, + "400": { + "description": "`VALIDATION_FAILED`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "VALIDATION_FAILED": { + "summary": "VALIDATION_FAILED", + "value": { + "error": { + "code": "VALIDATION_FAILED", + "message": "Invalid input: expected string, received undefined" + } + } + } + } + } + } + }, + "401": { + "description": "`AUTHENTICATION_REQUIRED` | `INVALID_TOKEN`. A malformed, expired, or deleted credential. Trunk's edge usually answers this before the request reaches the API, with a body that is not this envelope (`{\"error\": \"unauthorized: invalid api key\"}` for a bad API key, or plain text for a bad `x-trunk-token`), so branch on the status. A request with no credential at all gets `403` from the edge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "AUTHENTICATION_REQUIRED": { + "summary": "AUTHENTICATION_REQUIRED", + "value": { + "error": { + "code": "AUTHENTICATION_REQUIRED", + "message": "No credential was supplied" + } + } + }, + "INVALID_TOKEN": { + "summary": "INVALID_TOKEN", + "value": { + "error": { + "code": "INVALID_TOKEN", + "message": "The supplied token failed verification" + } + } + } + } + } + } + }, + "403": { + "description": "`INSUFFICIENT_PERMISSIONS`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INSUFFICIENT_PERMISSIONS": { + "summary": "INSUFFICIENT_PERMISSIONS", + "value": { + "error": { + "code": "INSUFFICIENT_PERMISSIONS", + "message": "The supplied credential is missing the required permission" + } + } + } + } + } + } + }, + "429": { + "description": "`RATE_LIMIT_EXCEEDED`. Trunk's edge answers this, and does not yet wrap it in the error envelope or send `Retry-After`: treat any `429` as rate limiting, whatever its body, and retry with backoff.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "RATE_LIMIT_EXCEEDED": { + "summary": "RATE_LIMIT_EXCEEDED", + "value": { + "error": { + "code": "RATE_LIMIT_EXCEEDED", + "message": "Rate limit exceeded; retry with backoff" + } + } + } + } + } + } + }, + "500": { + "description": "`INTERNAL_ERROR`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "INTERNAL_ERROR": { + "summary": "INTERNAL_ERROR", + "value": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred" + } + } + } + } + } + } + } + } + } + }, "/v2/tests/{testId}/ticket": { "get": { "operationId": "tests.getTicket", From c187877d3fa96810b8047b2228a30a51f6ed9bab Mon Sep 17 00:00:00 2001 From: Phil Vendola Date: Wed, 4 Nov 2026 15:08:39 +0000 Subject: [PATCH 5/5] docs(merge-queue): tighten per-PR merge method copy; state resubmit and refusal behaviour exactly Co-Authored-By: Claude Opus 5.5 --- .../administration/advanced-settings.mdx | 34 ++++++++++--------- merge-queue/reference/common-problems.mdx | 4 +-- merge-queue/using-the-queue/api.mdx | 10 +++--- merge-queue/using-the-queue/reference.mdx | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/merge-queue/administration/advanced-settings.mdx b/merge-queue/administration/advanced-settings.mdx index bc91d2cd..accb4d7c 100644 --- a/merge-queue/administration/advanced-settings.mdx +++ b/merge-queue/administration/advanced-settings.mdx @@ -121,7 +121,7 @@ You can change your merge method at any time: 1. Navigate to the **Merge Queue** tab → **[repository]** → **Settings** → **General** 2. Find the **Default merge method** dropdown 3. Select your preferred method: Squash, Merge Commit, or Rebase -4. The new method will apply to all PRs merged after the change, except PRs that [chose their own method](#choosing-a-merge-method-per-pull-request) +4. The new method applies to all PRs merged after the change, except PRs that [chose their own method](#choosing-a-merge-method-per-pull-request) while that setting is on **Note:** Changing the merge method only affects future merges. PRs already merged will retain their original merge method. @@ -138,34 +138,36 @@ The merge method is configured per queue, so different queues in your organizati ### Choosing a merge method per pull request -By default, every PR in a queue merges with the queue's merge method. To let the person who submits a PR choose a different method for that PR, turn on **Allow choosing a merge method per pull request**: +By default, every PR merges with the queue's merge method. To let whoever submits a PR choose a different one, turn on **Allow choosing a merge method per pull request**: 1. Navigate to the **Merge Queue** tab → **[repository]** → **Settings** → **General** 2. Turn on the **Allow choosing a merge method per pull request** toggle -The queue's merge method becomes the default: a PR that doesn't choose a method merges with it. Anyone who can submit PRs to the queue can choose a method. +A PR that doesn't choose still merges with the queue's method. Anyone who can submit PRs to the queue can choose. -When the setting is on, you choose a method as you submit the PR: +Choose a method when you submit the PR: -* **Trunk web app:** select a method from the **Merge method** menu next to the submit button. **Queue default** uses the queue's method. -* **Comment:** add `--merge-method` to the `/trunk merge` comment, with `squash`, `rebase`, or `merge` (a merge commit). For example, `/trunk merge --merge-method=rebase`. This works in GitHub comments and pull request reviews. -* **API:** send `mergeMethod` when you submit or update a PR. See [Automate the queue with the API](../using-the-queue/api#merge-method). +* **Trunk web app:** open the menu beside the submit button and pick one under **Merge method**. **Queue default** uses the queue's method. +* **Comment:** add `--merge-method=` with `squash`, `rebase`, or `merge` (a merge commit) to `/trunk merge`, for example `/trunk merge --merge-method=rebase`. This also works in a pull request review. +* **API:** send `mergeMethod` (`squash`, `rebase`, or `mergeCommit`) when you submit or update the PR. See [Merge method](../using-the-queue/api#merge-method). -Submitting with a label or the merge checkbox always uses the queue's method. +Submitting with a label or the merge checkbox uses the queue's method. Submitting a PR again without a method, while it waits to enter the queue or after it failed or was cancelled, drops the method it chose before; a PR already in the queue keeps its method. Resubmitting the same commit from the Trunk web app after a failure keeps the method selected. -A chosen method shows on the submit button (for example, **Submit to merge queue · Rebase**) and as a **Merge method** badge on the PR's page in Trunk. While the setting is on, Trunk's comment on the PR says how it will merge the PR, for example "Trunk will merge this pull request by rebasing its commits (chosen for it)." +A chosen method shows on the submit button (for example **Submit to merge queue · Rebase**) and as a **Merge method** badge on the PR's page in Trunk. While the setting is on, Trunk's comment on a PR that is waiting for or in the queue names the method, for example "Trunk will merge this pull request by rebasing its commits (chosen for it)." -Trunk refuses the method, and says why, when: +When Trunk merges a [stack](../using-the-queue/stacked-pull-requests), every PR it merges together uses the method chosen on the PR you submitted. -* The setting is off. -* The PR is already being merged and you choose a different method. -* The branch's GitHub rulesets don't allow that method. +If you turn the setting off, every PR merges with the queue's method, including PRs that chose their own. If you turn it back on, those choices apply again. -If the repository refuses the method when Trunk merges the PR, Trunk removes the PR from the queue and says why. +#### If a merge method is refused -If you turn the setting off, every PR merges with the queue's method, including PRs that chose their own. If you turn it back on, those choices apply again. +When you submit, Trunk refuses the method and gives the reason (in its reply comment, the web app, or the API response) if: + +* The setting is off and the method differs from the PR's current one. +* The PR is already being merged and you choose a different method. +* The branch's GitHub rulesets don't allow the method. -When Trunk merges a [stack](../using-the-queue/stacked-pull-requests), the method chosen on the PR you enqueued applies to it and to every PR below it that merges with it. +If GitHub refuses the method when Trunk merges the PR, Trunk removes the PR from the queue, and its comment on the PR gives GitHub's reason. ### Custom merge commit titles diff --git a/merge-queue/reference/common-problems.mdx b/merge-queue/reference/common-problems.mdx index d7439c85..871ce266 100644 --- a/merge-queue/reference/common-problems.mdx +++ b/merge-queue/reference/common-problems.mdx @@ -31,9 +31,7 @@ og:title: "Trunk Merge Queue FAQ" The merge method is configured for each queue in **Merge Queue** → **[your repository]** → **Settings** → **General** → **Default merge method**. PRs merged through the queue use the selected method. - To let a PR use a different method, turn on **Allow choosing a merge method per pull request**. Then whoever submits the PR can choose its method, for example with `/trunk merge --merge-method=rebase`. See [Choosing a merge method per pull request](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request). - - See [Merge Method documentation](/merge-queue/administration/advanced-settings#merge-method) for detailed information on each option. + To let a PR use a different method, turn on **Allow choosing a merge method per pull request**; whoever submits the PR can then choose, for example with `/trunk merge --merge-method=rebase`. See [Choosing a merge method per pull request](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request), and [Merge method](/merge-queue/administration/advanced-settings#merge-method) for each option. diff --git a/merge-queue/using-the-queue/api.mdx b/merge-queue/using-the-queue/api.mdx index 6962836a..8d5e197e 100644 --- a/merge-queue/using-the-queue/api.mdx +++ b/merge-queue/using-the-queue/api.mdx @@ -17,7 +17,7 @@ Merge queues are GitHub-only. Install the Trunk GitHub App on the repository, an - **Mode**: `queue` mode (**Single** in the app) puts pull requests in one line, each tested on top of the ones ahead of it, several at a time. `graph` mode (**Parallel** in the app) gives unrelated pull requests separate lines, based on the [impacted targets](/merge-queue/optimizations/parallel-queues) CI uploads for each. - **Priority**: lower `priorityValue` numbers test first. The names are `urgent` (0), `high` (10), `medium` (100), and `low` (200). Send a number as `priorityValue` or a name as `priorityName`. Without either, a pull request gets `medium` (100). **`urgent` (or `priorityValue: 0`) restarts lower-priority pull requests that are already testing**, so reserve it for emergencies. See [Priority merging](/merge-queue/optimizations/priority-merging). - **Direct to queue**: `direct: true` sends a pull request to the queue before its branch protection is satisfied. See [Direct-to-Queue](/merge-queue/using-the-queue/direct-to-queue). -- **Merge method**: how Trunk merges a pull request: `squash`, `rebase`, or `mergeCommit`. A pull request uses the queue's `mergeMethod` unless it chose its own. See [Merge method](#merge-method). +- **Merge method**: `squash`, `rebase`, or `mergeCommit`. A pull request uses the queue's `mergeMethod` unless it chose its own while the queue allows choosing. See [Merge method](#merge-method). ## Authentication @@ -72,9 +72,9 @@ To find out why a pull request failed, read the latest transition in its [queue- ## Merge method -Every pull request merges with the queue's merge method, `config.mergeMethod`, unless it chose its own. A pull request can choose only when the queue's `config.isMergeMethodOverrideEnabled` is `true`. Turn it on in the queue's [settings](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request), or send `{ "isMergeMethodOverrideEnabled": true }` to [`PATCH /v2/merge-queues/{id}/config`](/api/mergequeues/update-a-merge-queues-configuration) with a key that has **Update Merge Queue settings**. +Every pull request merges with the queue's merge method, `config.mergeMethod`, unless it chose its own. A pull request can choose only when the queue's `config.isMergeMethodOverrideEnabled` is `true`. Turn the setting on in the queue's [settings](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request), or send `{ "isMergeMethodOverrideEnabled": true }` to [`PATCH /v2/merge-queues/{id}/config`](/api/mergequeues/update-a-merge-queues-configuration) with a key that has **Update Merge Queue settings**. -To choose, send `mergeMethod` as `squash`, `rebase`, or `mergeCommit` when you submit: +To choose, send `mergeMethod` as `squash`, `rebase`, or `mergeCommit` when you submit. Choosing needs only the **Merge and cancel PRs** permission: ```bash curl -X POST https://api.trunk.io/v2/merge-queues/xK9mP2nQ/pull-requests \ @@ -83,9 +83,9 @@ curl -X POST https://api.trunk.io/v2/merge-queues/xK9mP2nQ/pull-requests \ -d '{ "number": 4901, "mergeMethod": "rebase" }' ``` -To change it while the pull request is queued, send `mergeMethod` to [`PATCH /v2/pull-requests/{id}/queue-entry`](/api/pullrequests/update-a-queued-pull-requests-entry). Send `null` to go back to the queue's method; leave it out to keep the current choice. Choosing needs only the **Merge and cancel PRs** permission. `queueEntry.mergeMethod` reads the choice back, and is `null` when the queue's method applies. +To change it while the pull request is queued, send `mergeMethod` to [`PATCH /v2/pull-requests/{id}/queue-entry`](/api/pullrequests/update-a-queued-pull-requests-entry). Send `null` to go back to the queue's method; leave it out to keep the current choice. `queueEntry.mergeMethod` reads the choice back, and is `null` when none is chosen. -Trunk refuses a choice with `422 PR_NOT_ENQUEUEABLE`, and the reason in `message`, when the queue's setting is off, when the pull request is already being merged, or when the branch's rulesets don't allow the method. +Trunk refuses a choice with `422 PR_NOT_ENQUEUEABLE`, and the reason in `message`, when the queue's setting is off and the method differs from the current one, when the pull request is already being merged and the method changes, or when the branch's rulesets don't allow the method. ## Queue order diff --git a/merge-queue/using-the-queue/reference.mdx b/merge-queue/using-the-queue/reference.mdx index 8ae3aa57..1db3577e 100644 --- a/merge-queue/using-the-queue/reference.mdx +++ b/merge-queue/using-the-queue/reference.mdx @@ -25,7 +25,7 @@ og:title: "Submit and cancel PRs in Trunk Merge Queue" - **Submit:** `/trunk merge` - **Cancel:** `/trunk cancel` - If your queue lets PRs [choose their own merge method](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request), add `--merge-method=squash`, `--merge-method=rebase`, or `--merge-method=merge` to `/trunk merge`. + To choose a merge method, if your queue [allows it](/merge-queue/administration/advanced-settings#choosing-a-merge-method-per-pull-request), add `--merge-method=` with `squash`, `rebase`, or `merge` (a merge commit) to `/trunk merge`. Apply a configured **enqueue label** to the pull request on GitHub to submit it automatically. See [Enqueueing by label](#enqueueing-by-label) below.