fix(workflow): 修正 PR 提交证据采集

This commit is contained in:
Mengz 2026-07-22 17:33:02 +08:00
parent 64da98ec54
commit 8094eeb38e
3 changed files with 121 additions and 11 deletions

View File

@ -18,7 +18,7 @@ gitlink-cli workflow +review-context \
新增结果字段:
- `commits`PR 提交记录,只有启用 `include-commits` 时出现。
- `commits`PR 提交记录,只有启用 `include-commits` 时出现。命令先从 PR 详情解析内部 `pull_request_id`,再调用平台实际 commits 端点,不能把网页 HTML 响应当成空列表。
- `ci_builds`:仓库 CI 构建记录,只有启用 `include-ci` 时出现。
- `ci_summary`:优先按 PR head SHA 关联构建,找不到 SHA 匹配时回退到 head branch状态统计只包含匹配构建并显式给出 `unmatched`,避免把其他分支的失败构建误判为当前 PR 失败。
- `sections`:成功获取的证据分区,便于 Agent 判断证据是否完整。
@ -30,6 +30,8 @@ gitlink-cli workflow +review-context \
- 所有新增请求均为 `GET`,不会评论、审批、合并、关闭或修改远程资源。
- 每个列表都带有上限,避免大仓库响应无限膨胀。
- CI 获取失败时保留其他证据,并在 `notes` 中标记为不可用;只有所有启用分区都失败时才返回错误。
- PR 详情缺少 head SHA 时,从带时间戳的最新 commit 推导当前 SHA再进行 CI 精确匹配;无法取得 commit 证据时才降级到分支匹配。
- 列表探针必须返回结构化数组或已知列表包装HTML、普通文本和未知对象会进入 `notes`,不会加入 `sections` 冒充成功。
- 标签列表兼容 `issue_tags``tags` 包装,避免接口返回标签数据时被错误解析为空。
## 验证
@ -38,3 +40,5 @@ gitlink-cli workflow +review-context \
go test ./shortcuts/workflow -run 'TestFetchReviewContext|TestSummarizeReviewCI|TestRenderReviewContextFormats' -count=1
go build ./...
```
真实平台验证PR #429 返回 2 条 commits最新 SHA 与 PR head 一致;仓库 CI 接口不可用时 `ci_builds` 不进入 `sections`,并保留可解释的 `ci_builds` note。

View File

@ -5,6 +5,7 @@ import (
"net/url"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -186,6 +187,7 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
Notes: []ScoringNote{},
}
successes := 0
var prEvidence map[string]interface{}
if opts.IncludeRepo {
if info, err := fetchRepoInfo(ctx, owner, repo); err != nil {
@ -196,13 +198,18 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
successes++
}
}
if opts.IncludePR {
if opts.IncludePR || opts.IncludeCommits || opts.IncludeCI {
if pr, err := fetchReviewContextPR(ctx, owner, repo, opts.Number); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_view", Note: fmt.Sprintf("pr +view equivalent failed: %v", err)})
if opts.IncludePR {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_view", Note: fmt.Sprintf("pr +view equivalent failed: %v", err)})
}
} else {
result.PR = pr
result.Sections = append(result.Sections, "pr")
successes++
prEvidence = pr
if opts.IncludePR {
result.PR = pr
result.Sections = append(result.Sections, "pr")
successes++
}
}
}
if opts.IncludeFiles {
@ -224,7 +231,10 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
}
}
if opts.IncludeCommits {
if commits, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/commits", workflowRepoPath(owner, repo), opts.Number), nil, opts.CommitLimit); err != nil {
commitPath, err := reviewContextCommitPath(owner, repo, prEvidence)
if err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_commits", Note: err.Error()})
} else if commits, err := fetchReviewContextList(ctx, commitPath, nil, opts.CommitLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_commits", Note: fmt.Sprintf("pr +commits equivalent failed: %v", err)})
} else {
result.Commits = commits
@ -237,7 +247,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.CISummary = summarizeReviewCI(prEvidence, result.Commits, builds)
result.Sections = append(result.Sections, "ci_builds")
successes++
}
@ -270,13 +280,13 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
return result, nil
}
func summarizeReviewCI(pr map[string]interface{}, builds []map[string]interface{}) *ReviewCISummary {
func summarizeReviewCI(pr map[string]interface{}, commits, 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")
summary.HeadSHA = reviewContextHeadSHA(pr, commits)
if summary.HeadSHA != "" {
summary.MatchMode = "sha"
} else if summary.HeadBranch != "" {
@ -313,6 +323,29 @@ func summarizeReviewCI(pr map[string]interface{}, builds []map[string]interface{
return summary
}
func reviewContextHeadSHA(pr map[string]interface{}, commits []map[string]interface{}) string {
if sha := firstPRString(pr, "head_commit_sha", "head_sha", "head_sha1", "last_commit_id"); sha != "" {
return sha
}
var headSHA string
var headTime time.Time
for _, commit := range commits {
sha := firstPRString(commit, "sha", "commit_sha", "id")
if sha == "" {
continue
}
commitTime := apiLatestTime(
firstPRTime(commit, "timestamp"),
firstPRTime(commit, "created_at", "committed_at", "date"),
)
if headSHA == "" || (!commitTime.IsZero() && (headTime.IsZero() || commitTime.After(headTime))) {
headSHA = sha
headTime = commitTime
}
}
return headSHA
}
func classifyReviewBuild(summary *ReviewCISummary, build map[string]interface{}) {
statusValues := make([]string, 0, 6)
for _, key := range []string{"status", "state", "build_status", "phase", "conclusion", "result"} {
@ -361,6 +394,17 @@ func fetchReviewContextPR(ctx *common.RuntimeContext, owner, repo string, number
return item, nil
}
func reviewContextCommitPath(owner, repo string, pr map[string]interface{}) (string, error) {
if pr == nil {
return "", fmt.Errorf("pr +commits requires PR details to resolve the internal pull_request_id")
}
id := firstPRInt(pr, "id", "pull_request_id")
if id <= 0 {
return "", fmt.Errorf("pr +commits response is unavailable: PR details did not contain pull_request_id")
}
return fmt.Sprintf("%s/pulls/%d/commits", plainRepoPath(owner, repo), id), nil
}
func fetchReviewContextList(ctx *common.RuntimeContext, path string, query url.Values, limit int) ([]map[string]interface{}, error) {
if limit <= 0 {
limit = 100
@ -373,6 +417,9 @@ func fetchReviewContextList(ctx *common.RuntimeContext, path string, query url.V
if err != nil {
return nil, err
}
if !isStructuredReviewContextList(env.Data) {
return nil, fmt.Errorf("response did not contain a structured list")
}
items := apiList(env.Data)
out := make([]map[string]interface{}, 0, len(items))
for _, raw := range items {
@ -388,6 +435,29 @@ func fetchReviewContextList(ctx *common.RuntimeContext, path string, query url.V
return out, nil
}
func isStructuredReviewContextList(data interface{}) bool {
normalized, err := normalizeAPIData(data)
if err != nil {
return false
}
switch value := normalized.(type) {
case []interface{}:
return true
case map[string]interface{}:
for _, key := range []string{"issues", "pulls", "pull_requests", "reviews", "journals", "comments", "notes", "files", "commits", "releases", "builds", "issue_tags", "tags", "items", "records"} {
if _, ok := value[key]; ok {
return true
}
}
if nested, ok := value["data"]; ok {
return isStructuredReviewContextList(nested)
}
return looksLikeIssueOrRepoItem(value)
default:
return false
}
}
func RenderReviewContext(context ReviewContext, format string) (string, error) {
var rendered strings.Builder
switch normalizeFormat(format) {

View File

@ -125,7 +125,7 @@ func TestFetchReviewContextPartialFailureKeepsNotes(t *testing.T) {
func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/owner/repo/pulls/12/commits.json":
case "/owner/repo/pulls/9012/commits.json":
if got := r.URL.Query().Get("limit"); got != "2" {
t.Fatalf("commit limit = %q, want 2", got)
}
@ -139,6 +139,7 @@ func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
case "/owner/repo/pulls/12.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"pull_request": map[string]interface{}{
"id": 9012,
"number": 12,
"head_branch": "feature/evidence",
"head_commit_sha": "abcdef1234567",
@ -182,6 +183,7 @@ func TestFetchReviewContextEvidenceSectionsAreBounded(t *testing.T) {
func TestSummarizeReviewCIUsesSHABeforeBranch(t *testing.T) {
summary := summarizeReviewCI(
map[string]interface{}{"head_branch": "feature/x", "head_commit_sha": "abcdef1234567"},
nil,
[]map[string]interface{}{
{"sha": "abcdef1", "status": "completed", "conclusion": "success"},
{"sha": "different", "branch": "feature/x", "status": "failed"},
@ -196,6 +198,7 @@ func TestSummarizeReviewCIUsesSHABeforeBranch(t *testing.T) {
func TestSummarizeReviewCIFallsBackToBranch(t *testing.T) {
summary := summarizeReviewCI(
map[string]interface{}{"head_branch": "feature/x"},
nil,
[]map[string]interface{}{
{"ref": "refs/heads/feature/x", "status": "running"},
{"branch": "feature/y", "status": "success"},
@ -206,12 +209,45 @@ func TestSummarizeReviewCIFallsBackToBranch(t *testing.T) {
}
}
func TestSummarizeReviewCIDerivesHeadSHAFromLatestCommit(t *testing.T) {
summary := summarizeReviewCI(
map[string]interface{}{"head_branch": "feature/x"},
[]map[string]interface{}{
{"sha": "older1234567", "timestamp": 100},
{"sha": "newer1234567", "timestamp": 200},
},
[]map[string]interface{}{
{"sha": "newer12", "status": "success"},
{"branch": "feature/x", "status": "failed"},
},
)
if summary.HeadSHA != "newer1234567" || summary.MatchMode != "sha" || summary.Matched != 1 || summary.Passed != 1 || summary.Failed != 0 {
t.Fatalf("summary = %+v, want latest commit SHA with one matching pass", summary)
}
}
func TestFetchReviewContextListRejectsUnstructuredBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeWorkflowJSON(t, w, "<!doctype html><html></html>")
}))
defer server.Close()
_, err := fetchReviewContextList(workflowTestContext(server), "/owner/repo/pulls/1/commits", nil, 10)
if err == nil || !strings.Contains(err.Error(), "structured list") {
t.Fatalf("error = %v, want unstructured response rejection", err)
}
}
func TestFetchReviewContextEvidenceFailureIsNonFatal(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/owner/repo.json" {
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo"})
return
}
if r.URL.Path == "/owner/repo/pulls/13.json" {
writeWorkflowJSON(t, w, map[string]interface{}{"pull_request": map[string]interface{}{"id": 9013, "head": "feature/evidence"}})
return
}
if r.URL.Path == "/owner/repo/builds.json" {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("CI unavailable"))