增强 PR 审查队列的等待与责任信号 #430
14
README.md
14
README.md
|
|
@ -507,6 +507,7 @@ gitlink-cli search +users -k "zhangsan"
|
|||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
- `workflow +review-queue`
|
||||
|
||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
|
||||
|
|
@ -582,6 +583,17 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma
|
|||
|
||||
# Repository workflow report from a local JSON file
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
|
||||
# Compare the current review queue with a previous JSON snapshot
|
||||
gitlink-cli workflow +review-queue \
|
||||
--owner Gitlink --repo gitlink-cli \
|
||||
--previous queue-previous.json --format markdown
|
||||
|
||||
# Produce a reproducible queue snapshot and tune the stale threshold
|
||||
gitlink-cli workflow +review-queue \
|
||||
--owner Gitlink --repo gitlink-cli \
|
||||
--as-of 2026-07-26T00:00:00Z \
|
||||
--stale-after-hours 72 --format json
|
||||
```
|
||||
|
||||
Output formats:
|
||||
|
|
@ -597,6 +609,8 @@ Safety:
|
|||
- They do not depend on LLM APIs.
|
||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
||||
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||
- `workflow +review-queue` supports an optional local `--previous` JSON snapshot so maintainers can focus on queue changes instead of rereading unchanged PRs. It accepts UTF-8 snapshots with or without a BOM, which keeps PowerShell-produced files compatible.
|
||||
- Use `--as-of` to make queue age calculations reproducible and `--stale-after-hours` to choose the SLA threshold (default: 72 hours). JSON items include `age_hours`, `waiting_hours`, `waiting_on`, `review_state`, and `stale`; `changes` is present only when `--previous` is supplied.
|
||||
|
||||
### Raw API
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
# PR 审查队列快照差异
|
||||
|
||||
## 变更说明
|
||||
|
||||
恢复并完善现有 `workflow +review-queue` 工作流注册,新增 `--previous` 队列快照比较能力。维护者可以保存一次 JSON 输出,在下一次扫描时只查看新增、已解决、优先级变化、风险变化和未变化数量,不必重新阅读整个队列。
|
||||
|
||||
这是为已经合并的维护类 Skill 提供的第二条前置基础能力:维护者雷达可以把变化项直接变成今日待办,PR 拓扑分析可以识别队列变化后仍然存在的关系热点,代码审查和集成 Skill 仍然保留各自的代码与合并判断职责。本 PR 不把队列排序算法升级为任何一个 Skill 的替代品。
|
||||
|
||||
## 使用方式
|
||||
|
||||
先生成基线快照:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +review-queue \
|
||||
--owner Gitlink --repo gitlink-cli --format json > queue-previous.json
|
||||
```
|
||||
|
||||
再次扫描并比较:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +review-queue \
|
||||
--owner Gitlink --repo gitlink-cli \
|
||||
--previous queue-previous.json --format markdown
|
||||
```
|
||||
|
||||
`changes` 中的条目按 PR 编号匹配;没有编号的本地输入才回退到规范化标题。比较只读本地快照,不会写入远程仓库。
|
||||
|
||||
## 等待效率信号
|
||||
|
||||
队列结果现在同时记录 `created_at`、`updated_at`、`activity_source`、`age_hours`、`waiting_hours`、`stale`、`review_state`、`reviewers` 和 `waiting_on`。通过 `--as-of` 可固定计算时刻,通过 `--stale-after-hours` 可按仓库 SLA 调整阈值;默认阈值为 72 小时。真实列表响应没有更新时间时,使用创建时间计算等待时长,并通过 `activity_source=created_at_fallback` 明确证据来源;没有 reviewer 的 open PR 标记为 `unassigned` 并归为等待维护者分配,已请求 Review 的 PR 才归为等待 reviewer。
|
||||
|
||||
GitLink 的列表接口可能在 `state=open` 响应中混入 closed PR。客户端优先按官方数值字段 `pull_request_status`(0=open、1=merged、2=closed)二次过滤,同时兼容字符串字段 `pull_request_staus`;状态缺失或不匹配的条目不会进入 open 队列。标题、作者、分支和创建时间同时兼容真实响应的 `name`、`issue.author`、`pull_request_base/head`、`pr_full_time/pr_created_unix` 字段。
|
||||
|
||||
## 兼容性与验证
|
||||
|
||||
- 不传 `--previous` 时原有输出和优先级排序保持不变。
|
||||
- `changes` 是可选 JSON 字段,旧消费者可以忽略。
|
||||
- 非法快照会给出明确错误,不会静默生成不完整差异。
|
||||
- 真实 API 回归验证确认 open 队列不再包含 closed PR,标题、作者、时间、SLA 和责任字段均可解释。
|
||||
- `go test ./shortcuts/workflow -run 'TestAnalyzeReviewQueue|TestCompareReviewQueue|TestReadReviewQueue|TestFetchReviewQueue|TestRenderReviewQueue' -count=1` 通过。
|
||||
- `go build ./...` 和 `git diff --check` 通过。
|
||||
|
|
@ -157,38 +157,42 @@ func prAPIObject(data interface{}) map[string]interface{} {
|
|||
|
||||
func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) {
|
||||
number := firstPRInt(item, "number", "index", "iid", "pull_request_number")
|
||||
title := firstPRString(item, "title", "subject")
|
||||
title := firstPRString(item, "title", "subject", "name")
|
||||
if number == 0 && strings.TrimSpace(title) == "" {
|
||||
return PRSummaryInput{}, false
|
||||
}
|
||||
body := firstPRString(item, "body", "description", "content")
|
||||
state := firstPRString(item, "state", "status")
|
||||
state := firstPRState(item)
|
||||
author := firstPRAuthor(item)
|
||||
issueID := firstPRIssueID(item)
|
||||
base := firstPRBranch(item, "base_branch", "target_branch", "base")
|
||||
head := firstPRBranch(item, "head_branch", "source_branch", "head")
|
||||
createdAt := firstPRTime(item, "created_at", "createdAt")
|
||||
base := firstPRBranch(item, "base_branch", "target_branch", "pull_request_base", "base")
|
||||
head := firstPRBranch(item, "head_branch", "source_branch", "pull_request_head", "head")
|
||||
createdAt := firstPRTime(item, "created_at", "createdAt", "pr_full_time", "pr_created_unix")
|
||||
updatedAt := apiLatestTime(
|
||||
firstPRTime(item, "updated_at", "updatedAt"),
|
||||
firstPRTime(item, "last_updated_at", "lastUpdatedAt"),
|
||||
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")
|
||||
deletions := firstPRInt(item, "deletions", "deletions_count")
|
||||
|
||||
return PRSummaryInput{
|
||||
Number: number,
|
||||
IssueID: issueID,
|
||||
Title: title,
|
||||
Author: author,
|
||||
State: state,
|
||||
BaseBranch: base,
|
||||
HeadBranch: head,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Body: body,
|
||||
Additions: additions,
|
||||
Deletions: deletions,
|
||||
Number: number,
|
||||
IssueID: issueID,
|
||||
Title: title,
|
||||
Author: author,
|
||||
State: state,
|
||||
BaseBranch: base,
|
||||
HeadBranch: head,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
ReviewState: reviewState,
|
||||
Reviewers: reviewers,
|
||||
Body: body,
|
||||
Additions: additions,
|
||||
Deletions: deletions,
|
||||
}, true
|
||||
}
|
||||
|
||||
|
|
@ -264,6 +268,53 @@ func firstPRString(item map[string]interface{}, keys ...string) string {
|
|||
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 {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
|
|
@ -287,6 +338,9 @@ func firstPRTime(item map[string]interface{}, keys ...string) time.Time {
|
|||
}
|
||||
|
||||
func firstPRAuthor(item map[string]interface{}) string {
|
||||
if author := firstPRString(item, "author_login", "creator_login", "create_user", "author_name"); author != "" {
|
||||
return author
|
||||
}
|
||||
for _, key := range []string{"author", "user", "creator"} {
|
||||
if value, ok := item[key]; ok {
|
||||
if s := apiAuthor(value); s != "" {
|
||||
|
|
@ -294,6 +348,20 @@ func firstPRAuthor(item map[string]interface{}) string {
|
|||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"issue", "issue_info"} {
|
||||
nested, ok := item[key].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, authorKey := range []string{"author", "user", "creator"} {
|
||||
if author := apiAuthor(nested[authorKey]); author != "" {
|
||||
return author
|
||||
}
|
||||
}
|
||||
if author := firstPRString(nested, "author_login", "author_name"); author != "" {
|
||||
return author
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -325,9 +393,41 @@ func firstPRIssueID(item map[string]interface{}) int {
|
|||
}
|
||||
}
|
||||
}
|
||||
if _, ok := item["pull_request_id"]; ok {
|
||||
return firstPRInt(item, "id")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstPRState(item map[string]interface{}) string {
|
||||
if value, ok := item["pull_request_status"]; ok {
|
||||
switch strings.TrimSpace(apiString(value)) {
|
||||
case "0":
|
||||
return "open"
|
||||
case "1":
|
||||
return "merged"
|
||||
case "2":
|
||||
return "closed"
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"pull_request_staus", "pull_request_state", "state", "status"} {
|
||||
value, ok := item[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
state := strings.ToLower(strings.TrimSpace(apiString(value)))
|
||||
switch state {
|
||||
case "open", "opened":
|
||||
return "open"
|
||||
case "closed", "close":
|
||||
return "closed"
|
||||
case "merged", "merge":
|
||||
return "merged"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPRBranch(item map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ type PRSummaryInput struct {
|
|||
HeadBranch string `json:"head_branch"`
|
||||
CreatedAt time.Time `json:"created_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"`
|
||||
ChangedFiles []PRChangedFile `json:"changed_files"`
|
||||
Commits []PRCommit `json:"commits"`
|
||||
|
|
|
|||
|
|
@ -9,15 +9,18 @@ import (
|
|||
"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"`
|
||||
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 {
|
||||
|
|
@ -30,25 +33,59 @@ type ReviewQueueResult struct {
|
|||
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"`
|
||||
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"`
|
||||
ActivitySource string `json:"activity_source,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 {
|
||||
|
|
@ -57,9 +94,12 @@ func newReviewQueueShortcut() *common.Shortcut {
|
|||
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,
|
||||
|
|
@ -73,6 +113,14 @@ func runReviewQueue(ctx *common.RuntimeContext) error {
|
|||
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"
|
||||
|
|
@ -86,6 +134,17 @@ func runReviewQueue(ctx *common.RuntimeContext) 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 != "" {
|
||||
input, err := readReviewQueueInput(path)
|
||||
if err != nil {
|
||||
|
|
@ -94,6 +153,12 @@ func collectReviewQueueInput(ctx *common.RuntimeContext) (ReviewQueueInput, erro
|
|||
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")
|
||||
|
|
@ -113,17 +178,31 @@ func collectReviewQueueInput(ctx *common.RuntimeContext) (ReviewQueueInput, erro
|
|||
return ReviewQueueInput{}, err
|
||||
}
|
||||
return ReviewQueueInput{
|
||||
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||
PullRequests: prs,
|
||||
Source: "remote-read-only-fetch",
|
||||
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)
|
||||
}
|
||||
data = trimUTF8BOM(data)
|
||||
var input ReviewQueueInput
|
||||
if err := json.Unmarshal(data, &input); err == nil && (len(input.PullRequests) > 0 || strings.TrimSpace(input.Repository) != "") {
|
||||
return input, nil
|
||||
|
|
@ -135,6 +214,25 @@ func readReviewQueueInput(path string) (ReviewQueueInput, error) {
|
|||
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)
|
||||
}
|
||||
data = trimUTF8BOM(data)
|
||||
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
|
||||
}
|
||||
|
||||
// trimUTF8BOM accepts JSON written by Windows PowerShell and other tools that
|
||||
// prefix UTF-8 output with a BOM. encoding/json expects the JSON object first.
|
||||
func trimUTF8BOM(data []byte) []byte {
|
||||
return bytes.TrimPrefix(data, []byte{0xef, 0xbb, 0xbf})
|
||||
}
|
||||
|
||||
func fetchReviewQueuePullRequests(ctx *common.RuntimeContext, state string, page, limit int) ([]PRSummaryInput, string, string, error) {
|
||||
owner, repo, err := resolveFetchRepo(ctx, "", "")
|
||||
if err != nil {
|
||||
|
|
@ -165,6 +263,9 @@ func fetchReviewQueuePullRequests(ctx *common.RuntimeContext, state string, page
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !reviewQueueStateMatches(input.State, state) {
|
||||
continue
|
||||
}
|
||||
input.Repository = fmt.Sprintf("%s/%s", owner, repo)
|
||||
input.Source = "remote-read-only-fetch:list-metadata"
|
||||
if strings.TrimSpace(input.State) == "" {
|
||||
|
|
@ -191,12 +292,20 @@ func AnalyzeReviewQueue(input ReviewQueueInput, lang string) ReviewQueueResult {
|
|||
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)
|
||||
item := buildReviewQueueItem(pr, summary, lang, asOf, staleAfterHours)
|
||||
items = append(items, item)
|
||||
focus = append(focus, item.ReviewFocus...)
|
||||
}
|
||||
|
|
@ -211,11 +320,13 @@ func AnalyzeReviewQueue(input ReviewQueueInput, lang string) ReviewQueueResult {
|
|||
})
|
||||
|
||||
result := ReviewQueueResult{
|
||||
Repository: repository,
|
||||
TotalPRs: len(items),
|
||||
Items: items,
|
||||
TopFocus: limitStringsForReviewQueue(uniqueStrings(focus), 10),
|
||||
Source: source,
|
||||
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
|
||||
|
|
@ -232,14 +343,122 @@ func AnalyzeReviewQueue(input ReviewQueueInput, lang string) ReviewQueueResult {
|
|||
return result
|
||||
}
|
||||
|
||||
func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang string) ReviewQueueItem {
|
||||
score, reasons := scoreReviewQueueItem(pr, summary)
|
||||
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
|
||||
activitySource := "updated_at"
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = pr.CreatedAt
|
||||
activitySource = "created_at_fallback"
|
||||
}
|
||||
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"
|
||||
}
|
||||
reviewState := normalizeReviewQueueState(pr.ReviewState)
|
||||
if reviewState == "" && strings.EqualFold(pr.State, "open") {
|
||||
if len(pr.Reviewers) == 0 {
|
||||
reviewState = "unassigned"
|
||||
} else {
|
||||
reviewState = "review_requested"
|
||||
}
|
||||
}
|
||||
return ReviewQueueItem{
|
||||
Number: summary.Number,
|
||||
Title: summary.Title,
|
||||
|
|
@ -256,10 +475,20 @@ func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang strin
|
|||
Reasons: reasons,
|
||||
SuggestedAction: reviewQueueSuggestedAction(priority, summary.RiskLevel, summary.ChangeType, lang),
|
||||
ReviewFocus: summary.ReviewFocus,
|
||||
CreatedAt: pr.CreatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
ActivitySource: activitySource,
|
||||
AgeHours: ageHours,
|
||||
WaitingHours: waitingHours,
|
||||
Stale: stale,
|
||||
ReviewState: reviewState,
|
||||
Reviewers: append([]string(nil), pr.Reviewers...),
|
||||
ReviewerCount: len(pr.Reviewers),
|
||||
WaitingOn: reviewQueueWaitingOn(reviewState, pr.Reviewers, pr.State),
|
||||
}
|
||||
}
|
||||
|
||||
func scoreReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult) (int, []string) {
|
||||
func scoreReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, waitingHours, staleAfterHours int) (int, []string) {
|
||||
score := 0
|
||||
reasons := []string{}
|
||||
switch summary.RiskLevel {
|
||||
|
|
@ -309,12 +538,65 @@ func scoreReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult) (int, []st
|
|||
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, reviewers []string, prState 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"
|
||||
case "unassigned":
|
||||
return "maintainer"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(prState), "open") {
|
||||
if len(reviewers) > 0 {
|
||||
return "reviewer"
|
||||
}
|
||||
return "maintainer"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func reviewQueueStateMatches(actual, requested string) bool {
|
||||
requested = strings.ToLower(strings.TrimSpace(requested))
|
||||
if requested == "" || requested == "all" {
|
||||
return true
|
||||
}
|
||||
actual = strings.ToLower(strings.TrimSpace(actual))
|
||||
if requested == "opened" {
|
||||
requested = "open"
|
||||
}
|
||||
if requested == "close" {
|
||||
requested = "closed"
|
||||
}
|
||||
return actual != "" && actual == requested
|
||||
}
|
||||
|
||||
func hasReviewQueueTestSignal(pr PRSummaryInput) bool {
|
||||
for _, file := range pr.ChangedFiles {
|
||||
if isTestPath(normalizedPath(file.Filename)) {
|
||||
|
|
@ -455,6 +737,12 @@ func writeReviewQueueMarkdown(buf *bytes.Buffer, result ReviewQueueResult, lang
|
|||
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
|
||||
}
|
||||
|
|
@ -477,6 +765,11 @@ func writeReviewQueueMarkdown(buf *bytes.Buffer, result ReviewQueueResult, lang
|
|||
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`, activity source `%s`\n", item.AgeHours, item.WaitingHours, item.Stale, item.WaitingOn, item.ReviewerCount, item.ActivitySource); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
|
|
@ -76,6 +79,80 @@ func TestAnalyzeReviewQueuePrioritizesRiskAndSize(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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")
|
||||
|
|
@ -108,6 +185,51 @@ func TestReadReviewQueueInputSupportsObjectAndArray(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReadReviewQueueFilesAcceptUTF8BOM(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inputPath := filepath.Join(dir, "queue-input.json")
|
||||
previousPath := filepath.Join(dir, "queue-previous.json")
|
||||
|
||||
inputJSON, err := json.Marshal(ReviewQueueInput{
|
||||
Repository: "owner/repo",
|
||||
PullRequests: []PRSummaryInput{{Number: 7, Title: "BOM input"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousJSON, err := json.Marshal(ReviewQueueResult{
|
||||
Repository: "owner/repo",
|
||||
Items: []ReviewQueueItem{{Number: 7, Title: "BOM previous"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, data := range map[string][]byte{
|
||||
inputPath: inputJSON,
|
||||
previousPath: previousJSON,
|
||||
} {
|
||||
if err := os.WriteFile(path, append([]byte{0xef, 0xbb, 0xbf}, data...), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
input, err := readReviewQueueInput(inputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readReviewQueueInput returned error: %v", err)
|
||||
}
|
||||
if input.Repository != "owner/repo" || len(input.PullRequests) != 1 || input.PullRequests[0].Number != 7 {
|
||||
t.Fatalf("input = %+v, want BOM-prefixed queue input", input)
|
||||
}
|
||||
|
||||
previous, err := readReviewQueueResult(previousPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readReviewQueueResult returned error: %v", err)
|
||||
}
|
||||
if previous.Repository != "owner/repo" || len(previous.Items) != 1 || previous.Items[0].Number != 7 {
|
||||
t.Fatalf("previous = %+v, want BOM-prefixed review queue result", previous)
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
|
|
@ -123,16 +245,30 @@ func TestFetchReviewQueuePullRequestsUsesReadOnlyQuery(t *testing.T) {
|
|||
t.Fatalf("limit query = %q, want 5", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"pulls": []map[string]interface{}{
|
||||
"issues": []map[string]interface{}{
|
||||
{
|
||||
"id": 101,
|
||||
"pull_request_id": 1001,
|
||||
"pull_request_number": 11,
|
||||
"title": "feat: remote review queue",
|
||||
"description": "Adds queue analysis.",
|
||||
"status": "open",
|
||||
"creator": map[string]interface{}{"login": "alice"},
|
||||
"name": "feat: remote review queue",
|
||||
"pull_request_staus": "open",
|
||||
"pull_request_status": 0,
|
||||
"issue": map[string]interface{}{"author": map[string]interface{}{"login": "alice"}},
|
||||
"pr_full_time": "2026-07-18T12:00:00.000Z",
|
||||
"pull_request_base": "master",
|
||||
"pull_request_head": "feature/review-queue",
|
||||
"review_state": "requested",
|
||||
"reviewers": []interface{}{map[string]interface{}{"login": "reviewer"}},
|
||||
"additions": 130,
|
||||
"deletions": 5,
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"pull_request_id": 1002,
|
||||
"pull_request_number": 10,
|
||||
"name": "closed PR returned by open endpoint",
|
||||
"pull_request_status": 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
|
@ -157,6 +293,38 @@ func TestFetchReviewQueuePullRequestsUsesReadOnlyQuery(t *testing.T) {
|
|||
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].IssueID != 101 || prs[0].Title != "feat: remote review queue" || prs[0].Author != "alice" || prs[0].BaseBranch != "master" || prs[0].HeadBranch != "feature/review-queue" || prs[0].CreatedAt.IsZero() {
|
||||
t.Fatalf("real GitLink fields not normalized: %+v", 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 TestAnalyzeReviewQueueInfersUnassignedOwnershipAndCreationFallback(t *testing.T) {
|
||||
asOf := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
|
||||
createdAt := asOf.Add(-96 * time.Hour)
|
||||
result := AnalyzeReviewQueue(ReviewQueueInput{
|
||||
Repository: "owner/repo",
|
||||
AsOf: asOf,
|
||||
StaleAfterHours: 72,
|
||||
PullRequests: []PRSummaryInput{{
|
||||
Number: 42,
|
||||
Title: "unassigned pull request",
|
||||
State: "open",
|
||||
CreatedAt: createdAt,
|
||||
}},
|
||||
}, "en")
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("items = %d, want 1", len(result.Items))
|
||||
}
|
||||
item := result.Items[0]
|
||||
if item.ReviewState != "unassigned" || item.WaitingOn != "maintainer" {
|
||||
t.Fatalf("ownership = state:%q waiting_on:%q, want unassigned/maintainer", item.ReviewState, item.WaitingOn)
|
||||
}
|
||||
if item.WaitingHours != 96 || !item.Stale || item.ActivitySource != "created_at_fallback" || !item.UpdatedAt.Equal(createdAt) {
|
||||
t.Fatalf("fallback freshness = waiting:%d stale:%t source:%q updated:%s", item.WaitingHours, item.Stale, item.ActivitySource, item.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderReviewQueueMarkdownAndTable(t *testing.T) {
|
||||
|
|
@ -192,3 +360,36 @@ func TestRenderReviewQueueMarkdownAndTable(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newPRSummaryShortcut(nil),
|
||||
newRepoReportShortcut(),
|
||||
newReviewContextShortcut(),
|
||||
newReviewQueueShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue