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"},
|
"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. Ignored when resume=true."},
|
"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."}
|
"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"]
|
"required":["owner","name"]
|
||||||
@@ -89,8 +89,21 @@ type createProjectResult struct {
|
|||||||
FilesSubstituted []string `json:"files_substituted"`
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
PartialFailure string `json:"partial_failure,omitempty"`
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
DispatchAllowFailure string `json:"dispatch_allow_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) {
|
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
|
||||||
var args createProjectArgs
|
var args createProjectArgs
|
||||||
if err := parseArgs(raw, &args); err != nil {
|
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
|
// Opt the new project into headless dispatch if asked. Two INDEPENDENT gates,
|
||||||
// .dispatch-allow file on the default branch marks it dispatch-eligible
|
// both required (dispatch#3 + dispatch#19): the .dispatch-allow marker on this
|
||||||
// (dispatch#3). Skip if substitution itself already stalled — don't mark an
|
// repo, and this repo's owner/name listed in mathias/dispatch's git-tracked
|
||||||
// incomplete repo dispatch-eligible. A failure here is reported in its OWN
|
// allowlist. Skip both if substitution itself already stalled — don't mark an
|
||||||
// field (gitea-mcp#51) — it must never be indistinguishable from a
|
// incomplete repo dispatch-eligible. Each failure is reported in its OWN field
|
||||||
// substitution failure, since one can succeed while the other doesn't.
|
// (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 == "" {
|
if args.DispatchAllow && result.PartialFailure == "" {
|
||||||
didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch)
|
if didWrite, fail := t.injectDispatchAllow(ctx, args.Owner, args.Name, branch); fail != "" {
|
||||||
if fail != "" {
|
|
||||||
result.DispatchAllowFailure = fail
|
result.DispatchAllowFailure = fail
|
||||||
} else if didWrite {
|
} else if didWrite {
|
||||||
result.FilesSubstituted = append(result.FilesSubstituted, ".dispatch-allow")
|
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,
|
// 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, ""
|
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
|
// infra179FinalizeMessage explains the best-effort outcome when gitea's slow
|
||||||
// async template-generate (infra#179) leaves the branch unwritable within the
|
// async template-generate (infra#179) leaves the branch unwritable within the
|
||||||
// budget. It points at the concrete recovery step — re-invoking this same tool
|
// budget. It points at the concrete recovery step — re-invoking this same tool
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ type fakeTemplateServer struct {
|
|||||||
deletes []string
|
deletes []string
|
||||||
putBodies map[string]string // path -> decoded written content
|
putBodies map[string]string // path -> decoded written content
|
||||||
repoGetsPost int // GET dest after generate (branch fallback)
|
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 {
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
p := r.URL.Path
|
p := r.URL.Path
|
||||||
|
|
||||||
|
const dispatchReposPath = "/api/v1/repos/mathias/dispatch/contents/dispatch-repos.txt"
|
||||||
switch {
|
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:
|
case r.Method == http.MethodGet && p == "/api/v1/repos/mathias/"+tmpl:
|
||||||
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
|
_, _ = w.Write([]byte(templateRepoJSON(tmpl, true)))
|
||||||
|
|
||||||
@@ -151,6 +178,8 @@ type createOut struct {
|
|||||||
FilesSubstituted []string `json:"files_substituted"`
|
FilesSubstituted []string `json:"files_substituted"`
|
||||||
PartialFailure string `json:"partial_failure,omitempty"`
|
PartialFailure string `json:"partial_failure,omitempty"`
|
||||||
DispatchAllowFailure string `json:"dispatch_allow_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.
|
// 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")
|
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 ──────────────────────────────────────
|
// ── guardrails unchanged by the rewrite ──────────────────────────────────────
|
||||||
|
|
||||||
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
func TestCreateProject_NameRegexFailure(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user