forked from Gitlink/gitlink-cli
feat(workflow): 增强 PR 队列等待与责任信号
This commit is contained in:
parent
fe16be4a64
commit
914df4b06a
|
|
@ -25,10 +25,14 @@ gitlink-cli workflow +review-queue \
|
||||||
|
|
||||||
`changes` 中的条目按 PR 编号匹配;没有编号的本地输入才回退到规范化标题。比较只读本地快照,不会写入远程仓库。
|
`changes` 中的条目按 PR 编号匹配;没有编号的本地输入才回退到规范化标题。比较只读本地快照,不会写入远程仓库。
|
||||||
|
|
||||||
|
## 等待效率信号
|
||||||
|
|
||||||
|
队列结果现在同时记录 `created_at`、`updated_at`、`age_hours`、`waiting_hours`、`stale`、`review_state`、`reviewers` 和 `waiting_on`。通过 `--as-of` 可固定计算时刻,通过 `--stale-after-hours` 可按仓库 SLA 调整阈值;默认阈值为 72 小时。`waiting_on` 只根据明确的 review 状态推断作者、reviewer 或维护者,不会把未知状态伪装成责任归属。等待达到阈值的条目会增加队列优先级,并在 Markdown 中单独显示 SLA 摘要。
|
||||||
|
|
||||||
## 兼容性与验证
|
## 兼容性与验证
|
||||||
|
|
||||||
- 不传 `--previous` 时原有输出和优先级排序保持不变。
|
- 不传 `--previous` 时原有输出和优先级排序保持不变。
|
||||||
- `changes` 是可选 JSON 字段,旧消费者可以忽略。
|
- `changes` 是可选 JSON 字段,旧消费者可以忽略。
|
||||||
- 非法快照会给出明确错误,不会静默生成不完整差异。
|
- 非法快照会给出明确错误,不会静默生成不完整差异。
|
||||||
- `go test ./shortcuts/workflow -run 'TestCompareReviewQueue|TestReadReviewQueueResult|TestRenderReviewQueueMarkdownIncludesChanges' -count=1` 通过。
|
- `go test ./shortcuts/workflow -run 'TestAnalyzeReviewQueue|TestCompareReviewQueue|TestReadReviewQueue|TestFetchReviewQueue|TestRenderReviewQueue' -count=1` 通过。
|
||||||
- `go build ./...` 和 `git diff --check` 通过。
|
- `go build ./...` 和 `git diff --check` 通过。
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,8 @@ func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool)
|
||||||
firstPRTime(item, "last_updated_at", "lastUpdatedAt"),
|
firstPRTime(item, "last_updated_at", "lastUpdatedAt"),
|
||||||
firstPRTime(item, "last_activity_at", "lastActivityAt"),
|
firstPRTime(item, "last_activity_at", "lastActivityAt"),
|
||||||
)
|
)
|
||||||
|
reviewState := firstPRString(item, "review_state", "review_status", "reviewer_state", "review_decision")
|
||||||
|
reviewers := firstPRStringList(item, "reviewers", "reviewer", "requested_reviewers", "reviewer_logins")
|
||||||
additions := firstPRInt(item, "additions", "additions_count")
|
additions := firstPRInt(item, "additions", "additions_count")
|
||||||
deletions := firstPRInt(item, "deletions", "deletions_count")
|
deletions := firstPRInt(item, "deletions", "deletions_count")
|
||||||
|
|
||||||
|
|
@ -186,6 +188,8 @@ func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool)
|
||||||
HeadBranch: head,
|
HeadBranch: head,
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
UpdatedAt: updatedAt,
|
UpdatedAt: updatedAt,
|
||||||
|
ReviewState: reviewState,
|
||||||
|
Reviewers: reviewers,
|
||||||
Body: body,
|
Body: body,
|
||||||
Additions: additions,
|
Additions: additions,
|
||||||
Deletions: deletions,
|
Deletions: deletions,
|
||||||
|
|
@ -264,6 +268,53 @@ func firstPRString(item map[string]interface{}, keys ...string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstPRStringList(item map[string]interface{}, keys ...string) []string {
|
||||||
|
for _, key := range keys {
|
||||||
|
value, ok := item[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values := normalizePRStringList(value)
|
||||||
|
if len(values) > 0 {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePRStringList(value interface{}) []string {
|
||||||
|
values := []string{}
|
||||||
|
appendValue := func(raw interface{}) {
|
||||||
|
if item, ok := raw.(map[string]interface{}); ok {
|
||||||
|
for _, key := range []string{"login", "username", "name", "handle"} {
|
||||||
|
if name := strings.TrimSpace(apiString(item[key])); name != "" {
|
||||||
|
values = append(values, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, part := range strings.FieldsFunc(apiString(raw), func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
||||||
|
if name := strings.TrimSpace(part); name != "" {
|
||||||
|
values = append(values, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range typed {
|
||||||
|
appendValue(item)
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
for _, item := range typed {
|
||||||
|
appendValue(item)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
appendValue(value)
|
||||||
|
}
|
||||||
|
return uniqueStrings(values)
|
||||||
|
}
|
||||||
|
|
||||||
func firstPRInt(item map[string]interface{}, keys ...string) int {
|
func firstPRInt(item map[string]interface{}, keys ...string) int {
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if value, ok := item[key]; ok {
|
if value, ok := item[key]; ok {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ type PRSummaryInput struct {
|
||||||
HeadBranch string `json:"head_branch"`
|
HeadBranch string `json:"head_branch"`
|
||||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
ReviewState string `json:"review_state,omitempty"`
|
||||||
|
Reviewers []string `json:"reviewers,omitempty"`
|
||||||
Body string `json:"body,omitempty"`
|
Body string `json:"body,omitempty"`
|
||||||
ChangedFiles []PRChangedFile `json:"changed_files"`
|
ChangedFiles []PRChangedFile `json:"changed_files"`
|
||||||
Commits []PRCommit `json:"commits"`
|
Commits []PRCommit `json:"commits"`
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
|
@ -18,6 +19,8 @@ type ReviewQueueInput struct {
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
PullRequests []PRSummaryInput `json:"pull_requests"`
|
PullRequests []PRSummaryInput `json:"pull_requests"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
AsOf time.Time `json:"as_of,omitempty"`
|
||||||
|
StaleAfterHours int `json:"stale_after_hours,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReviewQueueResult struct {
|
type ReviewQueueResult struct {
|
||||||
|
|
@ -30,6 +33,8 @@ type ReviewQueueResult struct {
|
||||||
TopFocus []string `json:"top_focus"`
|
TopFocus []string `json:"top_focus"`
|
||||||
Recommendations []string `json:"recommendations"`
|
Recommendations []string `json:"recommendations"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
AsOf time.Time `json:"as_of"`
|
||||||
|
StaleAfterHours int `json:"stale_after_hours"`
|
||||||
Changes *ReviewQueueDelta `json:"changes,omitempty"`
|
Changes *ReviewQueueDelta `json:"changes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,6 +76,15 @@ type ReviewQueueItem struct {
|
||||||
Reasons []string `json:"reasons"`
|
Reasons []string `json:"reasons"`
|
||||||
SuggestedAction string `json:"suggested_action"`
|
SuggestedAction string `json:"suggested_action"`
|
||||||
ReviewFocus []string `json:"review_focus,omitempty"`
|
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 {
|
func newReviewQueueShortcut() *common.Shortcut {
|
||||||
|
|
@ -83,6 +97,8 @@ func newReviewQueueShortcut() *common.Shortcut {
|
||||||
{Name: "state", Usage: "Remote pull request state to fetch", Default: "open"},
|
{Name: "state", Usage: "Remote pull request state to fetch", Default: "open"},
|
||||||
{Name: "page", Short: "p", Usage: "Remote pull request page", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Remote pull request page", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Maximum pull requests to include", Default: "30"},
|
{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},
|
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
||||||
},
|
},
|
||||||
Run: runReviewQueue,
|
Run: runReviewQueue,
|
||||||
|
|
@ -117,6 +133,17 @@ func runReviewQueue(ctx *common.RuntimeContext) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectReviewQueueInput(ctx *common.RuntimeContext) (ReviewQueueInput, error) {
|
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 != "" {
|
if path := strings.TrimSpace(ctx.Arg("from")); path != "" {
|
||||||
input, err := readReviewQueueInput(path)
|
input, err := readReviewQueueInput(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -125,6 +152,12 @@ func collectReviewQueueInput(ctx *common.RuntimeContext) (ReviewQueueInput, erro
|
||||||
if strings.TrimSpace(input.Source) == "" {
|
if strings.TrimSpace(input.Source) == "" {
|
||||||
input.Source = "local-json"
|
input.Source = "local-json"
|
||||||
}
|
}
|
||||||
|
if input.AsOf.IsZero() {
|
||||||
|
input.AsOf = asOf
|
||||||
|
}
|
||||||
|
if input.StaleAfterHours <= 0 {
|
||||||
|
input.StaleAfterHours = staleAfterHours
|
||||||
|
}
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
limit, err := parseIntArg(ctx.Arg("limit"), 30, "limit")
|
limit, err := parseIntArg(ctx.Arg("limit"), 30, "limit")
|
||||||
|
|
@ -147,9 +180,22 @@ func collectReviewQueueInput(ctx *common.RuntimeContext) (ReviewQueueInput, erro
|
||||||
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||||
PullRequests: prs,
|
PullRequests: prs,
|
||||||
Source: "remote-read-only-fetch",
|
Source: "remote-read-only-fetch",
|
||||||
|
AsOf: asOf,
|
||||||
|
StaleAfterHours: staleAfterHours,
|
||||||
}, nil
|
}, 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) {
|
func readReviewQueueInput(path string) (ReviewQueueInput, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -234,12 +280,20 @@ func AnalyzeReviewQueue(input ReviewQueueInput, lang string) ReviewQueueResult {
|
||||||
if repository == "" {
|
if repository == "" {
|
||||||
repository = "local"
|
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))
|
items := make([]ReviewQueueItem, 0, len(input.PullRequests))
|
||||||
focus := []string{}
|
focus := []string{}
|
||||||
for _, pr := range input.PullRequests {
|
for _, pr := range input.PullRequests {
|
||||||
summary := AnalyzePRSummary(pr, lang)
|
summary := AnalyzePRSummary(pr, lang)
|
||||||
item := buildReviewQueueItem(pr, summary, lang)
|
item := buildReviewQueueItem(pr, summary, lang, asOf, staleAfterHours)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
focus = append(focus, item.ReviewFocus...)
|
focus = append(focus, item.ReviewFocus...)
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +313,8 @@ func AnalyzeReviewQueue(input ReviewQueueInput, lang string) ReviewQueueResult {
|
||||||
Items: items,
|
Items: items,
|
||||||
TopFocus: limitStringsForReviewQueue(uniqueStrings(focus), 10),
|
TopFocus: limitStringsForReviewQueue(uniqueStrings(focus), 10),
|
||||||
Source: source,
|
Source: source,
|
||||||
|
AsOf: asOf,
|
||||||
|
StaleAfterHours: staleAfterHours,
|
||||||
}
|
}
|
||||||
for i := range result.Items {
|
for i := range result.Items {
|
||||||
result.Items[i].Rank = i + 1
|
result.Items[i].Rank = i + 1
|
||||||
|
|
@ -366,8 +422,15 @@ func sortReviewQueueDeltaItems(items []ReviewQueueDeltaItem, previousOnly bool)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang string) ReviewQueueItem {
|
func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang string, asOf time.Time, staleAfterHours int) ReviewQueueItem {
|
||||||
score, reasons := scoreReviewQueueItem(pr, summary)
|
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"
|
priority := "low"
|
||||||
if score >= 70 {
|
if score >= 70 {
|
||||||
priority = "high"
|
priority = "high"
|
||||||
|
|
@ -390,10 +453,19 @@ func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang strin
|
||||||
Reasons: reasons,
|
Reasons: reasons,
|
||||||
SuggestedAction: reviewQueueSuggestedAction(priority, summary.RiskLevel, summary.ChangeType, lang),
|
SuggestedAction: reviewQueueSuggestedAction(priority, summary.RiskLevel, summary.ChangeType, lang),
|
||||||
ReviewFocus: summary.ReviewFocus,
|
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) (int, []string) {
|
func scoreReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, waitingHours, staleAfterHours int) (int, []string) {
|
||||||
score := 0
|
score := 0
|
||||||
reasons := []string{}
|
reasons := []string{}
|
||||||
switch summary.RiskLevel {
|
switch summary.RiskLevel {
|
||||||
|
|
@ -443,12 +515,42 @@ func scoreReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult) (int, []st
|
||||||
score += 8
|
score += 8
|
||||||
reasons = append(reasons, "test signal not obvious")
|
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 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
}
|
}
|
||||||
return score, uniqueStrings(reasons)
|
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 {
|
func hasReviewQueueTestSignal(pr PRSummaryInput) bool {
|
||||||
for _, file := range pr.ChangedFiles {
|
for _, file := range pr.ChangedFiles {
|
||||||
if isTestPath(normalizedPath(file.Filename)) {
|
if isTestPath(normalizedPath(file.Filename)) {
|
||||||
|
|
@ -617,6 +719,11 @@ func writeReviewQueueMarkdown(buf *bytes.Buffer, result ReviewQueueResult, lang
|
||||||
if _, err := fmt.Fprintf(buf, " - Action: %s\n", item.SuggestedAction); err != nil {
|
if _, err := fmt.Fprintf(buf, " - Action: %s\n", item.SuggestedAction); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
|
@ -107,6 +108,40 @@ func TestCompareReviewQueueClassifiesChanges(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestReadReviewQueueResultRejectsInvalidJSON(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "previous.json")
|
path := filepath.Join(t.TempDir(), "previous.json")
|
||||||
if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil {
|
||||||
|
|
@ -171,6 +206,10 @@ func TestFetchReviewQueuePullRequestsUsesReadOnlyQuery(t *testing.T) {
|
||||||
"description": "Adds queue analysis.",
|
"description": "Adds queue analysis.",
|
||||||
"status": "open",
|
"status": "open",
|
||||||
"creator": map[string]interface{}{"login": "alice"},
|
"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,
|
"additions": 130,
|
||||||
"deletions": 5,
|
"deletions": 5,
|
||||||
},
|
},
|
||||||
|
|
@ -198,6 +237,9 @@ func TestFetchReviewQueuePullRequestsUsesReadOnlyQuery(t *testing.T) {
|
||||||
if prs[0].Number != 11 || prs[0].Repository != "owner/repo" || prs[0].State != "open" {
|
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])
|
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) {
|
func TestRenderReviewQueueMarkdownAndTable(t *testing.T) {
|
||||||
|
|
@ -252,3 +294,17 @@ func TestRenderReviewQueueMarkdownIncludesChanges(t *testing.T) {
|
||||||
t.Fatalf("markdown = %q, want queue change summary", markdown)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue