Files
gitea-mcp/internal/tools/create_project_from_template.go
T
mathiasandClaude Opus 4.8 09a7fad6ba
CD / Lint / Test / Vet (push) Successful in 7s
CD / Build & Import (push) Successful in 21s
CD / Deploy via GitOps (push) Has been skipped
feat(create_project): dispatch_allow also registers the repo into dispatch's allowlist (#54)
Extends the existing dispatch_allow flag (which already injects .dispatch-allow,
#43/#51/#53) to also append owner/name to mathias/dispatch's git-tracked
dispatch-repos.txt (dispatch#19) — the second of the two required dispatch
gates. Being listed there is necessary but not sufficient on its own (the repo
still needs its own .dispatch-allow marker) — both are applied independently,
neither implies the other, matching dispatch#3's structural trust-zone design
where both must say yes.

Idempotent: a repo already listed (e.g. dispatch_allow re-requested on resume)
is a no-op, not a duplicate line. Failure is reported in its own
dispatch_allowlist_failure field, distinct from partial_failure (substitution)
and dispatch_allow_failure (the marker file) — the three gates can each fail
independently, never conflated (same principle as #51).

The allowlist owner/repo/path/branch are hardcoded to match mathias/dispatch's
own DISPATCH_ALLOWLIST_* env defaults, confirmed unoverridden in the live
CronJob (2026-07-06) before implementing.

Tests: fresh registration (existing entries preserved, not clobbered),
already-listed no-op (zero writes), allowlist-write failure as a distinct
field, dispatch_allow=false never touches the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 00:33:39 +02:00

488 lines
22 KiB
Go

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 the shortfall — call this tool again with resume=true (same owner/name) once the branch settles to safely continue where it left off; already-correct files/renames are left untouched. Check files_substituted, partial_failure, and dispatch_allow_failure. Defaults to the server-configured template; pass template_name to override (e.g. template-go-agent) — ignored when resume=true. Pass dispatch_allow=true to also inject a .dispatch-allow file so the project is dispatch-eligible (dispatch#3); safe to re-request on resume.",
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. Ignored when resume=true."},
"dispatch_allow":{"type":"boolean","description":"When true, inject a .dispatch-allow file (dispatch#3) AND register owner/name into mathias/dispatch's git-tracked allowlist (dispatch-repos.txt, dispatch#19) — both gates are required for the watcher to actually pick the repo up; each is applied independently and idempotently. Default false. Safe to re-request on resume."},
"resume":{"type":"boolean","description":"Resume substitution on an ALREADY-CREATED repo from a prior call that hit infra#179's branch-writability race (its partial_failure names this). Skips template lookup and repo generation entirely; the destination must already exist. Safe to call repeatedly — files/renames already correct are left untouched. 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"`
Resume bool `json:"resume"`
}
// 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"`
DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"`
DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"`
DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"`
}
// The dispatch allowlist (dispatch#19) is a fixed integration point — a
// specific file in a specific repo the mathias/dispatch watcher reads at the
// start of every cycle. Hardcoded to match its own DISPATCH_ALLOWLIST_OWNER/
// _REPO/_PATH defaults (unconfigured in the live CronJob, confirmed 2026-07-06).
const (
dispatchAllowlistOwner = "mathias"
dispatchAllowlistRepo = "dispatch"
dispatchAllowlistPath = "dispatch-repos.txt"
dispatchAllowlistBranch = "main"
)
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)
}
var result createProjectResult
var branch string
var err error
if args.Resume {
result, branch, err = t.resumeDestination(ctx, args.Owner, args.Name)
} else {
result, branch, err = t.createDestination(ctx, args)
}
if err != nil {
return nil, err
}
// 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. GetTree reflects the
// branch's CURRENT state, which is what makes this loop safe to re-run on
// resume: a file already fixed in a prior partial pass shows up already-correct
// (or already-renamed) and substituteEntry is a no-op for it.
repls := substitutions(args.Owner, args.Name)
tree, terr := t.c.GetTree(ctx, args.Owner, args.Name, branch, true)
if terr != nil {
result.PartialFailure = fmt.Sprintf("tree walk (%s@%s): %v", args.Name, branch, terr)
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. Two INDEPENDENT gates,
// both required (dispatch#3 + dispatch#19): the .dispatch-allow marker on this
// repo, and this repo's owner/name listed in mathias/dispatch's git-tracked
// allowlist. Skip both if substitution itself already stalled — don't mark an
// incomplete repo dispatch-eligible. Each failure is reported in its OWN field
// (gitea-mcp#51/#54) — never conflated with each other or with substitution,
// since any one of the three can fail independently of the other two.
if args.DispatchAllow && result.PartialFailure == "" {
if didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch); fail != "" {
result.DispatchAllowFailure = fail
} else if didWrite {
result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow")
}
ownerName := args.Owner + "/" + args.Name
if didRegister, fail := t.registerDispatchAllowlist(ctx, ownerName); fail != "" {
result.DispatchAllowlistFailure = fail
} else if didRegister {
result.DispatchAllowlisted = true
}
}
// If substitution stalled because the generated branch wasn't writable in time,
// the repo IS created — say so clearly and point to the concrete recovery step
// (resume=true), 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 = infra179FinalizeMessage(
branch, substitutionBudget, len(result.FilesSubstituted), result.PartialFailure)
}
// Fail loud on a FRESH create with nothing substituted: a real template
// should have placeholders, so finding none is suspicious. On resume, nothing
// left to substitute is the expected steady state once a prior partial run is
// fully caught up — success, not a loud failure.
if !args.Resume && 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)
}
// createDestination generates a new repo from the template: verifies the
// template exists and the destination doesn't already exist, then calls
// gitea's /generate and resolves the default branch.
func (t *CreateProjectFromTemplate) createDestination(ctx context.Context, args createProjectArgs) (createProjectResult, string, error) {
// 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 createProjectResult{}, "", fmt.Errorf("template lookup: %w", err)
}
if !tmpl.Template {
return createProjectResult{}, "", 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 createProjectResult{}, "", fmt.Errorf(
"destination %s/%s already exists: %w (pass resume:true to continue a prior partial create)",
args.Owner, args.Name, gitea.ErrConflict)
} else if !errors.Is(err, gitea.ErrNotFound) {
return createProjectResult{}, "", 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 createProjectResult{}, "", 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
return result, branch, nil
}
// resumeDestination looks up an ALREADY-CREATED repo to continue substitution
// on (gitea-mcp#50). It errors if the destination doesn't exist — resume has
// nothing to resume without it. Template lookup and generation are skipped
// entirely: resume only ever operates on the destination.
func (t *CreateProjectFromTemplate) resumeDestination(ctx context.Context, owner, name string) (createProjectResult, string, error) {
repo, err := t.c.GetRepo(ctx, owner, name)
if err != nil {
if errors.Is(err, gitea.ErrNotFound) {
return createProjectResult{}, "", fmt.Errorf(
"resume:true but %s/%s does not exist — nothing to resume; omit resume to create it: %w",
owner, name, gitea.ErrValidation)
}
return createProjectResult{}, "", fmt.Errorf("resume destination check: %w", err)
}
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
return createProjectResult{
FullName: repo.FullName,
HTMLURL: repo.HTMLURL,
CloneURL: repo.CloneURL,
DefaultBranch: branch,
}, branch, nil
}
// injectDispatchAllow makes .dispatch-allow's presence idempotent (safe to call
// on every resume, not just the first attempt): creates it if absent, updates
// it if present but different, leaves it untouched if already correct. Without
// this, a naive create-only write would error on a re-invoke (gitea rejects a
// create at a path that already exists) — that was the reported failure in
// gitea-mcp#51. Returns whether a write actually happened.
func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, owner, name, branch string) (didWrite bool, failure string) {
const path = ".dispatch-allow"
sha := ""
if fc, err := t.c.GetFileContents(ctx, owner, name, path, branch); err == nil {
if decoded, derr := base64.StdEncoding.DecodeString(fc.Content); derr == nil && string(decoded) == dispatchAllowContent {
return false, "" // already present and correct — idempotent no-op
}
sha = fc.Sha // exists but differs (unexpected) — update it, don't blind-create
} else if !errors.Is(err, gitea.ErrNotFound) {
return false, fmt.Sprintf("read %s: %v", path, err)
}
if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{
Branch: branch,
Content: base64.StdEncoding.EncodeToString([]byte(dispatchAllowContent)),
Message: "dispatch: mark project dispatch-eligible (dispatch#3)",
Sha: sha,
}); err != nil {
return false, fmt.Sprintf("write %s: %v", path, err)
}
return true, ""
}
// registerDispatchAllowlist appends ownerName ("owner/name") to
// mathias/dispatch's git-tracked dispatch-repos.txt if not already listed
// (gitea-mcp#54) — idempotent, safe to call on every resume. This is the
// SECOND of the two required dispatch gates: being listed here is necessary
// but not sufficient on its own (the repo also needs its own .dispatch-allow
// marker, injectDispatchAllow's job) — dispatch#3's structural trust-zone
// design requires both independently. Returns whether a write actually
// happened, so the caller only reports a fresh registration, not a no-op.
func (t *CreateProjectFromTemplate) registerDispatchAllowlist(ctx context.Context, ownerName string) (didRegister bool, failure string) {
fc, err := t.c.GetFileContents(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, dispatchAllowlistBranch)
if err != nil {
return false, fmt.Sprintf("read %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return false, fmt.Sprintf("decode %s: %v", dispatchAllowlistPath, err)
}
content := string(decoded)
for _, line := range strings.Split(content, "\n") {
if strings.TrimSpace(line) == ownerName {
return false, "" // already listed — idempotent no-op
}
}
newContent := strings.TrimRight(content, "\n") + "\n" + ownerName + "\n"
if _, err := t.c.UpsertFile(ctx, dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, gitea.UpsertFileArgs{
Branch: dispatchAllowlistBranch,
Content: base64.StdEncoding.EncodeToString([]byte(newContent)),
Message: fmt.Sprintf("chore(allowlist): opt in %s for headless dispatch", ownerName),
Sha: fc.Sha,
}); err != nil {
return false, fmt.Sprintf("append to %s/%s:%s: %v", dispatchAllowlistOwner, dispatchAllowlistRepo, dispatchAllowlistPath, err)
}
return true, ""
}
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
// async template-generate (infra#179) leaves the branch unwritable within the
// budget. It points at the concrete recovery step — re-invoking this same tool
// with resume=true (gitea-mcp#50) — rather than a manual clone/sed/push, since
// resume safely continues from wherever substitution stalled.
func infra179FinalizeMessage(branch string, budget, done int, underlying string) string {
return 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). Retry by calling this tool again with resume=true "+
"(same owner/name) once the branch is writable — it safely continues where this left "+
"off, skipping anything already correct. Underlying: %s",
branch, budget, done, underlying)
}
// 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 with partial_failure naming resume=true as the recovery path
// (gitea-mcp#50), rather than blocking the call for a minute-plus.
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 {
return t.renameEntry(ctx, owner, name, branch, path, newPath, enc, newContent, fc.Sha)
}
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, ""
}
// renameEntry writes newPath then deletes oldPath. Both halves are idempotent
// so a resume that hits a prior write-succeeded/delete-failed rename (the two
// are separate, non-atomic API calls) completes cleanly instead of erroring on
// a blind re-create at a path that already exists (gitea-mcp#53): if newPath
// already holds the correct content, the write is skipped and only the
// outstanding delete of oldPath runs; if oldPath is already gone, the delete
// is a no-op too.
func (t *CreateProjectFromTemplate) renameEntry(ctx context.Context, owner, name, branch, oldPath, newPath, enc, newContent, oldSha string) (substituted, failure string) {
existing, err := t.c.GetFileContents(ctx, owner, name, newPath, branch)
switch {
case err == nil:
decoded, derr := base64.StdEncoding.DecodeString(existing.Content)
if derr == nil && string(decoded) == newContent {
break // already correct from a prior partial run — skip the write
}
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch, Content: enc, Sha: existing.Sha,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
}); writeErr != nil {
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
}
case errors.Is(err, gitea.ErrNotFound):
if writeErr := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{
Branch: branch, Content: enc,
Message: fmt.Sprintf("template: substitute + rename %s -> %s", oldPath, newPath),
}); writeErr != nil {
return "", fmt.Sprintf("write %s: %v", newPath, writeErr)
}
default:
return "", fmt.Sprintf("read %s: %v", newPath, err)
}
if _, err := t.c.DeleteFile(ctx, owner, name, oldPath, gitea.DeleteFileArgs{
Branch: branch,
Sha: oldSha,
Message: fmt.Sprintf("template: drop placeholder path %s", oldPath),
}); err != nil && !errors.Is(err, gitea.ErrNotFound) {
return "", fmt.Sprintf("delete %s: %v", oldPath, err)
}
return oldPath + " -> " + newPath, ""
}