19 KiB
PR Diff 功能实现方案
一、问题定义
现状
pr +diff 和 pr +files 实现完全相同——都调用 /pulls/{index}/files.json(简版文件列表 API),返回文件名和增删行数统计,不包含任何差异内容。
// shortcuts/pr/pr.go — 当前 +diff 实现(与 +files 一模一样)
{
Name: "diff",
Description: "Show diff for a pull request",
Run: func(ctx *common.RuntimeContext) error {
// ...
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
// ...
},
}
用户期望
pr +diff 应输出类似 git diff 的内容:每个文件的增删行和具体变更,而不仅仅是文件列表。
二、GitLink API 调研
经过对 gitlink_api_reference.md 的全面分析,GitLink 提供了 3 个 与 diff 相关的 API 端点:
端点 1:PR 文件列表(简版)— 当前 +files 使用的
GET /api/v1/{owner}/{repo}/pulls/{index}/files.json
- 返回内容:文件名、增删行数统计、SHA(无 diff 内容)
- 适用场景:文件概览列表,适合
+files命令
端点 2:PR 版本列表
GET /api/v1/{owner}/{repo}/pulls/{index}/versions.json
- 返回内容:PR 的每次推送产生一个 version,包含
id、add_line_num、del_line_num、commits_count、files_count、各 commit SHA - 关键作用:获取最新 version 的
id,用于调用端点 3
返回示例:
{
"total_count": 2,
"versions": [
{
"id": 38,
"add_line_num": 1,
"del_line_num": 0,
"files_count": 1,
"base_commit_sha": "96dc82d...",
"head_commit_sha": "37d52b...",
"start_commit_sha": "96dc82d..."
},
{
"id": 39,
"add_line_num": 5,
"del_line_num": 3,
"files_count": 2,
...
}
]
}
端点 3:PR 版本 Diff(核心端点)— 需要使用
GET /api/v1/{owner}/{repo}/pulls/{index}/versions/{version_id}/diff.json
- 返回内容:完整的 diff 信息,包含每个文件的
sections→lines,每行有type(1=新增/2=修改/3=删除/4=统计头)和content - 可选参数:
filepath查询参数可只查看单个文件的 diff
返回示例:
{
"file_nums": 1,
"total_addition": 1,
"total_deletion": 0,
"files": [
{
"name": "main.go",
"oldname": "main.go",
"addition": 1,
"deletion": 0,
"type": 1,
"is_created": true,
"sections": [
{
"file_name": "main.go",
"lines": [
{
"type": 4,
"content": "@@ -0,0 +1 @@"
},
{
"type": 2,
"content": "+fmt.Println(\"hello\")"
}
]
}
]
}
]
}
端点 4(备选):Compare Diff
GET /api/v1/{owner}/{repo}/compare.json
GET /api/{owner}/{repo}/compare/{head}...{base}.json
- 返回内容:两个分支间的完整 diff(结构与端点 3 类似)
- 适用场景:本地分支比较,不依赖 PR 编号
三、实现方案
方案选择:基于 PR Versions API 的两步调用
调用流程:
用户执行: gitlink-cli pr +diff --id 42
│
▼
Step 1: GET /{owner}/{repo}/pulls/42/versions.json
│ 获取版本列表,取最新版本的 id
│
▼
Step 2: GET /{owner}/{repo}/pulls/42/versions/{version_id}/diff.json
│ 获取完整 diff 数据
│
▼
格式化输出(unified diff 风格 / JSON / 统计摘要)
3.1 核心实现:修改 shortcuts/pr/pr.go
3.1.1 新增 +diff Shortcut
替换现有的 +diff 命令实现:
{
Name: "diff",
Description: "Show diff for a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "file", Short: "f", Usage: "Filter diff to a specific file path"},
{Name: "stat", Usage: "Show only diff stat summary (no line-level detail)", Bool: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, _ := ctx.RequireArg("id")
// Step 1: 获取最新 version ID
versionID, err := getLatestVersionID(ctx, id)
if err != nil {
return fmt.Errorf("获取 PR 版本失败: %w", err)
}
// Step 2: 获取 diff 数据
diffPath := fmt.Sprintf("/v1%s/pulls/%s/versions/%s/diff",
ctx.RepoPath(), id, versionID)
q := url.Values{}
if f := ctx.Arg("file"); f != "" {
q.Set("filepath", f)
}
env, err := ctx.CallAPIWithQuery("GET", diffPath, q)
if err != nil {
return err
}
// Step 3: 格式化输出
if ctx.Arg("stat") == "true" {
return ctx.Output(formatDiffStat(env))
}
return ctx.Output(formatDiffUnified(env))
},
},
3.1.2 辅助函数:获取最新 Version ID
// getLatestVersionID 调用 versions API 并返回最新版本的 ID。
// GitLink 返回的 versions 数组按时间倒序排列,第一个即最新。
func getLatestVersionID(ctx *common.RuntimeContext, prID string) (string, error) {
env, err := ctx.CallAPI("GET",
fmt.Sprintf("/v1%s/pulls/%s/versions", ctx.RepoPath(), prID), nil)
if err != nil {
return "", err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected versions response format")
}
versions, ok := data["versions"].([]interface{})
if !ok || len(versions) == 0 {
return "", fmt.Errorf("no versions found for PR #%s", prID)
}
latest, ok := versions[0].(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected version format")
}
idFloat, ok := latest["id"].(float64)
if !ok {
return "", fmt.Errorf("version missing id field")
}
return fmt.Sprintf("%d", int64(idFloat)), nil
}
3.1.3 格式化:统一 Diff 风格输出
将 GitLink API 返回的 sections/lines 结构转为类似 git diff 的文本格式:
// formatDiffUnified 将 API diff 数据转为 unified diff 风格的 Envelope。
// 在 JSON 模式下直接输出原始结构;在 table 模式下输出 diff 文本。
func formatDiffUnified(env *output.Envelope) *output.Envelope {
// 直接返回原始数据,让 formatter 处理
// JSON/YAML 模式下输出完整结构化数据
// Table 模式下由自定义渲染器处理
return env
}
// formatDiffStat 提取增删统计摘要。
func formatDiffStat(env *output.Envelope) *output.Envelope {
data, ok := env.Data.(map[string]interface{})
if !ok {
return env
}
stat := map[string]interface{}{
"file_nums": data["file_nums"],
"total_addition": data["total_addition"],
"total_deletion": data["total_deletion"],
}
// 提取每个文件的简要统计
if files, ok := data["files"].([]interface{}); ok {
var fileStats []map[string]interface{}
for _, f := range files {
if fm, ok := f.(map[string]interface{}); ok {
fileStats = append(fileStats, map[string]interface{}{
"name": fm["name"],
"addition": fm["addition"],
"deletion": fm["deletion"],
"type": fm["type"],
})
}
}
stat["files"] = fileStats
}
return output.SuccessEnvelope(stat, nil)
}
3.2 增强输出格式化:支持 Diff 文本渲染
在 internal/output/formatter.go 中新增 diff 格式化能力:
// printDiffTable 渲染 diff 内容为可读的文本格式。
// 仅当 Data 中包含 diff 结构(有 files[].sections)时生效。
func printDiffTable(w io.Writer, envelope *Envelope) error {
data, ok := envelope.Data.(map[string]interface{})
if !ok {
return printJSON(w, envelope)
}
files, ok := data["files"].([]interface{})
if !ok {
return printJSON(w, envelope)
}
// 统计头部
fileNums, _ := data["file_nums"].(float64)
totalAdd, _ := data["total_addition"].(float64)
totalDel, _ := data["total_deletion"].(float64)
fmt.Fprintf(w, " %d files changed, %d insertions(+), %d deletions(-)\n\n",
int(fileNums), int(totalAdd), int(totalDel))
for _, f := range files {
fm, ok := f.(map[string]interface{})
if !ok {
continue
}
name, _ := fm["name"].(string)
addition, _ := fm["addition"].(float64)
deletion, _ := fm["deletion"].(float64)
// 文件头
fmt.Fprintf(w, "diff --git a/%s b/%s\n", name, name)
if isCreated, _ := fm["is_created"].(bool); isCreated {
fmt.Fprintf(w, "new file\n")
}
if isDeleted, _ := fm["is_deleted"].(bool); isDeleted {
fmt.Fprintf(w, "deleted file\n")
}
fmt.Fprintf(w, "--- a/%s\n", name)
fmt.Fprintf(w, "+++ b/%s\n", name)
fmt.Fprintf(w, "@@ +%d -%d @@\n", int(addition), int(deletion))
// 渲染每一行
sections, _ := fm["sections"].([]interface{})
for _, sec := range sections {
secMap, ok := sec.(map[string]interface{})
if !ok {
continue
}
lines, _ := secMap["lines"].([]interface{})
for _, l := range lines {
lineMap, ok := l.(map[string]interface{})
if !ok {
continue
}
content, _ := lineMap["content"].(string)
lineType, _ := lineMap["type"].(float64)
switch int(lineType) {
case 4: // diff hunk header
fmt.Fprintf(w, "%s\n", content)
case 2: // addition
fmt.Fprintf(w, "\033[32m%s\033[0m\n", content)
case 3: // deletion
fmt.Fprintf(w, "\033[31m%s\033[0m\n", content)
default: // context line
fmt.Fprintf(w, "%s\n", content)
}
}
}
fmt.Fprintln(w)
}
return nil
}
3.3 修改 printTable 路由以支持 diff 格式
在 internal/output/formatter.go 的 printTable 函数中增加 diff 检测逻辑:
func printTable(w io.Writer, envelope *Envelope) error {
// ... 现有错误和空数据处理 ...
// 检测是否为 diff 数据(包含 files[].sections)
if isDiffData(envelope.Data) {
return printDiffTable(w, envelope)
}
// ... 现有 slice/map 处理逻辑 ...
}
// isDiffData 检测 Envelope.Data 是否为 PR diff 结构。
func isDiffData(data interface{}) bool {
m, ok := data.(map[string]interface{})
if !ok {
return false
}
// diff 数据的特征:有 file_nums 和 files 字段
_, hasFileNums := m["file_nums"]
_, hasFiles := m["files"]
_, hasTotalAdd := m["total_addition"]
return hasFileNums && hasFiles && hasTotalAdd
}
3.4 完整的文件变更清单
| 文件 | 变更类型 | 说明 |
|---|---|---|
shortcuts/pr/pr.go |
修改 | 替换 +diff 实现,新增 getLatestVersionID、formatDiffUnified、formatDiffStat 函数 |
internal/output/formatter.go |
修改 | 新增 isDiffData、printDiffTable 函数,修改 printTable 路由 |
shortcuts/pr/pr_test.go |
修改 | 新增 +diff 功能测试用例 |
skills/gitlink-pr/SKILL.md |
修改 | 更新 +diff 命令文档,说明新增的 --file 和 --stat 参数 |
skills/gitlink-pr/references/gitlink-pr-files.md |
修改 | 补充 diff 与 files 的区别说明 |
四、测试方案
4.1 单元测试
在 shortcuts/pr/pr_test.go 中新增以下测试用例:
| 测试用例 | 验证内容 |
|---|---|
TestPRDiffFetchesVersionThenDiff |
完整两步调用:先请求 versions,再请求 diff,验证最终输出正确 |
TestPRDiffWithFileFilter |
验证 --file 参数正确传递 filepath 查询参数 |
TestPRDiffStatMode |
验证 --stat 模式只输出统计摘要,不输出逐行内容 |
TestPRDiffFailsWhenNoVersions |
PR 无版本时返回友好错误信息 |
TestPRDiffFailsWhenPRNotFound |
PR 不存在时(404)正确处理错误 |
TestGetLatestVersionID |
直接测试 getLatestVersionID 函数,验证取第一个版本 |
TestFormatDiffStat |
测试统计摘要格式化逻辑 |
测试 Mock Server 示例:
func TestPRDiffFetchesVersionThenDiff(t *testing.T) {
var requestPaths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPaths = append(requestPaths, r.URL.Path)
switch {
// Step 1: versions 请求
case strings.Contains(r.URL.Path, "/versions.json") &&
!strings.Contains(r.URL.Path, "/diff"):
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"versions": []interface{}{
map[string]interface{}{
"id": float64(38),
"add_line_num": 3,
"del_line_num": 1,
"files_count": 2,
},
},
})
// Step 2: diff 请求
case strings.Contains(r.URL.Path, "/versions/38/diff"):
writeJSON(t, w, map[string]interface{}{
"file_nums": float64(2),
"total_addition": float64(3),
"total_deletion": float64(1),
"files": []interface{}{
map[string]interface{}{
"name": "main.go",
"addition": float64(2),
"deletion": float64(1),
"sections": []interface{}{
map[string]interface{}{
"lines": []interface{}{
map[string]interface{}{
"type": float64(4),
"content": "@@ -10,3 +10,4 @@",
},
map[string]interface{}{
"type": float64(3),
"content": "-old line",
},
map[string]interface{}{
"type": float64(2),
"content": "+new line",
},
},
},
},
},
},
})
}
}))
defer server.Close()
err := runPRShortcut(t, server, "diff", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("diff shortcut failed: %v", err)
}
// 验证两次请求都发生了
if len(requestPaths) < 2 {
t.Fatalf("expected 2 API calls, got %d: %v", len(requestPaths), requestPaths)
}
}
4.2 集成测试建议
由于需要真实 GitLink API,建议:
- 在测试仓库上创建测试 PR(含多文件变更)
- 验证
gitlink-cli pr +diff --id <N>输出与 Web UI 显示一致 - 验证
--file过滤功能 - 验证
--stat模式只输出统计
五、API 路径注意事项
当前项目 Client 的 .json 后缀自动追加逻辑(client.go:44-55)会自动为路径添加 .json,因此代码中写路径时不需要手动加 .json:
// 正确 — Client 会自动追加 .json
"/v1%s/pulls/%s/versions"
"/v1%s/pulls/%s/versions/%s/diff"
// 错误 — 会导致双重后缀
"/v1%s/pulls/%s/versions.json" // → versions.json.json
"/v1%s/pulls/%s/versions/%s/diff.json" // → diff.json.json
注意:现有 +files 使用的是无 /v1 前缀的路径 /{owner}/{repo}/pulls/{index}/files,而 versions 端点的文档路径为 /api/v1/{owner}/{repo}/pulls/{index}/versions.json。需要验证 Client 的 BaseURL 是否已包含 /api 前缀。查看 config 默认值:
// internal/config/config.go 中 BaseURL 默认值
// 需要确认是否为 "https://www.gitlink.org.cn/api"
// 如果是,则路径写为 "/v1/{owner}/{repo}/pulls/{index}/versions"
六、风险与降级策略
| 风险 | 影响 | 降级方案 |
|---|---|---|
| GitLink versions API 不稳定或返回空 | 无法获取 diff | 保留旧 +files 行为作为 fallback,输出提示 "diff unavailable, showing file list only" |
| 大型 PR 的 diff 数据量过大 | 响应慢、终端刷屏 | 默认只显示统计摘要(--stat),加 --full 参数才显示逐行内容 |
| API 路径前缀与实际不匹配 | 请求 404 | 开发时先通过 api 命令验证路径:gitlink-cli api GET /v1/owner/repo/pulls/1/versions |
| table 模式下 diff 文本格式化复杂 | 渲染异常 | table 模式 fallback 到 JSON 输出 |
七、实现步骤(Checklist)
-
Step 1:验证 API 端点可达性
gitlink-cli api GET /v1/{owner}/{repo}/pulls/1/versions gitlink-cli api GET /v1/{owner}/{repo}/pulls/1/versions/{vid}/diff确认 BaseURL + 路径组合正确
-
Step 2:修改
shortcuts/pr/pr.go- 替换
+diff的Run函数为两步调用逻辑 - 新增
getLatestVersionID函数 - 新增
formatDiffUnified、formatDiffStat函数 - 为
+diff添加--file和--stat参数
- 替换
-
Step 3:修改
internal/output/formatter.go- 新增
isDiffData检测函数 - 新增
printDiffTable渲染函数 - 修改
printTable路由增加 diff 分支
- 新增
-
Step 4:编写测试
shortcuts/pr/pr_test.go新增 5-7 个测试用例- 使用 httptest.Server mock versions 和 diff 端点
-
Step 5:更新 Skill 文档
- 更新
skills/gitlink-pr/SKILL.md - 更新
skills/gitlink-pr/references/gitlink-pr-files.md
- 更新
-
Step 6:手动集成测试
- 在真实 GitLink 仓库上验证完整流程
八、预期效果
命令使用示例
# 查看完整 diff(默认)
gitlink-cli pr +diff --id 42
# 只查看某个文件的 diff
gitlink-cli pr +diff --id 42 --file "main.go"
# 只看统计摘要(适合大型 PR)
gitlink-cli pr +diff --id 42 --stat
# JSON 格式输出(适合脚本处理)
gitlink-cli pr +diff --id 42 --format json
输出示例(table 模式)
2 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/main.go b/main.go
new file
--- a/main.go
+++ b/main.go
@@ +5 -2 @@
@@ -10,3 +10,4 @@
-old implementation
+new implementation
+another new line
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ +3 -1 @@
@@ -5,7 +5,6 @@
-removed section
输出示例(--stat 模式)
FILE ADDITION DELETION TYPE
--- -------- -------- ----
main.go 15 2 added
README.md 0 1 modified
输出示例(JSON 模式)
{
"ok": true,
"data": {
"file_nums": 2,
"total_addition": 15,
"total_deletion": 3,
"files": [
{
"name": "main.go",
"addition": 15,
"deletion": 2,
"sections": [...]
}
]
}
}