package tools import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "regexp" "strings" "time" "git.d-ma.be/mathias/gitea-mcp/internal/allowlist" "git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.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. Best-effort substitution of placeholders (__PROJECT_NAME__, __MODULE_PATH__) in every file's content AND path (e.g. renaming cmd/__PROJECT_NAME__/): it completes only if the generated branch is promptly writable. If gitea's async generate is slow (infra#179) the repo is still created and partial_failure explains how to finalize locally (`hyperguild new-project`). Check files_substituted and partial_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent). Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is immediately dispatch-eligible (dispatch#3).", 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."}, "dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file so the new project is immediately opt-in for headless dispatch (dispatch#3). Default false."} }, "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"` DispatchAllow bool `json:"dispatch_allow"` } // dispatchAllowContent is the body injected when dispatch_allow=true. Mirrors the // sandbox convention: presence of the file (not its content) marks the repo // dispatch-eligible; the comment exists only to explain that to a human reader. const dispatchAllowContent = "# Presence of this file marks this repo as opt-in for headless dispatch.\n" + "# See dispatch#3.\n" 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) } } // Opt the new project into headless dispatch if asked: presence of a // .dispatch-allow file on the default branch marks it dispatch-eligible // (dispatch#3). Ride the same upsertRetry path as substitution so it inherits // the infra#179 branch-readiness / partial-failure handling below. Skip if the // loop already stalled — a failed injection then degrades identically. if args.DispatchAllow && result.PartialFailure == "" { const dispatchAllowPath = ".dispatch-allow" if err := t.upsertRetry(ctx, args.Owner, args.Name, dispatchAllowPath, gitea.UpsertFileArgs{ Branch: branch, Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)), Message: "dispatch: mark project dispatch-eligible (dispatch#3)", }); err != nil { result.PartialFailure = fmt.Sprintf("write %s: %v", dispatchAllowPath, err) } else { result.FilesSubstituted = append(result.FilesSubstituted, dispatchAllowPath) } } // If substitution stalled because the generated branch wasn't writable in time, // the repo IS created — say so clearly and point to the local finalize step, // rather than leaking the raw "branch does not exist" (infra#179: gitea's // template-generate is slow-async on this instance, so tool-side substitution // is best-effort). if strings.Contains(result.PartialFailure, "branch does not exist") || strings.Contains(result.PartialFailure, "not found") { result.PartialFailure = fmt.Sprintf( "repo created, but its branch (%s) was not writable within %ds — gitea's "+ "template-generate is slow-async on this instance (infra#179), so substitution "+ "is incomplete (%d file(s) done). Finalize locally with `hyperguild new-project` "+ "(clone + substitute, no API race). Underlying: %s", branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure) } // 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) } // substitutionBudget bounds how long we retry the first write while the freshly // generated branch becomes writable. gitea's /generate returns (and serves reads) // before the branch ref is committed, so writes 404 "branch does not exist" for a // window. We keep the budget SHORT so the MCP call stays responsive: a healthy // gitea commits in ~1s and this catches it; a slow one (infra#179, observed >40s) // fails fast and we defer substitution with clear guidance rather than hang. const substitutionBudget = 5 // upsertRetry retries UpsertFile on the transient post-generate "branch does not // exist" not-found, up to substitutionBudget. The write itself is the readiness // probe — BranchExists reports the branch present before writes succeed. func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error { var err error for i := 0; i < substitutionBudget; 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, "" }