feat(workflow): 精确关联 PR 的 CI 证据

This commit is contained in:
Mengz 2026-07-20 17:33:53 +08:00
parent 3026c9d71e
commit 64da98ec54
4 changed files with 144 additions and 3 deletions

View File

@ -20,6 +20,7 @@ gitlink-cli workflow +review-context \
- `commits`PR 提交记录,只有启用 `include-commits` 时出现。 - `commits`PR 提交记录,只有启用 `include-commits` 时出现。
- `ci_builds`:仓库 CI 构建记录,只有启用 `include-ci` 时出现。 - `ci_builds`:仓库 CI 构建记录,只有启用 `include-ci` 时出现。
- `ci_summary`:优先按 PR head SHA 关联构建,找不到 SHA 匹配时回退到 head branch状态统计只包含匹配构建并显式给出 `unmatched`,避免把其他分支的失败构建误判为当前 PR 失败。
- `sections`:成功获取的证据分区,便于 Agent 判断证据是否完整。 - `sections`:成功获取的证据分区,便于 Agent 判断证据是否完整。
- `notes`:单个证据探针失败时的可解释记录,不会伪造成功数据。 - `notes`:单个证据探针失败时的可解释记录,不会伪造成功数据。
@ -29,10 +30,11 @@ gitlink-cli workflow +review-context \
- 所有新增请求均为 `GET`,不会评论、审批、合并、关闭或修改远程资源。 - 所有新增请求均为 `GET`,不会评论、审批、合并、关闭或修改远程资源。
- 每个列表都带有上限,避免大仓库响应无限膨胀。 - 每个列表都带有上限,避免大仓库响应无限膨胀。
- CI 获取失败时保留其他证据,并在 `notes` 中标记为不可用;只有所有启用分区都失败时才返回错误。 - CI 获取失败时保留其他证据,并在 `notes` 中标记为不可用;只有所有启用分区都失败时才返回错误。
- 标签列表兼容 `issue_tags``tags` 包装,避免接口返回标签数据时被错误解析为空。
## 验证 ## 验证
```bash ```bash
go test ./shortcuts/workflow -run 'TestFetchReviewContextEvidence|TestRenderReviewContextFormats' go test ./shortcuts/workflow -run 'TestFetchReviewContext|TestSummarizeReviewCI|TestRenderReviewContextFormats' -count=1
go build ./... go build ./...
``` ```

View File

