510 lines
15 KiB
Go
510 lines
15 KiB
Go
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()
|
||
}
|