From 64da98ec54ef57573267eb7c8da4e4b47c6c07ce Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Mon, 20 Jul 2026 17:33:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E7=B2=BE=E7=A1=AE=E5=85=B3?= =?UTF-8?q?=E8=81=94=20PR=20=E7=9A=84=20CI=20=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow-review-context-evidence.md | 4 +- shortcuts/workflow/api_types.go | 2 +- shortcuts/workflow/review_context.go | 100 ++++++++++++++++++ shortcuts/workflow/review_context_test.go | 41 ++++++- 4 files changed, 144 insertions(+), 3 deletions(-) diff --git a/doc/changes/workflow-review-context-evidence.md b/doc/changes/workflow-review-context-evidence.md index 50e8e97..dd076d9 100644 --- a/doc/changes/workflow-review-context-evidence.md +++ b/doc/changes/workflow-review-context-evidence.md @@ -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 ./... ``` diff --git a/shortcuts/workflow/api_types.go b/shortcuts/workflow/api_types.go index 8eb51fa..c31e058 100644 --- a/shortcuts/workflow/api_types.go +++ b/shortcuts/workflow/api_types.go @@ -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 diff --git a/shortcuts/workflow/review_context.go b/shortcuts/workflow/review_context.go index 3d70fcf..7712acc 100644 --- a/shortcuts/workflow/review_context.go +++ b/shortcuts/workflow/review_context.go @@ -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 { diff --git a/shortcuts/workflow/review_context_test.go b/shortcuts/workflow/review_context_test.go index 10fabac..8a60b70 100644 --- a/shortcuts/workflow/review_context_test.go +++ b/shortcuts/workflow/review_context_test.go @@ -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) {