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
156 lines
4.9 KiB
Go
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)
|
|
}
|