From 39dc22ec3a20e106fb37e9fb411382adf7f61012 Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Mon, 4 May 2026 23:02:21 +0200 Subject: [PATCH] feat(tools): create_project_from_template Generates a new repo from mathias/template-go-web via Gitea's generate API, then substitutes __PROJECT_NAME__ and __MODULE_PATH__ placeholders in six known files (best-effort, partial failure surfaced in result). Validates name regex, allowlist, template flag, and destination non-existence before generating. Adds Template field to gitea.Repo. Co-Authored-By: Claude Sonnet 4.6 --- cmd/gitea-mcp/main.go | 1 + internal/gitea/repos.go | 1 + internal/gitea/templates.go | 72 +++++ internal/gitea/templates_test.go | 156 ++++++++++ .../tools/create_project_from_template.go | 146 ++++++++++ .../create_project_from_template_test.go | 266 ++++++++++++++++++ 6 files changed, 642 insertions(+) create mode 100644 internal/gitea/templates.go create mode 100644 internal/gitea/templates_test.go create mode 100644 internal/tools/create_project_from_template.go create mode 100644 internal/tools/create_project_from_template_test.go diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index 35adbde..6beb226 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -41,6 +41,7 @@ func main() { reg.Register(tools.NewIssueComment(giteaClient, ownerAllow)) reg.Register(tools.NewPRComment(giteaClient, ownerAllow)) reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow)) + reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web")) mcpSrv := mcp.NewServer(mcp.ServerOptions{ Registry: reg, diff --git a/internal/gitea/repos.go b/internal/gitea/repos.go index a5fefe0..0c5e3ad 100644 --- a/internal/gitea/repos.go +++ b/internal/gitea/repos.go @@ -15,6 +15,7 @@ type Repo struct { Private bool `json:"private"` CloneURL string `json:"clone_url"` HTMLURL string `json:"html_url"` + Template bool `json:"template"` } func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) { diff --git a/internal/gitea/templates.go b/internal/gitea/templates.go new file mode 100644 index 0000000..c2d1f51 --- /dev/null +++ b/internal/gitea/templates.go @@ -0,0 +1,72 @@ +package gitea + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// GenerateFromTemplateArgs is the request body for POST /repos/{owner}/{repo}/generate. +type GenerateFromTemplateArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Private bool `json:"private"` + DefaultBranch string `json:"default_branch,omitempty"` + GitContent bool `json:"git_content"` // include all template files +} + +// GenerateFromTemplate creates a new repository from a template via POST /repos/{tmplOwner}/{tmplName}/generate. +func (c *Client) GenerateFromTemplate(ctx context.Context, tmplOwner, tmplName string, args GenerateFromTemplateArgs) (*Repo, error) { + p := fmt.Sprintf("/api/v1/repos/%s/%s/generate", tmplOwner, tmplName) + payload, err := json.Marshal(args) + if err != nil { + return nil, err + } + body, status, err := c.PostJSON(ctx, p, payload) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var r Repo + if err := json.Unmarshal(body, &r); err != nil { + return nil, err + } + return &r, nil +} + +// SubstituteFile reads a file from the given branch, applies string replacements, +// and writes it back if any changes were made. Best-effort — returns a wrapped error +// that includes the file path. +func (c *Client) SubstituteFile(ctx context.Context, owner, repo, branch, path string, replacements map[string]string) error { + fc, err := c.GetFileContents(ctx, owner, repo, path, branch) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + decoded, err := base64.StdEncoding.DecodeString(fc.Content) + if err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + content := string(decoded) + for k, v := range replacements { + content = strings.ReplaceAll(content, k, v) + } + if content == string(decoded) { + return nil // no changes, skip write + } + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + _, err = c.UpsertFile(ctx, owner, repo, path, UpsertFileArgs{ + Branch: branch, + Content: encoded, + Message: "Apply template substitutions", + Sha: fc.Sha, + }) + if err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} diff --git a/internal/gitea/templates_test.go b/internal/gitea/templates_test.go new file mode 100644 index 0000000..e7ec513 --- /dev/null +++ b/internal/gitea/templates_test.go @@ -0,0 +1,156 @@ +package gitea_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateFromTemplate(t *testing.T) { + var capturedBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/repos/mathias/template-go-web/generate", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + var err error + capturedBody, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "name":"new-svc", + "full_name":"mathias/new-svc", + "default_branch":"main", + "description":"A new service", + "private":true, + "clone_url":"http://gitea.example.com/mathias/new-svc.git", + "html_url":"http://gitea.example.com/mathias/new-svc", + "template":false + }`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + repo, err := c.GenerateFromTemplate(context.Background(), "mathias", "template-go-web", gitea.GenerateFromTemplateArgs{ + Owner: "mathias", + Name: "new-svc", + Description: "A new service", + Private: true, + GitContent: true, + }) + require.NoError(t, err) + + // Verify the captured POST body contains the expected fields. + var payload map[string]any + require.NoError(t, json.Unmarshal(capturedBody, &payload)) + assert.Equal(t, "mathias", payload["owner"]) + assert.Equal(t, "new-svc", payload["name"]) + assert.Equal(t, "A new service", payload["description"]) + assert.Equal(t, true, payload["private"]) + assert.Equal(t, true, payload["git_content"]) + + // Verify the decoded repo fields. + assert.Equal(t, "new-svc", repo.Name) + assert.Equal(t, "mathias/new-svc", repo.FullName) + assert.Equal(t, "main", repo.DefaultBranch) + assert.Equal(t, "A new service", repo.Description) + assert.True(t, repo.Private) + assert.Equal(t, "http://gitea.example.com/mathias/new-svc.git", repo.CloneURL) + assert.Equal(t, "http://gitea.example.com/mathias/new-svc", repo.HTMLURL) +} + +func TestSubstituteFileApplies(t *testing.T) { + originalContent := "module __MODULE_PATH__\n\ngo 1.22\n" + encoded := base64.StdEncoding.EncodeToString([]byte(originalContent)) + + var capturedPutBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path) + assert.Equal(t, "main", r.URL.Query().Get("ref")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":30,"content":"` + encoded + `","encoding":"base64"}`)) + case http.MethodPut: + assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path) + var err error + capturedPutBody, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"commitsha","html_url":""}}`)) + default: + t.Errorf("unexpected method %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + } + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{ + "__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc", + }) + require.NoError(t, err) + + // Verify the PUT body contains the substituted content. + require.NotNil(t, capturedPutBody, "PUT should have been called") + var payload map[string]string + require.NoError(t, json.Unmarshal(capturedPutBody, &payload)) + + decoded, err := base64.StdEncoding.DecodeString(payload["content"]) + require.NoError(t, err) + assert.Contains(t, string(decoded), "gitea.d-ma.be/mathias/new-svc") + assert.NotContains(t, string(decoded), "__MODULE_PATH__") + assert.Equal(t, "abc123", payload["sha"]) + assert.Equal(t, "Apply template substitutions", payload["message"]) +} + +func TestSubstituteFileNoChangeSkipsWrite(t *testing.T) { + originalContent := "module gitea.d-ma.be/mathias/existing\n\ngo 1.22\n" + encoded := base64.StdEncoding.EncodeToString([]byte(originalContent)) + + var putCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":40,"content":"` + encoded + `","encoding":"base64"}`)) + case http.MethodPut: + putCount.Add(1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`)) + } + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + // Replacements that don't match anything in the content. + err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{ + "__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc", + }) + require.NoError(t, err) + assert.Equal(t, int32(0), putCount.Load(), "PUT should not be called when content is unchanged") +} + +func TestSubstituteFileReadError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"file not found"}`)) + })) + defer srv.Close() + + c := gitea.NewClient(srv.URL, "tok") + err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{ + "__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc", + }) + require.Error(t, err) + assert.True(t, errors.Is(err, gitea.ErrNotFound), "error should wrap ErrNotFound, got: %v", err) +} diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go new file mode 100644 index 0000000..0f548aa --- /dev/null +++ b/internal/tools/create_project_from_template.go @@ -0,0 +1,146 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/registry" +) + +var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`) + +var substitutionFiles = []string{ + "go.mod", + "Taskfile.yml", + "Dockerfile", + ".gitea/workflows/cd.yml", + "README.md", + ".context/PROJECT.md", +} + +func substitutions(owner, name string) map[string]string { + return map[string]string{ + "__PROJECT_NAME__": name, + "__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name, + } +} + +// CreateProjectFromTemplate is the exported type so tests can reference it. +type CreateProjectFromTemplate struct { + c *gitea.Client + a *allowlist.Allowlist + templateOwner string + templateName string +} + +func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplOwner, tmplName string) *CreateProjectFromTemplate { + return &CreateProjectFromTemplate{c: c, a: a, templateOwner: tmplOwner, templateName: tmplName} +} + +func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "create_project_from_template", + Description: "Create a new project repo from the template, applying placeholder substitutions to known files.", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"}, + "description":{"type":"string"}, + "private":{"type":"boolean"} + }, + "required":["owner","name"] + }`), + } +} + +type createProjectArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Description string `json:"description"` + Private bool `json:"private"` +} + +type createProjectResult struct { + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + CloneURL string `json:"clone_url"` + DefaultBranch string `json:"default_branch"` + FilesSubstituted []string `json:"files_substituted"` + PartialFailure string `json:"partial_failure,omitempty"` +} + +func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args createProjectArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + + // Allowlist check first. + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + + // Validate name format. + if !nameRe.MatchString(args.Name) { + return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation) + } + + // Verify template exists and is marked as a template repo. + tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName) + if err != nil { + return nil, fmt.Errorf("template lookup: %w", err) + } + if !tmpl.Template { + return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, t.templateName, gitea.ErrValidation) + } + + // Verify destination doesn't already exist. + if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil { + return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict) + } else if !errors.Is(err, gitea.ErrNotFound) { + return nil, fmt.Errorf("destination check: %w", err) + } + + // Generate repo from template. + newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{ + Owner: args.Owner, + Name: args.Name, + Description: args.Description, + Private: args.Private, + GitContent: true, + }) + if err != nil { + return nil, fmt.Errorf("generate: %w", err) + } + + result := createProjectResult{ + FullName: newRepo.FullName, + HTMLURL: newRepo.HTMLURL, + CloneURL: newRepo.CloneURL, + DefaultBranch: newRepo.DefaultBranch, + } + + // Substitute placeholders in known files (best-effort). + repls := substitutions(args.Owner, args.Name) + branch := newRepo.DefaultBranch + for _, path := range substitutionFiles { + if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil { + // Files that don't exist in this template are silently skipped. + if errors.Is(err, gitea.ErrNotFound) { + continue + } + // Any other error halts the substitution pass with partial_failure recorded. + result.PartialFailure = fmt.Sprintf("%s: %v", path, err) + break + } + result.FilesSubstituted = append(result.FilesSubstituted, path) + } + + return textOK(result) +} diff --git a/internal/tools/create_project_from_template_test.go b/internal/tools/create_project_from_template_test.go new file mode 100644 index 0000000..5c34d92 --- /dev/null +++ b/internal/tools/create_project_from_template_test.go @@ -0,0 +1,266 @@ +package tools_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" + "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" + "gitea.d-ma.be/mathias/gitea-mcp/internal/tools" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// substitutionFileList matches the tool's internal list — used to drive fake server routing. +var substitutionFileList = []string{ + "go.mod", + "Taskfile.yml", + "Dockerfile", + ".gitea/workflows/cd.yml", + "README.md", + ".context/PROJECT.md", +} + +// contentWithPlaceholder is a template file body that contains the placeholder. +const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n" + +func encodedContent(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +// fileContentsJSON returns a JSON FileContents object for the given path. +func fileContentsJSON(path string) string { + enc := encodedContent(contentWithPlaceholder) + return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`, + path, strings.ReplaceAll(path, "/", "-"), enc) +} + +// fileWriteResultJSON returns a minimal FileWriteResult JSON. +func fileWriteResultJSON(path string) string { + return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path) +} + +// newTemplateRepoJSON returns a JSON Repo marked as template. +func newTemplateRepoJSON(name string, isTemplate bool) string { + return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`, + name, name, name, name, isTemplate) +} + +// newGeneratedRepoJSON returns the JSON for the newly generated repo. +func newGeneratedRepoJSON(name string) string { + return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`, + name, name, name, name) +} + +func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate { + c := gitea.NewClient(srvURL, "tok") + a := allowlist.New([]string{"mathias"}) + return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web") +} + +// TestCreateProjectHappyPath: all 6 files served and substituted. +func TestCreateProjectHappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + // Template repo lookup + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": + _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true))) + + // Destination repo lookup — 404 means it doesn't exist yet + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + + // Generate + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc"))) + + // File contents GET — handle all 6 substitution files + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): + filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") + _, _ = w.Write([]byte(fileContentsJSON(filePath))) + + // File contents PUT — handle all 6 substitution files + case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): + filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(fileWriteResultJSON(filePath))) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + tool := newCreateProjectTool(srv.URL) + result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`)) + require.NoError(t, err) + + var out struct { + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + CloneURL string `json:"clone_url"` + DefaultBranch string `json:"default_branch"` + FilesSubstituted []string `json:"files_substituted"` + PartialFailure string `json:"partial_failure,omitempty"` + } + require.NoError(t, json.Unmarshal(result, &out)) + + assert.Equal(t, "mathias/new-svc", out.FullName) + assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL) + assert.Equal(t, "main", out.DefaultBranch) + assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted) + assert.Empty(t, out.PartialFailure) +} + +// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network. +func TestCreateProjectNameRegexFailure(t *testing.T) { + tool := tools.NewCreateProjectFromTemplate( + gitea.NewClient("http://unused", ""), + allowlist.New([]string{"mathias"}), + "mathias", "template-go-web", + ) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} + +// TestCreateProjectAllowlistRejects: owner not in allowlist returns error. +func TestCreateProjectAllowlistRejects(t *testing.T) { + tool := tools.NewCreateProjectFromTemplate( + gitea.NewClient("http://unused", ""), + allowlist.New([]string{"mathias"}), + "mathias", "template-go-web", + ) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "allowlist") +} + +// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template. +func TestCreateProjectTemplateNotTemplate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Template lookup returns a non-template repo. + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" { + _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false))) + return + } + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + tool := newCreateProjectTool(srv.URL) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) +} + +// TestCreateProjectDestinationExists: destination repo already exists. +func TestCreateProjectDestinationExists(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": + _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true))) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc": + // Destination exists — return 200. + _, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false))) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + tool := newCreateProjectTool(srv.URL) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrConflict) +} + +// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails; +// the first 3 are substituted, partial_failure is populated, no Go error is returned. +func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) { + // Files that should succeed (index 0-2 in substitutionFileList). + successFiles := map[string]bool{ + "go.mod": true, + "Taskfile.yml": true, + "Dockerfile": true, + } + // The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500. + failFile := ".gitea/workflows/cd.yml" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": + _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true))) + + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + + case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate": + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc"))) + + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): + filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") + _, _ = w.Write([]byte(fileContentsJSON(filePath))) + + case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"): + filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/") + if filePath == failFile { + // Simulate upstream 500. + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"internal server error"}`)) + return + } + if !successFiles[filePath] { + t.Errorf("unexpected PUT for file: %s", filePath) + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(fileWriteResultJSON(filePath))) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + tool := newCreateProjectTool(srv.URL) + result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`)) + // Best-effort: no Go error returned, partial state in result. + require.NoError(t, err) + + var out struct { + FullName string `json:"full_name"` + FilesSubstituted []string `json:"files_substituted"` + PartialFailure string `json:"partial_failure,omitempty"` + } + require.NoError(t, json.Unmarshal(result, &out)) + + // First 3 files should be in FilesSubstituted. + assert.Len(t, out.FilesSubstituted, 3) + assert.Contains(t, out.FilesSubstituted, "go.mod") + assert.Contains(t, out.FilesSubstituted, "Taskfile.yml") + assert.Contains(t, out.FilesSubstituted, "Dockerfile") + assert.NotContains(t, out.FilesSubstituted, failFile) + + // partial_failure should be non-empty. + assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure") +}