feat(create_project): add dispatch_allow to inject .dispatch-allow (#43)
Optional dispatch_allow bool (default false). When true, inject a .dispatch-allow file at repo root on the resolved default branch after substitution, marking the new project dispatch-eligible (dispatch#3) without a manual follow-up commit. Rides the existing upsertRetry path so injection inherits the infra#179 branch-readiness / partial_failure handling; a stalled injection degrades exactly like substitution. Reported in files_substituted. false/omitted is byte-for-byte unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user