Skip to content
Chapter 96Lesson 3

The pull request as designed artifact

Designing GitHub pull requests as the team's unit of change, sized small, reviewable, and reversible, with descriptions and review conventions a teammate can act on.

So far you’ve opened pull requests as a formality. You finish a branch, you click the green button, you merge. That’s exactly the right instinct when you’re the only person who will ever read the code. The moment a second engineer joins, it stops being right, not because the mechanics change, but because the pull request stops being your checkpoint and becomes the team’s unit of change. It’s the thing a teammate reads before it lands, the thing CI runs its checks against, the thing the preview deployment binds to so reviewers can click around the actual feature, and the thing the squash-merge commit records as one line in main’s history. Everyone downstream of you sees the change through this one object.

When that object is built carelessly, here is the failure it causes. A reviewer opens your pull request , sees a nine-hundred-line diff, scrolls for ninety seconds, can’t hold any of it in their head, types “LGTM,” and approves. The bug sitting in line 600 ships to production. Nobody caught it, because nobody could. The PR was un-reviewable, so it didn’t get reviewed; it got rubber-stamped.

The fix is not to review harder. The fix is to treat the pull request as something you design. In the trunk-based loop you learned to keep one PR per logical change; this lesson is the reason behind that rule and the method for carrying it out well. By the end you’ll be able to size a change so it reviews in one sitting, write a description a reviewer can act on without pulling you into a call, and run the everyday review loop smoothly from both the author’s seat and the reviewer’s. That loop uses fixup commits and squash-merge, both of which you met in the rescue toolkit lesson. The whole lesson hangs on three words: small, reviewable, reversible.

Before the individual parts, you need the map. A pull request is not a single event; it’s a short pipeline of checkpoints, and the goal at every one of them is the same: this change lands in one round of review. Once you can see the whole pipeline, every later section of this lesson has a place to fit. You’ll know that fixup commits belong to the discussion stage, that CODEOWNERS acts at the reviewer-request stage, and so on.

Here is the full lifecycle. Scrub through it; each step highlights the stage that’s active and names who is doing the work.

You push the finished branch: `git push -u origin feat/invoice-status`. The remote now has your commits, but nothing is proposed yet.
You open the PR against `main`, on the web or with `gh pr create`. This is where the diff and the description come together into a proposal.
Reviewers get requested — automatically, if a CODEOWNERS file maps the changed files to a team (covered later this lesson).
CI runs its checks against the PR's commits. Wiring CI to run on every PR is the next chapter's job — for now, just know this stage exists.
A preview deployment of the branch goes live at its own URL, so reviewers click through the real feature. The mechanics arrive in a later chapter.
Reviewers leave comments; you push fixup commits in response. This is the negotiation, and it's where most of the back-and-forth happens.
A reviewer approves. The proposal is now ratified — but not yet on `main`.
You squash-merge. Every commit on the branch collapses into one clean commit on `main`.
The merged branch is auto-deleted. The work is on `main`; the branch has served its purpose.

Most of those stages are things you do with a single command. Here is the bare happy path of push, propose, merge, using the GitHub CLI, one command per line, so the stages have real commands attached. Don’t worry about memorizing the CLI yet; it gets its own short reference near the end.

The happy path
git push -u origin feat/invoice-status
gh pr create --fill
gh pr merge --squash --delete-branch

You just saw the lifecycle drawn out. Before moving on, fix the order in your mind by rebuilding it yourself. The following exercise gives you the stages shuffled; drag them back into sequence.

Order the stages a pull request moves through, from the first push to the merged branch. Drag the items into the correct order, then press Check.

Push the finished feature branch to the remote
Open the pull request against main
Reviewers are requested
CI runs its checks
A preview deployment goes live
Reviewers comment and the author pushes fixups
A reviewer approves
Squash-merge collapses the branch into one commit on main
The merged branch is deleted

These three words are the heart of the lesson, so slow down here. They are not three nice-to-haves; they are three constraints that reinforce each other, and almost every other decision in this lesson follows from them. Take each in turn.

Small. Roughly four hundred changed lines is the ceiling for a review that someone can actually do in one sitting. That’s not a lint rule and not a hard gate; it’s a number about human attention. A reviewer can hold a few hundred lines in their head, trace each change to the code that calls it, and form a real opinion. Push past that and attention degrades. Somewhere north of eight hundred lines the review quietly turns into skimming, and skimming is just rubber-stamping with extra scrolling. The failure mode is the eight-hundred-line PR that gets a thumbs-up nobody could have honestly given.

