mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-08 14:56:19 +02:00
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:
@@ -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(","));
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>(
|
||||
() => ({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
467
server/internal/handler/issue_involves_test.go
Normal file
467
server/internal/handler/issue_involves_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user