# Patch check (/v1/patch/check)

Every composer patch gets a verdict against the release a site installed:
whether it still applies, and whether its fix is already in that release.
A verdict comes from `git apply --check` against the release tag in the
project's bare clone. For a merge request, the merge request state is read
too.

This service downloads nothing. A patch is sent as diff text, and the URL it
was declared with goes in `source`.

## Request

### POST /v1/patch/check

```json
{"items": [
  {"project": "redirect", "version": "1.13.0",
   "patch": "<inline unified diff>",
   "source": "https://git.drupalcode.org/project/redirect/-/merge_requests/45.patch",
   "title": "Guide users who enter path aliases"},
  {"project": "drupal", "version": "11.4.5", "patch": "<inline unified diff>"}
],
 "reroll": false,
 "target_core": "11.4.5"}
```

| Field | What it holds |
|---|---|
| `items` | The batch, at most 50. |
| `reroll` | `true` also returns a re-roll for every item that ends `conflicts`, and for every `fuzzy` item. A fuzzy item applies today, so its verdict is `applies`, and a patch manager running `git apply` alone refuses it. A fuzzy item GNU `patch` applies whole is re-rolled from the tree GNU `patch` leaves. Every other item is re-rolled with a 3-way merge. A re-roll can be large. |
| `drop_tests` | What a re-roll does with a file the release package leaves out, for every item. Unset and `true` both leave it out; `false` keeps every hunk, for a site installed from source. |
| `target_core` | The core the added code is checked against: `11.4`, `11.4.x` (read as `11.4`) or `11.4.5`. Defaults to the lock's `drupal/core`, else the version of a `drupal` item. A target whose minor has no core release in the data is a 400, and so is a major alone such as `11`. `latest` is not refused and checks no references. The lock's core and a `drupal` item's version are never refused this way. |

One item:

| Field | What it holds |
|---|---|
| `project` | The drupal.org machine name. `drupal` or `core` for Drupal core. |
| `version` | The installed composer version: `1.13.0`, `8.x-1.13`, `2.0.0-beta4`, `11.4.5`. |
| `patch` | The unified diff text. A URL, a path or a script here is a 400. |
| `source` | The path or URL the declaration was written with. Optional. A `git.drupalcode.org` merge request URL fills `mr` and `merged_in_version`, and the squashed diff is fetched from it. A local path says nothing about where the bytes came from, so send `provenance` beside it. |
| `merge_patch` | The squashed form of a merge request, used for the 3-way merge while `patch` decides the verdict. Optional. A format-patch series applies its later diffs onto blobs its earlier commits left markers in, and one diff per file does not. |
| `upstream` | The merge request the patch was declared from, for a caller that sends no `source`. Optional. Read after `source` and `provenance`. |
| `provenance` | Where a copied patch's bytes came from, as the declaration records it: `{mr, commit, url, base, head, fetched, rerolled}`. Optional. A patch a site copied into itself travels under a local path. `mr` then fills `mr` and `merged_in_version`. `commit` is checked as an ancestor of the tag. `base` is the merge base a re-roll tries when the item sends no `base` of its own. Every other key is read by nothing. |
| `title` | Echoed back on the result. Optional. |
| `base` | The release the site runs: a commit sha, or a composer version. Optional. A re-roll of a patch without index lines tries it first as the merge base, before the newest tags. |
| `resolutions` | Decisions for the conflicted regions an earlier re-roll reported. Optional. |

### The site's own files

`items` can be replaced by the site's own composer files:

```json
{"composer_json": "<composer.json text>",
 "composer_lock": "<composer.lock text>",
 "patch_files": {"patches/webform.patch": "<diff text>"}}
```

Every declared patch is then checked at its installed version.
`patch_files` holds the text of each patch, keyed by the source as the
declaration wrote it. `missing_files` in the response lists the declared
patches the body did not include.

A site on cweagans/composer-patches 2.x keeps its patches out of
composer.json. Two more fields hold them:

- `patches_file`: the patches file, at the path in the site's own
  `extra.composer-patches.patches-file`, `patches.json` by default. Read
  after `extra.patches`; a source a package already declares is kept once.
- `patches_lock`: `patches.lock.json`, which the manager writes and applies
  from. It says what the site actually applies, so it answers alone.

Every document accepts both declaration shapes. One is the compact
title-to-source object. The other is a list of `{description, url, sha256,
depth, extra}` objects. A definition's `extra.drupatch` is read as the
patch's `provenance`.

### resolutions

A `conflicts` re-roll numbers the regions of each merged file from 0. A
resolution decides one region. The decisions go back with the same
`project`, `version` and `patch`.

| Field | What it holds |
|---|---|
| `file` | The file, as the `reroll.conflicts` entry gives it. |
| `region` | The region index in that file, from 0. |
| `choice` | `release` keeps what the release has. `patch` forces the patch's version. Ignored when `text` is set. |
| `text` | Replaces the whole region, written as given. It has its own indentation and no conflict markers. |
| `delete` | Empties the region. Neither side is kept, and nothing replaces them. It cannot be combined with `choice` or `text`. |

A region no resolution addressed falls back to the release side, and
`reroll.resolutions_missing` lists it.

### GET /v1/patch/check

GET answers this page. A `patch` query parameter is a 400: a diff does not
fit in a query string.

## Response

`{count, results}`, one result per item in input order. A request built from
the site's own files also gets `missing_files` when the body leaves out a
declared patch.

| Field | What it holds |
|---|---|
| `tag` | The release tag matched, or `unknown_version`. |
| `sha` | The commit the verdict was taken at. Present only when the version was a branch; a tag fixes its own commit. |
| `applies_at` | The `-p` level at which `git apply --check` passes (1, 0, 2, 4 tried in that order), or null. |
| `fuzzy` | True when the patch needed a lenient apply: git's own fuzzy flags, or the GNU `patch` fallback. A patch manager does the same, so the patch is installed on the site. |
| `strict_refused` | Why `git apply` refused a patch that a looser attempt accepted. Empty when the strict check passed. Set without `fuzzy` where this service's own mirror caused the refusal. A release archive ends with a packaging block the git tag lacks, so a patch made against the archive matches no tag. On a real site it applies. |
| `judged_without` | The earlier patches of the same package that did not apply whole and left part of themselves in the tree. This verdict was taken against that tree. |
| `hunks_failed` | `[{file, line, reason}]` at the most plausible level, at most 10. |
| `hunks_failed_total` | How many hunks failed, before that cap. |
| `reverse_applies` | True when the patched lines are already in the tag. |
| `hunks_shipped` | `[{file, line, reason}]`: the hunks the release already has verbatim, when only part of the patch landed. The verdict does not read them. |
| `hunks_shipped_total` | How many there were, before the same cap. |
| `files_shipped` | The files whose whole change is already in the release. |
| `mr` | `{state, target_branch, merged_at, merge_commit_sha, squash_commit_sha, head_sha, detailed_merge_status}` when `source` is a merge request URL. |
| `merged_in_version` | True when the merged request's squash or merge commit is an ancestor of the tag, or every `From <sha>` of a format-patch series is. False for an open request. Null when nothing could be checked. |
| `suggested` | The verdict: `merged`, `applies`, `conflicts`, or `unknown`. |
| `failure_mode` | What is wrong with a patch that applied. `broken syntax` means a file it touches no longer parses. Absent when nothing is wrong. |
| `syntax_errors` | The files behind a `broken syntax` failure mode, `path: message`. Set with `failure_mode`, so the file and its line are given without reading `references_note`. |
| `reroll` | Only with `reroll: true`, on a `conflicts` or `fuzzy` item. See below. |
| `core_references` | What the added code references in core, at `target_core`. See below. |
| `error` | Why a verdict is missing or partial. |

### reroll

| Field | What it holds |
|---|---|
| `status` | `clean`: every file merged, or GNU `patch` applied every hunk, and `patch` is the whole replacement. `conflicts`: `patch` has `<<<<<<< release / \|\|\|\|\|\|\| patch base / >>>>>>> patch` markers in the listed files. `unavailable`: no release takes the patch, or it was made from a commit in no release; `error` says which. |
| `level` | The `-p` level the re-roll ran at. |
| `base` | The tag the merge ran from, when it was not the patch's own index lines. It is the item's `base` when the patch applies there, else the newest tag it applies to. Empty on a re-roll GNU `patch` made. |
| `patch` | The re-rolled diff. Paths are relative to the repository root: contrib at `-p1`, core under `core/` at `-p2` from `web/core`. On a conflicted merge it holds the hunks that merged cleanly. |
| `verified` | True when `patch` was apply-checked against the tag at `-p1` from the repository root. |
| `verified_by` | The check that ran: the command, the `-p` level and the release tag. |
| `syntax_errors` | Files that do not parse after the merge, `path: message`. PHP, YAML, JSON, JavaScript and Twig are read. `verified` is false then: the diff applies, and the result will not load. |
| `conflicts` | `[{file, regions, removed, hunks}]`, at most 20 files. `removed` is true when the release deleted the file, and no resolution for it is ever applied. |
| `conflicts_total` | How many files were left with markers, before that cap. |
| `dropped_paths` | The files the re-roll left out: the ones the release package omits, read from the tag's own `git archive`. |
| `dropped_tests` | The same list under the name drupatch 0.21.0 reads. Prefer `dropped_paths`. |
| `merged_from` | The patch the merge ran on, when it was not the one sent. A multi-commit merge request is judged as declared and merged from its squashed diff. |
| `unioned` | `[{file, line}]`: the regions the merge decided by keeping both sides. The base side of each was present and empty, and the two sides declared no name in common. |
| `resolutions_applied` | How many regions the sent resolutions decided. |
| `resolutions_missing` | The conflicted regions no resolution addressed. Each fell back to the release side. |
| `truncated` | True when `patch` was cut at the size cap. |
| `note` | Why a clean merge has nothing in it: the release already has the change. |
| `error` | Why no re-roll was produced. |

Each `conflicts[].hunks` entry is one region: `{line, release, base, patch,
release_line, release_context, cut}`. `release_context` is the release's
lines around the region, numbered, so a diff can be written from this result
alone. `cut` is true when a side was longer than the line cap.

### core_references

`{target, checked, flagged, flagged_more, deprecated, unresolved, note}`.

| Field | What it holds |
|---|---|
| `flagged` | `[{symbol, kind, via, file, line, reference, issue, site_arguments, target_parameters, since, change_record, replacement}]`. |
| `flagged[].kind` | `removed`; `moved` (a live class has the name elsewhere); `signature` (the argument count is outside the declared parameters). |
| `flagged[].via` | For `parent::__construct` and `new`: the walk to the nearest core ancestor whose constructor was checked. |
| `deprecated` | `[{fqn, deprecated_in, removal_in}]`. |
| `note` | Why the references could not be checked, for a patch that does not apply. |

Added lines only, and only these references:

- `extends`
- `implements`
- trait `use`
- `new`
- static calls
- `parent::__construct`

They are read from the re-rolled diff when a clean re-roll produced one.

## Limits

| What | Cap |
|---|---|
| Items per call | 50 |
| Request body | 32 MB |
| `composer_json` | 1 MB |
| `composer_lock` | 4 MB |
| `patches_file`, `patches_lock` | 1 MB each |
| One patch, inline | 16 MB |
| Files one patch touches | 4000 |
| `hunks_failed` per item | 10 |
| One resolution's `text` | 64 KB |
| Re-rolled diff | 16 MB |
| Conflicted files listed | 20 |
| Regions with text, per file | 5 |
| Lines per side of a region | 40 |

The api-wide per-IP rate limit applies to every call. Each POST also counts in
the patch check rate limit, whatever its body holds. That limit allows 30
requests per minute per IP, shared with a scan that sends `patches: true` to
`/v1/composer/scan`. 100 requests over the limit within 15 minutes ban the
address from both for 15 minutes. A POST result is not cached. This page is
cached for 24 hours.

## Errors

| Status | When |
|---|---|
| 400 | The body does not parse, `items` is empty, or the batch is over 50. Also a bad `project`, `version`, `base` or resolution, and a `patch` that is a URL, a path, a script, or has no diff header. Also a `target_core` whose minor has no core release in the data, or a major alone. |
| 413 | The body is over 32 MB. Also one patch over 32 MB once encoded for the patch check. |
| 429 | The patch check rate limit refused the POST, or the address is banned. `Retry-After` gives the wait in seconds. |
| 503 | The patch check is busy. Also a `target_core` sent while the data lacks core releases. `Retry-After` gives the wait in seconds. |
| 502 | The patch check is unreachable, or it failed upstream. |
