Files
gitea-mcp/internal/tools/create_project_from_template.go
Mathias 5545d6ab4b fix(create_project_from_template): accept per-call template_name override
The template name was hardcoded into the binary at startup via
NewCreateProjectFromTemplate("mathias", "template-go-web"), so
generating from a different template (e.g. template-go-agent)
required a code change and restart. The constructor already
parameterised it correctly — the gap was at the tool's input
schema, which never exposed template_name to the caller.

Adds an optional template_name input field. When set, it overrides
the server-configured default for that call only; when omitted,
behavior is unchanged. Template owner stays server-configured —
only the repo name is per-call.

Server-side validation already verifies the resolved template
exists and is marked as a template repo, so no enum constraint
is added — keeps the door open for future templates (go-ml,
go-service, ...) without redeploys.

Adds TestCreateProjectTemplateNameOverride verifying the override
directs both the template lookup and the /generate POST.

Closes #24
2026-05-16 23:24:16 +02:00

156 lines
4.9 KiB
Go

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 a template, applying placeholder substitutions to known files. 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,
}
// 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)
}