Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server/cmd/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
16 changes: 10 additions & 6 deletions server/internal/handler/agent_access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, `
Expand All @@ -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, `
Expand Down
16 changes: 16 additions & 0 deletions server/internal/handler/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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++
Expand Down
67 changes: 67 additions & 0 deletions server/internal/handler/issue_batch_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handler

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading