diff --git a/README.md b/README.md index 44de506..c3e409f 100644 --- a/README.md +++ b/README.md @@ -485,11 +485,11 @@ gitlink-cli search +users -k "zhangsan" - `workflow +health` - `workflow +pr-summary` - `workflow +repo-report` -- `workflow +dependency-audit` +- `workflow +review-queue` `workflow +pr-summary` defaults to `table` when `--format` is omitted. `workflow +repo-report` defaults to `markdown` when `--format` is omitted. -`workflow +dependency-audit` defaults to `table` when `--format` is omitted. +`workflow +review-queue` defaults to `table` when `--format` is omitted. Examples: @@ -563,11 +563,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 -# Audit go.mod dependency risk signals without network access -gitlink-cli workflow +dependency-audit --from go.mod --format table +# Pull request review queue by read-only GitLink fetch +gitlink-cli workflow +review-queue --owner Gitlink --repo gitlink-cli --limit 20 --format table -# Render dependency audit findings as markdown -gitlink-cli workflow +dependency-audit --repository Gitlink/gitlink-cli --from go.mod --format markdown +# Pull request review queue from local PR summary inputs +gitlink-cli workflow +review-queue --from review_queue.json --format markdown ``` Output formats: @@ -583,7 +583,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 +dependency-audit` reads local go.mod or JSON input only; it does not download modules or contact remote services. +- `workflow +review-queue` only sorts and explains the review queue; it does not comment, approve, reject, or merge pull requests. ### Raw API diff --git a/README.zh-CN.md b/README.zh-CN.md index ae40d7d..69004b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -457,7 +457,7 @@ gitlink-cli search +users -k "zhangsan" ### 工作流命令 -`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和依赖风险审计。 +`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和 PR 审查队列排序。 ```bash # 生成单个 PR 的审查摘要 @@ -466,14 +466,14 @@ gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 - # 生成仓库工作流报告 gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown -# 审计 go.mod 中的依赖风险信号 -gitlink-cli workflow +dependency-audit --from go.mod --format table +# 根据风险、变更类型、diff 规模和测试信号生成 PR 审查队列 +gitlink-cli workflow +review-queue --owner Gitlink --repo gitlink-cli --limit 20 --format table -# 输出 markdown 格式的依赖审计报告 -gitlink-cli workflow +dependency-audit --repository Gitlink/gitlink-cli --from go.mod --format markdown +# 从本地 JSON 输入生成 PR 审查队列 +gitlink-cli workflow +review-queue --from review_queue.json --format markdown ``` -`workflow +dependency-audit` 默认使用 `table` 输出,会检查本地 replace、pseudo version、预发布版本、主版本路径不匹配、go 指令缺失或过旧等风险;命令只读取本地 `go.mod` 或 JSON 输入,不会下载模块或访问远端服务。 +`workflow +review-queue` 默认使用 `table` 输出,适合维护者快速决定先审哪个 PR;它只读取 PR 元数据并进行本地规则分析,不会评论、审批、拒绝或合并 PR。 ### Raw API diff --git a/doc/changes/workflow-review-queue.md b/doc/changes/workflow-review-queue.md new file mode 100644 index 0000000..6af924c --- /dev/null +++ b/doc/changes/workflow-review-queue.md @@ -0,0 +1,7 @@ +# 新增 PR 审查队列工作流 + +`gitlink-cli workflow +review-queue` 新增了面向维护者的 PR 审查队列排序能力。命令可以从 GitLink 只读拉取 open PR 列表,也可以读取本地 JSON 输入;每个 PR 会复用已有 `workflow +pr-summary` 的变更类型、风险等级、审查重点和测试建议,再结合 diff 规模、提交数量、测试信号等因素计算优先级分数。 + +该能力适合 open PR 较多的仓库先做审查排队:高风险修复、涉及认证/API/核心路径的大改动会被排在前面,文档类和低风险小改动会自然下沉。输出支持 `table`、`markdown` 和 `json`,终端查看、PR/Issue 评论草稿以及脚本消费都可以直接复用。 + +命令保持工作流模块的安全边界:远端模式只发起 `GET /v1/{owner}/{repo}/pulls` 请求,不会写入评论、审批、拒绝或合并 PR。测试覆盖了本地队列排序、对象和数组两种 JSON 输入、远端查询参数、中文 markdown 渲染和 table 输出,确保新增功能可验证、可审查。 diff --git a/shortcuts/workflow/review_queue.go b/shortcuts/workflow/review_queue.go new file mode 100644 index 0000000..e094f98 --- /dev/null +++ b/shortcuts/workflow/review_queue.go @@ -0,0 +1,509 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type ReviewQueueInput struct { + Repository string `json:"repository"` + PullRequests []PRSummaryInput `json:"pull_requests"` + Source string `json:"source"` +} + +type ReviewQueueResult struct { + Repository string `json:"repository"` + TotalPRs int `json:"total_prs"` + HighPriority int `json:"high_priority"` + MediumPriority int `json:"medium_priority"` + LowPriority int `json:"low_priority"` + Items []ReviewQueueItem `json:"items"` + TopFocus []string `json:"top_focus"` + Recommendations []string `json:"recommendations"` + Source string `json:"source"` +} + +type ReviewQueueItem struct { + Rank int `json:"rank"` + Number int `json:"number,omitempty"` + Title string `json:"title"` + Author string `json:"author,omitempty"` + State string `json:"state,omitempty"` + ChangeType string `json:"change_type"` + RiskLevel string `json:"risk_level"` + Priority string `json:"priority"` + PriorityScore int `json:"priority_score"` + ChangedFiles int `json:"changed_files"` + Commits int `json:"commits"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Reasons []string `json:"reasons"` + SuggestedAction string `json:"suggested_action"` + ReviewFocus []string `json:"review_focus,omitempty"` +} + +func newReviewQueueShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "review-queue", + Description: "Prioritize open pull requests for maintainer review", + Flags: []common.Flag{ + {Name: "from", Usage: "Read review queue input from a JSON file"}, + {Name: "state", Usage: "Remote pull request state to fetch", Default: "open"}, + {Name: "page", Short: "p", Usage: "Remote pull request page", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Maximum pull requests to include", Default: "30"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, + }, + Run: runReviewQueue, + } +} + +func runReviewQueue(ctx *common.RuntimeContext) error { + lang := normalizeLang(ctx.Arg("lang")) + input, err := collectReviewQueueInput(ctx) + if err != nil { + return err + } + result := AnalyzeReviewQueue(input, lang) + format := ctx.Format + if strings.TrimSpace(cmdutil.Format) == "" { + format = "table" + } + rendered, err := RenderReviewQueue(result, format, lang) + if err != nil { + return err + } + _, err = fmt.Fprint(os.Stdout, rendered) + return err +} + +func collectReviewQueueInput(ctx *common.RuntimeContext) (ReviewQueueInput, error) { + if path := strings.TrimSpace(ctx.Arg("from")); path != "" { + input, err := readReviewQueueInput(path) + if err != nil { + return ReviewQueueInput{}, err + } + if strings.TrimSpace(input.Source) == "" { + input.Source = "local-json" + } + return input, nil + } + limit, err := parseIntArg(ctx.Arg("limit"), 30, "limit") + if err != nil { + return ReviewQueueInput{}, err + } + page, err := parseIntArg(ctx.Arg("page"), 1, "page") + if err != nil { + return ReviewQueueInput{}, err + } + state := strings.TrimSpace(ctx.Arg("state")) + if state == "" { + state = "open" + } + prs, owner, repo, err := fetchReviewQueuePullRequests(ctx, state, page, limit) + if err != nil { + return ReviewQueueInput{}, err + } + return ReviewQueueInput{ + Repository: fmt.Sprintf("%s/%s", owner, repo), + PullRequests: prs, + Source: "remote-read-only-fetch", + }, nil +} + +func readReviewQueueInput(path string) (ReviewQueueInput, error) { + data, err := os.ReadFile(path) + if err != nil { + return ReviewQueueInput{}, fmt.Errorf("read review queue input: %w", err) + } + var input ReviewQueueInput + if err := json.Unmarshal(data, &input); err == nil && (len(input.PullRequests) > 0 || strings.TrimSpace(input.Repository) != "") { + return input, nil + } + var prs []PRSummaryInput + if err := json.Unmarshal(data, &prs); err != nil { + return ReviewQueueInput{}, fmt.Errorf("parse review queue input: expected ReviewQueueInput or []PRSummaryInput: %w", err) + } + return ReviewQueueInput{PullRequests: prs, Source: "local-json"}, nil +} + +func fetchReviewQueuePullRequests(ctx *common.RuntimeContext, state string, page, limit int) ([]PRSummaryInput, string, string, error) { + owner, repo, err := resolveFetchRepo(ctx, "", "") + if err != nil { + return nil, "", "", fmt.Errorf("workflow +review-queue remote mode requires --owner and --repo or a Git remote: %w", err) + } + if limit <= 0 { + limit = 30 + } + if page <= 0 { + page = 1 + } + query := url.Values{} + query.Set("state", state) + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("limit", fmt.Sprintf("%d", limit)) + env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query) + if err != nil { + return nil, "", "", fmt.Errorf("fetch pull requests for review queue: %w", err) + } + items := apiList(env.Data) + prs := make([]PRSummaryInput, 0, len(items)) + for _, raw := range items { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + input, ok := normalizePRSummaryItem(item) + if !ok { + continue + } + input.Repository = fmt.Sprintf("%s/%s", owner, repo) + input.Source = "remote-read-only-fetch:list-metadata" + if strings.TrimSpace(input.State) == "" { + input.State = state + } + prs = append(prs, input) + if len(prs) >= limit { + break + } + } + return prs, owner, repo, nil +} + +func AnalyzeReviewQueue(input ReviewQueueInput, lang string) ReviewQueueResult { + lang = normalizeLang(lang) + source := strings.TrimSpace(input.Source) + if source == "" { + source = "local" + } + repository := strings.TrimSpace(input.Repository) + if repository == "" && len(input.PullRequests) > 0 { + repository = input.PullRequests[0].Repository + } + if repository == "" { + repository = "local" + } + + items := make([]ReviewQueueItem, 0, len(input.PullRequests)) + focus := []string{} + for _, pr := range input.PullRequests { + summary := AnalyzePRSummary(pr, lang) + item := buildReviewQueueItem(pr, summary, lang) + items = append(items, item) + focus = append(focus, item.ReviewFocus...) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].PriorityScore != items[j].PriorityScore { + return items[i].PriorityScore > items[j].PriorityScore + } + if items[i].RiskLevel != items[j].RiskLevel { + return reviewQueueRiskWeight(items[i].RiskLevel) > reviewQueueRiskWeight(items[j].RiskLevel) + } + return items[i].Number < items[j].Number + }) + + result := ReviewQueueResult{ + Repository: repository, + TotalPRs: len(items), + Items: items, + TopFocus: limitStringsForReviewQueue(uniqueStrings(focus), 10), + Source: source, + } + for i := range result.Items { + result.Items[i].Rank = i + 1 + switch result.Items[i].Priority { + case "high": + result.HighPriority++ + case "medium": + result.MediumPriority++ + default: + result.LowPriority++ + } + } + result.Recommendations = buildReviewQueueRecommendations(result, lang) + return result +} + +func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang string) ReviewQueueItem { + score, reasons := scoreReviewQueueItem(pr, summary) + priority := "low" + if score >= 70 { + priority = "high" + } else if score >= 40 { + priority = "medium" + } + return ReviewQueueItem{ + Number: summary.Number, + Title: summary.Title, + Author: summary.Author, + State: summary.State, + ChangeType: summary.ChangeType, + RiskLevel: summary.RiskLevel, + Priority: priority, + PriorityScore: score, + ChangedFiles: summary.ChangedFilesCount, + Commits: summary.CommitCount, + Additions: summary.Additions, + Deletions: summary.Deletions, + Reasons: reasons, + SuggestedAction: reviewQueueSuggestedAction(priority, summary.RiskLevel, summary.ChangeType, lang), + ReviewFocus: summary.ReviewFocus, + } +} + +func scoreReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult) (int, []string) { + score := 0 + reasons := []string{} + switch summary.RiskLevel { + case PRRiskCritical: + score += 70 + reasons = append(reasons, "critical risk") + case PRRiskHigh: + score += 55 + reasons = append(reasons, "high risk") + case PRRiskMedium: + score += 30 + reasons = append(reasons, "medium risk") + case PRRiskLow: + score += 10 + reasons = append(reasons, "low risk") + } + switch summary.ChangeType { + case PRChangeTypeFeature: + score += 18 + reasons = append(reasons, "feature work") + case PRChangeTypeFix: + score += 16 + reasons = append(reasons, "bug fix") + case PRChangeTypeRefactor: + score += 14 + reasons = append(reasons, "refactor") + case PRChangeTypeCI: + score += 12 + reasons = append(reasons, "ci/build change") + case PRChangeTypeDocs: + score += 4 + reasons = append(reasons, "docs-only candidate") + } + size := summary.Additions + summary.Deletions + if size >= 800 || summary.ChangedFilesCount >= 25 { + score += 22 + reasons = append(reasons, "large diff") + } else if size >= 200 || summary.ChangedFilesCount >= 8 { + score += 12 + reasons = append(reasons, "medium diff") + } + if summary.CommitCount >= 10 { + score += 8 + reasons = append(reasons, "many commits") + } + if summary.ChangeType != PRChangeTypeDocs && !hasReviewQueueTestSignal(pr) { + score += 8 + reasons = append(reasons, "test signal not obvious") + } + if score > 100 { + score = 100 + } + return score, uniqueStrings(reasons) +} + +func hasReviewQueueTestSignal(pr PRSummaryInput) bool { + for _, file := range pr.ChangedFiles { + if isTestPath(normalizedPath(file.Filename)) { + return true + } + } + text := strings.ToLower(prTextCorpus(pr, true)) + for _, keyword := range []string{"test", "tests", "coverage", "verified", "go test", "passed"} { + if strings.Contains(text, keyword) { + return true + } + } + return false +} + +func reviewQueueRiskWeight(risk string) int { + switch risk { + case PRRiskCritical: + return 4 + case PRRiskHigh: + return 3 + case PRRiskMedium: + return 2 + case PRRiskLow: + return 1 + default: + return 0 + } +} + +func reviewQueueSuggestedAction(priority, risk, changeType, lang string) string { + if normalizeLang(lang) == langZH { + switch { + case priority == "high": + return "优先安排维护者审查,合并前确认测试和风险点" + case risk == PRRiskMedium || changeType == PRChangeTypeFeature: + return "安排常规审查,重点确认行为变更和测试覆盖" + default: + return "可作为低风险队列处理,快速确认后推进" + } + } + switch { + case priority == "high": + return "Prioritize maintainer review and verify tests plus risk areas before merge." + case risk == PRRiskMedium || changeType == PRChangeTypeFeature: + return "Schedule normal review with focus on behavior changes and test coverage." + default: + return "Treat as a low-risk queue item and move forward after a quick check." + } +} + +func buildReviewQueueRecommendations(result ReviewQueueResult, lang string) []string { + if normalizeLang(lang) == langZH { + recs := []string{} + if result.HighPriority > 0 { + recs = append(recs, fmt.Sprintf("先处理 %d 个高优先级 PR,避免高风险变更长时间积压。", result.HighPriority)) + } + if len(result.TopFocus) > 0 { + recs = append(recs, "审查时优先关注队列中反复出现的风险点。") + } + if len(recs) == 0 { + recs = append(recs, "当前队列整体风险较低,可按提交顺序推进。") + } + return recs + } + recs := []string{} + if result.HighPriority > 0 { + recs = append(recs, fmt.Sprintf("Review %d high-priority PR(s) first to avoid high-risk backlog.", result.HighPriority)) + } + if len(result.TopFocus) > 0 { + recs = append(recs, "Use the repeated review focus items as the first pass checklist.") + } + if len(recs) == 0 { + recs = append(recs, "The queue is mostly low risk; process it in normal submission order.") + } + return recs +} + +func limitStringsForReviewQueue(values []string, limit int) []string { + if limit <= 0 || len(values) <= limit { + return values + } + return values[:limit] +} + +func RenderReviewQueue(result ReviewQueueResult, format string, lang string) (string, error) { + var buf bytes.Buffer + switch normalizeFormat(format) { + case "json": + if err := writeJSON(&buf, result); err != nil { + return "", err + } + case "markdown": + if err := writeReviewQueueMarkdown(&buf, result, lang); err != nil { + return "", err + } + case "table": + if err := writeReviewQueueTable(&buf, result); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported workflow output format %q", format) + } + return buf.String(), nil +} + +func writeReviewQueueMarkdown(buf *bytes.Buffer, result ReviewQueueResult, lang string) error { + title := "Pull Request Review Queue" + if normalizeLang(lang) == langZH { + title = "PR 审查队列" + } + if _, err := fmt.Fprintf(buf, "# %s\n\n", title); err != nil { + return err + } + if _, err := fmt.Fprintf(buf, "- Repository: `%s`\n- Pull requests: `%d`\n- High priority: `%d`\n- Medium priority: `%d`\n- Low priority: `%d`\n- Source: `%s`\n\n", + result.Repository, + result.TotalPRs, + result.HighPriority, + result.MediumPriority, + result.LowPriority, + result.Source, + ); err != nil { + return err + } + if len(result.Recommendations) > 0 { + if _, err := fmt.Fprintln(buf, "## Recommendations"); err != nil { + return err + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + for _, rec := range result.Recommendations { + if _, err := fmt.Fprintf(buf, "- %s\n", rec); err != nil { + return err + } + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + } + if _, err := fmt.Fprintln(buf, "## Queue"); err != nil { + return err + } + if _, err := fmt.Fprintln(buf); err != nil { + return err + } + for _, item := range result.Items { + number := "" + if item.Number > 0 { + number = fmt.Sprintf("#%d ", item.Number) + } + if _, err := fmt.Fprintf(buf, "%d. %s%s `%s/%s score:%d`\n", item.Rank, number, item.Title, item.Priority, item.RiskLevel, item.PriorityScore); err != nil { + return err + } + if len(item.Reasons) > 0 { + if _, err := fmt.Fprintf(buf, " - Reasons: %s\n", strings.Join(item.Reasons, ", ")); err != nil { + return err + } + } + if _, err := fmt.Fprintf(buf, " - Action: %s\n", item.SuggestedAction); err != nil { + return err + } + } + return nil +} + +func writeReviewQueueTable(buf *bytes.Buffer, result ReviewQueueResult) error { + tw := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "RANK\tPR\tPRIORITY\tSCORE\tRISK\tTYPE\tFILES\tCOMMITS\tTITLE"); err != nil { + return err + } + for _, item := range result.Items { + number := "-" + if item.Number > 0 { + number = fmt.Sprintf("#%d", item.Number) + } + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%d\t%s\t%s\t%d\t%d\t%s\n", + item.Rank, + number, + item.Priority, + item.PriorityScore, + item.RiskLevel, + item.ChangeType, + item.ChangedFiles, + item.Commits, + truncateTableText(item.Title, 80), + ); err != nil { + return err + } + } + return tw.Flush() +} diff --git a/shortcuts/workflow/review_queue_test.go b/shortcuts/workflow/review_queue_test.go new file mode 100644 index 0000000..d2055cd --- /dev/null +++ b/shortcuts/workflow/review_queue_test.go @@ -0,0 +1,194 @@ +package workflow + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestAnalyzeReviewQueuePrioritizesRiskAndSize(t *testing.T) { + result := AnalyzeReviewQueue(ReviewQueueInput{ + Repository: "owner/repo", + Source: "local-json", + PullRequests: []PRSummaryInput{ + { + Repository: "owner/repo", + Number: 1, + Title: "docs: update README", + State: "open", + ChangedFiles: []PRChangedFile{ + {Filename: "README.md", Status: "modified", Additions: 8, Deletions: 1, Changes: 9}, + }, + Additions: 8, + Deletions: 1, + }, + { + Repository: "owner/repo", + Number: 2, + Title: "fix: avoid token leak in auth logging", + Body: "The current error path may expose an access token in logs.", + State: "open", + ChangedFiles: []PRChangedFile{ + {Filename: "internal/client/client.go", Status: "modified", Additions: 220, Deletions: 20, Changes: 240}, + }, + Commits: []PRCommit{ + {SHA: "abc", Message: "fix: avoid token leak"}, + }, + Additions: 220, + Deletions: 20, + }, + { + Repository: "owner/repo", + Number: 3, + Title: "feat: add workflow review queue", + State: "open", + ChangedFiles: []PRChangedFile{ + {Filename: "shortcuts/workflow/review_queue.go", Status: "added", Additions: 210, Deletions: 0, Changes: 210}, + {Filename: "shortcuts/workflow/review_queue_test.go", Status: "added", Additions: 80, Deletions: 0, Changes: 80}, + }, + Commits: []PRCommit{ + {SHA: "def", Message: "feat: add review queue"}, + }, + Additions: 290, + }, + }, + }, "en") + + if result.TotalPRs != 3 { + t.Fatalf("TotalPRs = %d, want 3", result.TotalPRs) + } + if result.Items[0].Number != 2 { + t.Fatalf("top PR = #%d, want #2: %+v", result.Items[0].Number, result.Items) + } + if result.HighPriority != 1 || result.MediumPriority != 1 || result.LowPriority != 1 { + t.Fatalf("priority counts = high:%d medium:%d low:%d, want 1/1/1", result.HighPriority, result.MediumPriority, result.LowPriority) + } + if len(result.TopFocus) == 0 { + t.Fatal("expected repeated review focus hints") + } + if !strings.Contains(result.Items[0].SuggestedAction, "Prioritize maintainer review") { + t.Fatalf("SuggestedAction = %q, want high-priority action", result.Items[0].SuggestedAction) + } +} + +func TestReadReviewQueueInputSupportsObjectAndArray(t *testing.T) { + dir := t.TempDir() + objectPath := filepath.Join(dir, "queue_object.json") + arrayPath := filepath.Join(dir, "queue_array.json") + + writeJSONFixture(t, objectPath, ReviewQueueInput{ + Repository: "owner/repo", + PullRequests: []PRSummaryInput{ + {Number: 7, Title: "feat: object input"}, + }, + }) + writeJSONFixture(t, arrayPath, []PRSummaryInput{ + {Number: 8, Title: "fix: array input"}, + }) + + objectInput, err := readReviewQueueInput(objectPath) + if err != nil { + t.Fatalf("readReviewQueueInput(object) returned error: %v", err) + } + if objectInput.Repository != "owner/repo" || len(objectInput.PullRequests) != 1 || objectInput.PullRequests[0].Number != 7 { + t.Fatalf("object input = %+v, want repository and PR #7", objectInput) + } + + arrayInput, err := readReviewQueueInput(arrayPath) + if err != nil { + t.Fatalf("readReviewQueueInput(array) returned error: %v", err) + } + if arrayInput.Source != "local-json" || len(arrayInput.PullRequests) != 1 || arrayInput.PullRequests[0].Number != 8 { + t.Fatalf("array input = %+v, want local-json PR #8", arrayInput) + } +} + +func TestFetchReviewQueuePullRequestsUsesReadOnlyQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Fatalf("state query = %q, want open", got) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page query = %q, want 2", got) + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Fatalf("limit query = %q, want 5", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "pulls": []map[string]interface{}{ + { + "pull_request_number": 11, + "title": "feat: remote review queue", + "description": "Adds queue analysis.", + "status": "open", + "creator": map[string]interface{}{"login": "alice"}, + "additions": 130, + "deletions": 5, + }, + }, + }) + })) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + } + + prs, owner, repo, err := fetchReviewQueuePullRequests(ctx, "open", 2, 5) + if err != nil { + t.Fatalf("fetchReviewQueuePullRequests returned error: %v", err) + } + if owner != "owner" || repo != "repo" || len(prs) != 1 { + t.Fatalf("owner/repo/prs = %s/%s/%d, want owner/repo/1", owner, repo, len(prs)) + } + if prs[0].Number != 11 || prs[0].Repository != "owner/repo" || prs[0].State != "open" { + t.Fatalf("normalized PR = %+v, want owner/repo #11 open", prs[0]) + } +} + +func TestRenderReviewQueueMarkdownAndTable(t *testing.T) { + result := AnalyzeReviewQueue(ReviewQueueInput{ + Repository: "owner/repo", + Source: "local-json", + PullRequests: []PRSummaryInput{ + { + Number: 9, + Title: "feat: add queue command", + State: "open", + ChangedFiles: []PRChangedFile{ + {Filename: "shortcuts/workflow/review_queue.go", Additions: 180, Changes: 180}, + }, + Additions: 180, + }, + }, + }, "zh-CN") + + markdown, err := RenderReviewQueue(result, "markdown", "zh-CN") + if err != nil { + t.Fatalf("RenderReviewQueue markdown returned error: %v", err) + } + if !strings.Contains(markdown, "# PR 审查队列") || !strings.Contains(markdown, "feat: add queue command") { + t.Fatalf("markdown output missing expected content:\n%s", markdown) + } + + table, err := RenderReviewQueue(result, "table", "en") + if err != nil { + t.Fatalf("RenderReviewQueue table returned error: %v", err) + } + if !strings.Contains(table, "RANK") || !strings.Contains(table, "#9") { + t.Fatalf("table output missing expected content:\n%s", table) + } +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index dfe131c..6ad2aa7 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), - newDependencyAuditShortcut(), + newReviewQueueShortcut(), } } diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index 3fa1a67..bdd3d23 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -31,8 +31,8 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) { if !names["repo-report"] { t.Fatal("Shortcuts missing repo-report") } - if !names["stale"] { - t.Fatal("Shortcuts missing stale") + if !names["review-queue"] { + t.Fatal("Shortcuts missing review-queue") } }