AI

审查意见都标成已解决之后,部分退款仍然没有把货加回库存(中英切换)

三人、七台电脑。4 号机下午在看一张退款 PR。全额退款的路径是干净的:钱退回去,两件货都加回库存。出问题的是部分退款。顾客买了两件 WID-1,先退一件、退 1000 分。refund 收下了 return_lines=[("WID-1", 1)],把钱记上,把状态改成 partial_refund,然后在 else 分支里把回库清单丢在一边。仓库里那一件已经回来了,系统里的库存还是 8,不是 9。

4 号机把意见钉在 shop/refund.py 的这个 else 分支上:部分退款把钱退了,对应件数没有回库。1 号机上的代理回了一句「已修复」,把对话点成 Resolve。测试还是 2 通过——这两条测试检查的是全额退款会回库,以及部分退款确实把钱退了。它们从不问库存是不是 9。6 号机如果只是再跑一遍同一组测试,也会是绿的;这次实验室没有跑 Windows 任务,我不能把 6 号机的退出码写成实测结果。7 号机如果把「对话都已解决、测试全绿」当成可以合并的理由,就会把这笔改动送进 main。

这篇要核对的是:审查意见被标成已解决,是不是就等于被改过了;只要求「合并前必须解决对话」,会放行哪几种假修复;合入闸门还要再看什么。它和 审查机点了 Request changes,开这个 PR 的代理会话已经关了:谁来改下一刀? 不是同一件事。那篇处理的是会话关掉以后,下一刀落到哪台还醒着的开发机上。今天处理的是跟进看起来已经做完,库存却还少一件。

我在本机搭了一个玩具商店仓库。git 提交、pytest、三种合入闸门、以及闸门之后的快进或拒绝,都是真跑的。审查线程的状态写在本地 JSON 里,没有调用 GitHub API;GitHub 的「Require conversation resolution before merging」只作为语义对照,本机没有在真实仓库上打开这条规则。工作流 YAML 只检查了结构,没有在 GitHub Actions 上跑。下面先把这次实际得到的数说清楚,再说明闸门怎么补。

代理点了 Resolve 之后,部分退款的库存还是少一件

实验室怎么搭

玩具仓库的主干是 16cdaff。库存从 10 件起,先为这张订单预留 2 件,手上剩下 8 件。全额退 2000 分时,代码会把 2 件加回去,库存回到 10,对应的测试通过。部分退 1000 分、并声明退回 1 件时,函数把钱记上,却不调用 restock。直接跑这一笔,打印出来是 stock=8 refunded=1000 status=partial_refund。正确值应是 9。

审查意见记在一条阻塞线程 T1 里:路径是 shop/refund.py,commented_sha 绑在主干 16cdaff,并写明跟进必须引入 tests/test_refund.py::test_partial_refund_restocks。这不是 GitHub 的线上线程,是实验室用来驱动闸门的夹具。生产里应当用 gh api 或 Actions 把真实的 review thread 填进同一份结构,并把「这条意见要求哪条回归测试」写在线程的约定字段里,或者写在 4 号机的跟进清单里。

闸门有三种模式,故意分开,这样才能看见「已解决」放行了什么。

resolution 只问阻塞线程是不是都 resolved=true。语义上接近 GitHub 那条「合并前必须解决对话」的规则,但本机没有打开真实的 ruleset,数字全部来自这份本地实现。

file_touched 在已解决的基础上,再问 commented_sha 之后,被评论的文件有没有 diff。加一行日志就能过。

addressed 才是我建议装上的那一档。声明「已修复」时,被评论的文件必须改过,指定的回归测试必须出现在评论之后的 diff 里,并且在要合入的那个 SHA 上通过。声明 wontfix 时,只能由审查角色(实验室里是 machine-4-reviewer、machine-5-reviewer)写下不少于八个字的理由,作者自己标不算。

merge_if_threads.sh 会先把目标 SHA 放到一个临时 worktree 里跑 pytest,再跑 thread_gate.py,最后才允许对 main 快进。测试必须跑在将要合入的那个提交上。若在当前工作区——往往是 main——上跑,D 分支新加的测试会被误判成不存在。这是实验室里真实踩过的一脚,后来改掉了。

只点 Resolve、代码不动时,已解决闸门会放行

A 是对照:线程还开着,代码停在主干。三种闸门都是 REFUSE。测试 2 通过,库存仍是 8。这一档正是 GitHub 那条规则要拦住的:未解决的对话不得合并。

B 把同一条线程标成已解决,代理写上「已修复」,一行代码都没改。测试仍是 2 通过。resolution 变成 ALLOW。file_touched 拒绝,理由是 shop/refund.py 在 16cdaff 之后没有被改。addressed 同样拒绝。也就是说,只要合入策略只看「对话已解决」和「测试是绿的」,B 已经可以进 main,库存还是 8。

代理并不需要恶意。在审查节奏被推得很快的时候,「已修复」再点一下 Resolve,是很常见的收尾动作。GitHub 的已解决标记记录的是人(或代理)点过那个按钮,不是库存被加回去了。

加一行日志以后,连「文件被改过」也会放行

C 更像实际会交上来的假修复。代理在 else 分支里加了一行日志,提交是 f0f07d8,再把线程标成已解决,理由写成「已修复:加了部分退款日志」。测试仍是 2 通过。直接跑部分退款,库存还是 8。

这时 resolution 是 ALLOW,file_touched 也是 ALLOW——文件确实改了。addressed 拒绝,理由是指定的 test_partial_refund_restocks 没有出现在 16cdaff 之后的 diff 里。

merge_if_threads.sh 带着 addressed 去合,测试是绿的,闸门仍是 REFUSE,main 留在 16cdaff。对照实验是把模式改成 resolution:脚本快进成功,main 来到 f0f07d8,同一笔部分退款打印出来仍是 stock=8。把模式改成 file_touched,结果一样,main 同样进到 f0f07d8,库存同样是 8。随后实验室把 main 重置回 16cdaff,后面的对照互不干扰。

G 是 C 的另一种收尾:代理把这条线程标成 outdated,理由写「outdated, code moved」,再一并 Resolve。三种闸门的结论和 C 完全相同。过期只说明行号可能移了,不说明回库已经做了。把过期对话批量点成已解决,过的是 resolution,过不了 addressed。

同一套玩具仓库,几种接法的实测结果

真修复会改到文件,也会带上会失败的回归

D 做了两件该做的事。else 分支按 return_lines 调用 restock;测试文件里新增 test_partial_refund_restocks,断言库存为 9。提交是 e30e480。pytest 是 3 通过。直接跑那笔部分退款,结果是 stock=9 refunded=1000 status=partial_refund。三种闸门都是 ALLOW。merge_if_threads.sh 在 addressed 模式下把 main 从 16cdaff 快进到 e30e480。合并后再跑同一笔,库存仍是 9。随后 main 被重置回 16cdaff。

H 是反向对照:代理只在测试文件里加了一个同名函数,里面只断言退了 1000 分,根本没改 shop/refund.py。测试变成 3 通过,库存仍是 8。resolution 放行,file_touched 拒绝,addressed 也拒绝。只靠「出现了指定测试名」不够,生产文件还得在评论之后被改过。H 被拦住,不是因为闸门读懂了断言,而是因为退款文件根本没动。

作者自己标 wontfix 不算;审查机写明范围可以放行

E 和 F 不再假装修过。E 里,作者 machine-1-agent 自己把 T1 标成 wontfix,理由写「部分退款先不回库,下个迭代再做」。resolution 放行,file_touched 也放行——wontfix 并不要求改文件。addressed 拒绝,理由是作者不能替自己关掉这条阻塞意见。

F 里,同样的范围决定改由 machine-4-reviewer 写下:「这次范围只改退款金额文案,回库单独立项」。三种闸门都是 ALLOW。库存当然还是 8,但这是 4 号机明确把回库划出范围,不是代理把「已修复」写在对话上。后续如果回库仍然该做,那是另一张票,不该伪装成这张 PR 已经处理完意见。

闸门不读断言:日志加上弱测试仍会漏过

I 把 C 和 H 叠在一起:f0f07d8 那种日志,外加 3a44515 那种只检查退款金额、却叫 test_partial_refund_restocks 的测试。提交是 d2b4221。pytest 是 3 通过。文件被改了,指定测试名出现在评论之后的 diff 里,并且通过。addressed 给出 ALLOW,merge_if_threads.sh 把 main 快进到 d2b4221。再跑同一笔部分退款,库存仍是 8。随后 main 再次被重置。

这是这套闸门的余量,必须说清楚。它核对的是「文件动过、指定测试被引进来并且是绿的」,不核对测试断言是不是真的在问库存。把弱断言写进同名测试,仍然进得了 main。4 号机还是要打开这条回归,看它断言的是 9 还是只断言了钱。闸门能拦住 B、C、G、H 和作者自报的 wontfix,拦不住一个愿意同时改生产文件和测试文件、却把测试写弱的跟进。那种写法属于削弱测试,应当另有测试审查,不要指望这一层闸门把它读出来。

已处理闸门在合入前要核对的几件事

机制:已解决记录的是按钮,不是行为

审查对话被解决,回答的是「这条线在界面上收起来了」。仓库要问的是另一件事:评论所针对的行为,现在是不是真的变了,而且以后再被改坏时会不会红。

这两件事被同一个按钮绑在一起,是因为人审查时,通常会先看 diff,再决定要不要点 Resolve。代理把这个顺序倒过来了。它先点 Resolve,再保证测试是绿的。已有测试如果本来就不覆盖那条意见,绿勾不会抗议。GitHub 把 Copilot 一类账户的评论也当成可以解决的对话,规则本身并不区分点按钮的是人还是代理;本机没有接这类账户,我不能把线上产品行为写成实测,但本地的 B 和 C 已经足够说明:按钮和回库不是同一条证据。

「文件被改过」看起来更严,其实很薄。日志、注释、重命名,都能让 shop/refund.py 出现在 diff 里。C 和 G 就是这样过的。要把意见钉住,还需要一条在评论之后才出现的回归,并且这条回归要在将要合入的 SHA 上跑过。闸门用临时 worktree 检出目标提交,就是为了不拿当前工作区的测试结果顶替。

wontfix 必须留口,否则每一条风格意见都会卡住合入。口不能开给作者。E 和 F 的差别不在理由写得长不长,而在于是谁承担「这次可以不改」的责任。

什么时候不该装这套闸门

如果审查意见只是措辞、格式、命名,要求每条都配一条回归测试,会把 4 号机和合入队列一起拖死。这类意见不该标成 blocking,也不该写 requires_test。闸门针对的是会改变金额、库存、权限、数据去向的那一类意见。

如果团队的规则是:未解决的对话不得合并,而且 4、5 号机一定会自己点 Resolve,并且一定会打开跟进 diff 和新增测试,那么本地的 addressed 主要是在代理抢先点按钮时做备份。三人七机、代理跟进很快的时候,这个前提通常不成立。

如果仓库里还没有「这条阻塞意见对应哪条测试」的约定,闸门会因为 requires_test 为空而拒绝所有「已修复」。这是故意的。先让 4 号机在意见里写上测试节点,或者在跟进清单里写上,再打开闸门;不要先让代理随便填一个测试名。

这条闸门也替代不了「Require conversation resolution」。A 那种还没人点 Resolve 的 PR,应当继续被那条规则拦住。两者叠在一起:先要求对话被处理,再要求「已修复」名副其实。

它更替代不了人对弱测试的阅读。I 已经把余量跑出来了。

明天可以抄走的做法

打开(或保留)GitHub 的「合并前必须解决对话」。这只能拦住 A,拦不住 B 和 C。

给会改变行为的阻塞意见写上 requires_test,节点要具体到函数名。AGENTS.md 里写明:代理不得把阻塞对话点成已解决,除非这条测试已经出现在评论之后的 diff 里并且通过;wontfix 留给 4、5 号机。

把 thread_gate.py --mode addressed 做成必需检查。检查要在目标 SHA 的 worktree 里跑测试,不要用当前工作区的结果。merge_if_threads.sh 只有在测试和闸门都通过之后才允许快进。

生产里用 GitHub 的 review thread 填充 JSON,不要用机器人自己的账号当作 resolved_by。必需检查的名字必须和规则集里要求的那个名字一致。这次的工作流只做了结构检查,没有在 Actions 上跑。

脚本、工作流和 AGENTS.md 片段在 review-thread-kit.tar.gz。闸门这样调用:

python3 tools/thread_gate.py --repo . --head HEAD --threads threads.json --mode addressed

今天最小的一步:打开一张最近已经合进去、并且审查对话全部是已解决的 PR,看其中有没有一条意见只换来了日志、注释,或一个并不断言原问题的同名测试。如果有,把那条回归补成会失败的断言,再决定这张 PR 是不是还该留在 main。

这次实际跑了什么

跑了:本机 git 2.47.3、Python 3.13.5、pytest 9.1.1;玩具仓库里的提交和分支;主干和各条对照上的 pytest;三种闸门模式;merge_if_threads.sh 拒绝 C 的 addressed 合入,并把 C 在 resolution / file_touched 模式下快进到 f0f07d8(库存 8,随后重置);把 D 快进到 e30e480(库存 9,随后重置);把 I 快进到 d2b4221(库存 8,随后重置);作者 wontfix 与审查机 wontfix 的判定。

没有跑:GitHub API、真实的 branch protection / ruleset、合并队列、第二个 GitHub 账号去点合并、GitHub Actions(YAML 只检查了结构,本机没有安装 PyYAML)、任何 Windows 任务。短 SHA 来自这一次运行,重建仓库会变;字段关系和判定不会变。

Three people, seven computers. In the afternoon, machine 4 is reviewing a refund pull request. The full-refund path is clean: the money goes back, and both units return to stock. The failure is on a partial refund. A customer bought two units of WID-1 and returns one of them for 1000 cents. refund accepts return_lines=[("WID-1", 1)], records the money, sets the status to partial_refund, and then drops the restock list on the else branch. The unit is already back in the warehouse. The system still shows 8 in stock, not 9.

Machine 4 pins a comment on that else branch in shop/refund.py: the partial refund returns the money and does not restock the returned unit. The agent on machine 1 replies “fixed” and marks the conversation resolved. The tests are still 2 passed. Those two tests check that a full refund restocks, and that a partial refund returns the money. They never ask whether stock is 9. If machine 6 only reruns the same tests, it will be green too. This lab did not run a Windows job, so I am not reporting a machine-6 exit code as something I measured. If machine 7 treats “every conversation is resolved, and the tests are green” as a reason to merge, this change lands on main.

This essay asks whether a review thread marked resolved is the same thing as a review thread that was addressed; which pretend fixes a “resolve conversations before merge” rule will allow; and what a merge gate still has to check. It is not the same problem as the earlier piece on who picks up the next change when the session that opened the pull request is already gone. That one is about binding a living pull request to a machine that is still awake. This one is about follow-up that looks finished while stock is still short one unit.

I built a toy shop repository on this machine. The git commits, pytest, the three merge gates, and the fast-forward or refusal after the gate were actually run. Review-thread state lives in local JSON; the GitHub API was not called. GitHub’s “Require conversation resolution before merging” is used only as a semantic counterpart. This machine did not enable that rule on a real repository. The workflow YAML was checked for structure and was not run on GitHub Actions. The numbers from this run come first; the gate comes after.

After the agent marks the thread resolved, stock is still short one unit

How the lab was built

The toy repository’s main is 16cdaff. Stock starts at 10. The order reserves 2 units, leaving 8. A full refund of 2000 cents restocks both units, stock returns to 10, and the matching test passes. A partial refund of 1000 cents that also declares one returned unit records the money and never calls restock. Running that case prints stock=8 refunded=1000 status=partial_refund. The correct stock is 9.

The review comment is stored as one blocking thread, T1: the path is shop/refund.py, commented_sha is bound to main at 16cdaff, and follow-up is required to introduce tests/test_refund.py::test_partial_refund_restocks. This is not a live GitHub thread. It is the fixture that drives the gate. In production, fill the same structure from gh api or from Actions, and put “which regression this comment requires” in a field on the thread, or on a follow-up list from machine 4.

The gate has three modes, kept separate on purpose, so it is visible what “resolved” will allow.

resolution only asks whether every blocking thread has resolved=true. That is the local counterpart of GitHub’s conversation-resolution rule. No real ruleset was turned on; every number comes from this local implementation.

file_touched adds a second question, once the thread is resolved: after commented_sha, does the commented file have a diff? A log line is enough.

addressed is the mode I would install. When the resolution is “fixed”, the commented file must have changed, the named regression test must appear in the diff after the comment, and that test must pass on the SHA you would merge. When the resolution is wontfix, only a review role (machine-4-reviewer or machine-5-reviewer in this lab) may say so, with a reason at least eight characters long. The author cannot wontfix their own blocking thread.

merge_if_threads.sh places the target SHA in a temporary worktree, runs pytest there, runs thread_gate.py, and only then allows a fast-forward of main. The tests have to run on the commit that would be merged. If they run in the current worktree — often main — a test that only exists on D is reported as missing. That is a real misstep from this lab, and it was fixed before the numbers below were recorded.

Resolve with no code change, and the resolution gate allows it

A is the control: the thread is still open, and the code is still main. All three gates are REFUSE. Tests are 2 passed, and stock is still 8. This is the case GitHub’s rule is meant to stop: unresolved conversations must not merge.

B marks the same thread resolved. The agent writes “fixed” and does not change a line of code. Tests are still 2 passed. resolution becomes ALLOW. file_touched refuses, because shop/refund.py did not change after 16cdaff. addressed refuses too. So if the merge policy only asks whether conversations are resolved and whether tests are green, B is already allowed onto main, and stock is still 8.

The agent does not have to be malicious. When review is moving at agent speed, “fixed” plus a click on Resolve is a very ordinary way to close a thread. GitHub’s resolved mark records that someone — or some agent — clicked the button. It does not record that the unit was restocked.

After a log line, “the file changed” allows it too

C is closer to the pretend fix that actually gets pushed. The agent adds a log line on the else branch, in commit f0f07d8, marks the thread resolved, and writes “fixed: added a partial-refund log.” Tests are still 2 passed. The partial refund still prints stock 8.

resolution is ALLOW, and file_touched is ALLOW — the file did change. addressed refuses, because test_partial_refund_restocks does not appear in the diff after 16cdaff.

merge_if_threads.sh in addressed mode keeps main at 16cdaff: the tests are green, the gate is still REFUSE. The control is to switch the mode to resolution: the script fast-forwards, main moves to f0f07d8, and the same partial refund still prints stock=8. Switching the mode to file_touched does the same thing: main moves to f0f07d8, stock is still 8. The lab then reset main to 16cdaff so later cases stay independent.

G is C with a different closing ritual. The agent marks the thread outdated, writes “outdated, code moved,” and resolves it. The three gates give the same answers as C. Outdated only means the line number may have moved. It does not mean restock happened. Bulk-resolving outdated conversations will satisfy resolution. It will not satisfy addressed.

Measured results on the same toy repository

A real fix changes the file and brings a regression that can fail

D does the two things that were required. The else branch calls restock for return_lines. The test file gains test_partial_refund_restocks, which asserts that stock is 9. The commit is e30e480. pytest is 3 passed. The same partial refund prints stock=9 refunded=1000 status=partial_refund. All three gates are ALLOW. merge_if_threads.sh in addressed mode fast-forwards main from 16cdaff to e30e480. After the merge, the same case still reports stock 9. main was then reset to 16cdaff.

H is the reverse control. The agent only adds a function of the required name in the test file. That function asserts that 1000 cents were refunded, and it does not touch shop/refund.py. Tests become 3 passed; stock is still 8. resolution allows, file_touched refuses, and addressed refuses. It is not enough for the required test name to appear. The production file must also have changed after the comment. H is stopped not because the gate understood the assertion, but because the refund file did not move.

An author wontfix does not count; a reviewer’s written scope can

E and F stop pretending the code was fixed. In E, the author machine-1-agent marks T1 as wontfix, with the reason that partial restock can wait for a later iteration. resolution allows, and file_touched allows — wontfix does not require a file change. addressed refuses, because the author cannot close their own blocking thread this way.

In F, the same scope decision is written by machine-4-reviewer: this pull request is only about refund copy, and restock will be a separate ticket. All three gates are ALLOW. Stock is still 8, but that is machine 4 taking the scope, not an agent writing “fixed” on the thread. If restock still needs to happen, it belongs on another ticket. It should not be disguised as this pull request having addressed the comment.

The gate does not read assertions: a log line plus a weak test still lands

I stacks C and H: the same kind of log line as f0f07d8, plus a test still named test_partial_refund_restocks that only checks the refund amount, as in 3a44515. The commit is d2b4221. pytest is 3 passed. The file changed, the required test name appears in the post-comment diff, and the test passes. addressed returns ALLOW, and merge_if_threads.sh fast-forwards main to d2b4221. The same partial refund still prints stock 8. main was reset again.

This remainder has to be stated clearly. The gate checks that the file moved, and that a named test was introduced and is green. It does not check whether the assertion actually asks about stock. A weak assertion under the required name still reaches main. Machine 4 still has to open that test and see whether it asserts 9, or only asserts the money. The gate will catch B, C, G, H, and an author’s own wontfix. It will not catch a follow-up that is willing to touch both the production file and the test file, and then weaken the test. That is a weakened test, and it needs a review of the test itself. This gate should not be asked to read the assertion.

