Mert Özen Further With Every Line
Testing & Quality Code Review — #05

The Review Process on GitHub: CODEOWNERS, Rulesets, Suggested Changes, Merge Queue, Bots and Metrics

Mert Özen Nov 14, 2026 15 min 24 views
The Review Process on GitHub: CODEOWNERS, Rulesets, Suggested Changes, Merge Queue, Bots and Metrics

On GitHub the review agreement moves into the platform through CODEOWNERS, rulesets and team assignment; but two teams on one CODEOWNERS line mean 'either of them', and a second required approval is set up with the ruleset's required reviewers rule. The post shows a ruleset JSON kept in the repository, suggested changes, merge queue with the merge_group trigger, the labeler and Dependabot, where Copilot approvals fit and a tool that derives review metrics from gh output.

Version note: The code in this post was run with .NET 10 (SDK 10.0.402) and actionlint 1.7.12. The GitHub settings (CODEOWNERS, rulesets, review assignment, suggested changes, merge queue, Copilot code review) are described from GitHub Docs as of November 2026 and were not run in a GitHub repository; the workflow files were validated locally with actionlint, the ruleset JSON against the schema in GitHub's REST API description and the Dependabot file against the SchemaStore schema.

Guess First

The team behind Bookstore, the online bookstore used as the shared example across this site's series, has grown: two more developers joined Emre and Ayşe, and the team split into two, backend and frontend. Ayşe caught a problem in a migration in the coupon pull request; from now on the team wants migrations approved both by the backend team and by the tech leads, and writes this line into CODEOWNERS:

/src/Bookstore.Api/Data/Migrations/   @bookstore/backend @bookstore/leads

"Require review from Code Owners" is on in the ruleset too. Guess: how many approvals does a migration pull request need before it can be merged? Is one person from backend enough, or does it need one approval from each team?

Area Ownership with CODEOWNERS

The answer is one. According to GitHub Docs, when code owner review is required, an approval from any one of a file's owners satisfies it. Writing two teams on a line means "either of them", not "both". The second approval the team wants cannot be set up with CODEOWNERS; we will set it up shortly with the required reviewers rule in the ruleset. Bookstore's file:

# Last matching pattern wins: general rules first, specific ones below.
*                                     @bookstore/leads

/src/Bookstore.Api/                   @bookstore/backend
/tests/                               @bookstore/backend
/web/bookstore-web/                   @bookstore/frontend

# Any one owner on a line is enough; the second approval for migrations comes from the ruleset.
/src/Bookstore.Api/Data/Migrations/   @bookstore/backend @bookstore/leads

# Changes to review rules and pipelines are approved by the leads.
/.github/                             @bookstore/leads
/eng/rulesets/                        @bookstore/leads

Where the file lives, its pattern syntax and the "last matching line wins" rule are covered in the Git & GitHub series, in "Teamwork with Git: Branching Strategies, Rulesets and Conventional Commits". A few more details matter for review. GitHub reads the file from the pull request's base branch; so a pull request that changes CODEOWNERS is reviewed under the existing rules, not its own change. Owners need write access to the repository; a team without it silently makes the line useless. Invalid lines are skipped, and GitHub lists the errors above the file in the UI and through the REST API; there is no official tool that checks this locally, so after opening a pull request that changes the file you need to look at GitHub's warnings. Code owners are not requested on draft pull requests; the request goes out when the pull request is marked ready for review. Finally, the /.github/ line protects the file itself: changing CODEOWNERS needs a lead's approval. Teams (like @bookstore/backend) need an organization; in a repository owned by a personal account, the lines list user names.

Watch the * line too. Every file that matches no specific line, from the README to configuration in the root folder, goes to the leads. If that line is kept broad, the leads turn into the bottleneck we discussed in the previous post. In Bookstore all of the code is covered by specific lines, so only files at the edges fall to *.

Rulesets: Approvals, Stale Reviews and the Last Push

The rules can be set up by clicking through the repository settings, but keeping them in the repository as a JSON file makes them reviewable. GitHub can import such a file through the "Import a ruleset" option in the settings; the same body can also be sent to the REST API's POST /repos/{owner}/{repo}/rulesets endpoint. Bookstore's ruleset for main is in eng/rulesets/main.json:

{
  "name": "main",
  "target": "branch",
  "enforcement": "active",
  "conditions": {
    "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] }
  },
  "bypass_actors": [],
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    {
      "type": "pull_request",
      "parameters": {
        "required_approving_review_count": 1,
        "dismiss_stale_reviews_on_push": true,
        "require_code_owner_review": true,
        "require_last_push_approval": true,
        "required_review_thread_resolution": true,
        "allowed_merge_methods": ["squash"],
        "required_reviewers": [
          {
            "file_patterns": ["src/Bookstore.Api/Data/Migrations/*"],
            "minimum_approvals": 1,
            "reviewer": { "id": 1234567, "type": "Team" }
          }
        ]
      }
    },
    {
      "type": "required_status_checks",
      "parameters": {
        "strict_required_status_checks_policy": false,
        "required_status_checks": [{ "context": "api" }, { "context": "web" }]
      }
    },
    {
      "type": "merge_queue",
      "parameters": {
        "merge_method": "SQUASH",
        "grouping_strategy": "ALLGREEN",
        "max_entries_to_build": 5,
        "min_entries_to_merge": 1,
        "max_entries_to_merge": 5,
        "min_entries_to_merge_wait_minutes": 5,
        "check_response_timeout_minutes": 30
      }
    }
  ]
}

The parameters of the pull_request rule are the review agreement from the first post, translated into the platform:

ParameterWhat it doesWhy it is on
required_approving_review_count: 1One approval to mergeThe agreement says one; raising the number spreads responsibility thin
dismiss_stale_reviews_on_pushA new commit after approval dismisses the approvalAn approval should belong to the code that was approved
require_code_owner_reviewApproval from one of the changed file's CODEOWNERSSomeone who knows the area should look
require_last_push_approvalThe last push must be approved by someone other than its pusherYou cannot approve your own push after an approval
required_review_thread_resolutionNo merge while a conversation is openBlocking comments are not passed over silently
allowed_merge_methods: squashSquash merge onlyFixup commits from review rounds do not enter main
required_reviewersAt least N approvals from a given team for given filesA lead approval on migrations, separate from CODEOWNERS

The required reviewers rule became generally available on 17 February 2026. It asks for the team by numeric id, not by name; 1234567 in the file is a placeholder to replace with the leads team's id. GitHub's announcement says the rule adds to CODEOWNERS rather than replacing it: CODEOWNERS defines ownership, covers individual people too and requests reviews even when they are not required; required reviewers makes a given team's approval mandatory for given files. It works with teams only, the team needs write access to the repository, and since personal accounts have no teams it cannot be used in repositories owned by them. That is how the second approval from the guess is set up: one approval from CODEOWNERS and one from the lead team on a pull request that touches a migration.

To validate the file locally, we took the schema of the ruleset body from the OpenAPI description in GitHub's rest-api-description repository and checked against it: the file conforms. Setting required_approving_review_count to 11 and writing merge_method in lower case makes both rules fail the schema; the first has a maximum of 10, the second accepts only upper-case values. A small typo in the JSON would surface during import; but because the file lives in the repository, it can also be seen in review.

Review Requests and Assignment

When CODEOWNERS adds a team to a review, who in the team gets the request is decided by the team's settings. On GitHub, automatic assignment can be turned on for teams: when the team is requested, the team request is removed and a set number of members are assigned instead. There are two algorithms. Round robin picks the member with the least recent request and ignores how many reviews each member has pending. Load balance takes recent requests and outstanding reviews into account and aims for an equal number of reviews per member over any 30-day period. Members whose status is "Busy" are not picked; specific members can be excluded from assignment entirely.

This setting is a direct answer to the review fatigue from the previous post. If half the reviews go to the same senior, load balance spreads the load and brings juniors into reviews. One caveat: when code owner review is required, the team's own request stays on the pull request until the assigned person finishes their review; that keeps the requirement that the approval comes from the team.

Suggested Changes: Proposing a Small Fix in a Comment

Remember the nitpick from the previous post: the normalized variable could be normalizedCode. Instead of a sentence describing such a change, you can write the change itself as the comment. The suggestion button in GitHub's comment box inserts the selected lines as a special code block; you edit what is inside the block:

nitpick (non-blocking): same naming as normalizedEmail in OrderService.

```suggestion
        string normalizedCode = code.Trim().ToUpperInvariant();
        Coupon? coupon = await db.Coupons.AsNoTracking().FirstOrDefaultAsync(c => c.Code == normalizedCode, ct);
```

The author applies the suggestion on its own with "Commit suggestion", or together with others as a single commit with "Add suggestion to batch". The person who wrote the suggestion is added to the commit as a co-author. Applying a suggestion needs write access to the repository; for pull requests from forks, the author has to allow edits by maintainers.