Reviewable. One logical change per PR. A reviewer should be able to say what the PR does in a single sentence, such as “adds a status filter to the invoices list,” and have that sentence cover every line in the diff. What breaks this is the drive-by edit: you’re in a file fixing a bug, you notice the formatting is off, and “while I’m in here” you reformat the whole module. Now the diff is a bug fix wearing a hundred lines of whitespace churn, and the reviewer can’t tell which lines are load-bearing and which are noise. The load-bearing lines are exactly the ones that need scrutiny, and you’ve hidden them.

Reversible. The change should be self-contained enough that a single git revert (which you met in the rescue toolkit lesson) undoes it cleanly, with nothing else coming along for the ride. This is the constraint that pays off weeks later, when something breaks in production and you need to back out just the change that caused it. If the PR that introduced the regression also bundled two unrelated features, reverting it to stop the bleeding tears out two things that were working fine. A reversible PR is one you can pull out of main like a single Jenga block.

Now see how the three chain together, because this is the part most people miss. Small enables reviewable: a small diff is almost forced to be about one thing. Reviewable enables reversible: a PR that’s genuinely one logical change is, by construction, one thing you can revert. The three aren’t independent goals you balance against each other; they’re a single property viewed from three angles. This is the real difference between a junior and an experienced engineer here. The junior optimizes for fewer PRs, because batching work feels efficient, while the experienced engineer optimizes for each PR fitting in one head, and ships more, smaller PRs as a result.

The following figure makes “small” concrete. It’s a ladder of size bands. Notice that the bands aren’t a cliff but a slope: review quality degrades gradually as the diff grows, which is exactly why four hundred is a heuristic and not a gate.

Line count isn't a gate — it's a proxy for how much attention a reviewer can actually give. The bigger the diff, the more review quality slides from real to ceremonial.

The obvious objection to “keep it small” is that some changes are genuinely big. Renaming a core type across the codebase, or shipping a feature that needs a refactor and a new helper and the user-facing wiring, is not a hundred-line change, and pretending it is just means cramming. The senior move isn’t to give up on small; it’s to split the big change into a stack of small PRs.

A stack is a dependency chain. Each PR is built on the branch of the one before it instead of on main, and each link is small enough to review on its own. Say you’re adding a status filter to the invoices list, and doing it right means reshaping the query first. You’d ship it as a chain of four: a pure refactor with no behavior change, then a small extraction, then the call-site swap, then the feature itself.

One big change as a stack of four
PR 1 feat/filter-refactor Reshape the invoice query (no behavior change)
PR 2 feat/filter-helper Extract a reusable status-filter helper
PR 3 feat/filter-callsite Use the helper where the list is built
PR 4 feat/filter-ui Add the status dropdown to the list UI

Each of those reviews in one sitting. Each merges in order: PR 1 first, then PR 2 once PR 1 is in, and so on. The pure-refactor PR is especially valuable on its own, because a reviewer can approve it fast precisely because it changes no behavior, and that confidence is only possible when it isn’t tangled with the feature.

The mechanic is one flag. Instead of opening a PR against main, you point it at the previous branch:

Terminal window
git switch -c feat/filter-helper
gh pr create --base feat/filter-refactor

--base tells GitHub the PR merges into feat/filter-refactor, not main, so the diff only shows this link’s changes rather than everything in the stack. There is a bookkeeping cost: when PR 1 merges, you re-point PR 2’s base to main, and so on down the chain. Tools like Graphite, git-spice, and Sapling automate exactly that re-pointing, but you don’t reach for them on day one. The honest threshold is to pick up stack tooling when you’re routinely juggling three or more dependent PRs at once. Below that, chaining --base by hand is fine, and it’s what most two-to-five-person SaaS teams in 2026 actually do.

The description is the argument for the diff

Section titled “The description is the argument for the diff”

A diff is a perfect record of what changed and a terrible record of why. It shows every line that moved and not one word about the bug that prompted it, the approach you rejected, or the thing you want the reviewer to look at hardest. Leave the description blank and you’ve handed the reviewer a puzzle: reverse-engineer your intent from the code, then judge whether the code matches the intent you guessed. That’s slow, and it’s where reviewers miss things, because they spend their attention reconstructing what you meant and have none left for whether you’re right.

