package tools import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "regexp" "strings" "time" "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]$`) func substitutions(owner, name string) map[string]string { return map[string]string{ "__PROJECT_NAME__": 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 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 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":{ "owner":{"type":"string"}, "name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"}, "description":{"type":"string"}, "private":{"type":"boolean"}, "template_name":{"type":"string","description":"Template repo name to generate from. Defaults to the server-configured template."} }, "required":["owner","name"] }`), } } type createProjectArgs struct { Owner string `json:"owner"` Name string `json:"name"` Description string `json:"description"` Private bool `json:"private"` TemplateName string `json:"template_name"` } 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) } // Resolve template: per-call override takes precedence over the // server-configured default. Owner stays server-configured. tmplName := args.TemplateName if tmplName == "" { tmplName = t.templateName } // Verify template exists and is marked as a template repo. tmpl, err := t.c.GetRepo(ctx, t.templateOwner, tmplName) 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, tmplName, 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, tmplName, 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, } // 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 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 } 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) } // upsertRetry is the readiness gate for the async-generate branch race: gitea's // /generate returns (and serves reads) before the branch ref is committed, so the // first writes 404 "branch does not exist" until the initial commit lands (observed // up to ~30s under load). BranchExists is not a usable signal — it reports the // branch present before writes succeed. So the write itself is the probe: retry on // the transient not-found until it takes. Once the first write lands, the branch is // writable and the rest succeed on the first try. func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error { var err error for i := 0; i < 60; i++ { if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil { return nil } if !errors.Is(err, gitea.ErrNotFound) { return err } select { case <-ctx.Done(): return err case <-time.After(time.Second): } } return err } // 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.upsertRetry(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.upsertRetry(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, "" }