feat: let Binjovi create a repository, and expose it via MCP #1716

Merged
binjovi-bot merged 7 commits from feat/repository-creation into trunk 2026-09-13 09:18:46 +00:00
Owner

Finishes the repository-creation half of Binjovi's Services layer (settings, branch/tag policy and collaborator grants were already generic; creation was explicitly denied at three layers plus a locking test). Repository creation is what Forgejo's own MAX_CREATION_LIMIT=0 makes admin-only, which is why this needed a real design pass rather than just deleting the denial -- see the commit messages for the reasoning at each step.

Five commits, each independently tested against a real throwaway PostgreSQL (tests/with-postgres.sh):

  1. Dynamic repository catalog + real create implementation. ForgejoRepositoryBindings.legacy_names/1 now returns {:ok, []} for a well-formed name outside the static ~43-repo catalog instead of erroring. Fixed a real bug found along the way: service_repository_snapshot/2 collapsed 404 into :denied, making the "absent -> create" branch dead code for every repository even before today. Client.service_repository_create/3 creates the repository via a new, isolated opts[:admin_authorization] credential (kept separate from the ordinary writer token every other write in this client uses). The locking test's actual intent -- creation must be deliberate and auditable -- is preserved, not weakened: it now asserts the exact bounded call sequence for an external, observed-absent repository, and that a managed resource can never reach it.
  2. Worker claim-filtering. ServiceOperations.claim/3 gains only:/exclude: by {provider, kind, command} -- the mechanism a future isolated worker (holding the admin credential) needs to claim only forgejo/repository/create, while every other worker excludes exactly that.
  3. Push-mirror support for brand-new repos. The same static-catalog gate existed in ForgejoPushMirrorBindings too; fixed identically.
  4. RepositoryIntake, the actual entrypoint: declares and queues create for both the repository and its Codeberg mirror, idempotent, guarded (lowercase-only, not already catalogued, full settings validation).
  5. MCP create_repository tool, wired through Commands/Tools/MCPPermissions exactly like request_release. Found and fixed two small real gaps in shared MCP code: no boolean-schema support existed yet, and Commands.call/3 hardcoded args["project"] as the value written to mcp_commands.project (NOT NULL) -- repository creation has no "project", so a project_selector/2 picks args["repository"] for this one tool.

Deliberately out of scope for this PR (each is a distinct, separable piece of follow-up work):

  • The isolated binjovi_repo_creator OTP release + its Kubernetes Deployment/Secret/RBAC + the dedicated Forgejo site-admin bot identity. service_repository_create/3 and ServiceOperations.claim/3's filter are the building blocks; nothing in mix.exs/runtime.exs configures the admin credential yet, so this capability cannot actually run in production until that lands. Until then it is safe, inert, fully-tested code.
  • Migrate/import from an existing external source (Client.service_repository_migrate/4 does not exist yet) -- the stated motivation (onboarding a new app repo with no prior history) only needs the empty-create path.
  • Waiting for verification before create_repository returns (matching request_release's wait_ms convenience) and read-side tools (get_repository/list_repositories).
  • The 37-file ForgejoRepository/ForgejoPushMirror/ForgejoCollaborator CR-fleet retirement in the seanfarm monorepo this whole thing exists to eventually unblock -- a separate, much larger, already-designed initiative, deliberately sequenced after this capability is proven in production.

https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3

Finishes the repository-creation half of Binjovi's Services layer (settings, branch/tag policy and collaborator grants were already generic; creation was explicitly denied at three layers plus a locking test). Repository creation is what Forgejo's own MAX_CREATION_LIMIT=0 makes admin-only, which is why this needed a real design pass rather than just deleting the denial -- see the commit messages for the reasoning at each step. Five commits, each independently tested against a real throwaway PostgreSQL (tests/with-postgres.sh): 1. Dynamic repository catalog + real create implementation. ForgejoRepositoryBindings.legacy_names/1 now returns {:ok, []} for a well-formed name outside the static ~43-repo catalog instead of erroring. Fixed a real bug found along the way: service_repository_snapshot/2 collapsed 404 into :denied, making the "absent -> create" branch dead code for every repository even before today. Client.service_repository_create/3 creates the repository via a new, isolated opts[:admin_authorization] credential (kept separate from the ordinary writer token every other write in this client uses). The locking test's actual intent -- creation must be deliberate and auditable -- is preserved, not weakened: it now asserts the exact bounded call sequence for an external, observed-absent repository, and that a managed resource can never reach it. 2. Worker claim-filtering. ServiceOperations.claim/3 gains only:/exclude: by {provider, kind, command} -- the mechanism a future isolated worker (holding the admin credential) needs to claim only forgejo/repository/create, while every other worker excludes exactly that. 3. Push-mirror support for brand-new repos. The same static-catalog gate existed in ForgejoPushMirrorBindings too; fixed identically. 4. RepositoryIntake, the actual entrypoint: declares and queues create for both the repository and its Codeberg mirror, idempotent, guarded (lowercase-only, not already catalogued, full settings validation). 5. MCP create_repository tool, wired through Commands/Tools/MCPPermissions exactly like request_release. Found and fixed two small real gaps in shared MCP code: no boolean-schema support existed yet, and Commands.call/3 hardcoded args["project"] as the value written to mcp_commands.project (NOT NULL) -- repository creation has no "project", so a project_selector/2 picks args["repository"] for this one tool. Deliberately out of scope for this PR (each is a distinct, separable piece of follow-up work): - The isolated binjovi_repo_creator OTP release + its Kubernetes Deployment/Secret/RBAC + the dedicated Forgejo site-admin bot identity. service_repository_create/3 and ServiceOperations.claim/3's filter are the building blocks; nothing in mix.exs/runtime.exs configures the admin credential yet, so this capability cannot actually run in production until that lands. Until then it is safe, inert, fully-tested code. - Migrate/import from an existing external source (Client.service_repository_migrate/4 does not exist yet) -- the stated motivation (onboarding a new app repo with no prior history) only needs the empty-create path. - Waiting for verification before create_repository returns (matching request_release's wait_ms convenience) and read-side tools (get_repository/list_repositories). - The 37-file ForgejoRepository/ForgejoPushMirror/ForgejoCollaborator CR-fleet retirement in the seanfarm monorepo this whole thing exists to eventually unblock -- a separate, much larger, already-designed initiative, deliberately sequenced after this capability is proven in production. https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3
Repository creation was denied at three layers plus a locking test that
proved zero HTTP calls happened for kind=repository. The denial existed
because Forgejo itself refuses non-admin repository creation
(MAX_CREATION_LIMIT=0, see tests/forgejo-repo-creation-locked.sh in
seanfarm) but nothing in this repo could reach an admin credential
safely yet, and the compiled repository catalog had no room for a name
that was not one of the ~43 pre-existing repos.

This lands the domain and control-layer half of that capability:

- ForgejoRepositoryBindings.legacy_names/1 now returns {:ok, []} for a
  well-formed repository name outside the static catalog (owner "sean",
  valid characters) instead of {:error, :unsupported_target}. An empty
  legacy-name list is what lets ServiceLegacyClient.legacy_state/2
  short-circuit to "absent" with zero Kubernetes API calls, since a
  brand-new repository has no legacy CR to check for absence. A
  malformed name (wrong owner, invalid characters) is still rejected.
- The three denial points are gone: ServiceCatalog no longer excludes
  "create" for repository targets (safe/no-op for the 43 pre-existing,
  already-present catalog repos, since ServiceManager's "matches" branch
  fires before provider.create is ever called); ServiceOperation.validate/2
  no longer special-cases repository's supported_command; ServiceProvider.create/2
  now has a real implementation for kind=repository, ownership=external.
- Fixed a real bug found while doing this: service_repository_snapshot/2
  routed through service_tag_get/2, which collapsed 404 into the same
  :denied bucket as 401/403. That made ServiceManager's absent-to-create
  branch dead code for every repository, even before today. Repository
  now has its own service_repository_get/2 that reports 404 as :not_found.
- Client.service_repository_create/3 creates the repository with
  auto_init true, so it never reads back empty, which
  service_repository_snapshot/2 correctly still treats as
  :invalid_response for an established repository, via a new, distinct
  opts admin_authorization key kept separate from the ordinary
  opts authorization key every other write in this client uses, so a
  worker configured with only the ordinary writer credential fails closed
  with :denied here instead of silently reusing a token Forgejo would
  refuse. No release configures that credential yet (next commit).
  default_branch is deliberately not set: ServiceRepository's schema
  excludes it as an unverified ref (see ServiceRepositoryTest, "without
  identity or import effects"), so this always takes the instance's own
  configured default. Importing from an existing source is a separate,
  not-yet-built capability.

The locking test's actual intent, that creation must be deliberate and
auditable and not silently reachable, is preserved rather than weakened:
it now asserts the exact bounded request sequence (GET, then POST to
/admin/users/{owner}/repos, then the ordinary settings PATCH) happens
only for an external, observed-absent repository, through the admin
credential, and that a managed resource's drift-queued apply can never
reach create at all.

Claude-Session: https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3
Binjovi.Store.ServiceOperations.claim/2 (now claim/3) claims the single
oldest due operation for whichever worker calls it, with no way to
restrict which rows one worker can see. The isolated binjovi_repo_creator
release (next commit) needs the opposite of what every other worker needs:
it must claim ONLY forgejo/repository/create operations, and the main
release must claim everything EXCEPT that one tuple, so the admin
credential the repository-create path needs never reaches a worker that
was not deliberately configured to hold it.

claim/3 takes an optional opts list: only: [{provider, kind, command}, ...]
restricts to exactly those tuples; exclude: [...] is the complement. The
query now joins service_resources to test provider/kind alongside the
operation's own command column. With no opts, behavior is byte-identical
to before (same SQL shape, same ordering, same lease semantics) -- this is
additive, not a rewrite of the claim path every operation already depends
on.

Added a repository fixture (a well-formed name outside
ForgejoRepositoryBindings' static catalog) since testing this needed a
second resource kind alongside the existing branch_protection fixture.

Verified against a real throwaway PostgreSQL (tests/with-postgres.sh): the
full suite including every postgres-tagged integration test still passes,
392 -> 395 in binjovi_store.

Claude-Session: https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3
ForgejoPushMirrorBindings.upstream/1 and legacy_name/1 were closed over
two static tables: the 2 mirrors Binjovi already owns, and the ~43
pre-existing catalog repositories (borrowed from ForgejoRepositoryBindings
only to resolve upstream naming). A repository outside BOTH tables --
which, after the previous two commits, now includes any brand-new
repository Binjovi itself creates -- resolved to :error/:unsupported_target
in both, meaning a newly created repository could never get a backup
mirror at all.

- ForgejoRepositoryBindings gains known?/1 (is this one of the ~43
  pre-existing catalog repos) and valid_name?/1 is now public, so
  ForgejoPushMirrorBindings can reuse the identical name-syntax check
  rather than duplicating it.
- ForgejoPushMirrorBindings.upstream/1: a name outside both tables, if
  well-formed, now resolves to itself. A brand-new repository has no
  historical case-mismatch quirk to correct for (that class of quirk --
  rpg-hello's mirror is a CR named ibmi-examples -- only exists for repos
  that predate Binjovi).
- ForgejoPushMirrorBindings gains legacy_names/1, mirroring
  ForgejoRepositoryBindings' shape exactly: a declared mirror resolves its
  one legacy name; a well-formed name that is NEITHER a declared mirror NOR
  one of the ~43 catalog repos returns {:ok, []} (no legacy CR to check);
  anything else (malformed, or a catalog repo not yet migrated to Binjovi
  ownership) is still refused. legacy_name/1 now derives from legacy_names/1
  instead of its own table lookup.
- ServiceOperation.legacy_names/1 gets a push_mirror clause delegating to
  the above, the same shape as the repository and collaborator clauses
  already there -- without it, the generic wrapper around legacy_name/1
  would produce {:ok, [nil]} for a new mirror and crash
  ServiceLegacyClient's string concatenation, the exact bug already fixed
  for repository.

Updated the two existing tests that asserted the old "always :error/nil
outside the declared tables" behavior to cover the three real cases
instead: malformed name (still refused), a pre-existing catalog repo not
yet declared as a mirror (still refused -- correct, most of those have a
live CR), and a brand-new well-formed name (now resolves).

Full suite including postgres-tagged integration tests still green
(tests/with-postgres.sh), 345 -> 346 in binjovi_domain.

Claude-Session: https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3
Declares a brand-new repository AND its paired Codeberg push-mirror
backup in one call, and queues a durable "create" operation for each.
This is the single control-plane function both the future MCP
create_repository tool and any HTTP route will call into -- deliberately
separate from ServiceIntake, which stays catalog-closed by design (it
guards every call with `when target in @targets`, computed once from the
compiled catalog; a genuinely new repository name is never in that list).

Both operations are independently durable and idempotency-keyed (derived
from the caller's own key), and nothing here waits for either to actually
run: the repository's create is picked up by the isolated
binjovi_repo_creator worker (the only one holding the admin credential),
the mirror's by the ordinary worker. If the mirror is attempted first, its
own snapshot read of the not-yet-created repository fails closed, and the
store's existing observe-before-retry backoff tries it again once the
repository exists -- the same "kind runs behind its own dependency" shape
ServiceManager already handles for every one of the 43 existing catalog
repositories, not new machinery.

Guards, all proven before anything is declared: the name must be
lowercase (a mixed-case name is refused rather than silently
lowercased into a different Forgejo repository than the caller asked
for -- ServiceResource.new/1 downcases every forgejo target, the same
as it already does for the 43 static entries), must pass
ForgejoRepositoryBindings.valid_name?/1, and must not already be one of
those 43. Settings must satisfy ServiceRepository's existing closed
schema -- nothing new there.

A repeat call with the same idempotency key is a no-op (both
ServiceResources.declare/6 and ServiceOperations.create/6 are themselves
idempotency-keyed); a second call for the same repository under a NEW key
is a genuine :revision_conflict, not silently accepted.

This is the first binjovi_control test to touch the store directly rather
than a stubbed HTTP client, so it needs a real PostgreSQL --
apps/binjovi_control/test/test_helper.exs now excludes :postgres by
default, mirroring binjovi_store's own test_helper.exs (tests/
postgres-gate-contract.sh's non-vacuity check already covers this file
too, since it greps apps/*/test).

Verified against a real throwaway PostgreSQL (tests/with-postgres.sh):
full suite green, 912 -> 919 in binjovi_control.

Claude-Session: https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3
feat(api): expose create_repository as an MCP tool
Some checks failed
binjovi/ci Binjovi failed the frozen plan
49ea801713
Adds create_repository to the MCP tool surface, following the exact
pattern request_release already uses: a JSON-schema tool definition, a
dedicated permission (binjovi.repository.create), and a Commands.execute
clause that calls into RepositoryIntake (the entrypoint added two commits
ago) and returns a durable, idempotency-keyed receipt naming both the
repository and push-mirror operation ids.

Two small, real gaps in shared MCP infrastructure, found and fixed while
building this:

- Tools.valid_value?/2 had no clause for a boolean-typed field -- every
  existing tool's schema only ever used string/integer/array/enum. This
  is the first tool with a plain flag (private, allow_rebase), and JSON
  Schema booleans are exactly this ordinary; added the missing clause
  rather than encode the flags as string enums to dodge it.
- Commands.call/3 hardcoded args["project"] as both the policy-check
  selector and the value written to mcp_commands.project (NOT NULL).
  Repository creation has no "project" in the release/deploy sense, so a
  project_selector/2 helper picks args["repository"] for this one tool
  and args["project"] (unchanged) for every other -- both the permission
  check and the stored row now agree. Found by directly probing why a
  plain, working RepositoryIntake.create/5 call turned into a caught,
  silently-swallowed :authority_unavailable once it ran inside
  MCPAuthority.command/6: not a nested-transaction problem (verified that
  works fine standalone), a NOT NULL constraint violation on a column
  every other command happened to always populate.

Deliberately NOT included in this pass: waiting for the repository and
its mirror to actually verify before the command returns (matching
request_release's wait_ms convenience) -- Operations.wait/5's
read_snapshot/4 is hardcoded to "request_release"/"request_deploy" only,
and extending it for a resource pair with no deployment-target concept is
a distinct, separable piece of work. create_repository returns once both
operations are durably queued, the same synchronous-acceptance contract
RepositoryIntake.create/5 already has; wait_ms is accepted in the schema
for shape-consistency but not yet acted on. Read-side tools
(get_repository/list_repositories) are similarly deferred -- nothing
today reads an MCP-created repository's state back through MCP, only
through the existing binjovictl service commands.

This is also the first test in binjovi_api to need a real PostgreSQL
(everything else here tests against stubbed clients); its
test_helper.exs now excludes :postgres by default, matching
binjovi_control's and binjovi_store's.

Verified against a real throwaway PostgreSQL (tests/with-postgres.sh):
full suite green, 330 -> 338 in binjovi_api.

Claude-Session: https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3
style: mix format
Some checks failed
binjovi/ci Binjovi failed the frozen plan
587cfdf160
The build's static-format gate (mix format --check-formatted) failed on
every file this branch touched. Applied mix format; no behavior change.

Claude-Session: https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3
test: pin the mutating-tool count at 7, not 6
All checks were successful
binjovi/ci Binjovi completed the frozen plan
dbee0c0475
tests/mcp-tools-contract.sh asserted exactly 6 tools with
readOnlyHint=false. create_repository is the 7th. Ran make check (the
full local equivalent of the CI build, including the postgres
integration suite) end to end this time before pushing -- it is now
fully green.

Claude-Session: https://claude.ai/code/session_01JGe4pyhQ36AhgDfPJry4d3
binjovi-bot deleted branch feat/repository-creation 2026-09-13 09:18:46 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
sean/binjovi!1716
No description provided.