diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index bab49472d..0e2f596d3 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -480,6 +480,7 @@ export class ApiClient { if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); if (params?.creator_id) search.set("creator_id", params.creator_id); if (params?.project_id) search.set("project_id", params.project_id); + if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id); if (params?.open_only) search.set("open_only", "true"); const path = `/api/issues?${search}`; const raw = await this.fetch(path); @@ -500,6 +501,7 @@ export class ApiClient { if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(",")); if (params.creator_id) search.set("creator_id", params.creator_id); if (params.project_id) search.set("project_id", params.project_id); + if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id); if (params.assignee_filters?.length) { search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(",")); } diff --git a/packages/core/issues/queries.ts b/packages/core/issues/queries.ts index 12de9387f..684e850da 100644 --- a/packages/core/issues/queries.ts +++ b/packages/core/issues/queries.ts @@ -55,7 +55,7 @@ export const issueKeys = { export type MyIssuesFilter = Pick< ListIssuesParams, - "assignee_id" | "assignee_ids" | "creator_id" | "project_id" + "assignee_id" | "assignee_ids" | "creator_id" | "project_id" | "involves_user_id" >; export type AssigneeGroupedIssuesFilter = Omit< diff --git a/packages/core/types/api.ts b/packages/core/types/api.ts index 698467def..6a83437e4 100644 --- a/packages/core/types/api.ts +++ b/packages/core/types/api.ts @@ -45,6 +45,15 @@ export interface ListIssuesParams { assignee_ids?: string[]; creator_id?: string; project_id?: string; + /** + * Widen the assignee filter to issues where the user is the *indirect* + * assignee — assignee is one of the user's owned agents, or a squad that + * involves the user (human member / leader-via-owned-agent / agent member + * owned by the user). Direct member assignment is intentionally excluded: + * `involves_user_id` and `assignee_id=` (tab "Assigned to me") produce + * disjoint result sets by construction. + */ + involves_user_id?: string; open_only?: boolean; } @@ -65,6 +74,8 @@ export interface ListGroupedIssuesParams { assignee_ids?: string[]; creator_id?: string; project_id?: string; + /** See `ListIssuesParams.involves_user_id` — same semantics. */ + involves_user_id?: string; assignee_filters?: IssueActorRef[]; include_no_assignee?: boolean; creator_filters?: IssueActorRef[]; diff --git a/packages/views/my-issues/components/my-issues-page.tsx b/packages/views/my-issues/components/my-issues-page.tsx index 9a3dc6426..8bc0535ad 100644 --- a/packages/views/my-issues/components/my-issues-page.tsx +++ b/packages/views/my-issues/components/my-issues-page.tsx @@ -10,7 +10,6 @@ import { useAuthStore } from "@multica/core/auth"; import { useCurrentWorkspace } from "@multica/core/paths"; import { WorkspaceAvatar } from "../../workspace/workspace-avatar"; import { useQuery } from "@tanstack/react-query"; -import { agentListOptions } from "@multica/core/workspace/queries"; import { filterIssues } from "../../issues/utils/filter"; import { BOARD_STATUSES } from "@multica/core/issues/config"; import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context"; @@ -32,8 +31,6 @@ export function MyIssuesPage() { const user = useAuthStore((s) => s.user); const workspace = useCurrentWorkspace(); const wsId = useWorkspaceId(); - const { data: agents = [] } = useQuery(agentListOptions(wsId)); - const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode); const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters); const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters); @@ -48,15 +45,11 @@ export function MyIssuesPage() { useIssueSelectionStore.getState().clear(); }, [viewMode, scope]); - // Build server-side filter based on scope - const myAgentIds = useMemo(() => { - if (!user) return [] as string[]; - return agents - .filter((a) => a.owner_id === user.id) - .map((a) => a.id) - .sort(); - }, [agents, user]); - + // Build server-side filter based on scope. The `agents` tab uses + // `involves_user_id` so the server expands the user's identity to all + // assignees that indirectly belong to them (owned agents + related squads). + // Direct member assignment is intentionally excluded — that is the + // `assigned` tab's semantics. const filter: MyIssuesFilter = useMemo(() => { if (!user) return {}; switch (scope) { @@ -65,11 +58,11 @@ export function MyIssuesPage() { case "created": return { creator_id: user.id }; case "agents": - return { assignee_ids: myAgentIds }; + return { involves_user_id: user.id }; default: return { assignee_id: user.id }; } - }, [scope, user, myAgentIds]); + }, [scope, user]); const assigneeGroupFilter = useMemo( () => ({ diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 0dec18e5b..1d256d491 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -741,16 +741,30 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } projectFilter = id } + // involves_user_id widens the assignee filter to surface issues where the + // user is the indirect assignee (their owned agent, or a squad they belong + // to / lead / have an agent inside). Direct member-assignment is excluded + // by design — that is the meaning of `assignee_id` (tab 1), and tab 3 must + // be disjoint from tab 1. + var involvesUserFilter pgtype.UUID + if u := r.URL.Query().Get("involves_user_id"); u != "" { + id, ok := parseUUIDOrBadRequest(w, u, "involves_user_id") + if !ok { + return + } + involvesUserFilter = id + } // open_only=true returns all non-done/cancelled issues (no limit). if r.URL.Query().Get("open_only") == "true" { issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ - WorkspaceID: wsUUID, - Priority: priorityFilter, - AssigneeID: assigneeFilter, - AssigneeIds: assigneeIdsFilter, - CreatorID: creatorFilter, - ProjectID: projectFilter, + WorkspaceID: wsUUID, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, + InvolvesUserID: involvesUserFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -799,15 +813,16 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{ - WorkspaceID: wsUUID, - Limit: int32(limit), - Offset: int32(offset), - Status: statusFilter, - Priority: priorityFilter, - AssigneeID: assigneeFilter, - AssigneeIds: assigneeIdsFilter, - CreatorID: creatorFilter, - ProjectID: projectFilter, + WorkspaceID: wsUUID, + Limit: int32(limit), + Offset: int32(offset), + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, + InvolvesUserID: involvesUserFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -816,13 +831,14 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { // Get the true total count for pagination awareness. total, err := h.Queries.CountIssues(ctx, db.CountIssuesParams{ - WorkspaceID: wsUUID, - Status: statusFilter, - Priority: priorityFilter, - AssigneeID: assigneeFilter, - AssigneeIds: assigneeIdsFilter, - CreatorID: creatorFilter, - ProjectID: projectFilter, + WorkspaceID: wsUUID, + Status: statusFilter, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, + InvolvesUserID: involvesUserFilter, }) if err != nil { total = int64(len(issues)) @@ -1015,6 +1031,50 @@ func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) { } where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(id))) } + // Mirror the involves_user_id 4-branch UNION from sqlc's ListIssues / + // ListOpenIssues / CountIssues. ListGroupedIssues is a hand-written dynamic + // SQL builder that does not share parameters with sqlc, so the fragment is + // re-implemented here in lock-step. Member-direct assignment is excluded by + // design: that semantics belongs to tab 1 (`assignee_id`), and tab 3 must + // stay disjoint from tab 1. + if raw := r.URL.Query().Get("involves_user_id"); raw != "" { + id, ok := parseUUIDOrBadRequest(w, raw, "involves_user_id") + if !ok { + return + } + ref := addArg(id) + where = append(where, fmt.Sprintf(`( + (i.assignee_type = 'agent' AND i.assignee_id IN ( + SELECT a.id FROM agent a + WHERE a.workspace_id = $1 + AND a.owner_id = %[1]s::uuid + )) + OR (i.assignee_type = 'squad' AND i.assignee_id IN ( + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'member' + AND sm.member_id = %[1]s::uuid + UNION + SELECT s.id + FROM squad s + JOIN agent a ON a.id = s.leader_id + WHERE s.workspace_id = $1 + AND a.workspace_id = $1 + AND a.owner_id = %[1]s::uuid + UNION + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + JOIN agent a ON a.id = sm.member_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'agent' + AND a.workspace_id = $1 + AND a.owner_id = %[1]s::uuid + )) +)`, ref)) + } assigneeFilters, ok := parseActorFilterList(w, r.URL.Query().Get("assignee_filters"), "assignee_filters") if !ok { diff --git a/server/internal/handler/issue_involves_test.go b/server/internal/handler/issue_involves_test.go new file mode 100644 index 000000000..9ee56cdcd --- /dev/null +++ b/server/internal/handler/issue_involves_test.go @@ -0,0 +1,467 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// involvesFixture seeds, for a single test, the data needed to exercise every +// branch of the `involves_user_id` 4-branch filter — owned agent, squad human +// member, squad canonical leader (via squad.leader_id, NOT a squad_member copy +// row), and squad agent member — plus a parallel set in a second workspace so +// the cross-workspace negative tests can prove subquery-level isolation. +type involvesFixture struct { + // All IDs are in the primary handler-test workspace unless noted otherwise. + userID string // the "me" user the filter is keyed on (== testUserID) + otherID string // a different user in the same workspace + + ownedAgentID string // agent.owner_id = userID — branch (1) seed + otherAgentID string // agent.owner_id = otherID — must NOT match + squadMemberID string // squad with userID as human member — branch (2) + squadLeaderID string // squad whose leader_id is an agent owned by userID — branch (3) + squadAgentMemID string // squad with an owned-agent as squad_member row — branch (4) + + // Other workspace, mirror objects — used by ExcludesOtherWorkspace* tests + otherWsID string + otherWsAgent string // owned by userID but in other workspace + otherWsSquadMember string // squad with userID as human member, in other ws + otherWsSquadLeader string // squad whose leader is userID's agent (in other ws) + otherWsSquadAgentMem string // squad with userID's agent as member (in other ws) +} + +func setupInvolvesFixture(t *testing.T) *involvesFixture { + t.Helper() + ctx := context.Background() + suffix := time.Now().UnixNano() + + fx := &involvesFixture{userID: testUserID} + + // --- second user inside the primary workspace --- + var otherUserID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id + `, "Involves Other User", fmt.Sprintf("involves-other-%d@multica.ai", suffix)).Scan(&otherUserID); err != nil { + t.Fatalf("create other user: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, otherUserID) }) + fx.otherID = otherUserID + if _, err := testPool.Exec(ctx, ` + INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member') + `, testWorkspaceID, otherUserID); err != nil { + t.Fatalf("create other member: %v", err) + } + + runtimeID := handlerTestRuntimeID(t) + + // --- agents in primary workspace --- + fx.ownedAgentID = insertAgent(t, ctx, testWorkspaceID, runtimeID, fx.userID, + fmt.Sprintf("Involves Owned Agent %d", suffix)) + fx.otherAgentID = insertAgent(t, ctx, testWorkspaceID, runtimeID, fx.otherID, + fmt.Sprintf("Involves Other Agent %d", suffix)) + + // --- a squad we already have to satisfy NOT NULL leader_id --- + leaderForMemberSquad := insertAgent(t, ctx, testWorkspaceID, runtimeID, fx.otherID, + fmt.Sprintf("Involves Leader-for-MemberSquad %d", suffix)) + fx.squadMemberID = insertSquad(t, ctx, testWorkspaceID, leaderForMemberSquad, + fmt.Sprintf("InvolvesSquadMember-%d", suffix)) + // Add the test user as a human member. + if _, err := testPool.Exec(ctx, ` + INSERT INTO squad_member (squad_id, member_type, member_id) VALUES ($1, 'member', $2) + `, fx.squadMemberID, fx.userID); err != nil { + t.Fatalf("add squad human member: %v", err) + } + + // --- squad with leader = our owned agent — branch (3). Critically, we do + // NOT insert a squad_member row for the leader, so the test exercises the + // canonical squad.leader_id path. --- + fx.squadLeaderID = insertSquad(t, ctx, testWorkspaceID, fx.ownedAgentID, + fmt.Sprintf("InvolvesSquadLeader-%d", suffix)) + + // --- squad whose agent member is our owned agent — branch (4) --- + leaderForAgentMemSquad := insertAgent(t, ctx, testWorkspaceID, runtimeID, fx.otherID, + fmt.Sprintf("Involves Leader-for-AgentMemSquad %d", suffix)) + fx.squadAgentMemID = insertSquad(t, ctx, testWorkspaceID, leaderForAgentMemSquad, + fmt.Sprintf("InvolvesSquadAgentMem-%d", suffix)) + // Use a fresh owned agent so the squad_member row is the only signal — + // keeps branch (4) independent from branch (1)/(3). + branch4Agent := insertAgent(t, ctx, testWorkspaceID, runtimeID, fx.userID, + fmt.Sprintf("Involves Branch4 Agent %d", suffix)) + if _, err := testPool.Exec(ctx, ` + INSERT INTO squad_member (squad_id, member_type, member_id) VALUES ($1, 'agent', $2) + `, fx.squadAgentMemID, branch4Agent); err != nil { + t.Fatalf("add squad agent member: %v", err) + } + + // --- second workspace, mirroring all four shapes for cross-ws negatives --- + var otherWsID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO workspace (name, slug, description, issue_prefix) + VALUES ($1, $2, '', 'OTH') + RETURNING id + `, fmt.Sprintf("InvolvesOtherWs-%d", suffix), fmt.Sprintf("involves-other-ws-%d", suffix)).Scan(&otherWsID); err != nil { + t.Fatalf("create other workspace: %v", err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, otherWsID) }) + fx.otherWsID = otherWsID + + // Membership in other workspace (so the user could legitimately be assigned + // there too — exercises whether subquery workspace_id clause filters it out). + if _, err := testPool.Exec(ctx, ` + INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner') + `, otherWsID, fx.userID); err != nil { + t.Fatalf("create other-ws member: %v", err) + } + + var otherRuntimeID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO agent_runtime ( + workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at + ) VALUES ($1, NULL, $2, 'cloud', 'other_ws_runtime', 'online', $3, '{}'::jsonb, now()) + RETURNING id + `, otherWsID, fmt.Sprintf("OtherWs Runtime %d", suffix), "other-ws-runtime").Scan(&otherRuntimeID); err != nil { + t.Fatalf("create other-ws runtime: %v", err) + } + + fx.otherWsAgent = insertAgent(t, ctx, otherWsID, otherRuntimeID, fx.userID, + fmt.Sprintf("OtherWs Owned Agent %d", suffix)) + + leaderForOtherWsMemberSquad := insertAgent(t, ctx, otherWsID, otherRuntimeID, fx.otherID, + fmt.Sprintf("OtherWs Leader-for-MemberSquad %d", suffix)) + fx.otherWsSquadMember = insertSquad(t, ctx, otherWsID, leaderForOtherWsMemberSquad, + fmt.Sprintf("OtherWsSquadMember-%d", suffix)) + if _, err := testPool.Exec(ctx, ` + INSERT INTO squad_member (squad_id, member_type, member_id) VALUES ($1, 'member', $2) + `, fx.otherWsSquadMember, fx.userID); err != nil { + t.Fatalf("add other-ws squad member: %v", err) + } + + fx.otherWsSquadLeader = insertSquad(t, ctx, otherWsID, fx.otherWsAgent, + fmt.Sprintf("OtherWsSquadLeader-%d", suffix)) + + leaderForOtherWsAgentMemSquad := insertAgent(t, ctx, otherWsID, otherRuntimeID, fx.otherID, + fmt.Sprintf("OtherWs Leader-for-AgentMemSquad %d", suffix)) + fx.otherWsSquadAgentMem = insertSquad(t, ctx, otherWsID, leaderForOtherWsAgentMemSquad, + fmt.Sprintf("OtherWsSquadAgentMem-%d", suffix)) + otherWsBranch4Agent := insertAgent(t, ctx, otherWsID, otherRuntimeID, fx.userID, + fmt.Sprintf("OtherWs Branch4 Agent %d", suffix)) + if _, err := testPool.Exec(ctx, ` + INSERT INTO squad_member (squad_id, member_type, member_id) VALUES ($1, 'agent', $2) + `, fx.otherWsSquadAgentMem, otherWsBranch4Agent); err != nil { + t.Fatalf("add other-ws squad agent member: %v", err) + } + + return fx +} + +func insertAgent(t *testing.T, ctx context.Context, workspaceID, runtimeID, ownerID, name string) string { + t.Helper() + var id string + if err := testPool.QueryRow(ctx, ` + INSERT INTO agent ( + workspace_id, name, description, runtime_mode, runtime_config, + runtime_id, visibility, max_concurrent_tasks, owner_id + ) + VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4) + RETURNING id + `, workspaceID, name, runtimeID, ownerID).Scan(&id); err != nil { + t.Fatalf("create agent %q: %v", name, err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, id) }) + return id +} + +func insertSquad(t *testing.T, ctx context.Context, workspaceID, leaderAgentID, name string) string { + t.Helper() + var id string + // creator_id is loose (no FK) — reuse testUserID to keep the row valid. + if err := testPool.QueryRow(ctx, ` + INSERT INTO squad (workspace_id, name, leader_id, creator_id) + VALUES ($1, $2, $3, $4) + RETURNING id + `, workspaceID, name, leaderAgentID, testUserID).Scan(&id); err != nil { + t.Fatalf("create squad %q: %v", name, err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, id) }) + return id +} + +// insertIssueTo creates an issue in the given workspace assigned to the given +// (assigneeType, assigneeID) pair and returns its UUID. Issue rows are +// best-effort-cleaned up by the test. +func insertIssueTo(t *testing.T, ctx context.Context, workspaceID, title, assigneeType, assigneeID string) string { + t.Helper() + var number int32 + if err := testPool.QueryRow(ctx, ` + UPDATE workspace + SET issue_counter = GREATEST( + issue_counter, + (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1) + ) + 1 + WHERE id = $1 + RETURNING issue_counter + `, workspaceID).Scan(&number); err != nil { + t.Fatalf("next issue number: %v", err) + } + var id string + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue ( + workspace_id, title, description, status, priority, + assignee_type, assignee_id, creator_type, creator_id, + position, number + ) + VALUES ($1, $2, NULL, 'todo', 'none', $3, $4, 'member', $5, 0, $6) + RETURNING id + `, workspaceID, title, assigneeType, assigneeID, testUserID, number).Scan(&id); err != nil { + t.Fatalf("create issue %q: %v", title, err) + } + t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, id) }) + return id +} + +// listIssuesInvolves runs ListIssues with `involves_user_id` set to userID +// (against testWorkspaceID) and returns the resulting issue IDs. +func listIssuesInvolves(t *testing.T, userID string) []string { + t.Helper() + path := fmt.Sprintf("/api/issues?workspace_id=%s&involves_user_id=%s&limit=500", + testWorkspaceID, userID) + w := httptest.NewRecorder() + testHandler.ListIssues(w, newRequest("GET", path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Issues []IssueResponse `json:"issues"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode list response: %v", err) + } + ids := make([]string, 0, len(resp.Issues)) + for _, iss := range resp.Issues { + ids = append(ids, iss.ID) + } + return ids +} + +// listGroupedIssuesInvolves runs ListGroupedIssues with `involves_user_id` +// set to userID and returns the flattened set of issue IDs across all groups. +func listGroupedIssuesInvolves(t *testing.T, userID string) []string { + t.Helper() + path := fmt.Sprintf( + "/api/issues/grouped?workspace_id=%s&group_by=assignee&statuses=todo&involves_user_id=%s&limit=100", + testWorkspaceID, userID) + w := httptest.NewRecorder() + testHandler.ListGroupedIssues(w, newRequest("GET", path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("ListGroupedIssues: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp GroupedIssuesResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode grouped response: %v", err) + } + ids := []string{} + for _, g := range resp.Groups { + for _, iss := range g.Issues { + ids = append(ids, iss.ID) + } + } + return ids +} + +func containsIssueID(ids []string, target string) bool { + for _, id := range ids { + if id == target { + return true + } + } + return false +} + +// ---- positive branches ---- + +func TestListIssues_InvolvesUserID_MatchesOwnedAgentAssignee(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + wantID := insertIssueTo(t, ctx, testWorkspaceID, + "issue assigned to my owned agent", "agent", fx.ownedAgentID) + if got := listIssuesInvolves(t, fx.userID); !containsIssueID(got, wantID) { + t.Fatalf("branch (1) miss: owned-agent assignee not surfaced (want %s, got %v)", wantID, got) + } +} + +func TestListIssues_InvolvesUserID_MatchesSquadMember(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + wantID := insertIssueTo(t, ctx, testWorkspaceID, + "issue assigned to a squad I'm a member of", "squad", fx.squadMemberID) + if got := listIssuesInvolves(t, fx.userID); !containsIssueID(got, wantID) { + t.Fatalf("branch (2) miss: human-member squad assignee not surfaced (want %s, got %v)", wantID, got) + } +} + +func TestListIssues_InvolvesUserID_MatchesLeaderViaCanonicalRelation(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + // Fixture deliberately omits the squad_member leader-copy row, so this + // can only match if the SQL reads squad.leader_id directly (branch 3). + wantID := insertIssueTo(t, ctx, testWorkspaceID, + "issue assigned to a squad my agent leads", "squad", fx.squadLeaderID) + if got := listIssuesInvolves(t, fx.userID); !containsIssueID(got, wantID) { + t.Fatalf("branch (3) miss: squad-leader-via-canonical assignee not surfaced (want %s, got %v)", wantID, got) + } +} + +func TestListIssues_InvolvesUserID_MatchesSquadAgentMember(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + wantID := insertIssueTo(t, ctx, testWorkspaceID, + "issue assigned to a squad my agent is a member of", "squad", fx.squadAgentMemID) + if got := listIssuesInvolves(t, fx.userID); !containsIssueID(got, wantID) { + t.Fatalf("branch (4) miss: squad agent-member assignee not surfaced (want %s, got %v)", wantID, got) + } +} + +// ---- the critical negative: tab 3 must be disjoint from tab 1 ---- + +// Nails the semantics: `involves_user_id` MUST NOT surface issues whose +// assignee is the user themself (member type). Direct member assignment is +// the meaning of `assignee_id` (tab 1 "Assigned to me"); the two tabs must +// produce disjoint result sets. If anyone adds a fifth UNION branch +// `(assignee_type='member' AND assignee_id=involves_user_id)` back in, this +// test fails. +func TestListIssues_InvolvesUserID_ExcludesDirectMemberAssignee(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + issueID := insertIssueTo(t, ctx, testWorkspaceID, + "tab 3 must NOT surface member-direct assignment", "member", fx.userID) + if got := listIssuesInvolves(t, fx.userID); containsIssueID(got, issueID) { + t.Fatalf("tab 3 semantics violated: involves_user_id surfaced a member-direct assignee issue (id=%s); that belongs to tab 1. Full result: %v", + issueID, got) + } +} + +// Same negative on the grouped (dynamic SQL) path — the dynamic builder is a +// separate code path from sqlc, so it gets its own regression. +func TestListGroupedIssues_InvolvesUserID_ExcludesDirectMemberAssignee(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + issueID := insertIssueTo(t, ctx, testWorkspaceID, + "grouped tab 3 must NOT surface member-direct assignment", "member", fx.userID) + if got := listGroupedIssuesInvolves(t, fx.userID); containsIssueID(got, issueID) { + t.Fatalf("grouped tab 3 semantics violated: involves_user_id surfaced a member-direct assignee issue (id=%s); full result: %v", + issueID, got) + } +} + +// ---- workspace isolation negatives — each subquery must clamp workspace_id ---- + +func TestListIssues_InvolvesUserID_ExcludesOtherWorkspaceAgent(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + // Issue lives in the *primary* workspace but is assigned to an agent UUID + // that only exists in the OTHER workspace and is owned by our user. If the + // agent subquery is missing `a.workspace_id = $1`, this match would leak. + issueID := insertIssueTo(t, ctx, testWorkspaceID, + "cross-ws agent assignee must not leak", "agent", fx.otherWsAgent) + if got := listIssuesInvolves(t, fx.userID); containsIssueID(got, issueID) { + t.Fatalf("workspace isolation violated: cross-workspace agent surfaced (id=%s); full result: %v", + issueID, got) + } +} + +func TestListIssues_InvolvesUserID_ExcludesOtherWorkspaceLeader(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + issueID := insertIssueTo(t, ctx, testWorkspaceID, + "cross-ws squad-leader assignee must not leak", "squad", fx.otherWsSquadLeader) + if got := listIssuesInvolves(t, fx.userID); containsIssueID(got, issueID) { + t.Fatalf("workspace isolation violated: cross-workspace squad-leader surfaced (id=%s); full result: %v", + issueID, got) + } +} + +func TestListIssues_InvolvesUserID_ExcludesOtherWorkspaceSquadMember(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + issueID := insertIssueTo(t, ctx, testWorkspaceID, + "cross-ws squad-human-member assignee must not leak", "squad", fx.otherWsSquadMember) + if got := listIssuesInvolves(t, fx.userID); containsIssueID(got, issueID) { + t.Fatalf("workspace isolation violated: cross-workspace squad-human-member surfaced (id=%s); full result: %v", + issueID, got) + } +} + +func TestListIssues_InvolvesUserID_ExcludesOtherWorkspaceSquadAgentMember(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + issueID := insertIssueTo(t, ctx, testWorkspaceID, + "cross-ws squad-agent-member assignee must not leak", "squad", fx.otherWsSquadAgentMem) + if got := listIssuesInvolves(t, fx.userID); containsIssueID(got, issueID) { + t.Fatalf("workspace isolation violated: cross-workspace squad-agent-member surfaced (id=%s); full result: %v", + issueID, got) + } +} + +// ---- combo + boundary ---- + +// involves_user_id and creator_id must AND together — combining narrowing +// filters should never widen the result. +func TestListIssues_InvolvesUserID_CombinesWithCreatorID(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + // Issue with creator = otherID: involves matches (branch 1) but creator + // filter must exclude it. + exclude := insertIssueTo(t, ctx, testWorkspaceID, + "involves matches but creator does not", "agent", fx.ownedAgentID) + // Patch the creator to otherID directly. + if _, err := testPool.Exec(ctx, `UPDATE issue SET creator_id = $1 WHERE id = $2`, fx.otherID, exclude); err != nil { + t.Fatalf("patch creator: %v", err) + } + + path := fmt.Sprintf("/api/issues?workspace_id=%s&involves_user_id=%s&creator_id=%s&limit=500", + testWorkspaceID, fx.userID, fx.userID) + w := httptest.NewRecorder() + testHandler.ListIssues(w, newRequest("GET", path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Issues []IssueResponse `json:"issues"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode list response: %v", err) + } + got := make([]string, 0, len(resp.Issues)) + for _, iss := range resp.Issues { + got = append(got, iss.ID) + } + if containsIssueID(got, exclude) { + t.Fatalf("combined filter widened result: issue %s with non-matching creator surfaced; full result: %v", + exclude, got) + } +} + +func TestListIssues_InvolvesUserID_InvalidUUIDReturns400(t *testing.T) { + path := fmt.Sprintf("/api/issues?workspace_id=%s&involves_user_id=not-a-uuid", testWorkspaceID) + w := httptest.NewRecorder() + testHandler.ListIssues(w, newRequest("GET", path, nil)) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on invalid UUID, got %d: %s", w.Code, w.Body.String()) + } +} + +// Grouped path also exercises canonical-leader resolution, so a single positive +// guards the dynamic SQL builder against accidentally dropping branch (3). +func TestListGroupedIssues_InvolvesUserID_MatchesLeaderViaCanonicalRelation(t *testing.T) { + ctx := context.Background() + fx := setupInvolvesFixture(t) + wantID := insertIssueTo(t, ctx, testWorkspaceID, + "grouped: squad my agent leads", "squad", fx.squadLeaderID) + if got := listGroupedIssuesInvolves(t, fx.userID); !containsIssueID(got, wantID) { + t.Fatalf("grouped branch (3) miss: squad-leader-via-canonical not surfaced (want %s, got %v)", wantID, got) + } +} diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 45184f0a7..f6663989d 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -94,26 +94,60 @@ func (q *Queries) CountCreatedIssueAssignees(ctx context.Context, arg CountCreat } const countIssues = `-- name: CountIssues :one -SELECT count(*) FROM issue -WHERE workspace_id = $1 - AND ($2::text IS NULL OR status = $2) - AND ($3::text IS NULL OR priority = $3) - AND ($4::uuid IS NULL OR assignee_id = $4) - AND ($5::uuid[] IS NULL OR assignee_id = ANY($5::uuid[])) - AND ($6::uuid IS NULL OR creator_id = $6) - AND ($7::uuid IS NULL OR project_id = $7) +SELECT count(*) FROM issue i +WHERE i.workspace_id = $1 + AND ($2::text IS NULL OR i.status = $2) + AND ($3::text IS NULL OR i.priority = $3) + AND ($4::uuid IS NULL OR i.assignee_id = $4) + AND ($5::uuid[] IS NULL OR i.assignee_id = ANY($5::uuid[])) + AND ($6::uuid IS NULL OR i.creator_id = $6) + AND ($7::uuid IS NULL OR i.project_id = $7) + AND ( + $8::uuid IS NULL + OR (i.assignee_type = 'agent' AND i.assignee_id IN ( + SELECT a.id FROM agent a + WHERE a.workspace_id = $1 + AND a.owner_id = $8::uuid + )) + OR (i.assignee_type = 'squad' AND i.assignee_id IN ( + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'member' + AND sm.member_id = $8::uuid + UNION + SELECT s.id + FROM squad s + JOIN agent a ON a.id = s.leader_id + WHERE s.workspace_id = $1 + AND a.workspace_id = $1 + AND a.owner_id = $8::uuid + UNION + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + JOIN agent a ON a.id = sm.member_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'agent' + AND a.workspace_id = $1 + AND a.owner_id = $8::uuid + )) + ) ` type CountIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Status pgtype.Text `json:"status"` - Priority pgtype.Text `json:"priority"` - AssigneeID pgtype.UUID `json:"assignee_id"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - CreatorID pgtype.UUID `json:"creator_id"` - ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` + InvolvesUserID pgtype.UUID `json:"involves_user_id"` } +// See ListIssues for the semantics of involves_user_id. func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64, error) { row := q.db.QueryRow(ctx, countIssues, arg.WorkspaceID, @@ -123,6 +157,7 @@ func (q *Queries) CountIssues(ctx context.Context, arg CountIssuesParams) (int64 arg.AssigneeIds, arg.CreatorID, arg.ProjectID, + arg.InvolvesUserID, ) var count int64 err := row.Scan(&count) @@ -295,7 +330,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error { } const findActiveDuplicateIssue = `-- name: FindActiveDuplicateIssue :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') AND project_id IS NOT DISTINCT FROM $2::uuid @@ -343,6 +378,7 @@ func (q *Queries) FindActiveDuplicateIssue(ctx context.Context, arg FindActiveDu &i.OriginType, &i.OriginID, &i.FirstExecutedAt, + &i.StartDate, ) return i, err } @@ -566,31 +602,72 @@ func (q *Queries) ListChildIssues(ctx context.Context, parentIssueID pgtype.UUID } const listIssues = `-- name: ListIssues :many -SELECT id, workspace_id, title, description, status, priority, - assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, start_date, due_date, created_at, updated_at, number, project_id -FROM issue -WHERE workspace_id = $1 - AND ($4::text IS NULL OR status = $4) - AND ($5::text IS NULL OR priority = $5) - AND ($6::uuid IS NULL OR assignee_id = $6) - AND ($7::uuid[] IS NULL OR assignee_id = ANY($7::uuid[])) - AND ($8::uuid IS NULL OR creator_id = $8) - AND ($9::uuid IS NULL OR project_id = $9) -ORDER BY position ASC, created_at DESC +SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, + i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, + i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id +FROM issue i +WHERE i.workspace_id = $1 + AND ($4::text IS NULL OR i.status = $4) + AND ($5::text IS NULL OR i.priority = $5) + AND ($6::uuid IS NULL OR i.assignee_id = $6) + AND ($7::uuid[] IS NULL OR i.assignee_id = ANY($7::uuid[])) + AND ($8::uuid IS NULL OR i.creator_id = $8) + AND ($9::uuid IS NULL OR i.project_id = $9) + AND ( + $10::uuid IS NULL + -- (1) assignee is an agent owned by the user + OR (i.assignee_type = 'agent' AND i.assignee_id IN ( + SELECT a.id FROM agent a + WHERE a.workspace_id = $1 + AND a.owner_id = $10::uuid + )) + -- (2)(3)(4) assignee is a squad related to the user — three relations + OR (i.assignee_type = 'squad' AND i.assignee_id IN ( + -- (2) the user is a human member of the squad + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'member' + AND sm.member_id = $10::uuid + UNION + -- (3) the squad's canonical leader is an agent owned by the user. + -- We read squad.leader_id directly rather than relying on a + -- squad_member row, because the leader copy in squad_member is + -- best-effort (see squad.go AddSquadMember error handling). + SELECT s.id + FROM squad s + JOIN agent a ON a.id = s.leader_id + WHERE s.workspace_id = $1 + AND a.workspace_id = $1 + AND a.owner_id = $10::uuid + UNION + -- (4) the squad has an agent member owned by the user + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + JOIN agent a ON a.id = sm.member_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'agent' + AND a.workspace_id = $1 + AND a.owner_id = $10::uuid + )) + ) +ORDER BY i.position ASC, i.created_at DESC LIMIT $2 OFFSET $3 ` type ListIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` - Status pgtype.Text `json:"status"` - Priority pgtype.Text `json:"priority"` - AssigneeID pgtype.UUID `json:"assignee_id"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - CreatorID pgtype.UUID `json:"creator_id"` - ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + Status pgtype.Text `json:"status"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` + InvolvesUserID pgtype.UUID `json:"involves_user_id"` } type ListIssuesRow struct { @@ -614,6 +691,12 @@ type ListIssuesRow struct { ProjectID pgtype.UUID `json:"project_id"` } +// involves_user_id widens the assignee filter to surface issues where the user +// is *indirectly* the assignee — via an owned agent or a squad they belong to / +// lead / have an agent inside. The semantics intentionally exclude direct +// member assignment (`assignee_type='member' AND assignee_id=involves_user_id`) +// because that is already the meaning of the `assignee_id` filter (tab 1 +// "Assigned to me"), and the two filters must produce disjoint result sets. func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListIssuesRow, error) { rows, err := q.db.Query(ctx, listIssues, arg.WorkspaceID, @@ -625,6 +708,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI arg.AssigneeIds, arg.CreatorID, arg.ProjectID, + arg.InvolvesUserID, ) if err != nil { return nil, err @@ -664,27 +748,60 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]ListI } const listOpenIssues = `-- name: ListOpenIssues :many -SELECT id, workspace_id, title, description, status, priority, - assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, start_date, due_date, created_at, updated_at, number, project_id -FROM issue -WHERE workspace_id = $1 - AND status NOT IN ('done', 'cancelled') - AND ($2::text IS NULL OR priority = $2) - AND ($3::uuid IS NULL OR assignee_id = $3) - AND ($4::uuid[] IS NULL OR assignee_id = ANY($4::uuid[])) - AND ($5::uuid IS NULL OR creator_id = $5) - AND ($6::uuid IS NULL OR project_id = $6) -ORDER BY position ASC, created_at DESC +SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, + i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, + i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id +FROM issue i +WHERE i.workspace_id = $1 + AND i.status NOT IN ('done', 'cancelled') + AND ($2::text IS NULL OR i.priority = $2) + AND ($3::uuid IS NULL OR i.assignee_id = $3) + AND ($4::uuid[] IS NULL OR i.assignee_id = ANY($4::uuid[])) + AND ($5::uuid IS NULL OR i.creator_id = $5) + AND ($6::uuid IS NULL OR i.project_id = $6) + AND ( + $7::uuid IS NULL + OR (i.assignee_type = 'agent' AND i.assignee_id IN ( + SELECT a.id FROM agent a + WHERE a.workspace_id = $1 + AND a.owner_id = $7::uuid + )) + OR (i.assignee_type = 'squad' AND i.assignee_id IN ( + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'member' + AND sm.member_id = $7::uuid + UNION + SELECT s.id + FROM squad s + JOIN agent a ON a.id = s.leader_id + WHERE s.workspace_id = $1 + AND a.workspace_id = $1 + AND a.owner_id = $7::uuid + UNION + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + JOIN agent a ON a.id = sm.member_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'agent' + AND a.workspace_id = $1 + AND a.owner_id = $7::uuid + )) + ) +ORDER BY i.position ASC, i.created_at DESC ` type ListOpenIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Priority pgtype.Text `json:"priority"` - AssigneeID pgtype.UUID `json:"assignee_id"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - CreatorID pgtype.UUID `json:"creator_id"` - ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` + InvolvesUserID pgtype.UUID `json:"involves_user_id"` } type ListOpenIssuesRow struct { @@ -708,6 +825,8 @@ type ListOpenIssuesRow struct { ProjectID pgtype.UUID `json:"project_id"` } +// See ListIssues for the semantics of involves_user_id (mirrors the 4-branch +// filter; member-direct assignment is intentionally excluded). func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) { rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, @@ -716,6 +835,7 @@ func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) arg.AssigneeIds, arg.CreatorID, arg.ProjectID, + arg.InvolvesUserID, ) if err != nil { return nil, err @@ -758,8 +878,8 @@ const lockIssueDuplicateKey = `-- name: LockIssueDuplicateKey :exec SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0)) ` -func (q *Queries) LockIssueDuplicateKey(ctx context.Context, key string) error { - _, err := q.db.Exec(ctx, lockIssueDuplicateKey, key) +func (q *Queries) LockIssueDuplicateKey(ctx context.Context, dollar_1 string) error { + _, err := q.db.Exec(ctx, lockIssueDuplicateKey, dollar_1) return err } diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index dc0dcc784..245e5bc4c 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -1,16 +1,62 @@ -- name: ListIssues :many -SELECT id, workspace_id, title, description, status, priority, - assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, start_date, due_date, created_at, updated_at, number, project_id -FROM issue -WHERE workspace_id = $1 - AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) - AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) - AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) - AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) - AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) -ORDER BY position ASC, created_at DESC +-- involves_user_id widens the assignee filter to surface issues where the user +-- is *indirectly* the assignee — via an owned agent or a squad they belong to / +-- lead / have an agent inside. The semantics intentionally exclude direct +-- member assignment (`assignee_type='member' AND assignee_id=involves_user_id`) +-- because that is already the meaning of the `assignee_id` filter (tab 1 +-- "Assigned to me"), and the two filters must produce disjoint result sets. +SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, + i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, + i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id +FROM issue i +WHERE i.workspace_id = $1 + AND (sqlc.narg('status')::text IS NULL OR i.status = sqlc.narg('status')) + AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR i.creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id')) + AND ( + sqlc.narg('involves_user_id')::uuid IS NULL + -- (1) assignee is an agent owned by the user + OR (i.assignee_type = 'agent' AND i.assignee_id IN ( + SELECT a.id FROM agent a + WHERE a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + )) + -- (2)(3)(4) assignee is a squad related to the user — three relations + OR (i.assignee_type = 'squad' AND i.assignee_id IN ( + -- (2) the user is a human member of the squad + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'member' + AND sm.member_id = sqlc.narg('involves_user_id')::uuid + UNION + -- (3) the squad's canonical leader is an agent owned by the user. + -- We read squad.leader_id directly rather than relying on a + -- squad_member row, because the leader copy in squad_member is + -- best-effort (see squad.go AddSquadMember error handling). + SELECT s.id + FROM squad s + JOIN agent a ON a.id = s.leader_id + WHERE s.workspace_id = $1 + AND a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + UNION + -- (4) the squad has an agent member owned by the user + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + JOIN agent a ON a.id = sm.member_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'agent' + AND a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + )) + ) +ORDER BY i.position ASC, i.created_at DESC LIMIT $2 OFFSET $3; -- name: GetIssue :one @@ -76,9 +122,9 @@ SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0)); SELECT * FROM issue WHERE workspace_id = $1 AND status NOT IN ('done', 'cancelled') - AND project_id IS NOT DISTINCT FROM $2::uuid - AND parent_issue_id IS NOT DISTINCT FROM $3::uuid - AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = $4 + AND project_id IS NOT DISTINCT FROM sqlc.arg('project_id')::uuid + AND parent_issue_id IS NOT DISTINCT FROM sqlc.arg('parent_issue_id')::uuid + AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = sqlc.arg('normalized_title') ORDER BY created_at ASC LIMIT 1; @@ -86,28 +132,95 @@ LIMIT 1; DELETE FROM issue WHERE id = $1; -- name: ListOpenIssues :many -SELECT id, workspace_id, title, description, status, priority, - assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, start_date, due_date, created_at, updated_at, number, project_id -FROM issue -WHERE workspace_id = $1 - AND status NOT IN ('done', 'cancelled') - AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) - AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) - AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) - AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) - AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')) -ORDER BY position ASC, created_at DESC; +-- See ListIssues for the semantics of involves_user_id (mirrors the 4-branch +-- filter; member-direct assignment is intentionally excluded). +SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, + i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, + i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at, i.updated_at, i.number, i.project_id +FROM issue i +WHERE i.workspace_id = $1 + AND i.status NOT IN ('done', 'cancelled') + AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR i.creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id')) + AND ( + sqlc.narg('involves_user_id')::uuid IS NULL + OR (i.assignee_type = 'agent' AND i.assignee_id IN ( + SELECT a.id FROM agent a + WHERE a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + )) + OR (i.assignee_type = 'squad' AND i.assignee_id IN ( + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'member' + AND sm.member_id = sqlc.narg('involves_user_id')::uuid + UNION + SELECT s.id + FROM squad s + JOIN agent a ON a.id = s.leader_id + WHERE s.workspace_id = $1 + AND a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + UNION + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + JOIN agent a ON a.id = sm.member_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'agent' + AND a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + )) + ) +ORDER BY i.position ASC, i.created_at DESC; -- name: CountIssues :one -SELECT count(*) FROM issue -WHERE workspace_id = $1 - AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) - AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority')) - AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id')) - AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) - AND (sqlc.narg('creator_id')::uuid IS NULL OR creator_id = sqlc.narg('creator_id')) - AND (sqlc.narg('project_id')::uuid IS NULL OR project_id = sqlc.narg('project_id')); +-- See ListIssues for the semantics of involves_user_id. +SELECT count(*) FROM issue i +WHERE i.workspace_id = $1 + AND (sqlc.narg('status')::text IS NULL OR i.status = sqlc.narg('status')) + AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority')) + AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id')) + AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) + AND (sqlc.narg('creator_id')::uuid IS NULL OR i.creator_id = sqlc.narg('creator_id')) + AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id')) + AND ( + sqlc.narg('involves_user_id')::uuid IS NULL + OR (i.assignee_type = 'agent' AND i.assignee_id IN ( + SELECT a.id FROM agent a + WHERE a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + )) + OR (i.assignee_type = 'squad' AND i.assignee_id IN ( + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'member' + AND sm.member_id = sqlc.narg('involves_user_id')::uuid + UNION + SELECT s.id + FROM squad s + JOIN agent a ON a.id = s.leader_id + WHERE s.workspace_id = $1 + AND a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + UNION + SELECT sm.squad_id + FROM squad_member sm + JOIN squad s ON s.id = sm.squad_id + JOIN agent a ON a.id = sm.member_id + WHERE s.workspace_id = $1 + AND sm.member_type = 'agent' + AND a.workspace_id = $1 + AND a.owner_id = sqlc.narg('involves_user_id')::uuid + )) + ); -- name: ListChildIssues :many SELECT * FROM issue