@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} {
case []interface{}: case []interface{}:
return v return v
case map[string]interface{}: case map[string]interface{}:
for _, key := range []string{"issues", "pulls", "pull_requests", "reviews", "journals", "comments", "notes", "files", "commits", "releases", "builds", "items", "records", "data"} { for _, key := range []string{"issues", "pulls", "pull_requests", "reviews", "journals", "comments", "notes", "files", "commits", "releases", "builds", "issue_tags", "tags", "items", "records", "data"} {
if raw, ok := v[key]; ok { if raw, ok := v[key]; ok {
if items := apiList(raw); len(items) > 0 { if items := apiList(raw); len(items) > 0 {
return items return items

View File

@ -43,9 +43,23 @@ type ReviewContext struct {
Labels []map[string]interface{} `json:"labels,omitempty"` Labels []map[string]interface{} `json:"labels,omitempty"`
Commits []map[string]interface{} `json:"commits,omitempty"` Commits []map[string]interface{} `json:"commits,omitempty"`
Builds []map[string]interface{} `json:"ci_builds,omitempty"` Builds []map[string]interface{} `json:"ci_builds,omitempty"`
CISummary *ReviewCISummary `json:"ci_summary,omitempty"`
Notes []ScoringNote `json:"notes,omitempty"` Notes []ScoringNote `json:"notes,omitempty"`
} }
type ReviewCISummary struct {
Total int `json:"total"`
Matched int `json:"matched"`
Unmatched int `json:"unmatched"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Pending int `json:"pending"`
Unknown int `json:"unknown"`
MatchMode string `json:"match_mode"`
HeadBranch string `json:"head_branch,omitempty"`
HeadSHA string `json:"head_sha,omitempty"`
}
func newReviewContextShortcut() *common.Shortcut { func newReviewContextShortcut() *common.Shortcut {
return &common.Shortcut{ return &common.Shortcut{
Name: "review-context", Name: "review-context",
@ -223,6 +237,7 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
result.Notes = append(result.Notes, ScoringNote{Metric: "ci_builds", Note: fmt.Sprintf("ci +builds equivalent failed: %v", err)}) result.Notes = append(result.Notes, ScoringNote{Metric: "ci_builds", Note: fmt.Sprintf("ci +builds equivalent failed: %v", err)})
} else { } else {
result.Builds = builds result.Builds = builds
result.CISummary = summarizeReviewCI(result.PR, builds)
result.Sections = append(result.Sections, "ci_builds") result.Sections = append(result.Sections, "ci_builds")
successes++ successes++
} }
@ -255,6 +270,85 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
return result, nil return result, nil
} }
func summarizeReviewCI(pr map[string]interface{}, builds []map[string]interface{}) *ReviewCISummary {
summary := &ReviewCISummary{Total: len(builds), MatchMode: "unavailable"}
if pr == nil {
summary.MatchMode = "unavailable"
} else {
summary.HeadBranch = firstPRBranch(pr, "head_branch", "source_branch", "head")
summary.HeadSHA = firstPRString(pr, "head_commit_sha", "head_sha", "head_sha1", "last_commit_id")
if summary.HeadSHA != "" {
summary.MatchMode = "sha"
} else if summary.HeadBranch != "" {
summary.MatchMode = "branch"
}
}
shaMatchedBuilds := make([]map[string]interface{}, 0, len(builds))
branchMatchedBuilds := make([]map[string]interface{}, 0, len(builds))
for _, build := range builds {
buildSHA := firstPRString(build, "head_commit_sha", "commit_sha", "commit_id", "sha", "after", "revision")
buildBranch := normalizeBuildBranch(firstPRString(build, "branch", "head_branch", "source_branch", "ref"))
if summary.HeadSHA != "" && commitPrefixMatches(buildSHA, summary.HeadSHA) {
shaMatchedBuilds = append(shaMatchedBuilds, build)
}
if summary.HeadBranch != "" && buildBranch == normalizeBuildBranch(summary.HeadBranch) && buildBranch != "" {
branchMatchedBuilds = append(branchMatchedBuilds, build)
}
}
matchedBuilds := shaMatchedBuilds
if len(matchedBuilds) > 0 {
summary.MatchMode = "sha"
} else if len(branchMatchedBuilds) > 0 {
matchedBuilds = branchMatchedBuilds
summary.MatchMode = "branch"
} else if len(builds) > 0 {
summary.MatchMode = "none"
}
summary.Matched = len(matchedBuilds)
summary.Unmatched = summary.Total - summary.Matched
for _, build := range matchedBuilds {
classifyReviewBuild(summary, build)
}
return summary
}
func classifyReviewBuild(summary *ReviewCISummary, build map[string]interface{}) {
statusValues := make([]string, 0, 6)
for _, key := range []string{"status", "state", "build_status", "phase", "conclusion", "result"} {
if value := strings.ToLower(strings.TrimSpace(firstPRString(build, key))); value != "" {
statusValues = append(statusValues, value)
}
}
status := strings.Join(statusValues, " ")
switch {
case containsAny(status, []string{"failure", "failed", "error", "cancel", "red"}):
summary.Failed++
case containsAny(status, []string{"success", "succeeded", "passed", "pass", "green"}):
summary.Passed++
case containsAny(status, []string{"pending", "running", "queued", "waiting", "created", "started"}):
summary.Pending++
default:
summary.Unknown++
}
}
func normalizeBuildBranch(branch string) string {
return strings.TrimPrefix(strings.TrimSpace(branch), "refs/heads/")
}
func commitPrefixMatches(left, right string) bool {
left = strings.ToLower(strings.TrimSpace(left))
right = strings.ToLower(strings.TrimSpace(right))
if left == "" || right == "" {
return false
}
if left == right {
return true
}
return len(left) >= 7 && len(right) >= 7 && (strings.HasPrefix(left, right) || strings.HasPrefix(right, left))
}
func fetchReviewContextPR(ctx *common.RuntimeContext, owner, repo string, number int) (map[string]interface{}, error) { func fetchReviewContextPR(ctx *common.RuntimeContext, owner, repo string, number int) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%d", plainRepoPath(owner, repo), number), nil) env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%d", plainRepoPath(owner, repo), number), nil)
if err != nil { if err != nil {
@ -326,6 +420,12 @@ func writeReviewContextMarkdown(w *strings.Builder, context ReviewContext) error
_, _ = fmt.Fprintf(w, "- Reviews: `%d`\n", len(context.Reviews)) _, _ = fmt.Fprintf(w, "- Reviews: `%d`\n", len(context.Reviews))
_, _ = fmt.Fprintf(w, "- Commits: `%d`\n", len(context.Commits)) _, _ = fmt.Fprintf(w, "- Commits: `%d`\n", len(context.Commits))
_, _ = fmt.Fprintf(w, "- CI builds: `%d`\n", len(context.Builds)) _, _ = fmt.Fprintf(w, "- CI builds: `%d`\n", len(context.Builds))
if context.CISummary != nil {
_, _ = fmt.Fprintf(w, "- CI matched: `%d/%d` (`%s`), unmatched `%d`, passed `%d`, failed `%d`, pending `%d`, unknown `%d`\n",
context.CISummary.Matched, context.CISummary.Total, context.CISummary.MatchMode,
context.CISummary.Unmatched,
context.CISummary.Passed, context.CISummary.Failed, context.CISummary.Pending, context.CISummary.Unknown)
}
_, _ = fmt.Fprintf(w, "- Open issues included: `%d`\n", len(context.OpenIssues)) _, _ = fmt.Fprintf(w, "- Open issues included: `%d`\n", len(context.OpenIssues))
_, _ = fmt.Fprintf(w, "- Labels included: `%d`\n", len(context.Labels)) _, _ = fmt.Fprintf(w, "- Labels included: `%d`\n", len(context.Labels))
if len(context.Notes) > 0 { if len(context.Notes) > 0 {

View File

@ -136,12 +136,20 @@ func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
{"sha": "ignored", "message": "should be bounded"}, {"sha": "ignored", "message": "should be bounded"},
}, },
}) })
case "/owner/repo/pulls/12.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"pull_request": map[string]interface{}{
"number": 12,
"head_branch": "feature/evidence",
"head_commit_sha": "abcdef1234567",
},
})
case "/owner/repo/builds.json": case "/owner/repo/builds.json":
if got := r.URL.Query().Get("limit"); got != "1" { if got := r.URL.Query().Get("limit"); got != "1" {
t.Fatalf("CI limit = %q, want 1", got) t.Fatalf("CI limit = %q, want 1", got)
} }
writeWorkflowJSON(t, w, map[string]interface{}{ writeWorkflowJSON(t, w, map[string]interface{}{
"builds": []map[string]interface{}{{"id": 9, "status": "success"}, {"id": 10, "status": "failed"}}, "builds": []map[string]interface{}{{"id": 9, "sha": "abcdef1", "status": "completed", "conclusion": "success"}, {"id": 10, "sha": "other", "status": "failed"}},
}) })
default: default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
@ -155,6 +163,7 @@ func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
BuildLimit: 1, BuildLimit: 1,
IncludeCommits: true, IncludeCommits: true,
IncludeCI: true, IncludeCI: true,
IncludePR: true,
}) })
if err != nil { if err != nil {
t.Fatalf("FetchReviewContext returned error: %v", err) t.Fatalf("FetchReviewContext returned error: %v", err)
@ -165,6 +174,36 @@ func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
if !strings.Contains(strings.Join(got.Sections, ","), "commits") || !strings.Contains(strings.Join(got.Sections, ","), "ci_builds") { if !strings.Contains(strings.Join(got.Sections, ","), "commits") || !strings.Contains(strings.Join(got.Sections, ","), "ci_builds") {
t.Fatalf("sections = %v, want commits and ci_builds", got.Sections) t.Fatalf("sections = %v, want commits and ci_builds", got.Sections)
} }
if got.CISummary == nil || got.CISummary.MatchMode != "sha" || got.CISummary.Matched != 1 || got.CISummary.Passed != 1 || got.CISummary.Failed != 0 || got.CISummary.Unmatched != 0 {
t.Fatalf("ci summary = %+v, want one matched passing build", got.CISummary)
}
}
func TestSummarizeReviewCIUsesSHABeforeBranch(t *testing.T) {
summary := summarizeReviewCI(
map[string]interface{}{"head_branch": "feature/x", "head_commit_sha": "abcdef1234567"},
[]map[string]interface{}{
{"sha": "abcdef1", "status": "completed", "conclusion": "success"},
{"sha": "different", "branch": "feature/x", "status": "failed"},
{"sha": "other", "branch": "feature/y", "status": "failed"},
},
)
if summary.MatchMode != "sha" || summary.Matched != 1 || summary.Unmatched != 2 || summary.Passed != 1 || summary.Failed != 0 {
t.Fatalf("summary = %+v, want SHA-only matching with one pass", summary)
}
}
func TestSummarizeReviewCIFallsBackToBranch(t *testing.T) {
summary := summarizeReviewCI(
map[string]interface{}{"head_branch": "feature/x"},
[]map[string]interface{}{
{"ref": "refs/heads/feature/x", "status": "running"},
{"branch": "feature/y", "status": "success"},
},
)
if summary.MatchMode != "branch" || summary.Matched != 1 || summary.Unmatched != 1 || summary.Pending != 1 || summary.Passed != 0 {
t.Fatalf("summary = %+v, want branch fallback with one pending build", summary)
}
} }
func TestFetchReviewContextEvidenceFailureIsNonFatal(t *testing.T) { func TestFetchReviewContextEvidenceFailureIsNonFatal(t *testing.T) {