diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go index 0e8554f..0cd0c96 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 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).", + 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":{ @@ -56,7 +56,8 @@ 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."} + "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"] }`), @@ -64,13 +65,20 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { } type createProjectArgs struct { - Owner string `json:"owner"` - Name string `json:"name"` - Description string `json:"description"` - Private bool `json:"private"` - TemplateName string `json:"template_name"` + 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"` @@ -176,6 +184,24 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag } } + // 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 diff --git a/internal/tools/create_project_from_template_test.go b/internal/tools/create_project_from_template_test.go index a91f744..5da3833 100644 --- a/internal/tools/create_project_from_template_test.go +++ b/internal/tools/create_project_from_template_test.go @@ -226,6 +226,38 @@ func TestCreateProject_WriteFailure_PartialFailure(t *testing.T) { assert.Contains(t, out.PartialFailure, "go.mod") } +// dispatch_allow injects a .dispatch-allow file (dispatch#3) only when true. +func TestCreateProject_DispatchAllow(t *testing.T) { + tests := []struct { + name string + argsJSON string + wantFile bool + }{ + {"true injects .dispatch-allow", `{"owner":"mathias","name":"new-svc","dispatch_allow":true}`, true}, + {"false does not inject", `{"owner":"mathias","name":"new-svc","dispatch_allow":false}`, false}, + {"omitted does not inject", `{"owner":"mathias","name":"new-svc"}`, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} + f := newFakeTemplateServer(files, "main") + srv := httptest.NewServer(f.handler(t, "template-go-agent", "new-svc")) + defer srv.Close() + + out := callTool(t, srv.URL, "template-go-agent", tc.argsJSON) + require.Empty(t, out.PartialFailure) + if tc.wantFile { + assert.Contains(t, out.FilesSubstituted, ".dispatch-allow") + assert.Contains(t, f.puts, ".dispatch-allow") + assert.Contains(t, f.putBodies[".dispatch-allow"], "dispatch#3") + } else { + assert.NotContains(t, out.FilesSubstituted, ".dispatch-allow") + assert.NotContains(t, f.puts, ".dispatch-allow") + } + }) + } +} + // ── guardrails unchanged by the rewrite ────────────────────────────────────── func TestCreateProject_NameRegexFailure(t *testing.T) {