Технический английский для разработчиков · Глава 7 из 12

Глава 7. Pull requests и code review

Прогресс сохранится в этом браузере (войдите, чтобы синхронизировать).

Введение

Pull request — самый частый жанр письменного английского в жизни разработчика. Писем ты можешь не писать месяцами. А PR открываешь каждую неделю и каждый день оставляешь комментарии в чужих — между делом, обычно не перечитывая. Поэтому язык здесь инструмент, а не украшение: от описания зависит, сколько ревьюер провозится, прежде чем поймёт изменение, а от формулировки замечания — закроют его коммитом за десять минут или будут обсуждать три дня.

Про три дня — не фигура речи. Я как-то оставил под чужой функцией комментарий This is wrong. и ушёл спать. Утром там висели три абзаца объяснений, почему автор сделал именно так; к вечеру подключился его тимлид; на третий день мы созвонились и выяснили, что прав я был ровно в одной строчке из двадцати. Напиши я тогда I think this misses the empty-list case — всё закрылось бы одним коммитом до обеда.

У русскоязычного разработчика с PR обычно две беды, и обе не про грамматику. Первая — описание в стиле fix bug: код же видно, чего расписывать. Вторая — та самая прямота. «Это неправильно, перепиши» по-русски звучит как техническая констатация, а This is wrong. Rewrite it. по-английски — как выговор от начальника подчинённому. Сначала разберём, как упаковывать своё изменение, потом — как разговаривать о чужом.

Язык ревью — не набор реверансов ради красоты. Это протокол, который держит цену недоразумения в разумных пределах, когда люди никогда не виделись лично, живут в шести часовых поясах и не слышат интонацию. Всё, что в разговоре сделал бы голос — пауза, усмешка, вопросительная интонация в конце, — в PR приходится делать словами.

Заголовок pull request

Заголовок PR увидят все, даже те, кто не откроет диф: он попадёт в список открытых PR, в уведомление в Slack, в письмо на почту, а после squash-merge — ещё и в историю коммитов навсегда. Работа у него ровно одна: за одну строку сказать, что меняется.

Делается это глаголом в повелительном наклонении (imperative mood) — будто ты отдаёшь команду кодовой базе. Соглашение пришло из гайдлайнов Git: заголовок должен логично продолжать фразу If applied, this commit will… («если применить, этот коммит…»). Подставь её мысленно перед своим заголовком — сразу слышно, годится он или нет.

Bad:  fixes
Bad:  Update UserService.java
Bad:  Fixed the bug where the cart was not updating sometimes when user

Good: Fix cart total not updating after coupon removal
Good: Add retry with exponential backoff to payment webhook
Good: Remove unused legacy image resizer

fixes не говорит ничего. Update UserService.java повторяет то, что и так написано в дифе: файл назван, смысл — нет. Третий вариант расползся и оборвался на полуслове. Хорошие все устроены одинаково: глагол (Fix, Add, Remove), объект и, если без него непонятно, условие — «after coupon removal».

Многие команды используют conventional commits — формат type(scope): description. Типы стандартные: feat (новая функциональность), fix (исправление), refactor (изменение кода без изменения поведения), docs, test, chore (рутина: зависимости, конфиги), perf (производительность). Выглядит как бюрократия, но у неё есть потребитель: по этим префиксам робот собирает CHANGELOG и считает следующую версию по semver. Назвал фикс словом feat — получил минорный релиз вместо патча.

feat(auth): add password reset via email
fix(cart): recalculate total after coupon removal
perf(search): cache facet counts for 5 minutes
chore(deps): bump django from 5.2 to 6.0

Две грамматические детали, на которых спотыкаются почти все. Глагол — в базовой форме: add, не adds и не added. Описание — с маленькой буквы и без точки в конце; это соглашение, а не безграмотность, и поправлять его в чужом PR не надо. Если изменение ломает обратную совместимость, ставят восклицательный знак и объясняют в теле: feat(api)!: drop support for v1 tokens.

Описание: What / Why / How / How to test

