From fe16be4a64a6bcad986eb379d2e384819a59b659 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Mon, 20 Jul 2026 16:18:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(workflow):=20=E5=A2=9E=E5=8A=A0=20PR?= =?UTF-8?q?=20=E5=AE=A1=E6=9F=A5=E9=98=9F=E5=88=97=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=B7=AE=E5=BC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++ doc/changes/workflow-review-queue-delta.md | 34 +++++ shortcuts/workflow/review_queue.go | 140 +++++++++++++++++++++ shortcuts/workflow/review_queue_test.go | 60 +++++++++ shortcuts/workflow/workflow.go | 1 + 5 files changed, 242 insertions(+) create mode 100644 doc/changes/workflow-review-queue-delta.md diff --git a/README.md b/README.md index d48d41a..cff81e3 100644 --- a/README.md +++ b/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,11 @@ 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 ``` Output formats: @@ -597,6 +603,7 @@ 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. ### Raw API diff --git a/doc/changes/workflow-review-queue-delta.md b/doc/changes/workflow-review-queue-delta.md new file mode 100644 index 0000000..238d9fc --- /dev/null +++ b/doc/changes/workflow-review-queue-delta.md @@ -0,0 +1,34 @@ +# 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 编号匹配;没有编号的本地输入才回退到规范化标题。比较只读本地快照,不会写入远程仓库。 + +## 兼容性与验证 + +- 不传 `--previous` 时原有输出和优先级排序保持不变。 +- `changes` 是可选 JSON 字段,旧消费者可以忽略。 +- 非法快照会给出明确错误,不会静默生成不完整差异。 +- `go test ./shortcuts/workflow -run 'TestCompareReviewQueue|TestReadReviewQueueResult|TestRenderReviewQueueMarkdownIncludesChanges' -count=1` 通过。 +- `go build ./...` 和 `git diff --check` 通过。 diff --git a/shortcuts/workflow/review_queue.go b/shortcuts/workflow/review_queue.go index e094f98..33c2adb 100644 --- a/shortcuts/workflow/review_queue.go +++ b/shortcuts/workflow/review_queue.go @@ -30,6 +30,28 @@ type ReviewQueueResult struct { TopFocus []string `json:"top_focus"` Recommendations []string `json:"recommendations"` Source string `json:"source"` + 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 { @@ -57,6 +79,7 @@ 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"}, @@ -73,6 +96,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" @@ -135,6 +166,18 @@ 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) + } + 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 { @@ -232,6 +275,97 @@ func AnalyzeReviewQueue(input ReviewQueueInput, lang string) ReviewQueueResult { 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) ReviewQueueItem { score, reasons := scoreReviewQueueItem(pr, summary) priority := "low" @@ -455,6 +589,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 } diff --git a/shortcuts/workflow/review_queue_test.go b/shortcuts/workflow/review_queue_test.go index d2055cd..39e154d 100644 --- a/shortcuts/workflow/review_queue_test.go +++ b/shortcuts/workflow/review_queue_test.go @@ -3,6 +3,7 @@ package workflow import ( "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -76,6 +77,46 @@ 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 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") @@ -192,3 +233,22 @@ 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) + } +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index 696cfa9..b347be0 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -26,6 +26,7 @@ func Shortcuts() []*common.Shortcut { newPRSummaryShortcut(nil), newRepoReportShortcut(), newReviewContextShortcut(), + newReviewQueueShortcut(), } } -- 2.34.1 From 914df4b06aafcc6d9a11168e5367becdd9de15f4 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Mon, 20 Jul 2026 17:33:53 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(workflow):=20=E5=A2=9E=E5=BC=BA=20PR?= =?UTF-8?q?=20=E9=98=9F=E5=88=97=E7=AD=89=E5=BE=85=E4=B8=8E=E8=B4=A3?= =?UTF-8?q?=E4=BB=BB=E4=BF=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/changes/workflow-review-queue-delta.md | 6 +- shortcuts/workflow/pr_fetch.go | 75 +++++++-- shortcuts/workflow/pr_summary.go | 2 + shortcuts/workflow/review_queue.go | 169 +++++++++++++++++---- shortcuts/workflow/review_queue_test.go | 56 +++++++ 5 files changed, 264 insertions(+), 44 deletions(-) diff --git a/doc/changes/workflow-review-queue-delta.md b/doc/changes/workflow-review-queue-delta.md index 238d9fc..f4a218a 100644 --- a/doc/changes/workflow-review-queue-delta.md +++ b/doc/changes/workflow-review-queue-delta.md @@ -25,10 +25,14 @@ gitlink-cli workflow +review-queue \ `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` 时原有输出和优先级排序保持不变。 - `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` 通过。 diff --git a/shortcuts/workflow/pr_fetch.go b/shortcuts/workflow/pr_fetch.go index b70e3c9..d15468c 100644 --- a/shortcuts/workflow/pr_fetch.go +++ b/shortcuts/workflow/pr_fetch.go @@ -173,22 +173,26 @@ func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) 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 { diff --git a/shortcuts/workflow/pr_summary.go b/shortcuts/workflow/pr_summary.go index 7705401..38927ea 100644 --- a/shortcuts/workflow/pr_summary.go +++ b/shortcuts/workflow/pr_summary.go @@ -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"` diff --git a/shortcuts/workflow/review_queue.go b/shortcuts/workflow/review_queue.go index 33c2adb..d07c5b0 100644 --- a/shortcuts/workflow/review_queue.go +++ b/shortcuts/workflow/review_queue.go @@ -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,6 +33,8 @@ 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"` } @@ -55,22 +60,31 @@ type ReviewQueueDeltaItem struct { } 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"` + 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 { @@ -83,6 +97,8 @@ func newReviewQueueShortcut() *common.Shortcut { {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, @@ -117,6 +133,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 { @@ -125,6 +152,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") @@ -144,12 +177,25 @@ 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 { @@ -234,12 +280,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...) } @@ -254,11 +308,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 @@ -366,8 +422,15 @@ func sortReviewQueueDeltaItems(items []ReviewQueueDeltaItem, previousOnly bool) }) } -func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang string) ReviewQueueItem { - score, reasons := scoreReviewQueueItem(pr, summary) +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" @@ -390,10 +453,19 @@ 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, + 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 reasons := []string{} switch summary.RiskLevel { @@ -443,12 +515,42 @@ 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) 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)) { @@ -617,6 +719,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`\n", item.AgeHours, item.WaitingHours, item.Stale, item.WaitingOn, item.ReviewerCount); err != nil { + return err + } + } } return nil } diff --git a/shortcuts/workflow/review_queue_test.go b/shortcuts/workflow/review_queue_test.go index 39e154d..e366a05 100644 --- a/shortcuts/workflow/review_queue_test.go +++ b/shortcuts/workflow/review_queue_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/gitlink-org/gitlink-cli/internal/client" "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) { path := filepath.Join(t.TempDir(), "previous.json") if err := os.WriteFile(path, []byte("not-json"), 0o600); err != nil { @@ -171,6 +206,10 @@ func TestFetchReviewQueuePullRequestsUsesReadOnlyQuery(t *testing.T) { "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, }, @@ -198,6 +237,9 @@ 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].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) { @@ -252,3 +294,17 @@ func TestRenderReviewQueueMarkdownIncludesChanges(t *testing.T) { 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) + } +} -- 2.34.1 From 21603f519caa9d39cc660fee8ec48587fd9d3775 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 22 Jul 2026 17:33:02 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(workflow):=20=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E5=AE=A1=E6=9F=A5=E9=98=9F=E5=88=97=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E8=B4=A3=E4=BB=BB=E4=BF=A1=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/changes/workflow-review-queue-delta.md | 5 +- shortcuts/workflow/pr_fetch.go | 59 ++++++++++++++++++++-- shortcuts/workflow/review_queue.go | 46 +++++++++++++++-- shortcuts/workflow/review_queue_test.go | 53 ++++++++++++++++--- 4 files changed, 146 insertions(+), 17 deletions(-) diff --git a/doc/changes/workflow-review-queue-delta.md b/doc/changes/workflow-review-queue-delta.md index f4a218a..3159516 100644 --- a/doc/changes/workflow-review-queue-delta.md +++ b/doc/changes/workflow-review-queue-delta.md @@ -27,12 +27,15 @@ gitlink-cli workflow +review-queue \ ## 等待效率信号 -队列结果现在同时记录 `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 摘要。 +队列结果现在同时记录 `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` 通过。 diff --git a/shortcuts/workflow/pr_fetch.go b/shortcuts/workflow/pr_fetch.go index d15468c..a8fe351 100644 --- a/shortcuts/workflow/pr_fetch.go +++ b/shortcuts/workflow/pr_fetch.go @@ -157,17 +157,17 @@ 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"), @@ -338,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 != "" { @@ -345,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 "" } @@ -376,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 { diff --git a/shortcuts/workflow/review_queue.go b/shortcuts/workflow/review_queue.go index d07c5b0..8ae9d03 100644 --- a/shortcuts/workflow/review_queue.go +++ b/shortcuts/workflow/review_queue.go @@ -78,6 +78,7 @@ type ReviewQueueItem struct { 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"` @@ -254,6 +255,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) == "" { @@ -424,8 +428,10 @@ func sortReviewQueueDeltaItems(items []ReviewQueueDeltaItem, previousOnly bool) 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) @@ -437,6 +443,14 @@ func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang strin } 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, @@ -455,13 +469,14 @@ func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang strin ReviewFocus: summary.ReviewFocus, CreatedAt: pr.CreatedAt, UpdatedAt: updatedAt, + ActivitySource: activitySource, AgeHours: ageHours, WaitingHours: waitingHours, Stale: stale, - ReviewState: normalizeReviewQueueState(pr.ReviewState), + ReviewState: reviewState, Reviewers: append([]string(nil), pr.Reviewers...), ReviewerCount: len(pr.Reviewers), - WaitingOn: reviewQueueWaitingOn(pr.ReviewState), + WaitingOn: reviewQueueWaitingOn(reviewState, pr.Reviewers, pr.State), } } @@ -539,7 +554,7 @@ func normalizeReviewQueueState(value string) string { return strings.ToLower(strings.NewReplacer("-", "_", " ", "_").Replace(strings.TrimSpace(value))) } -func reviewQueueWaitingOn(state string) string { +func reviewQueueWaitingOn(state string, reviewers []string, prState string) string { switch normalizeReviewQueueState(state) { case "changes_requested", "changes_request", "request_changes": return "author" @@ -547,10 +562,33 @@ func reviewQueueWaitingOn(state string) string { 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)) { @@ -720,7 +758,7 @@ func writeReviewQueueMarkdown(buf *bytes.Buffer, result ReviewQueueResult, lang 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 { + 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 } } diff --git a/shortcuts/workflow/review_queue_test.go b/shortcuts/workflow/review_queue_test.go index e366a05..d177955 100644 --- a/shortcuts/workflow/review_queue_test.go +++ b/shortcuts/workflow/review_queue_test.go @@ -199,20 +199,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"}, - "created_at": "2026-07-18T12:00:00Z", - "updated_at": "2026-07-19T12:00:00Z", + "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, + }, }, }) })) @@ -237,11 +247,40 @@ 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) { result := AnalyzeReviewQueue(ReviewQueueInput{ Repository: "owner/repo", -- 2.34.1 From 2010f31b380134e2103a255e0de5a28e270c65b7 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Sun, 26 Jul 2026 10:05:54 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(workflow):=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=B8=A6=20BOM=20=E7=9A=84=E5=AE=A1=E6=9F=A5=E9=98=9F=E5=88=97?= =?UTF-8?q?=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 ++++- shortcuts/workflow/review_queue.go | 8 +++++ shortcuts/workflow/review_queue_test.go | 46 +++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cff81e3..bab8349 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,12 @@ gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report 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: @@ -603,7 +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. +- `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 diff --git a/shortcuts/workflow/review_queue.go b/shortcuts/workflow/review_queue.go index 8ae9d03..b827112 100644 --- a/shortcuts/workflow/review_queue.go +++ b/shortcuts/workflow/review_queue.go @@ -202,6 +202,7 @@ func readReviewQueueInput(path string) (ReviewQueueInput, error) { 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 @@ -218,6 +219,7 @@ func readReviewQueueResult(path string) (ReviewQueueResult, error) { 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) @@ -225,6 +227,12 @@ func readReviewQueueResult(path string) (ReviewQueueResult, error) { 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 { diff --git a/shortcuts/workflow/review_queue_test.go b/shortcuts/workflow/review_queue_test.go index d177955..c5cb3e8 100644 --- a/shortcuts/workflow/review_queue_test.go +++ b/shortcuts/workflow/review_queue_test.go @@ -1,6 +1,7 @@ package workflow import ( + "encoding/json" "net/http" "net/http/httptest" "os" @@ -184,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" { -- 2.34.1