Suggested changes are good for small, exact fixes: a name, a typo, a missing CancellationToken. They are bad for design changes: the author applies the suggestion with one click, but without understanding why it was made. Suggestions also know nothing about each other; if two suggestions touch the same line, both cannot be applied.

Merge Queue

As the team grows, a new problem appears. Two pull requests are green on their own, but the build breaks once both are in main; each was tested without seeing the other's change. The classic answer is the "branch must be up to date before merging" rule, but then after every merge every waiting pull request has to be updated and CI run again, and in a busy repository that turns into a queue. Merge queue hands that queue over to GitHub.

According to the documentation it works like this (not run): a pull request enters the queue, GitHub creates a temporary branch that combines it with the current main and the pull requests ahead of it, and runs CI on that combination. If the checks pass, the group is merged into main; if one pull request's checks fail, it is removed from the queue and the group is rebuilt without it. The merge_queue parameters in the ruleset tune this behaviour: how many pull requests run CI at the same time, the minimum and maximum number of pull requests in one merge, how long to wait for checks. That is why we keep strict_required_status_checks_policy off: the queue already checks freshness on the combined state.

For the queue to work, the CI workflow must also run on the merge_group event the queue produces; otherwise the required checks are never reported and the merge fails. The triggers of Bookstore's CI:

name: ci

on:
  pull_request:
  merge_group:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  api:
    runs-on: ubuntu-latest
    # ... SQL Server service container, dotnet build, dotnet test
  web:
    runs-on: ubuntu-latest
    # ... npm ci, ng build, ng test

The full workflow is in the repository, in .github/workflows/ci.yml; the job names (api, web) match the required check names in the ruleset. We checked the workflow with actionlint before sending it to GitHub. A clean file produces no output; with the trigger misspelled as merge-group:

$ actionlint .github/workflows/ci.yml
.github/workflows/ci.yml:5:3: unknown Webhook event "merge-group". see https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#webhook-events for list of all Webhook event names [events]
  |
5 |   merge-group:
  |   ^~~~~~~~~~~~
$ echo $?
1

This mistake is valid YAML; GitHub accepts the file, the queue just never runs. Workflow syntax and the other details of Actions are covered in the GitHub Actions series.

Merge queue can be enabled in public repositories owned by an organization and in private repositories of organizations using GitHub Enterprise Cloud. For a team of two to four it is usually unnecessary; it pays off where dozens of pull requests a day merge into the same branch. Used with stacked pull requests, a stack enters the queue in order; if one layer is removed from the queue, the layers above it are removed too.

Automation: Label Bots, Dependency Pull Requests and AI Reviewers

Labels tell the reviewer, scanning the pull request list, how much time to give to what. Bookstore puts labels on by changed files with actions/labeler:

backend:
  - changed-files:
      - any-glob-to-any-file: ['src/**', 'tests/**']
frontend:
  - changed-files:
      - any-glob-to-any-file: 'web/**'
migration:
  - changed-files:
      - any-glob-to-any-file: 'src/Bookstore.Api/Data/Migrations/**'
ci:
  - changed-files:
      - any-glob-to-any-file: ['.github/**', 'eng/**']

The labeler's workflow runs on pull_request_target, because writing labels on pull requests from forks needs write access. This trigger runs the workflow with the base branch's code and a token with write access; so a step that checks out or runs the pull request's code must never be added. Our workflow runs only the labeler. The migration label makes the kind of pull request Ayşe spent an hour on in the previous post stand out in the list right away.

Dependency updates are a separate review flow. In Dependabot's .github/dependabot.yml we group updates (all @angular/* packages in one pull request) and set a seven-day waiting period (cooldown) for newly released versions; the file passes the SchemaStore schema. In these pull requests the reviewer does not look at code: is CI green, does the changelog have a breaking change, has the new version really waited out the period? Package supply chain risks are the subject of the DevSecOps series.

AI reviewers belong in this layer too. How GitHub Copilot code review behaves and its limits are covered in the AI-Assisted Development series, in "Reviewing and Verifying AI-Written Code: Typical Failure Modes, a Checklist and AI Reviewers"; here we only look at its place in the process. Rulesets have a rule called copilot_code_review; it requests a Copilot review automatically for new pull requests and can optionally run on every push and on draft pull requests. Since 28 October 2026, Copilot approvals are generally available: off by default, and when an enterprise, organization or repository admin turns them on, Copilot's approving review counts toward the required approvals; admins can limit which file paths Copilot may approve, and a commit after the approval dismisses it. GitHub's own documentation says Copilot is not guaranteed to find every problem in a pull request and recommends complementing its feedback with human review. Bookstore's decision: use Copilot review as a pre-check that runs before the human review, and do not count its approval as a required approval. The warning from the previous post applies here too: if bot comments go unanswered, human comments get lost in the crowd.

Measuring Review

In the first post we named three signals: time to first review, pull request size and the number of review rounds. They can be derived from GitHub CLI's JSON output. Bookstore's eng/review-metrics.cs is a small file-based app that reads the output of gh pr list:

#:property PublishAot=false
// Usage:
//   gh pr list --state merged --limit 200 --json number,author,createdAt,mergedAt,additions,deletions,reviews > prs.json
//   dotnet run eng/review-metrics.cs -- prs.json
using System.Globalization;
using System.Text.Json;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

JsonSerializerOptions json = new(JsonSerializerDefaults.Web);
List<PullRequest> prs = JsonSerializer.Deserialize<List<PullRequest>>(File.ReadAllText(args[0]), json) ?? [];

// Bot PRs (dependency updates) have a different review flow; keep them out of team numbers.
List<PullRequest> human = [.. prs.Where(p => !p.Author.Login.EndsWith("[bot]"))];

Console.WriteLine($"{"PR",-5} {"author",-7} {"lines",6} {"first review (h)",17} {"rounds",7}");
List<double> waits = [];
foreach (PullRequest pr in human.OrderBy(p => p.Number))
{
    Review? first = pr.Reviews
        .Where(r => r.Author.Login != pr.Author.Login)
        .OrderBy(r => r.SubmittedAt)
        .FirstOrDefault();
    double? wait = first is null ? null : (first.SubmittedAt - pr.CreatedAt).TotalHours;
    if (wait is double w)
    {
        waits.Add(w);
    }
    int rounds = pr.Reviews.Count(r => r.State == "CHANGES_REQUESTED") + 1;
    Console.WriteLine($"#{pr.Number,-4} {pr.Author.Login,-7} {pr.Additions + pr.Deletions,6} {wait?.ToString("0.0") ?? "-",17} {rounds,7}");
}

Console.WriteLine();
Console.WriteLine($"median time to first review: {Median(waits):0.0} h");
Console.WriteLine($"median PR size:              {Median([.. human.Select(p => (double)(p.Additions + p.Deletions))]):0} lines");
Console.WriteLine($"PRs over 400 lines:          {human.Count(p => p.Additions + p.Deletions > 400)} of {human.Count}");
Console.WriteLine("reviews given:               " + string.Join(", ", human
    .SelectMany(p => p.Reviews.Where(r => r.Author.Login != p.Author.Login).Select(r => (p.Number, r.Author.Login)))
    .Distinct()
    .GroupBy(x => x.Login)
    .OrderByDescending(g => g.Count())
    .Select(g => $"{g.Key} {g.Count()}")));

static double Median(List<double> values)
{
    if (values.Count == 0)
    {
        return 0;
    }
    List<double> sorted = [.. values.Order()];
    int middle = sorted.Count / 2;
    return sorted.Count % 2 == 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}

record Author(string Login);
record Review(Author Author, string State, DateTimeOffset SubmittedAt);
record PullRequest(
    int Number, Author Author, DateTimeOffset CreatedAt, DateTimeOffset? MergedAt, int Additions, int Deletions,
    List<Review> Reviews);

PublishAot=false on the first line turns off native AOT, which is on by default for file-based apps; together with the repository's TreatWarningsAsErrors, a security warning for the AOT compiler package version in this SDK was breaking the build, and this tool does not need AOT. Since we do not connect to GitHub, we prepared a sample file in the same shape as gh's output, standing for 13 pull requests from October 2026 (eng/metrics/sample-prs.json):

$ dotnet run eng/review-metrics.cs -- eng/metrics/sample-prs.json
PR    author   lines  first review (h)  rounds
#101  emre        98               1.8       1
#102  ayse       182               3.7       2
#104  emre      1416              32.8       2
#105  ayse        42               0.6       1
#106  emre        82               1.2       1
#107  ayse       335               3.3       2
#108  emre        15               0.6       1
#109  emre       650              67.2       1
#110  ayse       117               1.3       1
#112  emre        79               0.8       1
#113  ayse       180               3.7       1

median time to first review: 1.8 h
median PR size:              117 lines
PRs over 400 lines:          2 of 11
reviews given:               ayse 6, emre 5

The two bot pull requests (103 and 111) are not in the list. The median wait is 1.8 hours; the agreement's one-working-day goal holds comfortably. The real information is at the edges: the two pull requests over 400 lines are the two that waited longest. #104's 1416 lines include a migration's generated files; gh's additions value ignores the linguist-generated mark in .gitattributes. #109's 67 hours come from being opened on a Friday afternoon and reviewed on Monday morning; a metric that does not count in working days makes the weekend look like a slow reviewer.

How the numbers are used matters as much as the numbers. When a measure becomes a target it stops being a good measure; this observation, attributed to the economist Charles Goodhart, shows up quickly in review metrics. A target such as "time to first review under two hours" produces fast, empty approvals. "Pull requests under 400 lines" produces pull requests split into parts that make no sense on their own. So these numbers are used to start a conversation in the team retrospective, not to compare people. Some things should not be measured at all: comments per person, bugs found per person, a "most approvals" list. They turn review into a performance contest. The team-level measures of delivery speed and quality, the DORA metrics, are covered in the CI/CD Fundamentals series, in "Measuring and Speeding Up a Pipeline: DORA Metrics, Caching and Parallelism".

In Production

A ruleset can first be tried in "evaluate" mode; that mode shows what would be blocked without enforcing the rules, but it is specific to GitHub Enterprise. On other plans, announcing a new rule to the team before turning it on and collecting the cases that would have needed a bypass in the first week works well. The bypass list is empty on purpose: even an urgent fix gets a pull request, but it goes through with one approval and a quick review.

Keeping the rules in a file means changes to the rules go through review too. eng/rulesets/ and .github/ belong to the leads in CODEOWNERS; a change that removes the required approval cannot be made in the settings without anyone noticing. But the file and the setting on GitHub can drift apart; after the pull request that changes the file is merged, the setting needs updating too. A workflow could automate that, but it needs a token with admin rights; in Bookstore this step is manual and is written into the "Risk and rollback" section of the pull request template.

A team that turns on Copilot approvals should think about it together with the approval settings in the ruleset: every new commit dismisses the approval, Copilot runs again and can approve again. If code owner review is not required and the required approval count is one, a pull request can be merged without any human approving it. We could not find in the documentation whether a Copilot approval satisfies the code owner requirement; so try the setting in a test repository before turning it on.

Common Pitfalls

Raising the required approval count and spreading responsibility thin. On a pull request that needs two approvals, each reviewer may assume the other looked carefully. One approval from a person who is really responsible beats two shallow ones; mark the places that need a second pair of eyes (migrations, payments) file by file with required reviewers.

Writing CODEOWNERS too broadly. A single * line at the root makes one team the required reviewer of every pull request and turns the bottleneck into a platform rule.

Bot comments drowning human comments. If the label, dependency, coverage and AI bots each leave a few comments on a pull request, the reviewer's comment gets lost in the middle of the page. Configuring bots to produce checks or labels instead of comments, or merging their comments into one summary comment, prevents this.

Leaving a rule in the settings and not in the agreement. Someone who does not know why the ruleset asks for require_last_push_approval will find it "unnecessary" the first time it gets in the way and ask for it to be removed. The rule's reason should be written in the review agreement or an ADR.

Turning metrics into people. A team average starts a conversation; a per-person table starts a ranking.

A Small Experiment

★☆☆ Open CODEOWNERS in your own repository and check that the owners on every line have write access to the repository. If a line has two owners, does the team mean "either" or "both"? If it is the latter, move it to a required reviewers rule.

★★☆ Write the output of gh pr list --state merged --limit 50 --json number,author,createdAt,mergedAt,additions,deletions,reviews to a file and run review-metrics.cs on it. Change the wait calculation to count working hours: weekends and out-of-hours time should not count.

★★★ Map your team's review agreement (like docs/code-review.md in the first post) item by item to ruleset parameters. Which items can become a platform rule, and which can only be kept by team habit? Write the ones that can be platform rules as a ruleset JSON and put it up for review in a pull request.

The series ends here. We started with why review is done, then saw writing a small and readable pull request, what to look at as a reviewer and in which order, the language of comments, and finally which GitHub settings support all of this. Settings protect the process but do not run it; what makes review valuable is two people looking at the same goal: code and a team that are better than before.