gitea's template-generate is slow-async on this instance (repo not writable for >40s; infra#179) — no synchronous MCP tool can wait that long, and the 60s retry made the call hang until the client timed out. Bound the write-readiness retry to 5s (a healthy gitea commits in ~1s and this still catches it), and when the branch isn't writable in time, return a clear partial_failure: repo created, substitution deferred, finalize locally with `hyperguild new-project`. Substitution logic is intact and completes automatically once generate is fast (infra#179). Tool description updated to describe substitution as best-effort. Refs #42, infra#179. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
287 lines
11 KiB
Go
287 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"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]$`)
|
|
|
|
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).",
|
|
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,
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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, ""
|
|
}
|