Here is the reframe to keep in mind for the rest of this lesson: a pull request is a proposal with an argument attached. The diff is the proposal, the what you want to merge. The description is the argument, the why it’s correct and safe. The review is the negotiation, and the squash-merge is the agreed result that becomes one line of main’s history. Seen that way, a blank description is a proposal submitted with no case made for it, and you’re asking the reviewer to build the argument themselves before they can even judge it.

So make the case. A good argument has a predictable shape of six sections. Read the whole thing first as one artifact, the way a reviewer would, then walk it section by section.

## What
Adds a status filter to the invoices list. Users can now narrow the
list to Draft, Sent, Paid, or Archived invoices from a dropdown above
the table.
## Why
Closes INV-412. Support has been fielding "where did my paid invoices
go" tickets because the list mixes every status together with no way
to focus. This is the smallest fix that addresses the complaint.
## How
The filter is driven by a URL search param (`?status=`) so a filtered
view is shareable and survives a refresh. I considered client-side
filtering of the full list but rejected it — the list is paginated
server-side, so filtering on the client would silently miss rows on
other pages.
## Test plan
- Added a unit test covering each status case and the "all" default.
- Manually filtered to each status against seeded data; confirmed
counts match the badge totals.
- Checked an empty result (no archived invoices) renders the empty
state, not a blank table.
## Screenshots
Before / after of the list with the new dropdown. (Once preview
deployments are wired, a link to the live preview goes here instead.)
## Risks / rollback
Read-only change — no schema or data migration. If the filter
misbehaves, a single `git revert` removes the dropdown and restores
the unfiltered list with no cleanup.

One paragraph on the user-visible change. The reviewer reads this first, and everything after is checked against it. State what a user can now do, not how the code does it.

## What
Adds a status filter to the invoices list. Users can now narrow the
list to Draft, Sent, Paid, or Archived invoices from a dropdown above
the table.
## Why
Closes INV-412. Support has been fielding "where did my paid invoices
go" tickets because the list mixes every status together with no way
to focus. This is the smallest fix that addresses the complaint.
## How
The filter is driven by a URL search param (`?status=`) so a filtered
view is shareable and survives a refresh. I considered client-side
filtering of the full list but rejected it — the list is paginated
server-side, so filtering on the client would silently miss rows on
other pages.
## Test plan
- Added a unit test covering each status case and the "all" default.
- Manually filtered to each status against seeded data; confirmed
counts match the badge totals.
- Checked an empty result (no archived invoices) renders the empty
state, not a blank table.
## Screenshots
Before / after of the list with the new dropdown. (Once preview
deployments are wired, a link to the live preview goes here instead.)
## Risks / rollback
Read-only change — no schema or data migration. If the filter
misbehaves, a single `git revert` removes the dropdown and restores
the unfiltered list with no cleanup.

Link the ticket or decision and state the problem solved. This is the part the diff can never contain, and it’s what lets a reviewer judge whether the change is even the right thing to build.

## What
Adds a status filter to the invoices list. Users can now narrow the
list to Draft, Sent, Paid, or Archived invoices from a dropdown above
the table.
## Why
Closes INV-412. Support has been fielding "where did my paid invoices
go" tickets because the list mixes every status together with no way
to focus. This is the smallest fix that addresses the complaint.
## How
The filter is driven by a URL search param (`?status=`) so a filtered
view is shareable and survives a refresh. I considered client-side
filtering of the full list but rejected it — the list is paginated
server-side, so filtering on the client would silently miss rows on
other pages.
## Test plan
- Added a unit test covering each status case and the "all" default.
- Manually filtered to each status against seeded data; confirmed
counts match the badge totals.
- Checked an empty result (no archived invoices) renders the empty
state, not a blank table.
## Screenshots
Before / after of the list with the new dropdown. (Once preview
deployments are wired, a link to the live preview goes here instead.)
## Risks / rollback
Read-only change — no schema or data migration. If the filter
misbehaves, a single `git revert` removes the dropdown and restores
the unfiltered list with no cleanup.

Only the non-obvious choices and the alternatives you rejected. Skip what the diff already makes plain, since narrating obvious code wastes the reviewer’s time. The rejected alternative is often the most valuable line here.

