feat(workflow): 新增 PR 审查队列命令

This commit is contained in:
Mengz 2026-06-10 09:13:46 +08:00
parent 52b7093846
commit 1eee85d13f
7 changed files with 743 additions and 0 deletions

View File

@ -485,9 +485,11 @@ gitlink-cli search +users -k "zhangsan"
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
- `workflow +review-queue`
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
`workflow +review-queue` defaults to `table` when `--format` is omitted.
Examples:
@ -560,6 +562,12 @@ 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
# Pull request review queue by read-only GitLink fetch
gitlink-cli workflow +review-queue --owner Gitlink --repo gitlink-cli --limit 20 --format table
# Pull request review queue from local PR summary inputs
gitlink-cli workflow +review-queue --from review_queue.json --format markdown
```
Output formats:
@ -575,6 +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 +review-queue` only sorts and explains the review queue; it does not comment, approve, reject, or merge pull requests.
### Raw API

View File

@ -455,6 +455,26 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### 工作流命令
`workflow` 提供面向维护者和 AI Agent 的只读分析能力,可用于 Issue 分流、仓库健康度评估、PR 审查摘要、仓库工作流报告和 PR 审查队列排序。
```bash
# 生成单个 PR 的审查摘要
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
# 生成仓库工作流报告
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
# 根据风险、变更类型、diff 规模和测试信号生成 PR 审查队列
gitlink-cli workflow +review-queue --owner Gitlink --repo gitlink-cli --limit 20 --format table
# 从本地 JSON 输入生成 PR 审查队列
gitlink-cli workflow +review-queue --from review_queue.json --format markdown
```
`workflow +review-queue` 默认使用 `table` 输出,适合维护者快速决定先审哪个 PR它只读取 PR 元数据并进行本地规则分析,不会评论、审批、拒绝或合并 PR。
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:

View File

@ -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 输出,确保新增功能可验证、可审查。

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
newHealthShortcut(),
newPRSummaryShortcut(),
newRepoReportShortcut(),
newReviewQueueShortcut(),
}
}

View File

@ -31,6 +31,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) {
if !names["repo-report"] {
t.Fatal("Shortcuts missing repo-report")
}
if !names["review-queue"] {
t.Fatal("Shortcuts missing review-queue")
}
}
func TestRunTriageWithSingleIssueArgs(t *testing.T) {