GitHub 关闭 PR 后,如何找回 Pull Request 会话历史
最近回头找自己提交过的一个 Obsidian 插件 PR:obsidianmd/obsidian-releases#5839。这个仓库后来关闭了 Pull requests 模块,网页中的 PR 链接、REST API,甚至按编号的 GraphQL 查询都失效了。
我原本打算从 GH Archive 的事件归档里慢慢拼回对话。最后发现,更有效的路径是:先从第三方索引找回 PR 的 GitHub 全局对象 ID(node_id),再通过 GraphQL 的 node(id:) 查询读取原始 PR 对象。
这篇文章记录这个排查过程,也说明 GH Archive 在其中真正适合做什么。
现象:PR 编号还在,PR 却“消失”了
代码提交还保留在仓库历史中:
0ee35c9f chore: Add plugin Nutstore Sync (#5839)
但常规 GitHub 接口已经不可用:
curl https://api.github.com/repos/obsidianmd/obsidian-releases/pulls/5839
# {"message":"Not Found", ...}
curl https://api.github.com/repos/obsidianmd/obsidian-releases/issues/5839/comments
# {"message":"Pull requests are disabled for this repo", ...}
gh api graphql 中的 repository.pullRequest(number: 5839) 也是同样的结果:Pull requests are disabled for this repository.
这时不要因为 URL 失效就认定数据被删除了。关闭模块更像是让“按仓库 + 编号”的入口不可访问;GitHub 内部的对象是否还存在,是另一回事。
第一步:从 Git 历史确定锚点
我最开始并不知道 PR 编号,只知道当时使用的 GitHub 用户名是 nutstore-dev,于是可以先从 Git 历史反查:
git log --all --author='你的名字或邮箱' --oneline
然后再对目标提交查看完整元数据:
git show --format=fuller --stat 0ee35c9f
这一步从 commit message 中找到了 (#5839),并得到如下最小事实集:
- PR:
#5839 - 标题:
Add plugin: Nutstore Sync - 合入提交:
0ee35c9f3c86a4d027daaa1f611f0227d345d029 - 合入时间:2025-11-28
这些信息一方面可用于在归档中缩小时间范围,另一方面可避免把相邻编号或同名提交误当成目标。
第二步:从 Ecosyste.ms 找回 node_id
Ecosyste.ms 是一个独立于 GitHub 的开源数据服务集合。它索引公共开源项目的仓库、Issue、PR 和活动元数据;其中的 issues.ecosyste.ms 并不等同于 GitHub 的实时 API,数据可能有延迟,但在本例中恰好保留了目标 PR 的元数据。
curl -sS \
'https://issues.ecosyste.ms/api/v1/hosts/GitHub/repositories/obsidianmd%2Fobsidian-releases/issues/5839' \
| jq '{number, title, user, created_at, node_id, comments_count}'
输出中最关键的一项是:
{
"number": 5839,
"title": "Add plugin: Nutstore Sync",
"node_id": "PR_kwDOD6MHws6QfHNP"
}
node_id 是 GitHub GraphQL 使用的全局对象 ID。PR_ 前缀表明对象类型是 Pull Request;其余字符是 GitHub 的不透明内部标识,不应尝试自行编码或解码。
这里的 Ecosyste.ms 只是提供了线索,不能把它当作完整备份:它当时只显示 6 条评论,而且状态仍是旧的 open。这说明第三方索引适合恢复定位信息,不能替代原始数据源。
第三步:改用 GraphQL 的全局对象查询
拿到 node_id 后,不再按仓库和编号查找,而是直接查询全局对象:
gh api graphql \
-f query='query($id: ID!) {
node(id: $id) {
__typename
... on PullRequest {
number
title
state
merged
createdAt
updatedAt
mergedAt
body
author { login }
comments(first: 100) {
totalCount
nodes {
author { login }
body
createdAt
updatedAt
url
}
}
reviews(first: 100) {
totalCount
nodes {
author { login }
body
state
submittedAt
url
}
}
reviewThreads(first: 100) {
totalCount
nodes {
isResolved
comments(first: 100) {
nodes {
author { login }
body
path
line
createdAt
url
}
}
}
}
}
}
}' \
-F id='PR_kwDOD6MHws6QfHNP'
这次成功返回了对象。它确认 #5839 已合并,并找回了 PR 描述和 24 条普通会话评论;该 PR 没有正式 review 或行级 review thread。
这条路的关键不是“绕过权限”。查询使用的是 GitHub 正常的 GraphQL 全局对象接口,只是仓库设置让按编号的入口失效了,而目标对象在查询时仍可通过其 ID 解析。GitHub 随时可能改变这一行为,因此应在成功后立即导出结果并保存。
GH Archive 应该放在什么位置?
GH Archive 按小时保存公开 GitHub 事件。它非常适合做事件取证和交叉验证,相关类型包括:
PullRequestEvent:创建、关闭、合入、打标签等 PR 生命周期事件IssueCommentEvent:PR Conversation 中的普通评论PullRequestReviewEvent:Review 的正文与状态PullRequestReviewCommentEvent:代码行级评论
例如已经知道 PR 开始活动的日期时,可以先只查询单日,避免 BigQuery 扫描太多数据:
SELECT
type,
created_at,
actor.login AS actor,
JSON_VALUE(payload, '$.action') AS action,
COALESCE(
JSON_VALUE(payload, '$.number'),
JSON_VALUE(payload, '$.issue.number'),
JSON_VALUE(payload, '$.pull_request.number')
) AS pr_number,
JSON_VALUE(payload, '$.comment.body') AS comment_body,
JSON_VALUE(payload, '$.review.body') AS review_body
FROM `githubarchive.day.20250328`
WHERE repo.name = 'obsidianmd/obsidian-releases'
AND type IN (
'PullRequestEvent',
'IssueCommentEvent',
'PullRequestReviewEvent',
'PullRequestReviewCommentEvent'
)
AND COALESCE(
JSON_VALUE(payload, '$.number'),
JSON_VALUE(payload, '$.issue.number'),
JSON_VALUE(payload, '$.pull_request.number')
) = '5839'
ORDER BY created_at;
GH Archive 的优势是事件在当时已经被归档,不依赖 GitHub 现在是否还展示对象;不足是它是事件流,不是 PR 的完整快照。要重建一段很长的会话,需要知道所有活跃日期、逐日查询,并处理创建、编辑和删除事件。它适合作为“最后的取证来源”,不应是已经能访问 GitHub 原始对象时的首选。