4601dbc2d2
* 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.
298 lines
8.3 KiB
Go
298 lines
8.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
// fakeTaskLookup is a tiny in-memory implementation of taskLookup keyed
|
|
// by task id. Setting parent="" represents a root task.
|
|
type fakeTaskLookup struct {
|
|
tasks map[string]*models.Task
|
|
}
|
|
|
|
func (f *fakeTaskLookup) GetTask(ctx context.Context, id string) (*models.Task, error) {
|
|
if t, ok := f.tasks[id]; ok {
|
|
return t, nil
|
|
}
|
|
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))
|
|
for _, t := range tasks {
|
|
m[t.ID] = t
|
|
}
|
|
return &fakeTaskLookup{tasks: m}
|
|
}
|
|
|
|
func newTask(id, parent, ws string) *models.Task {
|
|
return &models.Task{ID: id, ParentID: parent, WorkspaceID: ws}
|
|
}
|
|
|
|
func TestCanReadDocuments_Self(t *testing.T) {
|
|
g := newGraph(newTask("A", "", "ws-1"))
|
|
ok, err := canReadDocuments(context.Background(), g, nil, "A", "A")
|
|
if err != nil || !ok {
|
|
t.Fatalf("self read should be allowed: ok=%v err=%v", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestCanReadDocuments_AncestorAndDescendant(t *testing.T) {
|
|
g := newGraph(
|
|
newTask("root", "", "ws-1"),
|
|
newTask("child", "root", "ws-1"),
|
|
newTask("grand", "child", "ws-1"),
|
|
)
|
|
ctx := context.Background()
|
|
cases := []struct {
|
|
name string
|
|
caller, tgt string
|
|
want bool
|
|
}{
|
|
{"child reads root (ancestor)", "child", "root", true},
|
|
{"grand reads root (ancestor 2 hops)", "grand", "root", true},
|
|
{"root reads child (descendant)", "root", "child", true},
|
|
{"root reads grand (descendant 2 hops)", "root", "grand", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := canReadDocuments(ctx, g, nil, tc.caller, tc.tgt)
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("got %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCanReadDocuments_Sibling(t *testing.T) {
|
|
g := newGraph(
|
|
newTask("parent", "", "ws-1"),
|
|
newTask("a", "parent", "ws-1"),
|
|
newTask("b", "parent", "ws-1"),
|
|
)
|
|
ok, err := canReadDocuments(context.Background(), g, nil, "a", "b")
|
|
if err != nil || !ok {
|
|
t.Fatalf("siblings should read each other: ok=%v err=%v", ok, err)
|
|
}
|
|
}
|
|
|
|
// REGRESSION: two ROOT tasks both have empty parent_id. The naive
|
|
// "shared parent_id" rule would have made them siblings and leaked
|
|
// document access. The fix requires a non-empty common parent.
|
|
func TestCanReadDocuments_RootsAreNotSiblings(t *testing.T) {
|
|
g := newGraph(
|
|
newTask("root-a", "", "ws-1"),
|
|
newTask("root-b", "", "ws-1"),
|
|
)
|
|
ok, err := canReadDocuments(context.Background(), g, nil, "root-a", "root-b")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("two roots in the same workspace must NOT see each other as siblings")
|
|
}
|
|
}
|
|
|
|
func TestCanReadDocuments_WorkspaceMismatch(t *testing.T) {
|
|
g := newGraph(
|
|
newTask("p", "", "ws-1"),
|
|
newTask("a", "p", "ws-1"),
|
|
newTask("b", "p", "ws-2"), // somehow same parent but different workspace
|
|
)
|
|
ok, err := canReadDocuments(context.Background(), g, nil, "a", "b")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("workspace mismatch must always deny access")
|
|
}
|
|
}
|
|
|
|
func TestCanReadDocuments_UnrelatedTasks(t *testing.T) {
|
|
g := newGraph(
|
|
newTask("p1", "", "ws-1"),
|
|
newTask("a1", "p1", "ws-1"),
|
|
newTask("p2", "", "ws-1"),
|
|
newTask("a2", "p2", "ws-1"),
|
|
)
|
|
ok, _ := canReadDocuments(context.Background(), g, nil, "a1", "a2")
|
|
if ok {
|
|
t.Error("unrelated tasks (different parents) must not see each other")
|
|
}
|
|
}
|
|
|
|
// fakeBlockerLookup implements blockerLookup for the access tests.
|
|
// Returns the canned blocker IDs for the queried task; defaults to
|
|
// empty so unrelated tasks stay denied.
|
|
type fakeBlockerLookup struct {
|
|
byTask map[string][]string
|
|
}
|
|
|
|
func (f *fakeBlockerLookup) BlockerTaskIDs(_ context.Context, taskID string) ([]string, error) {
|
|
return f.byTask[taskID], nil
|
|
}
|
|
|
|
// REGRESSION (post-review #6): the simplified Phase 3 model uses
|
|
// blocker edges as the document handoff readiness gate. A consumer
|
|
// task MUST be able to read its blocker's documents — without this
|
|
// branch the simplified handoff model is unreachable end-to-end.
|
|
func TestCanReadDocuments_BlockerEdgeGrantsRead(t *testing.T) {
|
|
g := newGraph(
|
|
newTask("planner", "", "ws-1"),
|
|
newTask("implementer", "", "ws-1"),
|
|
)
|
|
blockers := &fakeBlockerLookup{
|
|
byTask: map[string][]string{"implementer": {"planner"}},
|
|
}
|
|
ok, err := canReadDocuments(context.Background(), g, blockers, "implementer", "planner")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("implementer (blocked-by planner) must be allowed to read planner's docs")
|
|
}
|
|
// Reverse direction is NOT granted by a blocker edge — the producer
|
|
// can't read the consumer's docs via the same relationship.
|
|
if got, _ := canReadDocuments(context.Background(), g, blockers, "planner", "implementer"); got {
|
|
t.Error("planner (blocking implementer) must NOT auto-read implementer's docs via blocker edge")
|
|
}
|
|
}
|
|
|
|
func TestCanReadDocuments_BlockerCrossWorkspaceDenied(t *testing.T) {
|
|
g := newGraph(
|
|
newTask("planner", "", "ws-1"),
|
|
newTask("implementer", "", "ws-2"),
|
|
)
|
|
blockers := &fakeBlockerLookup{
|
|
byTask: map[string][]string{"implementer": {"planner"}},
|
|
}
|
|
if got, _ := canReadDocuments(context.Background(), g, blockers, "implementer", "planner"); got {
|
|
t.Error("workspace mismatch must deny even when a blocker edge exists")
|
|
}
|
|
}
|
|
|
|
func TestCanReadDocuments_MissingTasksDeny(t *testing.T) {
|
|
g := newGraph(newTask("known", "", "ws-1"))
|
|
ok, _ := canReadDocuments(context.Background(), g, nil, "known", "missing")
|
|
if ok {
|
|
t.Error("missing target must deny")
|
|
}
|
|
ok, _ = canReadDocuments(context.Background(), g, nil, "missing", "known")
|
|
if ok {
|
|
t.Error("missing caller must deny")
|
|
}
|
|
}
|
|
|
|
func TestCanWriteDocuments_SelfAndAncestorOnly(t *testing.T) {
|
|
g := newGraph(
|
|
newTask("root", "", "ws-1"),
|
|
newTask("child", "root", "ws-1"),
|
|
newTask("sib", "root", "ws-1"),
|
|
newTask("grand", "child", "ws-1"),
|
|
)
|
|
ctx := context.Background()
|
|
cases := []struct {
|
|
name string
|
|
caller, tgt string
|
|
want bool
|
|
}{
|
|
{"self write", "child", "child", true},
|
|
{"child writes parent", "child", "root", true},
|
|
{"grand writes 2-hop ancestor", "grand", "root", true},
|
|
{"sibling write denied", "child", "sib", false},
|
|
{"descendant write denied", "root", "child", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := canWriteDocuments(ctx, g, tc.caller, tc.tgt)
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("got %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAncestorIDs_HopCap: corrupt data with a parent cycle must not
|
|
// loop forever. The walk caps at ancestorWalkHopCap and bails cleanly.
|
|
func TestAncestorIDs_HopCap(t *testing.T) {
|
|
// Build a long chain longer than the hop cap. The walk should
|
|
// terminate at the cap without erroring.
|
|
const n = ancestorWalkHopCap + 10
|
|
tasks := make([]*models.Task, n)
|
|
for i := 0; i < n; i++ {
|
|
parent := ""
|
|
if i > 0 {
|
|
parent = "t" + itoa(i-1)
|
|
}
|
|
tasks[i] = newTask("t"+itoa(i), parent, "ws-1")
|
|
}
|
|
g := newGraph(tasks...)
|
|
got, err := ancestorIDs(context.Background(), g, "t"+itoa(n-1))
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if len(got) != ancestorWalkHopCap {
|
|
t.Errorf("ancestor walk len = %d, want %d (hop cap)", len(got), ancestorWalkHopCap)
|
|
}
|
|
}
|
|
|
|
// TestAncestorIDs_CycleHandled: an explicit parent cycle a → b → a must
|
|
// not loop forever.
|
|
func TestAncestorIDs_CycleHandled(t *testing.T) {
|
|
g := newGraph(
|
|
&models.Task{ID: "a", ParentID: "b", WorkspaceID: "ws-1"},
|
|
&models.Task{ID: "b", ParentID: "a", WorkspaceID: "ws-1"},
|
|
)
|
|
got, err := ancestorIDs(context.Background(), g, "a")
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
// Either ["b"] (cycle detected after one step) or the full chain
|
|
// truncated by the cap is acceptable; the important property is no
|
|
// hang and bounded length.
|
|
if len(got) == 0 || len(got) > ancestorWalkHopCap {
|
|
t.Errorf("unexpected walk length: %v", got)
|
|
}
|
|
}
|
|
|
|
// itoa avoids pulling in strconv.
|
|
func itoa(i int) string {
|
|
if i == 0 {
|
|
return "0"
|
|
}
|
|
neg := i < 0
|
|
if neg {
|
|
i = -i
|
|
}
|
|
var b [20]byte
|
|
pos := len(b)
|
|
for i > 0 {
|
|
pos--
|
|
b[pos] = byte('0' + i%10)
|
|
i /= 10
|
|
}
|
|
if neg {
|
|
pos--
|
|
b[pos] = '-'
|
|
}
|
|
return string(b[pos:])
|
|
}
|