diff --git a/README.md b/README.md index 3d68be0..e4ae4d6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index e760403..e36e53d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 diff --git a/doc/changes/workflow-shortcut-backed-fetches.md b/doc/changes/workflow-shortcut-backed-fetches.md new file mode 100644 index 0000000..0ceb5b1 --- /dev/null +++ b/doc/changes/workflow-shortcut-backed-fetches.md @@ -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 ./... +``` diff --git a/shortcuts/workflow/api_types.go b/shortcuts/workflow/api_types.go index 4a4fe53..ecae6ca 100644 --- a/shortcuts/workflow/api_types.go +++ b/shortcuts/workflow/api_types.go @@ -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 diff --git a/shortcuts/workflow/review_context.go b/shortcuts/workflow/review_context.go new file mode 100644 index 0000000..6522c2c --- /dev/null +++ b/shortcuts/workflow/review_context.go @@ -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) +} diff --git a/shortcuts/workflow/review_context_test.go b/shortcuts/workflow/review_context_test.go new file mode 100644 index 0000000..87c458e --- /dev/null +++ b/shortcuts/workflow/review_context_test.go @@ -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") + } +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index 4dc69e5..4eb981a 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), + newReviewContextShortcut(), } } diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index d8cd3ef..54d0ef5 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -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") } diff --git a/skills/README.md b/skills/README.md index 9f05e9e..9555d42 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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 对照 | diff --git a/skills/gitlink-code-review/SKILL.md b/skills/gitlink-code-review/SKILL.md index 4326ec7..8ee0fa4 100644 --- a/skills/gitlink-code-review/SKILL.md +++ b/skills/gitlink-code-review/SKILL.md @@ -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 --format json +# 一次性获取仓库、PR、变更文件、已有 Review、开放 Issue 和标签上下文 +gitlink-cli workflow +review-context --owner --repo --number --format json -# 获取变更文件列表 -gitlink-cli pr +files --id --format json - -# 获取 Diff 内容(含变更行号和代码上下文) -gitlink-cli pr +diff --id --format json +# 如只需要规则化审查摘要 +gitlink-cli workflow +pr-summary --owner --repo --number --format markdown ``` #### Step 2:逐文件分析 @@ -163,18 +160,17 @@ gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ gitlink-cli repo +info --owner --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 --repo --path src --ref master --format json +gitlink-cli repo +tree --owner --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 --repo --path README.md --ref master --format json +gitlink-cli repo +raw --owner --repo --path .gitignore --ref master --format json +gitlink-cli repo +manifest --owner --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 --repo --format json +gitlink-cli repo +contributors --owner --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 --repo --number --format json -# 获取 PR 变更文件列表 -gitlink-cli api GET /:owner/:repo/pulls/:id/files --format json +# 获取规则化 PR 审查摘要 +gitlink-cli workflow +pr-summary --owner --repo --number --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 --repo -i --status common -c "..." --dry-run # 获取仓库文件列表 -gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=' +gitlink-cli repo +tree --owner --repo --path --ref --format json # 获取仓库语言统计 -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --owner --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 --repo --format json ``` ## 代码审查最佳实践 diff --git a/skills/gitlink-gatekeeper/SKILL.md b/skills/gitlink-gatekeeper/SKILL.md index ce31ddb..3b64e6c 100644 --- a/skills/gitlink-gatekeeper/SKILL.md +++ b/skills/gitlink-gatekeeper/SKILL.md @@ -96,20 +96,20 @@ behavior: | 步骤 | 数据 | 命令 | |------|------|------| +| 聚合上下文 | 仓库、PR、文件、Review、Issue、标签 | `gitlink-cli workflow +review-context --number --format json` | | PR 元信息 | 标题/描述/作者/关联 issue | `gitlink-cli pr +view -i --format json` | | 变更文件 | 文件路径列表 | `gitlink-cli pr +files -i --format json` | | Diff | 变更内容供 AI 审查 | `gitlink-cli pr +diff -i --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」 diff --git a/skills/gitlink-insight/SKILL.md b/skills/gitlink-insight/SKILL.md index 436c66c..fdccafa 100644 --- a/skills/gitlink-insight/SKILL.md +++ b/skills/gitlink-insight/SKILL.md @@ -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 --repo --ref master --format json # 5. 获取语言统计 -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --owner --repo --format json # 6. 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --owner --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 --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 --repo --format json # 仓库语言统计 -gitlink-cli api GET /:owner/:repo/languages --format json +gitlink-cli repo +languages --owner --repo --format json # 贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors --format json +gitlink-cli repo +contributors --owner --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 --repo --ref master --format json # 获取用户信息 -gitlink-cli api GET /users/:user_id --format json +gitlink-cli user +info --login --format json # 用户贡献热力图 -gitlink-cli api GET /users/:user_id/headmaps --format json +gitlink-cli user +heatmap --user --format json ``` ## 注意事项 diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 0f7aa2a..a2b587b 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -44,13 +44,10 @@ gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[ # 1. 获取 PR 详情 gitlink-cli pr +view --id --format json -# 2. 获取变更文件列表 -gitlink-cli pr +files --id --format json +# 2. 获取完整审查上下文(仓库、PR、变更文件、Review、Issue、标签) +gitlink-cli workflow +review-context --number --format json -# 3. 获取 PR 提交列表 -gitlink-cli pr +diff --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