feat(forgejo): Forgejo provider for self-hosted instances - #1425
Draft
wyattjoh wants to merge 9 commits into
Draft
feat(forgejo): Forgejo provider for self-hosted instances#1425wyattjoh wants to merge 9 commits into
wyattjoh wants to merge 9 commits into
Conversation
Adds repositories, organizations, teams, team members, labels, branch protection, Actions secrets and variables, webhooks, and API tokens, backed by a tagged-error REST client that paginates list endpoints. Registers Forgejo as an auth provider, so `alchemy profile edit --add Forgejo` connects an instance. Because Forgejo is self-hosted, the stored credential carries the instance URL alongside the token. `providers()` resolves from the selected profile, falling back to FORGEJO_URL / FORGEJO_TOKEN in CI. The credential layers resolve HttpClient from the environment rather than constructing one, so callers can substitute an implementation. Includes the /forgejo docs hub and its sidebar, tab, icon, and provider directory registrations.
wyattjoh
marked this pull request as ready for review
September 1, 2026 05:04
wyattjoh
marked this pull request as draft
September 1, 2026 05:24
- Resolve repository identity from the stable numeric ID in `read`, `reconcile`, and `delete`. A rename whose state write failed left `olds.name` stale, so `delete` 404'd — swallowed by `optional` — and leaked the repository while dropping its state row. - Lift bulk secret values through their `Input` wrapper instead of casting to `Redacted`, so a `Config`/`Effect`/`Output` value resolves before it is wrapped. Extracted GitHub's `liftValue` to `Util/redacted.ts` and shared it. - Observe webhooks by delivery URL when no ID is known, so a create whose state write failed adopts the existing hook rather than adding a duplicate on every retry. - Skip the repository settings PATCH and topics PUT when live state already matches, so an unchanged deploy issues no writes. - Fail with `ForgejoPaginationLimit` instead of silently truncating an enumeration that exceeds the page cap. - Compare Actions scopes structurally rather than by serialization, so moving from the legacy repository props to an explicit `scope` no longer plans a needless replacement. - Make `MissingGeneratedToken` a `Data.TaggedError`, guard `output` in the webhook and API-token deletes, and wrap the secret timestamp in `Effect.sync`. - Correct the docs: teams and memberships use the organization endpoints, not `/admin`. Adds mock coverage for repository rename, delete after an out-of-band rename, no-op deploys, the bulk `Secrets`/`Variables` helpers including an Output-valued secret, and webhook adoption by URL.
…ists Verified the provider's request and response shapes against Forgejo's published OpenAPI spec, which turned up three defects: - `Organization` carries no `html_url` (only `Repository` does), so `OrganizationAttributes.htmlUrl` was typed `string` but always `undefined` at runtime. It is now derived from the instance the client points at. The mock had invented the field, so no test caught it. - `alchemy nuke` passes the Attributes shape as `olds`, so a resource whose Attributes omit its parent identity built `/repos/undefined/undefined/...`, 404'd, and had the not-found swallowed — reporting success while the resource survived. Webhook, Label, and BranchProtection attributes now carry `owner`/`repository` and their deletes address the resource from `output` alone. - A push whitelist is inert unless `enable_push`/`enable_push_whitelist` are set, which the provider never sent — the documented "limit pushes to a team" example produced a rule that restricted nobody. Declaring a whitelist now enables it, overridable via the new `enablePush` and `enablePushWhitelist` props. Webhook adoption now matches on delivery URL *and* event set. Matching on URL alone collapsed two sibling webhooks that legitimately share a URL with different events onto one hook, each deploy overwriting the other. Adds `NukeContract.test.ts`, which drives `Nuke.ts`'s exact delete call shape for all three affected resources, and a webhook test covering two hooks that share a URL.
- `paginate` stopped at the first page shorter than the requested limit. Forgejo clamps `limit` to the instance's `[api] MAX_RESPONSE_ITEMS`, so on a server whose administrator lowered that below 50 every full page looks short and enumeration ended after page one, reporting a partial list as complete — the exact outcome `ForgejoPaginationLimit` exists to prevent. It now stops only on an empty page. - `WebhookProps.url` was declared `Input<string>`, which AGENTS.md forbids: the Resource machinery applies `Input` deeply already, so the annotation double-wrapped and forced two `as string` casts to undo. Declared plain; both casts deleted. - Added `owner`/`repository` to the webhook, label, and branch-protection `stables`, since a change to either already forces a replacement. The mocks served their whole list for every page, which the old short-page rule masked. They now slice by `page`/`limit` through a shared `jsonList` helper, so enumeration terminates the way it does against a real instance, and pagination is covered for a clamped page size.
Checked each remaining call against Forgejo's OpenAPI spec:
- Forgejo has no `/user/actions/secrets` collection endpoint, only
`{secretname}` PUT/DELETE, so the user-scoped sweep 404'd on every
`list()` and was swallowed. Dropped it, with a comment saying why the
scope is absent rather than leaving code that reads as if it works.
- `/branch_protections` accepts no `page`/`limit` and returns every rule,
so paginating it just cost an extra request. Reads it directly now.
- The org-create and branch-protection-create race catches keyed on
`ForgejoConflict`, but neither endpoint returns 409 — a duplicate
arrives as 403/422. Both now catch the tags actually declared.
- `EditHookOption` has no `type`; only `CreateHookOption` does. Split the
create and edit bodies. The old test asserted the wrong-by-schema shape.
Organization, Team, Label, and BranchProtection now skip their PATCH when
live state already matches, via a shared `matchesDesired`. Adoption of an
unchanged resource issues no write at all.
Replacing an API token deletes it before minting the new one, so the
trigger no longer fires on a reordered `scopes` or `repositories` list.
Narrowed `visibility` and `permission` to their API enums, documented the
create-only repository props and the webhook secret that cannot be
cleared by omission, and listed `ForgejoPaginationLimit` in the docs.
The widened create-race catch covered genuine failures too: a credential
that is not an instance administrator gets the same 403 as a duplicate,
so the recovery ran, its `GET /orgs/{org}` 404'd because nothing had been
created, and the user saw `ForgejoNotFound` instead of `ForgejoForbidden`
— the clearest diagnosis replaced by the most misleading one.
The recovery now only takes over when the organization actually exists,
and otherwise re-fails with the original error.
BranchProtection needs no equivalent change: an unauthorized create falls
through to a PATCH that fails with the same tag, so the right error
already propagates.
Validated the provider against a live Forgejo 16.0.3 instance for the
first time; every earlier round checked it against the published OpenAPI
spec, which a running server disagrees with in four places.
Deleting an organization that still owns repositories fails. Forgejo
answers it with `500 {"message":"user still has ownership of
repositories [uid: N]"}` — a dependency violation wearing a server-error
status. The engine deletes independent resources concurrently, so an
organization racing its own repositories loses often enough that
`destroy` fails outright and only succeeds on a re-run. That body now
maps to a `ForgejoDependencyViolation` tag, which the organization
reconciler retries until the repositories are gone.
None of the client errors carried a message, so the failure above
surfaced as `ForgejoServerError:` and nothing else — no method, path,
status, or body to diagnose it from. Each class now renders the request
it failed on, as `ForgejoPaginationLimit` already did.
`enable_push_whitelist` cannot be true while `enable_push` is false;
Forgejo stores false regardless, on create and on edit alike. Declaring
`enablePushWhitelist: true` alongside `enablePush: false` therefore asked
for a state the instance will never hold, so every deploy observed drift
and re-issued the same rejected edit forever. The body builder now
mirrors the server's own rule, while still leaving an omitted prop
unmanaged.
`matchesDesired` was documented as guarding against archived
repositories, on the grounds that Forgejo rejects edits to them. It does
not: a `PATCH` to a repository with `archived: true` returns 200 and
applies the change. The guard stands on its real merit — not writing when
nothing changed — and the false rationale is gone.
Two inferences the live instance confirmed, and which are unchanged: the
create-race catches match what Forgejo returns (a duplicate organization
422, a duplicate repository 409, a duplicate branch-protection rule 403),
and `GET /repositories/{id}` follows a rename, so deleting by observed id
resolves the live name.
The regression tests were checked against reverted fixes to confirm they
fail without them.
…hooks Three lifecycle paths that corrupted or silently failed to converge live state. Each is covered by a test confirmed to fail without its fix. `ApiToken` reconciled a lost state row into an unusable loop. It only looked for an existing token when `output` was set, so a create that succeeded while its state write did not left the next deploy minting the same name again. Forgejo returns a token's secret once, at creation, so the live token can be neither read back nor adopted, and Forgejo refuses a second token under the name — the stack could never converge again. The collision is now detected before creating and reported as `UnrecoverableApiToken`, which names the token and what to do about it. The live token is left alone: it may still be in use, and this provider cannot tell whether it created it. An organization's `owner` change replaced a resource with itself. The login identifies an organization globally, so the replacement's create observed the existing organization and adopted it right back, reporting success for a transfer that never happened; with removal opted in, the old generation's delete then removed the organization the new state pointed at. Forgejo exposes no ownership-transfer endpoint, so `diff` now replaces only on a changed login and `reconcile` rejects the transfer with `UnsupportedOwnerChange`. Webhook adoption matched on delivery URL and event set alone, which two resources may legitimately share. The second then adopted the first's hook, leaving one live hook behind two state rows with each deploy undoing the other — on a first deploy, not only after state loss. Forgejo 16.0.3 accepts two hooks with the same URL and events, and reports `active`, `branch_filter`, and `config.content_type` on every hook it lists, so the match now covers the full declared identity. Two review findings were not acted on. An organization renamed out of band was reported as leaking, on the grounds that `read` and `delete` address it by a stale login. Forgejo keeps a redirect from the old login and the client follows it: driving the real client through a rename, `GET`, `PATCH`, and `DELETE` on the stale login all resolved to the correct organization, and it was gone afterwards. There is nothing to fix. Enumeration was reported as missing organization-owned resources. `/user/repos` is documented as listing repositories the user owns, but in practice returns organization-owned ones too whenever the credential is a member, so the only gap is organizations it is not a member of. Closing that means enumerating `/admin/orgs`, and these lists are what `alchemy unsafe nuke` deletes — with no resource tags in Forgejo to narrow such a list back down, that would put every unrelated user's organizations in a nuke's path. Under-reporting a resource is recoverable; deleting a stranger's organization is not. The boundary is documented in `Lists.ts` instead.
sam-goodwin
reviewed
Sep 1, 2026
Contributor
There was a problem hiding this comment.
This needs to be deleted ands implemented as a generated SDK in https://github.com/alchemy-run/distilled and patched to include tagged errors. We don't accept providers without this.
Contributor
There was a problem hiding this comment.
cc @Mkassabov who can help with generating SDKs
Author
There was a problem hiding this comment.
Opened a PR over here alchemy-run/distilled#541, will update this one once it's merged and available.
Replace the hand-written Forgejo HTTP client with @distilled.cloud/forgejo,
generated from Forgejo's Swagger document and patched with the tagged
errors the lifecycle code branches on (NotFound, Forbidden, Conflict,
UnprocessableEntity, and OrganizationOwnsRepositories for the 500 an
organization delete answers while it still owns repositories).
Every resource now calls the typed operations directly. Credentials are
the SDK's `Credentials` service, built from the profile, the environment,
or an explicit `{ baseUrl, token }`; `providers()` re-exports the
`HttpClient` it is built with so a test can point every request at an
in-memory instance. Page walking stays alchemy-side, since Forgejo
signals the last page only through a response header.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a Forgejo provider for self-hosted Forgejo instances. It talks to Forgejo's REST API directly, no vendor SDK, so it works against any instance reachable with a token.
Quick start
Provider surface
RepositoryOrganizationTeam/TeamMemberLabelBranchProtectionSecret/SecretsVariable/VariablesWebhookApiTokenRedactedoutputRepositories and organizations default to the
retainremoval policy, matching the GitHub provider. Renaming a repository (same logical ID, newname) renames in place; changingownerreplaces.Auth
Forgejo is registered as an auth provider via
makeStoredAuthProvider, and added to the built-in registry inAlchemist/Session.ts. Because Forgejo is self-hosted, the instance URL is part of the credential rather than a constant, so the provider collects and stores it next to the token:alchemy profile edit --add Forgejoconnects an instance interactively.providers()resolves from the selected profile, falling back toFORGEJO_URL/FORGEJO_TOKENin CI. Passingproviders({ baseUrl, token })pins both in code and bypasses profile resolution.A stored token is the only method here. Forgejo does support custom OAuth2 applications (public client + PKCE), but its OAuth2 tokens are unscoped and carry full administrative rights, where a personal access token can be limited to
write:repository. Anoauthmethod can be added later as a second variant, the wayCloudflareAuthConfigSchemaunionsstoredandoauth.Client
Every API failure is a tagged error rather than an HTTP status check, so lifecycle code branches on
Effect.catchTag:The credential layers resolve
HttpClientfrom the environment rather than constructing one, soproviders()leaves it inRIn(satisfied byStackServices) and a caller can substitute a different implementation:List endpoints paginate through
paginate()rather than taking Forgejo's default 30-entry first page.Differences from the GitHub provider
ApiTokengo through Forgejo's/adminendpoints and need an administrator credential.Webhookonly.Docs
New
/forgejohub (overview, setup, repositories, Actions config, organizations & teams), registered in the sidebar, docs tabs, provider directory, and icon map.