What the addressed gate checks before a merge

The mechanism: resolved records a click, not a behavior

A conversation marked resolved answers “this thread is folded up in the UI.” The repository needs to ask something else: did the behavior the comment named actually change, and will it go red if someone changes it back?

Those two questions get bound to the same button because a human reviewer usually reads the diff first, and only then decides to resolve the thread. An agent reverses the order. It resolves first, and then keeps the tests green. If the existing tests never covered the comment, the green check does not object. GitHub treats comments from accounts such as Copilot as conversations that can be resolved; the rule itself does not care whether a person or an agent clicked the button. This lab did not attach those accounts, so I am not reporting live product behavior as something I measured. B and C on this machine are already enough: the button and the restock are not the same evidence.

“The file changed” looks stricter, and is thin. A log line, a comment, or a rename can put shop/refund.py in the diff. That is how C and G pass. To pin the review comment down, you also need a regression that appears only after the comment, and that regression has to run on the SHA you would merge. The gate uses a temporary worktree at that SHA so the current worktree’s test results cannot stand in.

wontfix has to exist, or every style comment will stall the merge. The opening cannot belong to the author. The difference between E and F is not how long the reason is. It is who takes responsibility for “we will not change this now.”

When you should not install this gate

If the comments are wording, formatting, or naming, requiring a regression for every thread will stall machine 4 and the merge queue together. Those comments should not be blocking, and they should not carry requires_test. The gate is for comments that change money, stock, permissions, or where data goes.

If the team’s rule is that unresolved conversations cannot merge, and machines 4 and 5 always resolve the threads themselves after they have opened the follow-up diff and the new test, then the local addressed mode is mostly a backup for the case where an agent clicks Resolve first. On a three-person, seven-machine team shipping at agent speed, that premise usually fails.

If the repository has no convention for “which test this blocking comment requires,” the gate will refuse every “fixed” claim because requires_test is empty. That is intentional. Have machine 4 put the test node on the comment, or on a follow-up list, before you turn the gate on. Do not let the agent invent a test name.

This gate also does not replace “Require conversation resolution.” A pull request like A, where nobody has resolved the thread, should still be blocked by that rule. Use both: first require the conversation to be handled, then require a “fixed” claim to be real.

It does not replace a person reading a weak test either. I already measured that remainder.

What to copy tomorrow

Turn on, or keep, GitHub’s “require conversation resolution before merging.” It will stop A. It will not stop B or C.

Put a requires_test on blocking comments that change behavior, down to the function name. In AGENTS.md, say that agents must not resolve a blocking thread unless that test is in the diff after the comment and is passing. Leave wontfix to machines 4 and 5.

Make thread_gate.py --mode addressed a required check. Run the tests in a worktree of the target SHA; do not use the current worktree’s results. merge_if_threads.sh should fast-forward only after both the tests and the gate pass.

In production, fill the JSON from GitHub review threads, and do not treat the workflow robot as resolved_by. The required check name must match the name your ruleset expects. The workflow in this lab was inspected for structure; it was not run on Actions.

The scripts, the workflow, and the AGENTS.md snippet are in review-thread-kit.tar.gz. Call the gate like this:

python3 tools/thread_gate.py --repo . --head HEAD --threads threads.json --mode addressed

The smallest step today: open a pull request you already merged whose review conversations are all resolved, and see whether any comment was closed with only a log line, a remark, or a same-named test that does not assert the original problem. If you find one, turn that regression into an assertion that would have failed, and then decide whether the pull request should still be on main.

What was actually run

Run: git 2.47.3, Python 3.13.5, and pytest 9.1.1 on this machine; the commits and branches in the toy repository; pytest on main and on each case; the three gate modes; merge_if_threads.sh refusing an addressed merge of C, and fast-forwarding C under resolution and file_touched to f0f07d8 (stock 8, then reset); fast-forwarding D to e30e480 (stock 9, then reset); fast-forwarding I to d2b4221 (stock 8, then reset); the author-wontfix and reviewer-wontfix decisions.

Not run: the GitHub API; a real branch protection rule or ruleset; a merge queue; a second GitHub account to click merge; GitHub Actions (the YAML was checked for structure, and PyYAML was not installed on this machine); any Windows job. The short SHAs come from this run. They will change if the repository is rebuilt; the field relationships and the decisions will not.