Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/cmd/kandev/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
// inside their own container).
if githubSvc != nil {
taskSvc.SetRemoteBranchLister(githubBranchListerAdapter{svc: githubSvc})
taskSvc.SetPRTaskResolver(githubSvc)
}

// Initialize Automation service
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/internal/github/service_pr_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,12 @@ func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string
return s.store.ListTaskPRsByTaskIDs(ctx, taskIDs)
}

// FindTaskIDsByPRNumber returns the IDs of tasks in a workspace associated with
// the given PR number. Used by task search to surface a task by its PR number.
func (s *Service) FindTaskIDsByPRNumber(ctx context.Context, workspaceID string, prNumber int) ([]string, error) {
return s.store.ListTaskIDsByPRNumber(ctx, workspaceID, prNumber)
}

// ListWorkspaceTaskPRs returns all PR associations for a workspace, grouped by
// task_id. Multi-repo tasks may have more than one PR per task. It returns
// cached data immediately and triggers background refresh for stale entries.
Expand Down
19 changes: 19 additions & 0 deletions apps/backend/internal/github/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ func (s *Store) initSchema() error {
if err := s.backfillPRWatchesRepositoryID(); err != nil {
return fmt.Errorf("backfill github_pr_watches.repository_id: %w", err)
}
// pr_number is the 3rd column of UNIQUE(task_id, repository_id, pr_number),
// so SQLite can't use that index for the PR-number task search. Add a
// dedicated leading-key index so lookups by PR number stay index-backed.
_, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_github_task_prs_pr_number ON github_task_prs (pr_number)`)
return nil
}

Expand Down Expand Up @@ -744,6 +748,21 @@ func (s *Store) ListTaskPRsByWorkspaceID(ctx context.Context, workspaceID string
return groupTaskPRsByTask(prs), nil
}

// ListTaskIDsByPRNumber returns the IDs of tasks in a workspace that have a PR
// association with the given PR number. Workspace-scoped via the JOIN on tasks
// so a PR number shared across workspaces never leaks results. A task with
// multiple PR rows for the same number (multi-repo) is returned once.
func (s *Store) ListTaskIDsByPRNumber(ctx context.Context, workspaceID string, prNumber int) ([]string, error) {
var ids []string
if err := s.ro.SelectContext(ctx, &ids,
`SELECT DISTINCT gtp.task_id FROM github_task_prs gtp
INNER JOIN tasks t ON gtp.task_id = t.id
WHERE t.workspace_id = ? AND gtp.pr_number = ?`, workspaceID, prNumber); err != nil {
return nil, err
}
return ids, nil
}
Comment thread
zeval marked this conversation as resolved.

func groupTaskPRsByTask(prs []TaskPR) map[string][]*TaskPR {
result := make(map[string][]*TaskPR)
for i := range prs {
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/internal/github/store_multi_repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func newTestStore(t *testing.T) *Store {
}
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
if _, err := sqlxDB.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, archived_at DATETIME)`); err != nil {
if _, err := sqlxDB.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, workspace_id TEXT, archived_at DATETIME)`); err != nil {
t.Fatalf("create tasks table: %v", err)
}
store, err := NewStore(sqlxDB, sqlxDB)
Expand Down
61 changes: 61 additions & 0 deletions apps/backend/internal/github/store_pr_lookup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package github

import (
"context"
"testing"
"time"
)

func TestListTaskIDsByPRNumber(t *testing.T) {
// newTestStore's tasks table carries workspace_id, which the
// workspace-scoped PR-number lookup needs.
store := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC()

// task-1 (ws-1) has PR #1243, task-2 (ws-1) has PR #99,
// task-3 (ws-2) also has PR #1243 — must not leak into ws-1.
exec := store.db
for _, q := range []string{
`INSERT INTO tasks (id, workspace_id) VALUES ('task-1', 'ws-1')`,
`INSERT INTO tasks (id, workspace_id) VALUES ('task-2', 'ws-1')`,
`INSERT INTO tasks (id, workspace_id) VALUES ('task-3', 'ws-2')`,
} {
if _, err := exec.Exec(q); err != nil {
t.Fatalf("seed task: %v", err)
}
}
mkPR := func(id, taskID string, num int) *TaskPR {
return &TaskPR{
ID: id, TaskID: taskID, RepositoryID: "repo-" + id,
Owner: "kdlbs", Repo: "kandev", PRNumber: num,
PRURL: "https://github.com/kdlbs/kandev/pull/1", PRTitle: "x",
HeadBranch: "feat/x", BaseBranch: "main", State: "open", CreatedAt: now,
}
}
for _, pr := range []*TaskPR{
mkPR("p1", "task-1", 1243),
mkPR("p2", "task-2", 99),
mkPR("p3", "task-3", 1243),
} {
if err := store.CreateTaskPR(ctx, pr); err != nil {
t.Fatalf("create PR: %v", err)
}
}

got, err := store.ListTaskIDsByPRNumber(ctx, "ws-1", 1243)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if len(got) != 1 || got[0] != "task-1" {
t.Errorf("expected [task-1] for ws-1 PR #1243, got %v", got)
}

none, err := store.ListTaskIDsByPRNumber(ctx, "ws-1", 7777)
if err != nil {
t.Fatalf("lookup unknown: %v", err)
}
if len(none) != 0 {
t.Errorf("expected no tasks for unknown PR number, got %v", none)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,15 @@ func (m *mockRepository) GetTask(ctx context.Context, id string) (*models.Task,
}
return nil, nil
}
func (m *mockRepository) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) {
var out []*models.Task
for _, id := range ids {
if task, ok := m.tasks[id]; ok {
out = append(out, task)
}
}
return out, nil
}
func (m *mockRepository) UpdateTask(ctx context.Context, task *models.Task) error { return nil }
func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { return nil }
func (m *mockRepository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) {
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/internal/task/handlers/process_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ func (m *mockRepository) CreateTask(ctx context.Context, task *models.Task) erro
func (m *mockRepository) GetTask(ctx context.Context, id string) (*models.Task, error) {
return nil, nil
}
func (m *mockRepository) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) {
return nil, nil
}
func (m *mockRepository) UpdateTask(ctx context.Context, task *models.Task) error {
return nil
}
Expand Down
1 change: 1 addition & 0 deletions apps/backend/internal/task/repository/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type WorkspaceRepository interface {
type TaskRepository interface {
CreateTask(ctx context.Context, task *models.Task) error
GetTask(ctx context.Context, id string) (*models.Task, error)
GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error)
UpdateTask(ctx context.Context, task *models.Task) error
DeleteTask(ctx context.Context, id string) error
ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error)
Expand Down
24 changes: 24 additions & 0 deletions apps/backend/internal/task/repository/sqlite/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -653,6 +654,29 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
return result, rows.Err()
}

// GetTasksByIDs fetches multiple tasks in a single query. Missing IDs are
// silently omitted; result order is not guaranteed, so callers that need a
// specific order should reorder by ID themselves.
func (r *Repository) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) {
if len(ids) == 0 {
return nil, nil
}
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(`SELECT %s FROM tasks t WHERE t.id IN (%s)`,
taskSelectColumns("t"), strings.Join(placeholders, ","))
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
}

// ArchiveTask sets the archived_at timestamp on a task
func (r *Repository) ArchiveTask(ctx context.Context, id string) error {
now := time.Now().UTC()
Expand Down
34 changes: 34 additions & 0 deletions apps/backend/internal/task/repository/task_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,40 @@ func TestSQLiteRepository_ListTasks(t *testing.T) {
}
}

func TestSQLiteRepository_GetTasksByIDs(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
ctx := context.Background()

_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"})
for _, id := range []string{"task-1", "task-2", "task-3"} {
_ = repo.CreateTask(ctx, &models.Task{ID: id, WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: id})
}

// Empty input returns no tasks and no error.
none, err := repo.GetTasksByIDs(ctx, nil)
if err != nil {
t.Fatalf("GetTasksByIDs(nil): %v", err)
}
if len(none) != 0 {
t.Errorf("expected 0 tasks for empty input, got %d", len(none))
}

// Existing + missing IDs: only the existing ones come back.
got, err := repo.GetTasksByIDs(ctx, []string{"task-1", "task-3", "missing"})
if err != nil {
t.Fatalf("GetTasksByIDs: %v", err)
}
ids := map[string]bool{}
for _, tk := range got {
ids[tk.ID] = true
}
if len(got) != 2 || !ids["task-1"] || !ids["task-3"] {
t.Errorf("expected [task-1 task-3], got %v", ids)
}
}

func TestSQLiteRepository_ListTasksByWorkflowStep(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/internal/task/service/handoff_access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ func (f *fakeTaskLookup) GetTask(ctx context.Context, id string) (*models.Task,
}
return nil, nil
}
func (f *fakeTaskLookup) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) {
var out []*models.Task
for _, id := range ids {
if t, ok := f.tasks[id]; ok {
out = append(out, t)
}
}
return out, nil
}

func newGraph(tasks ...*models.Task) *fakeTaskLookup {
m := make(map[string]*models.Task, len(tasks))
Expand Down
15 changes: 15 additions & 0 deletions apps/backend/internal/task/service/handoff_workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@ func (f *fakeTaskRepo) GetTask(_ context.Context, id string) (*models.Task, erro
return f.tasks[id], nil
}

func (f *fakeTaskRepo) GetTasksByIDs(_ context.Context, ids []string) ([]*models.Task, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []*models.Task
for _, id := range ids {
if t, ok := f.tasks[id]; ok {
out = append(out, t)
}
}
return out, nil
}

func (f *fakeTaskRepo) ListChildren(_ context.Context, parentID string) ([]*models.Task, error) {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down Expand Up @@ -291,6 +303,9 @@ type phase4TaskRepo struct {
func (r *phase4TaskRepo) GetTask(ctx context.Context, id string) (*models.Task, error) {
return r.base.GetTask(ctx, id)
}
func (r *phase4TaskRepo) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) {
return r.base.GetTasksByIDs(ctx, ids)
}
func (r *phase4TaskRepo) ListChildren(ctx context.Context, parentID string) ([]*models.Task, error) {
return r.base.ListChildren(ctx, parentID)
}
Expand Down
14 changes: 14 additions & 0 deletions apps/backend/internal/task/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ type WorkflowStepGetter interface {
GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error)
}

// PRTaskResolver resolves which tasks are associated with a GitHub PR number.
// Implemented by the github service; injected so the task service can surface a
// task by its PR number in search without coupling to the github schema.
type PRTaskResolver interface {
FindTaskIDsByPRNumber(ctx context.Context, workspaceID string, prNumber int) ([]string, error)
}

// StartStepResolver resolves the starting step for a workflow.
type StartStepResolver interface {
ResolveStartStep(ctx context.Context, workflowID string) (string, error)
Expand Down Expand Up @@ -152,6 +159,7 @@ type Service struct {
workflowStepCreator WorkflowStepCreator
workflowStepGetter WorkflowStepGetter
startStepResolver StartStepResolver
prTaskResolver PRTaskResolver
quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat)
branchFetcher *branchFetcher
envDestroyer EnvironmentDestroyer
Expand Down Expand Up @@ -222,6 +230,12 @@ func (s *Service) SetStartStepResolver(resolver StartStepResolver) {
s.startStepResolver = resolver
}

// SetPRTaskResolver wires the GitHub PR→task resolver for PR-number search.
// Optional — when unset, search by PR number is a no-op.
func (s *Service) SetPRTaskResolver(resolver PRTaskResolver) {
s.prTaskResolver = resolver
}

// SetQuickChatDir sets the directory for quick-chat workspaces.
// When set, task cleanup deletes the session directory under this path for all tasks.
func (s *Service) SetQuickChatDir(dir string) {
Expand Down
Loading
Loading