create_project_from_template returned files_substituted:null and produced a non-building scaffold. Three root causes, all fixed: 1. Empty branch: /generate omits default_branch, so every SubstituteFile read hit an empty ref and 404'd → nothing substituted. Resolve the branch explicitly (re-fetch the repo; fall back to "main"). The old unit test hid this by mocking default_branch:"main". 2. Incomplete + rename-incapable: substitution ran over a fixed 6-file list that missed cmd/__PROJECT_NAME__/main.go and could not rename the cmd/__PROJECT_NAME__/ directory. Replace with a recursive tree walk: content-substitute every blob, and for any path carrying a placeholder, rename it (POST-create new path + delete old). 3. Stale module host: __MODULE_PATH__ used gitea.d-ma.be (the pre-rename host, which breaks `go mod download` downstream). Use git.d-ma.be. Also: fail loud — if nothing was substituted, populate partial_failure instead of returning silent success (the null that started this). Tests rewritten to drive the tree-walk flow and assert: cmd/ rename (new path POST + old path delete), git.d-ma.be module substitution, empty-generate-branch fallback, and the loud-on-nothing path. Closes #42. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
281 lines
12 KiB
Go
281 lines
12 KiB
Go
package tools_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"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"
|
|
)
|
|
|
|
func encb64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|
|
|
|
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)
|
|
}
|
|
|
|
// 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 newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer {
|
|
return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}}
|
|
}
|
|
|
|
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 {
|
|
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
|
|
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
|
|
|
|
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)))
|
|
|
|
case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate":
|
|
f.generated = true
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = 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)))
|
|
|
|
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, ","))))
|
|
|
|
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(`{"content":null,"commit":{"sha":"c"}}`))
|
|
|
|
default:
|
|
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()
|
|
|
|
out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc"}`)
|
|
|
|
assert.Equal(t, "main", out.DefaultBranch)
|
|
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)
|
|
}
|
|
|
|
// 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) {
|
|
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()
|
|
|
|
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")
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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")
|
|
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()
|
|
_, 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)
|
|
}
|
|
|
|
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 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()
|
|
_, 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)
|
|
}
|