From 46d855cc2374415dc8415ef84a5e90349b93c834 Mon Sep 17 00:00:00 2001 From: hallong Date: Sat, 20 Jun 2026 00:29:42 +0800 Subject: [PATCH 1/3] feat: add issue skill embedding API --- server/cmd/server/router.go | 2 + server/go.mod | 1 + server/go.sum | 2 + server/internal/handler/issue_skill_test.go | 242 ++++++++++++++++++ server/internal/handler/skill.go | 221 ++++++++++++++++ .../122_issue_skill_embeddings.down.sql | 2 + .../122_issue_skill_embeddings.up.sql | 27 ++ server/pkg/db/generated/models.go | 18 ++ server/pkg/db/generated/skill.sql.go | 158 ++++++++++++ server/pkg/db/queries/skill.sql | 39 +++ 10 files changed, 712 insertions(+) create mode 100644 server/internal/handler/issue_skill_test.go create mode 100644 server/migrations/122_issue_skill_embeddings.down.sql create mode 100644 server/migrations/122_issue_skill_embeddings.up.sql diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 3ba8cb455a..ff66f3bc80 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -741,6 +741,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Put("/metadata/{key}", h.SetIssueMetadataKey) r.Delete("/metadata/{key}", h.DeleteIssueMetadataKey) r.Get("/pull-requests", h.ListPullRequestsForIssue) + r.Post("/skills", h.CreateIssueSkill) }) }) @@ -886,6 +887,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus r.Get("/", h.ListSkills) r.Post("/", h.CreateSkill) r.Get("/search", h.SearchSkills) + r.Post("/vector-search", h.SearchSkillEmbeddings) r.Post("/import", h.ImportSkill) r.Route("/{id}", func(r chi.Router) { r.Get("/", h.GetSkill) diff --git a/server/go.mod b/server/go.mod index 0554c307d0..c0304a5d94 100644 --- a/server/go.mod +++ b/server/go.mod @@ -55,6 +55,7 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pgvector/pgvector-go v0.4.0 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.9 // indirect diff --git a/server/go.sum b/server/go.sum index 635fc359c9..31bb87e840 100644 --- a/server/go.sum +++ b/server/go.sum @@ -96,6 +96,8 @@ github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pgvector/pgvector-go v0.4.0 h1:879hQCnuix1bkfa5TQISnnK9ik4Fo+cHj2vuZSgW5v4= +github.com/pgvector/pgvector-go v0.4.0/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= diff --git a/server/internal/handler/issue_skill_test.go b/server/internal/handler/issue_skill_test.go new file mode 100644 index 0000000000..26c526ad6c --- /dev/null +++ b/server/internal/handler/issue_skill_test.go @@ -0,0 +1,242 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testEmbedding(first float64) []float64 { + v := make([]float64, skillEmbeddingDimension) + v[0] = first + v[1] = 1 - first + return v +} + +func createDoneIssueForSkillTest(t *testing.T, title string) string { + t.Helper() + w := httptest.NewRecorder() + req := newRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": title, + "status": "done", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var resp IssueResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode issue: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, resp.ID) + }) + return resp.ID +} + +func TestCreateIssueSkillRejectsInvalidIssueID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest(http.MethodPost, "/api/issues/not-a-uuid/skills", map[string]any{ + "name": "bad-id-skill", + }) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.CreateIssueSkill(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateIssueSkillCrossWorkspaceIs404(t *testing.T) { + ctx := context.Background() + var otherWorkspaceID, issueID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO workspace (name, slug, issue_prefix) + VALUES ('Issue Skill Other', 'issue-skill-other', 'ISO') + RETURNING id + `).Scan(&otherWorkspaceID); err != nil { + t.Fatalf("create other workspace: %v", err) + } + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, otherWorkspaceID) + }) + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue (workspace_id, creator_type, creator_id, title, status, number) + VALUES ($1, 'member', $2, 'foreign done issue', 'done', 1) + RETURNING id + `, otherWorkspaceID, testUserID).Scan(&issueID); err != nil { + t.Fatalf("create foreign issue: %v", err) + } + + w := httptest.NewRecorder() + req := newRequest(http.MethodPost, "/api/issues/"+issueID+"/skills", map[string]any{ + "name": "cross-workspace-skill", + "content": "content", + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateIssueSkill(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestCreateIssueSkillRequiresDoneIssue(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest(http.MethodPost, "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ + "title": "todo skill source", + "status": "todo", + }) + testHandler.CreateIssue(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var issue IssueResponse + json.NewDecoder(w.Body).Decode(&issue) + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issue.ID) + }) + + w = httptest.NewRecorder() + req = newRequest(http.MethodPost, "/api/issues/"+issue.ID+"/skills", map[string]any{ + "name": "todo-issue-skill", + "content": "content", + }) + req = withURLParam(req, "id", issue.ID) + testHandler.CreateIssueSkill(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String()) + } + + var count int + if err := testPool.QueryRow(context.Background(), + `SELECT count(*) FROM skill WHERE workspace_id = $1 AND name = 'todo-issue-skill'`, + testWorkspaceID, + ).Scan(&count); err != nil { + t.Fatalf("count skill: %v", err) + } + if count != 0 { + t.Fatalf("non-done issue created %d skills", count) + } +} + +func TestCreateIssueSkillStoresSourceEmbeddingAndPreservesListShape(t *testing.T) { + issueID := createDoneIssueForSkillTest(t, "done issue skill source") + name := "issue-sourced-skill-shape" + + w := httptest.NewRecorder() + req := newRequest(http.MethodPost, "/api/issues/"+issueID+"/skills", CreateIssueSkillRequest{ + CreateSkillRequest: CreateSkillRequest{ + Name: name, + Description: "from issue", + Content: "secret list payload must not include this", + Files: []CreateSkillFileRequest{ + {Path: "README.md", Content: "readme"}, + }, + }, + Embedding: &SkillEmbeddingRequest{ + Model: "test-model", + Vector: testEmbedding(1), + ContentHash: "hash-1", + }, + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateIssueSkill(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssueSkill: expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var resp IssueSkillResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.SourceIssueID != issueID || !resp.EmbeddingStored { + t.Fatalf("source/embedding mismatch: %+v", resp) + } + + var sourceCount, embeddingCount int + if err := testPool.QueryRow(context.Background(), + `SELECT count(*) FROM issue_skill_source WHERE skill_id = $1 AND issue_id = $2 AND workspace_id = $3`, + resp.Skill.ID, issueID, testWorkspaceID, + ).Scan(&sourceCount); err != nil { + t.Fatalf("count source: %v", err) + } + if err := testPool.QueryRow(context.Background(), + `SELECT count(*) FROM skill_embedding WHERE skill_id = $1 AND workspace_id = $2 AND embedding_model = 'test-model'`, + resp.Skill.ID, testWorkspaceID, + ).Scan(&embeddingCount); err != nil { + t.Fatalf("count embedding: %v", err) + } + if sourceCount != 1 || embeddingCount != 1 { + t.Fatalf("sourceCount=%d embeddingCount=%d, want 1/1", sourceCount, embeddingCount) + } + + w = httptest.NewRecorder() + req = newRequest(http.MethodGet, "/api/skills?workspace_id="+testWorkspaceID, nil) + testHandler.ListSkills(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ListSkills: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "secret list payload") { + t.Fatalf("skill list leaked content: %s", w.Body.String()) + } +} + +func TestSearchSkillEmbeddingsReturnsNearestInWorkspace(t *testing.T) { + issueA := createDoneIssueForSkillTest(t, "vector source a") + issueB := createDoneIssueForSkillTest(t, "vector source b") + for i, tc := range []struct { + issueID string + name string + first float64 + }{ + {issueA, "vector-far-skill", 1}, + {issueB, "vector-near-skill", 2}, + } { + w := httptest.NewRecorder() + req := newRequest(http.MethodPost, "/api/issues/"+tc.issueID+"/skills", CreateIssueSkillRequest{ + CreateSkillRequest: CreateSkillRequest{ + Name: fmt.Sprintf("%s-%d", tc.name, i), + Content: "content", + }, + Embedding: &SkillEmbeddingRequest{ + Model: "test-search-model", + Vector: testEmbedding(tc.first), + }, + }) + req = withURLParam(req, "id", tc.issueID) + testHandler.CreateIssueSkill(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssueSkill %s: expected 201, got %d: %s", tc.name, w.Code, w.Body.String()) + } + } + + w := httptest.NewRecorder() + req := newRequest(http.MethodPost, "/api/skills/vector-search", SkillVectorSearchRequest{ + Embedding: SkillEmbeddingRequest{ + Model: "test-search-model", + Vector: testEmbedding(2), + }, + Limit: 2, + }) + testHandler.SearchSkillEmbeddings(w, req) + if w.Code != http.StatusOK { + t.Fatalf("SearchSkillEmbeddings: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp []SkillVectorSearchResult + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp) != 2 { + t.Fatalf("expected 2 results, got %d: %+v", len(resp), resp) + } + if !strings.Contains(resp[0].Name, "vector-near-skill") { + t.Fatalf("nearest result first = %q, want vector-near-skill", resp[0].Name) + } + if resp[0].SourceIssueID == nil || *resp[0].SourceIssueID != issueB { + t.Fatalf("source issue = %v, want %s", resp[0].SourceIssueID, issueB) + } +} diff --git a/server/internal/handler/skill.go b/server/internal/handler/skill.go index cd045426dd..4f31fd23b1 100644 --- a/server/internal/handler/skill.go +++ b/server/internal/handler/skill.go @@ -7,10 +7,12 @@ import ( "fmt" "io" "log/slog" + "math" "net/http" "net/url" "os" "path/filepath" + "strconv" "strings" "time" @@ -101,6 +103,18 @@ type SkillWithFilesResponse struct { Files []SkillFileResponse `json:"files"` } +type IssueSkillResponse struct { + Skill SkillWithFilesResponse `json:"skill"` + SourceIssueID string `json:"source_issue_id"` + EmbeddingStored bool `json:"embedding_stored"` +} + +type SkillVectorSearchResult struct { + SkillSummaryResponse + SourceIssueID *string `json:"source_issue_id,omitempty"` + Distance float64 `json:"distance"` +} + type SkillImportResult struct { Status string `json:"status"` Reason string `json:"reason,omitempty"` @@ -215,6 +229,22 @@ type CreateSkillRequest struct { Files []CreateSkillFileRequest `json:"files,omitempty"` } +type CreateIssueSkillRequest struct { + CreateSkillRequest + Embedding *SkillEmbeddingRequest `json:"embedding,omitempty"` +} + +type SkillEmbeddingRequest struct { + Model string `json:"model"` + Vector []float64 `json:"vector"` + ContentHash string `json:"content_hash"` +} + +type SkillVectorSearchRequest struct { + Embedding SkillEmbeddingRequest `json:"embedding"` + Limit int32 `json:"limit"` +} + type CreateSkillFileRequest struct { Path string `json:"path"` Content string `json:"content"` @@ -253,6 +283,29 @@ func validateFilePath(p string) bool { return true } +const skillEmbeddingDimension = 1536 + +func vectorLiteral(vector []float64) (string, bool) { + if len(vector) != skillEmbeddingDimension { + return "", false + } + parts := make([]string, len(vector)) + for i, v := range vector { + if math.IsNaN(v) || math.IsInf(v, 0) { + return "", false + } + parts[i] = strconv.FormatFloat(v, 'g', -1, 64) + } + return "[" + strings.Join(parts, ",") + "]", true +} + +func validateEmbedding(req SkillEmbeddingRequest) (string, bool) { + if strings.TrimSpace(req.Model) == "" { + return "", false + } + return vectorLiteral(req.Vector) +} + func (h *Handler) loadSkillForUser(w http.ResponseWriter, r *http.Request, id string) (db.Skill, bool) { workspaceID := h.resolveWorkspaceID(r) if workspaceID == "" { @@ -394,6 +447,174 @@ func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, resp) } +func (h *Handler) CreateIssueSkill(w http.ResponseWriter, r *http.Request) { + workspaceID := h.resolveWorkspaceID(r) + creatorID, ok := requireUserID(w, r) + if !ok { + return + } + workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + issueID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "id"), "issue id") + if !ok { + return + } + + issue, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{ + ID: issueID, + WorkspaceID: workspaceUUID, + }) + if err != nil { + writeError(w, http.StatusNotFound, "issue not found") + return + } + if issue.Status != "done" { + writeError(w, http.StatusConflict, "issue must be done before creating a skill") + return + } + + var req CreateIssueSkillRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + for _, f := range req.Files { + if !validateFilePath(f.Path) { + writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path) + return + } + } + + var embeddingLiteral string + if req.Embedding != nil { + var valid bool + embeddingLiteral, valid = validateEmbedding(*req.Embedding) + if !valid { + writeError(w, http.StatusBadRequest, "embedding must include model and 1536 finite vector values") + return + } + } + + tx, err := h.TxStarter.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to start transaction") + return + } + defer tx.Rollback(r.Context()) + qtx := h.Queries.WithTx(tx) + + skill, err := createSkillWithFilesInTx(r.Context(), qtx, skillCreateInput{ + WorkspaceID: workspaceUUID, + CreatorID: parseUUID(creatorID), + Name: req.Name, + Description: req.Description, + Content: req.Content, + Config: req.Config, + Files: req.Files, + }) + if err != nil { + if isUniqueViolation(err) { + writeError(w, http.StatusConflict, "a skill with this name already exists") + return + } + writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error()) + return + } + skillID := parseUUID(skill.ID) + if _, err := qtx.CreateIssueSkillSource(r.Context(), db.CreateIssueSkillSourceParams{ + SkillID: skillID, + IssueID: issue.ID, + WorkspaceID: workspaceUUID, + CreatedBy: parseUUID(creatorID), + }); err != nil { + writeError(w, http.StatusInternalServerError, "failed to create skill source") + return + } + if req.Embedding != nil { + if _, err := qtx.UpsertSkillEmbedding(r.Context(), db.UpsertSkillEmbeddingParams{ + SkillID: skillID, + WorkspaceID: workspaceUUID, + EmbeddingModel: req.Embedding.Model, + ContentHash: req.Embedding.ContentHash, + Embedding: embeddingLiteral, + }); err != nil { + writeError(w, http.StatusInternalServerError, "failed to store skill embedding") + return + } + } + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "failed to commit") + return + } + + resp := IssueSkillResponse{ + Skill: skill, + SourceIssueID: uuidToString(issue.ID), + EmbeddingStored: req.Embedding != nil, + } + actorType, actorID := h.resolveActor(r, creatorID, workspaceID) + h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": skill}) + writeJSON(w, http.StatusCreated, resp) +} + +func (h *Handler) SearchSkillEmbeddings(w http.ResponseWriter, r *http.Request) { + workspaceID := h.resolveWorkspaceID(r) + workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + + var req SkillVectorSearchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + embeddingLiteral, ok := validateEmbedding(req.Embedding) + if !ok { + writeError(w, http.StatusBadRequest, "embedding must include model and 1536 finite vector values") + return + } + limit := req.Limit + if limit <= 0 || limit > 50 { + limit = 10 + } + + rows, err := h.Queries.SearchSkillEmbeddings(r.Context(), db.SearchSkillEmbeddingsParams{ + WorkspaceID: workspaceUUID, + EmbeddingModel: req.Embedding.Model, + Limit: limit, + Embedding: embeddingLiteral, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to search skill embeddings") + return + } + + resp := make([]SkillVectorSearchResult, len(rows)) + for i, row := range rows { + var sourceIssueID *string + if row.IssueID.Valid { + v := uuidToString(row.IssueID) + sourceIssueID = &v + } + resp[i] = SkillVectorSearchResult{ + SkillSummaryResponse: skillSummaryToResponse( + row.ID, row.WorkspaceID, row.Name, row.Description, row.Config, + row.CreatedBy, row.CreatedAt, row.UpdatedAt, + ), + SourceIssueID: sourceIssueID, + Distance: row.Distance, + } + } + writeJSON(w, http.StatusOK, resp) +} + // canManageSkill checks whether the current user can update or delete a skill. // The skill creator or workspace owner/admin can manage any skill. func (h *Handler) canManageSkill(w http.ResponseWriter, r *http.Request, skill db.Skill) bool { diff --git a/server/migrations/122_issue_skill_embeddings.down.sql b/server/migrations/122_issue_skill_embeddings.down.sql new file mode 100644 index 0000000000..ea3a8ec870 --- /dev/null +++ b/server/migrations/122_issue_skill_embeddings.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS skill_embedding; +DROP TABLE IF EXISTS issue_skill_source; diff --git a/server/migrations/122_issue_skill_embeddings.up.sql b/server/migrations/122_issue_skill_embeddings.up.sql new file mode 100644 index 0000000000..816c9e9e10 --- /dev/null +++ b/server/migrations/122_issue_skill_embeddings.up.sql @@ -0,0 +1,27 @@ +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE issue_skill_source ( + skill_id UUID PRIMARY KEY REFERENCES skill(id) ON DELETE CASCADE, + issue_id UUID NOT NULL UNIQUE REFERENCES issue(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_issue_skill_source_workspace_issue + ON issue_skill_source(workspace_id, issue_id); + +CREATE TABLE skill_embedding ( + skill_id UUID PRIMARY KEY REFERENCES skill(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + embedding vector(1536) NOT NULL, + embedding_model TEXT NOT NULL, + content_hash TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_skill_embedding_workspace + ON skill_embedding(workspace_id); + +CREATE INDEX idx_skill_embedding_vector_cosine + ON skill_embedding USING hnsw (embedding vector_cosine_ops); diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index e0ce7fc9d9..61aa4e9c86 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type ActivityLog struct { @@ -428,6 +429,14 @@ type IssueReaction struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type IssueSkillSource struct { + SkillID pgtype.UUID `json:"skill_id"` + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type IssueSubscriber struct { IssueID pgtype.UUID `json:"issue_id"` UserType string `json:"user_type"` @@ -610,6 +619,15 @@ type Skill struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type SkillEmbedding struct { + SkillID pgtype.UUID `json:"skill_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Embedding pgvector.Vector `json:"embedding"` + EmbeddingModel string `json:"embedding_model"` + ContentHash string `json:"content_hash"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type SkillFile struct { ID pgtype.UUID `json:"id"` SkillID pgtype.UUID `json:"skill_id"` diff --git a/server/pkg/db/generated/skill.sql.go b/server/pkg/db/generated/skill.sql.go index 5020bb6073..ba2940cf1b 100644 --- a/server/pkg/db/generated/skill.sql.go +++ b/server/pkg/db/generated/skill.sql.go @@ -27,6 +27,40 @@ func (q *Queries) AddAgentSkill(ctx context.Context, arg AddAgentSkillParams) er return err } +const createIssueSkillSource = `-- name: CreateIssueSkillSource :one + +INSERT INTO issue_skill_source (skill_id, issue_id, workspace_id, created_by) +VALUES ($1, $2, $3, $4) +ON CONFLICT (issue_id) DO NOTHING +RETURNING skill_id, issue_id, workspace_id, created_by, created_at +` + +type CreateIssueSkillSourceParams struct { + SkillID pgtype.UUID `json:"skill_id"` + IssueID pgtype.UUID `json:"issue_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CreatedBy pgtype.UUID `json:"created_by"` +} + +// Issue-sourced skills +func (q *Queries) CreateIssueSkillSource(ctx context.Context, arg CreateIssueSkillSourceParams) (IssueSkillSource, error) { + row := q.db.QueryRow(ctx, createIssueSkillSource, + arg.SkillID, + arg.IssueID, + arg.WorkspaceID, + arg.CreatedBy, + ) + var i IssueSkillSource + err := row.Scan( + &i.SkillID, + &i.IssueID, + &i.WorkspaceID, + &i.CreatedBy, + &i.CreatedAt, + ) + return i, err +} + const createSkill = `-- name: CreateSkill :one INSERT INTO skill (workspace_id, name, description, content, config, created_by) VALUES ($1, $2, $3, $4, $5, $6) @@ -475,6 +509,83 @@ func (q *Queries) RemoveAllAgentSkills(ctx context.Context, agentID pgtype.UUID) return err } +const searchSkillEmbeddings = `-- name: SearchSkillEmbeddings :many +SELECT + s.id, + s.workspace_id, + s.name, + s.description, + s.config, + s.created_by, + s.created_at, + s.updated_at, + iss.issue_id, + (se.embedding <=> $4::text::vector)::float8 AS distance +FROM skill_embedding se +JOIN skill s ON s.id = se.skill_id AND s.workspace_id = se.workspace_id +LEFT JOIN issue_skill_source iss ON iss.skill_id = s.id AND iss.workspace_id = s.workspace_id +WHERE se.workspace_id = $1 + AND se.embedding_model = $2 +ORDER BY se.embedding <=> $4::text::vector +LIMIT $3 +` + +type SearchSkillEmbeddingsParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + EmbeddingModel string `json:"embedding_model"` + Limit int32 `json:"limit"` + Embedding string `json:"embedding"` +} + +type SearchSkillEmbeddingsRow struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description"` + Config []byte `json:"config"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + IssueID pgtype.UUID `json:"issue_id"` + Distance float64 `json:"distance"` +} + +func (q *Queries) SearchSkillEmbeddings(ctx context.Context, arg SearchSkillEmbeddingsParams) ([]SearchSkillEmbeddingsRow, error) { + rows, err := q.db.Query(ctx, searchSkillEmbeddings, + arg.WorkspaceID, + arg.EmbeddingModel, + arg.Limit, + arg.Embedding, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SearchSkillEmbeddingsRow{} + for rows.Next() { + var i SearchSkillEmbeddingsRow + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.Name, + &i.Description, + &i.Config, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.IssueID, + &i.Distance, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateSkill = `-- name: UpdateSkill :one UPDATE skill SET name = COALESCE($2, name), @@ -517,6 +628,53 @@ func (q *Queries) UpdateSkill(ctx context.Context, arg UpdateSkillParams) (Skill return i, err } +const upsertSkillEmbedding = `-- name: UpsertSkillEmbedding :one +INSERT INTO skill_embedding (skill_id, workspace_id, embedding, embedding_model, content_hash) +VALUES ($1, $2, $5::text::vector, $3, $4) +ON CONFLICT (skill_id) DO UPDATE SET + workspace_id = EXCLUDED.workspace_id, + embedding = EXCLUDED.embedding, + embedding_model = EXCLUDED.embedding_model, + content_hash = EXCLUDED.content_hash, + updated_at = now() +RETURNING skill_id, workspace_id, embedding_model, content_hash, updated_at +` + +type UpsertSkillEmbeddingParams struct { + SkillID pgtype.UUID `json:"skill_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + EmbeddingModel string `json:"embedding_model"` + ContentHash string `json:"content_hash"` + Embedding string `json:"embedding"` +} + +type UpsertSkillEmbeddingRow struct { + SkillID pgtype.UUID `json:"skill_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + EmbeddingModel string `json:"embedding_model"` + ContentHash string `json:"content_hash"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) UpsertSkillEmbedding(ctx context.Context, arg UpsertSkillEmbeddingParams) (UpsertSkillEmbeddingRow, error) { + row := q.db.QueryRow(ctx, upsertSkillEmbedding, + arg.SkillID, + arg.WorkspaceID, + arg.EmbeddingModel, + arg.ContentHash, + arg.Embedding, + ) + var i UpsertSkillEmbeddingRow + err := row.Scan( + &i.SkillID, + &i.WorkspaceID, + &i.EmbeddingModel, + &i.ContentHash, + &i.UpdatedAt, + ) + return i, err +} + const upsertSkillFile = `-- name: UpsertSkillFile :one INSERT INTO skill_file (skill_id, path, content) VALUES ($1, $2, $3) diff --git a/server/pkg/db/queries/skill.sql b/server/pkg/db/queries/skill.sql index 811801ecab..f7df990998 100644 --- a/server/pkg/db/queries/skill.sql +++ b/server/pkg/db/queries/skill.sql @@ -110,3 +110,42 @@ FROM agent_skill ask JOIN skill s ON s.id = ask.skill_id WHERE s.workspace_id = $1 ORDER BY s.name ASC; + +-- Issue-sourced skills + +-- name: CreateIssueSkillSource :one +INSERT INTO issue_skill_source (skill_id, issue_id, workspace_id, created_by) +VALUES ($1, $2, $3, $4) +ON CONFLICT (issue_id) DO NOTHING +RETURNING *; + +-- name: UpsertSkillEmbedding :one +INSERT INTO skill_embedding (skill_id, workspace_id, embedding, embedding_model, content_hash) +VALUES ($1, $2, sqlc.arg('embedding')::text::vector, $3, $4) +ON CONFLICT (skill_id) DO UPDATE SET + workspace_id = EXCLUDED.workspace_id, + embedding = EXCLUDED.embedding, + embedding_model = EXCLUDED.embedding_model, + content_hash = EXCLUDED.content_hash, + updated_at = now() +RETURNING skill_id, workspace_id, embedding_model, content_hash, updated_at; + +-- name: SearchSkillEmbeddings :many +SELECT + s.id, + s.workspace_id, + s.name, + s.description, + s.config, + s.created_by, + s.created_at, + s.updated_at, + iss.issue_id, + (se.embedding <=> sqlc.arg('embedding')::text::vector)::float8 AS distance +FROM skill_embedding se +JOIN skill s ON s.id = se.skill_id AND s.workspace_id = se.workspace_id +LEFT JOIN issue_skill_source iss ON iss.skill_id = s.id AND iss.workspace_id = s.workspace_id +WHERE se.workspace_id = $1 + AND se.embedding_model = $2 +ORDER BY se.embedding <=> sqlc.arg('embedding')::text::vector +LIMIT $3; From 5c98b46eb08e3c2b4bae25a5a5eab4b3d43c8825 Mon Sep 17 00:00:00 2001 From: hallong Date: Sat, 20 Jun 2026 01:05:57 +0800 Subject: [PATCH 2/3] fix: capture issue skills on done updates --- server/internal/handler/issue.go | 16 ++++ server/internal/handler/issue_batch_test.go | 67 +++++++++++++++ server/internal/handler/issue_skill_test.go | 92 +++++++++++++++++++++ server/internal/handler/skill.go | 70 ++++++++++++++++ 4 files changed, 245 insertions(+) diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 81bd83a215..4b7dde9bbd 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -2577,6 +2577,14 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { // fails best-effort. if statusChanged { h.notifyParentOfChildDone(r.Context(), prevIssue, issue, actorType, actorID) + if prevIssue.Status != "done" && issue.Status == "done" { + creatorID, err := util.ParseUUID(userID) + if err == nil { + if _, err := h.createSkillFromDoneIssue(r.Context(), issue, creatorID); err != nil { + slog.Warn("create issue skill from done issue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", uuidToString(issue.ID), "workspace_id", workspaceID)...) + } + } + } } writeJSON(w, http.StatusOK, resp) @@ -3063,6 +3071,14 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) { // (MUL-2538). Best-effort; failure does not abort the batch. if statusChanged { h.notifyParentOfChildDone(r.Context(), prevIssue, issue, actorType, actorID) + if prevIssue.Status != "done" && issue.Status == "done" { + creatorID, err := util.ParseUUID(userID) + if err == nil { + if _, err := h.createSkillFromDoneIssue(r.Context(), issue, creatorID); err != nil { + slog.Warn("batch create issue skill from done issue failed", "error", err, "issue_id", uuidToString(issue.ID), "workspace_id", workspaceID) + } + } + } } updated++ diff --git a/server/internal/handler/issue_batch_test.go b/server/internal/handler/issue_batch_test.go index 687ddb5f17..3adc837483 100644 --- a/server/internal/handler/issue_batch_test.go +++ b/server/internal/handler/issue_batch_test.go @@ -1,6 +1,7 @@ package handler import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -121,6 +122,72 @@ func TestBatchUpdateValidUpdatesPersistAndCount(t *testing.T) { } } +func TestBatchUpdateDoneCapturesSkillOnceAndSkipsForeignWorkspace(t *testing.T) { + aTitle := "batch done capture once A" + bTitle := "batch done capture once B" + a := createTestIssue(t, aTitle, "todo", "none") + b := createTestIssue(t, bTitle, "done", "none") + t.Cleanup(func() { deleteTestIssue(t, a) }) + t.Cleanup(func() { deleteTestIssue(t, b) }) + cleanupAutoIssueSkillByTitle(t, aTitle) + cleanupAutoIssueSkillByTitle(t, bTitle) + + ctx := context.Background() + var otherWorkspaceID, foreignIssueID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO workspace (name, slug, issue_prefix) + VALUES ('Batch Issue Skill Other', 'batch-issue-skill-other', 'BIS') + RETURNING id + `).Scan(&otherWorkspaceID); err != nil { + t.Fatalf("create other workspace: %v", err) + } + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, otherWorkspaceID) + }) + if err := testPool.QueryRow(ctx, ` + INSERT INTO issue (workspace_id, creator_type, creator_id, title, status, number) + VALUES ($1, 'member', $2, 'foreign batch done', 'todo', 1) + RETURNING id + `, otherWorkspaceID, testUserID).Scan(&foreignIssueID); err != nil { + t.Fatalf("create foreign issue: %v", err) + } + + w := httptest.NewRecorder() + req := newRequest("POST", "/api/issues/batch-update", map[string]any{ + "issue_ids": []string{a, a, b, foreignIssueID}, + "updates": map[string]any{"status": "done"}, + }) + testHandler.BatchUpdateIssues(w, req) + if w.Code != http.StatusOK { + t.Fatalf("BatchUpdateIssues done: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := countIssueSkillSources(t, a); got != 1 { + t.Fatalf("todo->done issue skill sources = %d, want 1", got) + } + if got := countIssueSkillSources(t, b); got != 0 { + t.Fatalf("done->done issue skill sources = %d, want 0", got) + } + if got := countIssueSkillSources(t, foreignIssueID); got != 0 { + t.Fatalf("foreign issue skill sources = %d, want 0", got) + } + + w = httptest.NewRecorder() + req = newRequest("POST", "/api/issues/batch-update", map[string]any{ + "issue_ids": []string{a}, + "updates": map[string]any{"title": aTitle + " edited"}, + }) + testHandler.BatchUpdateIssues(w, req) + if w.Code != http.StatusOK { + t.Fatalf("BatchUpdateIssues title: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := countIssueSkillSources(t, a); got != 1 { + t.Fatalf("title update duplicated issue skill sources = %d, want 1", got) + } + if got := countIssueSkillFiles(t, a); got != 0 { + t.Fatalf("auto issue skill files = %d, want 0", got) + } +} + // createTestIssue is a small helper to keep the table-driven cases clean. // Returns the new issue's id; caller is responsible for cleanup. func createTestIssue(t *testing.T, title, status, priority string) string { diff --git a/server/internal/handler/issue_skill_test.go b/server/internal/handler/issue_skill_test.go index 26c526ad6c..e35cb80762 100644 --- a/server/internal/handler/issue_skill_test.go +++ b/server/internal/handler/issue_skill_test.go @@ -38,6 +38,98 @@ func createDoneIssueForSkillTest(t *testing.T, title string) string { return resp.ID } +func countIssueSkillSources(t *testing.T, issueID string) int { + t.Helper() + var count int + if err := testPool.QueryRow(context.Background(), + `SELECT count(*) FROM issue_skill_source WHERE issue_id = $1`, + issueID, + ).Scan(&count); err != nil { + t.Fatalf("count issue skill sources: %v", err) + } + return count +} + +func countIssueSkillFiles(t *testing.T, issueID string) int { + t.Helper() + var count int + if err := testPool.QueryRow(context.Background(), ` + SELECT count(*) + FROM skill_file sf + JOIN issue_skill_source iss ON iss.skill_id = sf.skill_id + WHERE iss.issue_id = $1 + `, issueID).Scan(&count); err != nil { + t.Fatalf("count issue skill files: %v", err) + } + return count +} + +func cleanupAutoIssueSkillByTitle(t *testing.T, title string) { + t.Helper() + t.Cleanup(func() { + testPool.Exec(context.Background(), + `DELETE FROM skill WHERE workspace_id = $1 AND name LIKE $2`, + testWorkspaceID, + "%: "+title, + ) + }) +} + +func TestUpdateIssueDoneCapturesSkillOnce(t *testing.T) { + title := "update done capture once" + issueID := createTestIssue(t, title, "todo", "none") + t.Cleanup(func() { deleteTestIssue(t, issueID) }) + cleanupAutoIssueSkillByTitle(t, title) + + w := httptest.NewRecorder() + req := newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{"status": "done"}) + req = withURLParam(req, "id", issueID) + testHandler.UpdateIssue(w, req) + if w.Code != http.StatusOK { + t.Fatalf("UpdateIssue done: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := countIssueSkillSources(t, issueID); got != 1 { + t.Fatalf("skill sources after done = %d, want 1", got) + } + + w = httptest.NewRecorder() + req = newRequest(http.MethodPut, "/api/issues/"+issueID, map[string]any{ + "title": title + " edited", + "status": "done", + }) + req = withURLParam(req, "id", issueID) + testHandler.UpdateIssue(w, req) + if w.Code != http.StatusOK { + t.Fatalf("UpdateIssue repeat done: expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := countIssueSkillSources(t, issueID); got != 1 { + t.Fatalf("skill sources after repeat done = %d, want 1", got) + } + if got := countIssueSkillFiles(t, issueID); got != 0 { + t.Fatalf("auto issue skill files = %d, want 0", got) + } +} + +func TestIssueCommentDoesNotCaptureSkill(t *testing.T) { + title := "comment does not capture skill" + issueID := createTestIssue(t, title, "done", "none") + t.Cleanup(func() { deleteTestIssue(t, issueID) }) + cleanupAutoIssueSkillByTitle(t, title) + + w := httptest.NewRecorder() + req := newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{ + "content": "a comment on a done issue is not a done transition", + }) + req = withURLParam(req, "id", issueID) + testHandler.CreateComment(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String()) + } + if got := countIssueSkillSources(t, issueID); got != 0 { + t.Fatalf("comment created %d issue skill sources, want 0", got) + } +} + func TestCreateIssueSkillRejectsInvalidIssueID(t *testing.T) { w := httptest.NewRecorder() req := newRequest(http.MethodPost, "/api/issues/not-a-uuid/skills", map[string]any{ diff --git a/server/internal/handler/skill.go b/server/internal/handler/skill.go index 4f31fd23b1..05163f3406 100644 --- a/server/internal/handler/skill.go +++ b/server/internal/handler/skill.go @@ -563,6 +563,76 @@ func (h *Handler) CreateIssueSkill(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, resp) } +func (h *Handler) createSkillFromDoneIssue(ctx context.Context, issue db.Issue, creatorID pgtype.UUID) (bool, error) { + var existing pgtype.UUID + err := h.DB.QueryRow(ctx, ` + SELECT skill_id + FROM issue_skill_source + WHERE issue_id = $1 AND workspace_id = $2 + `, issue.ID, issue.WorkspaceID).Scan(&existing) + if err == nil { + return false, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return false, err + } + + tx, err := h.TxStarter.Begin(ctx) + if err != nil { + return false, err + } + defer tx.Rollback(ctx) + qtx := h.Queries.WithTx(tx) + + skill, err := createSkillWithFilesInTx(ctx, qtx, skillCreateInput{ + WorkspaceID: issue.WorkspaceID, + CreatorID: creatorID, + Name: autoIssueSkillName(issue), + Description: "Captured from a resolved issue.", + Content: autoIssueSkillContent(issue), + }) + if err != nil { + if isUniqueViolation(err) { + return false, nil + } + return false, err + } + if _, err := qtx.CreateIssueSkillSource(ctx, db.CreateIssueSkillSourceParams{ + SkillID: parseUUID(skill.ID), + IssueID: issue.ID, + WorkspaceID: issue.WorkspaceID, + CreatedBy: creatorID, + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, err + } + return true, nil +} + +func autoIssueSkillName(issue db.Issue) string { + return fmt.Sprintf("Issue %d: %s", issue.Number, issue.Title) +} + +func autoIssueSkillContent(issue db.Issue) string { + var b strings.Builder + b.WriteString("# ") + b.WriteString(issue.Title) + b.WriteString("\n\n") + if issue.Description.Valid && strings.TrimSpace(issue.Description.String) != "" { + b.WriteString(strings.TrimSpace(issue.Description.String)) + b.WriteString("\n\n") + } + b.WriteString("Source issue: ") + b.WriteString(uuidToString(issue.ID)) + b.WriteString("\n") + return b.String() +} + func (h *Handler) SearchSkillEmbeddings(w http.ResponseWriter, r *http.Request) { workspaceID := h.resolveWorkspaceID(r) workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") From 0354c00340593e3f48e679623c05d76dd3a04de8 Mon Sep 17 00:00:00 2001 From: hallong Date: Sat, 20 Jun 2026 01:33:39 +0800 Subject: [PATCH 3/3] test: isolate private agent fixture emails --- server/internal/handler/agent_access_test.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/server/internal/handler/agent_access_test.go b/server/internal/handler/agent_access_test.go index b35db45bc4..2894e2ad8b 100644 --- a/server/internal/handler/agent_access_test.go +++ b/server/internal/handler/agent_access_test.go @@ -3,10 +3,12 @@ package handler import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + "github.com/google/uuid" "github.com/multica-ai/multica/server/internal/middleware" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" @@ -59,16 +61,17 @@ func privateAgentTestFixture(t *testing.T) (agentID, ownerID, memberID string) { t.Helper() ctx := context.Background() + ownerEmail := fmt.Sprintf("private-agent-owner-%s@multica.test", uuid.NewString()) if err := testPool.QueryRow(ctx, ` INSERT INTO "user" (name, email) - VALUES ('Private Agent Owner', 'private-agent-owner@multica.test') + VALUES ('Private Agent Owner', $1) RETURNING id - `).Scan(&ownerID); err != nil { + `, ownerEmail).Scan(&ownerID); err != nil { t.Fatalf("create owner user: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), - `DELETE FROM "user" WHERE email = 'private-agent-owner@multica.test'`) + `DELETE FROM "user" WHERE id = $1`, ownerID) }) if _, err := testPool.Exec(ctx, ` @@ -78,16 +81,17 @@ func privateAgentTestFixture(t *testing.T) (agentID, ownerID, memberID string) { t.Fatalf("add owner as member: %v", err) } + memberEmail := fmt.Sprintf("plain-member-%s@multica.test", uuid.NewString()) if err := testPool.QueryRow(ctx, ` INSERT INTO "user" (name, email) - VALUES ('Plain Member', 'plain-member@multica.test') + VALUES ('Plain Member', $1) RETURNING id - `).Scan(&memberID); err != nil { + `, memberEmail).Scan(&memberID); err != nil { t.Fatalf("create plain member user: %v", err) } t.Cleanup(func() { testPool.Exec(context.Background(), - `DELETE FROM "user" WHERE email = 'plain-member@multica.test'`) + `DELETE FROM "user" WHERE id = $1`, memberID) }) if _, err := testPool.Exec(ctx, `