feat(create_project): dispatch_allow also registers the repo into dispatch's allowlist (#54)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user