Tech Lead NotesRohit’s interview study guide
Chapter 02

Code Review

What review is for, a checklist for reading a pull request, writing comments people can act on, keeping pull requests small and reviews fast across time zones, letting tools do the boring checks, and reviewing code an AI assistant wrote.

6 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What code review is for

Added

Code review is a second engineer reading a change before it merges, and the two of them agreeing it leaves the codebase better than it was. Most teams say the point is catching bugs. The research says that's only part of it, and not the biggest part. In Modern Code Review: A Case Study at Google (Sadowski et al., ICSE SEIP 2018, nine million reviewed changes), Google engineers described four expectations: education (teaching and learning), maintaining norms (style, API usage, the team's way of doing things), gatekeeping (no one commits arbitrary code without oversight) and accident prevention (bugs and defects), plus using review history later to understand how a change came about. The paper's first finding is that review there "does not center around problem solving": it was introduced to keep code readable and maintainable, and defect finding is "welcomed but not the only focus".

Microsoft's numbers point the same way. Czerwonka, Greiler and Tilford (Code Reviews Do Not Find Bugs, ICSE SEIP 2015) report that only about 15% of review comments point to a possible defect, while at least half are about long-term maintainability. Bacchelli and Bird (Expectations, Outcomes, and Challenges of Modern Code Review, ICSE 2013) found that developers expect review to find defects, but what it mostly delivers is code improvements, knowledge transfer and team awareness. The 15% figure is about what comments say, not how many bugs reviews catch, so don't quote it as "reviews don't find bugs"; quote it as "most of review's value isn't bug-finding".

A useful mental model: a reviewer is an editor, not a proofreader. A proofreader catches typos and house-style slips, which a spell checker can do. An editor asks whether the chapter should exist, whether the argument holds, whether a reader will follow it, and whether a fact is wrong. In code, the spell checker is your formatter, linter, type checker and tests (see Automating the boring parts); the human reviewer edits. And like a good editor, the reviewer comes away knowing the book: after the review, two people understand the change instead of one.

PurposeWhat it looks like in a reviewWhat it isn't
Accident prevention"This deletes the row before the upload succeeds; a failed upload loses the data."A substitute for tests. If the reviewer found it by reading, a test should pin it.
Design and maintainability"This puts device state in two places; can the poller read it from the store?"A redesign. Big design disagreements belong in a design doc before the code.
Knowledge sharing"There's a withTimeout helper in http.js that clears its timer; use it here."A lecture. Link the helper or the doc; keep it to what this change needs.
Norms"Errors use the problem() shape so the UI can show detail."Personal taste. If it isn't in the standard or the surrounding code, it's at most a nit.
Gatekeeping and history"Who can call this? It reboots a device in a live meeting." Later: "why did we add this check?" → the review thread.A power move. Holding approvals hostage is a breakdown the Google paper names explicitly.

Here is a real change, made in a throwaway Git repository so the diff is exactly what a reviewer would see. main has a shared http.js with two helpers; the branch adds an endpoint that reboots a conference-room device:

review-purpose.mjs
// A real pull request in a throwaway repository: main has shared helpers, the branch adds an endpoint.
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "review-"));
const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
const git = (when, ...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8",
  env: { ...env, GIT_AUTHOR_NAME: "Priya", GIT_AUTHOR_EMAIL: "priya@example.com", GIT_COMMITTER_NAME: "Priya",
    GIT_COMMITTER_EMAIL: "priya@example.com", GIT_AUTHOR_DATE: when, GIT_COMMITTER_DATE: when } });
const write = (path, text) => { mkdirSync(join(dir, path, ".."), { recursive: true }); writeFileSync(join(dir, path), text); };

git("2026-10-05T09:00:00Z", "init", "-q", "-b", "main");
write("src/http.js", `// Shared helpers every handler uses.
export const problem = (status, title, detail) => ({ status, body: { type: "about:blank", title, status, detail } });

export function withTimeout(promise, ms, label) {
  let timer;
  const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(\`\${label} timed out\`)), ms); });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
`);
write("src/devices.js", `import { problem } from "./http.js";

export async function getDevice(store, id) {
  const device = await store.find(id);
  if (!device) return problem(404, "Device not found", \`No device \${id}\`);
  return { status: 200, body: device };
}
`);
git("2026-10-05T09:00:00Z", "add", ".");
git("2026-10-05T09:00:00Z", "commit", "-q", "-m", "Device lookup endpoint");

git("2026-10-06T10:00:00Z", "switch", "-q", "-c", "reboot-device");
write("src/devices.js", `import { problem } from "./http.js";

export async function getDevice(store, id) {
  const device = await store.find(id);
  if (!device) return problem(404, "Device not found", \`No device \${id}\`);
  return { status: 200, body: device };
}

export async function rebootDevice(store, agent, id, user) {
  const device = await store.find(id);
  if (!device) return { status: 404, body: { error: "not found" } };
  await Promise.race([
    agent.reboot(device.address),
    new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
  ]);
  await store.audit("reboot", id);
  return { status: 202, body: { id, state: "rebooting" } };
}
`);
git("2026-10-06T10:00:00Z", "commit", "-q", "-am", "Add reboot endpoint");

console.log(git("2026-10-06T10:00:00Z", "log", "--oneline", "--graph", "--all"));
console.log(git("2026-10-06T10:00:00Z", "diff", "--stat", "main...reboot-device"));
console.log(git("2026-10-06T10:00:00Z", "diff", "main...reboot-device"));
rmSync(dir, { recursive: true, force: true });
Result
* cc77944 Add reboot endpoint
* 1974ba3 Device lookup endpoint

 src/devices.js | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/src/devices.js b/src/devices.js
index c3c338f..54c1ec9 100644
--- a/src/devices.js
+++ b/src/devices.js
@@ -5,3 +5,14 @@ export async function getDevice(store, id) {
   if (!device) return problem(404, "Device not found", `No device ${id}`);
   return { status: 200, body: device };
 }