## What
Adds a status filter to the invoices list. Users can now narrow the
list to Draft, Sent, Paid, or Archived invoices from a dropdown above
the table.
## Why
Closes INV-412. Support has been fielding "where did my paid invoices
go" tickets because the list mixes every status together with no way
to focus. This is the smallest fix that addresses the complaint.
## How
The filter is driven by a URL search param (`?status=`) so a filtered
view is shareable and survives a refresh. I considered client-side
filtering of the full list but rejected it — the list is paginated
server-side, so filtering on the client would silently miss rows on
other pages.
## Test plan
- Added a unit test covering each status case and the "all" default.
- Manually filtered to each status against seeded data; confirmed
counts match the badge totals.
- Checked an empty result (no archived invoices) renders the empty
state, not a blank table.
## Screenshots
Before / after of the list with the new dropdown. (Once preview
deployments are wired, a link to the live preview goes here instead.)
## Risks / rollback
Read-only change — no schema or data migration. If the filter
misbehaves, a single `git revert` removes the dropdown and restores
the unfiltered list with no cleanup.

What you actually ran, not what you intend to run: the seeded test, the manual flow, the edge case you checked. This is the author’s evidence that the change works.

## What
Adds a status filter to the invoices list. Users can now narrow the
list to Draft, Sent, Paid, or Archived invoices from a dropdown above
the table.
## Why
Closes INV-412. Support has been fielding "where did my paid invoices
go" tickets because the list mixes every status together with no way
to focus. This is the smallest fix that addresses the complaint.
## How
The filter is driven by a URL search param (`?status=`) so a filtered
view is shareable and survives a refresh. I considered client-side
filtering of the full list but rejected it — the list is paginated
server-side, so filtering on the client would silently miss rows on
other pages.
## Test plan
- Added a unit test covering each status case and the "all" default.
- Manually filtered to each status against seeded data; confirmed
counts match the badge totals.
- Checked an empty result (no archived invoices) renders the empty
state, not a blank table.
## Screenshots
Before / after of the list with the new dropdown. (Once preview
deployments are wired, a link to the live preview goes here instead.)
## Risks / rollback
Read-only change — no schema or data migration. If the filter
misbehaves, a single `git revert` removes the dropdown and restores
the unfiltered list with no cleanup.

For any UI change, before/after images so the reviewer sees the result without checking out the branch. A later chapter replaces this with a link to the per-PR preview deployment.

## What
Adds a status filter to the invoices list. Users can now narrow the
list to Draft, Sent, Paid, or Archived invoices from a dropdown above
the table.
## Why
Closes INV-412. Support has been fielding "where did my paid invoices
go" tickets because the list mixes every status together with no way
to focus. This is the smallest fix that addresses the complaint.
## How
The filter is driven by a URL search param (`?status=`) so a filtered
view is shareable and survives a refresh. I considered client-side
filtering of the full list but rejected it — the list is paginated
server-side, so filtering on the client would silently miss rows on
other pages.
## Test plan
- Added a unit test covering each status case and the "all" default.
- Manually filtered to each status against seeded data; confirmed
counts match the badge totals.
- Checked an empty result (no archived invoices) renders the empty
state, not a blank table.
## Screenshots
Before / after of the list with the new dropdown. (Once preview
deployments are wired, a link to the live preview goes here instead.)
## Risks / rollback
Read-only change — no schema or data migration. If the filter
misbehaves, a single `git revert` removes the dropdown and restores
the unfiltered list with no cleanup.

What could break and how to undo it, tied to git revert from the rescue toolkit lesson. A change with a clear rollback is one a reviewer can approve with less fear.

1 / 1

You won’t type those six headings from memory every time. The repo ships a file, .github/pull_request_template.md, that pre-fills the description box with this skeleton the moment you open a PR. It’s a one-time setup that makes the good structure the path of least resistance for everyone. Authoring that template in depth, and the broader discipline of shipping docs inside the PR, is a later chapter’s subject; for now, know the file exists and that it holds exactly the six sections you just walked.

One point of senior judgment is worth stating plainly, because beginner-facing advice often gets it wrong. Resist the urge to bolt a checkbox onto that template, the ”☐ I ran the tests” kind. A self-attested checkbox enforces nothing: anyone can tick it in two seconds without having run anything, so it looks like a gate but isn’t one. The real enforcement of “the tests passed” is the CI status check that runs against the PR, which is the next chapter’s topic. Reserve the description for the argument; let the machine enforce the facts.

One habit costs you sixty seconds and saves an entire review round, yet almost nobody does it early in their career. Before you request a single reviewer, open your own PR’s “Files changed” tab and read the diff as if it were a stranger’s. The shift in posture is the whole trick: reading to approve surfaces things that reading while writing never does. You’ll catch roughly half the comments a reviewer would have left, such as the debug console.log you forgot, the variable you renamed everywhere but one place, or the test you meant to add.

And where the diff genuinely needs context, leave the comment yourself, on your own PR. A line like “intentionally not handling the archived case here; it’s a separate ticket, INV-419” pre-answers the exact question the reviewer was about to type, and turns a round-trip into nothing. Beyond the time saved, self-review signals to the team that you don’t outsource the first pass of caring about your own work. Do this right after you’ve written the description and right before you request review; it’s the author’s last step.

GitHub lets you open a PR in draft status: it shows a “Draft” badge, can’t be merged, and signals that you want eyes, not approval, because the work isn’t done. It’s a genuinely useful mode, and it’s also one of the easiest defaults to get wrong, so treat it as a conditional tool with a specific trigger.

A normal, non-draft PR gets attention: reviewers see it in their queue and pick it up. A draft gets the opposite, because people learn that drafts aren’t ready and skip them, so a PR you open as a draft “just to be safe” can sit ignored for days. That’s the trade-off to weigh. Open a draft only when one of three things is actually true:

  • The approach is uncertain, and a reviewer glancing at the direction now could save you a day of building the wrong thing.
  • The PR depends on another that hasn’t merged yet, and you want it visible as in-progress without inviting an approval it can’t act on.
  • You’re using CI as an early-warning signal, pushing to watch the checks run, and reviewers genuinely shouldn’t read it yet.

If none of those hold, open it as a normal PR and let it get attention. Opening it as a draft is one line:

Terminal window
gh pr create --draft

Review is a conversation, and you’ll sit on both sides of it constantly: author one hour, reviewer the next. You need enough working vocabulary to take part competently in each seat, and that working level is what follows. The deep review methodology, with its layered review stack and formal severity taxonomy, is a later chapter’s whole subject; here we’re after fluency in the everyday exchange.

The comment styles that keep a review moving

Section titled “The comment styles that keep a review moving”

A reviewer who only knows one kind of comment, “change this,” creates friction on every line, because not every observation is a demand. Four distinct shapes do most of the work, and naming them keeps a review from stalling:

  • Suggestion. GitHub has a suggestion code fence that renders as a one-click “apply” button on the author’s side. Reach for it on the small stuff, such as a typo, a clearer variable name, or a tiny refactor, where writing the fix is faster than describing it and the author accepts in a single click.
  • Question. “Why this approach and not that one?” A real question is legitimate and valuable when the reviewer is missing context the author has. It becomes a problem when it’s a demand in disguise: “have you considered not doing it this way?” is an instruction wearing a question mark, and everyone can tell.
  • Blocking comment. “This has to change before I approve.” The key is to make it visible rather than bury it in a thread the author might miss. Attach it to the Request changes review action, which flips the PR’s review state to “changes requested” for everyone to see. (Turning that state into a hard merge block takes a ruleset, which is the next lesson’s job; here it’s the signal that matters.)
  • Nit. A comment you explicitly mark as non-blocking: “nit: I’d inline this, but take it or leave it.” It tells the author you noticed and you care about the bar, but you won’t hold the PR hostage over style. Naming a nit as a nit is what keeps it from reading as a demand.

The suggestion fence is worth seeing once, since it’s a concrete bit of syntax rather than just a label. You write it as a fenced block with the word suggestion, containing the exact replacement for the lines you’re commenting on:

```suggestion
const statuses = ['draft', 'sent', 'paid', 'archived'] as const;
```

That covers the reviewer’s side. The author has exactly one habit to internalize: respond to every comment. Every single one, even if the response is just “good catch, fixed” or “deferring this to follow-up issue #418.” A comment left with no reply reads as dismissal, because the reviewer can’t tell whether you disagreed, missed it, or silently complied, and that ambiguity erodes the trust the whole loop runs on.

When you’re the reviewer, you work in two gears, and confusing them is a classic mistake. New reviewers tend to do one undifferentiated pass: either a slow careful read of a PR that turns out to be dead on arrival, or a fast skim of a PR that deserved real scrutiny. The fix is to run two distinct passes with two different jobs.

The 60-second pass is triage. Read the description, scan the diff, and flag only the things that make the PR dead on arrival: no tests where there obviously should be some, a missing migration, a secret committed by accident, a description that says “see commits.” This pass exists so that a fundamentally not-ready PR gets bounced in a minute instead of consuming thirty.

The 30-minute pass is the actual review. You re-read with full attention, you follow each change to the code that calls it, you open the preview deployment and click through the real feature, and then you leave your review. The 60-second pass decides whether the PR is worth the 30-minute pass; the 30-minute pass is where the real findings come from. Knowing which gear you’re in keeps you from spending thirty minutes on something that should have been bounced in one, and from waving through something that needed the full read.

Reading about review only goes so far. The fastest way to internalize what makes a diff reviewable is to be the reviewer: to look at a real diff with the reviewer’s eye and leave the comments you’d leave. The following exercise is a small feat/invoice-status PR with three planted defects, each one a thing this lesson primed you to catch. Open each file, click the lines that deserve a comment, and write what you’d actually say. Submit when you’ve left your review, and you’ll see which of the three you caught.

You're reviewing a teammate's PR that adds a status filter to the invoices list. Leave a comment on every line that deserves one. Click any line to leave a review comment, then press Submit review.

src/db/queries/list-invoices.ts
import { and, asc, eq } from 'drizzle-orm';
import { eq, and, asc } from 'drizzle-orm';
import { db } from '@/db';
import { invoices } from '@/db/schema';
const FILTERABLE_STATUSES = ['draft', 'sent', 'paid'];
export async function listInvoices(orgId: string, status?: string) {
const where = [eq(invoices.orgId, orgId)];
if (status && FILTERABLE_STATUSES.includes(status)) {
where.push(eq(invoices.status, status));
}
return db
.select()
.from(invoices)
.where(and(...where))
.orderBy(asc(invoices.createdAt));
}

The everyday loop: fixup commits and squash-merge

Section titled “The everyday loop: fixup commits and squash-merge”

Now connect the conversation back to the Git mechanics from the previous two lessons, because the everyday review loop is where all three lessons converge. The cycle goes like this: a reviewer leaves a comment, you make the fix, you push, the reviewer looks again, they approve, you squash-merge. The only open question is how you make that fix, and the answer is the reason the earlier lessons chose the defaults they did.

When a reviewer asks for a change, you have two ways to commit the fix.

Terminal window
git commit -m "address review"
git push

Works fine. The squash-merge will absorb this commit, so main ends up clean either way. The cost shows up during review: “address review” is a meaningless commit subject, and the fix floats free of the original commit it’s correcting, so the history while the PR is open reads as noise.

With a squash-merge, both of those collapse to the identical clean commit on main, so the difference is entirely about legibility while the PR is open. The --fixup version keeps the intent of each fix visible during review; the plain version doesn’t. That’s a small win on any single PR and a real one across a team that does this all day.

The reviewer side has its own small efficiency here. They don’t re-read the whole diff after you push fixes, because GitHub offers a “changes since your last review” filter on the Files changed tab that shows only the commits added since they last looked. A five-line fix is then a five-line re-review, not a re-read of four hundred lines.

Now the payoff lands. This is why the trunk-based lesson made squash-merge the default and why the rescue toolkit lesson taught --fixup. During review, the reviewer sees every incremental change, including the work-in-progress commits, the fixups, and the back-and-forth, which is exactly what they need to follow your reasoning. At merge, the squash collapses all of it into one clean commit, so the messy in-progress history never reaches main. The PR page keeps the full conversation forever, for anyone who later asks why a given line is there. Review gets the detail, main gets the summary, and nothing is lost. That’s the two disciplines from the earlier lessons doing their job together.

gh, CODEOWNERS, and the squash-merge setting

Section titled “gh, CODEOWNERS, and the squash-merge setting”

The workflow you’ve built so far is mostly habit and judgment. A few pieces of tooling and a couple of repo settings turn that judgment into something the repo enforces, so the right thing happens whether or not everyone remembers to do it. Three of them are worth knowing now. Each has a deeper enforcement story that belongs to the next lesson; here you’re learning the tool and the shape of its data, not the rules that lock it down.

