Severity-tagged review comments
Label every comment; block only what a run proves; give the author the impact and the change.
Predict first
Before you read: predict
MR !3410 adds a bulk regrade to ExamHall: after a question's key is corrected, every attempt's score is recomputed. Candidates may skip questions; a skipped question has no entry in answers. The MR's test, a 10-question exam where a candidate answers all 10 and gets 7 right, scores 70 and passes. A teammate drafted this comment on line 8:
nitpick: `exam.questions.length` reads better than `attempt.answers.length` here.
Which label should this comment carry when it is posted?
// src/grading/bulk-regrade.ts (after the MR)import type { GradeStore } from '../infra/types'; export async function bulkRegrade(store: GradeStore, examId: string, newKeys: Record<string, string>) { const exam = await store.exam(examId); const attempts = await store.attempts(examId); for (const attempt of attempts) { const right = attempt.answers.filter((a) => a.choice === (newKeys[a.questionId] ?? exam.keys[a.questionId])).length; await store.setScore(attempt.id, Math.round((100 * right) / attempt.answers.length)); } return { regraded: attempts.length };}The 30-second version
A review comment is a label, a severity and a sentence the author can act on: what you saw, what it does to someone, and what to change. Block only what a test shows is wrong; say plainly that everything else is optional. Write about the code, never the person.
Label every comment; block only what a run proves; give the author the impact and the change.
How it works
Step 1 of 12
issue, suggestion, nitpick, question, todo, praise.In the kitchen
- each defect gets its own entry: the appliance, where it is, what she found → one finding, one comment, on its line (steps 1, 3)
Analogy: A gas engineer writing up a rented flat's annual gas safety check
| At the flat | In the review |
|---|---|
| each defect gets its own entry: the appliance, where it is, what she found | one finding, one comment, on its line (steps 1, 3) |
| the entry says what it means for the tenant and what to do | impact and suggestion (steps 4, 5) |
| a boiler that fails the flue spillage test is switched off until fixed, however small the repair | blocking follows the run (step 6) |
| ugly boxing round the pipes is not a defect and goes on no entry as one | a preference never blocks (step 7) |
| "boiler dodgy" leaves the landlord unable to tell whether to call someone today or next year | "this is wrong" (the common trap) |
| she writes about the appliance, never about the last fitter | the code, not the person (step 11) |
In real terms: each comment carries a severity the author can trust and the reasoning to act on it, so the blocking finding stands out from the preferences.
Breaks down when: gas classes are fixed by an industry procedure and the record is final; review labels are agreed by the team, and the author can reply and change your mind (step 12).
Contrasting case: a one-person project with no author to inform needs no labels; the code's next reader is you.
Examples, run for real
4.1 ExamHall EXH-3110: "this is wrong" ships the bug
EXH-3110: a practice review of MR !3412, which regrades one question after its key is corrected. The results page shows each candidate's total, which the store recomputes only when asked:
// src/grading/regrade-question.ts (as merged)
import type { MarksStore } from '../infra/types';
export async function regradeQuestion(store: MarksStore, examId: string, questionId: string, newKey: string) {
const answers = await store.answersTo(examId, questionId);
for (const a of answers) {
const mark = a.choice === newKey ? 1 : 0;
await store.setMark(a.id, mark);
}
return { regraded: answers.length };
}
The first review held three bare comments. The author renamed the variable, answered the question and replied "works for me, the test passes" to the third. The second review labelled the same three findings, and each was run:
4.1 MR's test (q3, key b -> c): marks c1 0 · c2 1 · c3 1 · green
4.1 first review: "this is wrong" (l.9) · "a?" (l.5) · "`a` is a bad name" (l.5) · labelled 0 of 3
4.1 as merged, the results page after the regrade: totals c1 3 · c2 2 · c3 2 (the marks add to 2 · 3 · 3) · red
4.1 issue (blocking): its suggestion applied (recomputeTotals before returning): totals c1 2 · c2 3 · c3 3 · green · MR's test green
4.1 nitpick: rename a -> answer: marks c1 0 · c2 1 · c3 1, totals c1 3 · c2 2 · c3 2 · no result changes, non-blocking
4.1 question: asks for a fact the code does not hold · no run decides it, the author answers
4.1 second review: "issue (blocking): after the loop nothing recomputes the totals, so the results page keeps the old scores (c1 shows 3 where its marks add to 2); call `store.recomputeTotals(examId)` before returning."
4.1 second review: "question (non-blocking): can `answersTo` return a withdrawn candidate's answers? If it can, should they be regraded?"
4.1 second review: "nitpick: `a` would read better as `answer`."
4.1 second review parsed: issue (blocking) · question · nitpick · 1 of 3 blocking
MR !3413 is the author's fix after the second review: the suggested line and the rename. The bare "this is wrong" named no line, input or effect, so the author could check nothing (steps 3–5). Labelled, the one defect is the only blocking comment, its impact is a total the run printed, and its suggestion is the line that turned the test green; the rename changes no mark, so it stays a nitpick (steps 6, 7).
4.2 Proof domain, media streaming: the same finding, written twice
A video platform's MR plays the next episode of a 12-episode season when one ends:
// src/player/next-episode.ts (after the MR)
import type { Episode } from '../infra/types';
export function nextEpisode(season: Episode[], currentId: string): Episode | null {
const order = season.map((e) => e.number).sort();
const current = season.find((e) => e.id === currentId);
if (!current) return null;
const next = order[order.indexOf(current.number) + 1];
return season.find((e) => e.number === next) ?? null;
}
4.2 MR's test (a 3-episode season, after episode 1): 2 · green
4.2 as merged, a 12-episode season: after 1 -> 10 · after 9 -> none · after 12 -> 2
4.2 with sort((a, b) => a - b): after 1 -> 2 · after 9 -> 10 · after 12 -> none · MR's test green
4.2 first version: "this sort is wrong." · unlabelled · names no line, input, effect or change
4.2 second version: "issue (blocking): line 4's `sort()` orders episode numbers as text, so in a 12-episode season autoplay goes from episode 1 to 10, stops after 9, and plays 2 after the finale; sort with `(a, b) => a - b`."
4.2 second version: issue (blocking) · each effect it states matches the run (1 -> 10, 9 -> none, 12 -> 2) · its change turns them to 2, 10, none
The first version is a verdict. The second names the line and the input, states the impact the run printed and gives the change, so the author can verify it without a reply (steps 2–5). Why sort() with no compare function orders numbers as text belongs to the JavaScript records.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A video platform pays creators by the watch minute; MR !3414 adds watchMinutes, which the monthly payout job uses to sum each creator's views. The MR's test, two views of 3 and 5 minutes give 8, passes:
Your comment on line 5 needs its impact line, on one creator's month: she posts 20-second clips, and they had 30 views this month, each watched to the end.
Which impact line is true?
// src/payouts/watch-minutes.ts (after the MR)import type { View } from '../infra/types'; export function watchMinutes(views: View[]): number { let minutes = 0; for (const v of views) minutes += Math.round(v.watchedSec / 60); return minutes;}Question 2 of 7
A box office's MR adds a sweep that releases every seat whose hold has ended. The MR's test, seats A1 (held until 300,000) and A2 (held until 900,000) swept at 600,000, releases A1 and passes. A reviewer posted issue (blocking): this is wrong. on line 6, with nothing else.
// src/infra/types.ts (unchanged)
export type HeldSeat = { seatId: string; buyerId: string; holdEndsAt: number };
export interface SeatStore {
/** Every held seat, in seat order: A1, A2, A3, … */
heldSeats(): Promise<HeldSeat[]>;
release(seatId: string): Promise<void>;
}
The author replied: "All the tests pass, so mark it non-blocking: a nitpick or a question." Which label should that comment carry?
// src/holds/release-expired.ts (after the MR)import type { SeatStore } from '../infra/types'; export async function releaseExpired(store: SeatStore, now: number) { const released: string[] = []; for (const seat of await store.heldSeats()) { if (seat.holdEndsAt > now) break; await store.release(seat.seatId); released.push(seat.seatId); } return released;}Question 3 of 7
A team chat MR refreshes the message list by asking the server every second. The app already keeps a push channel open that carries new messages.
Your first draft: "Why would you poll every second when you clearly didn't think about the server?" Which version do you post?
Question 4 of 7
On a payments MR, your issue (blocking) comment says that a refund notice received twice is applied twice, and links a test that shows it. The author, a senior engineer, replies that the payment provider never sends a refund notice twice. You have each replied three times; the thread is eight comments long, and you still read it differently.
What do you do next?
Question 5 of 7
The comment is a short text file: a first line label (decoration): observation, then a line starting Impact:, then a line starting Suggestion:, then the whole suggested watchMinutes in a fenced ts block. It is about the code, not the author.
Write the comment you would post on line 5 of P1's watchMinutes.
Question 6 of 7
A video platform MR adds a retry helper to the upload worker; it is correct and tested. You notice that the thumbnail worker, which another team owns, has its own older copy of the same helper. This MR ships today. Your team uses Google's comment prefixes.
Which prefix does your comment about the two copies take?
Question 7 of 7
MR !3415 adds a currency check to a payments ledger's postEntry. Currency codes arrive as strings, as typed, and only upper-case ISO codes are valid. The MR's test, a GBP entry to a EUR account is refused with 422, passes. A teammate drafted this comment on line 4:
issue (blocking): `!=` lets a lower-case code like `eur` through to a EUR account; use `!==`.
Which label should the comment carry when it is posted?
// src/ledger/post-entry.ts (after the MR)import type { Account, Entry, Reply } from '../infra/types'; export function postEntry(account: Account, entry: Entry): Reply { if (entry.currency != account.currency) return { status: 422, body: { message: 'currency mismatch' } }; return { status: 201, body: { balanceCents: account.balanceCents + entry.amountCents } };}Go deeper
Pro tip
- Read your labels as a list before you post. Parse the first line of each draft and count them by severity. A list with no label on some lines, or with every line blocking, tells you which drafts to fix before the author sees them. When: a review with more than a handful of comments (steps 1, 10). Verify (RAN,
tips.mjs):
Status: RAN.tip 6 drafts by severity: issue (blocking) 1 · nitpick 2 · unlabelled 1 · question 1 · praise 1 tip no label: l.20 "hmm, odd" · fix it before posting: what did you see, and does it block?
Source
All three pages were read again on 2026-10-07 with bodies unchanged (sha256 equal). Valid as of 2026-10-06.
- Paul Slaughter, Conventional Comments (the curriculum row's first source), https://conventionalcomments.org/ (
conventional-comments.txt). Quoted: "<label> [decorations]: <subject> [discussion]". Supports steps 1–2. Quoted: "It is strongly recommended to pair this comment with a suggestion ." Supports step 5. Quoted: "nitpick: Nitpicks are trivial preference-based requests. These should be non-blocking by nature." Supports step 7. Quoted: "question: Questions are appropriate if you have a potential concern but are not quite sure if it's relevant or not." Supports step 8. Quoted: "(blocking) A comment with this decoration should prevent the subject under review from being accepted, until it is resolved." Supports step 6. - Google Engineering Practices, How to write code review comments (the row's second source), https://google.github.io/eng-practices/review/reviewer/comments.html (
google-comments.txt). Quoted: "FYI: I don’t expect you to do this in this CL, but you may find this interesting to think about for the future." Supports step 9. Quoted: "without comment labels, authors may interpret all comments as mandatory, even if some comments are merely intended to be informational or optional." Supports step 10. Quoted: "One way to do this is to be sure that you are always making comments about the code and never making comments about the developer ." Supports step 11. Quoted: "Be kind. Explain your reasoning." Supports steps 3–5. - GitLab documentation, Code Review Guidelines (the row's third source), https://docs.gitlab.com/development/code_review/ (
gitlab-code-review.txt). Quoted: "Use the Conventional Comment format to convey intent. Mark non-mandatory suggestions as ( non-blocking: )." Supports steps 1, 7. Quoted: "Consider a one-on-one call for lengthy misunderstandings and post a follow-up summary." Supports step 12. Quoted: "Only resolve threads you have fully addressed." Supports step 12. Quoted: "If a comment can be addressed in a follow-up issue, work with the maintainer on a path forward." Supports step 9.
Related lessons
Checked against the tools and docs as of 2026-10-06.