+
+export async function rebootDevice(store, agent, id, user) {
+  const device = await store.find(id);
+  if (!device) return { status: 404, body: { error: "not found" } };
+  await Promise.race([
+    agent.reboot(device.address),
+    new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
+  ]);
+  await store.audit("reboot", id);
+  return { status: 202, body: { id, state: "rebooting" } };
+}
What’s happening
  1. The model builds a real repository: one commit on main with http.js (the team's problem() error shape and a withTimeout helper that clears its timer) and devices.js, then a branch reboot-device with one commit. Author, committer and dates are fixed, so the hashes (cc77944, 1974ba3) are the same every run. GIT_CONFIG_GLOBAL=/dev/null keeps anyone's personal Git settings out of it.
  2. git diff main...reboot-device (three dots) is what a pull request page shows: the changes on the branch since it left main, ignoring anything that landed on main meanwhile. The hunk header @@ -5,3 +5,14 @@ export async function getDevice… says the old file's lines 5–7 became lines 5–18 of the new one; Git prints the nearest function name as context.
  3. Read it as an editor. Accident prevention: user is passed in and never used, so anyone who can call the API can reboot a room in the middle of a meeting. Norms: the 404 returns { error: "not found" }, not the problem() shape every other handler uses, so the UI's error banner will show nothing useful.
  4. Knowledge sharing: the author hand-rolled a timeout with Promise.race and a bare setTimeout. It works, but the timer is never cleared, so every successful reboot leaves a five-second timer behind (proved in the next box), and the error says only "timeout". The team already has withTimeout for exactly this; the author didn't know, which is the cheapest thing a review can fix.
  5. Gatekeeping and history: store.audit("reboot", id) records what happened but not who did it. When someone asks in three months why a room rebooted during a board meeting, the audit log and this review thread are the history.
  6. None of these is a typo a linter would catch, and only one (the permission check) is a classic "bug". That's the research in miniature: the review's value here is mostly norms, shared knowledge and safety, with a defect thrown in.
AdvancedThe review comments on the reboot PR

These are the comments that review leaves, in the Conventional Comments format explained in Giving and receiving feedback (label, whether it blocks, then the why):

Review comments on "Add reboot endpoint"
src/devices.js:9   issue (blocking): any signed-in user can reboot any device.
  `user` is passed in but never checked. Rebooting drops a live call, so this needs the same
  rule as the admin UI: return problem(403, …) unless the user has the device-admin role
  for the device's site. Please add a test for the viewer case so it stays fixed.

src/devices.js:12  suggestion (non-blocking): use withTimeout from http.js.
  It clears its timer and names what timed out ("reboot timed out"). The Promise.race here
  leaves a 5 s timer pending after every successful reboot.

src/devices.js:11  todo: return problem(404, "Device not found", …) like getDevice does.
  The UI reads `detail` from that shape; { error } shows an empty banner.

src/devices.js:13  question: what should the caller see when the device is offline?
  Right now agent.reboot's rejection becomes a 500. Is 504 with a problem body more useful
  for the room-status page?

src/devices.js:16  suggestion: record who did it: store.audit("reboot", id, user.id).

general  chore: no test file changed. A test for 202, 403 and 404 is enough.

src/devices.js:17  praise: 202 with { state: "rebooting" } is the right answer for an
  action that finishes later. Nice.

Summary: requesting changes for the permission check and the tests; the rest are small.
Happy to look again today, or to pair for 15 minutes in the overlap if that's quicker.
What’s happening
  1. Each comment names a line, a label and a decoration. The author can sort them in ten seconds: two things block (issue (blocking) and the chore for tests), one todo is a small required fix, and the rest are suggestions, a question and praise.
  2. Every comment says why, not just what: "rebooting drops a live call", "the UI reads detail". The why is what turns a review into teaching; next time the author applies the rule without being told.
  3. The comment on the timeout points at the existing helper instead of rewriting the code for the author. Google's guide puts it this way: the developer is responsible for fixing the change; the reviewer's job is to point at the problem and help.
  4. The question is a real question: the reviewer doesn't know the right status for an offline device and says so, instead of dressing an opinion up as a rule.
  5. The summary states the verdict, what to fix before approval, and offers a faster channel. On a US–India team that offer matters: a 15-minute call in the overlap can save a whole day of round trips (see Small pull requests and fast reviews).
AdvancedProving the timer leak before you claim it

The suggestion above claims the hand-rolled Promise.race leaves timers behind. A claim in a review should be checkable, so here it is, checked. Node's process.getActiveResourcesInfo() lists what's keeping the event loop alive:

timer-leak.mjs
// The branch's hand-rolled timeout vs the team's withTimeout helper. What does each leave behind?
function handRolled(promise, ms) {
  return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]);
}
function withTimeout(promise, ms, label) {
  let timer;
  const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out`)), ms); });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

const agent = { reboot: async () => "ok" }; // a healthy device answers at once
const pendingTimers = () => process.getActiveResourcesInfo().filter((r) => r === "Timeout").length;

for (let i = 0; i < 100; i++) await handRolled(agent.reboot(), 300);
console.log(`hand-rolled: 100 reboots finished, ${pendingTimers()} timers still pending`);
await new Promise((resolve) => setTimeout(resolve, 350));
console.log(`350 ms later: ${pendingTimers()} timers pending (they fired and rejected into nothing)`);

for (let i = 0; i < 100; i++) await withTimeout(agent.reboot(), 300, "reboot");
console.log(`withTimeout: 100 reboots finished, ${pendingTimers()} timers still pending`);

const slow = { reboot: () => new Promise((resolve) => setTimeout(resolve, 1000)) };
const errors = await Promise.allSettled([handRolled(slow.reboot(), 50), withTimeout(slow.reboot(), 50, "reboot")]);
console.log("error messages:", errors.map((e) => e.reason.message));
Result
hand-rolled: 100 reboots finished, 100 timers still pending
350 ms later: 0 timers pending (they fired and rejected into nothing)
withTimeout: 100 reboots finished, 0 timers still pending
error messages: [ 'timeout', 'reboot timed out' ]
What’s happening
  1. Both functions race the real call against a timer. handRolled is the branch's code; withTimeout is the helper already on main, which keeps a handle to its timer and clears it in .finally().
  2. After 100 fast reboots with handRolled, 100 timers are still pending: each one will fire 300 ms later and reject a promise nobody is listening to any more. In a server handling thousands of requests, that's thousands of live timers and closures held for five seconds each, for nothing.
  3. Once they've all fired, the count drops to 0. Nothing crashed, which is why this survives testing: the leak is only visible as memory and event-loop work under load.
  4. With withTimeout, the count is 0 right after the loop: .finally(() => clearTimeout(timer)) cancels each timer as soon as the reboot answers.
  5. The last line shows the second difference: on a real timeout the messages are "timeout" and "reboot timed out". When this shows up in a log at 2 a.m., the second one tells you what timed out. That's the kind of thing the reviewer knows because they've debugged it before, and the review is how that knowledge spreads.
AdvancedWhat review isn't, and the breakdowns to avoid
  • Not where design gets decided. If the reviewer's real comment is "this whole approach is wrong", the team skipped a step. Agree the approach in a short design note, an issue comment or a 15-minute call before the code (see Architecture decision records). The Google paper lists "review subject" as a breakdown: some teams expected design to be settled before the first review, others wanted to discuss it in the review, and the mismatch caused friction.
  • Not a replacement for tests or QA. A reviewer reading 300 lines finds some bugs; a test runs on every change forever. When a review finds a bug, ask for the test that would have caught it.
  • Not the lead's approval stamp. If every change waits for the lead, the lead is the bottleneck and the team learns nothing about each other's code. Rotate reviewers, use CODEOWNERS for areas that truly need an expert, and review the reviews instead of every diff.
  • Not a power tool. The Google paper names two social breakdowns: tone (authors are sensitive to comments; the paper cites earlier sentiment research finding that negatively worded comments are less likely to be useful) and power (dragging out reviews or withholding approval to make someone change behaviour). Both are a lead's problem to notice and fix.
  • Not context-free. The paper's "context" breakdown: reviewers didn't know whether a change was an urgent production fix or a nice-to-have, so they reviewed it with the wrong urgency. A PR description that says why, and how urgent, prevents it.
AdvancedRubber-stamp approvals
Which purpose does each comment serve?

Match each comment to education, norms, gatekeeping or accident prevention:

  1. "If the upload fails after this line, we've already deleted the old file."
  2. "We name REST collections in the plural: /devices, not /device."
  3. "TIL Intl.RelativeTimeFormat — nice, I didn't know about that."
  4. "This changes who can call the billing export. Can someone from the payments team look?"
Show answer

1 is accident prevention, 2 is norms, 3 is education (going the other way), 4 is gatekeeping.

What’s happening
  1. Comment 1 describes a sequence that loses data on a failure path. That's a defect: accident prevention, and it should come with a request for a test of the failure path.
  2. Comment 2 isn't about correctness at all: /device works. It's about the team's agreed convention, so it's norms, and it's only blocking if the convention is written down (ideally enforced by a linter or an API style check).
  3. Comment 3 is the reviewer learning from the author. Education runs both ways; seniors learn from juniors' reviews too, and saying so is good for the team.
  4. Comment 4 is gatekeeping in the good sense: a boundary around a sensitive area, with the right owners pulled in. CODEOWNERS automates exactly this (see Automating the boring parts).

#What to look for in a review

Added

A review goes wrong in two opposite ways: the reviewer skims and approves, or the reviewer drowns the author in thirty comments about names while missing that the change shouldn't exist. The fix for both is an order. Google's reviewer guide (What to look for in a code review, google.github.io/eng-practices, checked October 2026) lists design first, then functionality, complexity, tests, naming, comments, style, consistency, documentation, reading every line, the surrounding context, and the good things worth praising. The order matters more than the list.

The mental model is zoom levels on a map. First the country: should this change exist, and is it in the right place in the system? Then the city: does it do what it claims, for every input, and do the tests prove it? Then the street: is it readable, named well, consistent? A comment about a variable name in a file that should be deleted is wasted, and a perfectly named function with an SQL injection is still a blocker. Zoom in only after the level above is fine, and say so in the review ("approach looks right; a few things in the query").

Here is the checklist, grouped by zoom level. It's meant to be skimmed, not recited: most changes only need a few rows.

ZoomAskTypical blocking finding
ContextWhat problem does this solve, and how urgent is it? Is the description enough to review it?No description on a 400-line change: ask for one before reading.
DesignDoes it belong here? Does it duplicate something that exists? Is it simpler than the alternatives? Is anything built for a future that isn't needed yet?A second cache of device state that will drift from the first.
CorrectnessEvery input: empty, one, many, huge, malformed, concurrent, retried. Error paths, timeouts, partial failure.Paging that skips the first page; a retry that sends a command twice.
TestsWould these tests fail if the code were wrong? Do they cover the edge cases above, not just the happy path?A test that asserts the SQL contains site_id = 7 and nothing else.
SecurityAuthn and authz on every new endpoint, input reaching SQL, shell, HTML or logs, secrets in code or logs, new dependencies.Search text concatenated into SQL.
Data and compatibilityAPI changes: breaking for existing clients? Migrations: reversible, safe on a big table, deployable before the code?Renaming a JSON field the device firmware still reads.
OperabilityLogs that help at 2 a.m., metrics, feature flag, rollback plan, config defaults.A new background job with no way to switch it off.
ReadabilityNames, function size, comments that explain why, consistency with nearby code.Rarely blocking. Mostly nitpick or suggestion.

And here is a pull request to use it on: the admin UI needs a paged, searchable list of devices for a site. The model makes the change in a throwaway repository and prints what the PR page would show:

review-checklist.mjs
// The pull request under review: a paged device list for the admin UI. Printed as the PR page shows it.
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "review-"));
const when = "2026-10-07T11:00:00Z";
const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
  GIT_AUTHOR_NAME: "Karthik", GIT_AUTHOR_EMAIL: "karthik@example.com", GIT_AUTHOR_DATE: when,
  GIT_COMMITTER_NAME: "Karthik", GIT_COMMITTER_EMAIL: "karthik@example.com", GIT_COMMITTER_DATE: when };
const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", env });
const write = (path, text) => { mkdirSync(join(dir, path, ".."), { recursive: true }); writeFileSync(join(dir, path), text); };

git("init", "-q", "-b", "main");
write("src/devices/query.js", "// Device list queries.\n");
git("add", ".");
git("commit", "-q", "-m", "Start device queries module");

git("switch", "-q", "-c", "device-list-paging");
write("src/devices/query.js", `// Device list queries.

export function listDevicesQuery({ siteId, search = "", page = 1, size = 50 }) {
  const offset = page * size;
  let sql = \`SELECT id, name, status FROM devices WHERE site_id = \${siteId}\`;
  if (search) sql += \` AND name LIKE '%\${search}%'\`;
  sql += \` ORDER BY name LIMIT \${size} OFFSET \${offset}\`;
  return sql;
}
`);
write("test/query.test.js", `import { test } from "node:test";
import assert from "node:assert/strict";
import { listDevicesQuery } from "../src/devices/query.js";

test("builds the device list query", () => {
  assert.match(listDevicesQuery({ siteId: 7 }), /site_id = 7/);
});
`);
git("add", ".");
git("commit", "-q", "-m", "Paged, searchable device list");

console.log(git("diff", "--stat", "main...device-list-paging"));
console.log(git("diff", "main...device-list-paging"));
rmSync(dir, { recursive: true, force: true });
Result
 src/devices/query.js | 8 ++++++++
 test/query.test.js   | 7 +++++++
 2 files changed, 15 insertions(+)

diff --git a/src/devices/query.js b/src/devices/query.js
index d1937b7..9f2d9ce 100644
--- a/src/devices/query.js
+++ b/src/devices/query.js
@@ -1 +1,9 @@
 // Device list queries.
+
+export function listDevicesQuery({ siteId, search = "", page = 1, size = 50 }) {
+  const offset = page * size;
+  let sql = `SELECT id, name, status FROM devices WHERE site_id = ${siteId}`;
+  if (search) sql += ` AND name LIKE '%${search}%'`;
+  sql += ` ORDER BY name LIMIT ${size} OFFSET ${offset}`;
+  return sql;
+}
diff --git a/test/query.test.js b/test/query.test.js
new file mode 100644
index 0000000..da57c11
--- /dev/null
+++ b/test/query.test.js
@@ -0,0 +1,7 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { listDevicesQuery } from "../src/devices/query.js";
+
+test("builds the device list query", () => {
+  assert.match(listDevicesQuery({ siteId: 7 }), /site_id = 7/);
+});
What’s happening
  1. Context and design first. The description (not shown) says "paged device list for the admin UI". A query builder in src/devices/query.js and a test is a sensible shape: small, in the module it belongs to, nothing duplicated. Design is fine, so the review says so and zooms in.
  2. Security, line 6: search is pasted inside LIKE '%…%'. A search box is user input, so x' OR '1'='1 changes the query. siteId on line 5 is interpolated too; if it ever comes from a URL, it's the same hole. This blocks, and the fix is parameters, not escaping.
  3. Correctness, line 4: page defaults to 1, and offset = page * size, so page 1 starts at row 50 and the first 50 devices are never shown. Either pages start at 0 or the offset is (page - 1) * size. Line 7: ORDER BY name isn't unique (two rooms called "Huddle 2"), so rows can swap between pages; add id as a tie-breaker. size has no cap, so size=1000000 is a full table scan per request.
  4. Tests: the only test checks that the string contains site_id = 7. It would pass with every bug above. A test is only worth what it would catch if the code were wrong.
  5. Readability: fine. There's nothing worth a nit here, and a good review doesn't invent one. Four real findings, all blocking or close to it, found in a 15-line diff because the reviewer read in order.
AdvancedProving the paging and injection bugs

Before writing "this skips the first page" in a review, prove it. This runs the branch's function and a fixed version side by side:

review-checklist-proof.mjs
// Run the PR's query builder on the inputs a reviewer worries about, then the fixed version.
function listDevicesQuery({ siteId, search = "", page = 1, size = 50 }) {
  const offset = page * size;
  let sql = `SELECT id, name, status FROM devices WHERE site_id = ${siteId}`;
  if (search) sql += ` AND name LIKE '%${search}%'`;
  sql += ` ORDER BY name LIMIT ${size} OFFSET ${offset}`;
  return sql;
}

console.log("PR, first page:     ", listDevicesQuery({ siteId: 7 }));
console.log("PR, hostile search: ", listDevicesQuery({ siteId: 7, search: "x' OR '1'='1" }));
console.log("PR, huge page:      ", listDevicesQuery({ siteId: 7, size: 1_000_000 }));

const MAX_SIZE = 100;
function listDevicesQueryFixed({ siteId, search = "", page = 1, size = 50 }) {
  const limit = Math.min(Math.max(1, size), MAX_SIZE);
  const values = [siteId];
  let text = "SELECT id, name, status FROM devices WHERE site_id = $1";
  if (search) {
    values.push(`%${search.replace(/[\\%_]/g, "\\$&")}%`);
    text += ` AND name LIKE $${values.length} ESCAPE '\\'`;
  }
  values.push(limit, (Math.max(1, page) - 1) * limit);
  text += ` ORDER BY name, id LIMIT $${values.length - 1} OFFSET $${values.length}`;
  return { text, values };
}

console.log("\nfixed, first page:  ", listDevicesQueryFixed({ siteId: 7 }));
console.log("fixed, hostile:     ", listDevicesQueryFixed({ siteId: 7, search: "x' OR '1'='1" }));
console.log("fixed, huge page:   ", listDevicesQueryFixed({ siteId: 7, size: 1_000_000, page: 3 }));
Result
PR, first page:      SELECT id, name, status FROM devices WHERE site_id = 7 ORDER BY name LIMIT 50 OFFSET 50
PR, hostile search:  SELECT id, name, status FROM devices WHERE site_id = 7 AND name LIKE '%x' OR '1'='1%' ORDER BY name LIMIT 50 OFFSET 50
PR, huge page:       SELECT id, name, status FROM devices WHERE site_id = 7 ORDER BY name LIMIT 1000000 OFFSET 1000000

fixed, first page:   {
  text: 'SELECT id, name, status FROM devices WHERE site_id = $1 ORDER BY name, id LIMIT $2 OFFSET $3',
  values: [ 7, 50, 0 ]
}
fixed, hostile:      {
  text: "SELECT id, name, status FROM devices WHERE site_id = $1 AND name LIKE $2 ESCAPE '\\' ORDER BY name, id LIMIT $3 OFFSET $4",
  values: [ 7, "%x' OR '1'='1%", 50, 0 ]
}
fixed, huge page:    {
  text: 'SELECT id, name, status FROM devices WHERE site_id = $1 ORDER BY name, id LIMIT $2 OFFSET $3',
  values: [ 7, 100, 200 ]
}
What’s happening
  1. The first line confirms the paging bug in the PR's own words: page 1 produces OFFSET 50. Pasting that into the review ("listDevicesQuery({ siteId: 7 }) gives OFFSET 50") ends the discussion before it starts. Technical facts beat opinions, and a one-line repro is a technical fact.
  2. The hostile search turns name LIKE '%x' into … OR '1'='1%', so the query's meaning is now chosen by whoever typed in the search box. That's SQL injection, from a field the author thought of as "just a filter".
  3. The huge page produces LIMIT 1000000. Not a security hole, but an easy way for one browser tab to make the database scan every device of a big customer on every request.
  4. The fixed version returns { text, values } for a parameterised query (the $1 style used by pg for PostgreSQL; MySQL drivers use ?). The search text travels as data in values, never as SQL. %, _ and \ inside it are escaped so a search for 50% means the literal text, and ESCAPE '\' tells the database which escape character that is.
  5. Paging is (page - 1) * limit with limit clamped to 1–100, and the order is name, id, so a page boundary is stable even when names repeat. Page 3 of a "million-row" request is LIMIT 100 OFFSET 200.
  6. This is how a lead keeps reviews from turning into debates: claim only what you've checked, and show the check. The proof took two minutes and it also becomes the test the PR is missing.
AdvancedThe review it gets
Review comments on "Paged, searchable device list"
general  praise: right module, right size, easy to review. Approach looks good.

src/devices/query.js:6  issue (blocking): search text goes straight into the SQL.
  listDevicesQuery({ siteId: 7, search: "x' OR '1'='1" }) produces
  "… AND name LIKE '%x' OR '1'='1%' …". Please return { text, values } and pass the
  search as a parameter (siteId too, line 5). Escape % and _ so "50%" is literal.

src/devices/query.js:4  issue (blocking): page 1 skips the first 50 devices.
  listDevicesQuery({ siteId: 7 }) gives OFFSET 50. Use (page - 1) * size.

src/devices/query.js:7  issue (non-blocking): ORDER BY name isn't unique, so rows can
  move between pages. ORDER BY name, id fixes it.

src/devices/query.js:3  suggestion: cap size (100?) so one request can't ask for the whole table.

test/query.test.js:6  issue (blocking): this test passes with all of the above.
  Could it cover: first page starts at offset 0; a search with a quote in it ends up in
  values, not text; size is capped? Those three would have caught every finding here.

Summary: requesting changes for the injection, the paging off-by-one and the tests.
The repro for each is in the comment, so you can turn them straight into tests.
What’s happening
  1. The review opens with what's right ("right module, right size") and states that the design is fine. That tells the author the remaining comments are details to fix, not a rejection of the approach.
  2. Each blocking comment carries its own repro, copied from the proof above. The author doesn't need to trust the reviewer; they can run it.
  3. The ordering comment is an issue (non-blocking): it's a real bug, but it only shows on sites with duplicate names and it's a one-word fix, so the reviewer trusts the author to fix it here or in a follow-up. The size cap is a suggestion with a number in a question, inviting a different number.
  4. The test comment doesn't say "add more tests". It names the three tests that would have caught every finding. Specific requests get done; vague ones get a token test.
AdvancedA full-stack checklist by layer

The table above is generic. For a web UI plus REST API on a device platform, these are the extra questions worth asking per layer:

  • REST API: Is it a breaking change for existing clients (removed or renamed fields, a field that changed type, a new required parameter)? Status codes and the error body match the team's standard? Pagination bounded? Idempotent where a client will retry (PUT, or an Idempotency-Key on POST)? Authorisation checked on the resource, not just "logged in"?
  • Database: Does the migration lock a big table (adding a column with a default on an old MySQL, adding an index without CONCURRENTLY on PostgreSQL)? Can the new code and the old code both run against the new schema during a rolling deploy (expand first, contract later)? Is there an index for the new query's WHERE and ORDER BY?
  • Web UI: Loading, empty and error states, not just the happy path. Keyboard access and labels. Long names, translated strings and narrow screens. No secrets or internal ids in the bundle. Does the UI handle the API's error shape?
  • Device and integration code: Timeouts on every network call, retries only for idempotent operations, back-off, and what the user sees while a device is offline or rebooting. Logs that include the device id but never credentials.
  • Dependencies: Is the new package needed, maintained, licensed compatibly, and does it exist under exactly that name (see Reviewing AI-generated code)?
AdvancedA routine for one review
  1. Read the description and the linked ticket first. If there's no "why", ask before reading the code. Note the urgency: a production fix gets a narrower, faster review than a feature.
  2. Look at the shape: the file list and --stat. If it's 1,000 lines across 40 files, say so now and ask how it could be split (see Small pull requests and fast reviews) rather than reviewing it badly.
  3. Read the tests before the code. They tell you what the author thinks the code does. Missing cases jump out faster from the tests than from the implementation.
  4. Read the main change, then the rest. Start from the file that does the work, then callers, then the boring files.
  5. Run it when reading isn't enough. UI changes, anything with timing or concurrency, and anything you're about to call a bug: check out the branch (gh pr checkout 123) or write a one-line repro.
  6. Write the summary last: the verdict, what blocks, what doesn't, and when you'll look again.
AdvancedHow much to read at once

The best-known numbers come from SmartBear's 2006 case study at Cisco (2,500 reviews of 3.2 million lines over 10 months, published in Best Kept Secrets of Peer Code Review, SmartBear Software, 2006). Its conclusions: keep the code under review under 200 lines and not over 400; inspection rates under 300 lines an hour found the most defects, under 500 was still good, and faster than that missed a significant share; total review time under 60 minutes and not over 90. Its single best piece of advice was 100–300 lines at a time in 30–60 minutes. It also found that authors who annotated their own change before the review had far fewer defects, which the authors put down to the author being forced to self-review. Treat these as guidance from one vendor-run study of one product group, not as laws, but they match what the Google and Microsoft studies say about size: usefulness of comments drops as changes grow.

What's wrong with this handler?
JavaScript
export async function setVolume(devices, level) {
  devices.forEach(async (device) => {
    await device.api.put("/audio/volume", { level });
  });
  return { status: 200, body: { updated: devices.length } };
}
Show answer

It returns 200 … updated: 3 before any device has been updated, and a failure on any device is lost (it becomes an unhandled rejection, which crashes a Node process by default since Node 15). forEach doesn't wait for async callbacks.

set-volume.mjs
const slowDevice = (name, fail = false) => ({ api: { put: async () => {
  await new Promise((resolve) => setTimeout(resolve, 20));
  if (fail) throw new Error(`${name} is offline`);
  console.log(`  ${name}: volume set`);
} } });

async function setVolume(devices, level) {
  devices.forEach(async (device) => { await device.api.put("/audio/volume", { level }); });
  return { status: 200, body: { updated: devices.length } };
}
async function setVolumeFixed(devices, level) {
  const results = await Promise.allSettled(devices.map((d) => d.api.put("/audio/volume", { level })));
  const failed = results.flatMap((r, i) => (r.status === "rejected" ? [{ device: i, error: r.reason.message }] : []));
  return { status: failed.length ? 207 : 200, body: { updated: devices.length - failed.length, failed } };
}

console.log("PR version:");
console.log("  returned", JSON.stringify(await setVolume([slowDevice("room-a"), slowDevice("room-b")], 40)));
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("fixed version:");
const fixed = await setVolumeFixed([slowDevice("room-a"), slowDevice("room-b", true), slowDevice("room-c")], 40);
console.log("  returned", JSON.stringify(fixed));
Result
PR version:
  returned {"status":200,"body":{"updated":2}}
  room-a: volume set
  room-b: volume set
fixed version:
  room-a: volume set
  room-c: volume set
  returned {"status":207,"body":{"updated":2,"failed":[{"device":1,"error":"room-b is offline"}]}}
What’s happening
  1. devices.forEach(async …) calls the async callback for each device and throws away the promises it returns. forEach has no idea they're promises, so it finishes immediately.
  2. The function returns at once, so the PR version prints returned {"status":200,…} before either volume set line: the caller was told it worked before anything happened.
  3. The fixed version maps the devices to promises and awaits Promise.allSettled, which waits for all of them and never rejects. Each device's result is then checked: room-b threw, so it's reported in failed.
  4. It returns 207 (Multi-Status, from WebDAV but commonly used for partial success) with what succeeded and what didn't. Some teams prefer 200 with a failed list; either is fine if it's the team's standard. Silently claiming success isn't.
  5. In a review this is a blocking issue with the repro attached. It's also the kind of bug a linter can catch for you: typescript-eslint's no-misused-promises rule flags an async callback passed where a void-returning one is expected.

#Giving and receiving feedback

Added

A review comment is read later, alone, without your face or your tone of voice, often by someone in another time zone and sometimes in their second or third language. The same words that sound like a friendly nudge across a desk ("why didn't you just use the helper?") read as an accusation in a notification at 9 p.m. Google's 2018 study names tone as one of the main ways reviews break down, and cites earlier sentiment research showing that comments with a negative tone are less likely to be useful. So the craft of review is half reading code and half writing comments someone can act on without guessing.

The mental model: each comment is a small ticket with a priority. The author has to know three things from it, in seconds: what you're asking, why it matters, and how much it matters (must fix, should consider, just a thought). Comments fail when one of the three is missing: "hmm, not sure about this" has none of them. Google's guide (How to write code review comments, checked October 2026) asks you to be courteous, comment on the code rather than the person, explain why, and mark severity with prefixes such as Nit:, Optional: or FYI:. Conventional Comments (conventionalcomments.org) makes the priority a format: label (decorations): subject, with labels praise, nitpick, suggestion, issue, todo, question, thought, chore and note, and decorations (blocking), (non-blocking) and (if-minor).

Here are comments as they're often written, and the same points rewritten so the author can act on them:

Before and after
Before: Why didn't you use the existing helper?
After:  suggestion: withTimeout in src/http.js does this and clears its timer
        (the race here leaves a timer pending). Swapping it in also gives a
        clearer error message.

Before: This is wrong.
After:  issue (blocking): page 1 starts at OFFSET 50, so the first 50 devices never show.
        listDevicesQuery({ siteId: 7 }) → "… OFFSET 50". (page - 1) * size fixes it.

Before: Can you clean this up?
After:  suggestion (non-blocking): the three branches of this if only differ in the
        status code. A small map { offline: 504, busy: 409, unknown: 502 } would make
        adding the next one a one-line change. Fine to leave for a follow-up.

Before: I'd do this differently.
After:  thought: we could push status changes to the UI over SSE instead of polling
        every 5 s. Not for this PR; I'll open an issue so we can discuss it.

Before: nit: rename
After:  nitpick: `d` → `device`? The function is long enough that I lost track of `d`.

Before: LGTM
After:  praise: the retry test with the fake clock is really clear, I'm stealing it.
        Approved. One non-blocking suggestion on line 40.
What’s happening
  1. "Why didn't you…" is the most common trap. It asks for a justification instead of making a request, and it sounds like blame. The rewrite states the suggestion, the evidence (the timer) and the benefit, and drops "why didn't you" entirely.
  2. "This is wrong" names no problem and no fix. The rewrite is an issue (blocking) with a repro and the fix, so the author can act without asking a follow-up question across a time-zone gap that costs a day.
  3. "Clean this up" is a judgement without a target. The rewrite says what's duplicated, what the better shape is, why (the next status code), and that it's (non-blocking), so the author can choose a follow-up PR.
  4. "I'd do this differently" is a design opinion arriving at the wrong time. Labelled thought and moved to an issue, it becomes a conversation for later instead of a blocker on today's change.
  5. A nit is fine if it says what and why, and it's labelled so nobody mistakes it for a blocker. A bare "LGTM" is fine too, but specific praise ("the retry test with the fake clock") teaches the whole team what good looks like, which is why Google's guide lists "good things" as something to look for.
AdvancedWorking out the verdict from labels

Labels pay off when someone, or something, reads the review as a whole. This model takes a review's comments, checks that each has a known label, and works out the verdict a reviewer should submit:

review-summary.mjs
// Reads Conventional Comments ("label (decorations): subject") and decides the review verdict.
const LABELS = ["praise", "nitpick", "suggestion", "issue", "todo", "question", "thought", "chore", "note"];
const BLOCKING_BY_DEFAULT = new Set(["issue", "todo", "chore"]); // a team choice; written down in the review guide

function parse(comment) {
  const m = comment.match(/^(\w+)\s*(?:\(([^)]*)\))?:\s*(.+)$/);
  if (!m || !LABELS.includes(m[1])) return { label: null, text: comment };
  const decorations = (m[2] ?? "").split(",").map((d) => d.trim()).filter(Boolean);
  const blocking = decorations.includes("blocking") || (BLOCKING_BY_DEFAULT.has(m[1]) && !decorations.includes("non-blocking"));
  return { label: m[1], decorations, blocking, text: m[3] };
}

const review = [
  "praise: right module, right size, easy to review.",
  "issue (blocking): search text goes straight into the SQL.",
  "issue: ORDER BY name isn't unique, so rows can move between pages.",
  "suggestion (non-blocking): cap size at 100.",
  "nitpick: `d` → `device`?",
  "question: what should the caller see when the device is offline?",
  "this feels off",
];

const parsed = review.map(parse);
for (const c of parsed) {
  const tag = c.label === null ? "UNLABELLED" : c.blocking ? "blocks" : "doesn't block";
  console.log(`${tag.padEnd(14)} ${c.label ?? "-"}`.padEnd(30), c.text);
}
const blockers = parsed.filter((c) => c.blocking).length;
const unlabelled = parsed.filter((c) => c.label === null).length;
const openQuestions = parsed.filter((c) => c.label === "question").length;
const verdict = blockers ? "Request changes" : openQuestions ? "Comment (questions open)" : "Approve";
console.log(`\n${blockers} blocking, ${openQuestions} question(s), ${unlabelled} unlabelled → ${verdict}`);
if (unlabelled) console.log("Fix before submitting: say what the unlabelled comment asks for and whether it blocks.");
Result
doesn't block  praise          right module, right size, easy to review.
blocks         issue           search text goes straight into the SQL.
blocks         issue           ORDER BY name isn't unique, so rows can move between pages.
doesn't block  suggestion      cap size at 100.
doesn't block  nitpick         `d` → `device`?
doesn't block  question        what should the caller see when the device is offline?
UNLABELLED     -               this feels off

