gitlink-cli/shortcuts/workflow/review_queue_test.go

311 lines
11 KiB
Go

package workflow
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"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 TestCompareReviewQueueClassifiesChanges(t *testing.T) {
previous := ReviewQueueResult{Items: []ReviewQueueItem{
{Rank: 1, Number: 10, Title: "stable", Priority: "medium", RiskLevel: PRRiskMedium, PriorityScore: 45},
{Rank: 2, Number: 11, Title: "escalated", Priority: "low", RiskLevel: PRRiskLow, PriorityScore: 20},
{Rank: 3, Number: 12, Title: "resolved", Priority: "high", RiskLevel: PRRiskHigh, PriorityScore: 80},
}}
current := ReviewQueueResult{Items: []ReviewQueueItem{
{Rank: 1, Number: 13, Title: "new", Priority: "high", RiskLevel: PRRiskHigh, PriorityScore: 75},
{Rank: 2, Number: 11, Title: "escalated", Priority: "high", RiskLevel: PRRiskHigh, PriorityScore: 75},
{Rank: 3, Number: 10, Title: "stable", Priority: "medium", RiskLevel: PRRiskMedium, PriorityScore: 45},
}}
delta := CompareReviewQueue(current, previous)
if !delta.Compared || len(delta.New) != 1 || delta.New[0].Number != 13 {
t.Fatalf("new = %+v, want PR #13", delta.New)
}
if len(delta.Resolved) != 1 || delta.Resolved[0].Number != 12 {
t.Fatalf("resolved = %+v, want PR #12", delta.Resolved)
}
if len(delta.PriorityChanged) != 1 || delta.PriorityChanged[0].Number != 11 || delta.PriorityChanged[0].ScoreDelta != 55 {
t.Fatalf("priority changes = %+v, want PR #11 with score delta 55", delta.PriorityChanged)
}
if len(delta.RiskChanged) != 1 || delta.RiskChanged[0].Number != 11 {
t.Fatalf("risk changes = %+v, want PR #11", delta.RiskChanged)
}
if delta.Unchanged != 1 {
t.Fatalf("unchanged = %d, want 1", delta.Unchanged)
}
}
func TestAnalyzeReviewQueueIncludesSLAAndReviewOwnership(t *testing.T) {
asOf := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
result := AnalyzeReviewQueue(ReviewQueueInput{
Repository: "owner/repo",
Source: "local-json",
AsOf: asOf,
StaleAfterHours: 72,
PullRequests: []PRSummaryInput{
{
Number: 42,
Title: "fix: update validation",
State: "open",
CreatedAt: asOf.Add(-120 * time.Hour),
UpdatedAt: asOf.Add(-80 * time.Hour),
ReviewState: "changes_requested",
Reviewers: []string{"alice", "bob"},
},
},
}, "en")
if len(result.Items) != 1 {
t.Fatalf("items = %d, want 1", len(result.Items))
}
item := result.Items[0]
if item.AgeHours != 120 || item.WaitingHours != 80 || !item.Stale {
t.Fatalf("freshness = age:%d waiting:%d stale:%t, want 120/80/true", item.AgeHours, item.WaitingHours, item.Stale)
}
if item.WaitingOn != "author" || item.ReviewerCount != 2 || item.ReviewState != "changes_requested" {
t.Fatalf("ownership = waiting_on:%q reviewers:%d state:%q", item.WaitingOn, item.ReviewerCount, item.ReviewState)
}
if !strings.Contains(strings.Join(item.Reasons, ","), "no update for 80h") {
t.Fatalf("reasons = %v, want stale reason", item.Reasons)
}
}
func TestReadReviewQueueResultRejectsInvalidJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "previous.json")
if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := readReviewQueueResult(path); err == nil || !strings.Contains(err.Error(), "expected JSON") {
t.Fatalf("error = %v, want invalid previous queue hint", err)
}
}
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"},
"created_at": "2026-07-18T12:00:00Z",
"updated_at": "2026-07-19T12:00:00Z",
"review_state": "requested",
"reviewers": []interface{}{map[string]interface{}{"login": "reviewer"}},
"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])
}
if prs[0].ReviewState != "requested" || len(prs[0].Reviewers) != 1 || prs[0].Reviewers[0] != "reviewer" {
t.Fatalf("review metadata = state:%q reviewers:%v", prs[0].ReviewState, prs[0].Reviewers)
}
}
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)
}
}
func TestRenderReviewQueueMarkdownIncludesChanges(t *testing.T) {
result := ReviewQueueResult{
Repository: "owner/repo",
Changes: &ReviewQueueDelta{
Compared: true,
New: []ReviewQueueDeltaItem{{Number: 21, Title: "new PR"}},
Resolved: []ReviewQueueDeltaItem{{Number: 19, Title: "resolved PR"}},
Unchanged: 2,
},
}
markdown, err := RenderReviewQueue(result, "markdown", "en")
if err != nil {
t.Fatalf("RenderReviewQueue returned error: %v", err)
}
if !strings.Contains(markdown, "## Queue Changes") || !strings.Contains(markdown, "New: `1`") || !strings.Contains(markdown, "Resolved: `1`") {
t.Fatalf("markdown = %q, want queue change summary", markdown)
}
}
func TestRenderReviewQueueMarkdownIncludesSLA(t *testing.T) {
result := ReviewQueueResult{
Repository: "owner/repo",
Items: []ReviewQueueItem{{Rank: 1, Number: 7, Title: "stale PR", WaitingHours: 80, AgeHours: 100, Stale: true, WaitingOn: "reviewer", ReviewerCount: 2}},
}
markdown, err := RenderReviewQueue(result, "markdown", "en")
if err != nil {
t.Fatalf("RenderReviewQueue returned error: %v", err)
}
if !strings.Contains(markdown, "SLA: age `100h`, waiting `80h`, stale `true`") {
t.Fatalf("markdown = %q, want SLA summary", markdown)
}
}