feat(tasks): search tasks by PR number in command panel (#1268)

* feat(tasks): search tasks by PR number in command panel

Typing a PR number (e.g. 1243 or #1243) in the Cmd+K panel now surfaces
the task that PR is associated with, alongside the existing
title/description/repo search.

The github store gains a workspace-scoped ListTaskIDsByPRNumber lookup
over github_task_prs; the task service resolves it through an injected
PRTaskResolver interface (github service wired in cmd/kandev) and merges
PR matches into search results, deduped and respecting the
archived/ephemeral/config filters. Keeping the lookup at the service
layer avoids coupling the task repository to the github schema, so
existing search queries and tests are untouched.

* fix(tasks): scope PR-number search to page 1 and unfiltered queries

Address review feedback on PR-number search:

- Only augment results on the first page of an unscoped search. Running
  on every page re-prepended the PR match and truncated a real result
  off each subsequent page; running under a workflow/repository filter
  leaked tasks that don't satisfy the filter.
- Add idx_github_task_prs_pr_number so the lookup is index-backed
  instead of a full scan (pr_number is the 3rd column of the existing
  UNIQUE constraint, unusable as a leading key).
- Log instead of silently dropping a task when GetTask fails in the
  PR-match loop.

* test(github): dedup PR-lookup test setup into shared newTestStore

Add workspace_id to newTestStore's tasks table and drop the near-identical
newTestStoreWithWorkspaces helper, per review feedback.

* perf(tasks): batch-fetch PR-matched tasks instead of N+1 GetTask

Add a repository GetTasksByIDs and use it in augmentWithPRMatches so PR-number
search loads all matched tasks in one query instead of one GetTask per id,
addressing the N+1 noted in review. Fetch+filter is extracted into a
fetchPRMatchedTasks helper that preserves resolver order and dedup.
This commit is contained in:
José Almeida
2026-06-05 16:20:30 +01:00
committed by GitHub
parent 0eac403499
commit 4601dbc2d2
15 changed files with 546 additions and 1 deletions
@@ -354,6 +354,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.
+19
View File
@@ -207,6 +207,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
}
@@ -814,6 +818,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
}
func groupTaskPRsByTask(prs []TaskPR) map[string][]*TaskPR {
result := make(map[string][]*TaskPR)
for i := range prs {
@@ -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)
@@ -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)
}
}