2 blocking, 1 question(s), 1 unlabelled → Request changes
Fix before submitting: say what the unlabelled comment asks for and whether it blocks.
What’s happening
  1. parse splits label (decorations): subject with one regular expression. issue (blocking): … gives label issue, decorations ["blocking"]; nitpick: … has none.
  2. Whether a comment blocks comes from its decorations first, then from a team default: here issue, todo and chore block unless marked (non-blocking). Conventional Comments itself only defines the decorations; the default is a choice each team should write down, so "issue" means the same thing in Bengaluru and in New York.
  3. So issue: ORDER BY name isn't unique blocks even without a decoration, and suggestion (non-blocking) doesn't. question doesn't block on its own, but an open question means the reviewer can't approve with a clear conscience yet.
  4. "this feels off" has no label, so it's flagged UNLABELLED: the author can't tell what to do with it. The model's last line is the advice you'd give a reviewer: fix that comment before submitting.
  5. With two blockers the verdict is Request changes. Remove both and it would be "Comment (questions open)" until the question is answered, then "Approve". That's the decision a reviewer makes in their head every time; writing labels makes it visible to the author.
AdvancedReceiving feedback

Leads get reviewed too, and the team copies how you take it.

  • Assume the comment is about the code. Read it twice before replying. If it stings, wait an hour.
  • Answer every comment, even with "Done" plus the commit (Done in 3f2a91c). Silence on a comment reads as ignoring it, and the reviewer has to re-check everything.
  • When you disagree, give facts and offer a way forward: "I kept the polling because the device firmware we support doesn't do SSE; I've added a comment saying so. OK to resolve?" Google's guide puts it simply: technical facts and data overrule opinions and personal preferences, and the style guide is the authority on style.
  • Move long threads to a call, then write the outcome back in the thread: "Agreed on the call: we keep polling for now, ticket DEV-412 for SSE." Five back-and-forth comments across a 10-hour time difference is a week.
  • Say thank you for the catches. "Good catch, that would have dropped calls" costs nothing and makes the next review more honest.
AdvancedTeaching through review

For the engineers you lead, especially new ones, a review is the most frequent one-to-one teaching you'll do.

  • Ask questions when you want them to find it: "What happens to this loop if devices is empty?" teaches more than "this breaks on an empty list". Use it for learning moments, not for blockers in an urgent fix.
  • Link the rule, not just your opinion: the team's standard, the style guide, a past incident review. It moves the authority from you to the document, which matters when you're the lead.
  • Praise specifically and publicly, correct in detail and, for anything personal or a repeated pattern, one-to-one on a call rather than in the PR.
  • Review their reviews. Pair a new engineer with you on a review: they write the comments, you discuss them before they post. It's the fastest way to spread the team's standards.
  • Let go of taste. If it's not in the standard, not a bug and not hurting readability, it's a nitpick at most, or nothing. A team that gets thirty nits per PR stops reading comments.
AdvancedNit storms and "just"
Rewrite this comment

A reviewer writes on a junior engineer's PR: "Why are you catching the error here? This is bad practice." What's wrong with it, and what would you write instead?

Show answer

It asks for a justification, labels the person's choice "bad" without saying what goes wrong, and doesn't say whether it blocks. A better version names the consequence and the fix:

Output
issue (blocking): this catch swallows the error, so the API returns 200 with an empty
list when the database is down, and nothing is logged. Could we let it propagate to the
error middleware (which logs it and returns a 503 problem body), or rethrow after logging?
What’s happening
  1. "Why are you…" puts the author on the defensive; they'll explain their reasoning instead of fixing the code. State the problem instead.
  2. "Bad practice" is an appeal to authority with no content. The consequence is the content: a 200 with an empty list when the database is down, and no log line. That's what makes it blocking.
  3. Offer the direction (let it propagate to the middleware, or log and rethrow) but leave the choice to the author, who knows the code better.
  4. The label issue (blocking) tells them this must be fixed before merge, so they don't have to guess from the wording, which is hardest for someone junior or working in a second language.

#Small pull requests and fast reviews

Added

Size is the one thing about a pull request that changes everything else. Google's reviewer guide (Small CLs, checked October 2026) says 100 lines is usually a reasonable size and 1,000 is usually too large, and that a reviewer may reject a change for being too large alone. Its list of reasons is the whole argument: small changes are reviewed more quickly and more thoroughly, are less likely to introduce bugs, waste less work if rejected, merge more easily, are easier to design well, block less and are simpler to roll back. Google's own data fits: in the 2018 case study the median change modified 24 lines, over 35% touched a single file and about 90% fewer than 10 files, and the median time for the whole review was under four hours. At Microsoft (Czerwonka et al., 2015) the share of useful review comments fell as the number of files grew, though the drop only became noticeable for reviews with 20 or more changed files.

The mental model is batch size, the same idea as in lean manufacturing and in DORA's research on delivery: a big batch waits longer in every queue, gets a shallower inspection, and when something goes wrong you can't tell which part did it. A 600-line PR doesn't get six times the review of a 100-line PR; it gets one tired skim. Small batches also matter more across time zones, because the cost of a review is counted in round trips, not lines, and on a US–India team each round trip can cost a day.

How do you build a feature as a series of small PRs without waiting for each one? You stack them: each branch starts from the one below it, so you keep working while the bottom one is reviewed. This model builds a "device groups" feature both ways in a real repository:

stack-of-prs.mjs
// One big branch vs a stack of small ones, built for real with git. Then review feedback on the bottom PR.
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "stack-"));
let minute = 0; // every commit one minute later than the last, so hashes are fixed
const git = (...args) => {
  const when = new Date(Date.UTC(2026, 9, 12, 9, minute)).toISOString();
  return execFileSync("git", args, { cwd: dir, encoding: "utf8", env: { ...process.env,
    GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com",
    GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: when, GIT_COMMITTER_DATE: when } });
};
const write = (path, lines) => { mkdirSync(join(dir, path, ".."), { recursive: true }); writeFileSync(join(dir, path), lines.join("\n") + "\n"); };
const lines = (n, what) => Array.from({ length: n }, (_, i) => `${what} ${i + 1}`);
const commit = (message) => { git("add", "-A"); minute++; git("commit", "-q", "-m", message); };

git("init", "-q", "-b", "main");
write("src/devices.js", lines(60, "// device code"));
commit("Device module");

// Four layers of one feature, "device groups". Each layer is its own branch on top of the one below.
const layers = [
  ["groups-1-repo", "Extract device repository", () => { write("src/devices.js", lines(20, "// device code")); write("src/device-repo.js", lines(70, "// repository")); }],
  ["groups-2-schema", "Add device_groups table", () => write("migrations/0042_device_groups.sql", lines(45, "-- schema"))],
  ["groups-3-api", "Groups API endpoints", () => { write("src/groups-api.js", lines(110, "// endpoint")); write("test/groups-api.test.js", lines(80, "// test")); }],
  ["groups-4-ui", "Groups admin page", () => { write("ui/GroupsPage.jsx", lines(130, "// ui")); write("ui/GroupsPage.test.jsx", lines(60, "// ui test")); }],
];
let below = "main";
for (const [branch, message, change] of layers) {
  git("switch", "-q", "-c", branch, below);
  change();
  commit(message);
  console.log(`${branch.padEnd(16)} vs ${below.padEnd(16)} ${git("diff", "--shortstat", `${below}...${branch}`).trim()}`);
  below = branch;
}
console.log(`${"all four".padEnd(16)} vs ${"main".padEnd(16)} ${git("diff", "--shortstat", "main...groups-4-ui").trim()}\n`);

// Review feedback lands on the bottom PR. Fix it there, then move the whole stack in one command.
git("switch", "-q", "groups-1-repo");
write("src/device-repo.js", [...lines(70, "// repository"), "// review fix: close the connection"]);
commit("Close repository connection (review)");
git("switch", "-q", "groups-4-ui");
minute++;
git("rebase", "-q", "--update-refs", "groups-1-repo");
console.log(git("log", "--oneline", "--decorate=short", "--format=%h %s%d", "main..groups-4-ui"));
console.log("branches containing the fix:", git("branch", "--contains", "groups-1-repo", "--format=%(refname:short)").trim().split("\n").join(", "));
rmSync(dir, { recursive: true, force: true });
Result
groups-1-repo    vs main             2 files changed, 70 insertions(+), 40 deletions(-)
groups-2-schema  vs groups-1-repo    1 file changed, 45 insertions(+)
groups-3-api     vs groups-2-schema  2 files changed, 190 insertions(+)
groups-4-ui      vs groups-3-api     2 files changed, 190 insertions(+)
all four         vs main             7 files changed, 495 insertions(+), 40 deletions(-)

29e19c0 Groups admin page (HEAD -> groups-4-ui)
07b2fee Groups API endpoints (groups-3-api)
b4d4728 Add device_groups table (groups-2-schema)
05b61d5 Close repository connection (review) (groups-1-repo)
b1aa8ea Extract device repository

branches containing the fix: groups-1-repo, groups-2-schema, groups-3-api, groups-4-ui
What’s happening
  1. Each layer is its own branch created from the branch below (git switch -c groups-2-schema groups-1-repo), so the stack is main ← groups-1-repo ← groups-2-schema ← groups-3-api ← groups-4-ui. Each PR is opened against the branch below it, so its diff shows only its own layer.
  2. The sizes, from git diff --shortstat below...branch: a pure refactor (70 lines in, 40 out), a 45-line migration, 190 lines of API and tests, 190 lines of UI and tests. Each is a size a reviewer can read properly in one sitting. Together they're 7 files, 495 insertions and 40 deletions, past the 400 lines the SmartBear/Cisco study treats as the limit for an effective review (see What to look for in a review).
  3. The refactor is its own PR on purpose. Google's guide says refactorings are usually best in a separate change from features and fixes: "moves and renames, no behaviour change" is quick to review when it's alone and very hard to see when it's mixed into 300 lines of new code.
  4. Then review feedback arrives on the bottom PR. The fix is committed on groups-1-repo, which leaves the three branches above it based on the old version. git rebase --update-refs groups-1-repo, run from the top branch, replays the upper commits onto the fixed bottom and moves every branch label in the stack along with them (--update-refs arrived in Git 2.38, 2022).
  5. The log shows the result: each commit now has its branch label in the right place, and git branch --contains lists all four branches as containing the fix. Without --update-refs you'd rebase each branch by hand, in order, and it's easy to get wrong; that bookkeeping is why stacking tools exist.
AdvancedReview latency across a 10.5-hour gap

The other half is speed. This model follows one PR that needs two rounds of changes, written in Bengaluru and reviewed in New York, under three review habits:

review-latency.mjs
// How long does a PR that needs two rounds of changes take to merge when the author is in Bengaluru and the
// reviewer is in New York? Times are in UTC minutes; the week of 16 November 2026 (US daylight time has
// ended, so New York is UTC−5 and Bengaluru UTC+5:30, a 10 h 30 min difference).
const IST = 330, EST = -300; // offsets from UTC in minutes
const DAY = 24 * 60;
const MON = 0; // Monday 16 Nov 2026, 00:00 UTC

const fmt = (t, offset) => {
  const local = t + offset;
  const day = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][Math.floor(local / DAY)];
  const m = ((local % DAY) + DAY) % DAY;
  return `${day} ${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
};
// The first time at or after t when this person looks at their review queue (local clock times, weekdays only).
function nextLook(t, offset, looks) {
  for (let d = 0; d < 14; d++) {
    if ((d % 7) > 4) continue; // weekend
    for (const hhmm of looks) {
      const [h, m] = hhmm.split(":").map(Number);
      const at = MON + d * DAY + h * 60 + m - offset;
      if (at >= t) return at;
    }
  }
}

function simulate(name, reviewerLooks, authorLooks, approveWithComments = false) {
  console.log(name);
  let t = MON + 17 * 60 - IST; // the author opens the PR at 17:00 in Bengaluru on Monday
  const opened = t;
  console.log(`  opened by author      ${fmt(t, IST)} IST  (${fmt(t, EST)} New York)`);
  for (const round of [1, 2, 3]) {
    t = nextLook(t, EST, reviewerLooks) + 30; // a review takes 30 minutes
    const last = round === 3 || (approveWithComments && round === 2);
    const verdict = !last ? "changes requested" : round === 3 ? "approved, merged" : "approved with comments";
    console.log(`  review ${round}              ${fmt(t, EST)} EST  (${fmt(t, IST)} Bengaluru)  ${verdict}`);
    if (round === 3) break;
    t = nextLook(t, IST, authorLooks) + 60; // fixing the comments takes an hour
    console.log(`  fixes ${round} pushed        ${fmt(t, IST)} IST  (${fmt(t, EST)} New York)${last ? "  merged by author" : ""}`);
    if (last) break;
  }
  const hours = (t - opened) / 60;
  console.log(`  open for ${hours} hours (${(hours / 24).toFixed(1)} days)\n`);
}

simulate("A. Reviewer checks the queue once a day, mid-afternoon:", ["15:00"], ["10:00"]);
simulate("B. Reviewer also checks first thing (08:00 New York = 18:30 Bengaluru):", ["08:00", "15:00"], ["10:00"]);
simulate("C. As B, and round 2 is approved with comments (only nits left):", ["08:00", "15:00"], ["10:00"], true);
Result
A. Reviewer checks the queue once a day, mid-afternoon:
  opened by author      Mon 17:00 IST  (Mon 06:30 New York)
  review 1              Mon 15:30 EST  (Tue 02:00 Bengaluru)  changes requested
  fixes 1 pushed        Tue 11:00 IST  (Tue 00:30 New York)
  review 2              Tue 15:30 EST  (Wed 02:00 Bengaluru)  changes requested
  fixes 2 pushed        Wed 11:00 IST  (Wed 00:30 New York)
  review 3              Wed 15:30 EST  (Thu 02:00 Bengaluru)  approved, merged
  open for 57 hours (2.4 days)

B. Reviewer also checks first thing (08:00 New York = 18:30 Bengaluru):
  opened by author      Mon 17:00 IST  (Mon 06:30 New York)
  review 1              Mon 08:30 EST  (Mon 19:00 Bengaluru)  changes requested
  fixes 1 pushed        Tue 11:00 IST  (Tue 00:30 New York)
  review 2              Tue 08:30 EST  (Tue 19:00 Bengaluru)  changes requested
  fixes 2 pushed        Wed 11:00 IST  (Wed 00:30 New York)
  review 3              Wed 08:30 EST  (Wed 19:00 Bengaluru)  approved, merged
  open for 50 hours (2.1 days)

C. As B, and round 2 is approved with comments (only nits left):
  opened by author      Mon 17:00 IST  (Mon 06:30 New York)
  review 1              Mon 08:30 EST  (Mon 19:00 Bengaluru)  changes requested
  fixes 1 pushed        Tue 11:00 IST  (Tue 00:30 New York)
  review 2              Tue 08:30 EST  (Tue 19:00 Bengaluru)  approved with comments
  fixes 2 pushed        Wed 11:00 IST  (Wed 00:30 New York)  merged by author
  open for 42 hours (1.8 days)
What’s happening
  1. The clock is UTC minutes for the week of 16 November 2026. US daylight saving time ends on 1 November 2026, so New York is UTC−5 and Bengaluru UTC+5:30 (India has no daylight saving): a 10 h 30 min difference. In summer it's 9 h 30 min, which is why overlap windows move twice a year.
  2. The author opens the PR at 17:00 in Bengaluru, which is 06:30 in New York. In A the reviewer looks at the queue once, at 15:00, so the first review lands at 02:00 Bengaluru time. The author fixes it the next morning, and so on: three reviews, two fixes, 57 hours, and the author waited for most of it.
  3. In B the reviewer also looks at 08:00 New York, which is 18:30 in Bengaluru. Each review now lands at 19:00 Bengaluru time, at the end of the author's day instead of in the middle of their night, and the PR merges at 08:30 New York on Wednesday instead of 15:30. Same work, same number of round trips, 50 hours. That's Google's guidance in practice: respond within one business day at most, and if the author has finished for the day, finish the review before they start the next one.
  4. C removes a whole round trip: in round 2 only nits are left, so the reviewer approves with comments and trusts the author to fix them and merge. Google's guide lists exactly this case ("LGTM with comments") and notes it's especially worth considering when the developer and reviewer are in different time zones. 42 hours.
  5. Notice what didn't help: nobody worked faster or longer. The fixes always land at 11:00 Bengaluru time because that's when the author's day allows it. Across time zones, the levers are fewer round trips (small PRs, clear blocking labels, approve with comments) and reviewing at the right moment, not effort.
Monday Tuesday Wednesday Bengaluru 10:00–19:00 IST New York 08:00–17:00 EST opened review 1 fixes 1 review 2 fixes 2 merged Habit B: 50 hours. The two days only overlap for 30 minutes, so every round trip costs about a day.
Habit B from the model, on a UTC timeline. Green: the author's working day in Bengaluru; blue: the reviewer's in New York.
AdvancedSplitting a change

Google's Small CLs page lists the main ways to split work, and they map well onto full-stack features:

  • Stack (above): send the first small change for review and keep building on top of it.
  • By layer (horizontal): schema and repository, then API, then UI, with stubs or shared types so each layer can be reviewed alone. The device-groups stack is this.
  • By feature slice (vertical): "groups: create and list" end to end, then "groups: rename", then "groups: delete". Each slice is shippable, often behind a flag.
  • By reviewer: files that need different experts (a migration for the DBA, a UI change for the front-end owner) go in separate PRs so nobody waits for an expert on a file they don't care about.
  • Refactor first, then the change: "move and rename, no behaviour change" as its own PR, then the feature on the cleaned-up code.
  • Behind a feature flag (common practice rather than one of the Small CLs page's strategies): merge incomplete work to main switched off, so nothing has to wait in a long-lived branch. Trunk-based teams rely on this (see Trunk-based development vs GitFlow).

Generated files, lockfile updates, large deletions and mechanical renames can be big without being hard to review; say so in the description so the reviewer can skim them.

AdvancedTools for stacks

You can stack with plain Git as the model does. GitHub now has stacked pull requests built in: a stack is a chain of PRs in one repository, each targeting the branch of the one below, managed from the website or the optional gh stack CLI extension (for example gh stack init, gh stack rebase, gh stack push to push the branches and gh stack submit to open or update the PRs). Per GitHub's docs (checked 27 October 2026), required reviews, required status checks and CODEOWNERS are evaluated as if each PR targeted the stack's base, not the branch below; PRs merge from the bottom up, and merging the whole stack lands it as one atomic operation; merge queues are supported. It went from a private preview in April 2026 (reported by InfoQ on 29 April) to public preview on 30 July and general availability on all github.com plans on 6 October 2026 (GitHub changelog); GitHub Enterprise Server support is still to come. Third-party tools (Graphite, git-town, Sapling, Jujutsu) do the same bookkeeping.

AdvancedReview turnaround as a team agreement

Speed comes from habits the lead sets and protects, not from people trying harder:

  • A written turnaround target, for example "first response within one working day, ideally the reviewer's next morning", taken from Google's one-business-day maximum. Track the median "time to first review" for a month before and after; don't turn it into a per-person score.
  • Review at the start of your day and after breaks, not by interrupting focused work. Google's guide says the same: don't interrupt a focused task, respond at a natural break.
  • Put the reviewer's US morning in the India evening to use. On a 10.5-hour difference the US morning is the Indian evening; reviewing first thing means feedback is waiting when the author starts.
  • Two reviewers who can approve each area, at least one in the author's time zone where possible, so a vacation or a holiday in one country doesn't stop the work. India and the US have different public holidays; put both calendars in the team's plan.
  • Draft PRs for early feedback on direction ("is this the right approach?") before the code is polished.
  • A PR description that answers the reviewer's first questions. The SmartBear/Cisco study found self-annotated changes had far fewer defects; the description is where the author does that self-review.
.github/pull_request_template.md
## What and why
<!-- One or two sentences. Link the ticket. -->

## How to review
<!-- Where to start, what can be skimmed (generated files, renames), anything you want a second opinion on. -->

## Risk and rollout
<!-- Migrations, API changes, feature flag, how to roll back. "None" is a fine answer. -->

## Testing
<!-- What you ran: unit tests, the UI flows you clicked through, a device you tried it on. -->

## Stack
<!-- If this is part of a stack: below / above. -->
What’s happening
  1. GitHub uses a file at .github/pull_request_template.md (or the repository root, or docs/) to pre-fill the description of every new PR, so authors don't start from a blank box.
  2. What and why gives the reviewer context and urgency, the "context" breakdown from the Google study. How to review is the author's self-annotation: start here, skim that.
  3. Risk and rollout forces the questions that decide how careful the review must be. A migration or an API change gets a different reviewer and more attention than a copy change.
  4. Testing says what was actually run, so the reviewer doesn't have to guess whether "it works" means "I ran it" or "it compiles". Stack links the layers so a reviewer can find the PR below.
AdvancedA big PR split into commits isn't a small PR
Which change shortens the wait?

An engineer in Bengaluru says their PRs take three days to merge; the reviewer is you, in New York. Which change helps most?

  1. Ask the engineer to fix review comments faster.
  2. Add a second required reviewer, also in New York.
  3. Keep PRs small enough to need one round, and approve with comments when only nits are left.
  4. Look at the review queue twice a day instead of once.
Show answer

3 helps most, 4 helps a little, 1 doesn't help, and 2 makes it worse.

What’s happening
  1. Option 1, fixing faster, doesn't help: in the model the fixes always land at 11:00 Bengaluru time because the author only sees the review when their day starts. The time is spent waiting in queues, not working.
  2. Option 2: a second required reviewer in the same time zone adds another queue and another chance of "changes requested", so it can only add round trips. (A second reviewer who can approve, in India, is a different thing and does help.)
  3. Option 4, a second look a day, helps a little. In the model, habit B saved 7 hours over habit A, but it was still three reviews and two fixes over three days, because each round trip still crosses the time difference once each way.
  4. Option 3, removing round trips, is what moves the number. Approving with comments took the model from 50 to 42 hours, and with habit B a PR small and clear enough to pass in one round opened at 17:00 Bengaluru time is approved at 19:00 the same evening (review 1 in the model's output). Each round trip you remove is worth about a day, which is why the advice for distributed teams is small PRs and labelled comments first, and faster habits second.

#Automating the boring parts

Added

Every minute a reviewer spends on a missing semicolon, an unsorted import or a test that was never run is a minute not spent on design and correctness. Worse, those comments are the ones that feel petty and start arguments. The rule a lead sets is simple: if a machine can check it, a machine checks it, before a human looks. Formatting goes to a formatter (Prettier), code-quality rules to a linter (ESLint, typescript-eslint), types to the compiler (tsc --noEmit), behaviour to tests, and "who must look at this" to CODEOWNERS. Google works the same way: the 2018 case study describes static analysis results from its Tricorder platform shown inside the review tool (110 analysers at the time, five of them plugin systems for hundreds more checks), so reviewers see them alongside the diff instead of finding the same issues by hand.

The mental model is the robot goes first. A pull request reaches a human only after the robots have passed it: the editor formats on save, a pre-commit hook catches obvious leftovers in seconds, CI runs the full checks on every push, GitHub requests the right owners, and branch protection refuses the merge until all of it is green and approved. The human's review then starts from "this is formatted, typed, linted and tested" and can be about the things only a human can judge (back to the editor and the proofreader from What code review is for).

developer's machine GitHub format on save pre-commit hook CI checks format, lint, types, tests CODEOWNERS requests human review merge queue main branch protection or a ruleset on main required checks green · required approvals · code owner review seconds, skippable (--no-verify) minutes, enforced: nothing reaches main around it
The robots go first. Local checks give fast feedback; only the checks on GitHub are enforced.

Here is the CI half: a workflow that runs on every pull request and in the merge queue, checked with actionlint:

.github/workflows/pr-checks.yml
name: PR checks

on:
  pull_request:
  merge_group:

permissions:
  contents: read

concurrency:
  group: pr-checks-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  checks:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - name: Formatting
        run: npx prettier --check .
      - name: Lint
        run: npx eslint . --max-warnings=0
      - name: Types
        run: npx tsc --noEmit
      - name: Tests
        run: npm test

  size:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - name: Warn on large pull requests
        env:
          BASE: ${{ github.event.pull_request.base.sha }}
          HEAD: ${{ github.event.pull_request.head.sha }}
        run: |
          changed=$(git diff --numstat "$BASE...$HEAD" -- . ':!package-lock.json' | awk '{ s += $1 + $2 } END { print s + 0 }')
          echo "Lines changed (excluding the lockfile): $changed"
          if [ "$changed" -gt 400 ]; then
            echo "::warning title=Large pull request::$changed lines changed. Could it be split? See the review guide."
          fi
What’s happening
  1. on: pull_request runs the checks on every push to a PR. merge_group runs them again when the PR enters a merge queue, which tests it combined with whatever merges ahead of it. GitHub's merge queue docs say a required workflow must listen to merge_group; otherwise the check is never reported for the queued commit, the queue waits until its CI timeout and the merge fails.
  2. permissions: contents: read gives the workflow's token read-only access: a checks workflow has no reason to write to the repository, and it's the safe default for code from a PR. concurrency with cancel-in-progress cancels the run for the previous push when a new one arrives, so a quick fix-up doesn't queue behind a stale run.
  3. The checks job runs the four robots in order of speed: formatting (prettier --check only reports, never rewrites), lint with --max-warnings=0 so warnings can't pile up, types with tsc --noEmit, then tests. npm ci installs exactly what the lockfile says, and cache: npm reuses the npm download cache between runs. Node 24 is an LTS line in October 2026; actions/checkout@v7 and actions/setup-node@v7 were the current major versions on GitHub when this was written (27 October 2026).
  4. The size job is advice, not a gate: it counts changed lines between the PR's base and head (three dots, as on the PR page), ignoring package-lock.json, and prints a GitHub warning annotation over 400 lines, the SmartBear/Cisco limit. fetch-depth: 0 fetches the full history so both commits are present. The SHAs go through env: rather than being pasted into the script with ${{ }}, which is the habit that prevents script injection when a value can be attacker-controlled (branch names and PR titles can).
  5. In the repository's branch protection or ruleset you then mark the checks job as a required status check. Until then it's only a coloured dot that people learn to ignore.
AdvancedWho has to look: CODEOWNERS

A CODEOWNERS file maps paths to the people or teams who own them. GitHub automatically requests a review from the owners of every file a PR changes, and with "require review from Code Owners" turned on in branch protection, the PR can't merge until an owner has approved. Per GitHub's docs (checked 27 October 2026): the file lives in .github/, the repository root or docs/ (searched in that order, first one wins); the PR uses the file from its base branch; the last matching pattern takes precedence; owners need explicit write access; and an approval from any one listed owner satisfies the requirement for that file. Some .gitignore syntax doesn't work: ! negation, [ ] ranges and escaping a leading # with \.

codeowners.mjs
// Who gets asked to review? A CODEOWNERS file, a real branch, and GitHub's "last matching pattern wins" rule.
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "owners-"));
const when = "2026-10-14T08:00:00Z";
const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
  GIT_AUTHOR_NAME: "Ravi", GIT_AUTHOR_EMAIL: "ravi@example.com", GIT_AUTHOR_DATE: when,
  GIT_COMMITTER_NAME: "Ravi", GIT_COMMITTER_EMAIL: "ravi@example.com", GIT_COMMITTER_DATE: when };
const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", env });
const write = (path, text) => { mkdirSync(join(dir, path, ".."), { recursive: true }); writeFileSync(join(dir, path), text); };

const CODEOWNERS = `# Default owners for everything not matched below
*                  @acme/web-team
/src/api/          @acme/api-owners
/src/devices/      @acme/devices-team
/migrations/       @acme/api-owners @acme/dba
/ui/               @acme/frontend
*.md               @acme/docs
/.github/          @acme/tech-leads
`;
git("init", "-q", "-b", "main");
write(".github/CODEOWNERS", CODEOWNERS);
for (const f of ["src/api/groups.js", "src/devices/agent.js", "ui/GroupsPage.jsx", "README.md", "package.json"]) write(f, "v1\n");
git("add", ".");
git("commit", "-q", "-m", "Initial");
git("switch", "-q", "-c", "device-groups");
for (const f of ["src/api/groups.js", "migrations/0042_device_groups.sql", "ui/GroupsPage.jsx", "src/api/README.md", ".github/workflows/pr-checks.yml"]) write(f, "v2\n");
git("add", ".");
git("commit", "-q", "-m", "Device groups");

// A simplified matcher for the pattern shapes used above (GitHub's rules follow .gitignore; this isn't its code).
function matches(pattern, file) {
  if (pattern === "*") return true;
  if (pattern.startsWith("/") && pattern.endsWith("/")) return file.startsWith(pattern.slice(1));
  if (pattern.startsWith("*.")) return file.endsWith(pattern.slice(1));
  return file === pattern.replace(/^\//, "");
}
const rules = CODEOWNERS.split("\n").filter((l) => l.trim() && !l.startsWith("#")).map((l) => {
  const [pattern, ...owners] = l.trim().split(/\s+/);
  return { pattern, owners };
});
const changed = git("diff", "--name-only", "main...device-groups").trim().split("\n");
const requested = new Set();
for (const file of changed) {
  const rule = rules.findLast((r) => matches(r.pattern, file)); // the LAST matching line wins
  rule.owners.forEach((o) => requested.add(o));
  console.log(`${file.padEnd(36)} ${rule.pattern.padEnd(14)} → ${rule.owners.join(" ")}`);
}
console.log(`\nreview requested from: ${[...requested].join(", ")}`);
rmSync(dir, { recursive: true, force: true });
Result
.github/workflows/pr-checks.yml      /.github/      → @acme/tech-leads
migrations/0042_device_groups.sql    /migrations/   → @acme/api-owners @acme/dba
src/api/README.md                    *.md           → @acme/docs
src/api/groups.js                    /src/api/      → @acme/api-owners
ui/GroupsPage.jsx                    /ui/           → @acme/frontend

review requested from: @acme/tech-leads, @acme/api-owners, @acme/dba, @acme/docs, @acme/frontend
What’s happening
  1. The model writes a real .github/CODEOWNERS, commits it on main, and makes a branch that changes five files. git diff --name-only main...device-groups lists them exactly as the PR's "Files changed" tab would.
  2. For each file it walks the rules and keeps the last one that matches (findLast), which is GitHub's rule. * matches everything, so it's the fallback, and it has to be the first line: put it last and it would override every specific rule above it.
  3. The surprise is src/api/README.md. You might expect the API owners, but *.md comes later in the file than /src/api/, so the docs team owns it and the API owners aren't asked. Order in CODEOWNERS is meaning; put broad patterns first and specific ones last.
  4. migrations/ has two owners, so both teams are requested, and one approval from either satisfies the requirement for that file. /.github/ is owned by the tech leads, which protects the workflows, the PR template and CODEOWNERS itself: otherwise anyone could remove themselves from review by editing the file in their PR (GitHub recommends owning the CODEOWNERS file for this reason).
  5. The matcher is a simplified re-implementation for the pattern shapes used here, written to show the precedence rule; GitHub's real matching follows .gitignore rules. The last match wins behaviour is what the docs specify.
  6. Use it sparingly. Code owners on every directory turns into "every PR needs four teams", which is the opposite of fast review. Own the risky areas (migrations, auth, billing, CI config, device firmware interfaces), and let the default team review the rest.
AdvancedLocal hooks: fast, helpful, and not a gate

A Git pre-commit hook is a script Git runs before it records a commit; if it exits non-zero, the commit doesn't happen. It gives feedback in seconds, before anything is pushed. Tools such as Husky and lint-staged install hooks and run the formatter on staged files only; this model uses plain Git so you can see what they do underneath:

pre-commit-hook.mjs
// A pre-commit hook in a real repository: what it catches, and why CI still has to check the same thing.
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "hook-"));
const when = "2026-10-15T08:00:00Z";
const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
  GIT_AUTHOR_NAME: "Meera", GIT_AUTHOR_EMAIL: "meera@example.com", GIT_AUTHOR_DATE: when,
  GIT_COMMITTER_NAME: "Meera", GIT_COMMITTER_EMAIL: "meera@example.com", GIT_COMMITTER_DATE: when };
const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", env, stdio: "pipe" });
const write = (path, text, mode) => { mkdirSync(join(dir, path, ".."), { recursive: true }); writeFileSync(join(dir, path), text, { mode }); };
const tryCommit = (label, ...extra) => {
  try {
    git("commit", "-q", "-m", label, ...extra);
    console.log(`${label}: committed ${git("rev-parse", "--short", "HEAD").trim()}`);
  } catch (err) {
    console.log(`${label}: REFUSED (exit ${err.status})\n${(err.stdout + err.stderr).trimEnd().replace(/^/gm, "  | ")}`);
  }
};

git("init", "-q", "-b", "main");
write(".githooks/pre-commit", `#!/bin/sh
# Refuse commits that add debugging leftovers to source files.
if git diff --cached -U0 -- '*.js' '*.jsx' '*.ts' '*.tsx' | grep -E '^\\+.*(console\\.log\\(|\\.only\\(|debugger)'; then
  echo "pre-commit: remove console.log, .only or debugger before committing" >&2
  exit 1
fi
`, 0o755);
git("config", "core.hooksPath", ".githooks"); // a repo setting: every clone has to run this once (or use a tool that does)

write("src/groups.js", "export const groupName = (g) => g.name.trim();\n");
write("test/groups.test.js", 'test.only("trims names", () => {});\n');
git("add", ".");
tryCommit("Add groups helper");

write("test/groups.test.js", 'test("trims names", () => {});\n');
git("add", ".");
tryCommit("Add groups helper");

write("src/groups.js", 'export const groupName = (g) => { console.log(g); return g.name.trim(); };\n');
git("add", ".");
tryCommit("Quick debug", "--no-verify");
console.log("\nlast commit contains:", git("show", "HEAD:src/groups.js").trim());
rmSync(dir, { recursive: true, force: true });
Result
Add groups helper: REFUSED (exit 1)
  | +test.only("trims names", () => {});
  | pre-commit: remove console.log, .only or debugger before committing
Add groups helper: committed 1c4db22
Quick debug: committed d695f2f

last commit contains: export const groupName = (g) => { console.log(g); return g.name.trim(); };
What’s happening
  1. The hook lives in .githooks/pre-commit, committed with the code, and git config core.hooksPath .githooks tells Git to look there instead of .git/hooks (which isn't versioned). It must be executable (mode 0o755).
  2. The hook greps the staged diff (git diff --cached -U0) for added lines containing console.log(, .only( or debugger in source files. The first commit adds test.only(…), a classic leftover that silently skips every other test in the file (in Jest, Vitest and Mocha; Node's node:test honours only only with --test-only), so the hook prints the offending line and exits 1, and Git refuses the commit.
  3. After the .only is removed the same commit goes through (1c4db22). That's the value of a hook: the mistake is caught on the developer's machine, seconds after it was made, before a reviewer or CI ever sees it.
  4. Then the limit: git commit --no-verify skips the hook entirely, and the console.log is committed (d695f2f). Hooks also only run on machines where someone ran the core.hooksPath setup. So a hook is a convenience for the author, never the enforcement: the same check must run in CI as a required status check, where nobody can skip it.
AdvancedBranch protection and rulesets

The settings that turn the checks into a gate, with their names in GitHub's docs (checked 27 October 2026): Require a pull request before merging (with Require approvals and a number of approvals), Dismiss stale pull request approvals when new commits are pushed, review from Code Owners, Require approval of the most recent reviewable push (someone other than the last pusher must approve), Require conversation resolution before merging, Require status checks to pass before merging (optionally Require branches to be up to date before merging), Require merge queue and Require linear history. Rulesets are the newer way to apply the same rules, and can target many branches or repositories at once. How many of these to turn on is a team decision; a sensible start for a team like this one is one approval, code owners for risky paths, required checks, conversation resolution and stale-approval dismissal.

Where should this check live?

For each, choose editor/hook, CI required check, CODEOWNERS, or human review: (1) imports sorted; (2) a new endpoint checks the caller's permission; (3) changes to migrations/ are seen by the DBA; (4) the unit tests pass; (5) the API change is backwards compatible for the device firmware.

Show answer

(1) formatter or lint rule, locally and in CI; (2) human review, backed by a test; (3) CODEOWNERS; (4) CI required check; (5) human review, ideally backed by a contract test in CI.

What’s happening
  1. Sorted imports are pure mechanics: a formatter or a lint rule with auto-fix does it on save, and the same rule in CI makes it impossible to merge unsorted code. A reviewer should never type this comment.
  2. Whether an endpoint checks the right permission is judgement about the domain, so a human has to read it. Once it's right, a test (viewer gets 403) keeps it right, which turns the human's finding into a machine check.
  3. "The DBA must see migrations" is routing, which is exactly what CODEOWNERS does: /migrations/ @acme/dba, with code owner review required.
  4. Tests passing is the most basic required status check. If people merge with red tests "because it's flaky", fix or quarantine the flaky test; never make the check optional.
  5. Backwards compatibility needs judgement (is removing this field safe?), but parts of it can be automated: an OpenAPI diff check in CI or a consumer contract test catches the obvious breaks, and the human reviews the rest.

#Reviewing AI-generated code

Added

AI coding assistants (GitHub Copilot in the editor, Copilot cloud agent opening pull requests on its own, chat assistants pasting whole functions) have made code cheap to produce. They haven't made it cheap to understand, and understanding is what review is for. The change for a lead is that more of the code arriving in review was written by something that is fluent, fast and never says "I'm not sure". It compiles, it's formatted, it has tests, and it can still be wrong in ways that look right.

The evidence so far says to take this seriously. The 2024 DORA Accelerate State of DevOps report (Google Cloud, 22 October 2024) estimated that for every 25% increase in AI adoption, delivery throughput falls by "an estimated 1.5%" and delivery stability by "an estimated 7.2%" (p. 40; model estimates from survey data, not measured drops), even though 75% of respondents reported productivity gains from AI. The report's own hypothesis is batch size: AI may have made teams forget the importance of small batches, and "it is possible, even likely, that changelists are growing in size". Laura Tacho (DX, quoted by InfoQ on 28 November 2024) made the same point: bigger changesets are riskier, which DORA's research has long supported. Veracode's 2025 GenAI Code Security Report (30 July 2025, 80 coding tasks across more than 100 models; figures from Veracode's press release and blog, as the report itself sits behind a sign-up form) found security flaws in about 45% of the generated code, with Python, C# and JavaScript between 38% and 45% and Java above 70%, and cross-site scripting and log injection failing in 86% and 88% of the relevant cases. And Spracklen et al. (We Have a Package for You!, USENIX Security 2025) found models recommending packages that don't exist, at least 5.2% of the time for commercial models and 21.7% for open-source ones, across 576,000 samples: an opening for attackers who register those names.

The mental model: review AI code like a pull request from a confident new contractor. Fast, articulate, knows every library's surface, doesn't know your system, your users, your incident history or what's dangerous here, and will happily write a test that confirms its own mistake. You wouldn't skip reviewing the contractor because the code looks tidy; you'd check the assumptions. And the person who opened the PR owns it, whoever or whatever typed it.

Here's an assistant-written PR as the reviewer sees it, made in a throwaway repository, with the branch's own tests run at the end:

ai-pr.mjs
// A pull request whose code and test an AI assistant wrote. Printed as the reviewer sees it.
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "ai-pr-"));
const when = "2026-10-20T06:00:00Z";
const env = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
  GIT_AUTHOR_NAME: "Dev", GIT_AUTHOR_EMAIL: "dev@example.com", GIT_AUTHOR_DATE: when,
  GIT_COMMITTER_NAME: "Dev", GIT_COMMITTER_EMAIL: "dev@example.com", GIT_COMMITTER_DATE: when };
const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", env });
const write = (path, text) => { mkdirSync(join(dir, path, ".."), { recursive: true }); writeFileSync(join(dir, path), text); };

git("init", "-q", "-b", "main");
write("package.json", '{ "type": "module" }\n');
git("add", ".");
git("commit", "-q", "-m", "Devices module");

git("switch", "-q", "-c", "command-retries");
write("src/devices/commands.js", `// Sends a command to a device, retrying with exponential backoff.
export async function sendCommand(fetchFn, url, body, retries = 3) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetchFn(url, { method: "POST", body: JSON.stringify(body) });
      if (res.ok) return await res.json();
    } catch (err) {
      console.log(\`Retrying \${url} with \${JSON.stringify(body)}\`);
    }
    await new Promise((r) => setTimeout(r, 2 ** attempt * 100));
  }
  throw new Error("Request failed");
}
`);
write("test/commands.test.js", `import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { sendCommand } from "../src/devices/commands.js";

test("retries failed requests", async () => {
  const fetchFn = mock.fn(async () => ({ ok: false, status: 500 }));
  await assert.rejects(sendCommand(fetchFn, "/devices/42/commands", { command: "reboot" }));
  assert.equal(fetchFn.mock.callCount(), 4);
});
`);
git("add", ".");
git("commit", "-q", "-m", "Retry device commands with backoff", "-m", "Generated with an AI coding assistant; tests pass.");

console.log(git("log", "-1", "--format=%s%n%n%b").trim() + "\n");
console.log(git("diff", "--stat", "main...command-retries"));
console.log(git("diff", "main...command-retries"));
const tap = execFileSync(process.execPath, ["--test", "--test-reporter=tap", "test/commands.test.js"], { cwd: dir, encoding: "utf8" });
console.log("node --test on the branch:", tap.split("\n").filter((l) => /^# (tests|pass|fail) /.test(l)).join(", "));
rmSync(dir, { recursive: true, force: true });
Result
Retry device commands with backoff

Generated with an AI coding assistant; tests pass.

 src/devices/commands.js | 13 +++++++++++++
 test/commands.test.js   |  9 +++++++++
 2 files changed, 22 insertions(+)

diff --git a/src/devices/commands.js b/src/devices/commands.js
new file mode 100644
index 0000000..9907516
--- /dev/null
+++ b/src/devices/commands.js
@@ -0,0 +1,13 @@
+// Sends a command to a device, retrying with exponential backoff.
+export async function sendCommand(fetchFn, url, body, retries = 3) {
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    try {
+      const res = await fetchFn(url, { method: "POST", body: JSON.stringify(body) });
+      if (res.ok) return await res.json();
+    } catch (err) {
+      console.log(`Retrying ${url} with ${JSON.stringify(body)}`);
+    }
+    await new Promise((r) => setTimeout(r, 2 ** attempt * 100));
+  }
+  throw new Error("Request failed");
+}
diff --git a/test/commands.test.js b/test/commands.test.js
new file mode 100644
index 0000000..bed069f
--- /dev/null
+++ b/test/commands.test.js
@@ -0,0 +1,9 @@
+import { test, mock } from "node:test";
+import assert from "node:assert/strict";
+import { sendCommand } from "../src/devices/commands.js";
+
+test("retries failed requests", async () => {
+  const fetchFn = mock.fn(async () => ({ ok: false, status: 500 }));
+  await assert.rejects(sendCommand(fetchFn, "/devices/42/commands", { command: "reboot" }));
+  assert.equal(fetchFn.mock.callCount(), 4);
+});

node --test on the branch: # tests 1, # pass 1, # fail 0
What’s happening
  1. Everything on the surface is fine: a clear commit message, a comment, exponential backoff, a test, and node --test reports # pass 1, # fail 0. "Tests pass" is literally true. This is exactly what makes AI-written code easy to wave through.
  2. Now read it against the system. It's a POST that sends a command to a device, and it retries on any thrown error. A thrown error includes a timeout after the device has already rebooted and the answer got lost, so a retry reboots it again. Retrying non-idempotent operations is the classic retry bug, and nothing in the code knows that a reboot drops a live call.
  3. It also retries when res.ok is false, for any status: a 400 or 401 will be sent four times and fail four times. And the final error is "Request failed": the status, the URL and the original error are all thrown away, which is what you'll be reading in the logs during the incident.
  4. The catch logs the whole request body. Device commands can carry credentials (the next model's body has an adminPin), so this writes secrets to the logs. (JSON.stringify does escape newlines in the body, but url goes into the log line as it is; careless logging of request data is the area, log injection, where Veracode found models failing 88% of the time.)
  5. The test is the subtle part: it asserts callCount() === 4 for a 500, so it encodes the retry-everything behaviour as the requirement. Generated tests often test what the code does, not what it should do. A reviewer has to ask what the tests would catch, not whether they pass.
AdvancedProving the retry bugs with fake devices

The claims in those steps can be checked, so check them. This runs the generated helper and a reviewed version against fake devices: one that rejects the command with 400, and a "lossy" one that runs the command and then loses the response (backoff shortened to 10 ms so it runs fast):

ai-retry-proof.mjs
// The assistant's retry helper vs a reviewed version, against three fake devices.
function sendCommand(fetchFn, url, body, retries = 3) { // as generated, from the PR
  return (async () => {
    for (let attempt = 0; attempt <= retries; attempt++) {
      try {
        const res = await fetchFn(url, { method: "POST", body: JSON.stringify(body) });
        if (res.ok) return await res.json();
      } catch (err) {
        console.log(`    log: Retrying ${url} with ${JSON.stringify(body)}`);
      }
      await new Promise((r) => setTimeout(r, 2 ** attempt * 10));
    }
    throw new Error("Request failed");
  })();
}

const RETRYABLE = new Set([429, 502, 503, 504]);
async function sendCommandReviewed(fetchFn, url, body, { retries = 3, idempotencyKey } = {}) {
  let lastError;
  for (let attempt = 0; attempt <= retries; attempt++) {
    if (attempt > 0 && !idempotencyKey) break; // never resend a command the device may already have run
    try {
      const res = await fetchFn(url, { method: "POST", body: JSON.stringify(body),
        headers: idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {} });
      if (res.ok) return await res.json();
      lastError = new Error(`${url} answered ${res.status}`);
      if (!RETRYABLE.has(res.status)) break; // 400, 401, 404: retrying won't change the answer
    } catch (err) {
      lastError = new Error(`${url} failed: ${err.message}`, { cause: err });
      console.log(`    log: attempt ${attempt + 1} to ${url} failed: ${err.message}`); // no request body in logs
    }
    await new Promise((r) => setTimeout(r, 2 ** attempt * 10));
  }
  throw lastError;
}

// Fake devices. "lossy" runs the command, then the response is lost on the way back (a timeout).
// Like a well-behaved API, a device remembers Idempotency-Keys it has seen and doesn't run a command twice.
function device(kind) {
  const state = { calls: 0, reboots: 0 };
  const seen = new Set();
  const fetchFn = async (url, { headers = {} }) => {
    state.calls++;
    if (kind === "bad-request") return { ok: false, status: 400, json: async () => ({}) };
    const key = headers["Idempotency-Key"];
    if (!key || !seen.has(key)) state.reboots++;
    if (key) seen.add(key);
    if (kind === "lossy" && state.calls === 1) throw new Error("socket hang up");
    return { ok: true, status: 200, json: async () => ({ state: "rebooting" }) };
  };
  return { state, fetchFn };
}

const body = { command: "reboot", adminPin: "4821" };
for (const [name, fn, opts] of [["generated", sendCommand], ["reviewed", sendCommandReviewed],
  ["reviewed + key", sendCommandReviewed, { idempotencyKey: "cmd-7f3a" }]]) {
  console.log(`${name}:`);
  for (const kind of ["bad-request", "lossy"]) {
    const d = device(kind);
    const outcome = await fn(d.fetchFn, "/devices/42/commands", body, opts).then((r) => `ok ${JSON.stringify(r)}`, (e) => `error "${e.message}"`);
    console.log(`  ${kind.padEnd(12)} ${d.state.calls} call(s), device ran it ${d.state.reboots}x → ${outcome}`);
  }
}
Result
generated:
  bad-request  4 call(s), device ran it 0x → error "Request failed"
    log: Retrying /devices/42/commands with {"command":"reboot","adminPin":"4821"}
  lossy        2 call(s), device ran it 2x → ok {"state":"rebooting"}
reviewed:
  bad-request  1 call(s), device ran it 0x → error "/devices/42/commands answered 400"
    log: attempt 1 to /devices/42/commands failed: socket hang up
  lossy        1 call(s), device ran it 1x → error "/devices/42/commands failed: socket hang up"
reviewed + key:
  bad-request  1 call(s), device ran it 0x → error "/devices/42/commands answered 400"
    log: attempt 1 to /devices/42/commands failed: socket hang up
  lossy        2 call(s), device ran it 1x → ok {"state":"rebooting"}
What’s happening
  1. Generated, bad request: four calls for a 400 that will never succeed, then error "Request failed", with no status and no URL in the message. Four times the load on a device that already said no.
  2. Generated, lossy device: the first call reboots the device and then throws socket hang up; the helper logs the body, including "adminPin":"4821", and retries; the second call reboots it again. The caller sees ok, and the room rebooted twice. That's the bug the generated test can't see.
  3. Reviewed, without a key: the 400 gets one call and an error that names the URL and the status. On the lossy device it doesn't retry at all, because without an idempotency key it can't know whether the device ran the command, so it reports the failure (with the cause attached) instead of guessing. The log line names the attempt and the error, never the body.
  4. Reviewed, with an Idempotency-Key: the lossy case retries, and the fake device, like a well-behaved API, recognises the key and doesn't run the command again: 2 call(s), device ran it 1x → ok. Safe retries need the server's cooperation; the client alone can't make a POST safe. That's the design point the review should raise.
  5. The reviewed version also only retries 429, 502, 503 and 504. None of this is exotic; it's the knowledge a team builds from incidents, and it's exactly what an assistant doesn't have about your devices. The review is where it gets applied, and the fixed tests (400 → one call; lossy without a key → one call; lossy with a key → device ran it once) are the review's real output.
AdvancedA checklist for AI-assisted pull requests

Everything in What to look for in a review still applies. These are the extra questions:

  • Does the author understand every line? Ask them to walk you through the risky part. "The assistant wrote it" isn't an answer in a review, and an author who can't explain it can't maintain it.
  • Do the APIs, options and packages exist? Check unfamiliar imports against the registry and the library's docs for the installed version: a hallucinated option is silently ignored, and a hallucinated package name is a supply-chain risk.
  • Do the tests test the requirement? Read the tests as a spec. Would they fail if the behaviour were wrong? Were they generated from the code (so they agree with it by construction)?
  • Security hot spots: input reaching SQL, HTML, shell or logs; secrets in code, logs or test fixtures; authorisation on new endpoints; permissive defaults (CORS *, disabled TLS verification) copied from examples.
  • Retries, timeouts and errors: is anything non-idempotent retried? Are errors swallowed or replaced by a vague message? Are timeouts and their cleanup in place?
  • Fit with the codebase: does it duplicate a helper that exists (assistants often re-implement instead of reusing)? Does it follow the team's patterns, error shapes and naming?
  • Size: AI makes big diffs effortless. The size limits from Small pull requests and fast reviews apply unchanged, and DORA's batch-size finding is the reason.
AdvancedTeam rules that work
  • The human who opens the PR owns it, including code an agent wrote. They've run it, read it and can explain it.
  • Say in the description what was generated and what you verified ("handler and tests generated with Copilot; I rewrote the retry logic and added the 400 and lossy-device tests"). It tells the reviewer where to look harder. It isn't a confession; it's a review aid, like "the lockfile change is generated".
  • AI review is a first pass, not an approval. GitHub's Copilot code review docs (checked 27 October 2026) say Copilot "is not guaranteed to spot all problems" and to "always validate" its feedback and supplement it with a human review. By default its reviews don't count toward required approvals; an organisation can enable Copilot approvals (in public preview), and an approval is dismissed when new commits are pushed.
  • Agent-authored PRs still need a human who isn't the requester. For PRs created by Copilot cloud agent (GitHub's name since its 1 April 2026 changelog; formerly "Copilot coding agent"), an approval from the person who asked Copilot for the PR doesn't count toward required approvals; another reviewer must approve before it can merge. By default, GitHub Actions workflows don't run on Copilot's pushes until someone clicks Approve and run workflows (GitHub Docs on reviewing Copilot's pull requests, checked 27 October 2026). A 13 March 2026 changelog added a setting that lets repository administrators skip that approval so workflows run immediately; for a team like this one, leave the human step on.
  • Write the standards down where the tools read them. Copilot code review reads repository instructions from .github/copilot-instructions.md, path-specific files in .github/instructions/, and AGENTS.md. Encoding "never retry device commands without an idempotency key" there helps the AI reviewer and the human one.
What would you ask about this test?

An AI-assisted PR adds formatDuration(ms) for the meeting timer, with this test, and it passes:

JavaScript
test("formats durations", () => {
  assert.equal(formatDuration(65000), "1:05");
  assert.equal(formatDuration(3600000), "60:00");
});
Show answer

Is "60:00" the requirement, or just what the code happens to do? For a meeting timer, an hour is more likely to be shown as "1:00:00". The test may be encoding the implementation's behaviour, so ask, and ask for the edge cases the test doesn't cover.

What’s happening
  1. 65000 ms is 65 s, so "1:05" is right. It also checks zero-padding of seconds, which is good.
  2. 3600000 ms is one hour. The test says "60:00". That's what a minutes-and-seconds formatter produces, but it's a product decision, not a fact: check the design or the ticket. If the UI shows 1:00:00, this test locks the bug in.
  3. Then the missing cases: 0 ("0:00"?), 59999 (rounds to "1:00" or truncates to "0:59"?), negative values (a clock skew between the device and the server), and multi-hour meetings. Each one is a question about the requirement, and an assistant can't answer it for you.
  4. The comment to write: "question: is 60:00 what the timer should show at one hour? The design shows 1:00:00. Could we add 0, 59 999 and 2 h cases once we've agreed?" That turns the generated test into a reviewed spec.

Written fresh for a lead full stack role. Git examples were run; GitHub Actions workflows were checked with actionlint. Behavioural stories are yours to write in My notes.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.