精确关联 PR 的 CI 证据并完善上下文解析 #429
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue