From 3026c9d71eb302e4f788a7f113a45d180b9f27b9 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Mon, 20 Jul 2026 16:07:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E6=89=A9=E5=B1=95=20PR=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E8=AF=81=E6=8D=AE=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++ .../workflow-review-context-evidence.md | 38 +++++++++ shortcuts/workflow/review_context.go | 78 ++++++++++++++++++- shortcuts/workflow/review_context_test.go | 78 +++++++++++++++++++ 4 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 doc/changes/workflow-review-context-evidence.md diff --git a/README.md b/README.md index d48d41a..42a6806 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/changes/workflow-review-context-evidence.md b/doc/changes/workflow-review-context-evidence.md new file mode 100644 index 0000000..50e8e97 --- /dev/null +++ b/doc/changes/workflow-review-context-evidence.md @@ -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 ./... +``` diff --git a/shortcuts/workflow/review_context.go b/shortcuts/workflow/review_context.go index 6522c2c..3d70fcf 100644 --- a/shortcuts/workflow/review_context.go +++ b/shortcuts/workflow/review_context.go @@ -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), diff --git a/shortcuts/workflow/review_context_test.go b/shortcuts/workflow/review_context_test.go index 87c458e..10fabac 100644 --- a/shortcuts/workflow/review_context_test.go +++ b/shortcuts/workflow/review_context_test.go @@ -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) {