Pull requests and code review
A pull request is where your team decides, together, that a change is safe to ship. This lesson shows what a PR is made of, how to write one that gets a fast yes, how to review well, what each merge button does to history, and how a PM can read a PR in ten minutes without reading every line.
By the end you can
- Name every part of a PR page and say what each one tells you
- Write a PR description and a template that reviewers can act on
- Review with line comments, suggestions, approve and request changes
- Set up CODEOWNERS so the right people are asked automatically
- Pick merge commit, squash or rebase, knowing what each leaves on
main
The everyday picture
Before a festival rush, the pricing team prepares a new price list for 320 stores. One person types it. A second person checks it line by line against the approved offer sheet before it goes out. If she finds "Fresh milk 1L: $0.15" instead of $1.50, she circles it and sends it back. The typist fixes it, she checks again, signs it, and only then does it reach the stores.
Nobody thinks the second check is an insult. It is how a chain avoids selling milk at a tenth of the price for a weekend. A pull request is that second check for code. The branch is the draft price list, the reviewer is the second person, the automated checks are the calculator that adds up every column, and "merge" is the moment the list goes to the stores.
What a pull request is made of
A pull request (PR) asks to merge one branch into another. The branch with your work is the head branch. The branch you want to merge into, usually main, is the base branch. A PR is not a copy of your code. It points at your branch, so every new commit you push to that branch appears in the PR at once and the checks run again.
The PR page has four tabs, and each one answers a different question:
- Conversation: why this change exists. The title, the description, linked issues, review comments, and a timeline of everything that happened.
- Commits: the commits on the head branch, in order. Good commit messages (see Everyday Git) make this tab readable.
- Checks: the results of automated jobs such as lint, tests and build. These come from GitHub Actions (see GitHub Actions: continuous integration) or other tools, and each one is a status check.
- Files changed: the diff, file by file. This is what the code review happens on.
One detail explains many confusing diffs. The PR shows the changes on the head branch since it split from the base, measured from their common ancestor, the merge base (see Branches and merging). New commits on main do not appear as "removed" lines in your PR. They only matter when they conflict with yours (see Merge conflicts without panic).
Quick check
A reviewer asked for a fix, and you pushed two more commits to your branch. What do you need to do so the PR shows them?
A PR is a pointer to the head branch, not a copy of it. Every push shows up immediately and triggers the checks again. Opening a new PR would throw away the review history for no reason.
Writing a PR that gets a fast yes
Reviewers are busy. Your job as the author is to make "yes" easy and "no" obvious. Three habits do most of the work.
Keep it small. A PR that changes 80 lines gets read carefully. One that changes 1,500 lines gets skimmed and approved with "LGTM" (looks good to me). Many teams aim for under roughly 400 changed lines per PR, not counting lockfiles and generated code. Split big work into a chain: first the database change, then the API, then the screen. Each piece can be reviewed and merged on its own.
Write the description for a stranger. Say what changed and why, how you tested it, what the reviewer should look at first, and what could go wrong. Link the issue with a closing keyword such as Fixes #123, so the issue closes when the PR merges into the default branch (see Planning on GitHub). For anything a user can see, paste a screenshot or a short screen recording. A reviewer who can see the new Partner app receipt does not have to imagine it.
Use a draft while the work is unfinished. A draft pull request cannot be merged, and code owners are not asked to review it automatically. It is a good way to show early work, get CI running, and ask one question without asking for a full review. Click Ready for review when you are done.
Finally, review your own PR before anyone else does. Open the Files changed tab and read it as a reviewer would. You will find the leftover console.log, the file you did not mean to commit, and the test you forgot.
Quick check
Which PR description lets a reviewer start in under a minute?
The why, the issue link, the test plan and a starting point are what a reviewer cannot get from the diff. The file list is already on the Files changed tab, so repeating it adds nothing.
Reviewing: comments, suggestions and the three verdicts
In the Files changed tab you can comment on one line, or drag across several lines to comment on a block. Click the suggestion button in the comment box and GitHub inserts a suggestion code block with the selected lines, which you edit into what you want. The author (or anyone with write access) can then press Commit suggestion, or Add suggestion to batch to apply several as one commit. Suggestions turn "please rename this" into a one-click fix.
If you click Start a review instead of commenting right away, your comments stay pending and only you can see them. This lets you read the whole PR first and delete comments that the next file answers. Tick Viewed on each file you finish; it collapses, and if the author changes it later it unticks itself. When you are done, open Review changes, write a short summary, and pick one verdict:
- Comment: feedback only, no decision.
- Approve: you are happy for this to merge.
- Request changes: something must be fixed first. When branch rules require reviews, this blocks the merge until the reviewer approves or someone with write access dismisses the review.
When the author has dealt with a comment, the thread is closed with Resolve conversation. Teams can require every thread to be resolved before merging (see Branching strategies, releases and versions), which stops "I'll do it later" comments from disappearing.
Good review comments are specific and kind, and they say how important they are. A useful habit is a label at the start: nit: for a small style point that should not block, question: when you want to understand, blocking: when it must change before merge. Comment on the code, never the person ("this query runs once per sale" rather than "you wrote a slow query"). And agree as a team on response time, for example a first review within one working day. A PR waiting three days for review is three days of risk that it goes stale and conflicts.
Think first: you reviewed a PR, approved it, and the author then pushed two more commits. Does your approval still count?
By default, yes. GitHub keeps the approval even though you never saw the new code. That is why rulesets have options such as "Dismiss stale pull request approvals when new commits are pushed" and "Require approval of the most recent reviewable push". With either one on, the new commits need a fresh approval. You will set these in Branching strategies, releases and versions.
CODEOWNERS: asking the right people automatically
A CODEOWNERS file maps paths to the people or teams who own them. When a PR touches an owned path, GitHub requests a review from those owners. GitHub looks for it in .github/, then the repository root, then docs/, and uses the first one it finds. It reads the file from the PR's base branch, so a PR cannot change its own reviewers by editing CODEOWNERS.
Each line is a pattern (gitignore style) followed by owners. The last matching line wins, so put the general rule first and the specific ones below it. Owners must have write access to the repository, or the line is ignored. If the ruleset option "Require review from Code Owners" is on, an approval from any one of the listed owners for each changed path is enough. Treat it as a routing table: it makes sure the payment team sees payment code, without making every PR wait for everyone.
Quick check
A developer's PR edits .github/CODEOWNERS to remove the payments team from /src/payments/, and also changes a payment file. Who is asked to review the payment file?
CODEOWNERS is read from the base branch, so the rules in force are the ones already on main. A PR cannot route itself around its owners. That is also why owning /.github/ is a good idea: changes to the file itself need an owner's review.
Merge commit, squash or rebase: what lands on main
The green merge button has three modes, and each one leaves a different history on main. Repository admins choose which are allowed in Settings, under "Pull Requests": Allow merge commits, Allow squash merging and Allow rebase merging.
- Create a merge commit runs a merge with
--no-ff(see Branches and merging). All the PR's commits stay, and a new merge commit with two parents ties them in. You keep the true history, including "fix typo". - Squash and merge combines all the PR's commits into one new commit on
main. One PR becomes one commit, which is easy to read, to revert, and to bisect (see Reading code like a detective). The cost is that the individual commits are gone frommain. GitHub fills in the commit message from the PR title and commits, and admins can change that default. - Rebase and merge replays each commit onto the tip of
mainwithout a merge commit (see Rewriting history safely). History is linear and every commit is kept, but each one gets a new ID because its parent changed (see Inside the .git folder), and GitHub always records itself as the committer.
For most product teams, squash is the simplest default: one PR, one commit, a clean main, and Conventional Commit titles that a changelog tool can read (see Branching strategies, releases and versions). Teams with carefully crafted commits sometimes prefer rebase. One trap with squash: if you keep working on the same branch after it was squashed, your next PR will contain commits that are already on main in squashed form, and you get conflicts. Delete the branch after merging (turn on Automatically delete head branches) and start the next piece of work from a fresh main.
Quick check
A team writes careful, small commits and wants every one of them on main, but no merge commits. Which button fits?
Rebase and merge keeps each commit (with new IDs) in a straight line. Squash would fold them into one, and a merge commit is exactly what the team wants to avoid.
main as new commits with new IDs.| Merge method | Good for | Watch out |
|---|---|---|
| Merge commit | Full, true history; long-lived branches such as a release branch | Noisy main with "wip" commits; harder to read and bisect |
| Squash and merge | One PR = one commit; easy revert; clean changelog from PR titles | Loses individual commits; do not keep working on a squashed branch |
| Rebase and merge | Linear history that keeps well-made commits | New commit IDs; every commit must pass tests on its own |
How a PM reads a PR without reading every line
You do not need to review the code to review the change. Work through the page in this order and you will catch most product problems in about ten minutes:
- Title and linked issue. Does the title match what was asked? Open the issue and compare the acceptance criteria.
- Description. Is there a "why", a test plan and a risk note? No description is itself a finding.
- Screenshots. For any screen change, check the copy, the text in every language the app ships in, empty states and error messages.
- Checks. Green, or are any skipped or failing?
- The file list, not the code. Look for risky files: database migrations and
schema.prisma,.github/workflows/,package.jsonand the lockfile, environment or config files, anything under payments, auth or pricing. Then check that test files changed too. - Size. Thirty files for a copy change means something else is riding along.
Ask your questions as review comments, so the answers stay with the code. Unless your team agrees otherwise, use Comment rather than Approve: an approval counts toward the merge rule, and it should come from someone who has read the code.
Two habits make this faster. Use gh pr diff 42 --name-only to see only the file list from the terminal, and keep a saved search such as is:pr is:open label:enhancement for the PRs that touch your product. The Practice section below gives you a real-looking PR to read this way.
Build it
Add a PR template and CODEOWNERS to partner-api, then open, review and merge a PR from the terminal with the GitHub CLI (gh, set up in GitHub: remotes, clone, push, pull and authentication). The commands are the same in Git Bash, PowerShell and zsh. The Practice section has a browser exercise on closing keywords, the part of the description that links a PR to its issue.
-
Add a pull request template
GitHub fills every new PR description with this file. It must be merged into the default branch before it takes effect. The HTML comments are hints for the author and do not show in the rendered description.
Markdown<!-- .github/pull_request_template.md --> ## What and why <!-- One or two sentences. Link the issue: "Fixes #123" closes it on merge. --> Fixes # ## How I tested it - [ ] Unit tests added or updated - [ ] Ran it locally against the staging database <!-- Paste commands or steps a reviewer can repeat. --> ## Screenshots <!-- Required for any screen change (Store app, Partner app, dashboard). --> ## Risk and rollout - Touches: <!-- e.g. pricing, payments, migrations, CI --> - Safe to deploy during trading hours? yes / no - How to roll back: ## Reviewer notes <!-- Where to start reading, anything you are unsure about. --> -
Add CODEOWNERS
Replace
your-orgwith your GitHub organisation and use real team names. General rule first, specific rules after, because the last match wins. Owning/.github/means changes to workflows and to CODEOWNERS itself need a lead's eyes.Output# .github/CODEOWNERS # Default: the backend team reviews everything. * @your-org/backend # Database schema and migrations: backend leads. /prisma/ @your-org/backend-leads # Pricing and payments: their owners, plus the leads. /src/pricing/ @your-org/pricing @your-org/backend-leads /src/payments/ @your-org/payments @your-org/backend-leads # CI workflows, templates and this file: platform team. /.github/ @your-org/platformCommit both files on a branch and merge them like any other change. Then open
.github/CODEOWNERSon GitHub: lines with errors (for example a team without write access) are highlighted there. -
Open a draft PR from the terminal
Push your branch, then create the PR.
--draftkeeps code owners from being pinged until you are ready.--body-filereads the description from a file, so you can write it in VS Code.Terminalgit switch -c feature/festival-offer # ...edit, commit with Conventional Commit messages... git push -u origin feature/festival-offer gh pr create --draft --base main --title "feat(discount): festival offer at 10% for partner shops" --body-file pr-body.md --label enhancementghprints a line like the one below and then the PR's URL. Your organisation and PR number will differ.OutputCreating draft pull request for feature/festival-offer into main in your-org/partner-apiTerminalgh pr checks --watch # wait for CI on your branch gh pr ready # mark it ready: code owners are now requested gh pr view --web # open it in the browser to add screenshots -
Review someone else's PR
Check out the PR locally to run it, look at the file list first, then submit a verdict. Use the browser for line comments and suggestions; use the CLI for the verdict when that is quicker.
Terminalgh pr list --search "review-requested:@me" # PRs waiting for you gh pr checkout 42 # creates a local branch for PR 42 gh pr diff 42 --name-only # only the file list npm test # run it yourself if in doubt gh pr review 42 --request-changes --body "blocking: the migration locks the sale table; please add the index CONCURRENTLY" gh pr review 42 --approve --body "Tested locally with the festival offer sheet. Thanks!" -
Merge with squash and clean up
--squashpicks the method, and--delete-branchremoves the branch locally and on GitHub. If the checks are still running,--automerges as soon as all the rules are met (auto-merge must be enabled in the repository settings).Terminalgh pr merge 42 --squash --delete-branch # or, while checks are still running: gh pr merge 42 --squash --auto --delete-branch -
See the three merge methods with your own eyes
You can reproduce what each button does with plain Git in a scratch repository. Start with
mainat three commits andfeature/festival-offerholding three commits made from the second one, then run each method in its own clone.Terminalgit log --oneline --graph --allOutput* 23a53f9 docs: explain how to run tests | * 211e2b9 test(discount): cover festival rate | * f6d5bbb fix: typo | * e3c8809 feat(discount): add festival offer rate |/ * c3dfb8e docs: add README * 3377390 feat: add discount moduleTerminalgit merge --no-ff feature/festival-offer -m "Merge pull request #42 from your-org/feature/festival-offer" git log --oneline --graph mainOutputMerge made by the 'ort' strategy. discount.js | 1 + discount.test.js | 1 + 2 files changed, 2 insertions(+) create mode 100644 discount.test.js * 8155555 Merge pull request #42 from your-org/feature/festival-offer |\ | * 211e2b9 test(discount): cover festival rate | * f6d5bbb fix: typo | * e3c8809 feat(discount): add festival offer rate * | 23a53f9 docs: explain how to run tests |/ * c3dfb8e docs: add README * 3377390 feat: add discount moduleTerminalgit merge --squash feature/festival-offer git commit -m "feat(discount): add festival offer rate (#42)" git log --oneline --graph mainOutputAutomatic merge went well; stopped before committing as requested Squash commit -- not updating HEAD [main 4037fd3] feat(discount): add festival offer rate (#42) 2 files changed, 2 insertions(+) create mode 100644 discount.test.js * 4037fd3 feat(discount): add festival offer rate (#42) * 23a53f9 docs: explain how to run tests * c3dfb8e docs: add README * 3377390 feat: add discount moduleTerminalgit switch feature/festival-offer git rebase main git switch main git merge --ff-only feature/festival-offer git log --oneline --graph mainOutputSuccessfully rebased and updated refs/heads/feature/festival-offer. Updating 23a53f9..dac183d Fast-forward discount.js | 1 + discount.test.js | 1 + 2 files changed, 2 insertions(+) create mode 100644 discount.test.js * dac183d test(discount): cover festival rate * f67f1f6 fix: typo * 477f281 feat(discount): add festival offer rate * 23a53f9 docs: explain how to run tests * c3dfb8e docs: add README * 3377390 feat: add discount moduleCompare the IDs. The merge commit keeps
e3c8809,f6d5bbband211e2b9. Squash leaves none of them onmain. Rebase keeps the three messages, but as477f281,f67f1f6anddac183d: same changes, new parents, new IDs. Your IDs will differ, because they include your name and the time.
Bring it to your team
Use these to agree how PRs work on your team, then write the answers into the PR template and CONTRIBUTING file (see Your team's engineering playbook).
- "What is our size limit for a PR, and what do we do when a feature is bigger than that?"
- "What is our target time for a first review, and who covers when the owner is on leave?"
- "Which merge methods do we allow on main, and why? Can we turn on automatic branch deletion?"
- "Which paths are risky enough to need a specific owner in CODEOWNERS: migrations, payments, pricing, workflows?"
- "If someone pushes new commits after approval, does the approval still count? Should it?"
- "What must a PR description contain before you will review it: issue link, test plan, screenshots?"
Red flags
- PRs with thousands of changed lines approved within minutes.
- Authors merging their own PRs without any approval, or admins routinely bypassing the rules.
- Empty descriptions, or descriptions that only say "fixes".
- Workflow, migration or payment files changed with no owner asked to review.
- Review comments that attack the person, or a review queue nobody looks at for days.
Practice
Which issues will this PR close?
Mangrove's bot posts "this PR will close ..." on every new PR. Write issuesClosedOnMerge(pr, defaultBranch). pr is { base, body }. Return the issue references the PR will close when it merges, in order of first appearance, without duplicates. GitHub's rules: a keyword (close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved) in any case, as a whole word, optionally followed by a colon, then a space and a reference: #123 or owner/repo#123. Each reference needs its own keyword. Nothing closes unless the PR targets the default branch.
function issuesClosedOnMerge(pr, defaultBranch) {
// pr.base: the branch the PR merges into
// pr.body: the PR description
// return e.g. ['#123', 'your-org/store-app#88']
return [];
}
test('Fixes #123 into main closes #123', () => {
expect(issuesClosedOnMerge({ base: 'main', body: 'Fixes #123\n\nRounding moved.' }, 'main')).toEqual(['#123']);
});
test('keywords work in capitals and with a colon', () => {
expect(issuesClosedOnMerge({ base: 'main', body: 'CLOSES: #7 and resolved #9' }, 'main')).toEqual(['#7', '#9']);
});
test('each issue needs its own keyword; plain mentions do not close', () => {
expect(issuesClosedOnMerge({ base: 'main', body: 'Fixes #10, #12. Related to #5. The prefixes #4 are fine.' }, 'main')).toEqual(['#10']);
});
test('other repositories and duplicates', () => {
const body = 'Resolves your-org/store-app#88. Fixes #3. Also fixes #3.';
expect(issuesClosedOnMerge({ base: 'main', body }, 'main')).toEqual(['your-org/store-app#88', '#3']);
});
test('a PR into a release branch closes nothing', () => {
expect(issuesClosedOnMerge({ base: 'release/1.4', body: 'Fixes #123' }, 'main')).toEqual([]);
});
Show a solution
function issuesClosedOnMerge(pr, defaultBranch) {
if (pr.base !== defaultBranch) return []; // keywords act only on the default branch
const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+((?:[\w.-]+\/[\w.-]+)?#\d+)/gi;
const refs = [];
for (const m of pr.body.matchAll(re)) {
if (!refs.includes(m[1])) refs.push(m[1]);
}
return refs;
}Read this PR as a PM in ten minutes
You own the Store app's checkout receipt. This PR is waiting for review. Using the six-step read from this lesson, write three review comments and choose a verdict (Comment, Approve or Request changes).
#231 feat(receipt): show delivery slot on receipt
Description: "Adds the slot. Fixes #214"
Checks: lint passed, test (22) passed, test (24) passed, e2e skipped
Files changed (+412 -37):
src/receipt/receipt.ts
src/receipt/receipt.test.ts
prisma/schema.prisma
prisma/migrations/20261014_add_slot/migration.sql
.github/workflows/ci.yml
package.json
package-lock.json
Screenshots: none- You ask for a screenshot, because the receipt is something customers and store staff see
- You ask about the migration: is it safe during trading hours, and how would it be rolled back?
- You ask why
ci.ymlchanged and why the e2e check was skipped in a PR about a receipt - You ask what the
package.jsonchange adds, or ask for it to be split out - You compare the PR with the acceptance criteria in issue #214, or ask for a test plan in the description
- Your verdict is Comment (or Request changes for a clear blocker), not Approve, because you have not read the code
Show a model answer
1. "question: Could you add a screenshot of the receipt with a Now slot and a Later slot? Then we can check it against what issue #214 asked for."
2. "question: Does 20261014_add_slot lock the order table? Is it safe to run during trading hours, and what is the rollback?"
3. "question: Why does ci.yml change here, and is that why e2e was skipped? If the dependency in package.json is unrelated to the receipt, can it go in its own PR?"
Verdict: Comment. The answers decide whether a code owner should request changes; your role is to make sure the questions get asked and answered on the PR.
Check yourself
Your PR has six commits, including "wip" and "fix typo". The team wants each PR to appear on main as a single commit that can be reverted in one step. Which merge method fits?
Squash turns all six commits into one new commit on main, so one revert undoes the whole PR. A merge commit keeps all six plus the merge commit. Rebase keeps all six as separate commits with new IDs. Pushing to main skips review entirely.
CODEOWNERS has * @your-org/backend on line 1 and /prisma/ @your-org/data on line 5. A ready PR changes only prisma/schema.prisma. Who is asked to review?
In CODEOWNERS the last matching pattern takes precedence, so /prisma/ overrides * for that file. That is why general rules go at the top. Draft PRs are the opposite case: code owners are not requested until the PR is marked ready.
You approved a PR yesterday. Today the author pushed two commits that change the payment logic. The branch rules require one approval and nothing else. What is true?
Approvals stay by default. To force a fresh look, turn on "Dismiss stale pull request approvals when new commits are pushed" or "Require approval of the most recent reviewable push" in the ruleset (see Branching strategies, releases and versions). Pushing to an approved PR is always allowed.
Flashcards
Key takeaways
- A PR points at a branch. Every push updates it and re-runs the checks; the diff is measured from the merge base.
- Small PRs with a clear "why", a test plan, screenshots and a linked issue get faster and better reviews.
- Reviewers comment, suggest exact changes, and finish with Comment, Approve or Request changes. Resolve threads when they are done.
- CODEOWNERS routes reviews by path; the last matching line wins and owners need write access.
- Merge commit keeps everything, squash makes one commit per PR, rebase copies commits with new IDs. Pick one default as a team, and read PRs as a PM from the description and the file list.
Sources
- Pull requests documentation, GitHub Docs
- About code owners, GitHub Docs
- Linking a pull request to an issue (closing keywords), GitHub Docs
- GitHub CLI manual (
gh prcommands), GitHub