feat(workflow): 新增 PR 审查队列命令
This commit is contained in:
parent
52b7093846
commit
1eee85d13f
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 直接调用:
|
||||
|
|
|
|||
|
|
@ -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 输出,确保新增功能可验证、可审查。
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
newReviewQueueShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue