PostHog PR 描述写作技能实战解析:从三个已合并 PR 看五遍打磨如何缩短正文
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
导读
本文以 PostHog 开源仓库中的技能文档.agents/skills/writing-pr-descriptions/references/examples.md为骨架,完整解析该技能如何把 PR 描述写成"审阅者扫一眼就能定位注意力"的扫描面:先是三个已合并 PR 的端到端改写实例(一次重排、两次删减),再补上技能主文件.agents/skills/writing-pr-descriptions/SKILL.md的五遍工作法(Pass 0–5)以及posthog/models/flag_evaluations/sql.py、posthog/clickhouse/migrations/下的真实源码作为证据。读完后,你将掌握一套可复制的 PR 正文写作流程:效果前置、逐条删减、逐条成句、短句主动语态,以及"正文必须比初稿更短"的可检查自检标准。
为什么 PR 正文是扫描面,而不是文章
技能文档开宗明义:审阅者用几秒钟扫描一段描述,然后决定把注意力花在哪里。正文必须脱离 diff 独立成立(stand without the diff),因为许多审阅者直接看代码,只有当正文"赚到"注意力时才会回头读它。
两个关键词贯穿全文:
- 顺序(Order)决定理解:审阅者是否理解改动,取决于信息排列;
- 形式与长度(Form and length)决定速度:同一事实用哪种载体最快传递。
因此技能规定"先解决顺序,绝不为了形式牺牲顺序",并要求以五遍工作法推进:lead(效果前置)、route(路由到形式)、cut(删减)、shape(塑形)、check(自检)。已有正文时,先做 Pass 0 保留现场。
Pass 0:编辑已有正文,而不是覆盖
gh pr edit --body会替换整个正文,因此草稿中"没有归宿"的部分一推送就会消失。已有正文里藏着无法重建的工作:人工上传的截图与录制、收集的链接、勾选的复选框、写给指定审阅者的备注。
标准流程(来自 SKILL.md):
gh pr view <number> --json body --jq .body > pr-body.md # edit pr-body.md gh pr edit <number> --body-file pr-body.md把已有正文中的每一张图片、每段视频、每个链接、每个已勾选项都带进新正文,放到它所属的标题之下;只有改动使其不再成立时才替换,并在正文中说明替换理由。
Pass 1:第一行写效果,不写机制
第一行是唯一保证被读到的行。写作者刚在机制里泡了一小时,机制会自然先冒出来——技能要求把它压下去,把这一行留给"人体验到了什么"。
四个形状覆盖几乎全部 PR:
- 修复(fix):什么坏了,对谁坏;
- 特性(feature):什么人原来做不到什么、现在能做到了(例:SQL 编辑器支持 join,却无法给表挂计算字段);
- 重构/杂务/使能改动:谁被阻塞、代价是什么、消除了哪一类故障——没人看得见,但有人在等;
- 后续改动/栈中的一层:上一个 PR 留下了什么没做完,这一个补了什么;必须链接那个 PR 并假设没人读过它。
配套规则:
- 第一行若以符号、文件路径、类名或设置项开头,说明你以机制开头,重写;
- 在知道的前提下用一句话量化问题规模:多少团队、多频繁、从何时起;
- 机制跟在效果之后,按审阅者需要检查的顺序排列;
- Changes 的第一条子弹是改动本身;重命名、再生成的快照、注释修正放最后;
- 若改动含用户可见部分,用一行说明哪部分是机械性的,否则审阅者无法区分"纯内部改动"与"你描述成内部的可见改动";
- 若 diff 中某部分风险更高,点名它,并说明其余是机械性的。
examples.md 中 Example 1 就演示了这种"重排不改写":三个事实原样保留,只把顺序倒过来,审阅者先看到"挂了 30 秒再失败",再看到 MessagePort 时序,最后才是port?.postMessage(...)丢消息的机制——没有新增、没有删减,风险先于原因呈现。
Pass 2:把每条事实路由到最快的形式
散文是页面上最慢的载体。写任何句子前先问:什么形式传递得更快?路由表如下:
| 事实类型 | 承载形式 |
|---|---|
| 视觉变化(任何人看到的 UI) | 截图,前后对比,强制而非可选 |
| 流程/拓扑变化(CI 接线、管道、状态机、请求路径) | 两个带品牌配色的 flowchart,before 在前 |
| 同一维度下多个值比较 | Markdown 表格 |
| 配置/设置变更 | fenceddiff块 |
| 审阅者需要看的现有代码 | 行区间 permalink(GitHub 渲染为代码片段) |
| 测试输出、日志、长命令记录 | <details>块 |
| 不可错过的行为变化或风险 | > [!WARNING]或> [!NOTE] |
| 其他一切 | 子弹,遵循 Pass 4 的塑形规则 |
不是每个 PR 都需要所有形式;只有当它能加快审阅时才使用,绝不做装饰。空小节写一条子弹或 "None"。UI 改动却没有可见变化时,用一行说明"外观没有变化"——审阅者无法区分这种情形与漏截图,沉默会被读成后者。
Mermaid 与截图的上传约束
SKILL.md 补充了具体约束:Mermaid 语法错误会渲染成错误块;高管道用TD、宽路径用LR;Mermaid 读不了 CSS 变量,必须直接写 hex,并且每个fill配一个文本color,保证 GitHub 亮暗两种主题下都清晰。技能提供的四个品牌配色:
classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff; classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff; classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000; classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;按角色赋值(class NodeA,NodeB phBlue;):phBlue给 agent 与主路径,phRed给 API 与外部系统,phYellow给出入口,phGray给数据与产物;形状按种类:{{hexagon}}表示 agent,[rect]表示步骤。
截图通过hogli pr:upload-image <file>上传并粘贴其打印的 markdown;首次运行只警告,重跑加--yes。产物永久公开,因此严禁上传客户数据、客户名、密钥或内部信息。
Pass 3:删减——正文必须站得住,且更短
保留什么、删掉什么
正文必须独立成立:不要假设审阅者先读 diff,甚至完全不读。保留:
- 改动为什么必要;
- 它做了什么(达到无需打开文件就能理解的程度);
- 你否决的替代方案、爆炸半径、上线后要盯什么、先看哪里;
- 六个月后从
git blame抵达的人需要什么——他们问不到你,review 线程也不会告诉他们。
删掉:
- 逐文件、逐行的 diff 叙述;
- 无人质疑的选择背后的理由(只在否决了显而易见替代方案时保留理由);
- 标题的复述与上面小节的总结;
- 过程叙述("然后我跑了 X、Y"是关于你会话的事实,不是关于改动的事实);
- 对无争议事实的含糊其辞;
- 任何"原因 + 原因为什么重要"组合里的后半句;
- 无法点名读者的子弹。
判据不是"它是否在 diff 里"——diff 拥有每个细节,却完全没有要点。
规模跟着改动走
能套在任何 PR 上的正文,对这个 PR 就什么都没说。六行 diff 的正文必须读起来像六行改动的正文:
- 单文件修复:整篇正文 3 到 6 条子弹;
- 典型 PR:Problem 与 Changes 合计约 10 条子弹;
- 每个不适用的标题下写一行或 "None",那是完整回答而非空缺;
- 数字、路径、标识符在删减中存活,形容词与第二层解释不存活。
小不等于残缺:三条子弹仍要承载"为什么必要"与"做了什么"。
可检查的主张
描述是 PR 中唯一没有验证机制的人工产物——代码有 CI,正文只有你。因此要让每条主张都便宜到易于证伪:
- 关于世界的声明(你跑了什么、测了什么、在生产里看到了什么)必须链接证据:失败的运行、error tracking issue、行区间 permalink、dashboard;
- 删掉 CI 已经替你声明的内容("24 passed"、"mypy clean"既占一行又无法从正文核验,而且 checks 更有权威);
- 声明你没检查的内容("未运行:数据库相关套件,因为该沙箱没有数据库"是多数正文里最可信的一行);
- 绝不声称没做过的测试——事后被发现一次,就会赔上此后所有描述的可信度。
关于代码行为如何的陈述无需链接——审阅者对着代码就能核验。examples.md 明确区分两者:"The fallback never fires"属于第二类;"One source has failed every run since May"属于第一类,需要链接。
Changes 之下的小节
Problem 与 Changes 承载审阅。其下一切都是证据与来源,审阅者最后才看或根本不看;当下半部分超过上半部分时,砍下半部分:
- Testing:按上述主张规则点名每条新测试防住哪个回归;记录放
<details>块; - Agent context:自主性、工具、调用的技能、会话中发生了什么变化;
- 你的设计为何胜过显而易见替代方案的理由属于 Changes——审阅需要它,而没人会滚过 changelog 复选框去找它。
最终判据:正文必须比你的初稿更短,Pass 5 会检查它。
Pass 4:塑形——可检查的形状
形状可检查,语气不可检查,这正是技能不谈语气的原因。九条规则:
- 每条子弹一个事实;
- 子弹前置加载:扫描者看到开头几个词,所以以承载事实的主语开头,而不是它成立的条件下;
- 句子不超过 25 词;
- 主动语态、明确主语;仅当动作主体确实未知或无关时才用被动;
- 简单时态,不用完成时/进行时:"the builder took entry 1",而不是 "the builder has been taking entry 1";
- 同一事物始终用同一词,不为风格换词;
- 保留冠词:"The job downloads the artifact",而不是 "job downloads artifact";
- 名词串最多三个词:"The flag evaluation column codec" 改成 "the codec on the flag evaluation column";
- 无习语、无比喻、无玩笑。
规则 2 排布子弹内部的词序,Pass 1 排布子弹之间的顺序,两者从不冲突:效果在前,陈述效果的子弹以受影响的人开头。只作用于散文——表格单元格不是句子,图不是散文。
SKILL.md 中的工作示例:一个 28 词、五环因果链的句子被拆成三条各 22 词的可独立核验子弹,保留了每个标识符与审阅者必须检查的每个环节,去掉的是低于读者需求一层的细节(glob 匹配单个产物)。
其他散文规则:不用破折号(en-dash 仅在需要时用);标题、章节、加粗文本用句首大写(只大写首词与专有名词);少用行内代码,节制使用冒号与分号;不按列宽硬换行、不对齐表格(GitHub 自行重排渲染)。最重要的:句子的主语是改动本身,不是作者——绝不出现 "I/me/my","we" 只留给 PostHog;"The exporter now retries once",而不是 "I made the exporter retry once"。代理以 "I" 写作等于把别人没做过的工作记到被指派者头上。作者身份是## 🤖 Agent context中一条陈述事实,而不是正文的语气。
Pass 5:自检自己的草稿
在gh pr create或gh pr edit之前跑两项检查。
扫描测试(scan test)
只看标题、Problem 第一行、Changes 第一条子弹,遮住其余:
- 你知道现在什么不同了、对谁不同吗?
- 你知道这个 PR 对此做了什么吗?
- 你没有靠符号、文件路径或类名就到达了上述两点吗?
任何一处 "no" 都说明正文是按写作者而非读者排的,回到 Pass 1——行检查救不了这一点。
行检查(line check)
- 正文比初稿短吗?更长说明只拆没砍,回到 Pass 3;
- 正文规模跟随 diff 规模吗?六行改动配长篇正文读起来是填充;
- Problem 与 Changes 合计比其下的小节长吗?否则砍下半部分;
- 合上 diff 读正文,能说出这个 PR 为什么存在、做了什么吗?不能就是砍掉了读者需要的东西;
- 只读 Changes,能说出一个人现在会看到/做到什么不同,或说明没有用户可见变化吗?两者皆否则回到 Pass 1;
- 逐条读子弹并点名读者,点不出名字的就删;
- 每条子弹独立陈述一个事实吗?两个就拆;
- 最长句超过 25 词就拆;
- 把每个被动句改成主动(除非主体确实未知);用介词拆开超过三个词的名词串;
- 有没有句子以作者为主语?围绕改动重写,"I/me/my" 不得出现;
- 改动改变人能看到的东西吗?附前后截图,或说明外观为何无变化;
- 重写已有正文了吗?人工放置的图片、视频、链接、勾选项是否都还在;
- 改动流程或拓扑了吗?附品牌化的前后图;
- 散文在比较同一维度下的多个值吗?换成表格;
- 每一条关于"跑了什么、测了什么、看到了什么"的主张都链接证据或声明未核验吗?行为描述无需链接;
- 有
<!-- -->模板注释残留吗?该节未填,填充或删除; ## 🤖 Agent context填了吗?列出调用的技能;- 正文声明了没发生的手动测试吗?删掉;
- 正文点名了内部客户、事故、Slack 引文或运营指标吗?本仓库是公开的,删掉。
技能强调:没有任何检查器强制这些规则,Pass 5 就是强制手段。
examples.md:三个已合并 PR 的端到端演练
references/examples.md的定位是"规则在 SKILL.md 中清楚、但你想看它端到端应用"时的读物:三个已合并的 PR,按发布状态(经过全部五遍之后)展示。其中 Example 1 是重排——同样的事实,按审阅者需要的顺序排列;Example 2 与 3 是删减。三者都比原稿更短,因为子弹是切到关键事实的方式,而不是把段落以更长篇幅复述一遍。
Example 1:效果从三行降到两行
fix(dashboards): tolerate legacy keys in persisted dashboard filters。问题段初稿 82 词、发布稿 71 词,每个事实都存活,只是顺序重排:审阅者先学到"磁贴坏了、没人能在应用里修复",然后才轮到"哪个类校验了什么"。表格列出的三处移动印证了"路由"逻辑:
| 文本 | 从 | 到 |
|---|---|---|
| "A 400 for unknown keys on write would block saving: the UI echoes persisted blobs back into saves" | Agent context 最后一条子弹 | Changes,作为一条子弹 |
"Ranpyteston the two touched test files (52 passed), repo-widemypy(clean), andhogli ci:preflight --fix(no failures)" | Testing | 删除。checks 已报告三者 |
"The existing PATCH round-trip test intest_dashboard.pyguards the wiring" | Testing | 删除。已有测试仍然通过 |
被否决的替代方案是审阅者判断设计所需的一行,最终放在 changelog 复选框之下。
Example 2:一条长因果链
fix(data-warehouse): recognize Neon's pooler rejection of libpq options。发布稿 297 词,剩余各遍之后 197 词。核心机制:事务模式连接池拒绝 libpqoptions启动参数,_connect_with_options_fallback按池子用的确切措辞匹配并重连:
FATAL: unsupported startup parameter: optionsNeon 的 pooled 端点点名的是设置项而非参数:
ERROR: unsupported startup parameter in options: statement_timeout."unsupported startup parameter: options"不是后者的子串,于是 fallback 永不触发、连接直接失败。CDC 路径上该连接是cdc_extract_activity做的第一件事,流读取器总是发送options,因此每次针对 Neon pooled 端点的抽取都在stream_reader.connect()上永久失败,且被归类为可重试的connection_failed——用户被告知去检查一个既可达又健康的数据库。有一个数据源从创建当天起就从未成功抽取过。
Pass 3 砍了 100 词:Neon 错误第二行与完整用户可见消息(读者只需要破坏匹配的那句措辞)、statement_timeout=1800000 -c idle_in_transaction_session_timeout=0("总是发送 options"才是事实)、原因对中的后半句、无人质疑的主张、以及属于代码注释的 timeout 细节。Pass 4 把 76 词的失败链拆成审阅者逐条检查的五环。两个 fenced 块原样存活——Pass 4 只管散文。
示例还指出该改写本身未通过 Pass 1:首行讲的是 libpq 事实,而效果在第 6、8 条子弹。应改为以"Neon pooled 端点同步从未工作过"与"用户被告知检查一个健康的数据库"开篇;且首条子弹是唯一受 claim 规则约束的——"从未工作过""每次运行都失败"是作者亲见,读者无法对照代码核验,必须链接运行记录或 error tracking issue。
Example 3:一段里两个独立理由
fix(flags): drop custom codecs from flag_evaluations columns。发布稿 190 词,剩余各遍之后 111 词。Migration 0292 给flag_evaluations加了显式逐列 codec(String 列ZSTD(1)、datetime 列DoubleDelta, ZSTD(1)),但这是错误决策:该集群在 ClickHouse 服务端统一调优压缩,钉死列级 codec 只会把它从集群级调优中摘出去;且 DoubleDelta 只在值随排序键趋势变化时才有回报,而flag_evaluations按(team_id, flag_key, toDate(timestamp), cityHash64(distinct_id))排序,不以时间开头,DoubleDelta 在此不划算。Migration 0293 先 SET 再MODIFY COLUMN ... REMOVE CODEC,横跨分片数据表与两个 Distributed 表——先设置后移除,保证无论 0292 已运行(codec 存在)还是全新安装已按更新 DDL 建成无 codec 表(REMOVE CODEC对无 codec 列会报错),迁移都能干净执行。
Pass 3 砍了 79 词:精确 codec 值(上一条子弹已说明用途)、"That's the wrong call here"这类由下两条子弹自然推出的结论、同句重复的主张、三个完整表名("All three tables"足够)。Pass 4 把两个独立理由(集群统一调优 vs DoubleDelta 失效)从焊接成一段中拆开——否则想质疑 DoubleDelta 论证的审阅者必须先解开中央调优论证。最终效果线:"The codecs onflag_evaluationstake those columns out of the cluster's central compression tuning and buy nothing back."
仓库源码印证:flag_evaluations的真实形态
Example 3 讨论的表在当前仓库中真实存在,可以对照核验。列模板在 posthog/models/flag_evaluations/sql.py:
- 第 72–77 行明确写着"No column carries a CODEC",并给出与示例 PR 完全一致的理由:
ORDER BY只把时间戳归一到天、之后按 distinct_id 哈希排序,三个DateTime64列在磁盘上近乎随机排列,正是 delta 家族失效的地方,且注明"仅在有测量数据时重访"; - 表族命名沿用主集群分片惯例:
sharded_flag_evaluations(DATA 节点上的分片复制 MergeTree)、writable_flag_evaluations(ingestion 层 Distributed 写路径)、flag_evaluations(DATA 节点读路径,HogQL 以posthog.flag_evaluations暴露)、kafka_flag_evaluations与flag_evaluations_mv(见 sql.py 第 21–33 行); - 分片键用
sipHash64(distinct_id)与 events 表一致,使回填分片本地化;排序键内嵌toDate(timestamp)支撑月度分区下的按旗标日查询(第 47–58 行)。
迁移文件链与示例中"0292 建列、0293 删 codec"叙事对应:
- 0292_flag_evaluations.py 按五步创建整条表族:分片存储表(DATA)→ writable Distributed(ingestion 层)→ 读路径 Distributed(DATA)→ Kafka 引擎表(ingestion 层)→ 最后的物化视图;
- 0297_flag_evaluations_events_mirror.py 展示了 launch 前用"drop-and-recreate 而非 ALTER"重塑表族——topic 尚无生产者、全族为空,从规范列模板重建不会与全新安装漂移,且避免可能卡死发布流程的
DROP COLUMN;drop 按依赖逆序(MV → Kafka → Distributed 前端 → 存储),带SYNC清理复制表的 ZooKeeper 元数据; - 0301_flag_evaluations_default_columns.py 把九个类型化属性列从 MATERIALIZED 重建为 DEFAULT——
ALTER UPDATE可写的类型,供未来属性删除重写路径使用。
这些迁移直接呼应技能 Pass 3 的规则:sketch 级叙述("0292 给了逐列 codec、镜像 events 表")与机制("REMOVE CODEC对无 codec 列报错,所以先 SET")是正文该保留的事实,而精确值与重复结论应删除。若以此类迁移为素材写作 PR 描述,正文可按 examples.md 的模板组织:Problem 写人看到的失败("每次抽取都失败、用户被告知检查健康的数据库"),Changes 写机制与取舍("匹配改为前缀、先 SET 再 REMOVE、shared column template 同步剥除 CODEC"),并把属于代码注释的细节留在注释里。
技能背景与依据
SKILL.md 的 Background 一节给出了这套规则的研究依据:
- Pass 1 建立在三项研究上:NN/g 的网页写作研究中 19 名参与者有 15 名以扫描方式接近陌生文本,简洁、可扫描、前置加载的版本可用性高出 124%;扫描者看到的是每行开头,因此前几个词决定其余内容是否被读;Bacchelli 与 Bird(ICSE 2013)发现审阅时间花在理解改动而非发现缺陷上,所以"交付理解"是正文的第一职责。Google 的 CL 描述规范在 Pass 3 上与之呼应:描述承载问题与本方案的理由,并给不在代码里的读者足够上下文;
- Pass 4 改编自 ASD-STE100(Issue 9,2025 年 1 月)53 条写作规则中的子集:25 词上限是该标准对描述性文本的限值(程序性文本为 20 词,PR 正文很少涉及);规则 1 与 2 是自有规则——STE 对程序写"一句一个指令"、对描述写"一段一个主题",子弹介于两者之间,而前置加载来自上述扫描研究。标准另一半(约 900 个经许可的一词一义词汇表)刻意不纳入,词汇仍是判断问题;
- examples.md 中这些规则背后的测量数据存在于引入它们的 PR 里,可从
git log回溯SKILL.md找到——那是技能维护者的来源证明,不是写正文的指令。
结语:一套可执行的写作闭环
把技能浓缩为可执行闭环:先做 Pass 0 保全已有正文 → Pass 1 把效果提到第一行 → Pass 2 把每条事实路由到最快载体 → Pass 3 删到正文比初稿短且规模跟随 diff → Pass 4 用九条塑形规则把幸存内容切成可检查形状 → Pass 5 用扫描测试与 19 项行检查自检。examples.md 的三个已合并 PR 证明这套流程在真实 PostHog 仓库中落地有效:297 词到 197 词、190 词到 111 词、82 词到 71 词,全部比初稿短。技能对写作方式的全部承诺可以压缩成一句话:正文必须比初稿短、每颗子弹一个事实、句子不超过 25 词、主动语态、效果在前——先正确排序,再用形式加速,最后用删减与塑形收尾。
【免费下载链接】posthog:hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools – AI observability, analytics, session replay, flags, experiments, error tracking, logs, and more – capture all the context agents need to diagnose problems, uncover opportunities, and ship fixes. Steer it all from Slack, web, desktop, or the MCP.项目地址: https://gitcode.com/GitHub_Trending/po/posthog
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考