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) }