Status updates that write themselves from your git activity

Status updates that write themselves from your git activity

自动根据 Git 活动生成的进度更新

I’ve typed the same status update hundreds of times. “Worked on the auth refactor, still going on it, no blockers.” Then I’d open the PR I’d literally just pushed and stare at the description I’d already written there, and the commits I’d already named, and think: I just typed all of this. Twice. Why. 我曾数百次输入同样的进度更新:“正在进行身份验证重构,进展中,无阻塞。”然后,我会打开刚刚推送的 PR,盯着我已经在里面写好的描述,以及我刚刚命名的提交记录,心想:我刚刚把这些都写了一遍。写了两次。为什么?

We still do async standups, and I think they’re useful. But a standup is the wrong place to first learn what your teammates did. By the time you’re reading it, the work is hours old and someone retyped it by hand. The “what I did” part already exists. You generated it when you opened the PR and named the commits. Asking a developer to also type it into a separate box is asking them to be a worse version of git log. 我们仍然在进行异步站会,我认为这很有用。但站会并不是了解队友工作进展的首选渠道。当你读到这些更新时,工作已经过去几个小时了,而且还是某人手动重新输入的。“我做了什么”这部分内容其实已经存在了——当你打开 PR 并命名提交时,你就已经生成了它。要求开发者再将其输入到另一个框中,无异于让他们充当一个低配版的 git log

So when we built sparQ, an open-source developer-experience suite for teams that live on GitHub, we tried to make one part of this disappear. Not the standup. The status-typing. Its first product, Pulse, is a GitHub-native project management tool, and in it each person’s recent activity fills itself in from the work they’re already doing, so the standups and updates sit on top of that instead of repeating it. 因此,当我们构建 sparQ(一套为 GitHub 团队打造的开源开发者体验套件)时,我们试图消除其中的一个环节。不是站会,而是“输入进度”这个动作。它的首款产品 Pulse 是一个原生于 GitHub 的项目管理工具,每个人的近期活动都会根据他们正在进行的工作自动填充,这样站会和进度更新就建立在这些活动之上,而不是重复劳动。

The mechanism is simple: GitHub webhooks in, status lines out. Everything that turned out to matter was in the details. The core idea GitHub already knows what you did, and it’ll tell you over a webhook the instant it happens. We subscribe to three event types (push, pull_request, and issues), and the job is to turn that firehose into a feed a human actually wants to read. 其机制很简单:接收 GitHub Webhook,输出状态行。事实证明,一切关键都在细节中。核心理念是:GitHub 已经知道你做了什么,并且会在事情发生的瞬间通过 Webhook 告诉你。我们订阅了三种事件类型(push、pull_request 和 issues),我们的工作就是将这些海量数据转化为人类真正愿意阅读的信息流。

Push and PR events become a person’s current status; issue events drive a separate two-way sync I’ll come back to at the end. That’s the whole architecture. The wiring took an afternoon. The judgment calls took the rest of the week, and they’re what the rest of this is about. Push 和 PR 事件会成为个人的当前状态;Issue 事件则驱动一个独立的双向同步,我会在文末再谈。这就是整个架构。搭建这些线路只花了一个下午,但后续的决策判断却花了一周时间,这也是本文接下来要讨论的内容。

Can you trust it? A webhook URL is a public endpoint. Anyone can POST JSON at it, so if you take the payload on faith, I can post activity as you. GitHub signs every delivery with an HMAC of the body, so we verify that signature before doing anything else. 你能信任它吗?Webhook URL 是一个公共端点。任何人都可以向它 POST JSON 数据,所以如果你盲目信任这些负载,我就能冒充你发布活动。GitHub 会使用主体的 HMAC 对每次投递进行签名,因此我们在做任何处理之前都会先验证该签名。

Two non-obvious things bit us here. First, you have to hash the raw request bytes. If you parse the JSON and re-serialize it, your bytes won’t match GitHub’s and every signature fails. Second, compare the signatures with a constant-time check, not ==: hmac.compare_digest(expected, signature_header). A plain string compare returns as soon as two characters differ, which leaks enough timing information to guess the signature byte by byte. It never shows up in testing and it’s the first thing a security review flags. 这里有两个不太明显的坑。首先,你必须对原始请求字节进行哈希处理。如果你解析了 JSON 并重新序列化,你的字节将与 GitHub 的不匹配,导致所有签名验证失败。其次,要使用恒定时间检查(constant-time check)来比较签名,而不是使用 ==hmac.compare_digest(expected, signature_header)。普通的字符串比较一旦发现两个字符不同就会立即返回,这会泄露足够的计时信息,从而让人能够逐字节猜出签名。这在测试中永远不会出现,但却是安全审查中最先被指出的问题。

We also let unsigned webhooks through in development and fail closed in production. Local end-to-end testing already means standing up an ngrok tunnel and a real GitHub App; making people also wire up a shared secret before anything works is how you lose them in the first ten minutes. 我们还在开发环境中允许未签名的 Webhook 通过,但在生产环境中则采取严格的拒绝策略。本地端到端测试已经需要搭建 ngrok 隧道和真实的 GitHub App;如果还要让用户在一切开始前配置共享密钥,那他们在前十分钟就会流失。

How fast can you let go of it? GitHub retries on any non-2xx and is impatient about slow responses, so you never want a database write or an outbound API call sitting between you and your 200. The endpoint does the bare minimum: verify the signature, look up which workspace this installation belongs to, hand the payload to a background worker, and return immediately. 你能多快释放请求?GitHub 会对任何非 2xx 的响应进行重试,并且对慢响应非常不耐烦,所以你绝不希望在返回 200 状态码之前执行数据库写入或外部 API 调用。该端点只做最基本的工作:验证签名,查找该安装所属的工作区,将负载交给后台工作进程,然后立即返回。

The catch with going async is that the background thread has no request context, so the tenant the event belongs to vanishes unless you carry it across yourself. We capture the workspace id while we still have the request and re-establish it inside the worker. Every multi-tenant background job has some version of this footgun: it runs fine in your single-tenant test and then writes to the wrong customer in production. 异步处理的问题在于后台线程没有请求上下文,因此除非你手动传递,否则事件所属的租户信息就会丢失。我们在拥有请求时捕获工作区 ID,并在工作进程中重新建立它。每个多租户后台任务都有这种“自残”风险:在单租户测试中运行良好,但在生产环境中却写入了错误的客户数据。

What do you say about it? This is the part I expected to be trivial and wasn’t. A raw push payload is not something anyone wants in a feed. The real work is deciding what to throw away. The output is a single plain line, like “Pushed 3 commits to main: handle expired refresh tokens” or “Opened PR #214: fix token refresh race.” Most of the work is throwing things away to get there. 该显示什么内容?这是我原以为很简单但实际不然的部分。原始的 Push 负载并不是人们想在信息流中看到的东西。真正的工作是决定丢弃什么。输出应该是一行简单的文字,例如“向 main 分支推送了 3 个提交:处理过期的刷新令牌”或“打开了 PR #214:修复令牌刷新竞争”。大部分工作其实都是为了精简内容。

The one that surprised me was merge commits. Merge a PR and GitHub fires the pull_request “merged” event and, separately, a push for the merge commit it generates on your behalf. Honor both and every merge lands in the feed twice. So we sniff out that auto-generated commit and skip the push: if head_msg.startswith("Merge pull request "): return None. 让我惊讶的是合并提交(merge commits)。合并一个 PR 时,GitHub 会触发 pull_request 的“merged”事件,同时还会为它代你生成的合并提交触发一个 push 事件。如果两者都处理,每次合并都会在信息流中出现两次。所以我们识别出那个自动生成的提交并跳过 push 事件:if head_msg.startswith("Merge pull request "): return None

PR events have the opposite problem, which is too many of them. synchronize fires on every push to the branch, and then there’s labeled, edited, assigned, and a dozen more that nobody would call a status. Only opened, reopened, ready_for_review, and the final merged-or-closed actually say something, so those are the only actions that produce a line. PR 事件则有相反的问题:数量太多。synchronize 事件会在每次推送到分支时触发,此外还有 labelededitedassigned 等十几个没人会称之为“进度”的事件。只有 openedreopenedready_for_review 以及最终的 merged-or-closed 才有实际意义,所以只有这些动作才会生成状态行。

Branch deletes and tag pushes carry no commits at all, so they say nothing either. The rule underneath all of it: when in doubt, post nothing. An empty feed is fine. A noisy one gets muted, and a muted feed is the same as no feed, except you paid to build it. This part is unglamorous and it’s most of what makes the feed feel trustworthy instead of spammy. 删除分支和推送标签根本不包含提交记录,所以它们也没有意义。这一切背后的规则是:拿不准的时候,什么都别发。空的信息流没关系,但嘈杂的信息流会被屏蔽,而被屏蔽的信息流就等于没有,而且你还为此付出了开发成本。这部分工作虽然枯燥,但它正是让信息流显得可信而不是垃圾信息的关键。

Who does it belong to? GitHub identifies people by a numeric user id; sparQ has its own users. So each person links their GitHub account once, and after that a webhook’s actor id resolves to a sparQ member. If there’s no mapping, we drop the event on the floor rather than posting it. 这属于谁?GitHub 通过数字用户 ID 标识人员;而 sparQ 有自己的用户系统。因此,每个人只需关联一次 GitHub 账号,之后 Webhook 的操作者 ID 就会映射到 sparQ 成员。如果没有映射,我们会直接丢弃该事件,而不是发布它。

That last decision is deliberate. A status feed is about people. “What is Sam up to.” So an authorless post isn’t a degraded post, it’s a category error. Dropping unmapped actors also quietly filters out the noise you’d never want anyway, like Dependabot pushing forty commits with nobody’s face on them. 最后一个决定是深思熟虑的。状态信息流是关于人的,“Sam 在忙什么”。所以,没有作者的帖子不是质量下降,而是分类错误。丢弃未映射的操作者也悄悄过滤掉了你根本不想要的噪音,比如 Dependabot 推送了四十个提交,但上面却没有人的头像。

The harder half: keeping two systems honest. Reading activity is one-way and forgiving. The issue sync is two-way, and two-way sync has a failure mode one-way doesn’t: the infinite loop. Close an issue on GitHub and we close the linked task in sparQ. Fine. But closing the task fires sparQ’s own “task changed” listener, which… 更难的一半:保持两个系统的同步。读取活动是单向且宽容的。Issue 同步是双向的,而双向同步有一种单向同步所没有的故障模式:无限循环。在 GitHub 上关闭一个 Issue,我们就在 sparQ 中关闭关联的任务。这没问题。但关闭任务会触发 sparQ 自己的“任务已更改”监听器,这会导致……