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] =?UTF-8?q?feat(workflow):=20=E5=A2=9E=E5=8A=A0=20PR=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E9=98=9F=E5=88=97=E5=BF=AB=E7=85=A7=E5=B7=AE?= =?UTF-8?q?=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