Тело PR существует ради того, чего в дифе нет. Диф честно показывает построчно, что изменилось, и ни слова не говорит, зачем и какие три варианта ты перебрал, прежде чем остановиться на этом. Стандартный набор секций:

  • What — что сделано, в двух-трёх предложениях человеческим языком.
  • Why — зачем: какая проблема, какой тикет, какая жалоба от пользователя.
  • How — как именно решено, если решение неочевидное; здесь же отброшенные альтернативы.
  • How to test — по шагам, чтобы ревьюер мог проверить руками.
  • Screenshots — для любых визуальных изменений, желательно «до/после».
  • Risks — что может сломаться, нужна ли миграция, нужен ли фича-флаг, как откатывать.

Многие репозитории кладут заготовку в файл .github/PULL_REQUEST_TEMPLATE.md, и GitHub подставляет её в каждый новый PR. Типичный шаблон:

## What
<!-- One or two sentences. What does this PR change? -->

## Why
<!-- Link the issue. What problem does it solve? -->
Closes #142

## How
<!-- Only if the approach is not obvious. Mention alternatives you rejected. -->

## How to test
1.
2.
3.

## Screenshots
<!-- Before / after for any UI change. -->

## Risks
- [ ] Requires a database migration
- [ ] Behind a feature flag
- [ ] Needs a config change in production

Closes #142 (синонимы — Fixes, Resolves) не просто ссылка: GitHub закроет issue при мердже сам. Если PR относится к задаче, но не закрывает её, пиши Related to #142 — иначе тикет схлопнется раньше времени, и кто-нибудь потом полдня будет искать, куда делась половина работы.

Теперь заполненный пример. Мерять его стоит не длиной, а числом вопросов, которые ревьюер после него НЕ задаст.

## What
Adds a retry with exponential backoff to the payment webhook handler.
Failed deliveries are now retried up to 5 times over roughly 15 minutes.

## Why
Closes #517. The provider occasionally returns 503 during their
maintenance window. We currently drop those events, so around 40 orders
per week stay in `pending` and have to be fixed by hand.

## How
The retry lives in the handler rather than in the queue config, because
we only want to retry 5xx responses — a 400 means the payload is wrong
and retrying it forever would just fill the queue. I considered using
the built-in queue retry, but it cannot distinguish between the two.

## How to test
1. Run the app with `make dev`.
2. Point `PAYMENT_WEBHOOK_URL` at the mock in `tests/fixtures/flaky.py`.
3. Trigger a payment. The mock fails twice, then succeeds.
4. Check the logs: you should see two `retrying webhook` lines.

## Risks
- Adds a new setting `PAYMENT_WEBHOOK_MAX_RETRIES` (default 5).
- No migration, no data change. Safe to revert.

Стиль: короткие предложения, настоящее время (Failed deliveries are now retried, а не will be retried), числа вместо оценок — «около 40 заказов в неделю», а не «часто». Секция How объясняет не код, а выбор, и одним абзацем гасит вопрос «а почему не встроенный ретрай очереди?». Этот вопрос всё равно бы прилетел — только в комментарии, через шесть часов, когда ты уже переключился на другую задачу.

Язык комментария: критикуем код, а не человека

Главное правило англоязычного ревью помещается в одну строку: подлежащее в замечании — код, а не автор. На практике это значит выкинуть you везде, где вместо него можно назвать переменную, функцию или файл.

Автор в фокусеКод в фокусе
You forgot to close the file.This file handle is never closed.
You made this function too complex.This function is doing three things; splitting it might help.
Your naming is confusing.The name `data` here does not say what it holds.
You did not handle the empty case.What happens if `items` is empty?

Разница не косметическая. You forgot — утверждение о человеке и его небрежности; на такое хочется оправдываться. This file handle is never closed — утверждение о факте; на такое хочется ответить коммитом. Один и тот же баг, два разных разговора.

Второй приём — вопрос вместо приговора. Вопрос оставляет автору место объясниться, а тебе — право ошибаться. Он мог знать о коде что-то, чего не знаешь ты: например, что этот «лишний» лок стоит там из-за фонового джоба, который в дифе не виден.

Verdict:  This lock is useless here.
Question: What does this lock protect? The map looks single-threaded to me,
          but I might be missing a caller.

Хеджирование: почему смягчение — это не слабость

Хеджирование (hedging) — смягчающие обороты, которые превращают приговор в предложение обсудить. Русскоязычные разработчики часто видят в них лицемерие: зачем писать I'd suggest, если я точно знаю, что так лучше? Логика тут другая. Хедж говорит не о твоей неуверенности в факте, а о том, что кнопку merge жмёт автор PR, и решение в его ветке принимает он.

А ещё хедж — дешёвая страховка. Написал This is wrong, оказался неправ — отыгрывать назад неловко, потому что ты уже вынес вердикт. Написал I may be missing something, but this looks like it could double-count — и оговорка просто сбылась. Разговор идёт дальше, лицо цело, никто не считает вслух, сколько раз ты ошибся за квартал.

Could we extract this into a helper? It shows up in three places now.
I'd suggest moving the validation before the DB call.
What do you think about using a named constant here?
It might be worth adding a test for the empty list case.
I may be missing something, but does this handle a null `user`?
Have we considered caching this? It runs on every request.
This might be out of scope for this PR, but the same bug exists in `orders.py`.

Выучи наизусть пять конструкций, и на них уедет девять комментариев из десяти: Could we… (мы вместе, а не «ты обязан»), I'd suggest… (мнение, не закон), It might be worth… (стоило бы), Have we considered… (мягко напомнить про вариант), I may be missing something, but… (та самая страховка).

Обратная сторона тоже есть: хеджировать всё подряд нельзя. Замечание про SQL-инъекцию, завёрнутое в maybe it might be worth possibly considering, автор пролистает вместе с придирками к именам переменных. Дальше — про то, как проговаривать вес.

Маркеры важности замечания

Ссорятся в ревью чаще не из-за грубости, а из-за того, что непонятен вес замечания. Ревьюер обронил «мне кажется, тут лучше другое имя» — автор прочитал требование, три часа переименовывал по всему модулю и пришёл в чат с вопросом, зачем это было нужно. Бывает и зеркально: про конкатенацию SQL написано так же мягко, как про пробелы, автор счёл вкусовщиной и смерджил. Лечится одним словом в начале строки. Соглашение неформальное, но узнаваемое почти везде:

МаркерЗначениеНадо ли чинить
nit:nitpick, мелочь, вкусовщинаНа усмотрение автора
optional:идея, не требованиеНет
suggestion:конкретное предложение по улучшениюЖелательно
question:я не понял, объясниОтветить обязательно
blocking:без этого не апрувлюДа
praise:похвала, ничего делать не надоНет
nit: typo in the comment — "recieve" → "receive"
optional: a small docstring here would help future readers
suggestion: this loop could be a dict comprehension, up to you
question: is `retries=5` a product requirement or just a guess?
blocking: this builds SQL by string concatenation — it takes user input
          straight from the query string
praise: nice catch on the timezone here, that bug was hard to see

praise: выглядит как реверанс, но работает. Ревью из одних замечаний читается как «всё плохо», даже если замечаний три штуки на четыреста строк — автор видит только красные пометки и не видит, что остальное ты одобрил молча. Одна строка похвалы стоит десять секунд и меняет тон всей ветки.

Таблица: резко → принято

Слева — фразы, которые в обратном переводе на русский звучат совершенно нормально, а в английском ревью прилетают как пощёчина. Справа — то же самое содержание, ноль потерь смысла.

РезкоПринято
This is wrong.I think this misses the case where the list is empty.
Rewrite this.Could we restructure this a bit? Suggestion below.
Why did you do this?Could you walk me through the reasoning here?
Bad naming.nit: `tmp` is a bit vague — maybe `pending_orders`?
Obviously this won't scale.This runs a query per row. With 10k rows that's 10k queries — worth a join?
Useless test.This test passes even if the function returns nothing. Should it assert the value?
Read the docs.The framework has a built-in helper for this: [link]. Might save some code.

