# Branch protection and rulesets

Branch protection and rulesets that gate merges, and the mergeable state each pull request settles to.

Branch protection and rulesets decide whether a pull request can merge, and they set the `mergeable_state` your client reads. Protect the base branch, watch a pull request go from `clean` to `blocked` and satisfy each requirement in turn:

```bash
H='Authorization: Bearer ghp_anything'
R="$RYSTIC_URL/repos/tb-owner/tb-repo"
curl -s "$R/pulls/41" -H "$H" | jq -c '{mergeable, mergeable_state}'
# -> {"mergeable":true,"mergeable_state":"clean"}
curl -s -X PUT "$R/branches/tb-base-branch/protection" -H "$H" -d '{"required_status_checks":{"strict":false,"contexts":["unit"]},"enforce_admins":true,"required_pull_request_reviews":{"required_approving_review_count":1},"restrictions":null}' | jq -c '{enforce_admins: .enforce_admins.enabled, contexts: .required_status_checks.contexts}'
# -> {"enforce_admins":true,"contexts":["unit"]}
curl -s "$R/pulls/41" -H "$H" | jq -c '{mergeable, mergeable_state}'
# -> {"mergeable":true,"mergeable_state":"blocked"}
curl -s -X PUT "$R/pulls/41/merge" -H "$H" | jq -c '{message}'
# -> {"message":"Required status check \"unit\" is expected."}
curl -s -X POST "$R/statuses/517a4bbdfb525691b11db3e28f466eb3d7598d19" -H "$H" -d '{"state":"success","context":"unit"}' | jq -c '{state, context}'
# -> {"state":"success","context":"unit"}
curl -s -X PUT "$R/pulls/41/merge" -H "$H" | jq -c '{message}'
# -> {"message":"At least 1 approving review is required by reviewers with write access."}
curl -s -X POST "$R/pulls/41/reviews" -H "$H" -H 'X-Rystic-Actor: tb-reviewer-login' -d '{"event":"APPROVE"}' | jq -c '{state}'
# -> {"state":"APPROVED"}
curl -s -X PUT "$R/pulls/41/merge" -H "$H" | jq -c '{merged, message}'
# -> {"merged":true,"message":"Pull Request successfully merged"}
curl -s -X POST "$R/rulesets" -H "$H" -d '{"name":"main rules","target":"branch","enforcement":"active","conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"rules":[{"type":"deletion"}]}' | jq -c '{id, name, enforcement}'
# -> {"id":1,"name":"main rules","enforcement":"active"}
curl -s "$R/rules/branches/tb-base-branch" -H "$H" | jq -c '[.[] | {type, ruleset_id}]'
# -> [{"type":"deletion","ruleset_id":1}]
```

Pushes, ref writes and contents writes to a protected branch are on [Protected pushes](https://www.rystic.ai/docs/simulators/github-pulls/features/protected-pushes.md).

### mergeable_state

An open pull request's `mergeable_state` follows its base's protection, the rulesets that apply to its base and its head's results. `mergeable` stays `true` in each of these states.

| The pull request | `mergeable_state` |
|---|---|
| Its base is gated by protection or a ruleset, and a required context has no passing result on the head, approvals are short of `required_approving_review_count` or a review thread stands under `required_review_thread_resolution` | `blocked` |
| A check run on the head isn't completed or didn't conclude `success`, `neutral` or `skipped`, or a context's latest commit status isn't `success` | `unstable` |
| Otherwise | `clean` |

A required context's result is the latest commit status of that context, else the latest check run of that name.

### What re-settles it

Each status, check run, protection and ruleset write re-settles the pull requests it touches. So do submitting or dismissing a review, and creating a review comment through `POST .../pulls/{n}/comments` (a reply there included) or deleting one. These writes re-settle it too, for the head and base they leave it on:

| Write | `mergeable_state` then follows |
|---|---|
| A push, or a contents or git ref write, that moves the head | the new head's results, in the pull request's own repository even when the head is a fork's branch |
| Update-branch | its new merge commit, which has no results yet: `clean`, or `blocked` while its base's protection or a ruleset is unmet, as the table above says (except from `behind`, below) |
| A reopen | the head pushed while it was closed, and any result or protection added meanwhile |
| A change of base | the new base's protection, which can block or release it |

On a base that requires no check the new head lacks, an `unstable` pull request whose new head has no results yet reads `clean` until an unfinished or failing result lands on it. The `synchronize` that update-branch sends carries the state it settles to, except from `behind`. An edit that sends the current base re-settles it as well.

**Differs from GitHub:** update-branch on a pull request reading `behind` leaves it `clean`, and its `synchronize` carries `behind`, even where the base requires a check the new merge commit lacks. GitHub reads `blocked`.

**Differs from GitHub:** a pull request reopened after its base's protection or ruleset was lifted keeps the `blocked` it closed with. GitHub reads what its head's results give.

A scenario seeds `mergeable` and `mergeable_state`. A seeded `blocked` on a base nothing gates stands until a protection or ruleset write changes what gates that base, or the base changes.

A seeded `dirty` or `unknown` stands until the head or the base moves (a push, a merge, a ref write or a contents write), an edit sends `base` or the pull request reopens. Update-branch on an `unknown` pull request also counts. That redoes the test merge: a conflict reads `false` and `dirty`, and a clean merge reads `true` and `clean`. A head move, a base edit or a reopen then settles it as the table says.

A merge or update-branch refuses a pull request reading `dirty`. It also asks git at request time, so a conflicting test merge refuses one whatever the pull request reads.

**Differs from GitHub:** update-branch moves only its own pull request. Another one based on the moved branch keeps its `mergeable` and `mergeable_state`. Another one headed by it keeps its old `head.sha`, commits and files. It gets no `synchronize`.

**Differs from GitHub:** a `dirty` or `unknown` pull request whose base moves and whose test merge is clean reads `clean` until a write re-settles it, whatever its protection, rulesets and head results say. GitHub reads `blocked` or `unstable` as they give.

**Differs from GitHub:** an open pull request's `mergeable` and `mergeable_state` are always settled values. GitHub can return `null` on its first read while it computes them.

### Merging

A merge through `PUT .../pulls/{n}/merge` checks the merge method first, once the pull request is found open and mergeable. A method the repository disables returns GitHub's `405`.

Into a base that protection alone gates, a merge returns `405` while a requirement is unmet. The message names the first unmet requirement:

| Unmet | Message |
|---|---|
| One required check | `Required status check "<context>" is expected.`, `is pending.` or `is failing.` |
| Several checks, in one state | `<n> of <m> required status checks are <state>.` |
| Several checks, in mixed states | `<n> of <m> required status checks have not succeeded: 1 expected and 1 failing.`, with each state's count |
| Approvals | `At least <n> approving review(s) is/are required by reviewers with write access.` |

A repository admin merges past protection unless its `enforce_admins` is enabled.

### Rulesets

A ruleset applies to a branch while all of these hold:

- its `enforcement` is `active` and its `target` is `branch`;
- it holds at least one rule;
- one of its `conditions.ref_name.include` patterns names the branch;
- no `exclude` pattern names it.

`GET .../rules/branches/{branch}` returns `[]` for a branch no ruleset applies to, whether or not the branch exists.

Creating or updating a ruleset refuses what GitHub refuses on a personal repository's plan, such as enforcement `evaluate`, with GitHub's `422` error.

### What a ruleset's rules ask of a merge

A ruleset's `required_status_checks` and `pull_request` rules block the pull requests into the branches it applies to. Merging one returns GitHub's `405` with `Repository rule violations found`, then each violation once, an unmet context on a line of its own.

Once a ruleset applies to the base, protection's unmet requirements join that list: all of them, not only the first.

A repository admin is refused like anyone else, unless the ruleset's `bypass_actors` name a repository role the merger holds. Such an actor has `actor_type` `RepositoryRole`, any `bypass_mode` and `actor_id` 2 (maintain), 4 (write) or 5 (admin). `mergeable_state` reads `blocked` whoever asks.

A `pull_request` rule's `allowed_merge_methods` refuses any other method with the `405` a repository's disabled method returns. It is judged before every other rule, over GraphQL's `mergePullRequest` too. Every ruleset that applies must allow the method. A bypass actor merges past it, and `mergeable_state` doesn't move for it.

With `required_review_thread_resolution`, a review thread on the pull request blocks it. A thread is a top-level comment of a submitted review. While one stands, the merge returns `A conversation must be resolved before this pull request can be merged.` in place of the rule's approval count.

**Differs from GitHub:** the simulator serves no way to resolve a review thread. Deleting a thread's first comment ends it.

**Differs from GitHub:** protection's `required_conversation_resolution` is stored, but a review thread blocks neither the merge nor `mergeable_state` under it.

**Differs from GitHub:** the stale-review and last-push settings are stored but don't gate a merge: protection's `dismiss_stale_reviews` and `require_last_push_approval`, and a `pull_request` rule's `dismiss_stale_reviews_on_push` and `require_last_push_approval`.

---

Source: https://www.rystic.ai/docs/simulators/github-pulls/features/branch-protection · Markdown: https://www.rystic.ai/docs/simulators/github-pulls/features/branch-protection.md
