mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
v0.3.23
...
agent/matt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e0d598c8 |
@@ -4,7 +4,9 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -346,6 +348,9 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
|
||||
if handleSkillImportConflict(cmd, err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("import skill: %w", err)
|
||||
}
|
||||
|
||||
@@ -358,6 +363,31 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSkillImportConflict(cmd *cobra.Command, err error) bool {
|
||||
var httpErr *cli.HTTPError
|
||||
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusConflict || strings.TrimSpace(httpErr.Body) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(httpErr.Body), &body) != nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := body["existing_skill"]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
_ = cli.PrintJSON(os.Stdout, body)
|
||||
return true
|
||||
}
|
||||
|
||||
existing, _ := body["existing_skill"].(map[string]any)
|
||||
fmt.Printf("Skill already exists: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill file subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
98
server/cmd/multica/cmd_skill_test.go
Normal file
98
server/cmd/multica/cmd_skill_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newSkillImportTestCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "import"}
|
||||
cmd.Flags().String("server-url", "", "")
|
||||
cmd.Flags().String("workspace-id", "", "")
|
||||
cmd.Flags().String("profile", "", "")
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("output", "json", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func() error) (string, error) {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe stdout: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
defer func() { os.Stdout = old }()
|
||||
|
||||
runErr := fn()
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close stdout writer: %v", err)
|
||||
}
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatalf("read stdout: %v", err)
|
||||
}
|
||||
return string(out), runErr
|
||||
}
|
||||
|
||||
func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/skills/import" {
|
||||
t.Fatalf("path = %q, want /api/skills/import", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Workspace-ID") != "workspace-123" {
|
||||
t.Fatalf("X-Workspace-ID = %q, want workspace-123", r.Header.Get("X-Workspace-ID"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "a skill with this name already exists",
|
||||
"existing_skill": map[string]any{
|
||||
"id": "skill-123",
|
||||
"name": "review-helper",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
||||
|
||||
cmd := newSkillImportTestCmd()
|
||||
_ = cmd.Flags().Set("url", "https://skills.sh/acme/review-helper")
|
||||
_ = cmd.Flags().Set("output", "json")
|
||||
|
||||
out, err := captureStdout(t, func() error {
|
||||
return runSkillImport(cmd, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runSkillImport returned error for duplicate import: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode stdout JSON %q: %v", out, err)
|
||||
}
|
||||
if got["error"] != "a skill with this name already exists" {
|
||||
t.Fatalf("error = %v", got["error"])
|
||||
}
|
||||
existing, ok := got["existing_skill"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("existing_skill missing or wrong type: %#v", got["existing_skill"])
|
||||
}
|
||||
if existing["id"] != "skill-123" || existing["name"] != "review-helper" {
|
||||
t.Fatalf("existing_skill = %#v", existing)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
@@ -88,6 +90,18 @@ type SkillWithFilesResponse struct {
|
||||
Files []SkillFileResponse `json:"files"`
|
||||
}
|
||||
|
||||
type ExistingSkillIdentity struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func writeSkillImportDuplicateConflict(w http.ResponseWriter, existing ExistingSkillIdentity) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "a skill with this name already exists",
|
||||
"existing_skill": existing,
|
||||
})
|
||||
}
|
||||
|
||||
func skillToResponse(s db.Skill) SkillResponse {
|
||||
return SkillResponse{
|
||||
ID: uuidToString(s.ID),
|
||||
@@ -102,6 +116,20 @@ func skillToResponse(s db.Skill) SkillResponse {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) existingSkillIdentityByName(ctx context.Context, workspaceID pgtype.UUID, name string) (ExistingSkillIdentity, bool, error) {
|
||||
skill, err := h.Queries.GetSkillByWorkspaceAndName(ctx, db.GetSkillByWorkspaceAndNameParams{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ExistingSkillIdentity{}, false, nil
|
||||
}
|
||||
return ExistingSkillIdentity{}, false, err
|
||||
}
|
||||
return ExistingSkillIdentity{ID: uuidToString(skill.ID), Name: skill.Name}, true, nil
|
||||
}
|
||||
|
||||
// decodeSkillConfig decodes a JSONB skill.config blob, defaulting to {} when
|
||||
// missing or unparseable so the API surface always returns a JSON object.
|
||||
func decodeSkillConfig(raw []byte) any {
|
||||
@@ -1657,7 +1685,11 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "a skill with this name already exists")
|
||||
if existing, found, findErr := h.existingSkillIdentityByName(r.Context(), workspaceUUID, imported.name); findErr == nil && found {
|
||||
writeSkillImportDuplicateConflict(w, existing)
|
||||
} else {
|
||||
writeError(w, http.StatusConflict, "a skill with this name already exists")
|
||||
}
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())
|
||||
|
||||
48
server/internal/handler/skill_import_duplicate_test.go
Normal file
48
server/internal/handler/skill_import_duplicate_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExistingSkillIdentityByNameReturnsIDAndName(t *testing.T) {
|
||||
namePrefix := "duplicate-import-identity"
|
||||
name := namePrefix + "-" + t.Name()
|
||||
skillID := insertHandlerTestSkill(t, namePrefix, "# Duplicate import identity")
|
||||
|
||||
existing, ok, err := testHandler.existingSkillIdentityByName(context.Background(), parseUUID(testWorkspaceID), name)
|
||||
if err != nil {
|
||||
t.Fatalf("existingSkillIdentityByName: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected existing skill identity to be found")
|
||||
}
|
||||
if existing.ID != skillID || existing.Name != name {
|
||||
t.Fatalf("existing skill = %#v, want id %s name %s", existing, skillID, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSkillImportDuplicateConflictIncludesExistingSkill(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writeSkillImportDuplicateConflict(w, ExistingSkillIdentity{ID: "skill-123", Name: "review-helper"})
|
||||
|
||||
if w.Code != 409 {
|
||||
t.Fatalf("status = %d, want 409: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body["error"] != "a skill with this name already exists" {
|
||||
t.Fatalf("error = %v", body["error"])
|
||||
}
|
||||
existing, ok := body["existing_skill"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("existing_skill missing or wrong type: %#v", body["existing_skill"])
|
||||
}
|
||||
if existing["id"] != "skill-123" || existing["name"] != "review-helper" {
|
||||
t.Fatalf("existing_skill = %#v", existing)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user