From 09a7fad6ba80131e265d964238d5b78c04572eee Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 7 Jul 2026 00:33:39 +0200 Subject: [PATCH] feat(create_project): dispatch_allow also registers the repo into dispatch's allowlist (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../tools/create_project_from_template.go | 88 ++++++++++--- .../create_project_from_template_test.go | 119 +++++++++++++++++- 2 files changed, 186 insertions(+), 21 deletions(-) diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go index ab2c687..f122449 100644 --- a/internal/tools/create_project_from_template.go +++ b/internal/tools/create_project_from_template.go @@ -57,7 +57,7 @@ func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor { "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 so the project is opt-in for headless dispatch (dispatch#3). Default false. Safe to re-request on resume."}, + "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"] @@ -82,15 +82,28 @@ 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"` - DispatchAllowFailure string `json:"dispatch_allow_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"` + 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 { @@ -147,19 +160,26 @@ 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). 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. + // 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 == "" { - didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch) - if fail != "" { + 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, @@ -302,6 +322,42 @@ func (t *CreateProjectFromTemplate) injectDispatchAllow(ctx context.Context, own 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 diff --git a/internal/tools/create_project_from_template_test.go b/internal/tools/create_project_from_template_test.go index 093d531..4d8c590 100644 --- a/internal/tools/create_project_from_template_test.go +++ b/internal/tools/create_project_from_template_test.go @@ -38,6 +38,12 @@ type fakeTemplateServer struct { deletes []string putBodies map[string]string // path -> decoded written content repoGetsPost int // GET dest after generate (branch fallback) + + // dispatchRepos simulates mathias/dispatch:dispatch-repos.txt (gitea-mcp#54). + // "" (default) 404s the read, matching a repo that hasn't seeded the file + // (a distinct error path); tests that care set it explicitly. + dispatchRepos string + dispatchReposPuts int } func newFakeTemplateServer(files map[string]string, genBranch string) *fakeTemplateServer { @@ -60,7 +66,28 @@ func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.Handl w.Header().Set("Content-Type", "application/json") p := r.URL.Path + const dispatchReposPath = "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt" switch { + case r.Method == http.MethodGet && p == dispatchReposPath: + if f.dispatchRepos == "" { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + return + } + _, _ = fmt.Fprintf(w, `{"path":"dispatch-repos.txt","sha":"repos-sha","content":%q,"encoding":"base64"}`, encb64(f.dispatchRepos)) + + case r.Method == http.MethodPut && p == dispatchReposPath: + raw, _ := io.ReadAll(r.Body) + var args struct { + Content string `json:"content"` + } + _ = json.Unmarshal(raw, &args) + dec, _ := base64.StdEncoding.DecodeString(args.Content) + f.dispatchRepos = string(dec) + f.dispatchReposPuts++ + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"content":{"path":"dispatch-repos.txt","sha":"repos-sha2"},"commit":{"sha":"c"}}`)) + case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl: _, _ = w.Write([]byte(templateRepoJSON(tmpl, true))) @@ -146,11 +173,13 @@ 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"` - DispatchAllowFailure string `json:"dispatch_allow_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"` + DispatchAllowlisted bool `json:"dispatch_allowlisted,omitempty"` + DispatchAllowlistFailure string `json:"dispatch_allowlist_failure,omitempty"` } // Happy path: whole-tree substitution, content + path rename, correct module host. @@ -413,6 +442,86 @@ func TestCreateProject_DispatchAllowFailure_IsDistinctField(t *testing.T) { assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate dispatch failure") } +// dispatch_allow now ALSO registers the new repo into mathias/dispatch's +// git-tracked allowlist (dispatch-repos.txt) — the second of the two required +// dispatch gates, independent of the .dispatch-allow marker (gitea-mcp#54). +func TestCreateProject_DispatchAllow_RegistersAllowlist(t *testing.T) { + files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} + f := newFakeTemplateServer(files, "main") + f.dispatchRepos = "# comment\nmathias/dispatch-sandbox\nmathias/cobalt-dingo\n" + 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","dispatch_allow":true}`) + + assert.Empty(t, out.PartialFailure) + assert.Empty(t, out.DispatchAllowlistFailure) + assert.True(t, out.DispatchAllowlisted) + assert.Equal(t, 1, f.dispatchReposPuts) + assert.Contains(t, f.dispatchRepos, "mathias/new-svc") + // existing entries preserved, not clobbered + assert.Contains(t, f.dispatchRepos, "mathias/dispatch-sandbox") + assert.Contains(t, f.dispatchRepos, "mathias/cobalt-dingo") +} + +// Registering is idempotent: a repo already listed (e.g. a resume re-running +// with dispatch_allow:true) must not produce a duplicate line or a redundant write. +func TestCreateProject_DispatchAllow_RegistersAllowlist_AlreadyListedIsNoop(t *testing.T) { + files := map[string]string{"go.mod": "module git.d-ma.be/mathias/new-svc\n"} // already substituted + f := newFakeTemplateServerResumed(files, "main") + f.dispatchRepos = "mathias/dispatch-sandbox\nmathias/new-svc\n" + 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,"dispatch_allow":true}`) + + assert.Empty(t, out.PartialFailure) + assert.Empty(t, out.DispatchAllowlistFailure) + assert.False(t, out.DispatchAllowlisted, "already-listed must not be reported as a fresh registration") + assert.Equal(t, 0, f.dispatchReposPuts, "already-listed repo must not trigger a write") +} + +// A failure registering the allowlist is reported in its OWN field — distinct +// from PartialFailure (substitution) AND DispatchAllowFailure (the marker +// file) — since all three are independent gates that can fail independently. +func TestCreateProject_DispatchAllowlistFailure_IsDistinctField(t *testing.T) { + files := map[string]string{"go.mod": "module __MODULE_PATH__\n"} + f := newFakeTemplateServer(files, "main") + f.dispatchRepos = "mathias/dispatch-sandbox\n" + base := f.handler(t, "template-go-agent", "new-svc") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut && r.URL.Path == "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt" { + 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 succeeded — must not be conflated") + assert.Empty(t, out.DispatchAllowFailure, ".dispatch-allow marker succeeded — must not be conflated") + assert.NotEmpty(t, out.DispatchAllowlistFailure) + assert.Contains(t, out.DispatchAllowlistFailure, "dispatch-repos.txt") + assert.Contains(t, out.FilesSubstituted, "go.mod", "substitution must still be reported despite the separate allowlist failure") +} + +// dispatch_allow=false/omitted must never touch the allowlist file at all. +func TestCreateProject_DispatchAllowFalse_DoesNotTouchAllowlist(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", `{"owner":"mathias","name":"new-svc"}`) + + assert.Empty(t, out.PartialFailure) + assert.False(t, out.DispatchAllowlisted) + assert.Equal(t, 0, f.dispatchReposPuts) +} + // ── guardrails unchanged by the rewrite ────────────────────────────────────── func TestCreateProject_NameRegexFailure(t *testing.T) {