Merge pull request 'feat(workflow): replace raw api calls with shortcut-backed fetches' (#324) from ohanabi/gitlink-cli:feat/workflow-shortcut-backed-fetches into master
This commit is contained in:
commit
cdf41dff07
|
|
@ -679,6 +679,7 @@ gitlink-cli profile +contribution --user zhangsan --year 2025
|
|||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +review-context`
|
||||
- `workflow +repo-report`
|
||||
|
||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||
|
|
@ -747,6 +748,9 @@ gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30
|
|||
# PR review summary by read-only GitLink fetch
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
|
||||
# PR review context bundle by read-only GitLink fetch
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 --format json
|
||||
|
||||
# PR review summary from a local JSON file
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
|
||||
|
|
@ -769,6 +773,7 @@ Safety:
|
|||
- They do not modify remote GitLink data.
|
||||
- They do not depend on LLM APIs.
|
||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
||||
- `workflow +review-context` bundles repository, PR, file, review, issue, and label context without remote writes.
|
||||
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||
|
||||
### Dataset
|
||||
|
|
|
|||
|
|
@ -525,6 +525,21 @@ gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved
|
|||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
```
|
||||
|
||||
### 工作流 Agent 命令
|
||||
|
||||
```bash
|
||||
# 只读获取 PR 审查上下文包(仓库、PR、文件、Review、Issue、标签)
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo forgeplus --number 42 --format json
|
||||
|
||||
# 生成 PR 审查摘要
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo forgeplus --number 42 --format markdown
|
||||
|
||||
# 生成仓库工作流报告
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo forgeplus --format markdown
|
||||
```
|
||||
|
||||
> `workflow +review-context` 只读取 GitLink 数据,不会评论、审批、拒绝、合并或修改标签。
|
||||
|
||||
### 发布管理
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
# Workflow Shortcut-Backed Fetches
|
||||
|
||||
## Summary
|
||||
|
||||
Adds `workflow +review-context`, a read-only workflow command that returns a deterministic PR review context bundle for AI agents and maintainers.
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 --format json
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
The command aggregates shortcut-backed read-only fetches into one structured output:
|
||||
|
||||
- Repository info, equivalent to `repo +info`
|
||||
- Pull request details, equivalent to `pr +view`
|
||||
- Changed files, equivalent to `pr +files`
|
||||
- Existing reviews, equivalent to `pr +reviews`
|
||||
- Open issue context, equivalent to `issue +list --state open`
|
||||
- Issue labels, equivalent to `label +list`
|
||||
|
||||
Each optional section can be disabled with `--include-...=false`, and `--issue-limit` / `--label-limit` bound the amount of context returned. Partial section failures are recorded in `notes` so an Agent can continue with the available data.
|
||||
|
||||
## Why
|
||||
|
||||
Code review and gatekeeper Skills previously instructed Agents to stitch together multiple raw API calls or separate shortcut calls before analysis. A single read-only workflow command makes review context deterministic, easier to test, and safer for Agent usage.
|
||||
|
||||
## Safety
|
||||
|
||||
- The command is read-only.
|
||||
- It does not comment, approve, reject, merge, label, close, or modify remote resources.
|
||||
- Write operations such as PR review submission remain explicit commands and still require user confirmation.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Updates README examples in English and Chinese.
|
||||
- Updates `gitlink-workflow` with `workflow +review-context`.
|
||||
- Updates code review and gatekeeper Skills to prefer the workflow context bundle.
|
||||
- Updates insight guidance to use shortcut-covered repo and user fetches before Raw API.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
go test ./shortcuts/workflow ./shortcuts
|
||||
go test ./...
|
||||
```
|
||||
|
|
@ -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", "files", "commits", "releases", "builds", "items", "records", "data"} {
|
||||
for _, key := range []string{"issues", "pulls", "pull_requests", "files", "commits", "reviews", "issue_tags", "labels", "releases", "builds", "items", "records", "data"} {
|
||||
if raw, ok := v[key]; ok {
|
||||
if items := apiList(raw); len(items) > 0 {
|
||||
return items
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type ReviewContextOptions struct {
|
||||
Owner string
|
||||
Repo string
|
||||
Number int
|
||||
IssueLimit int
|
||||
LabelLimit int
|
||||
IncludeRepo bool
|
||||
IncludePR bool
|
||||
IncludeFiles bool
|
||||
IncludeReviews bool
|
||||
IncludeIssues bool
|
||||
IncludeLabels bool
|
||||
}
|
||||
|
||||
type ReviewContext struct {
|
||||
Repository string `json:"repository"`
|
||||
PullRequest int `json:"pull_request"`
|
||||
Source string `json:"source"`
|
||||
Sections []string `json:"sections"`
|
||||
RepositoryInfo map[string]interface{} `json:"repository_info,omitempty"`
|
||||
PR map[string]interface{} `json:"pr,omitempty"`
|
||||
Files []map[string]interface{} `json:"files,omitempty"`
|
||||
Reviews []map[string]interface{} `json:"reviews,omitempty"`
|
||||
OpenIssues []map[string]interface{} `json:"open_issues,omitempty"`
|
||||
Labels []map[string]interface{} `json:"labels,omitempty"`
|
||||
Notes []ScoringNote `json:"notes,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: "issue-limit", Usage: "Maximum open issues to include", Default: "20"},
|
||||
{Name: "label-limit", Usage: "Maximum labels to include", Default: "50"},
|
||||
{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"},
|
||||
},
|
||||
Run: runReviewContext,
|
||||
}
|
||||
}
|
||||
|
||||
func runReviewContext(ctx *common.RuntimeContext) error {
|
||||
number, err := parseIntArg(ctx.Arg("number"), 0, "number")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if number <= 0 {
|
||||
return fmt.Errorf("workflow +review-context requires --number with --owner and --repo for read-only fetch")
|
||||
}
|
||||
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
labelLimit, err := parseIntArg(ctx.Arg("label-limit"), 50, "label-limit")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
context, err := FetchReviewContext(ctx, ReviewContextOptions{
|
||||
Number: number,
|
||||
IssueLimit: issueLimit,
|
||||
LabelLimit: labelLimit,
|
||||
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),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
format := ctx.Format
|
||||
if strings.TrimSpace(cmdutil.Format) == "" {
|
||||
format = "json"
|
||||
}
|
||||
rendered, err := RenderReviewContext(context, format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(os.Stdout, rendered)
|
||||
return err
|
||||
}
|
||||
|
||||
func FetchReviewContext(ctx *common.RuntimeContext, opts ReviewContextOptions) (ReviewContext, error) {
|
||||
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
|
||||
if err != nil {
|
||||
return ReviewContext{}, fmt.Errorf("workflow +review-context remote mode requires --owner and --repo or a Git remote: %w", err)
|
||||
}
|
||||
if opts.Number <= 0 {
|
||||
return ReviewContext{}, fmt.Errorf("pull request number is required")
|
||||
}
|
||||
if opts.IssueLimit <= 0 {
|
||||
opts.IssueLimit = 20
|
||||
}
|
||||
if opts.LabelLimit <= 0 {
|
||||
opts.LabelLimit = 50
|
||||
}
|
||||
|
||||
result := ReviewContext{
|
||||
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||
PullRequest: opts.Number,
|
||||
Source: "shortcut-backed-read-only-fetch",
|
||||
Sections: []string{},
|
||||
Notes: []ScoringNote{},
|
||||
}
|
||||
successes := 0
|
||||
|
||||
if opts.IncludeRepo {
|
||||
if info, err := fetchRepoInfo(ctx, owner, repo); err != nil {
|
||||
result.Notes = append(result.Notes, ScoringNote{Metric: "repo_info", Note: fmt.Sprintf("repo +info equivalent failed: %v", err)})
|
||||
} else {
|
||||
result.RepositoryInfo = info
|
||||
result.Sections = append(result.Sections, "repo_info")
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if opts.IncludePR {
|
||||
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)})
|
||||
} else {
|
||||
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 {
|
||||
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_files", Note: fmt.Sprintf("pr +files equivalent failed: %v", err)})
|
||||
} else {
|
||||
result.Files = files
|
||||
result.Sections = append(result.Sections, "files")
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if opts.IncludeReviews {
|
||||
if reviews, err := fetchReviewContextList(ctx, fmt.Sprintf("%s/pulls/%d/reviews", workflowRepoPath(owner, repo), opts.Number), nil, 100); err != nil {
|
||||
result.Notes = append(result.Notes, ScoringNote{Metric: "pr_reviews", Note: fmt.Sprintf("pr +reviews equivalent failed: %v", err)})
|
||||
} else {
|
||||
result.Reviews = reviews
|
||||
result.Sections = append(result.Sections, "reviews")
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if opts.IncludeIssues {
|
||||
query := url.Values{}
|
||||
query.Set("category", "opened")
|
||||
if issues, err := fetchReviewContextList(ctx, workflowRepoPath(owner, repo)+"/issues", query, opts.IssueLimit); err != nil {
|
||||
result.Notes = append(result.Notes, ScoringNote{Metric: "open_issues", Note: fmt.Sprintf("issue +list equivalent failed: %v", err)})
|
||||
} else {
|
||||
result.OpenIssues = issues
|
||||
result.Sections = append(result.Sections, "open_issues")
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if opts.IncludeLabels {
|
||||
if labels, err := fetchReviewContextList(ctx, workflowRepoPath(owner, repo)+"/issue_tags", nil, opts.LabelLimit); err != nil {
|
||||
result.Notes = append(result.Notes, ScoringNote{Metric: "labels", Note: fmt.Sprintf("label +list equivalent failed: %v", err)})
|
||||
} else {
|
||||
result.Labels = labels
|
||||
result.Sections = append(result.Sections, "labels")
|
||||
successes++
|
||||
}
|
||||
}
|
||||
|
||||
result.Notes = uniqueScoringNotes(result.Notes)
|
||||
if successes == 0 {
|
||||
return ReviewContext{}, fmt.Errorf("fetch review context: all enabled sections failed")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
item := prAPIObject(env.Data)
|
||||
if item == nil {
|
||||
return nil, fmt.Errorf("PR response did not contain an object")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func fetchReviewContextList(ctx *common.RuntimeContext, path string, query url.Values, limit int) ([]map[string]interface{}, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
q := cloneValues(query)
|
||||
q.Set("page", "1")
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := apiList(env.Data)
|
||||
out := make([]map[string]interface{}, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func RenderReviewContext(context ReviewContext, format string) (string, error) {
|
||||
var rendered strings.Builder
|
||||
switch normalizeFormat(format) {
|
||||
case "json":
|
||||
if err := writeJSON(&rendered, context); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case "markdown":
|
||||
if err := writeReviewContextMarkdown(&rendered, context); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case "table":
|
||||
if err := writeReviewContextTable(&rendered, context); err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported workflow output format %q", format)
|
||||
}
|
||||
return rendered.String(), nil
|
||||
}
|
||||
|
||||
func writeReviewContextMarkdown(w *strings.Builder, context ReviewContext) error {
|
||||
_, _ = fmt.Fprintf(w, "# PR Review Context\n\n")
|
||||
_, _ = fmt.Fprintf(w, "- Repository: `%s`\n", context.Repository)
|
||||
_, _ = fmt.Fprintf(w, "- Pull request: `#%d`\n", context.PullRequest)
|
||||
_, _ = fmt.Fprintf(w, "- Source: `%s`\n", context.Source)
|
||||
_, _ = fmt.Fprintf(w, "- Sections: `%s`\n", strings.Join(context.Sections, ", "))
|
||||
_, _ = 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, "- Open issues included: `%d`\n", len(context.OpenIssues))
|
||||
_, _ = fmt.Fprintf(w, "- Labels included: `%d`\n", len(context.Labels))
|
||||
if len(context.Notes) > 0 {
|
||||
_, _ = fmt.Fprintf(w, "\n## Notes\n\n")
|
||||
for _, note := range context.Notes {
|
||||
_, _ = fmt.Fprintf(w, "- `%s`: %s\n", note.Metric, note.Note)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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",
|
||||
context.Repository,
|
||||
context.PullRequest,
|
||||
len(context.Sections),
|
||||
len(context.Files),
|
||||
len(context.Reviews),
|
||||
len(context.OpenIssues),
|
||||
len(context.Labels),
|
||||
len(context.Notes),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func plainRepoPath(owner, repo string) string {
|
||||
return fmt.Sprintf("/%s/%s", strings.TrimSpace(owner), strings.TrimSpace(repo))
|
||||
}
|
||||
|
||||
func parseBoolDefault(value string, defaultValue bool) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return parseBoolArg(value)
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestFetchReviewContextAllSections(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo", "default_branch": "master"})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/7.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"pull_request": map[string]interface{}{
|
||||
"number": 7,
|
||||
"title": "feat: add workflow context",
|
||||
"user": map[string]interface{}{"login": "alice"},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/7/files.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"files": []map[string]interface{}{
|
||||
{"filename": "shortcuts/workflow/review_context.go", "additions": 120},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/7/reviews.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"reviews": []map[string]interface{}{
|
||||
{"id": 1, "status": "approved", "user": map[string]interface{}{"login": "reviewer"}},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
if got := r.URL.Query().Get("category"); got != "opened" {
|
||||
t.Fatalf("issue category = %q, want opened", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "2" {
|
||||
t.Fatalf("issue limit = %q, want 2", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"issues": []map[string]interface{}{
|
||||
{"number": 1, "title": "bug: install fails"},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issue_tags.json":
|
||||
if got := r.URL.Query().Get("limit"); got != "3" {
|
||||
t.Fatalf("label limit = %q, want 3", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"issue_tags": []map[string]interface{}{
|
||||
{"id": 2, "name": "bug"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := workflowTestContext(server)
|
||||
got, err := FetchReviewContext(ctx, ReviewContextOptions{
|
||||
Number: 7,
|
||||
IssueLimit: 2,
|
||||
LabelLimit: 3,
|
||||
IncludeRepo: true,
|
||||
IncludePR: true,
|
||||
IncludeFiles: true,
|
||||
IncludeReviews: true,
|
||||
IncludeIssues: true,
|
||||
IncludeLabels: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchReviewContext returned error: %v", err)
|
||||
}
|
||||
if got.Repository != "owner/repo" || got.PullRequest != 7 {
|
||||
t.Fatalf("got repository=%q pr=%d", got.Repository, got.PullRequest)
|
||||
}
|
||||
if len(got.Sections) != 6 {
|
||||
t.Fatalf("sections = %v, want 6 sections", got.Sections)
|
||||
}
|
||||
if len(got.Files) != 1 || len(got.Reviews) != 1 || len(got.OpenIssues) != 1 || len(got.Labels) != 1 {
|
||||
t.Fatalf("context lists not populated: files=%d reviews=%d issues=%d labels=%d", len(got.Files), len(got.Reviews), len(got.OpenIssues), len(got.Labels))
|
||||
}
|
||||
if len(got.Notes) != 0 {
|
||||
t.Fatalf("notes = %+v, want empty", got.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReviewContextPartialFailureKeepsNotes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo"})
|
||||
case "/owner/repo/pulls/9/files.json":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("files unavailable"))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
got, err := FetchReviewContext(workflowTestContext(server), ReviewContextOptions{
|
||||
Number: 9,
|
||||
IncludeRepo: true,
|
||||
IncludeFiles: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchReviewContext returned error: %v", err)
|
||||
}
|
||||
if got.RepositoryInfo == nil {
|
||||
t.Fatal("expected repository info to be populated")
|
||||
}
|
||||
if len(got.Notes) != 1 || got.Notes[0].Metric != "pr_files" {
|
||||
t.Fatalf("notes = %+v, want one pr_files note", got.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReviewContextAllSectionsFail(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("server error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := FetchReviewContext(workflowTestContext(server), ReviewContextOptions{
|
||||
Number: 9,
|
||||
IncludeRepo: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all enabled sections fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewContextShortcutRemoteFetchJSON(t *testing.T) {
|
||||
restoreFormat := setCommandFormatForTest(t, "json")
|
||||
defer restoreFormat()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo"})
|
||||
case "/owner/repo/pulls/3.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"number": 3, "title": "fix: bug"})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: map[string]string{
|
||||
"number": "3",
|
||||
"issue-limit": "20",
|
||||
"label-limit": "50",
|
||||
"include-repo": "true",
|
||||
"include-pr": "true",
|
||||
"include-files": "false",
|
||||
"include-reviews": "false",
|
||||
"include-issues": "false",
|
||||
"include-labels": "false",
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(t, func() error {
|
||||
return findWorkflowShortcut(t, "review-context").Run(ctx)
|
||||
})
|
||||
var result ReviewContext
|
||||
if err := json.Unmarshal([]byte(output), &result); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output)
|
||||
}
|
||||
if result.PullRequest != 3 || result.Repository != "owner/repo" {
|
||||
t.Fatalf("result = %+v, want PR 3 owner/repo", result)
|
||||
}
|
||||
if len(result.Sections) != 2 {
|
||||
t.Fatalf("sections = %v, want repo and pr", result.Sections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewContextShortcutMissingNumber(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{Args: map[string]string{}}
|
||||
err := findWorkflowShortcut(t, "review-context").Run(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing number")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires --number") {
|
||||
t.Fatalf("error = %v, want missing number hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderReviewContextFormats(t *testing.T) {
|
||||
context := ReviewContext{
|
||||
Repository: "owner/repo",
|
||||
PullRequest: 4,
|
||||
Source: "shortcut-backed-read-only-fetch",
|
||||
Sections: []string{"repo_info", "pr"},
|
||||
Files: []map[string]interface{}{{"filename": "README.md"}},
|
||||
Notes: []ScoringNote{{Metric: "labels", Note: "label +list equivalent failed"}},
|
||||
}
|
||||
|
||||
table, err := RenderReviewContext(context, "table")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderReviewContext table returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(table, "REPOSITORY") || !strings.Contains(table, "owner/repo") {
|
||||
t.Fatalf("table output = %q", table)
|
||||
}
|
||||
|
||||
markdown, err := RenderReviewContext(context, "markdown")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderReviewContext markdown returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(markdown, "# PR Review Context") || !strings.Contains(markdown, "label +list") {
|
||||
t.Fatalf("markdown output = %q", markdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBoolDefault(t *testing.T) {
|
||||
if !parseBoolDefault("", true) {
|
||||
t.Fatal("empty value should use true default")
|
||||
}
|
||||
if parseBoolDefault("false", true) {
|
||||
t.Fatal("false value should override true default")
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
newReviewContextShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) {
|
|||
if !names["pr-summary"] {
|
||||
t.Fatal("Shortcuts missing pr-summary")
|
||||
}
|
||||
if !names["review-context"] {
|
||||
t.Fatal("Shortcuts missing review-context")
|
||||
}
|
||||
if !names["repo-report"] {
|
||||
t.Fatal("Shortcuts missing repo-report")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ skills/
|
|||
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |
|
||||
| **gitlink-wiki** | Wiki 页面管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` |
|
||||
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
|
||||
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
|
||||
| **gitlink-workflow** | AI 工作流 | `workflow +triage`, `workflow +pr-summary`, `workflow +review-context`, `workflow +repo-report` |
|
||||
| **gitlink-health** | 开源项目健康度 | 详情见SKILL.md |
|
||||
| **gitlink-semantic-audit** | CLI/平台语义审计(伪成功、参数错配、端点失效体检) | `api GET/POST ...` 与 shortcut 对照 |
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ metadata:
|
|||
|
||||
## 工作流概览
|
||||
|
||||
本 Skill 提供一套完整的 AI 驱动代码审查工作流,覆盖从获取 PR 变更到生成审查报告的全过程。不需要额外的 CLI Shortcuts——现有 `gitlink-cli` 命令 + AI Agent 的分析能力即可完成。
|
||||
本 Skill 提供一套完整的 AI 驱动代码审查工作流,覆盖从获取 PR 变更到生成审查报告的全过程。优先使用 `workflow +review-context` 获取结构化上下文,避免手工拼接多个 Raw API 请求。
|
||||
|
||||
| 阶段 | 操作 | AI Agent 角色 |
|
||||
|------|------|--------------|
|
||||
|
|
@ -38,14 +38,11 @@ metadata:
|
|||
#### Step 1:获取 PR 上下文
|
||||
|
||||
```bash
|
||||
# 获取 PR 详情
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
# 一次性获取仓库、PR、变更文件、已有 Review、开放 Issue 和标签上下文
|
||||
gitlink-cli workflow +review-context --owner <owner> --repo <repo> --number <pr_id> --format json
|
||||
|
||||
# 获取变更文件列表
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
|
||||
# 获取 Diff 内容(含变更行号和代码上下文)
|
||||
gitlink-cli pr +diff --id <pr_id> --format json
|
||||
# 如只需要规则化审查摘要
|
||||
gitlink-cli workflow +pr-summary --owner <owner> --repo <repo> --number <pr_id> --format markdown
|
||||
```
|
||||
|
||||
#### Step 2:逐文件分析
|
||||
|
|
@ -163,18 +160,17 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{
|
|||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 2. 获取仓库文件列表(遍历关键目录)
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=src&ref=master'
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=tests&ref=master'
|
||||
gitlink-cli repo +tree --owner <owner> --repo <repo> --path src --ref master --format json
|
||||
gitlink-cli repo +tree --owner <owner> --repo <repo> --path tests --ref master --format json
|
||||
|
||||
# 3. 获取关键文件内容
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/README.md
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/.gitignore
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/.eslintrc.js # 或类似配置
|
||||
gitlink-cli api GET /:owner/:repo/raw/master/package.json # 或 go.mod, Cargo.toml
|
||||
gitlink-cli repo +raw --owner <owner> --repo <repo> --path README.md --ref master --format json
|
||||
gitlink-cli repo +raw --owner <owner> --repo <repo> --path .gitignore --ref master --format json
|
||||
gitlink-cli repo +manifest --owner <owner> --repo <repo> --kind node --ref master --format json
|
||||
|
||||
# 4. 获取语言统计和贡献者
|
||||
gitlink-cli api GET /:owner/:repo/languages
|
||||
gitlink-cli api GET /:owner/:repo/contributors
|
||||
gitlink-cli repo +languages --owner <owner> --repo <repo> --format json
|
||||
gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
**健康度检查清单:**
|
||||
|
|
@ -254,34 +250,28 @@ gitlink-cli api POST /:owner/:repo/issues/:id --body '{
|
|||
|
||||
---
|
||||
|
||||
## Raw API 参考
|
||||
## Shortcut 参考
|
||||
|
||||
代码审查相关的 GitLink API 端点:
|
||||
|
||||
```bash
|
||||
# 获取 PR 详情
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id --format json
|
||||
# 获取 PR 审查上下文包
|
||||
gitlink-cli workflow +review-context --owner <owner> --repo <repo> --number <id> --format json
|
||||
|
||||
# 获取 PR 变更文件列表
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id/files --format json
|
||||
# 获取规则化 PR 审查摘要
|
||||
gitlink-cli workflow +pr-summary --owner <owner> --repo <repo> --number <id> --format markdown
|
||||
|
||||
# 获取 PR Diff
|
||||
gitlink-cli api GET /:owner/:repo/pulls/:id/diff --format json
|
||||
|
||||
# 提交 PR Review
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"...","event":"COMMENT"}'
|
||||
# 提交 PR Review(写操作前必须确认用户意图,可先 dry-run)
|
||||
gitlink-cli pr +review --owner <owner> --repo <repo> -i <id> --status common -c "..." --dry-run
|
||||
|
||||
# 获取仓库文件列表
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=<path>&ref=<branch>'
|
||||
gitlink-cli repo +tree --owner <owner> --repo <repo> --path <path> --ref <branch> --format json
|
||||
|
||||
# 获取仓库语言统计
|
||||
gitlink-cli api GET /:owner/:repo/languages --format json
|
||||
gitlink-cli repo +languages --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 获取贡献者列表
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
|
||||
# 获取仓库动态
|
||||
gitlink-cli api GET /:owner/:repo/activity --format json
|
||||
gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
## 代码审查最佳实践
|
||||
|
|
|
|||
|
|
@ -96,20 +96,20 @@ behavior:
|
|||
|
||||
| 步骤 | 数据 | 命令 |
|
||||
|------|------|------|
|
||||
| 聚合上下文 | 仓库、PR、文件、Review、Issue、标签 | `gitlink-cli workflow +review-context --number <id> --format json` |
|
||||
| PR 元信息 | 标题/描述/作者/关联 issue | `gitlink-cli pr +view -i <id> --format json` |
|
||||
| 变更文件 | 文件路径列表 | `gitlink-cli pr +files -i <id> --format json` |
|
||||
| Diff | 变更内容供 AI 审查 | `gitlink-cli pr +diff -i <id> --format json` |
|
||||
| commits | commit 列表(消息供 commit_quality) | `gitlink-cli api GET /:owner/:repo/pulls/:id/commits --format json` |
|
||||
| CI 状态 | 构建结果 | `gitlink-cli ci +builds --format json` |
|
||||
|
||||
> 实测注意:`pr +files` 与 `pr +diff` 底层都打 `/pulls/:id/files`——`+files` 取路径列表,`+diff` 取含 patch 的同一份数据,按需取用即可。无 `pr +commits` 快捷命令,commit 列表只能走 Raw API。CI 通过/失败需从 builds 返回的 `status` 字段判断;**无 build 记录时按「CI 未知」处理**(见 §3.5)。
|
||||
> 实测注意:优先使用 `workflow +review-context` 获取聚合上下文;如需更细的 diff 再补充 `pr +diff`。CI 通过/失败需从 builds 返回的 `status` 字段判断;**无 build 记录时按「CI 未知」处理**(见 §3.5)。
|
||||
|
||||
```bash
|
||||
PR=42
|
||||
gitlink-cli workflow +review-context --number "$PR" --format json
|
||||
gitlink-cli pr +view -i "$PR" --format json # title / body / 关联 issue
|
||||
gitlink-cli pr +files -i "$PR" --format json # changed files
|
||||
gitlink-cli pr +diff -i "$PR" --format json # diff(供 AI 审查)
|
||||
gitlink-cli api GET /:owner/:repo/pulls/$PR/commits --format json
|
||||
gitlink-cli ci +builds --format json
|
||||
```
|
||||
|
||||
|
|
@ -295,7 +295,7 @@ PR=42
|
|||
gitlink-cli pr +view -i "$PR" --format json
|
||||
gitlink-cli pr +files -i "$PR" --format json
|
||||
gitlink-cli pr +diff -i "$PR" --format json
|
||||
gitlink-cli api GET /:owner/:repo/pulls/$PR/commits --format json
|
||||
gitlink-cli workflow +review-context --number "$PR" --format json
|
||||
gitlink-cli ci +builds --format json
|
||||
# → AI 产出发现 → 按 §4 评分 → §5 硬门禁 → §6 裁决 → §7 渲染评分卡
|
||||
# → dry-run:仅把评分卡打印给用户,结尾提示「如需回写到 PR,请加 --apply」
|
||||
|
|
|
|||
|
|
@ -49,13 +49,13 @@ gitlink-cli pr +list --state open --format json
|
|||
gitlink-cli pr +list --state merged --format json
|
||||
|
||||
# 4. 获取仓库文件结构(检查文档、CI 配置)
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=master'
|
||||
gitlink-cli repo +tree --owner <owner> --repo <repo> --ref master --format json
|
||||
|
||||
# 5. 获取语言统计
|
||||
gitlink-cli api GET /:owner/:repo/languages --format json
|
||||
gitlink-cli repo +languages --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 6. 获取贡献者列表
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json
|
||||
```
|
||||
|
||||
### 分析指标
|
||||
|
|
@ -172,7 +172,7 @@ gitlink-cli api GET /:owner/:repo/activity --format json
|
|||
|
||||
```bash
|
||||
# 1. 获取贡献者列表
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 2. 获取每个贡献者的 PR
|
||||
# 通过 PR 列表按 author 过滤
|
||||
|
|
@ -249,29 +249,29 @@ gitlink-cli issue +list --state open --format json
|
|||
|
||||
---
|
||||
|
||||
## Raw API 参考
|
||||
## Shortcut 与 Raw API 参考
|
||||
|
||||
```bash
|
||||
# 仓库信息
|
||||
gitlink-cli api GET /:owner/:repo --format json
|
||||
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 仓库语言统计
|
||||
gitlink-cli api GET /:owner/:repo/languages --format json
|
||||
gitlink-cli repo +languages --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 贡献者列表
|
||||
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||
gitlink-cli repo +contributors --owner <owner> --repo <repo> --format json
|
||||
|
||||
# 仓库动态
|
||||
# 仓库动态(当前未覆盖为 shortcut,保留 Raw API)
|
||||
gitlink-cli api GET /:owner/:repo/activity --format json
|
||||
|
||||
# 文件列表(检查文档/配置完整性)
|
||||
gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=master'
|
||||
gitlink-cli repo +tree --owner <owner> --repo <repo> --ref master --format json
|
||||
|
||||
# 获取用户信息
|
||||
gitlink-cli api GET /users/:user_id --format json
|
||||
gitlink-cli user +info --login <user_login> --format json
|
||||
|
||||
# 用户贡献热力图
|
||||
gitlink-cli api GET /users/:user_id/headmaps --format json
|
||||
gitlink-cli user +heatmap --user <user_login> --format json
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
|
|
|||
|
|
@ -44,13 +44,10 @@ gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[<tag_id>
|
|||
# 1. 获取 PR 详情
|
||||
gitlink-cli pr +view --id <pr_id> --format json
|
||||
|
||||
# 2. 获取变更文件列表
|
||||
gitlink-cli pr +files --id <pr_id> --format json
|
||||
# 2. 获取完整审查上下文(仓库、PR、变更文件、Review、Issue、标签)
|
||||
gitlink-cli workflow +review-context --number <pr_id> --format json
|
||||
|
||||
# 3. 获取 PR 提交列表
|
||||
gitlink-cli pr +diff --id <pr_id> --format json
|
||||
|
||||
# 4. 添加 Review 评论
|
||||
# 3. 添加 Review 评论(写操作前需确认用户意图)
|
||||
gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审查意见...","event":"COMMENT"}'
|
||||
```
|
||||
|
||||
|
|
@ -97,8 +94,8 @@ gitlink-cli issue +list --state closed --format json
|
|||
gitlink-cli pr +list --state open --format json
|
||||
gitlink-cli pr +list --state merged --format json
|
||||
|
||||
# 3. 获取项目动态
|
||||
gitlink-cli api GET /:owner/:repo/activity --format json
|
||||
# 3. 获取仓库工作流报告
|
||||
gitlink-cli workflow +repo-report --format json
|
||||
```
|
||||
|
||||
## Workflow: PR Summary (Read-only)
|
||||
|
|
@ -119,6 +116,28 @@ Rules:
|
|||
- This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests.
|
||||
- Do not use LLM APIs for this workflow; it is rule-based and explainable.
|
||||
|
||||
## Workflow: Review Context (Read-only)
|
||||
|
||||
Use `workflow +review-context` when an Agent needs one deterministic JSON bundle for PR review or gatekeeping. It aggregates shortcut-backed read-only fetches for repository info, PR details, changed files, existing reviews, open issues, and labels.
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 --format json
|
||||
|
||||
# Trim context for large repositories
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 \
|
||||
--issue-limit 10 --label-limit 30 --format json
|
||||
|
||||
# Only fetch PR and changed files
|
||||
gitlink-cli workflow +review-context --owner Gitlink --repo gitlink-cli --number 1 \
|
||||
--include-repo=false --include-reviews=false --include-issues=false --include-labels=false \
|
||||
--format json
|
||||
```
|
||||
|
||||
Rules:
|
||||
- This command is read-only and never comments, approves, rejects, merges, labels, or closes resources.
|
||||
- Prefer it before `workflow +pr-summary` when a review agent needs raw context plus existing review state.
|
||||
- The command records partial fetch failures in `notes` so Agents can proceed with available context.
|
||||
|
||||
## Workflow: Repo Report (Read-only)
|
||||
|
||||
Use `workflow +repo-report` when a maintainer or Agent needs a single repository workflow report
|
||||
|
|
|
|||
Loading…
Reference in New Issue