Рецепт правой колонки везде один: вместо оценки — конкретный факт («runs a query per row»), вместо приказа — решение, оставленное автору. Заодно правая колонка полезнее: «obviously this won't scale» нечего чинить, а «10k rows — 10k queries» уже подсказывает join.

Как отвечать на ревью

Отвечать надо на каждый комментарий. Молчаливая правка кажется экономией времени, но ревьюер, вернувшись к PR, не знает, учёл ты замечание или проигнорировал, и лезет перечитывать диф целиком. Минимальный набор ответов:

Good catch, thanks — fixed in a1b2c3d.
Done in 4f5e6d7.
Fixed, thanks!
You're right, I misread the docs. Reverted to the built-in helper.
Ah, I see what you mean now. Updated.

Good catch — дежурная реакция на найденную ошибку: благодарно и без самобичевания. А хеш коммита рядом (fixed in a1b2c3d) экономит ревьюеру минуту поиска по дифу — мелочь, которую замечают.

Отдельный навык — не согласиться и не поссориться. Схема из трёх шагов: признай позицию собеседника, назови причину, оставь дверь открытой.

I'd rather keep it as is, because the caller already validates the input
and a second check would drift out of sync. Happy to add it if you think
it's worth the duplication.

That's a fair concern. I'd suggest we handle it in a follow-up: it needs
a schema change, and this PR is already large. I've opened #533 to track it.

Если спор встал — переписку не продолжают. Зовут третьего или переходят в голос:

We seem to see this differently. Could we get a second opinion from @maria,
who wrote the original module?

I think we're going back and forth on this. Want to jump on a quick call?
I'll write up whatever we decide here afterwards so it's not lost.

Последняя фраза не вежливость, а страховка. Всё, о чём договорились голосом, возвращается в PR текстом — иначе через полгода новый человек придёт с тем же вопросом, и никто уже не вспомнит, почему код такой.

Вердикты и просьбы о ревью

Финальных состояний PR на GitHub три: approve, comment, request changes. Вокруг них есть устойчивые сокращения:

  • LGTMlooks good to me, всё нравится, апрувлю. Иногда пишут LGTM with nits — апрув, но есть мелочи на твоё усмотрение.
  • ship it — неформальное «выкатывай», ровно то же, что LGTM.
  • PTALplease take another look, «посмотри ещё раз», после того как ты внёс правки.
  • Draft или WIP (work in progress) — PR не готов к мерджу, нужен ранний фидбек по подходу, а не построчное ревью.
LGTM, nice cleanup. Two nits inline, feel free to ignore them.
Approving so I don't block you — please address the blocking comment before merging.
Requesting changes: the migration drops a column that the reporting job still reads.

Просьба о ревью — короткая и без давления, зато с оценкой объёма. «Посмотри, там 60 строк конфига» соглашаются посмотреть между делом, «посмотри мой PR» откладывают на потом:

@anna could you take a look when you get a chance? It's a small one,
~60 lines, mostly config.

Hi @team, this one is blocking the release on Thursday. Any chance
someone could review it today?

Gentle bump on this one — no rush if you're heads-down, just want to
make sure it didn't fall off the list.

Gentle bump и Following up on… — стандартные вежливые напоминания, а no rush if… снимает давление.

Кейс из реального проекта: жизнь одного PR от описания до мерджа

Команда из пяти человек, Берлин и Буэнос-Айрес, пересечение по времени — четыре часа в день. Разработчик Дмитрий чинит баг с двойным списанием бонусов. Смотри не на код, а на то, как двое договариваются, ни разу не увидев друг друга.

Шаг 1. Дмитрий открывает PR.

fix(loyalty): prevent double redemption on concurrent checkout

## What
Adds a row-level lock when redeeming loyalty points, so two parallel
checkout requests cannot spend the same points twice.

## Why
Closes #712. Support reported 6 cases last month where a customer ended
up with a negative point balance. All of them came from double-clicking
the "Pay" button.

## How
`SELECT ... FOR UPDATE` on the balance row inside the existing
transaction. I also looked at an optimistic version column, but that
would need a migration and a retry loop in the caller, and the
contention here is very low — one row per user, a few times a day.

