gitlink-cli/shortcuts/workflow/review_queue.go

510 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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