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:
@@ -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