Generates a new repo from mathias/template-go-web via Gitea's generate API, then substitutes __PROJECT_NAME__ and __MODULE_PATH__ placeholders in six known files (best-effort, partial failure surfaced in result). Validates name regex, allowlist, template flag, and destination non-existence before generating. Adds Template field to gitea.Repo. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
147 lines
4.4 KiB
Go
147 lines
4.4 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 the template, applying placeholder substitutions to known files.",
|
|
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"}
|
|
},
|
|
"required":["owner","name"]
|
|
}`),
|
|
}
|
|
}
|
|
|
|
type createProjectArgs struct {
|
|
Owner string `json:"owner"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Private bool `json:"private"`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Verify template exists and is marked as a template repo.
|
|
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
|
|
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, t.templateName, 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, t.templateName, 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)
|
|
}
|