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 {
|
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
||||||
return registry.ToolDescriptor{
|
return registry.ToolDescriptor{
|
||||||
Name: "create_project_from_template",
|
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(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"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]$"},
|
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
|
||||||
"description":{"type":"string"},
|
"description":{"type":"string"},
|
||||||
"private":{"type":"boolean"},
|
"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"]
|
"required":["owner","name"]
|
||||||
}`),
|
}`),
|
||||||
@@ -64,13 +65,20 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createProjectArgs struct {
|
type createProjectArgs struct {
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Private bool `json:"private"`
|
Private bool `json:"private"`
|
||||||
TemplateName string `json:"template_name"`
|
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 {
|
type createProjectResult struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
HTMLURL string `json:"html_url"`
|
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,
|
// 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,
|
// 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
|
// 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")
|
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 ──────────────────────────────────────
|
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
|
||||||
|
|
||||||
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user