feat(workflow): 为五个维护 Skill 提供 PR 证据包 #426

Closed
Mengz wants to merge 1 commits from Mengz/gitlink-cli:mengz/review-context-evidence into master
4 changed files with 197 additions and 4 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,38 @@
# 工作流 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` 时出现。
- `ci_builds`:仓库 CI 构建记录,只有启用 `include-ci` 时出现。
- `sections`:成功获取的证据分区,便于 Agent 判断证据是否完整。
- `notes`:单个证据探针失败时的可解释记录,不会伪造成功数据。
## 兼容性与安全
- 旧参数、旧默认请求和已有 JSON 字段保持兼容。
- 所有新增请求均为 `GET`,不会评论、审批、合并、关闭或修改远程资源。
- 每个列表都带有上限,避免大仓库响应无限膨胀。
- CI 获取失败时保留其他证据,并在 `notes` 中标记为不可用;只有所有启用分区都失败时才返回错误。
## 验证
```bash
go test ./shortcuts/workflow -run 'TestFetchReviewContextEvidence|TestRenderReviewContextFormats'
go build ./...
```

View File

@ -14,14 +14,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,6 +41,8 @@ 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"`
Notes []ScoringNote `json:"notes,omitempty"`
}
@ -44,14 +52,20 @@ func newReviewContextShortcut() *common.Shortcut {
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 +79,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 +106,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 +151,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),
@ -144,7 +192,7 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
}
}
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 +201,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 +209,24 @@ func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (
successes++
}
}
if opts.IncludeCommits {
if commits, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/commits", workflowRepoPath(owner, repo), opts.Number), 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.Sections = append(result.Sections, "ci_builds")
successes++
}
}
if opts.IncludeIssues {
query := url.Values{}
query.Set("category", "opened")
@ -258,6 +324,8 @@ 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))
_, _ = 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 +338,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,79 @@ 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":
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/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"}},
})
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,
})
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)
}
}
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/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 +281,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 +301,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) {