fix(create_project): substitute the whole tree, rename cmd dir, resolve branch (#42)
CD / Lint / Test / Vet (push) Failing after 5s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped

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>
This commit is contained in:
2026-07-01 22:39:48 +02:00
co-authored by Claude Opus 4.8
parent 184d5a95dd
commit e3cdd23260
2 changed files with 303 additions and 259 deletions
+108 -22
View File
@@ -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, ""
}