From 93e6b0fe859150ce02143c4beb5d069892bf0214 Mon Sep 17 00:00:00 2001 From: Sho DOUHASHI Date: Sun, 9 Nov 2025 15:08:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20soba:todo=E5=84=AA=E5=85=88=E5=BA=A6?= =?UTF-8?q?=E3=83=A9=E3=83=99=E3=83=AB=E6=A9=9F=E8=83=BD=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - collectTodoIssuesメソッドを修正し、soba:todo:high/lowも収集対象に - hasActiveTaskメソッドを修正し、優先度ラベルを考慮 - updateTodoLabelsToQueuedメソッドを追加し、すべてのtodo系ラベルを削除 - 優先度機能の包括的なテストケースを追加 - 後方互換性を維持しながら優先度ベースのキューイングを実現 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/service/queue_manager.go | 119 ++++++----- .../service/queue_manager_collect_test.go | 142 +++++++++++++ .../service/queue_manager_priority_test.go | 3 + internal/service/queue_manager_update_test.go | 197 ++++++++++++++++++ 4 files changed, 404 insertions(+), 57 deletions(-) create mode 100644 internal/service/queue_manager_collect_test.go create mode 100644 internal/service/queue_manager_update_test.go diff --git a/internal/service/queue_manager.go b/internal/service/queue_manager.go index 3cb4ff8..8da10dc 100644 --- a/internal/service/queue_manager.go +++ b/internal/service/queue_manager.go @@ -58,9 +58,9 @@ func (q *QueueManager) EnqueueNextIssue(ctx context.Context, issues []github.Iss // 3. 優先度順で最適なIssueを選択 targetIssue := q.selectPriorityIssue(todoIssues) - // 4. ラベル変更(soba:todo → soba:queued) + // 4. ラベル変更(soba:todo系 → soba:queued) q.logger.Info(ctx, "Enqueueing issue", logging.Field{Key: "issue", Value: targetIssue.Number}) - err := q.updateLabels(ctx, targetIssue.Number, "soba:todo", "soba:queued") + err := q.updateTodoLabelsToQueued(ctx, targetIssue) if err != nil { q.logger.Info(ctx, "Queue management completed", logging.Field{Key: "result", Value: "failed"}, @@ -77,8 +77,11 @@ func (q *QueueManager) EnqueueNextIssue(ctx context.Context, issues []github.Iss // hasActiveTask はアクティブなタスクがあるかチェック func (q *QueueManager) hasActiveTask(issues []github.Issue) bool { for _, issue := range issues { - if q.hasSobaLabel(issue) && !q.hasLabel(issue, "soba:todo") { - return true // soba:todo以外のsobaラベルがある + if q.hasSobaLabel(issue) && + !q.hasLabel(issue, "soba:todo") && + !q.hasLabel(issue, "soba:todo:high") && + !q.hasLabel(issue, "soba:todo:low") { + return true // soba:todo系以外のsobaラベルがある } } return false @@ -88,7 +91,10 @@ func (q *QueueManager) hasActiveTask(issues []github.Issue) bool { func (q *QueueManager) collectTodoIssues(issues []github.Issue) []github.Issue { var todoIssues []github.Issue for _, issue := range issues { - if q.hasLabel(issue, "soba:todo") { + // soba:todo, soba:todo:high, soba:todo:low のいずれかがあれば収集 + if q.hasLabel(issue, "soba:todo") || + q.hasLabel(issue, "soba:todo:high") || + q.hasLabel(issue, "soba:todo:low") { todoIssues = append(todoIssues, issue) } } @@ -110,69 +116,68 @@ func (q *QueueManager) selectMinimumIssue(issues []github.Issue) *github.Issue { return &minIssue } -// updateLabels はラベルを更新する(削除→追加) -func (q *QueueManager) updateLabels(ctx context.Context, issueNumber int, removeLabel, addLabel string) error { - q.logger.Info(ctx, "Updating labels for queue management", - logging.Field{Key: "issue", Value: issueNumber}, - logging.Field{Key: "remove", Value: removeLabel}, - logging.Field{Key: "add", Value: addLabel}, +// updateTodoLabelsToQueued は todo系ラベルを削除して soba:queued を追加する +func (q *QueueManager) updateTodoLabelsToQueued(ctx context.Context, issue *github.Issue) error { + q.logger.Info(ctx, "Updating todo labels to queued", + logging.Field{Key: "issue", Value: issue.Number}, ) - // 古いラベルを削除 - if removeLabel != "" { - if err := q.client.RemoveLabelFromIssue(ctx, q.owner, q.repo, issueNumber, removeLabel); err != nil { - // エラーメッセージを解析して、ラベルが存在しない場合は警告として扱う - errMsg := err.Error() - if strings.Contains(strings.ToLower(errMsg), "not found") || - strings.Contains(strings.ToLower(errMsg), "404") || - strings.Contains(strings.ToLower(errMsg), "label does not exist") { - q.logger.Warn(ctx, "Label not found on issue, skipping removal", - logging.Field{Key: "issue", Value: issueNumber}, - logging.Field{Key: "label", Value: removeLabel}, - ) - // ラベルが存在しない場合はエラーとせず、処理を続行 + // todo系のラベルを削除 + todoLabels := []string{"soba:todo", "soba:todo:high", "soba:todo:low"} + for _, label := range todoLabels { + if q.hasLabel(*issue, label) { + if err := q.client.RemoveLabelFromIssue(ctx, q.owner, q.repo, issue.Number, label); err != nil { + // エラーメッセージを解析して、ラベルが存在しない場合は警告として扱う + errMsg := err.Error() + if strings.Contains(strings.ToLower(errMsg), "not found") || + strings.Contains(strings.ToLower(errMsg), "404") || + strings.Contains(strings.ToLower(errMsg), "label does not exist") { + q.logger.Warn(ctx, "Label not found on issue, skipping removal", + logging.Field{Key: "issue", Value: issue.Number}, + logging.Field{Key: "label", Value: label}, + ) + // ラベルが存在しない場合はエラーとせず、処理を続行 + } else { + q.logger.Error(ctx, "Failed to remove label", + logging.Field{Key: "error", Value: err.Error()}, + logging.Field{Key: "issue", Value: issue.Number}, + logging.Field{Key: "label", Value: label}, + ) + return errors.WrapInternal(err, "failed to remove label") + } } else { - q.logger.Error(ctx, "Failed to remove label", - logging.Field{Key: "error", Value: err.Error()}, - logging.Field{Key: "issue", Value: issueNumber}, - logging.Field{Key: "label", Value: removeLabel}, + q.logger.Info(ctx, "Successfully removed label from issue", + logging.Field{Key: "issue", Value: issue.Number}, + logging.Field{Key: "label", Value: label}, ) - return errors.WrapInternal(err, "failed to remove label") } - } else { - q.logger.Info(ctx, "Successfully removed label from issue", - logging.Field{Key: "issue", Value: issueNumber}, - logging.Field{Key: "label", Value: removeLabel}, - ) } } - // 新しいラベルを追加 - if addLabel != "" { - if err := q.client.AddLabelToIssue(ctx, q.owner, q.repo, issueNumber, addLabel); err != nil { - // エラーメッセージを解析して、既にラベルが存在する場合は警告として扱う - errMsg := err.Error() - if strings.Contains(strings.ToLower(errMsg), "already exists") || - strings.Contains(strings.ToLower(errMsg), "label already added") { - q.logger.Warn(ctx, "Label already exists on issue", - logging.Field{Key: "issue", Value: issueNumber}, - logging.Field{Key: "label", Value: addLabel}, - ) - // 既にラベルが存在する場合もエラーとせず、成功として扱う - } else { - q.logger.Error(ctx, "Failed to add label", - logging.Field{Key: "error", Value: err.Error()}, - logging.Field{Key: "issue", Value: issueNumber}, - logging.Field{Key: "label", Value: addLabel}, - ) - return errors.WrapInternal(err, "failed to add label") - } + // soba:queued を追加 + if err := q.client.AddLabelToIssue(ctx, q.owner, q.repo, issue.Number, "soba:queued"); err != nil { + // エラーメッセージを解析して、既にラベルが存在する場合は警告として扱う + errMsg := err.Error() + if strings.Contains(strings.ToLower(errMsg), "already exists") || + strings.Contains(strings.ToLower(errMsg), "label already added") { + q.logger.Warn(ctx, "Label already exists on issue", + logging.Field{Key: "issue", Value: issue.Number}, + logging.Field{Key: "label", Value: "soba:queued"}, + ) + // 既にラベルが存在する場合もエラーとせず、成功として扱う } else { - q.logger.Info(ctx, "Successfully added label to issue", - logging.Field{Key: "issue", Value: issueNumber}, - logging.Field{Key: "label", Value: addLabel}, + q.logger.Error(ctx, "Failed to add label", + logging.Field{Key: "error", Value: err.Error()}, + logging.Field{Key: "issue", Value: issue.Number}, + logging.Field{Key: "label", Value: "soba:queued"}, ) + return errors.WrapInternal(err, "failed to add label") } + } else { + q.logger.Info(ctx, "Successfully added label to issue", + logging.Field{Key: "issue", Value: issue.Number}, + logging.Field{Key: "label", Value: "soba:queued"}, + ) } return nil diff --git a/internal/service/queue_manager_collect_test.go b/internal/service/queue_manager_collect_test.go new file mode 100644 index 0000000..3695c38 --- /dev/null +++ b/internal/service/queue_manager_collect_test.go @@ -0,0 +1,142 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/douhashi/soba/internal/infra/github" + "github.com/douhashi/soba/pkg/logging" +) + +func TestQueueManager_collectTodoIssues_WithPriority(t *testing.T) { + qm := &QueueManager{ + logger: logging.NewMockLogger(), + } + + tests := []struct { + name string + issues []github.Issue + expected []int // Expected issue numbers + }{ + { + name: "soba:todo:highラベルを持つIssueも収集される", + issues: []github.Issue{ + {Number: 1, Labels: []github.Label{{Name: "soba:todo:high"}}}, + {Number: 2, Labels: []github.Label{{Name: "soba:todo"}}}, + {Number: 3, Labels: []github.Label{{Name: "soba:planning"}}}, + }, + expected: []int{1, 2}, + }, + { + name: "soba:todo:lowラベルを持つIssueも収集される", + issues: []github.Issue{ + {Number: 1, Labels: []github.Label{{Name: "soba:todo:low"}}}, + {Number: 2, Labels: []github.Label{{Name: "soba:todo"}}}, + {Number: 3, Labels: []github.Label{{Name: "bug"}}}, + }, + expected: []int{1, 2}, + }, + { + name: "soba:todo:highとsoba:todoの両方を持つIssueは1回だけ収集される", + issues: []github.Issue{ + {Number: 1, Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}}, + {Number: 2, Labels: []github.Label{{Name: "soba:todo"}}}, + }, + expected: []int{1, 2}, + }, + { + name: "優先度ラベルのみ(soba:todoなし)でも収集される", + issues: []github.Issue{ + {Number: 1, Labels: []github.Label{{Name: "soba:todo:high"}}}, + {Number: 2, Labels: []github.Label{{Name: "soba:todo:low"}}}, + {Number: 3, Labels: []github.Label{{Name: "soba:todo"}}}, + }, + expected: []int{1, 2, 3}, + }, + { + name: "優先度ラベル付きIssueが全て収集される", + issues: []github.Issue{ + {Number: 1, Labels: []github.Label{{Name: "soba:todo:high"}}}, + {Number: 2, Labels: []github.Label{{Name: "soba:todo"}}}, + {Number: 3, Labels: []github.Label{{Name: "soba:todo:low"}}}, + {Number: 4, Labels: []github.Label{{Name: "soba:planning"}}}, + {Number: 5, Labels: []github.Label{{Name: "soba:todo:high"}, {Name: "soba:todo"}}}, + }, + expected: []int{1, 2, 3, 5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := qm.collectTodoIssues(tt.issues) + + // Issue番号のリストを作成 + var resultNumbers []int + for _, issue := range result { + resultNumbers = append(resultNumbers, issue.Number) + } + + assert.Equal(t, tt.expected, resultNumbers) + }) + } +} + +func TestQueueManager_hasActiveTask_WithPriority(t *testing.T) { + qm := &QueueManager{ + logger: logging.NewMockLogger(), + } + + tests := []struct { + name string + issues []github.Issue + expected bool + }{ + { + name: "soba:todo:highのみはアクティブタスクではない", + issues: []github.Issue{ + {Labels: []github.Label{{Name: "soba:todo:high"}}}, + }, + expected: false, + }, + { + name: "soba:todo:lowのみはアクティブタスクではない", + issues: []github.Issue{ + {Labels: []github.Label{{Name: "soba:todo:low"}}}, + }, + expected: false, + }, + { + name: "soba:todo:highとsoba:planningが混在(アクティブタスクあり)", + issues: []github.Issue{ + {Labels: []github.Label{{Name: "soba:todo:high"}}}, + {Labels: []github.Label{{Name: "soba:planning"}}}, + }, + expected: true, + }, + { + name: "優先度ラベルのみの場合はアクティブタスクではない", + issues: []github.Issue{ + {Labels: []github.Label{{Name: "soba:todo:high"}}}, + {Labels: []github.Label{{Name: "soba:todo"}}}, + {Labels: []github.Label{{Name: "soba:todo:low"}}}, + }, + expected: false, + }, + { + name: "soba:queuedがある場合はアクティブタスクあり", + issues: []github.Issue{ + {Labels: []github.Label{{Name: "soba:todo:high"}}}, + {Labels: []github.Label{{Name: "soba:queued"}}}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := qm.hasActiveTask(tt.issues) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/service/queue_manager_priority_test.go b/internal/service/queue_manager_priority_test.go index 72a349f..671fba0 100644 --- a/internal/service/queue_manager_priority_test.go +++ b/internal/service/queue_manager_priority_test.go @@ -249,6 +249,7 @@ func TestQueueManager_EnqueueNextIssue_WithPriority(t *testing.T) { expectedIssueNum: 2, // 高優先度のIssue setupMock: func(m *MockQueueGitHubClient, issueNum int) { m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo").Return(nil) + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:high").Return(nil) m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) }, shouldEnqueue: true, @@ -272,6 +273,7 @@ func TestQueueManager_EnqueueNextIssue_WithPriority(t *testing.T) { expectedIssueNum: 3, // 高優先度の中で最小番号 setupMock: func(m *MockQueueGitHubClient, issueNum int) { m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo").Return(nil) + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:high").Return(nil) m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) }, shouldEnqueue: true, @@ -291,6 +293,7 @@ func TestQueueManager_EnqueueNextIssue_WithPriority(t *testing.T) { expectedIssueNum: 5, // 低優先度の中で最小番号 setupMock: func(m *MockQueueGitHubClient, issueNum int) { m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo").Return(nil) + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:low").Return(nil) m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) }, shouldEnqueue: true, diff --git a/internal/service/queue_manager_update_test.go b/internal/service/queue_manager_update_test.go new file mode 100644 index 0000000..17e1ee1 --- /dev/null +++ b/internal/service/queue_manager_update_test.go @@ -0,0 +1,197 @@ +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_updateTodoLabelsToQueued(t *testing.T) { + tests := []struct { + name string + issue github.Issue + setupMock func(*MockQueueGitHubClient, int) + wantError bool + }{ + { + name: "soba:todoラベルのみを削除", + issue: github.Issue{ + Number: 1, + Labels: []github.Label{{Name: "soba:todo"}}, + }, + 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) + }, + wantError: false, + }, + { + name: "soba:todo:highラベルを削除", + issue: github.Issue{ + Number: 2, + Labels: []github.Label{{Name: "soba:todo:high"}}, + }, + setupMock: func(m *MockQueueGitHubClient, issueNum int) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:high").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) + }, + wantError: false, + }, + { + name: "soba:todo:lowラベルを削除", + issue: github.Issue{ + Number: 3, + Labels: []github.Label{{Name: "soba:todo:low"}}, + }, + setupMock: func(m *MockQueueGitHubClient, issueNum int) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:low").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) + }, + wantError: false, + }, + { + name: "複数のtodo系ラベルを全て削除", + issue: github.Issue{ + Number: 4, + Labels: []github.Label{ + {Name: "soba:todo:high"}, + {Name: "soba:todo"}, + }, + }, + setupMock: func(m *MockQueueGitHubClient, issueNum int) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo").Return(nil) + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:high").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) + }, + wantError: false, + }, + { + name: "todo系ラベルとその他のラベルが混在", + issue: github.Issue{ + Number: 5, + Labels: []github.Label{ + {Name: "soba:todo:low"}, + {Name: "bug"}, + {Name: "enhancement"}, + }, + }, + setupMock: func(m *MockQueueGitHubClient, issueNum int) { + // bugとenhancementラベルは削除されない + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:low").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) + }, + wantError: false, + }, + { + name: "すべてのtodo系ラベルを削除", + issue: github.Issue{ + Number: 6, + Labels: []github.Label{ + {Name: "soba:todo:high"}, + {Name: "soba:todo"}, + {Name: "soba:todo:low"}, + }, + }, + setupMock: func(m *MockQueueGitHubClient, issueNum int) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo").Return(nil) + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:high").Return(nil) + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", issueNum, "soba:todo:low").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", issueNum, "soba:queued").Return(nil) + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockQueueGitHubClient) + tt.setupMock(mockClient, tt.issue.Number) + + qm := NewQueueManager(mockClient, "owner", "repo") + qm.SetLogger(logging.NewMockLogger()) + + err := qm.updateTodoLabelsToQueued(context.Background(), &tt.issue) + + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestQueueManager_EnqueueNextIssue_RemovePriorityLabels(t *testing.T) { + tests := []struct { + name string + issues []github.Issue + setupMock func(*MockQueueGitHubClient) + }{ + { + name: "soba:todo:highラベルが削除されてsoba:queuedが追加される", + issues: []github.Issue{ + { + Number: 1, + Labels: []github.Label{{Name: "soba:todo:high"}}, + }, + }, + setupMock: func(m *MockQueueGitHubClient) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", 1, "soba:todo:high").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", 1, "soba:queued").Return(nil) + }, + }, + { + name: "soba:todo:lowラベルが削除されてsoba:queuedが追加される", + issues: []github.Issue{ + { + Number: 2, + Labels: []github.Label{{Name: "soba:todo:low"}}, + }, + }, + setupMock: func(m *MockQueueGitHubClient) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", 2, "soba:todo:low").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", 2, "soba:queued").Return(nil) + }, + }, + { + name: "高優先度Issue(複数ラベル)のすべてのtodo系ラベルが削除される", + issues: []github.Issue{ + { + Number: 3, + Labels: []github.Label{ + {Name: "soba:todo:high"}, + {Name: "soba:todo"}, + {Name: "bug"}, + }, + }, + }, + setupMock: func(m *MockQueueGitHubClient) { + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", 3, "soba:todo").Return(nil) + m.On("RemoveLabelFromIssue", mock.Anything, "owner", "repo", 3, "soba:todo:high").Return(nil) + m.On("AddLabelToIssue", mock.Anything, "owner", "repo", 3, "soba:queued").Return(nil) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockQueueGitHubClient) + tt.setupMock(mockClient) + + qm := NewQueueManager(mockClient, "owner", "repo") + qm.SetLogger(logging.NewMockLogger()) + + err := qm.EnqueueNextIssue(context.Background(), tt.issues) + assert.NoError(t, err) + + mockClient.AssertExpectations(t) + }) + } +}