diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go index f8df046..e934ef8 100644 --- a/internal/tools/create_project_from_template.go +++ b/internal/tools/create_project_from_template.go @@ -2,10 +2,12 @@ package tools import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" "regexp" + "strings" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" @@ -14,22 +16,22 @@ import ( 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, + // git.d-ma.be is the canonical module host (the gitea.d-ma.be → git.d-ma.be + // rename; a stale host breaks `go mod download` for downstream consumers). + "__MODULE_PATH__": "git.d-ma.be/" + owner + "/" + name, } } +func applyReplacements(s string, repls map[string]string) string { + for k, v := range repls { + s = strings.ReplaceAll(s, k, v) + } + return s +} + // CreateProjectFromTemplate is the exported type so tests can reference it. type CreateProjectFromTemplate struct { c *gitea.Client @@ -45,7 +47,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { return registry.ToolDescriptor{ Name: "create_project_from_template", - Description: "Create a new project repo from a template, applying placeholder substitutions to known files. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).", + Description: "Create a new project repo from a template, substituting placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/) so the result builds. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent).", InputSchema: json.RawMessage(`{ "type":"object", "properties":{ @@ -135,21 +137,105 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag DefaultBranch: newRepo.DefaultBranch, } - // Substitute placeholders in known files (best-effort). - repls := substitutions(args.Owner, args.Name) + // The /generate response often omits default_branch — resolve it explicitly, + // otherwise every file read below hits an empty ref and nothing substitutes + // (the silent-null bug: gitea-mcp#42). 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) + if branch == "" { + if r, gerr := t.c.GetRepo(ctx, args.Owner, args.Name); gerr == nil && r.DefaultBranch != "" { + branch = r.DefaultBranch + } else { + branch = "main" + } + } + result.DefaultBranch = branch + + // Substitute across the WHOLE tree: content in every blob, plus a path rename + // for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go). + // A fixed known-files list can't rename directories or cover every templated + // file, which is why the old scaffold didn't build. + repls := substitutions(args.Owner, args.Name) + tree, err := t.c.GetTree(ctx, args.Owner, args.Name, branch, true) + if err != nil { + result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, err) + return textOK(result) + } + + for _, e := range tree.Tree { + if e.Type != "blob" { + continue + } + substituted, fail := t.substituteEntry(ctx, args.Owner, args.Name, branch, e.Path, repls) + if fail != "" { + result.PartialFailure = fail break } - result.FilesSubstituted = append(result.FilesSubstituted, path) + if substituted != "" { + result.FilesSubstituted = append(result.FilesSubstituted, substituted) + } + } + + // Fail loud: a scaffold that still holds placeholders does not build. Nothing + // substituted (with no explicit failure) means the walk found no placeholders — + // suspicious for a real template. Surface it instead of returning silent success. + if result.PartialFailure == "" && len(result.FilesSubstituted) == 0 { + result.PartialFailure = fmt.Sprintf("no placeholders substituted in %s@%s — verify the scaffold is not left templated", args.Name, branch) } return textOK(result) } + +// substituteEntry substitutes placeholders in one blob. If the path carries a +// placeholder it renames the file (write new + delete old); otherwise it rewrites +// content in place when changed. Returns a human-readable description of what was +// substituted ("" if nothing), and a non-empty partial-failure string on error. +func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, name, branch, path string, repls map[string]string) (substituted, failure string) { + newPath := applyReplacements(path, repls) + + fc, err := t.c.GetFileContents(ctx, owner, name, path, branch) + if err != nil { + if errors.Is(err, gitea.ErrNotFound) { + return "", "" // vanished between tree walk and read; skip + } + return "", fmt.Sprintf("read %s: %v", path, err) + } + decoded, err := base64.StdEncoding.DecodeString(fc.Content) + if err != nil { + return "", fmt.Sprintf("decode %s: %v", path, err) + } + newContent := applyReplacements(string(decoded), repls) + renamed := newPath != path + changed := newContent != string(decoded) + if !renamed && !changed { + return "", "" // nothing to do + } + enc := base64.StdEncoding.EncodeToString([]byte(newContent)) + + if renamed { + if _, err := t.c.UpsertFile(ctx, owner, name, newPath, gitea.UpsertFileArgs{ + Branch: branch, + Content: enc, + Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath), + }); err != nil { + return "", fmt.Sprintf("write %s: %v", newPath, err) + } + if _, err := t.c.DeleteFile(ctx, owner, name, path, gitea.DeleteFileArgs{ + Branch: branch, + Sha: fc.Sha, + Message: fmt.Sprintf("template: drop placeholder path %s", path), + }); err != nil { + return "", fmt.Sprintf("delete %s: %v", path, err) + } + return path + " -> " + newPath, "" + } + + if _, err := t.c.UpsertFile(ctx, owner, name, path, gitea.UpsertFileArgs{ + Branch: branch, + Content: enc, + Message: "template: substitute placeholders", + Sha: fc.Sha, + }); err != nil { + return "", fmt.Sprintf("write %s: %v", path, err) + } + return path, "" +} diff --git a/internal/tools/create_project_from_template_test.go b/internal/tools/create_project_from_template_test.go index a5de491..d6df141 100644 --- a/internal/tools/create_project_from_template_test.go +++ b/internal/tools/create_project_from_template_test.go @@ -5,9 +5,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" + "sync" "testing" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" @@ -17,306 +19,262 @@ import ( "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", -} +func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } -// 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}`, +func templateRepoJSON(name string, isTemplate bool) string { + return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","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) +// fakeTemplateServer serves the whole create-from-template flow off an in-memory +// file map, driving the tool's tree-walk. Records writes/deletes/put-bodies. +type fakeTemplateServer struct { + mu sync.Mutex + files map[string]string // path -> raw (un-substituted) content + genBranch string // default_branch returned by /generate ("" to force fallback) + generated bool + puts []string + deletes []string + putBodies map[string]string // path -> decoded written content + repoGetsPost int // GET dest after generate (branch fallback) } -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") +func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer { + return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}} } -// TestCreateProjectHappyPath: all 6 files served and substituted. -func TestCreateProjectHappyPath(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() w.Header().Set("Content-Type", "application/json") + p := r.URL.Path + 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))) + case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl: + _, _ = w.Write([]byte(templateRepoJSON(tmpl, 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"}`)) + case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+dest: + if !f.generated { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + return + } + f.repoGetsPost++ + _, _ = w.Write([]byte(fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","clone_url":"c","html_url":"h","template":false}`, dest, dest))) - // Generate - case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate": + case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate": + f.generated = true w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc"))) + _, _ = w.Write([]byte(fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`, + dest, dest, f.genBranch, dest, dest))) - // 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))) + case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"): + var entries []string + for path := range f.files { + entries = append(entries, fmt.Sprintf(`{"path":%q,"type":"blob","sha":"sha-%s"}`, path, strings.ReplaceAll(path, "/", "-"))) + } + // include a tree (directory) entry to exercise the blob filter + entries = append(entries, `{"path":"cmd","type":"tree","sha":"treesha"}`) + _, _ = w.Write([]byte(fmt.Sprintf(`{"sha":"root","tree":[%s],"truncated":false}`, strings.Join(entries, ",")))) - // 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/") + case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"): + path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/") + body, ok := f.files[path] + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + return + } + _, _ = w.Write([]byte(fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":1,"content":%q,"encoding":"base64"}`, + path, strings.ReplaceAll(path, "/", "-"), encb64(body)))) + + // POST = create (new/renamed file, no sha), PUT = update (existing, with sha). + case (r.Method == http.MethodPost || r.Method == http.MethodPut) && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"): + path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/") + raw, _ := io.ReadAll(r.Body) + var args struct { + Content string `json:"content"` + } + _ = json.Unmarshal(raw, &args) + dec, _ := base64.StdEncoding.DecodeString(args.Content) + f.puts = append(f.puts, path) + f.putBodies[path] = string(dec) + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + } else { + w.WriteHeader(http.StatusOK) + } + _, _ = w.Write([]byte(`{"content":{"path":"x","sha":"n"},"commit":{"sha":"c"}}`)) + + case r.Method == http.MethodDelete && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/"): + path := strings.TrimPrefix(p, "/api/v1/repos/mathias/"+dest+"/contents/") + f.deletes = append(f.deletes, path) w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(fileWriteResultJSON(filePath))) + _, _ = w.Write([]byte(`{"content":null,"commit":{"sha":"c"}}`)) default: - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + t.Errorf("unexpected request: %s %s", r.Method, p) w.WriteHeader(http.StatusNotFound) } - })) + } +} + +func newTool(srvURL, tmpl string) *tools.CreateProjectFromTemplate { + return tools.NewCreateProjectFromTemplate( + gitea.NewClient(srvURL, "tok"), allowlist.New([]string{"mathias"}), "mathias", tmpl) +} + +func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut { + t.Helper() + res, err := newTool(srvURL, tmpl).Call(context.Background(), json.RawMessage(argsJSON)) + require.NoError(t, err) + var out createOut + require.NoError(t, json.Unmarshal(res, &out)) + return out +} + +type createOut struct { + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` + FilesSubstituted []string `json:"files_substituted"` + PartialFailure string `json:"partial_failure,omitempty"` +} + +// Happy path: whole-tree substitution, content + path rename, correct module host. +func TestCreateProject_TreeWalk_SubstitutesAndRenames(t *testing.T) { + files := map[string]string{ + "go.mod": "module __MODULE_PATH__\n\ngo 1.26\n", + "README.md": "# __PROJECT_NAME__\n", + "cmd/__PROJECT_NAME__/main.go": "package main\nimport \"__MODULE_PATH__/pkg/litellm\"\nconst n = \"__PROJECT_NAME__\"\n", + "pkg/litellm/x.go": "package litellm\n", // no placeholder → untouched + } + f := newFakeTemplateServer(files, "main") + srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) 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) + out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`) - 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) + + // content-substituted files present; untouched file absent + assert.Contains(t, out.FilesSubstituted, "go.mod") + assert.Contains(t, out.FilesSubstituted, "README.md") + assert.NotContains(t, out.FilesSubstituted, "pkg/litellm/x.go") + // path rename recorded as "old -> new" + assert.Contains(t, out.FilesSubstituted, "cmd/__PROJECT_NAME__/main.go -> cmd/new-svc/main.go") + + // module host substituted correctly (git.d-ma.be, not gitea.d-ma.be) + assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n\ngo 1.26\n", f.putBodies["go.mod"]) + // rename: new path written, old path deleted + assert.Contains(t, f.puts, "cmd/new-svc/main.go") + assert.Contains(t, f.deletes, "cmd/__PROJECT_NAME__/main.go") + assert.Equal(t, "package main\nimport \"git.d-ma.be/mathias/new-svc/pkg/litellm\"\nconst n = \"new-svc\"\n", + f.putBodies["cmd/new-svc/main.go"]) + // the untouched file was never written + assert.NotContains(t, f.puts, "pkg/litellm/x.go") +} + +// The /generate response omits default_branch (the live gitea behavior the old +// mock hid) → tool must re-fetch the repo and still substitute. +func TestCreateProject_EmptyGenerateBranch_FallsBack(t *testing.T) { + files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} + f := newFakeTemplateServer(files, "") // generate returns default_branch:"" + srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) + defer srv.Close() + + out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`) + + assert.Equal(t, "main", out.DefaultBranch, "must resolve branch via GetRepo fallback") + assert.GreaterOrEqual(t, f.repoGetsPost, 1, "must re-fetch repo to resolve empty default_branch") + assert.Contains(t, out.FilesSubstituted, "go.mod") + assert.Equal(t, "module git.d-ma.be/mathias/new-svc\n", f.putBodies["go.mod"]) assert.Empty(t, out.PartialFailure) } -// TestCreateProjectTemplateNameOverride (issue #24): per-call template_name overrides the -// server-configured default, so the same binary can generate from template-go-web or -// template-go-agent without restart. -func TestCreateProjectTemplateNameOverride(t *testing.T) { - var templateLookups, generateCalls []string +// Fail loud: a template whose files carry no placeholders yields nothing +// substituted — surface it rather than returning silent success. +func TestCreateProject_NothingSubstituted_IsLoud(t *testing.T) { + files := map[string]string{"README.md": "# static, no placeholders\n"} + f := newFakeTemplateServer(files, "main") + srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) + defer srv.Close() + out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`) + assert.Empty(t, out.FilesSubstituted) + assert.NotEmpty(t, out.PartialFailure, "nothing substituted must not be silent success") +} + +// Write failure mid-pass → partial_failure populated, no Go error. +func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) { + files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} + f := newFakeTemplateServer(files, "main") + base := f.handler(t, "template-go-agent", "new-svc") 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-agent": - templateLookups = append(templateLookups, "template-go-agent") - _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-agent", true))) - - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web": - templateLookups = append(templateLookups, "template-go-web") - _, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true))) - - case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-agent": - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message":"not found"}`)) - - case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/generate"): - generateCalls = append(generateCalls, r.URL.Path) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(newGeneratedRepoJSON("new-agent"))) - - case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"): - filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/") - _, _ = w.Write([]byte(fileContentsJSON(filePath))) - - case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/contents/"): - filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-agent/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) + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/contents/go.mod") { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + return } + base(w, r) })) defer srv.Close() - // Server is configured with template-go-web as the default; call overrides to template-go-agent. - tool := newCreateProjectTool(srv.URL) - _, err := tool.Call(context.Background(), json.RawMessage( - `{"owner":"mathias","name":"new-agent","template_name":"template-go-agent"}`, - )) - require.NoError(t, err) - - assert.Equal(t, []string{"template-go-agent"}, templateLookups, - "override must direct the template lookup, not the server default") - require.Len(t, generateCalls, 1) - assert.Equal(t, "/api/v1/repos/mathias/template-go-agent/generate", generateCalls[0], - "override must direct the /generate call too") + out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`) + assert.NotEmpty(t, out.PartialFailure) + assert.Contains(t, out.PartialFailure, "go.mod") } -// 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"}`)) +// ── guardrails unchanged by the rewrite ────────────────────────────────────── + +func TestCreateProject_NameRegexFailure(t *testing.T) { + _, err := tools.NewCreateProjectFromTemplate( + gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent", + ).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"}`)) +func TestCreateProject_AllowlistRejects(t *testing.T) { + _, err := tools.NewCreateProjectFromTemplate( + gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "mathias", "template-go-agent", + ).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) { +func TestCreateProject_NotTemplate(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))) + if r.URL.Path == "/api/v1/repos/mathias/template-go-agent" { + _, _ = w.Write([]byte(templateRepoJSON("template-go-agent", 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"}`)) + _, err := newTool(srv.URL, "template-go-agent").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) { +func TestCreateProject_DestinationExists(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))) + switch r.URL.Path { + case "/api/v1/repos/mathias/template-go-agent": + _, _ = w.Write([]byte(templateRepoJSON("template-go-agent", true))) + case "/api/v1/repos/mathias/new-svc": + _, _ = w.Write([]byte(templateRepoJSON("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"}`)) + _, err := newTool(srv.URL, "template-go-agent").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") -}