757 lines
24 KiB
Go
757 lines
24 KiB
Go
package workflow
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
"text/tabwriter"
|
||
"time"
|
||
|
||
"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"`
|
||
AsOf time.Time `json:"as_of,omitempty"`
|
||
StaleAfterHours int `json:"stale_after_hours,omitempty"`
|
||
}
|
||
|
||
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"`
|
||
AsOf time.Time `json:"as_of"`
|
||
StaleAfterHours int `json:"stale_after_hours"`
|
||
Changes *ReviewQueueDelta `json:"changes,omitempty"`
|
||
}
|
||
|
||
type ReviewQueueDelta struct {
|
||
Compared bool `json:"compared"`
|
||
New []ReviewQueueDeltaItem `json:"new,omitempty"`
|
||
Resolved []ReviewQueueDeltaItem `json:"resolved,omitempty"`
|
||
PriorityChanged []ReviewQueueDeltaItem `json:"priority_changed,omitempty"`
|
||
RiskChanged []ReviewQueueDeltaItem `json:"risk_changed,omitempty"`
|
||
Unchanged int `json:"unchanged"`
|
||
}
|
||
|
||
type ReviewQueueDeltaItem struct {
|
||
Number int `json:"number,omitempty"`
|
||
Title string `json:"title"`
|
||
PreviousRank int `json:"previous_rank,omitempty"`
|
||
CurrentRank int `json:"current_rank,omitempty"`
|
||
PreviousPriority string `json:"previous_priority,omitempty"`
|
||
CurrentPriority string `json:"current_priority,omitempty"`
|
||
PreviousRisk string `json:"previous_risk,omitempty"`
|
||
CurrentRisk string `json:"current_risk,omitempty"`
|
||
ScoreDelta int `json:"score_delta,omitempty"`
|
||
}
|
||
|
||
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"`
|
||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||
AgeHours int `json:"age_hours,omitempty"`
|
||
WaitingHours int `json:"waiting_hours,omitempty"`
|
||
Stale bool `json:"stale"`
|
||
ReviewState string `json:"review_state,omitempty"`
|
||
Reviewers []string `json:"reviewers,omitempty"`
|
||
ReviewerCount int `json:"reviewer_count"`
|
||
WaitingOn string `json:"waiting_on,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: "previous", Usage: "Compare with a previous review queue JSON result"},
|
||
{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: "as-of", Usage: "Reference time for age and SLA calculations (RFC3339; defaults to now)"},
|
||
{Name: "stale-after-hours", Usage: "Mark PRs stale after this many hours without an update", Default: "72"},
|
||
{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)
|
||
if path := strings.TrimSpace(ctx.Arg("previous")); path != "" {
|
||
previous, err := readReviewQueueResult(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
delta := CompareReviewQueue(result, previous)
|
||
result.Changes = &delta
|
||
}
|
||
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) {
|
||
asOf, err := parseReviewQueueAsOf(ctx.Arg("as-of"))
|
||
if err != nil {
|
||
return ReviewQueueInput{}, err
|
||
}
|
||
staleAfterHours, err := parseIntArg(ctx.Arg("stale-after-hours"), 72, "stale-after-hours")
|
||
if err != nil {
|
||
return ReviewQueueInput{}, err
|
||
}
|
||
if staleAfterHours <= 0 {
|
||
return ReviewQueueInput{}, fmt.Errorf("stale-after-hours must be greater than zero")
|
||
}
|
||
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"
|
||
}
|
||
if input.AsOf.IsZero() {
|
||
input.AsOf = asOf
|
||
}
|
||
if input.StaleAfterHours <= 0 {
|
||
input.StaleAfterHours = staleAfterHours
|
||
}
|
||
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",
|
||
AsOf: asOf,
|
||
StaleAfterHours: staleAfterHours,
|
||
}, nil
|
||
}
|
||
|
||
func parseReviewQueueAsOf(value string) (time.Time, error) {
|
||
if strings.TrimSpace(value) == "" {
|
||
return time.Now().UTC(), nil
|
||
}
|
||
asOf, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
|
||
if err != nil {
|
||
return time.Time{}, fmt.Errorf("invalid as-of %q: use RFC3339", value)
|
||
}
|
||
return asOf.UTC(), 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 readReviewQueueResult(path string) (ReviewQueueResult, error) {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return ReviewQueueResult{}, fmt.Errorf("read previous review queue: %w", err)
|
||
}
|
||
var result ReviewQueueResult
|
||
if err := json.Unmarshal(data, &result); err != nil {
|
||
return ReviewQueueResult{}, fmt.Errorf("parse previous review queue: expected JSON from workflow +review-queue --format json: %w", err)
|
||
}
|
||
return result, 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"
|
||
}
|
||
asOf := input.AsOf
|
||
if asOf.IsZero() {
|
||
asOf = time.Now().UTC()
|
||
}
|
||
staleAfterHours := input.StaleAfterHours
|
||
if staleAfterHours <= 0 {
|
||
staleAfterHours = 72
|
||
}
|
||
|
||
items := make([]ReviewQueueItem, 0, len(input.PullRequests))
|
||
focus := []string{}
|
||
for _, pr := range input.PullRequests {
|
||
summary := AnalyzePRSummary(pr, lang)
|
||
item := buildReviewQueueItem(pr, summary, lang, asOf, staleAfterHours)
|
||
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,
|
||
AsOf: asOf,
|
||
StaleAfterHours: staleAfterHours,
|
||
}
|
||
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 CompareReviewQueue(current, previous ReviewQueueResult) ReviewQueueDelta {
|
||
delta := ReviewQueueDelta{Compared: true}
|
||
previousByKey := make(map[string]ReviewQueueItem, len(previous.Items))
|
||
currentByKey := make(map[string]ReviewQueueItem, len(current.Items))
|
||
for _, item := range previous.Items {
|
||
if key := reviewQueueItemKey(item); key != "" {
|
||
previousByKey[key] = item
|
||
}
|
||
}
|
||
for _, item := range current.Items {
|
||
if key := reviewQueueItemKey(item); key != "" {
|
||
currentByKey[key] = item
|
||
}
|
||
}
|
||
|
||
for key, item := range currentByKey {
|
||
old, ok := previousByKey[key]
|
||
if !ok {
|
||
delta.New = append(delta.New, reviewQueueDeltaItem(item, ReviewQueueItem{}))
|
||
continue
|
||
}
|
||
change := reviewQueueDeltaItem(item, old)
|
||
priorityChanged := old.Priority != item.Priority
|
||
riskChanged := old.RiskLevel != item.RiskLevel
|
||
if priorityChanged {
|
||
delta.PriorityChanged = append(delta.PriorityChanged, change)
|
||
}
|
||
if riskChanged {
|
||
delta.RiskChanged = append(delta.RiskChanged, change)
|
||
}
|
||
if !priorityChanged && !riskChanged {
|
||
delta.Unchanged++
|
||
}
|
||
}
|
||
for key, item := range previousByKey {
|
||
if _, ok := currentByKey[key]; !ok {
|
||
delta.Resolved = append(delta.Resolved, reviewQueueDeltaItem(ReviewQueueItem{}, item))
|
||
}
|
||
}
|
||
sortReviewQueueDeltaItems(delta.New, false)
|
||
sortReviewQueueDeltaItems(delta.PriorityChanged, false)
|
||
sortReviewQueueDeltaItems(delta.RiskChanged, false)
|
||
sortReviewQueueDeltaItems(delta.Resolved, true)
|
||
return delta
|
||
}
|
||
|
||
func reviewQueueItemKey(item ReviewQueueItem) string {
|
||
if item.Number > 0 {
|
||
return fmt.Sprintf("number:%d", item.Number)
|
||
}
|
||
title := strings.ToLower(strings.TrimSpace(item.Title))
|
||
if title == "" {
|
||
return ""
|
||
}
|
||
return "title:" + title
|
||
}
|
||
|
||
func reviewQueueDeltaItem(current, previous ReviewQueueItem) ReviewQueueDeltaItem {
|
||
item := ReviewQueueDeltaItem{
|
||
Number: current.Number,
|
||
Title: current.Title,
|
||
PreviousRank: previous.Rank,
|
||
CurrentRank: current.Rank,
|
||
PreviousPriority: previous.Priority,
|
||
CurrentPriority: current.Priority,
|
||
PreviousRisk: previous.RiskLevel,
|
||
CurrentRisk: current.RiskLevel,
|
||
ScoreDelta: current.PriorityScore - previous.PriorityScore,
|
||
}
|
||
if item.Title == "" {
|
||
item.Number = previous.Number
|
||
item.Title = previous.Title
|
||
}
|
||
return item
|
||
}
|
||
|
||
func sortReviewQueueDeltaItems(items []ReviewQueueDeltaItem, previousOnly bool) {
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
leftRank := items[i].CurrentRank
|
||
rightRank := items[j].CurrentRank
|
||
if previousOnly {
|
||
leftRank = items[i].PreviousRank
|
||
rightRank = items[j].PreviousRank
|
||
}
|
||
if leftRank != rightRank {
|
||
return leftRank < rightRank
|
||
}
|
||
return items[i].Number < items[j].Number
|
||
})
|
||
}
|
||
|
||
func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang string, asOf time.Time, staleAfterHours int) ReviewQueueItem {
|
||
updatedAt := pr.UpdatedAt
|
||
if updatedAt.IsZero() {
|
||
updatedAt = pr.CreatedAt
|
||
}
|
||
ageHours := elapsedHours(asOf, pr.CreatedAt)
|
||
waitingHours := elapsedHours(asOf, updatedAt)
|
||
stale := waitingHours >= staleAfterHours && !updatedAt.IsZero()
|
||
score, reasons := scoreReviewQueueItem(pr, summary, waitingHours, staleAfterHours)
|
||
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,
|
||
CreatedAt: pr.CreatedAt,
|
||
UpdatedAt: updatedAt,
|
||
AgeHours: ageHours,
|
||
WaitingHours: waitingHours,
|
||
Stale: stale,
|
||
ReviewState: normalizeReviewQueueState(pr.ReviewState),
|
||
Reviewers: append([]string(nil), pr.Reviewers...),
|
||
ReviewerCount: len(pr.Reviewers),
|
||
WaitingOn: reviewQueueWaitingOn(pr.ReviewState),
|
||
}
|
||
}
|
||
|
||
func scoreReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, waitingHours, staleAfterHours int) (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 staleAfterHours > 0 && waitingHours >= staleAfterHours {
|
||
score += 12
|
||
reasons = append(reasons, fmt.Sprintf("no update for %dh", waitingHours))
|
||
} else if staleAfterHours > 0 && waitingHours >= staleAfterHours/2 {
|
||
score += 5
|
||
reasons = append(reasons, fmt.Sprintf("waiting %dh", waitingHours))
|
||
}
|
||
if score > 100 {
|
||
score = 100
|
||
}
|
||
return score, uniqueStrings(reasons)
|
||
}
|
||
|
||
func elapsedHours(asOf, eventAt time.Time) int {
|
||
if eventAt.IsZero() || asOf.Before(eventAt) {
|
||
return 0
|
||
}
|
||
return int(asOf.Sub(eventAt) / time.Hour)
|
||
}
|
||
|
||
func normalizeReviewQueueState(value string) string {
|
||
return strings.ToLower(strings.NewReplacer("-", "_", " ", "_").Replace(strings.TrimSpace(value)))
|
||
}
|
||
|
||
func reviewQueueWaitingOn(state string) string {
|
||
switch normalizeReviewQueueState(state) {
|
||
case "changes_requested", "changes_request", "request_changes":
|
||
return "author"
|
||
case "approved", "approve":
|
||
return "maintainer"
|
||
case "requested", "review_requested", "pending", "unreviewed", "reviewing":
|
||
return "reviewer"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
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 result.Changes != nil {
|
||
if _, err := fmt.Fprintf(buf, "## Queue Changes\n\n- New: `%d`\n- Resolved: `%d`\n- Priority changed: `%d`\n- Risk changed: `%d`\n- Unchanged: `%d`\n\n",
|
||
len(result.Changes.New), len(result.Changes.Resolved), len(result.Changes.PriorityChanged), len(result.Changes.RiskChanged), result.Changes.Unchanged); 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
|
||
}
|
||
if item.WaitingHours > 0 || item.AgeHours > 0 || item.WaitingOn != "" {
|
||
if _, err := fmt.Fprintf(buf, " - SLA: age `%dh`, waiting `%dh`, stale `%t`, waiting on `%s`, reviewers `%d`\n", item.AgeHours, item.WaitingHours, item.Stale, item.WaitingOn, item.ReviewerCount); 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()
|
||
}
|