diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go index 16a7606..b697027 100644 --- a/internal/tools/create_project_from_template.go +++ b/internal/tools/create_project_from_template.go @@ -48,7 +48,7 @@ func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplO 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 finish substituting the placeholders manually. 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).", + 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":{ @@ -56,8 +56,9 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { "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."} + "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 so the project is opt-in for headless dispatch (dispatch#3). 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"] }`), @@ -71,6 +72,7 @@ type createProjectArgs struct { 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 @@ -80,12 +82,13 @@ const dispatchAllowContent = "# Presence of this file marks this repo as opt-in "# 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"` + 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"` } func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { @@ -104,6 +107,87 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag 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: presence of a + // .dispatch-allow file on the default branch marks it dispatch-eligible + // (dispatch#3). Skip if substitution itself already stalled — don't mark an + // incomplete repo dispatch-eligible. A failure here is reported in its OWN + // field (gitea-mcp#51) — it must never be indistinguishable from a + // substitution failure, since one can succeed while the other doesn't. + if args.DispatchAllow && result.PartialFailure == "" { + didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch) + if fail != "" { + result.DispatchAllowFailure = fail + } else if didWrite { + result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow") + } + } + + // 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 @@ -114,17 +198,19 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag // 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) + return createProjectResult{}, "", 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) + 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 nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict) + 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 nil, fmt.Errorf("destination check: %w", err) + return createProjectResult{}, "", fmt.Errorf("destination check: %w", err) } // Generate repo from template. @@ -136,7 +222,7 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag GitContent: true, }) if err != nil { - return nil, fmt.Errorf("generate: %w", err) + return createProjectResult{}, "", fmt.Errorf("generate: %w", err) } result := createProjectResult{ @@ -158,83 +244,76 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag } } result.DefaultBranch = branch + return result, branch, nil +} - // 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) +// 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 { - 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 + 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) } - 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) + 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) } - // 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 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) } - - // 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 = infra179FinalizeMessage( - 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) + 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 names the concrete remaining work — substituting the two -// placeholders — rather than pointing at a specific tool, so the guidance stays -// correct regardless of scaffolding-CLI state (gitea-mcp#46). +// 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). Finish it by cloning the repo and replacing the "+ - "remaining __PROJECT_NAME__ / __MODULE_PATH__ placeholders (in file contents and "+ - "paths), then pushing; or retry create once the branch settles. Underlying: %s", + "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) } @@ -243,7 +322,8 @@ func infra179FinalizeMessage(branch string, budget, done int, underlying string) // 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. +// 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 diff --git a/internal/tools/create_project_from_template_test.go b/internal/tools/create_project_from_template_test.go index 9a0f9e3..2f3b1b3 100644 --- a/internal/tools/create_project_from_template_test.go +++ b/internal/tools/create_project_from_template_test.go @@ -29,20 +29,30 @@ func templateRepoJSON(name string, isTemplate bool) string { // fakeTemplateServer serves the whole create-from-template flow off an in-memory // file map, driving the tool's tree-walk. Records writes/deletes/put-bodies. type fakeTemplateServer struct { - mu sync.Mutex - files map[string]string // path -> raw (un-substituted) content - genBranch string // default_branch returned by /generate ("" to force fallback) - generated bool - puts []string - deletes []string - putBodies map[string]string // path -> decoded written content - repoGetsPost int // GET dest after generate (branch fallback) + mu sync.Mutex + files map[string]string // path -> raw (un-substituted) content + genBranch string // default_branch returned by /generate ("" to force fallback) + generated bool + generateCalls int // # times POST .../generate was hit — resume must never increment this + puts []string + deletes []string + putBodies map[string]string // path -> decoded written content + repoGetsPost int // GET dest after generate (branch fallback) } func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer { return &fakeTemplateServer{files: files, genBranch: genBranch, putBodies: map[string]string{}} } +// newFakeTemplateServerResumed simulates a repo that already exists from a +// prior (real) generate call — GET dest succeeds immediately, without a +// /generate call first. Used for resume:true tests. +func newFakeTemplateServerResumed(files map[string]string, genBranch string) *fakeTemplateServer { + f := newFakeTemplateServer(files, genBranch) + f.generated = true + return f +} + func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() @@ -65,6 +75,7 @@ func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.Handl case r.Method == http.MethodPost && p == "/api/v1/repos/mathias/"+tmpl+"/generate": f.generated = true + f.generateCalls++ w.WriteHeader(http.StatusCreated) _, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`, dest, dest, f.genBranch, dest, dest) @@ -135,10 +146,11 @@ func callTool(t *testing.T, srvURL, tmpl, argsJSON string) createOut { } type createOut struct { - FullName string `json:"full_name"` - DefaultBranch string `json:"default_branch"` - FilesSubstituted []string `json:"files_substituted"` - PartialFailure string `json:"partial_failure,omitempty"` + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` + FilesSubstituted []string `json:"files_substituted"` + PartialFailure string `json:"partial_failure,omitempty"` + DispatchAllowFailure string `json:"dispatch_allow_failure,omitempty"` } // Happy path: whole-tree substitution, content + path rename, correct module host. @@ -258,6 +270,126 @@ func TestCreateProject_DispatchAllow(t *testing.T) { } } +// ── resume: durable substitution against infra#179 (gitea-mcp#50) ─────────── + +// resume:true requires an ALREADY-CREATED destination — there is nothing to +// resume otherwise. +func TestCreateProject_Resume_NoDestination_Errors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + })) + defer srv.Close() + + _, err := newTool(srv.URL, "template-go-agent").Call(context.Background(), + json.RawMessage(`{"owner":"mathias","name":"new-svc","resume":true}`)) + require.Error(t, err) + assert.ErrorIs(t, err, gitea.ErrValidation) + assert.Contains(t, err.Error(), "nothing to resume") +} + +// resume:true on an existing repo continues substitution — fixes only what's +// still wrong, leaves already-correct files untouched, and never calls +// /generate again (the whole point: no re-creation, just continuation). +func TestCreateProject_Resume_ContinuesPartialSubstitution(t *testing.T) { + files := map[string]string{ + "go.mod": "module git.d-ma.be/mathias/new-svc\n", // already correct from a prior partial run + "README.md": "# __PROJECT_NAME__\n", // still needs substitution + } + f := newFakeTemplateServerResumed(files, "main") + srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) + defer srv.Close() + + out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`) + + assert.Empty(t, out.PartialFailure) + assert.Equal(t, 0, f.generateCalls, "resume must never call /generate") + assert.Contains(t, out.FilesSubstituted, "README.md") + assert.NotContains(t, out.FilesSubstituted, "go.mod", "already-correct file must not be re-reported") + assert.NotContains(t, f.puts, "go.mod", "already-correct file must not be rewritten") + assert.Contains(t, f.puts, "README.md") +} + +// resume:true when everything is already substituted is the expected steady +// state (a prior resume already finished the job, or this is a redundant +// re-invoke) — success, NOT the "no placeholders substituted" loud failure +// that a fresh (non-resume) create would trigger. +func TestCreateProject_Resume_AlreadyFullyDone_IsSuccess(t *testing.T) { + files := map[string]string{ + "go.mod": "module git.d-ma.be/mathias/new-svc\n", + "README.md": "# new-svc\n", + } + f := newFakeTemplateServerResumed(files, "main") + srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) + defer srv.Close() + + out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true}`) + + assert.Empty(t, out.FilesSubstituted) + assert.Empty(t, out.PartialFailure, "nothing left to do on resume must be success, not a loud failure") +} + +// dispatch_allow injection is idempotent on resume: if .dispatch-allow already +// has the correct content (from an earlier successful injection), re-invoking +// must not attempt another write — and must not error the way a naive +// create-only write would (gitea 409/422 on an existing path with no sha). +func TestCreateProject_Resume_DispatchAllowIdempotent(t *testing.T) { + // Phase 1: a normal (non-resume) call captures the REAL content the tool + // writes for .dispatch-allow, without the test needing to know the exact + // unexported constant. + seedFiles := map[string]string{"go.mod": "module __MODULE_PATH__\n"} + seedSrv := newFakeTemplateServer(seedFiles, "main") + srv1 := httptest.NewServer(seedSrv.handler(t, "template-go-agent", "new-svc")) + out1 := callTool(t, srv1.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`) + srv1.Close() + require.Empty(t, out1.PartialFailure) + realContent := seedSrv.putBodies[".dispatch-allow"] + require.NotEmpty(t, realContent, "phase 1 must have written .dispatch-allow") + + // Phase 2: resume with .dispatch-allow ALREADY at that exact content, plus + // one file still needing substitution. + files := map[string]string{ + ".dispatch-allow": realContent, + "README.md": "# __PROJECT_NAME__\n", + } + f := newFakeTemplateServerResumed(files, "main") + srv2 := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) + defer srv2.Close() + + out2 := callTool(t, srv2.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","resume":true,"dispatch_allow":true}`) + + assert.Empty(t, out2.PartialFailure) + assert.Empty(t, out2.DispatchAllowFailure) + assert.NotContains(t, f.puts, ".dispatch-allow", "already-correct .dispatch-allow must not be rewritten") + assert.NotContains(t, out2.FilesSubstituted, ".dispatch-allow", "unchanged file must not be reported as substituted") + assert.Contains(t, out2.FilesSubstituted, "README.md") +} + +// dispatch_allow injection failing is reported in its OWN field, distinct from +// PartialFailure (gitea-mcp#51) — substitution can succeed while dispatch +// eligibility still fails, and the caller must be able to tell them apart. +func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) { + files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} + f := newFakeTemplateServer(files, "main") + base := f.handler(t, "template-go-agent", "new-svc") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/contents/.dispatch-allow") { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + return + } + base(w, r) + })) + defer srv.Close() + + out := callTool(t, srv.URL, "template-go-agent", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`) + + assert.Empty(t, out.PartialFailure, "substitution itself succeeded — must not be conflated with the dispatch failure") + assert.NotEmpty(t, out.DispatchAllowFailure) + assert.Contains(t, out.DispatchAllowFailure, ".dispatch-allow") + assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure") +} + // ── guardrails unchanged by the rewrite ────────────────────────────────────── func TestCreateProject_NameRegexFailure(t *testing.T) { diff --git a/internal/tools/create_project_internal_test.go b/internal/tools/create_project_internal_test.go index e27f16d..c53c5ec 100644 --- a/internal/tools/create_project_internal_test.go +++ b/internal/tools/create_project_internal_test.go @@ -6,12 +6,13 @@ import ( ) // #46: the infra#179 finalize guidance must not point at a non-existent command -// (`hyperguild new-project` was never built). It should name the real remaining -// work — substituting the placeholders — so the caller isn't sent to a dead end. +// (`hyperguild new-project` was never built). #50 superseded the original +// manual-editing guidance with a concrete, real recovery: re-invoke this same +// tool with resume=true. func TestInfra179FinalizeMessage(t *testing.T) { msg := infra179FinalizeMessage("main", 5, 2, "branch does not exist") - for _, want := range []string{"infra#179", "__PROJECT_NAME__", "__MODULE_PATH__", "branch does not exist"} { + for _, want := range []string{"infra#179", "resume=true", "branch does not exist"} { if !strings.Contains(msg, want) { t.Errorf("message missing %q\ngot: %s", want, msg) }