Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
1871 lines
54 KiB
Go
1871 lines
54 KiB
Go
package workspaceissues
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestServiceCreateIssueAndTaskProjection(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Checkout flow",
|
|
Content: "Plan the checkout work",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
if issue.IssueID == "" || issue.Status != StatusNotStarted {
|
|
t.Fatalf("created issue = %+v", issue)
|
|
}
|
|
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Wire payment form",
|
|
Priority: "urgent",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
if task.Priority != PriorityMedium {
|
|
t.Fatalf("task priority = %q, want %q", task.Priority, PriorityMedium)
|
|
}
|
|
if task.SortIndex != 1 {
|
|
t.Fatalf("task sort index = %d, want 1", task.SortIndex)
|
|
}
|
|
|
|
detail, err := service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
if len(detail.Tasks) != 1 {
|
|
t.Fatalf("detail tasks len = %d, want 1", len(detail.Tasks))
|
|
}
|
|
if detail.Issue.TaskCount != 1 || detail.Issue.NotStartedCount != 1 {
|
|
t.Fatalf("issue projection = %+v", detail.Issue)
|
|
}
|
|
}
|
|
|
|
func TestServiceCreateTasksAppendsInInputOrder(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "AgentGUI stability",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
|
|
first, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Existing baseline",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
if first.SortIndex != 1 {
|
|
t.Fatalf("first sort index = %d, want 1", first.SortIndex)
|
|
}
|
|
|
|
created, err := service.CreateTasks(ctx, CreateTasksInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Tasks: []CreateTaskItemInput{
|
|
{TaskID: "task-a", Title: "2. Define metrics"},
|
|
{TaskID: "task-b", Title: "3. Build checks"},
|
|
{TaskID: "task-c", Title: "4. Document baseline"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTasks() error = %v", err)
|
|
}
|
|
if len(created) != 3 {
|
|
t.Fatalf("created tasks len = %d, want 3", len(created))
|
|
}
|
|
for index, task := range created {
|
|
if task.SortIndex != index+2 {
|
|
t.Fatalf("created[%d].SortIndex = %d, want %d", index, task.SortIndex, index+2)
|
|
}
|
|
}
|
|
|
|
detail, err := service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
titles := make([]string, 0, len(detail.Tasks))
|
|
for _, task := range detail.Tasks {
|
|
titles = append(titles, task.Title)
|
|
}
|
|
want := []string{"Existing baseline", "2. Define metrics", "3. Build checks", "4. Document baseline"}
|
|
if strings.Join(titles, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("task titles = %#v, want %#v", titles, want)
|
|
}
|
|
}
|
|
|
|
func TestServiceUpdateIssueStatus(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Say hello",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
|
|
updated, err := service.UpdateIssue(ctx, UpdateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Status: string(StatusCompleted),
|
|
HasStatus: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateIssue() error = %v", err)
|
|
}
|
|
if updated.Status != StatusCompleted {
|
|
t.Fatalf("status = %q, want %q", updated.Status, StatusCompleted)
|
|
}
|
|
}
|
|
|
|
type managedMutationDetails interface {
|
|
error
|
|
ManagedIssueID() string
|
|
ManagedSourceSessionID() string
|
|
}
|
|
|
|
func TestServiceRejectsEveryGenericManagedIssueMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
testCases := []struct {
|
|
name string
|
|
mutate func(Service, *fakeStore, Issue, Task) error
|
|
}{
|
|
{
|
|
name: "issue update",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, _ Task) error {
|
|
_, err := service.UpdateIssue(ctx, UpdateIssueInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ActorUserID: "user-1", Title: "changed", HasTitle: true,
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "issue dispatch mutation",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, _ Task) error {
|
|
_, err := service.UpdateIssue(ctx, UpdateIssueInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ActorUserID: "user-1", DispatchPaused: true, HasDispatchPaused: true,
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "issue execution profile mutation",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, _ Task) error {
|
|
_, err := service.UpdateIssue(ctx, UpdateIssueInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ActorUserID: "user-1", HasExecutionProfile: true,
|
|
ExecutionProfile: ExecutionProfile{
|
|
ReasoningIntensity: 2, OrchestrationIntensity: 2,
|
|
},
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "issue budget mutation",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, _ Task) error {
|
|
_, err := service.UpdateIssue(ctx, UpdateIssueInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ActorUserID: "user-1", HasBudget: true, Budget: DefaultBudget(),
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "issue delete",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, _ Task) error {
|
|
_, err := service.DeleteIssue(ctx, issue.WorkspaceID, issue.IssueID, "user-1")
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task create",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, _ Task) error {
|
|
_, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ActorUserID: "user-1", Title: "new task",
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task batch create",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, _ Task) error {
|
|
_, err := service.CreateTasks(ctx, CreateTasksInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Tasks: []CreateTaskItemInput{{TaskID: "task-new", Title: "new task"}},
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task update",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, task Task) error {
|
|
_, err := service.UpdateTask(ctx, UpdateTaskInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
TaskID: task.TaskID, ActorUserID: "user-1",
|
|
Title: "changed", HasTitle: true,
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task reorder",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, task Task) error {
|
|
_, err := service.UpdateTask(ctx, UpdateTaskInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
TaskID: task.TaskID, ActorUserID: "user-1",
|
|
SortIndex: 2, HasSortIndex: true,
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task accept",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, task Task) error {
|
|
_, err := service.UpdateTask(ctx, UpdateTaskInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
TaskID: task.TaskID, ActorUserID: "user-1",
|
|
Status: string(StatusCompleted), HasStatus: true,
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task rework",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, task Task) error {
|
|
_, err := service.UpdateTask(ctx, UpdateTaskInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
TaskID: task.TaskID, ActorUserID: "user-1",
|
|
Status: string(StatusNotStarted), HasStatus: true,
|
|
AcceptanceState: string(AcceptanceAgentClaimed),
|
|
HasAcceptanceState: true,
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task delete",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, task Task) error {
|
|
_, err := service.DeleteTask(
|
|
ctx, issue.WorkspaceID, issue.IssueID, task.TaskID, "user-1",
|
|
)
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "issue context add",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, _ Task) error {
|
|
_, err := service.AddContextRefs(ctx, AddContextRefsInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ParentKind: string(ContextRefParentIssue),
|
|
Refs: []AddContextRefInput{{Path: "/tmp/context"}},
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task context add",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, task Task) error {
|
|
_, err := service.AddContextRefs(ctx, AddContextRefsInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
TaskID: task.TaskID, ParentKind: string(ContextRefParentTask),
|
|
Refs: []AddContextRefInput{{Path: "/tmp/context"}},
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "issue context remove",
|
|
mutate: func(service Service, store *fakeStore, issue Issue, _ Task) error {
|
|
_, _ = store.AddContextRefs(ctx, []ContextRef{{
|
|
ContextRefID: "ref-1", WorkspaceID: issue.WorkspaceID,
|
|
IssueID: issue.IssueID, ParentKind: ContextRefParentIssue,
|
|
}})
|
|
_, err := service.RemoveContextRef(ctx, RemoveContextRefInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ParentKind: string(ContextRefParentIssue), ContextRefID: "ref-1",
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "task context remove",
|
|
mutate: func(service Service, store *fakeStore, issue Issue, task Task) error {
|
|
_, _ = store.AddContextRefs(ctx, []ContextRef{{
|
|
ContextRefID: "ref-1", WorkspaceID: issue.WorkspaceID,
|
|
IssueID: issue.IssueID, TaskID: task.TaskID,
|
|
ParentKind: ContextRefParentTask,
|
|
}})
|
|
_, err := service.RemoveContextRef(ctx, RemoveContextRefInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
TaskID: task.TaskID, ParentKind: string(ContextRefParentTask),
|
|
ContextRefID: "ref-1",
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
{
|
|
name: "run create",
|
|
mutate: func(service Service, _ *fakeStore, issue Issue, task Task) error {
|
|
_, err := service.CreateRun(ctx, CreateRunInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
TaskID: task.TaskID, ActorUserID: "user-1",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
issue := Issue{
|
|
IssueID: "managed-issue", WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID, Title: "Managed",
|
|
PlanningSource: PlanningSourceTuttiModePlan,
|
|
SourceSessionID: "source-session",
|
|
}
|
|
if _, err := store.CreateIssue(ctx, issue); err != nil {
|
|
t.Fatalf("CreateIssue fixture error = %v", err)
|
|
}
|
|
task := Task{
|
|
TaskID: "task-1", IssueID: issue.IssueID, WorkspaceID: issue.WorkspaceID,
|
|
Title: "Task", Status: StatusNotStarted, Priority: PriorityMedium,
|
|
AcceptanceState: AcceptanceAgentClaimed,
|
|
}
|
|
tasks, err := store.AppendTasks(ctx, []Task{task})
|
|
if err != nil {
|
|
t.Fatalf("AppendTasks fixture error = %v", err)
|
|
}
|
|
err = testCase.mutate(service, store, issue, tasks[0])
|
|
if err == nil {
|
|
t.Fatal("generic managed mutation error = nil")
|
|
}
|
|
var details managedMutationDetails
|
|
if !errors.As(err, &details) {
|
|
t.Fatalf("generic managed mutation error = %T %v, want typed details", err, err)
|
|
}
|
|
if details.ManagedIssueID() != issue.IssueID ||
|
|
details.ManagedSourceSessionID() != issue.SourceSessionID {
|
|
t.Fatalf("managed mutation details = (%q, %q)",
|
|
details.ManagedIssueID(), details.ManagedSourceSessionID())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceLeavesManualAndTraditionalIssueMutationBehaviorUnchanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
for _, source := range []PlanningSource{
|
|
PlanningSourceManual,
|
|
PlanningSourceTraditionalPlan,
|
|
} {
|
|
t.Run(string(source), func(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
issue, err := store.CreateIssue(ctx, Issue{
|
|
IssueID: "issue-" + string(source), WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID, Title: "Mutable", PlanningSource: source,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue fixture error = %v", err)
|
|
}
|
|
updated, err := service.UpdateIssue(ctx, UpdateIssueInput{
|
|
WorkspaceID: issue.WorkspaceID, IssueID: issue.IssueID,
|
|
ActorUserID: "user-1", Title: "changed", HasTitle: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateIssue() error = %v", err)
|
|
}
|
|
if updated.Title != "changed" {
|
|
t.Fatalf("updated title = %q", updated.Title)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceDeleteTopic(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
topic, err := service.CreateTopic(ctx, CreateTopicInput{
|
|
WorkspaceID: "workspace-1",
|
|
ActorUserID: "user-1",
|
|
Title: "Launch plan",
|
|
Summary: "Track launch work",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTopic() error = %v", err)
|
|
}
|
|
|
|
removed, err := service.DeleteTopic(ctx, "workspace-1", topic.TopicID, "user-1")
|
|
if err != nil {
|
|
t.Fatalf("DeleteTopic() error = %v", err)
|
|
}
|
|
if !removed {
|
|
t.Fatal("DeleteTopic() removed = false, want true")
|
|
}
|
|
if _, err := store.GetTopic(ctx, "workspace-1", topic.TopicID); !errors.Is(err, ErrTopicNotFound) {
|
|
t.Fatalf("GetTopic() after delete error = %v, want ErrTopicNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceDeleteTopicRejectsDefaultAndNonEmptyTopics(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
if _, err := service.DeleteTopic(ctx, "workspace-1", DefaultTopicID, "user-1"); !errors.Is(err, ErrInvalidArgument) {
|
|
t.Fatalf("DeleteTopic(default) error = %v, want ErrInvalidArgument", err)
|
|
}
|
|
|
|
topic, err := service.CreateTopic(ctx, CreateTopicInput{
|
|
WorkspaceID: "workspace-1",
|
|
ActorUserID: "user-1",
|
|
Title: "Refactor",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTopic() error = %v", err)
|
|
}
|
|
if _, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: topic.TopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Refactor sidebar",
|
|
}); err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
|
|
removed, err := service.DeleteTopic(ctx, "workspace-1", topic.TopicID, "user-1")
|
|
if !errors.Is(err, ErrTopicNotEmpty) {
|
|
t.Fatalf("DeleteTopic(non-empty) error = %v, want ErrTopicNotEmpty", err)
|
|
}
|
|
if removed {
|
|
t.Fatal("DeleteTopic(non-empty) removed = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestServiceRunLifecycleTransitionsTaskAndIssue(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Editor polish",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Fix selection highlight",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
|
|
run, err := service.CreateRun(ctx, CreateRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
AgentProvider: "Codex",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() error = %v", err)
|
|
}
|
|
if run.Status != StatusRunning || run.AgentProvider != "codex" {
|
|
t.Fatalf("created run = %+v", run)
|
|
}
|
|
runningTask, err := store.GetTask(ctx, "workspace-1", issue.IssueID, task.TaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTask() error = %v", err)
|
|
}
|
|
if runningTask.Status != StatusRunning || runningTask.LatestRunID != run.RunID {
|
|
t.Fatalf("running task = %+v", runningTask)
|
|
}
|
|
|
|
completed, outputs, err := service.CompleteRun(ctx, CompleteRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: "completed",
|
|
Summary: "Ready for review",
|
|
Outputs: []CompleteRunOutputInput{{
|
|
Path: "/workspace/out/report.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteRun() error = %v", err)
|
|
}
|
|
if completed.Status != StatusCompleted || completed.CompletedAtUnixMS == 0 {
|
|
t.Fatalf("completed run = %+v", completed)
|
|
}
|
|
if len(outputs) != 1 || outputs[0].DisplayName != "report.md" {
|
|
t.Fatalf("outputs = %+v", outputs)
|
|
}
|
|
detail, err := service.GetTaskDetail(ctx, "workspace-1", issue.IssueID, task.TaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskDetail() error = %v", err)
|
|
}
|
|
if detail.Task.Status != StatusPendingAcceptance {
|
|
t.Fatalf("task status = %q, want %q", detail.Task.Status, StatusPendingAcceptance)
|
|
}
|
|
issueDetail, err := service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
if issueDetail.Issue.PendingAcceptanceCount != 1 || issueDetail.Issue.RunningCount != 0 {
|
|
t.Fatalf("issue projection = %+v", issueDetail.Issue)
|
|
}
|
|
}
|
|
|
|
func TestServiceCreateRunDerivesProviderFromAgentTargetID(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Editor polish",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Fix selection highlight",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
|
|
run, err := service.CreateRun(ctx, CreateRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() error = %v", err)
|
|
}
|
|
if run.AgentProvider != "codex" {
|
|
t.Fatalf("agent provider = %q, want codex", run.AgentProvider)
|
|
}
|
|
}
|
|
|
|
func TestServiceGetIssueDetailIncludesOutputsFromAllIssueTasks(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Collect all outputs",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
firstTask, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Main task",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask(first) error = %v", err)
|
|
}
|
|
secondTask, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Child task",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask(second) error = %v", err)
|
|
}
|
|
for _, item := range []struct {
|
|
taskID string
|
|
runID string
|
|
path string
|
|
}{
|
|
{taskID: firstTask.TaskID, runID: "run-main", path: "/workspace/main.md"},
|
|
{taskID: secondTask.TaskID, runID: "run-child", path: "/workspace/child.md"},
|
|
} {
|
|
run, err := service.CreateRun(ctx, CreateRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: item.taskID,
|
|
RunID: item.runID,
|
|
ActorUserID: "user-1",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun(%s) error = %v", item.runID, err)
|
|
}
|
|
if _, _, err := service.CompleteRun(ctx, CompleteRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: run.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: "completed",
|
|
Summary: "Done",
|
|
Outputs: []CompleteRunOutputInput{{
|
|
Path: item.path,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("CompleteRun(%s) error = %v", item.runID, err)
|
|
}
|
|
}
|
|
|
|
detail, err := service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
paths := make([]string, 0, len(detail.LatestOutputs))
|
|
for _, output := range detail.LatestOutputs {
|
|
paths = append(paths, output.Path)
|
|
}
|
|
sort.Strings(paths)
|
|
want := []string{"/workspace/child.md", "/workspace/main.md"}
|
|
if strings.Join(paths, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("issue detail outputs = %+v, want paths %v", detail.LatestOutputs, want)
|
|
}
|
|
}
|
|
|
|
func TestServiceGetIssueDetailDeduplicatesOutputsByPath(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Regenerate output",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Write package",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
for _, item := range []struct {
|
|
displayName string
|
|
runID string
|
|
}{
|
|
{runID: "run-old", displayName: "old.md"},
|
|
{runID: "run-new", displayName: "new.md"},
|
|
} {
|
|
run, err := service.CreateRun(ctx, CreateRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
RunID: item.runID,
|
|
ActorUserID: "user-1",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun(%s) error = %v", item.runID, err)
|
|
}
|
|
if _, _, err := service.CompleteRun(ctx, CompleteRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: run.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: "completed",
|
|
Summary: "Done",
|
|
Outputs: []CompleteRunOutputInput{{
|
|
DisplayName: item.displayName,
|
|
Path: "/workspace/package.md",
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("CompleteRun(%s) error = %v", item.runID, err)
|
|
}
|
|
}
|
|
|
|
detail, err := service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
if len(detail.LatestOutputs) != 1 {
|
|
t.Fatalf("issue detail outputs = %+v, want one deduplicated output", detail.LatestOutputs)
|
|
}
|
|
if detail.LatestOutputs[0].RunID != "run-new" || detail.LatestOutputs[0].DisplayName != "new.md" {
|
|
t.Fatalf("issue detail output = %+v, want latest run output", detail.LatestOutputs[0])
|
|
}
|
|
}
|
|
|
|
func TestServiceCompleteRunDoesNotOverwriteTerminalRun(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Keep original result",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Ship once",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
run, err := service.CreateRun(ctx, CreateRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() error = %v", err)
|
|
}
|
|
completed, outputs, err := service.CompleteRun(ctx, CompleteRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: "completed",
|
|
Summary: "Original summary",
|
|
Outputs: []CompleteRunOutputInput{{
|
|
Path: "/workspace/original.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteRun() error = %v", err)
|
|
}
|
|
repeated, repeatedOutputs, err := service.CompleteRun(ctx, CompleteRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: "failed",
|
|
Summary: "Overwritten summary",
|
|
ErrorMessage: "should not win",
|
|
Outputs: []CompleteRunOutputInput{{
|
|
Path: "/workspace/overwritten.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteRun() repeated error = %v", err)
|
|
}
|
|
if repeated.Status != completed.Status || repeated.Summary != "Original summary" || repeated.ErrorMessage != "" {
|
|
t.Fatalf("repeated run = %+v, want original terminal run", repeated)
|
|
}
|
|
if len(repeatedOutputs) != len(outputs) || repeatedOutputs[0].Path != "/workspace/original.md" {
|
|
t.Fatalf("repeated outputs = %+v, want original outputs %+v", repeatedOutputs, outputs)
|
|
}
|
|
}
|
|
|
|
func TestServiceUpdateTaskRejectsPendingAcceptanceOnAcceptedTask(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Guard acceptance",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Accept once",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
accepted, err := service.UpdateTask(ctx, UpdateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
Status: string(StatusCompleted),
|
|
HasStatus: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateTask(accept) error = %v", err)
|
|
}
|
|
if accepted.AcceptanceState != AcceptanceUserAccepted {
|
|
t.Fatalf("accepted task acceptance = %q, want user_accepted", accepted.AcceptanceState)
|
|
}
|
|
if _, err := service.UpdateTask(ctx, UpdateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
Status: string(StatusPendingAcceptance),
|
|
HasStatus: true,
|
|
}); !errors.Is(err, ErrInvalidArgument) {
|
|
t.Fatalf("UpdateTask(pending_acceptance on accepted) error = %v, want ErrInvalidArgument", err)
|
|
}
|
|
// Rework stays the explicit path back into execution.
|
|
reworked, err := service.UpdateTask(ctx, UpdateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ActorUserID: "user-1",
|
|
Status: string(StatusNotStarted),
|
|
HasStatus: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateTask(rework) error = %v", err)
|
|
}
|
|
if reworked.AcceptanceState != AcceptanceAgentClaimed {
|
|
t.Fatalf("reworked task acceptance = %q, want agent_claimed", reworked.AcceptanceState)
|
|
}
|
|
}
|
|
|
|
func TestServiceRunLifecycleTransitionsIssueWithoutTasks(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Say hello",
|
|
Content: "Say hello to me",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
|
|
run, err := service.CreateRun(ctx, CreateRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-issue-1",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() error = %v", err)
|
|
}
|
|
if run.TaskID == "" {
|
|
t.Fatal("run task id is empty, want generated task id")
|
|
}
|
|
if run.OutputDir != "" {
|
|
t.Fatalf("run output dir = %q, want empty", run.OutputDir)
|
|
}
|
|
|
|
detail, err := service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
if detail.Issue.Status != StatusRunning || detail.Issue.TaskCount != 1 || detail.Issue.RunningCount != 1 {
|
|
t.Fatalf("running issue detail = %+v", detail.Issue)
|
|
}
|
|
if len(detail.Tasks) != 1 || detail.Tasks[0].TaskID != run.TaskID {
|
|
t.Fatalf("detail tasks = %+v, run task id = %q", detail.Tasks, run.TaskID)
|
|
}
|
|
|
|
completed, outputs, err := service.CompleteRun(ctx, CompleteRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: run.TaskID,
|
|
RunID: run.RunID,
|
|
ActorUserID: "user-1",
|
|
Status: "completed",
|
|
Summary: "Ready for review",
|
|
Outputs: []CompleteRunOutputInput{{
|
|
Path: "/Users/test/project/summary.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CompleteRun() error = %v", err)
|
|
}
|
|
if completed.TaskID != run.TaskID || completed.Status != StatusCompleted {
|
|
t.Fatalf("completed run = %+v", completed)
|
|
}
|
|
if len(outputs) != 1 || outputs[0].TaskID != run.TaskID {
|
|
t.Fatalf("outputs = %+v", outputs)
|
|
}
|
|
|
|
detail, err = service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() after complete error = %v", err)
|
|
}
|
|
if detail.Issue.Status != StatusPendingAcceptance || detail.Issue.TaskCount != 1 {
|
|
t.Fatalf("completed issue detail = %+v", detail.Issue)
|
|
}
|
|
if len(detail.LatestOutputs) != 1 || detail.LatestOutputs[0].TaskID != run.TaskID {
|
|
t.Fatalf("issue latest outputs = %+v", detail.LatestOutputs)
|
|
}
|
|
}
|
|
|
|
func TestServiceCreateIssueRunCreatesTaskRunWhenIssueHasNoTasks(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Generate story",
|
|
Content: "Write a story",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
|
|
run, err := service.CreateRun(ctx, CreateRunInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-issue-1",
|
|
AgentProvider: "codex",
|
|
AgentTargetID: "local:codex",
|
|
AgentSessionID: "agent-session-1",
|
|
ExecutionDirectory: "/Users/test/project",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRun() error = %v", err)
|
|
}
|
|
if run.TaskID == "" {
|
|
t.Fatalf("run task id is empty, want generated task id")
|
|
}
|
|
if run.OutputDir != "" {
|
|
t.Fatalf("run output dir = %q, want empty", run.OutputDir)
|
|
}
|
|
if run.ExecutionDirectory != "/Users/test/project" {
|
|
t.Fatalf("run execution directory = %q", run.ExecutionDirectory)
|
|
}
|
|
|
|
detail, err := service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
if detail.Issue.Status != StatusRunning || detail.Issue.TaskCount != 1 || detail.Issue.RunningCount != 1 {
|
|
t.Fatalf("running issue projection = %+v", detail.Issue)
|
|
}
|
|
if len(detail.Tasks) != 1 || detail.Tasks[0].TaskID != run.TaskID {
|
|
t.Fatalf("detail tasks = %+v, run task id = %q", detail.Tasks, run.TaskID)
|
|
}
|
|
if detail.LatestRun == nil || detail.LatestRun.RunID != run.RunID || detail.LatestRun.TaskID != run.TaskID {
|
|
t.Fatalf("issue latest run = %+v, want run %+v", detail.LatestRun, run)
|
|
}
|
|
}
|
|
|
|
func TestServicePrepareRunDoesNotPersistImplicitTask(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Prepare atomic launch",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
|
|
prepared, err := service.PrepareRun(ctx, CreateRunInput{
|
|
WorkspaceID: issue.WorkspaceID,
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
RunID: "run-prepared",
|
|
AgentTargetID: "local:codex",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PrepareRun() error = %v", err)
|
|
}
|
|
if !prepared.TaskIsNew || prepared.Task.TaskID == "" {
|
|
t.Fatalf("prepared Task = %+v, TaskIsNew = %v", prepared.Task, prepared.TaskIsNew)
|
|
}
|
|
if len(store.tasks) != 0 {
|
|
t.Fatalf("persisted tasks after PrepareRun() = %d, want 0", len(store.tasks))
|
|
}
|
|
}
|
|
|
|
func TestProjectIssueStatusUsesRunningForPartialWork(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
counts StatusCounts
|
|
want Status
|
|
}{
|
|
{
|
|
name: "pending acceptance and not started is running",
|
|
counts: StatusCounts{
|
|
All: 2,
|
|
NotStarted: 1,
|
|
PendingAcceptance: 1,
|
|
},
|
|
want: StatusRunning,
|
|
},
|
|
{
|
|
name: "completed and not started is running",
|
|
counts: StatusCounts{
|
|
All: 2,
|
|
NotStarted: 1,
|
|
Completed: 1,
|
|
},
|
|
want: StatusRunning,
|
|
},
|
|
{
|
|
name: "running outranks failed",
|
|
counts: StatusCounts{
|
|
All: 2,
|
|
Running: 1,
|
|
Failed: 1,
|
|
},
|
|
want: StatusRunning,
|
|
},
|
|
{
|
|
name: "failed outranks pending acceptance",
|
|
counts: StatusCounts{
|
|
All: 2,
|
|
PendingAcceptance: 1,
|
|
Failed: 1,
|
|
},
|
|
want: StatusFailed,
|
|
},
|
|
{
|
|
name: "pending acceptance only when all active work is completed or pending acceptance",
|
|
counts: StatusCounts{
|
|
All: 2,
|
|
PendingAcceptance: 1,
|
|
Completed: 1,
|
|
},
|
|
want: StatusPendingAcceptance,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := ProjectIssueStatus(tt.counts); got != tt.want {
|
|
t.Fatalf("ProjectIssueStatus(%+v) = %q, want %q", tt.counts, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServiceAddContextRefs(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Context refs",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Collect files",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
|
|
refs, err := service.AddContextRefs(ctx, AddContextRefsInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ParentKind: string(ContextRefParentTask),
|
|
Refs: []AddContextRefInput{{
|
|
RefType: "file",
|
|
Path: "/workspace/README.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddContextRefs() error = %v", err)
|
|
}
|
|
if len(refs) != 1 || refs[0].DisplayName != "README.md" {
|
|
t.Fatalf("refs = %+v", refs)
|
|
}
|
|
detail, err := service.GetTaskDetail(ctx, "workspace-1", issue.IssueID, task.TaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskDetail() error = %v", err)
|
|
}
|
|
if len(detail.ContextRefs) != 1 {
|
|
t.Fatalf("detail context refs len = %d, want 1", len(detail.ContextRefs))
|
|
}
|
|
}
|
|
|
|
func TestServiceDeleteTaskRecalculatesIssueProjection(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Delete task",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Remove me",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
|
|
removed, err := service.DeleteTask(ctx, "workspace-1", issue.IssueID, task.TaskID, "user-1")
|
|
if err != nil {
|
|
t.Fatalf("DeleteTask() error = %v", err)
|
|
}
|
|
if !removed {
|
|
t.Fatal("DeleteTask() removed = false, want true")
|
|
}
|
|
detail, err := service.GetIssueDetail(ctx, "workspace-1", issue.IssueID)
|
|
if err != nil {
|
|
t.Fatalf("GetIssueDetail() error = %v", err)
|
|
}
|
|
if detail.Issue.TaskCount != 0 || detail.Issue.Status != StatusNotStarted {
|
|
t.Fatalf("issue projection after delete = %+v", detail.Issue)
|
|
}
|
|
}
|
|
|
|
func TestServiceRemoveContextRefUsesParentScope(t *testing.T) {
|
|
store := newFakeStore()
|
|
service := testService(store)
|
|
ctx := context.Background()
|
|
|
|
issue, err := service.CreateIssue(ctx, CreateIssueInput{
|
|
WorkspaceID: "workspace-1",
|
|
TopicID: DefaultTopicID,
|
|
ActorUserID: "user-1",
|
|
Title: "Scoped refs",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateIssue() error = %v", err)
|
|
}
|
|
task, err := service.CreateTask(ctx, CreateTaskInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ActorUserID: "user-1",
|
|
Title: "Task ref",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateTask() error = %v", err)
|
|
}
|
|
refs, err := service.AddContextRefs(ctx, AddContextRefsInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
TaskID: task.TaskID,
|
|
ParentKind: string(ContextRefParentTask),
|
|
Refs: []AddContextRefInput{{
|
|
ContextRefID: "ref-task-1",
|
|
RefType: "file",
|
|
Path: "/workspace/README.md",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddContextRefs() error = %v", err)
|
|
}
|
|
if len(refs) != 1 {
|
|
t.Fatalf("refs len = %d, want 1", len(refs))
|
|
}
|
|
|
|
removed, err := service.RemoveContextRef(ctx, RemoveContextRefInput{
|
|
WorkspaceID: "workspace-1",
|
|
IssueID: issue.IssueID,
|
|
ParentKind: string(ContextRefParentIssue),
|
|
ContextRefID: refs[0].ContextRefID,
|
|
})
|
|
if !errors.Is(err, ErrContextRefNotFound) {
|
|
t.Fatalf("RemoveContextRef() error = %v, want ErrContextRefNotFound", err)
|
|
}
|
|
if removed {
|
|
t.Fatal("RemoveContextRef() removed = true, want false")
|
|
}
|
|
|
|
detail, err := service.GetTaskDetail(ctx, "workspace-1", issue.IssueID, task.TaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskDetail() error = %v", err)
|
|
}
|
|
if len(detail.ContextRefs) != 1 {
|
|
t.Fatalf("detail context refs len = %d, want 1", len(detail.ContextRefs))
|
|
}
|
|
}
|
|
|
|
func TestIssueListCursorTokenRoundTrip(t *testing.T) {
|
|
cursor := &IssueListCursor{UpdatedAtUnixMS: 1_700_000_000_000, ID: 42}
|
|
token := EncodeIssueListCursorToken(cursor)
|
|
if token == "" {
|
|
t.Fatal("EncodeIssueListCursorToken() returned empty token")
|
|
}
|
|
decoded, err := DecodeIssueListCursorToken(token)
|
|
if err != nil {
|
|
t.Fatalf("DecodeIssueListCursorToken() error = %v", err)
|
|
}
|
|
if *decoded != *cursor {
|
|
t.Fatalf("decoded cursor = %+v, want %+v", decoded, cursor)
|
|
}
|
|
if _, err := DecodeIssueListCursorToken("not-a-valid-token"); !errors.Is(err, ErrInvalidArgument) {
|
|
t.Fatalf("DecodeIssueListCursorToken() error = %v, want ErrInvalidArgument", err)
|
|
}
|
|
}
|
|
|
|
func testService(store Store) Service {
|
|
counters := map[IDKind]int{}
|
|
return Service{
|
|
Clock: func() time.Time {
|
|
return time.UnixMilli(1_700_000_000_000)
|
|
},
|
|
IDGenerator: func(kind IDKind) string {
|
|
counters[kind]++
|
|
return string(kind) + "-" + strconv.Itoa(counters[kind])
|
|
},
|
|
Store: store,
|
|
}
|
|
}
|
|
|
|
type fakeStore struct {
|
|
nextID uint64
|
|
topics map[string]Topic
|
|
issues map[string]Issue
|
|
tasks map[string]Task
|
|
runs map[string]Run
|
|
output map[string][]RunOutput
|
|
refs map[string][]ContextRef
|
|
}
|
|
|
|
func newFakeStore() *fakeStore {
|
|
store := &fakeStore{
|
|
topics: map[string]Topic{},
|
|
issues: map[string]Issue{},
|
|
tasks: map[string]Task{},
|
|
runs: map[string]Run{},
|
|
output: map[string][]RunOutput{},
|
|
refs: map[string][]ContextRef{},
|
|
}
|
|
store.topics[topicKey("workspace-1", DefaultTopicID)] = Topic{
|
|
ID: 1,
|
|
TopicID: DefaultTopicID,
|
|
WorkspaceID: "workspace-1",
|
|
Title: "default",
|
|
IsDefault: true,
|
|
LastActivityAtUnixMS: 1_700_000_000_000,
|
|
CreatedAtUnixMS: 1_700_000_000_000,
|
|
UpdatedAtUnixMS: 1_700_000_000_000,
|
|
}
|
|
return store
|
|
}
|
|
|
|
func (s *fakeStore) ListTopics(_ context.Context, workspaceID string) (TopicList, error) {
|
|
items := make([]Topic, 0)
|
|
for _, topic := range s.topics {
|
|
if topic.WorkspaceID == workspaceID {
|
|
items = append(items, topic)
|
|
}
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].PinnedAtUnixMS > 0 || items[j].PinnedAtUnixMS > 0 {
|
|
return items[i].PinnedAtUnixMS > items[j].PinnedAtUnixMS
|
|
}
|
|
return items[i].LastActivityAtUnixMS > items[j].LastActivityAtUnixMS
|
|
})
|
|
return TopicList{Items: items}, nil
|
|
}
|
|
|
|
func (s *fakeStore) CreateTopic(_ context.Context, topic Topic) (Topic, error) {
|
|
key := topicKey(topic.WorkspaceID, topic.TopicID)
|
|
if _, ok := s.topics[key]; ok {
|
|
return Topic{}, ErrTopicAlreadyExists
|
|
}
|
|
s.nextID++
|
|
topic.ID = s.nextID
|
|
s.topics[key] = topic
|
|
return topic, nil
|
|
}
|
|
|
|
func (s *fakeStore) GetTopic(_ context.Context, workspaceID string, topicID string) (Topic, error) {
|
|
topic, ok := s.topics[topicKey(workspaceID, topicID)]
|
|
if !ok {
|
|
return Topic{}, ErrTopicNotFound
|
|
}
|
|
return topic, nil
|
|
}
|
|
|
|
func (s *fakeStore) UpdateTopic(_ context.Context, topic Topic) (Topic, error) {
|
|
key := topicKey(topic.WorkspaceID, topic.TopicID)
|
|
if _, ok := s.topics[key]; !ok {
|
|
return Topic{}, ErrTopicNotFound
|
|
}
|
|
s.topics[key] = topic
|
|
return topic, nil
|
|
}
|
|
|
|
func (s *fakeStore) DeleteTopic(_ context.Context, workspaceID string, topicID string) (bool, error) {
|
|
key := topicKey(workspaceID, topicID)
|
|
if _, ok := s.topics[key]; !ok {
|
|
return false, ErrTopicNotFound
|
|
}
|
|
delete(s.topics, key)
|
|
return true, nil
|
|
}
|
|
|
|
func (s *fakeStore) TouchTopicActivity(_ context.Context, workspaceID string, topicID string, atUnixMS int64) error {
|
|
key := topicKey(workspaceID, topicID)
|
|
topic, ok := s.topics[key]
|
|
if !ok {
|
|
return ErrTopicNotFound
|
|
}
|
|
topic.LastActivityAtUnixMS = atUnixMS
|
|
topic.UpdatedAtUnixMS = atUnixMS
|
|
s.topics[key] = topic
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeStore) ListIssues(_ context.Context, filter IssueListFilter) (IssueList, error) {
|
|
items := make([]Issue, 0)
|
|
for _, issue := range s.issues {
|
|
if issue.WorkspaceID != filter.WorkspaceID || issue.TopicID != filter.TopicID {
|
|
continue
|
|
}
|
|
if filter.StatusFilter != "" && issue.Status != filter.StatusFilter {
|
|
continue
|
|
}
|
|
if !matchesSearch(filter.SearchQuery, issue.Title) {
|
|
continue
|
|
}
|
|
items = append(items, issue)
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].UpdatedAtUnixMS == items[j].UpdatedAtUnixMS {
|
|
return items[i].ID < items[j].ID
|
|
}
|
|
return items[i].UpdatedAtUnixMS > items[j].UpdatedAtUnixMS
|
|
})
|
|
return IssueList{
|
|
Items: items,
|
|
TotalCount: len(items),
|
|
StatusCounts: countIssueStatuses(items),
|
|
}, nil
|
|
}
|
|
|
|
func (s *fakeStore) CreateIssue(_ context.Context, issue Issue) (Issue, error) {
|
|
s.nextID++
|
|
issue.ID = s.nextID
|
|
s.issues[issueKey(issue.WorkspaceID, issue.IssueID)] = issue
|
|
return issue, nil
|
|
}
|
|
|
|
func (s *fakeStore) CreateIssueWithTasks(_ context.Context, issue Issue, tasks []Task) (Issue, []Task, error) {
|
|
issueKeyValue := issueKey(issue.WorkspaceID, issue.IssueID)
|
|
if _, exists := s.issues[issueKeyValue]; exists {
|
|
return Issue{}, nil, ErrIssueAlreadyExists
|
|
}
|
|
if len(tasks) == 0 {
|
|
return Issue{}, nil, ErrInvalidArgument
|
|
}
|
|
for _, task := range tasks {
|
|
if task.WorkspaceID != issue.WorkspaceID || task.IssueID != issue.IssueID {
|
|
return Issue{}, nil, ErrInvalidArgument
|
|
}
|
|
if _, exists := s.tasks[taskKey(task.WorkspaceID, task.IssueID, task.TaskID)]; exists {
|
|
return Issue{}, nil, ErrTaskAlreadyExists
|
|
}
|
|
}
|
|
s.nextID++
|
|
issue.ID = s.nextID
|
|
createdTasks := make([]Task, 0, len(tasks))
|
|
for index, task := range tasks {
|
|
s.nextID++
|
|
task.ID = s.nextID
|
|
task.SortIndex = index + 1
|
|
createdTasks = append(createdTasks, task)
|
|
}
|
|
s.issues[issueKeyValue] = issue
|
|
for _, task := range createdTasks {
|
|
s.tasks[taskKey(task.WorkspaceID, task.IssueID, task.TaskID)] = task
|
|
}
|
|
topic := s.topics[topicKey(issue.WorkspaceID, issue.TopicID)]
|
|
topic.LastActivityAtUnixMS = issue.UpdatedAtUnixMS
|
|
s.topics[topicKey(issue.WorkspaceID, issue.TopicID)] = topic
|
|
return issue, createdTasks, nil
|
|
}
|
|
|
|
func (s *fakeStore) GetIssue(_ context.Context, workspaceID string, issueID string) (Issue, error) {
|
|
issue, ok := s.issues[issueKey(workspaceID, issueID)]
|
|
if !ok {
|
|
return Issue{}, ErrIssueNotFound
|
|
}
|
|
return issue, nil
|
|
}
|
|
|
|
func (s *fakeStore) UpdateIssue(_ context.Context, issue Issue) (Issue, error) {
|
|
key := issueKey(issue.WorkspaceID, issue.IssueID)
|
|
if _, ok := s.issues[key]; !ok {
|
|
return Issue{}, ErrIssueNotFound
|
|
}
|
|
s.issues[key] = issue
|
|
return issue, nil
|
|
}
|
|
|
|
func (s *fakeStore) DeleteIssue(_ context.Context, workspaceID string, issueID string, _ string) (bool, error) {
|
|
key := issueKey(workspaceID, issueID)
|
|
if _, ok := s.issues[key]; !ok {
|
|
return false, nil
|
|
}
|
|
delete(s.issues, key)
|
|
return true, nil
|
|
}
|
|
|
|
func (s *fakeStore) RecalculateIssueProjection(_ context.Context, workspaceID string, issueID string) (Issue, error) {
|
|
key := issueKey(workspaceID, issueID)
|
|
issue, ok := s.issues[key]
|
|
if !ok {
|
|
return Issue{}, ErrIssueNotFound
|
|
}
|
|
tasks := make([]Task, 0)
|
|
for _, task := range s.tasks {
|
|
if task.WorkspaceID == workspaceID && task.IssueID == issueID {
|
|
tasks = append(tasks, task)
|
|
}
|
|
}
|
|
counts := countTaskStatuses(tasks)
|
|
issue.TaskCount = counts.All
|
|
issue.NotStartedCount = counts.NotStarted
|
|
issue.RunningCount = counts.Running
|
|
issue.PendingAcceptanceCount = counts.PendingAcceptance
|
|
issue.CompletedCount = counts.Completed
|
|
issue.FailedCount = counts.Failed
|
|
issue.CanceledCount = counts.Canceled
|
|
issue.Status = projectedIssueStatus(counts)
|
|
s.issues[key] = issue
|
|
return issue, nil
|
|
}
|
|
|
|
func (s *fakeStore) ListTasks(_ context.Context, filter TaskListFilter) (TaskList, error) {
|
|
items := make([]Task, 0)
|
|
for _, task := range s.tasks {
|
|
if task.WorkspaceID != filter.WorkspaceID || task.IssueID != filter.IssueID {
|
|
continue
|
|
}
|
|
if filter.StatusFilter != "" && task.Status != filter.StatusFilter {
|
|
continue
|
|
}
|
|
if !matchesSearch(filter.SearchQuery, task.Title) {
|
|
continue
|
|
}
|
|
items = append(items, task)
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].SortIndex == items[j].SortIndex {
|
|
return items[i].ID < items[j].ID
|
|
}
|
|
return items[i].SortIndex < items[j].SortIndex
|
|
})
|
|
return TaskList{
|
|
Items: items,
|
|
TotalCount: len(items),
|
|
StatusCounts: countTaskStatuses(items),
|
|
}, nil
|
|
}
|
|
|
|
func (s *fakeStore) AppendTasks(_ context.Context, tasks []Task) ([]Task, error) {
|
|
if len(tasks) == 0 {
|
|
return []Task{}, nil
|
|
}
|
|
workspaceID := tasks[0].WorkspaceID
|
|
issueID := tasks[0].IssueID
|
|
if _, ok := s.issues[issueKey(workspaceID, issueID)]; !ok {
|
|
return nil, ErrIssueNotFound
|
|
}
|
|
next := 1
|
|
for _, task := range s.tasks {
|
|
if task.WorkspaceID == workspaceID && task.IssueID == issueID && task.SortIndex >= next {
|
|
next = task.SortIndex + 1
|
|
}
|
|
}
|
|
created := make([]Task, 0, len(tasks))
|
|
for index, task := range tasks {
|
|
if task.WorkspaceID != workspaceID || task.IssueID != issueID {
|
|
return nil, ErrInvalidArgument
|
|
}
|
|
key := taskKey(task.WorkspaceID, task.IssueID, task.TaskID)
|
|
if _, ok := s.tasks[key]; ok {
|
|
return nil, ErrTaskAlreadyExists
|
|
}
|
|
s.nextID++
|
|
task.ID = s.nextID
|
|
task.SortIndex = next + index
|
|
s.tasks[key] = task
|
|
created = append(created, task)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (s *fakeStore) CreateTask(_ context.Context, task Task) (Task, error) {
|
|
if _, ok := s.issues[issueKey(task.WorkspaceID, task.IssueID)]; !ok {
|
|
return Task{}, ErrIssueNotFound
|
|
}
|
|
s.nextID++
|
|
task.ID = s.nextID
|
|
s.tasks[taskKey(task.WorkspaceID, task.IssueID, task.TaskID)] = task
|
|
return task, nil
|
|
}
|
|
|
|
func (s *fakeStore) GetTask(_ context.Context, workspaceID string, issueID string, taskID string) (Task, error) {
|
|
task, ok := s.tasks[taskKey(workspaceID, issueID, taskID)]
|
|
if !ok {
|
|
return Task{}, ErrTaskNotFound
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func (s *fakeStore) UpdateTask(_ context.Context, task Task) (Task, error) {
|
|
key := taskKey(task.WorkspaceID, task.IssueID, task.TaskID)
|
|
if _, ok := s.tasks[key]; !ok {
|
|
return Task{}, ErrTaskNotFound
|
|
}
|
|
s.tasks[key] = task
|
|
return task, nil
|
|
}
|
|
|
|
func (s *fakeStore) DeleteTask(_ context.Context, workspaceID string, issueID string, taskID string, _ string) (bool, error) {
|
|
key := taskKey(workspaceID, issueID, taskID)
|
|
if _, ok := s.tasks[key]; !ok {
|
|
return false, nil
|
|
}
|
|
delete(s.tasks, key)
|
|
return true, nil
|
|
}
|
|
|
|
func (s *fakeStore) AddContextRefs(_ context.Context, refs []ContextRef) ([]ContextRef, error) {
|
|
saved := make([]ContextRef, 0, len(refs))
|
|
for _, ref := range refs {
|
|
s.nextID++
|
|
ref.ID = s.nextID
|
|
key := contextRefBucketKey(ref.WorkspaceID, ref.IssueID, ref.TaskID, ref.ParentKind)
|
|
s.refs[key] = append(s.refs[key], ref)
|
|
saved = append(saved, ref)
|
|
}
|
|
return saved, nil
|
|
}
|
|
|
|
func (s *fakeStore) ListContextRefs(_ context.Context, workspaceID string, issueID string, taskID string, parentKind ContextRefParentKind) ([]ContextRef, error) {
|
|
refs := s.refs[contextRefBucketKey(workspaceID, issueID, taskID, parentKind)]
|
|
return append([]ContextRef(nil), refs...), nil
|
|
}
|
|
|
|
func (s *fakeStore) RemoveContextRef(_ context.Context, workspaceID string, issueID string, taskID string, parentKind ContextRefParentKind, contextRefID string) (bool, error) {
|
|
key := contextRefBucketKey(workspaceID, issueID, taskID, parentKind)
|
|
refs := s.refs[key]
|
|
next := refs[:0]
|
|
removed := false
|
|
for _, ref := range refs {
|
|
if ref.WorkspaceID == workspaceID && ref.IssueID == issueID && ref.TaskID == taskID && ref.ParentKind == parentKind && ref.ContextRefID == contextRefID {
|
|
removed = true
|
|
continue
|
|
}
|
|
next = append(next, ref)
|
|
}
|
|
if removed {
|
|
s.refs[key] = next
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *fakeStore) CreateRun(_ context.Context, run Run) (Run, error) {
|
|
if run.TaskID != "" {
|
|
if _, ok := s.tasks[taskKey(run.WorkspaceID, run.IssueID, run.TaskID)]; !ok {
|
|
return Run{}, ErrTaskNotFound
|
|
}
|
|
} else if _, ok := s.issues[issueKey(run.WorkspaceID, run.IssueID)]; !ok {
|
|
return Run{}, ErrTaskNotFound
|
|
}
|
|
s.nextID++
|
|
run.ID = s.nextID
|
|
s.runs[runKey(run.WorkspaceID, run.IssueID, run.TaskID, run.RunID)] = run
|
|
return run, nil
|
|
}
|
|
|
|
func (s *fakeStore) CompleteRun(_ context.Context, run Run, outputs []RunOutput) (Run, []RunOutput, error) {
|
|
key := runKey(run.WorkspaceID, run.IssueID, run.TaskID, run.RunID)
|
|
if _, ok := s.runs[key]; !ok {
|
|
return Run{}, nil, ErrRunNotFound
|
|
}
|
|
s.runs[key] = run
|
|
saved := make([]RunOutput, 0, len(outputs))
|
|
for _, output := range outputs {
|
|
s.nextID++
|
|
output.ID = s.nextID
|
|
saved = append(saved, output)
|
|
}
|
|
s.output[key] = saved
|
|
return run, saved, nil
|
|
}
|
|
|
|
func (s *fakeStore) ListRuns(_ context.Context, workspaceID string, issueID string, taskID string) ([]Run, error) {
|
|
runs := make([]Run, 0)
|
|
for _, run := range s.runs {
|
|
if run.WorkspaceID == workspaceID && run.IssueID == issueID && (taskID == "" || run.TaskID == taskID) {
|
|
runs = append(runs, run)
|
|
}
|
|
}
|
|
sort.Slice(runs, func(i, j int) bool {
|
|
if runs[i].CreatedAtUnixMS == runs[j].CreatedAtUnixMS {
|
|
return runs[i].ID > runs[j].ID
|
|
}
|
|
return runs[i].CreatedAtUnixMS > runs[j].CreatedAtUnixMS
|
|
})
|
|
return runs, nil
|
|
}
|
|
|
|
func (s *fakeStore) ListRunningRuns(_ context.Context, workspaceID string, limit int) ([]Run, error) {
|
|
runs := make([]Run, 0)
|
|
for _, run := range s.runs {
|
|
if run.WorkspaceID == workspaceID && run.Status == StatusRunning && strings.TrimSpace(run.AgentSessionID) != "" {
|
|
runs = append(runs, run)
|
|
if limit > 0 && len(runs) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return runs, nil
|
|
}
|
|
|
|
func (s *fakeStore) GetRun(_ context.Context, workspaceID string, issueID string, taskID string, runID string) (Run, error) {
|
|
run, ok := s.runs[runKey(workspaceID, issueID, taskID, runID)]
|
|
if !ok {
|
|
return Run{}, ErrRunNotFound
|
|
}
|
|
return run, nil
|
|
}
|
|
|
|
func (s *fakeStore) ListRunOutputs(_ context.Context, workspaceID string, issueID string, taskID string, runID string) ([]RunOutput, error) {
|
|
outputs := s.output[runKey(workspaceID, issueID, taskID, runID)]
|
|
return append([]RunOutput(nil), outputs...), nil
|
|
}
|
|
|
|
func (s *fakeStore) ListLatestRunOutputs(_ context.Context, workspaceID string, issueID string, taskID string) ([]RunOutput, error) {
|
|
runs, err := s.ListRuns(context.Background(), workspaceID, issueID, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(runs) == 0 {
|
|
return nil, nil
|
|
}
|
|
return s.ListRunOutputs(context.Background(), workspaceID, issueID, runs[0].TaskID, runs[0].RunID)
|
|
}
|
|
|
|
func (s *fakeStore) SearchRunOutputs(_ context.Context, params RunOutputSearchParams) ([]RunOutputSearchHit, error) {
|
|
query := strings.ToLower(params.Query)
|
|
matches := make([]RunOutput, 0)
|
|
for _, outputs := range s.output {
|
|
for _, output := range outputs {
|
|
if output.WorkspaceID != params.WorkspaceID {
|
|
continue
|
|
}
|
|
if !strings.Contains(strings.ToLower(output.DisplayName), query) {
|
|
continue
|
|
}
|
|
if params.IssueID != "" {
|
|
if output.IssueID != params.IssueID {
|
|
continue
|
|
}
|
|
} else if params.TopicID != "" {
|
|
if s.issues[issueKey(output.WorkspaceID, output.IssueID)].TopicID != params.TopicID {
|
|
continue
|
|
}
|
|
}
|
|
matches = append(matches, output)
|
|
}
|
|
}
|
|
sort.Slice(matches, func(i, j int) bool {
|
|
if matches[i].CreatedAtUnixMS != matches[j].CreatedAtUnixMS {
|
|
return matches[i].CreatedAtUnixMS > matches[j].CreatedAtUnixMS
|
|
}
|
|
return matches[i].ID > matches[j].ID
|
|
})
|
|
hits := make([]RunOutputSearchHit, 0)
|
|
seenPaths := map[string]struct{}{}
|
|
for _, output := range matches {
|
|
if params.Limit > 0 && len(hits) >= params.Limit {
|
|
break
|
|
}
|
|
if _, exists := seenPaths[output.Path]; exists {
|
|
continue
|
|
}
|
|
seenPaths[output.Path] = struct{}{}
|
|
hits = append(hits, RunOutputSearchHit{
|
|
Output: output,
|
|
IssueTitle: s.issues[issueKey(output.WorkspaceID, output.IssueID)].Title,
|
|
})
|
|
}
|
|
return hits, nil
|
|
}
|
|
|
|
func issueKey(workspaceID string, issueID string) string {
|
|
return strings.Join([]string{workspaceID, issueID}, "\x00")
|
|
}
|
|
|
|
func topicKey(workspaceID string, topicID string) string {
|
|
return strings.Join([]string{workspaceID, topicID}, "\x00")
|
|
}
|
|
|
|
func taskKey(workspaceID string, issueID string, taskID string) string {
|
|
return strings.Join([]string{workspaceID, issueID, taskID}, "\x00")
|
|
}
|
|
|
|
func runKey(workspaceID string, issueID string, taskID string, runID string) string {
|
|
return strings.Join([]string{workspaceID, issueID, taskID, runID}, "\x00")
|
|
}
|
|
|
|
func contextRefBucketKey(workspaceID string, issueID string, taskID string, parentKind ContextRefParentKind) string {
|
|
return strings.Join([]string{workspaceID, issueID, taskID, string(parentKind)}, "\x00")
|
|
}
|
|
|
|
func matchesSearch(query string, values ...string) bool {
|
|
query = strings.ToLower(strings.TrimSpace(query))
|
|
if query == "" {
|
|
return true
|
|
}
|
|
for _, value := range values {
|
|
if strings.Contains(strings.ToLower(value), query) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func countIssueStatuses(items []Issue) StatusCounts {
|
|
counts := StatusCounts{All: len(items)}
|
|
for _, item := range items {
|
|
incrementStatus(&counts, item.Status)
|
|
}
|
|
return counts
|
|
}
|
|
|
|
func countTaskStatuses(items []Task) StatusCounts {
|
|
counts := StatusCounts{All: len(items)}
|
|
for _, item := range items {
|
|
incrementStatus(&counts, item.Status)
|
|
}
|
|
return counts
|
|
}
|
|
|
|
func incrementStatus(counts *StatusCounts, status Status) {
|
|
switch status {
|
|
case StatusRunning:
|
|
counts.Running++
|
|
case StatusPendingAcceptance:
|
|
counts.PendingAcceptance++
|
|
case StatusCompleted:
|
|
counts.Completed++
|
|
case StatusFailed:
|
|
counts.Failed++
|
|
case StatusCanceled:
|
|
counts.Canceled++
|
|
default:
|
|
counts.NotStarted++
|
|
}
|
|
}
|
|
|
|
func projectedIssueStatus(counts StatusCounts) Status {
|
|
return ProjectIssueStatus(counts)
|
|
}
|
|
|
|
var _ Store = (*fakeStore)(nil)
|
|
|
|
func TestFakeStoreNotFoundSentinels(t *testing.T) {
|
|
store := newFakeStore()
|
|
if _, err := store.GetIssue(context.Background(), "workspace-1", "missing"); !errors.Is(err, ErrIssueNotFound) {
|
|
t.Fatalf("GetIssue() error = %v, want ErrIssueNotFound", err)
|
|
}
|
|
}
|