diff --git a/internal/infra/github/gh_command.go b/internal/infra/github/gh_command.go index 84d8c32..0013d7e 100644 --- a/internal/infra/github/gh_command.go +++ b/internal/infra/github/gh_command.go @@ -166,6 +166,16 @@ func GetSobaLabels() []CreateLabelRequest { Color: "e1e4e8", Description: "New issue awaiting processing", }, + { + Name: "soba:todo:high", + Color: "d73a4a", + Description: "High priority task", + }, + { + Name: "soba:todo:low", + Color: "cfd3d7", + Description: "Low priority task", + }, { Name: "soba:queued", Color: "fbca04", diff --git a/internal/infra/github/label_test.go b/internal/infra/github/label_test.go new file mode 100644 index 0000000..e88d7d2 --- /dev/null +++ b/internal/infra/github/label_test.go @@ -0,0 +1,74 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetSobaLabels(t *testing.T) { + labels := GetSobaLabels() + + // 既存のラベルが含まれていることを確認 + expectedLabels := map[string]bool{ + "soba:todo": false, + "soba:todo:high": false, + "soba:todo:low": false, + "soba:queued": false, + "soba:planning": false, + "soba:ready": false, + "soba:doing": false, + "soba:review-requested": false, + "soba:reviewing": false, + "soba:done": false, + "soba:requires-changes": false, + "soba:revising": false, + "soba:lgtm": false, + } + + // 全てのラベルが定義されていることを確認 + assert.Equal(t, len(expectedLabels), len(labels), "ラベル数が一致しません") + + // 各ラベルが正しく定義されていることを確認 + for _, label := range labels { + if _, exists := expectedLabels[label.Name]; exists { + expectedLabels[label.Name] = true + // ラベルの色とdescriptionが設定されていることを確認 + assert.NotEmpty(t, label.Color, "ラベル %s の色が設定されていません", label.Name) + assert.NotEmpty(t, label.Description, "ラベル %s の説明が設定されていません", label.Name) + } + } + + // 全ての期待されるラベルが見つかったか確認 + for name, found := range expectedLabels { + assert.True(t, found, "期待されるラベル %s が見つかりません", name) + } +} + +func TestGetSobaLabels_Priority(t *testing.T) { + labels := GetSobaLabels() + + // 優先度ラベルが正しく定義されているか確認 + priorityLabels := map[string]struct { + expectedColor string + expectedDesc string + }{ + "soba:todo:high": { + expectedColor: "d73a4a", // 赤系の色(高優先度) + expectedDesc: "High priority task", + }, + "soba:todo:low": { + expectedColor: "cfd3d7", // グレー系の色(低優先度) + expectedDesc: "Low priority task", + }, + } + + for _, label := range labels { + if expected, ok := priorityLabels[label.Name]; ok { + assert.Equal(t, expected.expectedColor, label.Color, + "ラベル %s の色が期待値と異なります", label.Name) + assert.Equal(t, expected.expectedDesc, label.Description, + "ラベル %s の説明が期待値と異なります", label.Name) + } + } +} \ No newline at end of file diff --git a/internal/service/queue_manager.go b/internal/service/queue_manager.go index d5d0d87..3cb4ff8 100644 --- a/internal/service/queue_manager.go +++ b/internal/service/queue_manager.go @@ -2,6 +2,7 @@ package service import ( "context" + "sort" "strings" "github.com/douhashi/soba/internal/infra/github" @@ -54,8 +55,8 @@ func (q *QueueManager) EnqueueNextIssue(ctx context.Context, issues []github.Iss return nil } - // 3. 最小番号のIssueを選択 - targetIssue := q.selectMinimumIssue(todoIssues) + // 3. 優先度順で最適なIssueを選択 + targetIssue := q.selectPriorityIssue(todoIssues) // 4. ラベル変更(soba:todo → soba:queued) q.logger.Info(ctx, "Enqueueing issue", logging.Field{Key: "issue", Value: targetIssue.Number}) @@ -196,3 +197,56 @@ func (q *QueueManager) hasSobaLabel(issue github.Issue) bool { } return false } + +// getPriority はIssueの優先度を判定する(0:高、1:通常、2:低) +func (q *QueueManager) getPriority(issue github.Issue) int { + hasHigh := false + hasLow := false + + for _, label := range issue.Labels { + if label.Name == "soba:todo:high" { + hasHigh = true + } else if label.Name == "soba:todo:low" { + hasLow = true + } + } + + // 複数の優先度ラベルがある場合は最高優先度を採用 + if hasHigh { + return 0 + } + if hasLow { + return 2 + } + return 1 // デフォルトは通常優先度 +} + +// sortByPriority はIssueを優先度順にソートする +func (q *QueueManager) sortByPriority(issues []github.Issue) []github.Issue { + sorted := make([]github.Issue, len(issues)) + copy(sorted, issues) + + sort.Slice(sorted, func(i, j int) bool { + priorityI := q.getPriority(sorted[i]) + priorityJ := q.getPriority(sorted[j]) + + if priorityI != priorityJ { + return priorityI < priorityJ // 優先度が高いほど値が小さい + } + + // 同じ優先度の場合はIssue番号順 + return sorted[i].Number < sorted[j].Number + }) + + return sorted +} + +// selectPriorityIssue は優先度を考慮して最適なIssueを選択する +func (q *QueueManager) selectPriorityIssue(issues []github.Issue) *github.Issue { + if len(issues) == 0 { + return nil + } + + sorted := q.sortByPriority(issues) + return &sorted[0] +} diff --git a/internal/service/queue_manager_priority_test.go b/internal/service/queue_manager_priority_test.go new file mode 100644 index 0000000..72a349f --- /dev/null +++ b/internal/service/queue_manager_priority_test.go @@ -0,0 +1,332 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/douhashi/soba/internal/infra/github" + "github.com/douhashi/soba/pkg/logging" +) + +func TestQueueManager_getPriority(t *testing.T) { + qm := &QueueManager{ + logger: logging.NewMockLogger(), + } + + tests := []struct { + name string + issue github.Issue + expected int + }{ + { + name: "高優先度のIssue", + issue: github.Issue{ + Number: 1, + Labels: []github.Label{ + {Name: "soba:todo:high"}, + {Name: "soba:todo"}, + }, + }, + expected: 0, // 最高優先度 + }, + { + name: "通常優先度のIssue", + issue: github.Issue{ + Number: 2, + Labels: []github.Label{ + {Name: "soba:todo"}, + }, + }, + expected: 1, // 通常優先度 + }, + { + name: "低優先度のIssue", + issue: github.Issue{ + Number: 3, + Labels: []github.Label{ + {Name: "soba:todo:low"}, + {Name: "soba:todo"}, + }, + }, + expected: 2, // 低優先度 + }, + { + name: "高優先度と低優先度の両方を持つIssue(最高優先度を採用)", + issue: github.Issue{ + Number: 4, + Labels: []github.Label{ + {Name: "soba:todo:high"}, + {Name: "soba:todo:low"}, + {Name: "soba:todo"}, + }, + }, + expected: 0, // 最高優先度を採用 + }, + { + name: "優先度ラベルがないIssue", + issue: github.Issue{ + Number: 5, + Labels: []github.Label{ + {Name: "bug"}, + }, + }, + expected: 1, // デフォルトは通常優先度 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := qm.getPriority(tt.issue) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestQueueManager_sortByPriority(t *testing.T) { + qm := &QueueManager{ + logger: logging.NewMockLogger(), + } + + issues := []github.Issue{ + { + Number: 5, + Labels: []github.Label{{Name: "soba:todo:low"}, {Name: "soba:todo"}}, + }, + { + Number: 2, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + { + Number: 4, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + { + Number: 1, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + { + Number: 3, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + { + Number: 6, + Labels: []github.Label{{Name: "soba:todo:low"}, {Name: "soba:todo"}}, + }, + } + + sorted := qm.sortByPriority(issues) + + // 優先度順に並んでいることを確認 + // 高優先度(Issue 1, 2) + assert.Equal(t, 1, sorted[0].Number) + assert.Equal(t, 2, sorted[1].Number) + + // 通常優先度(Issue 3, 4) + assert.Equal(t, 3, sorted[2].Number) + assert.Equal(t, 4, sorted[3].Number) + + // 低優先度(Issue 5, 6) + assert.Equal(t, 5, sorted[4].Number) + assert.Equal(t, 6, sorted[5].Number) +} + +func TestQueueManager_selectPriorityIssue(t *testing.T) { + qm := &QueueManager{ + logger: logging.NewMockLogger(), + } + + tests := []struct { + name string + issues []github.Issue + expectedNumber int + }{ + { + name: "高優先度のIssueが優先される", + issues: []github.Issue{ + { + Number: 10, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + { + Number: 20, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + { + Number: 30, + Labels: []github.Label{{Name: "soba:todo:low"}, {Name: "soba:todo"}}, + }, + }, + expectedNumber: 20, // 高優先度のIssue + }, + { + name: "同じ優先度の場合はIssue番号が小さいものが選ばれる", + issues: []github.Issue{ + { + Number: 15, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + { + Number: 10, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + { + Number: 20, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + }, + expectedNumber: 10, // 同じ高優先度の中で最小番号 + }, + { + name: "通常優先度のみの場合", + issues: []github.Issue{ + { + Number: 30, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + { + Number: 10, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + { + Number: 20, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + }, + expectedNumber: 10, // 最小番号 + }, + { + name: "低優先度のみの場合", + issues: []github.Issue{ + { + Number: 25, + Labels: []github.Label{{Name: "soba:todo:low"}, {Name: "soba:todo"}}, + }, + { + Number: 15, + Labels: []github.Label{{Name: "soba:todo:low"}, {Name: "soba:todo"}}, + }, + }, + expectedNumber: 15, // 低優先度の中で最小番号 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := qm.selectPriorityIssue(tt.issues) + assert.NotNil(t, result) + assert.Equal(t, tt.expectedNumber, result.Number) + }) + } +} + +func TestQueueManager_EnqueueNextIssue_WithPriority(t *testing.T) { + tests := []struct { + name string + issues []github.Issue + expectedIssueNum int + setupMock func(*MockQueueGitHubClient, int) + shouldEnqueue bool + }{ + { + name: "高優先度のIssueが通常優先度より先に処理される", + issues: []github.Issue{ + { + Number: 1, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + { + Number: 2, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + { + Number: 3, + Labels: []github.Label{{Name: "soba:todo:low"}, {Name: "soba:todo"}}, + }, + }, + expectedIssueNum: 2, // 高優先度のIssue + setupMock: func(m *MockQueueGitHubClient, issueNum int) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) + }, + shouldEnqueue: true, + }, + { + name: "高優先度が複数ある場合は番号が小さいものが優先", + issues: []github.Issue{ + { + Number: 5, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + { + Number: 3, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + { + Number: 1, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + }, + expectedIssueNum: 3, // 高優先度の中で最小番号 + setupMock: func(m *MockQueueGitHubClient, issueNum int) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) + }, + shouldEnqueue: true, + }, + { + name: "低優先度のみの場合でも正しく処理される", + issues: []github.Issue{ + { + Number: 10, + Labels: []github.Label{{Name: "soba:todo:low"}, {Name: "soba:todo"}}, + }, + { + Number: 5, + Labels: []github.Label{{Name: "soba:todo:low"}, {Name: "soba:todo"}}, + }, + }, + expectedIssueNum: 5, // 低優先度の中で最小番号 + setupMock: func(m *MockQueueGitHubClient, issueNum int) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) + }, + shouldEnqueue: true, + }, + { + name: "アクティブタスクがある場合は優先度に関わらずスキップ", + issues: []github.Issue{ + { + Number: 1, + Labels: []github.Label{{Name: "soba:planning"}}, + }, + { + Number: 2, + Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}, + }, + }, + expectedIssueNum: 0, + setupMock: func(m *MockQueueGitHubClient, issueNum int) {}, + shouldEnqueue: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockQueueGitHubClient) + if tt.shouldEnqueue { + tt.setupMock(mockClient, tt.expectedIssueNum) + } + + qm := NewQueueManager(mockClient, "owner", "repo") + qm.SetLogger(logging.NewMockLogger()) + + err := qm.EnqueueNextIssue(context.Background(), tt.issues) + assert.NoError(t, err) + + mockClient.AssertExpectations(t) + }) + } +}