feat(my-issues): cover squad assignees via involves_user_id (MUL-2397) (#2829)

Re-introduces the `involves_user_id` filter on the issues list / open-list /
count / grouped paths, but with the semantics nailed down for the second time
around: tab 3 surfaces issues whose assignee is an *indirect* extension of the
user (owned agent, or a squad they're a human member of / lead via owned agent
/ have an owned agent inside) — and explicitly NOT direct member assignment,
which is tab 1's meaning.

- server/pkg/db/queries/issue.sql: 4-branch filter on ListIssues /
  ListOpenIssues / CountIssues. Each subquery clamps workspace_id because
  issue.assignee_id is polymorphic with no FK. Leader resolution reads
  squad.leader_id directly, not the squad_member copy row (squad.go ignores
  errors when seeding that copy, so it can be missing). FindActiveDuplicateIssue
  switched from positional $2/$3/$4 to named sqlc.arg() — pure hygiene so the
  generated struct field names don't drift when new nargs are added.
- server/internal/handler/issue.go: parse involves_user_id and plumb it into
  the three sqlc params; ListGroupedIssues (hand-written dynamic SQL) gets a
  mirrored 4-branch fragment, no shortcut.
- packages/core: ListIssuesParams / ListGroupedIssuesParams / MyIssuesFilter /
  api.listIssues / api.listGroupedIssues all carry the new param through.
- packages/views/my-issues: tab 3 switches from client-side agent-fanout to
  involves_user_id=user.id. agentListOptions import and the myAgentIds memo
  go away.
- server/internal/handler/issue_involves_test.go: 13 integration tests cover
  every branch (positive + cross-workspace negatives) plus the critical
  ExcludesDirectMemberAssignee negative on BOTH the sqlc and the grouped paths,
  locking tab 3 ∩ tab 1 = ∅.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-05-19 10:37:38 +08:00
committed by GitHub
parent 35fc318d68
commit 93153d08b7
8 changed files with 895 additions and 129 deletions

View File

@@ -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<unknown>(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(","));
}

View File

@@ -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<

View File

@@ -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=<user>` (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[];

View File

@@ -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<AssigneeGroupedIssuesFilter>(
() => ({

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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