The course default for opening and reviewing PRs is the GitHub web UI, where the diff, the conversation, and the merge button all live, and it’s the right place to learn. But the gh CLI gets faster than the UI once the muscle memory lands, and it composes with shell aliases and scripts in ways the web UI can’t. Treat the following as a recognition list, not a tutorial. You want to recognize these when you see them, and reach for them when clicking starts to feel slow:

gh — the commands you'll actually use
gh pr create # open a PR from the current branch
gh pr view --web # open the current branch's PR in the browser
gh pr checkout 123 # check out a teammate's PR locally to test it
gh pr review # approve, comment, or request changes
gh pr merge --squash --delete-branch # squash-merge and clean up the branch

When changes to the billing code should always be seen by the billing team, you don’t want to rely on whoever opened the PR remembering to add them. A CODEOWNERS file solves that. It’s a plain file at .github/CODEOWNERS that maps file-path globs to GitHub usernames or teams, and when a PR touches an owned path, GitHub automatically requests a review from the matching owner.

.github/CODEOWNERS
# Default owner for everything in the repo
* @org/eng
# Money paths get the billing leads on every change
/src/lib/billing/ @org/billing-leads
# Schema changes go through a database owner
/src/db/schema.ts @org/dba

Two things to hold onto. First, the file is data, not enforcement. On its own, CODEOWNERS only auto-requests the right reviewers, and an auto-requested review is still ignorable: nothing stops a merge if the owner never looks. Turning “requested” into “required” takes a ruleset, which is the next lesson’s job. Keep the distinction crisp: the file is the data, the rule is the enforcement.

Second, a real gotcha: the globs are gitignore-style, and the last matching line wins, not the most specific one. So you write the file general-to-specific, top to bottom, with the catch-all * first and the narrow paths below it. Put a broad pattern after a narrow one and it silently overrides the narrow rule you cared about, which is the single most common way a CODEOWNERS file goes wrong. As for what gets an owner: the high-stakes zones such as auth, billing, schema, and infra earn one, while ordinary UI and feature work usually doesn’t need the ceremony.

Squash-and-merge as the repo’s only merge button

Section titled “Squash-and-merge as the repo’s only merge button”

The trunk-based lesson made squash-merge a discipline. One repo setting makes it structural, so nobody can deviate even by accident. In the repo’s Settings → General → Pull Requests, enable Squash and merge and turn off both Allow merge commits and Allow rebase merging. Now the merge button offers exactly one choice, and the result is guaranteed: one commit per PR on main, every time, with no undocumented “I picked the merge-commit button this once” exceptions polluting the history. Pair it with Automatically delete head branches so merged feature branches don’t pile up into a heap of stale branches nobody dares delete.

Two more settings are worth knowing by name, though you won’t enable them by default:

  • Allow auto-merge queues a PR to merge itself the moment its checks pass and its reviews land. It’s useful when a PR is approved but you’re waiting on a slow CI run and don’t want to babysit the button. Reach for it once you trust your CI.
  • Merge queue serializes merges: it rebases and re-checks each PR against the latest main before landing it, which prevents the race where two PRs each pass CI alone but break main together. It’s a tool for larger teams, and below a certain volume it’s just overhead. We name it here but leave it out of the 2026 SaaS-startup minimum.

Where the PR becomes the spine of everything

Section titled “Where the PR becomes the spine of everything”

Step back and look at what you’ve actually built. The pull request stopped being a button you click and became the team’s unit of change: small, reviewable, reversible, argued for in a description, negotiated in review, and collapsed into one clean line of main’s history. That object is the assumption nearly every chapter from here on quietly builds on.

The next chapter wires CI to run on every PR, turning those checks into required gates instead of advisory ones. A later chapter binds a preview deployment to each PR, with its own URL and its own database branch, which is what finally fills the screenshots section of your description with a live link. The next lesson in this chapter takes the review and squash-merge habits you just learned and makes them mechanically enforced with rulesets, so they hold even on the day someone’s in a hurry. Later still, schema migrations ship as a series of small PRs exactly because you now know how to keep each one small. And the deep review methodology, the layered stack you’ve only seen the working surface of, gets its own chapter. Every one of those builds on the same thing: the PR as the hub the whole workflow turns around.

The references below go deeper on the three pillars of this lesson, sizing, stacking, and review etiquette, from teams that pioneered each.