gitlink-cli/shortcuts/workflow/review_queue.go

757 lines
24 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"
"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()
}