精确关联 PR 的 CI 证据并完善上下文解析 #429

Open
Mengz wants to merge 3 commits from Mengz/gitlink-cli:mengz/review-context-evidence-v2 into master
5 changed files with 454 additions and 10 deletions

View File

@ -507,6 +507,7 @@ gitlink-cli search +users -k "zhangsan"
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
- `workflow +review-context`
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
@ -582,6 +583,11 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma
# Repository workflow report from a local JSON file
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
# Build a bounded PR evidence bundle for review and integration Skills
gitlink-cli workflow +review-context \
--owner Gitlink --repo gitlink-cli --number 1 \
--include-commits=true --include-ci=true --format json
```
Output formats:
@ -597,6 +603,7 @@ Safety:
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
- `workflow +review-context` is a read-only, bounded evidence bundle. Commits and CI builds are opt-in so existing scripts keep their previous request and output behavior.
### Raw API

View File

@ -0,0 +1,44 @@
# 工作流 PR 证据包
## 变更说明
在现有 `workflow +review-context` 的基础上增加可选的提交记录和 CI 构建结果并为文件、Review、提交和构建结果统一提供数量上限。默认行为保持不变只有显式启用 `--include-commits=true``--include-ci=true` 时才会请求新增接口。
这项基础能力用于支撑已经合并的 PR 维护 Skills代码审查可以把提交记录、变更文件和 Review 放在同一份证据中,集成检查可以使用 CI 结果作为合并门禁,契约守卫可以验证新增输出仍然是有界且可机器读取的,维护者雷达和 PR 拓扑分析也可以复用同一份上下文而不重复请求 API。该 PR 只提供只读证据,不替代五个 Skill 各自的判断职责。
## 使用示例
```bash
gitlink-cli workflow +review-context \
--owner Gitlink --repo gitlink-cli --number 1 \
--include-commits=true --commit-limit 30 \
--include-ci=true --ci-limit 20 \
--format json
```
新增结果字段:
- `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 判断证据是否完整。
- `notes`:单个证据探针失败时的可解释记录,不会伪造成功数据。
## 兼容性与安全
- 旧参数、旧默认请求和已有 JSON 字段保持兼容。
- 所有新增请求均为 `GET`,不会评论、审批、合并、关闭或修改远程资源。
- 每个列表都带有上限,避免大仓库响应无限膨胀。
- CI 获取失败时保留其他证据,并在 `notes` 中标记为不可用;只有所有启用分区都失败时才返回错误。
- PR 详情缺少 head SHA 时,从带时间戳的最新 commit 推导当前 SHA再进行 CI 精确匹配;无法取得 commit 证据时才降级到分支匹配。
- 列表探针必须返回结构化数组或已知列表包装HTML、普通文本和未知对象会进入 `notes`,不会加入 `sections` 冒充成功。
- 标签列表兼容 `issue_tags``tags` 包装,避免接口返回标签数据时被错误解析为空。
## 验证
```bash
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

@ -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

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"
@ -14,14 +15,20 @@ type ReviewContextOptions struct {
Owner string
Repo string
Number int
FileLimit int
ReviewLimit int
IssueLimit int
LabelLimit int
CommitLimit int
BuildLimit int
IncludeRepo bool
IncludePR bool
IncludeFiles bool
IncludeReviews bool
IncludeIssues bool
IncludeLabels bool
IncludeCommits bool
IncludeCI bool
}
type ReviewContext struct {
@ -35,23 +42,45 @@ type ReviewContext struct {
Reviews []map[string]interface{} `json:"reviews,omitempty"`
OpenIssues []map[string]interface{} `json:"open_issues,omitempty"`
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",
Description: "Fetch read-only PR review context from shortcut-backed endpoints",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Pull request number", Required: true},
{Name: "file-limit", Usage: "Maximum changed files to include", Default: "100"},
{Name: "review-limit", Usage: "Maximum reviews to include", Default: "100"},
{Name: "issue-limit", Usage: "Maximum open issues to include", Default: "20"},
{Name: "label-limit", Usage: "Maximum labels to include", Default: "50"},
{Name: "commit-limit", Usage: "Maximum commits to include", Default: "100"},
{Name: "ci-limit", Usage: "Maximum CI builds to include", Default: "20"},
{Name: "include-repo", Usage: "Include repository info", Bool: true, Default: "true"},
{Name: "include-pr", Usage: "Include pull request details", Bool: true, Default: "true"},
{Name: "include-files", Usage: "Include pull request changed files", Bool: true, Default: "true"},
{Name: "include-reviews", Usage: "Include pull request reviews", Bool: true, Default: "true"},
{Name: "include-issues", Usage: "Include open issue context", Bool: true, Default: "true"},
{Name: "include-labels", Usage: "Include issue labels", Bool: true, Default: "true"},
{Name: "include-commits", Usage: "Include pull request commits", Bool: true, Default: "false"},
{Name: "include-ci", Usage: "Include repository CI builds for evidence checks", Bool: true, Default: "false"},
},
Run: runReviewContext,
}
@ -65,10 +94,26 @@ func runReviewContext(ctx *common.RuntimeContext) error {
if number <= 0 {
return fmt.Errorf("workflow +review-context requires --number with --owner and --repo for read-only fetch")
}
fileLimit, err := parseIntArg(ctx.Arg("file-limit"), 100, "file-limit")
if err != nil {
return err
}
reviewLimit, err := parseIntArg(ctx.Arg("review-limit"), 100, "review-limit")
if err != nil {
return err
}
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit")
if err != nil {
return err
}
commitLimit, err := parseIntArg(ctx.Arg("commit-limit"), 100, "commit-limit")
if err != nil {
return err
}
ciLimit, err := parseIntArg(ctx.Arg("ci-limit"), 20, "ci-limit")
if err != nil {
return err
}
labelLimit, err := parseIntArg(ctx.Arg("label-limit"), 50, "label-limit")
if err != nil {
return err
@ -76,14 +121,20 @@ func runReviewContext(ctx *common.RuntimeContext) error {
context, err := FetchReviewContext(ctx, ReviewContextOptions{
Number: number,
FileLimit: fileLimit,
ReviewLimit: reviewLimit,
IssueLimit: issueLimit,
LabelLimit: labelLimit,
CommitLimit: commitLimit,
BuildLimit: ciLimit,
IncludeRepo: parseBoolDefault(ctx.Arg("include-repo"), true),
IncludePR: parseBoolDefault(ctx.Arg("include-pr"), true),
IncludeFiles: parseBoolDefault(ctx.Arg("include-files"), true),
IncludeReviews: parseBoolDefault(ctx.Arg("include-reviews"), true),
IncludeIssues: parseBoolDefault(ctx.Arg("include-issues"), true),
IncludeLabels: parseBoolDefault(ctx.Arg("include-labels"), true),
IncludeCommits: parseBoolDefault(ctx.Arg("include-commits"), false),
IncludeCI: parseBoolDefault(ctx.Arg("include-ci"), false),
})
if err != nil {
return err
@ -115,6 +166,18 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
if opts.LabelLimit <= 0 {
opts.LabelLimit = 50
}
if opts.FileLimit <= 0 {
opts.FileLimit = 100
}
if opts.ReviewLimit <= 0 {
opts.ReviewLimit = 100
}
if opts.CommitLimit <= 0 {
opts.CommitLimit = 100
}
if opts.BuildLimit <= 0 {
opts.BuildLimit = 20
}
result := ReviewContext{
Repository: fmt.Sprintf("%s/%s", owner, repo),
@ -124,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 {
@ -134,17 +198,22 @@ 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 {
if files, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/files", plainRepoPath(owner, repo), opts.Number), nil, 100); err != nil {
if files, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/files", plainRepoPath(owner, repo), opts.Number), nil, opts.FileLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_files", Note: fmt.Sprintf("pr +files equivalent failed: %v", err)})
} else {
result.Files = files
@ -153,7 +222,7 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
}
}
if opts.IncludeReviews {
if reviews, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/reviews", workflowRepoPath(owner, repo), opts.Number), nil, 100); err != nil {
if reviews, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/reviews", workflowRepoPath(owner, repo), opts.Number), nil, opts.ReviewLimit); err != nil {
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_reviews", Note: fmt.Sprintf("pr +reviews equivalent failed: %v", err)})
} else {
result.Reviews = reviews
@ -161,6 +230,28 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
successes++
}
}
if opts.IncludeCommits {
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
result.Sections = append(result.Sections, "commits")
successes++
}
}
if opts.IncludeCI {
if builds, err := fetchReviewContextList(ctx, plainRepoPath(owner, repo)+"/builds", nil, opts.BuildLimit); err != nil {
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(prEvidence, result.Commits, builds)
result.Sections = append(result.Sections, "ci_builds")
successes++
}
}
if opts.IncludeIssues {
query := url.Values{}
query.Set("category", "opened")
@ -189,6 +280,108 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
return result, nil
}
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 = reviewContextHeadSHA(pr, commits)
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 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"} {
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 {
@ -201,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
@ -213,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 {
@ -228,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) {
@ -258,6 +488,14 @@ func writeReviewContextMarkdown(w *strings.Builder, context ReviewContext) error
_, _ = fmt.Fprintf(w, "\n## Summary\n\n")
_, _ = fmt.Fprintf(w, "- Changed files: `%d`\n", len(context.Files))
_, _ = 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 {
@ -270,13 +508,15 @@ func writeReviewContextMarkdown(w *strings.Builder, context ReviewContext) error
}
func writeReviewContextTable(w *strings.Builder, context ReviewContext) error {
_, _ = fmt.Fprintf(w, "REPOSITORY\tPR\tSECTIONS\tFILES\tREVIEWS\tISSUES\tLABELS\tNOTES\n")
_, _ = fmt.Fprintf(w, "%s\t#%d\t%d\t%d\t%d\t%d\t%d\t%d\n",
_, _ = fmt.Fprintf(w, "REPOSITORY\tPR\tSECTIONS\tFILES\tREVIEWS\tCOMMITS\tCI\tISSUES\tLABELS\tNOTES\n")
_, _ = fmt.Fprintf(w, "%s\t#%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n",
context.Repository,
context.PullRequest,
len(context.Sections),
len(context.Files),
len(context.Reviews),
len(context.Commits),
len(context.Builds),
len(context.OpenIssues),
len(context.Labels),
len(context.Notes),

View File

@ -122,6 +122,154 @@ 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 "/owner/repo/pulls/9012/commits.json":
if got := r.URL.Query().Get("limit"); got != "2" {
t.Fatalf("commit limit = %q, want 2", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"commits": []map[string]interface{}{
{"sha": "abc123", "message": "feat: add evidence"},
{"sha": "def456", "message": "test: cover evidence"},
{"sha": "ignored", "message": "should be bounded"},
},
})
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",
},
})
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, "sha": "abcdef1", "status": "completed", "conclusion": "success"}, {"id": 10, "sha": "other", "status": "failed"}},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
got, err := FetchReviewContext(workflowTestContext(server), ReviewContextOptions{
Number: 12,
CommitLimit: 2,
BuildLimit: 1,
IncludeCommits: true,
IncludeCI: true,
IncludePR: true,
})
if err != nil {
t.Fatalf("FetchReviewContext returned error: %v", err)
}
if len(got.Commits) != 2 || len(got.Builds) != 1 {
t.Fatalf("evidence sizes = commits:%d builds:%d, want 2/1", len(got.Commits), len(got.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)
}
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"},
nil,
[]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"},
nil,
[]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 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"))
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}))
defer server.Close()
got, err := FetchReviewContext(workflowTestContext(server), ReviewContextOptions{
Number: 13,
IncludeRepo: true,
IncludeCI: true,
})
if err != nil {
t.Fatalf("FetchReviewContext returned error: %v", err)
}
if len(got.Notes) != 1 || got.Notes[0].Metric != "ci_builds" {
t.Fatalf("notes = %+v, want one ci_builds note", got.Notes)
}
}
func TestFetchReviewContextAllSectionsFail(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@ -208,6 +356,8 @@ func TestRenderReviewContextFormats(t *testing.T) {
Source: "shortcut-backed-read-only-fetch",
Sections: []string{"repo_info", "pr"},
Files: []map[string]interface{}{{"filename": "README.md"}},
Commits: []map[string]interface{}{{"sha": "abc123"}},
Builds: []map[string]interface{}{{"id": 9, "status": "success"}},
Notes: []ScoringNote{{Metric: "labels", Note: "label +list equivalent failed"}},
}
@ -226,6 +376,9 @@ func TestRenderReviewContextFormats(t *testing.T) {
if !strings.Contains(markdown, "# PR Review Context") || !strings.Contains(markdown, "label +list") {
t.Fatalf("markdown output = %q", markdown)
}
if !strings.Contains(markdown, "Commits: `1`") || !strings.Contains(markdown, "CI builds: `1`") {
t.Fatalf("markdown output missing evidence counts = %q", markdown)
}
}
func TestParseBoolDefault(t *testing.T) {