## How to test
`pytest loyalty/test_redemption.py -k concurrent`. The new test spawns
two threads redeeming the same points; only one should succeed.

## Risks
- Holds a row lock for the duration of the checkout transaction (~50ms).
- No migration. Revert is safe.

Шаг 2. Ревью от Анны. Пять комментариев, у каждого маркер, и первый из них — похвала. Настоящая находка спрятана во втором.

praise: thanks for writing the concurrency test, those are painful to
        get right and this one is really readable.

blocking: the lock is taken after `points_needed` is computed from a
          separate read. Between the read and the lock the balance can
          change, so the check can pass on stale data. Could we move the
          read inside the locked block?

question: what happens if the checkout transaction is rolled back later —
          is the lock released before we send the confirmation email?

nit: `qs` → maybe `balance_qs`? Took me a second to see what it was.

optional: the magic number 3 on line 88 could be a named constant, but
          it's only used once so up to you.

Шаг 3. Ответы Дмитрия. Одно замечание принято, одно объяснено, одно отклонено — и всё это без единого «ты не понял».

> blocking: the lock is taken after `points_needed` is computed...

Good catch — that's a real race, the check was reading a stale balance.
Moved the read inside the lock in 9ac41f2 and extended the test to cover
it (it fails on the previous commit, I checked).

> question: what happens if the checkout transaction is rolled back...

The email is sent from an `on_commit` hook, so nothing is sent if the
transaction rolls back. I added a comment there since it wasn't obvious.

> optional: the magic number 3 on line 88...

I'd rather keep it inline — it's the HTTP retry count and it lives right
next to the client config, so a constant would move it away from its
context. Happy to change it if you feel strongly.

PTAL when you have a moment.

Шаг 4. Апрув.

LGTM. Fair point on the constant, keeping it inline is fine.
Nice work on the race — that one would have been very hard to debug in prod.

Восемь сообщений, полдня, гонка найдена и закрыта тестом. Теперь поищи в этом обмене слова wrong, bad и обвинительное you — их там нет ни одного. Мягкая форма, жёсткое содержание: именно так это и работает.

Типичные ошибки

Приказной тон

«Перепиши», «убери» в русском ревью — рабочая интонация. Дословный английский императив без смягчения — распоряжение начальника подчинённому, и получает его человек, который тебе не подчиняется.

Как НЕ надо:
Rewrite this.
Remove it.
Don't do this.
Use a map.

Как надо:
Could we restructure this? I left a suggestion below.
I think this can go — it's not called anywhere anymore.
A map would give us O(1) lookups here. Worth it?

«Why did you do this?»

Формально — обычный вопрос. Читается как «ты вообще зачем это сделал?». Виноваты две вещи сразу: you в фокусе и прошедшее время, вместе они превращают строчку в разбор полётов. Спрашивай о коде, а не о поступке.

Как НЕ надо:
Why did you add this dependency?

Как надо:
What does this dependency give us over the stdlib version?
Could you walk me through what this block does? I might be missing context.

Избыточные извинения

Обратная крайность, и я сам через неё прошёл: боишься показаться грубым — и топишь замечание в извинениях. Вежливее оно не становится, только длиннее, а сквозь три sorry уже не видно, что там вообще нашли. Хеджирование — не самоуничижение.

Как НЕ надо:
Sorry, maybe it's a stupid question and sorry for bothering you, but I'm
not sure, maybe I'm wrong, sorry — is this variable used anywhere?

Как надо:
question: is this variable used anywhere? I couldn't find a reference.

Одного хеджа хватает. I couldn't find a reference уже говорит «возможно, я плохо искал» — и ни одного sorry для этого не понадобилось.

Спор в комментариях вместо созвона

Три круга по одной ветке — предел. Дальше растёт только раздражение: интонации в тексте нет, а усталость есть, и каждая следующая реплика читается резче предыдущей, хотя формулировки не менялись. На четвёртом круге зови в голос — и не забудь вернуть решение в PR текстом.

«It doesn't work» без деталей

Ревьюер выкачал ветку, запустил, не завелось — и пишет одну строку. Дальше автор ведёт допрос: какая ОС, какая команда, что в логах. Каждый раунд — полсуток, если вы в разных часовых поясах. Даже внутри своего PR отчёт о поломке содержит команду, окружение и вывод.

Как НЕ надо:
It doesn't work on my machine.

Как надо:
I can't get this to start locally. On macOS 15, Python 3.12, after
`make dev` I get:

    ImportError: cannot import name 'RedisLock' from 'loyalty.locks'

Do I need to re-run migrations, or is a new env var required?

Итог

Pull request — текст, который читают чаще, чем код внутри него. Заголовок в повелительном наклонении, по формату conventional commits, говорит, что меняется. Описание по схеме What / Why / How / How to test / Risks закрывает всё, чего не видно в дифе, и снимает с ревьюера десяток вопросов ещё до того, как он их задал.

В комментариях работают два правила. Подлежащее — код, а не человек. Вес замечания проговаривается словом: nit:, question:, suggestion:, blocking:, optional:, praise:. Хеджирование (Could we…, I'd suggest…, I may be missing something, but…) — не робость, а способ оставить решение автору и дёшево отыграть назад, когда ошибся ты. И отвечать надо на всё: Good catch, Done in a1b2c3d, а при несогласии — признать позицию, назвать причину, позвать третьего.

В главе 8 уходим от кода к тексту вокруг него: issue и баг-репорты, на которые отвечают, и документация, которую дочитывают.

Практика

Решено 0 из 3

Ответ на каждое задание можно получить, только написав и запустив программу. Глава засчитывается, когда решены все задания.

  1. Расставьте маркеры важности и служебные сокращения в комментариях ревью.

    Каждое слово из банка подходит ровно в один пропуск. Введите слова через запятую в порядке пропусков, регистр не важен.

    Банк слов: blocking, LGTM, nit, praise, PTAL, question, suggestion

    1. ___ (1): the variable would read better in plural form. — мелочь, мержить не мешает
    2. ___ (2): do we still need this retry? — не замечание, а вопрос
    3. ___ (3): this query runs inside a loop. — так мержить нельзя
    4. ___ (4): could we extract the validation into its own function? — предложение, решать автору
    5. ___ (5): nice catch on the timezone handling. — похвала за удачное решение
    6. Rewritten as a single query. ___ (6) — просьба посмотреть ещё раз
    7. ___ (7), ship it. — одобрение, вопросов нет
  2. Сопоставьте резкую реплику с принятой в англоязычном ревью формулировкой того же самого замечания.

    Введите буквы через запятую в порядке пунктов 1, 2, 3… — например, e, a, …. Регистр не важен.

    #Выражение Значение
    1Rewrite this.aI may be missing something — what happens here when the list is empty?
    2Why did you do this?bCould we extract this into a separate function?
    3This is wrong.cWould you mind adding a case for the empty input?
    4You forgot the tests.dThe style guide suggests snake_case here — worth aligning?
    5This is slow.eThis runs once per row; could we do it in a single query?
    6I don't like this name.fWhat was the reasoning behind this approach?
    7You always ignore the style guide.gCould you add a short comment on why this branch is needed?
    8Explain this.hNaming nit: would pending_orders read better here?
  3. Вставьте пропущенные слова в формулы смягчения (hedging) — без них замечание звучит как приказ.

    Каждое слово из банка подходит ровно в один пропуск. Введите слова через запятую в порядке пропусков, регистр не важен.

    Банк слов: catch, might, rather, sure, think, wondering, worth

    1. Good ___ (1), fixed in the latest commit.
    2. I was ___ (2) whether we need the retry at all.
    3. It ___ (3) be worth extracting this into a helper.
    4. I would ___ (4) keep it as is: the client only retries idempotent requests.
    5. What do you ___ (5) about moving this into the service layer?
    6. I am not ___ (6) this covers the empty input.
    7. Probably not ___ (7) doing in this PR, but let us note it.

Ответы проверяются на сервере, а решённые задания сохраняются в этом браузере. Войдите, чтобы прогресс синхронизировался между устройствами.

Содержание серии (12)