forked from Gitlink/gitlink-cli
feat(workflow): 精确关联 PR 的 CI 证据
This commit is contained in:
parent
3026c9d71e
commit
64da98ec54
|
|
@ -20,6 +20,7 @@ gitlink-cli workflow +review-context \
|
|||
|
||||
- `commits`:PR 提交记录,只有启用 `include-commits` 时出现。
|
||||
- `ci_builds`:仓库 CI 构建记录,只有启用 `include-ci` 时出现。
|
||||
- `ci_summary`:优先按 PR head SHA 关联构建,找不到 SHA 匹配时回退到 head branch;状态统计只包含匹配构建,并显式给出 `unmatched`,避免把其他分支的失败构建误判为当前 PR 失败。
|
||||
- `sections`:成功获取的证据分区,便于 Agent 判断证据是否完整。
|
||||
- `notes`:单个证据探针失败时的可解释记录,不会伪造成功数据。
|
||||
|
||||
|
|
@ -29,10 +30,11 @@ gitlink-cli workflow +review-context \
|
|||
- 所有新增请求均为 `GET`,不会评论、审批、合并、关闭或修改远程资源。
|
||||
- 每个列表都带有上限,避免大仓库响应无限膨胀。
|
||||
- CI 获取失败时保留其他证据,并在 `notes` 中标记为不可用;只有所有启用分区都失败时才返回错误。
|
||||
- 标签列表兼容 `issue_tags` 和 `tags` 包装,避免接口返回标签数据时被错误解析为空。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./shortcuts/workflow -run 'TestFetchReviewContextEvidence|TestRenderReviewContextFormats'
|
||||
go test ./shortcuts/workflow -run 'TestFetchReviewContext|TestSummarizeReviewCI|TestRenderReviewContextFormats' -count=1
|
||||
go build ./...
|
||||
```
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} {
|
|||
case []interface{}:
|
||||
return v
|
||||
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 items := apiList(raw); len(items) > 0 {
|
||||
return items
|
||||
|
|
|
|||
|
|
@ -43,9 +43,23 @@ type ReviewContext struct {
|
|||
Labels []map[string]interface{} `json:"labels,omitempty"`
|
||||
Commits []map[string]interface{} `json:"commits,omitempty"`
|
||||
Builds []map[string]interface{} `json:"ci_builds,omitempty"`
|
||||
CISummary *ReviewCISummary `json:"ci_summary,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 {
|
||||
return &common.Shortcut{
|
||||
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)})
|
||||
} else {
|
||||
result.Builds = builds
|
||||
result.CISummary = summarizeReviewCI(result.PR, builds)
|
||||
result.Sections = append(result.Sections, "ci_builds")
|
||||
successes++
|
||||
}
|
||||
|
|
@ -255,6 +270,85 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
|
|||
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) {
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%d", plainRepoPath(owner, repo), number), 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, "- Commits: `%d`\n", len(context.Commits))
|
||||
_, _ = 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, "- Labels included: `%d`\n", len(context.Labels))
|
||||
if len(context.Notes) > 0 {
|
||||
|
|
|
|||
|
|
@ -136,12 +136,20 @@ func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
|
|||
{"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":
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("CI limit = %q, want 1", got)
|
||||
}
|
||||
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:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
|
|
@ -155,6 +163,7 @@ func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
|
|||
BuildLimit: 1,
|
||||
IncludeCommits: true,
|
||||
IncludeCI: true,
|
||||
IncludePR: true,
|
||||
})
|
||||
if err != nil {
|
||||
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") {
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue