gitlink-cli/doc/pr-diff-implementation.md

19 KiB
Raw Blame History

PR Diff 功能实现方案

一、问题定义

现状

pr +diffpr +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_reference.md 的全面分析GitLink 提供了 3 个 与 diff 相关的 API 端点:

端点 1PR 文件列表(简版)— 当前 +files 使用的

GET /api/v1/{owner}/{repo}/pulls/{index}/files.json
  • 返回内容文件名、增删行数统计、SHA无 diff 内容
  • 适用场景:文件概览列表,适合 +files 命令

端点 2PR 版本列表

GET /api/v1/{owner}/{repo}/pulls/{index}/versions.json
  • 返回内容PR 的每次推送产生一个 version包含 idadd_line_numdel_line_numcommits_countfiles_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,
      ...
    }
  ]
}

端点 3PR 版本 Diff核心端点— 需要使用

GET /api/v1/{owner}/{repo}/pulls/{index}/versions/{version_id}/diff.json
  • 返回内容:完整的 diff 信息,包含每个文件的 sectionslines,每行有 type1=新增/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.goprintTable 函数中增加 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 实现,新增 getLatestVersionIDformatDiffUnifiedformatDiffStat 函数
internal/output/formatter.go 修改 新增 isDiffDataprintDiffTable 函数,修改 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建议

  1. 在测试仓库上创建测试 PR含多文件变更
  2. 验证 gitlink-cli pr +diff --id <N> 输出与 Web UI 显示一致
  3. 验证 --file 过滤功能
  4. 验证 --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

    • 替换 +diffRun 函数为两步调用逻辑
    • 新增 getLatestVersionID 函数
    • 新增 formatDiffUnifiedformatDiffStat 函数
    • +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": [...]
      }
    ]
  }
}