Pull Requests and Code Review with the GitHub CLI
You will open a pull request from the command line, run reviews, and merge only after checks pass. By the end you will have a review workflow and branch protection that keeps unreviewed code out of main.
A pull request is more than a merge button: it is the checkpoint where code gets read by a second pair of eyes, where CI proves the change builds and passes tests, and where the reasoning behind the change is recorded for future you. Doing the whole flow with the gh CLI means you never leave the terminal, and every stage, opening, reviewing, checking status, merging, is scriptable and repeatable. That repeatability is what turns "we should review code" into a process a team can actually rely on.
Learning Objectives
- Create and manage PRs with the gh CLI.
- Request reviews and respond to feedback.
- Require status checks and reviews before merge.
- Time: ~2 hours · Difficulty: Intermediate · Prereqs: a GitHub repo and the gh CLI.
Architecture Overview
The diagram shows the merge gate. A feature branch becomes a pull request with gh pr create. Two independent signals then feed the merge decision: the required status checks (CI) must be green, and at least one reviewer must approve. Only when both are true does branch protection allow the merge, through the web button or gh pr merge, to land the change on main. If either signal is missing, the merge is refused no matter how convincing the branch looks locally.
This matters because it removes "it worked on my machine" from the equation. The checks run on a clean runner with a fresh checkout, and the approval comes from a human who is not the author. Branch protection enforces both at the repository level, so the rule holds even if someone tries to push directly to main or merge with the web UI at 2 a.m.
The lab walks the loop in five moves: prepare a branch (step 00), open the PR (step 01), review and wait on checks (step 02), merge (step 03), then lock the gate with branch protection (step 04).
How It Works
How It Works
A pull request is a proposed merge wrapped in discussion: the diff, the checks, the comments, and the decision live together. That packaging is what turns 'please merge my branch' into a reviewable, auditable change record.
Status checks are CI results projected onto the PR. Because they run on the exact merge result, green checks mean the merged code was tested, which is why branch protection can safely require them before the merge button enables.
Merge strategies shape history: a merge commit preserves the PR as a unit, squash condenses it to one commit, and rebase keeps it linear. Teams pick one for consistency; the PR discussion stays readable regardless of the method.
Environment Setup
Prerequisites
- A GitHub repository you can push to and open PRs in
- The
ghCLI authenticated (gh auth login) - Permission to manage branch protection rules (repo admin)
- Assumed skill: feature branches and pushing (see Git Fundamentals)
You will need: the gh CLI authenticated (gh auth login) and a repo.
Before you begin: enable branch protection on main in repo settings.
Install gh if you do not have it, then confirm you are authenticated against the right account and host. The status output also prints the token scopes, you need the repo scope for private repositories and workflow if your PRs touch GitHub Actions files.
INSTALL AND AUTHENTICATE gh
# macOS
brew install gh
# authenticate against github.com (browser flow is easiest)
gh auth login --hostname github.com --git-protocol https --web
# verify
gh auth status
Common mistake: authenticating with one GitHub account while the repo belongs to another. gh auth status shows the active account for each host; switch with gh auth switch if you have more than one.
Work from a local clone so gh can infer the repository from your git remote. Every gh pr command below assumes you run it inside the clone; otherwise you must pass --repo owner/name to each call.
CLONE THE REPO
gh repo clone TYFSADIK/demo-api
cd demo-api
git remote -v # origin must point at the GitHub repo
How to verify: gh repo view --json name,owner should print the repo you expect. If it errors with "not a git repository", you are in the wrong directory.
Step-by-Step Execution
A PR is a conversation about a branch, so before you can open one you need a branch with commits on it, pushed to GitHub. Keep the branch small and single-purpose, one endpoint, one fix, so the review stays quick and the diff stays readable. Naming conventions like feature/ and fix/ make the intent obvious in the PR list before anyone clicks.
BRANCH, COMMIT, PUSH
git checkout -b feature/api
# ...make your changes...
git add -A
git commit -m "Add /health endpoint"
git push -u origin feature/api
The -u flag sets the upstream so later git push calls need no arguments, and so gh knows which remote branch the PR should track when it suggests defaults.
How to verify: git status should report Your branch is up to date with 'origin/feature/api' with a clean working tree. Common mistake: if the push is rejected as non-fast-forward, someone else pushed to the same branch name, pick a unique one instead of force-pushing over a teammate's work.
What this teaches: The branch is the unit of review; pushing it publishes the proposal for others to examine.
Opening the PR is where you sell the change. The title shows up in the PR list and becomes the squash-merge commit message, so make it a sentence a stranger could act on. The body should answer three questions: what changed, why, and how you tested it. Reviewers read the body before the diff; a blank body gets a shallow review.
gh pr create --base main --head feature/api --title "Add API" --body "Implements endpoint"For anything non-trivial, write the body in a file instead of cramming it into one flag. Markdown renders in the PR, and a Closes #17 line auto-links the issue and closes it on merge:
PR BODY WITH CONTEXT (.github/pull_request.md)
gh pr create --base main --head feature/api \
--title "Add /health endpoint for uptime checks" \
--body-file .github/pull_request.md
# .github/pull_request.md
## What
Adds GET /health returning { "status": "ok" }.
## Why
The load balancer needs a lightweight liveness probe. Closes #17.
## How tested
curl localhost:8080/health locally + a new pytest case.
gh prints the PR URL, that is your confirmation it worked. Open it in the browser with gh pr view --web, or read it without leaving the terminal:
CONFIRM THE PR EXISTS
gh pr status # PRs for your branches: review state, checks
gh pr view 42 # title, body, reviewers, check rollup
Common mistake: running gh pr create before pushing the branch, gh errors with Head sha can't be blank because the remote has no such head. Push first (step 00), then create. How to verify: gh pr status lists the PR under "Current branch" with a checks summary.
What this teaches: The PR description is the pitch: what changed, why it matters, and how to verify it.
Two gates must go green: automated checks and human review. Check the CI rollup first, there is no point asking a person to read code that does not compile. gh pr checks lists every required and optional check with pass/fail; add --watch to follow a run in real time until it finishes.
In a real team flow the author requests reviewers and a teammate does the review. You can request reviewers at creation or after the fact, and read the exact diff under review before passing judgment:
REQUEST REVIEW AND READ THE DIFF
gh pr edit 42 --add-reviewer alice,team-leads
gh pr diff 42 # full diff, paginated
gh pr view 42 --comments # discussion so far
Reviews come in three flavours, and picking the right one is half of review culture:
THE THREE REVIEW MODES
gh pr review 42 --approve --body "LGTM - probe returns 200 in 3ms"
gh pr review 42 --request-changes --body "Handle the DB-down case before merge"
gh pr review 42 --comment --body "Question: why not reuse readiness.py?"
--request-changes is a blocking review: with branch protection on, the PR cannot merge until that reviewer dismisses the block or re-approves. --comment records feedback without affecting the merge gate, use it for questions, not requirements.
When feedback arrives, fix it on the same branch and push. The PR updates itself, CI re-runs, and the conversation threads stay attached to the lines they were about. Never open a second PR for review fixes, that throws away the discussion history.
RESPOND TO FEEDBACK
git checkout feature/api
# ...address the review comments...
git commit -am "Handle DB-down case in /health"
git push # PR #42 picks up the new commit automatically
gh pr checks 42 --watch # confirm CI re-runs and goes green
How to verify: gh pr view 42 should show Reviewers: alice (Approved) and a green check rollup. Note: GitHub will not let you approve your own PR as a satisfying review, on a solo repo, test the flow with a second account or a teammate.
What this teaches: Status checks report CI results directly on the PR; red checks mean 'not yet', not 'no'.
Only merge when both gates are green. The squash strategy compresses the whole branch into one commit on main, which keeps history linear and makes git revert a single-commit operation if the change misbehaves in production. --delete-branch removes the feature branch on both the remote and your local clone.
gh pr merge --squash --delete-branchPick the strategy deliberately, it shapes your history forever:
--squash: one commit per PR. Best default for feature work with messy "wip" commits.--merge: keeps every commit plus a merge commit. Good for release branches where individual commits matter.--rebase: replays the commits onto main with no merge commit. Linear history, but it rewrites commit SHAs.
After merging, sync your local main so the next branch starts from the merged state:
SYNC LOCAL main
git checkout main
git pull --prune # fetch the squash commit, prune deleted remote branches
Common mistake: merging with --admin to bypass protection when checks are red. It works, and it defeats the entire point of the gate, if a check is flaky, fix the check, do not route around it. How to verify: git log main --oneline -1 shows one new commit whose message matches the PR title.
What this teaches: Merging after green checks keeps main releasable; the merge records the review decision in history.
Everything above is convention until branch protection makes it a rule. Protection on main requires PRs, at least one approval, and passing checks, and applies the same rules to admins, which is the whole point. Set it once per repository, in Settings > Branches or with the API:
ENABLE BRANCH PROTECTION ON main
gh api repos/TYFSADIK/demo-api/branches/main/protection -X PUT \
--input - <<'EOF'
{
"required_status_checks": {
"strict": true,
"contexts": ["test"]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": true
},
"restrictions": null
}
EOF
strict: true means the branch must be up to date with main before merging, so the checks ran against what will actually land. dismiss_stale_reviews invalidates an approval when new commits arrive, closing the "approve, then sneak in one more commit" hole. The check context string (test) must match the exact job name the workflow reports, the workflow / job form only applies to reusable workflows, or the gate waits forever on a check that never reports.
VERIFY THE RULE
gh api repos/TYFSADIK/demo-api/branches/main/protection \
--jq '.required_pull_request_reviews.required_approving_review_count,
.required_status_checks.strict,
.enforce_admins.enabled'
How to verify: open a toy PR with a deliberately failing check and confirm gh pr merge is refused with a message about required status checks. Also try git push origin main directly, the remote should reject it. If either succeeds, the protection rule did not take effect.
What this teaches: Branch protection turns the convention into a rule: nobody can merge past red checks, including you.
Progress So Far
The three core moves, open, review, merge, are the loop you will run dozens of times a week. Steps 00 and 04 bookend it: a well-prepared branch makes the PR easy to review, and branch protection makes the loop mandatory rather than optional.
Anatomy of a Good PR
Reviewers decide how carefully to read based on signals you control. A PR that gets a fast, useful review usually has all of these:
- Small diff: under ~400 lines changed. Bigger than that and reviewers skim; split large work into stacked PRs.
- Single purpose: one feature or one fix. "Add endpoint + refactor logging + bump deps" forces the reviewer to hold three conversations at once.
- A body that answers what / why / how-tested, the template from step 01.
- Green checks before review is requested: asking a human to read code CI has already rejected wastes the most expensive resource in the loop.
- Linked issue:
Closes #17ties the change to the reason it exists and auto-closes the ticket on merge.
The inverse is also a skill: as a reviewer, a PR missing these signals is itself feedback. Ask for a split or a better description before diving into the diff, that is a legitimate --comment review.
Draft PRs and Work in Progress
Open a PR as a draft when you want CI feedback and early eyes but the change is not ready to merge. Drafts cannot be merged, which makes them a safe way to share half-finished work without risking an accidental merge:
DRAFT PR WORKFLOW
gh pr create --draft --title "WIP: caching layer" --body "Early feedback welcome"
# ...keep pushing commits; CI runs on every push...
gh pr ready # flip to ready-for-review when done
gh pr ready --undo # back to draft if review uncovers big problems
Drafts are also the answer to "how do I run CI without merging": the checks run on every push to the PR branch whether or not the PR is a draft, so you get the full pipeline signal long before a reviewer is involved.
Keeping a PR Up to Date with main
PRs age. While yours waits for review, main moves, and with strict=true in branch protection you must be current before merging. Two ways to catch up, both ending in a force-free push:
UPDATE THE PR BRANCH
# option A: merge main in (keeps history, adds a merge commit)
git checkout feature/api
git fetch origin
git merge origin/main
git push
# option B: rebase onto main (linear, but rewrites the branch)
git fetch origin
git rebase origin/main
git push --force-with-lease
Prefer merge on shared branches and rebase on branches only you touch; --force-with-lease (never plain --force) refuses to overwrite commits someone else pushed since your last fetch. Either way, expect CI to re-run and, with dismiss_stale_reviews on, expect to ask for a re-approval.
How to verify: mergeable reports MERGEABLE and the approval count is back above your required threshold before you reach for gh pr merge.
The Reviewer's Side
Reviews are easier to do well when you can run the code, not just read it. gh can check out any PR into a local branch in one command, so you can run the tests, poke the endpoint, and then leave your verdict:
CHECK OUT AND TEST A PR LOCALLY
gh pr checkout 42 # fetch PR head into a local branch
pip install -r requirements.txt
pytest -q # run the suite against the proposed change
curl -s localhost:8080/health # manual smoke test
When you are done, git checkout main drops you back; the PR branch is disposable. Review the behavior first, the diff second, a change that reads beautifully but breaks the smoke test still gets --request-changes.
Review etiquette worth adopting early: comment on the code, not the author ("this branch dereferences null" beats "you broke this"); distinguish blockers from nits explicitly (nit: prefix means "optional, your call"); and respond to every review comment, even if only with "done in 3f2a1b9", silence forces the reviewer to re-read the diff to find out what changed.
Testing & Validation
gh pr list --state merged --limit 1 && git log main --oneline -1You should see the merged PR and the squashed commit on main. With protection on, an unreviewed or failing PR cannot merge.
FULL VALIDATION SWEEP
gh pr list --state merged --limit 5 --json number,title,mergedAt \
--jq '.[] | "#\(.number) \(.title) - \(.mergedAt)"'
git log main --oneline -3
gh api repos/TYFSADIK/demo-api/branches/main/protection --jq '.enforce_admins.enabled'
For a negative test, push a branch with a deliberately failing test and open a PR. The merge command must refuse. If it does not, your protection rule or required check context is wrong, revisit step 04.
Finally, confirm the audit trail is intact: the PR page should show the full conversation, the approving review, the green check rollup, and the squash commit linking back to the PR number. That trail is what you will dig through six months from now when the endpoint misbehaves, if it is all there, the lab is done.
gh PR Command Cheat Sheet
The commands from this lab, collected for reference. Everything assumes you are inside the repo clone; add --repo owner/name to run them from anywhere:
EVERYDAY gh PR COMMANDS
gh pr create --base main --fill # open PR, reuse commit messages
gh pr status # your PRs + review/check state
gh pr list --state open # everything waiting on someone
gh pr view 42 --web # open in browser
gh pr diff 42 # read the patch
gh pr checkout 42 # run it locally
gh pr checks 42 --watch # follow CI until it finishes
gh pr review 42 --approve # or --request-changes / --comment
gh pr merge 42 --squash --delete-branch # merge + tidy up
gh pr ready 42 # flip a draft to ready-for-review
Troubleshooting
- Merge button blocked: branch protection requires checks/reviews; satisfy them first.
- gh not authenticated: run
gh auth loginand select the right account. - Stale branch after merge: use
--delete-branchto keep the repo tidy. Head sha can't be blankon create: the branch was never pushed, or you are on the wrong branch. Rungit push -u origin <branch>first, and confirm withgit branch --show-current.- Required check never appears in the PR: the
contexts[]name in the protection rule must match the check's reported name exactly (job name, not workflow file name). List what the PR actually reports withgh pr checks --json name,stateand copy the string. - Approval does not unblock the merge: self-approvals never satisfy required reviews, and
dismiss_stale_reviewsvoids approvals given before the latest push. Have a teammate re-approve after the final commit. gh pr mergesays the PR is not mergeable: usually a conflict with main. Rebase or merge main into your branch (git fetch origin && git merge origin/main), resolve, push, and wait for checks to re-run.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| gh: command not found | the GitHub CLI is not installed | install it from cli.github.com, then gh auth login |
| PR shows no checks | no workflow runs on the PR's events | ensure the workflow triggers on pull_request and the file is on the default branch |
| cannot merge: reviews required | branch protection demands approvals | request a review; the rule is doing its job |
| merge button offers unexpected method | repo allows merge, squash, and rebase | pick the team's agreed method in the dropdown; consistency beats preference |
| PR from a fork cannot access secrets | fork PRs run with restricted permissions | use pull_request_target carefully or require maintainer-triggered runs |
Extension Ideas
- Add automated checks in GitHub Actions so the required check has something real to run.
- Require CODEOWNERS review for sensitive paths, a
CODEOWNERSfile routes PRs touching/infra/or auth code to specific reviewers automatically. - Auto-merge once checks pass with
gh pr merge --auto, so approved PRs land the moment CI goes green without anyone babysitting the queue. - Adopt a PR template (
.github/pull_request_template.md) so every body starts with the what/why/how-tested structure from step 01.
Practice Challenges
Challenge 1: Review checklist
Goal: Write a PR template with a review checklist (tests, docs, security) and open a PR that fills it in.
Hint: Templates live in .github/pull_request_template.md and apply to every new PR.
Challenge 2: CODEOWNERS
Goal: Add a CODEOWNERS file so the right team is auto-requested on changes to critical paths.
Hint: Test it with a PR touching a covered path and confirm the review request appears.
Key Results
- Opened and merged a PR entirely from the CLI.
- Enforced review approval before merge.
- Required passing checks via branch protection.
- Kept main clean with squash + branch deletion.
Key Takeaways
- A PR is a gate, not a formality: it combines human review, automated checks, and a permanent record of why a change happened.
- Push the branch before
gh pr create, and write a body that answers what, why, and how-tested: reviewers read it before the diff. - Check CI before requesting review; use
--request-changesto block,--commentto ask, and push fixes to the same branch. - Squash-merge plus
--delete-branchkeeps main linear, revertible, and free of stale branches. - Branch protection is what turns the workflow from habit into policy: set it once, include admins, and verify it with a deliberately failing PR.