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

648 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PR Diff 功能实现方案
## 一、问题定义
### 现状
`pr +diff``pr +files` 实现完全相同——都调用 `/pulls/{index}/files.json`(简版文件列表 API返回文件名和增删行数统计**不包含任何差异内容**。
```go
// 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 端点:
### 端点 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包含 `id`、`add_line_num`、`del_line_num`、`commits_count`、`files_count`、各 commit SHA
- **关键作用**:获取最新 version 的 `id`,用于调用端点 3
返回示例:
```json
{
"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 信息,包含每个文件的 `sections``lines`,每行有 `type`1=新增/2=修改/3=删除/4=统计头)和 `content`
- **可选参数**`filepath` 查询参数可只查看单个文件的 diff
返回示例:
```json
{
"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` 命令实现:
```go
{
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
```go
// 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` 的文本格式:
```go
// 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 格式化能力:
```go
// 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 检测逻辑:
```go
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 示例:
```go
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](gitlink-cli/internal/client/client.go#L44-L55))会自动为路径添加 `.json`,因此代码中写路径时**不需要手动加 `.json`**
```go
// 正确 — 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 默认值:
```go
// 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 端点可达性
```bash
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 仓库上验证完整流程
---
## 八、预期效果
### 命令使用示例
```bash
# 查看完整 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 模式)
```json
{
"ok": true,
"data": {
"file_nums": 2,
"total_addition": 15,
"total_deletion": 3,
"files": [
{
"name": "main.go",
"addition": 15,
"deletion": 2,
"sections": [...]
}
]
}
}
```