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>
488 lines
22 KiB
Go
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, ""
|
|
}
|