forked from Gitlink/gitlink-cli
feat(workflow): 增加 PR 审查队列快照差异
This commit is contained in:
parent
d3bcbae82a
commit
fe16be4a64
|
|
@ -507,6 +507,7 @@ gitlink-cli search +users -k "zhangsan"
|
||||||
- `workflow +health`
|
- `workflow +health`
|
||||||
- `workflow +pr-summary`
|
- `workflow +pr-summary`
|
||||||
- `workflow +repo-report`
|
- `workflow +repo-report`
|
||||||
|
- `workflow +review-queue`
|
||||||
|
|
||||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||||
`workflow +repo-report` defaults to `markdown` 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
|
# Repository workflow report from a local JSON file
|
||||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
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:
|
Output formats:
|
||||||
|
|
@ -597,6 +603,7 @@ Safety:
|
||||||
- They do not depend on LLM APIs.
|
- They do not depend on LLM APIs.
|
||||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
- `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 +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
|
### Raw API
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` 通过。
|
||||||
|
|
@ -30,6 +30,28 @@ type ReviewQueueResult struct {
|
||||||
TopFocus []string `json:"top_focus"`
|
TopFocus []string `json:"top_focus"`
|
||||||
Recommendations []string `json:"recommendations"`
|
Recommendations []string `json:"recommendations"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
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 {
|
type ReviewQueueItem struct {
|
||||||
|
|
@ -57,6 +79,7 @@ func newReviewQueueShortcut() *common.Shortcut {
|
||||||
Description: "Prioritize open pull requests for maintainer review",
|
Description: "Prioritize open pull requests for maintainer review",
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "from", Usage: "Read review queue input from a JSON file"},
|
{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: "state", Usage: "Remote pull request state to fetch", Default: "open"},
|
||||||
{Name: "page", Short: "p", Usage: "Remote pull request page", Default: "1"},
|
{Name: "page", Short: "p", Usage: "Remote pull request page", Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Maximum pull requests to include", Default: "30"},
|
{Name: "limit", Short: "l", Usage: "Maximum pull requests to include", Default: "30"},
|
||||||
|
|
@ -73,6 +96,14 @@ func runReviewQueue(ctx *common.RuntimeContext) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result := AnalyzeReviewQueue(input, lang)
|
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
|
format := ctx.Format
|
||||||
if strings.TrimSpace(cmdutil.Format) == "" {
|
if strings.TrimSpace(cmdutil.Format) == "" {
|
||||||
format = "table"
|
format = "table"
|
||||||
|
|
@ -135,6 +166,18 @@ func readReviewQueueInput(path string) (ReviewQueueInput, error) {
|
||||||
return ReviewQueueInput{PullRequests: prs, Source: "local-json"}, nil
|
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) {
|
func fetchReviewQueuePullRequests(ctx *common.RuntimeContext, state string, page, limit int) ([]PRSummaryInput, string, string, error) {
|
||||||
owner, repo, err := resolveFetchRepo(ctx, "", "")
|
owner, repo, err := resolveFetchRepo(ctx, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -232,6 +275,97 @@ func AnalyzeReviewQueue(input ReviewQueueInput, lang string) ReviewQueueResult {
|
||||||
return result
|
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 {
|
func buildReviewQueueItem(pr PRSummaryInput, summary PRSummaryResult, lang string) ReviewQueueItem {
|
||||||
score, reasons := scoreReviewQueueItem(pr, summary)
|
score, reasons := scoreReviewQueueItem(pr, summary)
|
||||||
priority := "low"
|
priority := "low"
|
||||||
|
|
@ -455,6 +589,12 @@ func writeReviewQueueMarkdown(buf *bytes.Buffer, result ReviewQueueResult, lang
|
||||||
return err
|
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 {
|
if _, err := fmt.Fprintln(buf, "## Queue"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package workflow
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestReadReviewQueueInputSupportsObjectAndArray(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
objectPath := filepath.Join(dir, "queue_object.json")
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
newPRSummaryShortcut(nil),
|
newPRSummaryShortcut(nil),
|
||||||
newRepoReportShortcut(),
|
newRepoReportShortcut(),
|
||||||
newReviewContextShortcut(),
|
newReviewContextShortcut(),
|
||||||
|
newReviewQueueShortcut(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue