feat(workflow): 为五个维护 Skill 提供 PR 证据包 #426
|
|
@ -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,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 ./...
|
||||
```
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue