From e4d3cac833874857f204e6721ee197e8b64167d7 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 31 Aug 2026 22:02:24 -0600 Subject: [PATCH 1/9] feat(forgejo): Forgejo provider for self-hosted instances 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. --- packages/alchemy/package.json | 6 + packages/alchemy/src/Alchemist/Session.ts | 2 + packages/alchemy/src/Forgejo/ApiToken.ts | 231 ++++++++ packages/alchemy/src/Forgejo/AuthProvider.ts | 100 ++++ .../alchemy/src/Forgejo/BranchProtection.ts | 247 ++++++++ packages/alchemy/src/Forgejo/Client.ts | 497 ++++++++++++++++ packages/alchemy/src/Forgejo/Label.ts | 232 ++++++++ packages/alchemy/src/Forgejo/Lists.ts | 52 ++ packages/alchemy/src/Forgejo/Organization.ts | 219 +++++++ packages/alchemy/src/Forgejo/Providers.ts | 87 +++ packages/alchemy/src/Forgejo/Repository.ts | 363 ++++++++++++ packages/alchemy/src/Forgejo/Secret.ts | 308 ++++++++++ packages/alchemy/src/Forgejo/Secrets.ts | 56 ++ packages/alchemy/src/Forgejo/Team.ts | 218 +++++++ packages/alchemy/src/Forgejo/TeamMember.ts | 150 +++++ packages/alchemy/src/Forgejo/Variable.ts | 271 +++++++++ packages/alchemy/src/Forgejo/Variables.ts | 48 ++ packages/alchemy/src/Forgejo/Webhook.ts | 225 ++++++++ packages/alchemy/src/Forgejo/index.ts | 30 + .../alchemy/test/Forgejo/ActionsScope.test.ts | 41 ++ .../alchemy/test/Forgejo/ApiToken.test.ts | 114 ++++ .../alchemy/test/Forgejo/AuthProvider.test.ts | 108 ++++ packages/alchemy/test/Forgejo/Client.test.ts | 272 +++++++++ .../test/Forgejo/ProviderLifecycles.test.ts | 535 ++++++++++++++++++ .../test/Forgejo/ResourceSchemas.test.ts | 401 +++++++++++++ packages/alchemy/test/Forgejo/support/mock.ts | 133 +++++ website/astro.config.mjs | 18 + .../src/components/ProviderDirectory.astro | 4 + .../content/docs/forgejo/actions-config.mdx | 120 ++++ website/src/content/docs/forgejo/index.mdx | 167 ++++++ .../content/docs/forgejo/organizations.mdx | 116 ++++ .../src/content/docs/forgejo/repository.mdx | 135 +++++ website/src/content/docs/forgejo/setup.mdx | 129 +++++ website/src/docs-icons.ts | 2 + website/src/docs-tabs.ts | 8 + 35 files changed, 5645 insertions(+) create mode 100644 packages/alchemy/src/Forgejo/ApiToken.ts create mode 100644 packages/alchemy/src/Forgejo/AuthProvider.ts create mode 100644 packages/alchemy/src/Forgejo/BranchProtection.ts create mode 100644 packages/alchemy/src/Forgejo/Client.ts create mode 100644 packages/alchemy/src/Forgejo/Label.ts create mode 100644 packages/alchemy/src/Forgejo/Lists.ts create mode 100644 packages/alchemy/src/Forgejo/Organization.ts create mode 100644 packages/alchemy/src/Forgejo/Providers.ts create mode 100644 packages/alchemy/src/Forgejo/Repository.ts create mode 100644 packages/alchemy/src/Forgejo/Secret.ts create mode 100644 packages/alchemy/src/Forgejo/Secrets.ts create mode 100644 packages/alchemy/src/Forgejo/Team.ts create mode 100644 packages/alchemy/src/Forgejo/TeamMember.ts create mode 100644 packages/alchemy/src/Forgejo/Variable.ts create mode 100644 packages/alchemy/src/Forgejo/Variables.ts create mode 100644 packages/alchemy/src/Forgejo/Webhook.ts create mode 100644 packages/alchemy/src/Forgejo/index.ts create mode 100644 packages/alchemy/test/Forgejo/ActionsScope.test.ts create mode 100644 packages/alchemy/test/Forgejo/ApiToken.test.ts create mode 100644 packages/alchemy/test/Forgejo/AuthProvider.test.ts create mode 100644 packages/alchemy/test/Forgejo/Client.test.ts create mode 100644 packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts create mode 100644 packages/alchemy/test/Forgejo/ResourceSchemas.test.ts create mode 100644 packages/alchemy/test/Forgejo/support/mock.ts create mode 100644 website/src/content/docs/forgejo/actions-config.mdx create mode 100644 website/src/content/docs/forgejo/index.mdx create mode 100644 website/src/content/docs/forgejo/organizations.mdx create mode 100644 website/src/content/docs/forgejo/repository.mdx create mode 100644 website/src/content/docs/forgejo/setup.mdx diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index ba2588aaa1..8486760797 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -203,6 +203,12 @@ "worker": "./src/GitHub/index.ts", "import": "./lib/GitHub/index.js" }, + "./Forgejo": { + "types": "./lib/Forgejo/index.d.ts", + "bun": "./src/Forgejo/index.ts", + "worker": "./src/Forgejo/index.ts", + "import": "./lib/Forgejo/index.js" + }, "./Endpoint": { "types": "./lib/Endpoint/index.d.ts", "bun": "./src/Endpoint/index.ts", diff --git a/packages/alchemy/src/Alchemist/Session.ts b/packages/alchemy/src/Alchemist/Session.ts index 8c6c28c29f..bba45e2f3f 100644 --- a/packages/alchemy/src/Alchemist/Session.ts +++ b/packages/alchemy/src/Alchemist/Session.ts @@ -22,6 +22,7 @@ import { withProfileOverride } from "../Auth/Resolve.ts"; import { AwsAuth } from "../AWS/AuthProvider.ts"; import { AxiomAuth } from "../Axiom/AuthProvider.ts"; import { CloudflareAuth } from "../Cloudflare/Auth/AuthProvider.ts"; +import { ForgejoAuth } from "../Forgejo/AuthProvider.ts"; import { GitHubAuth } from "../GitHub/AuthProvider.ts"; import { HetznerAuth } from "../Hetzner/AuthProvider.ts"; import { NeonAuth } from "../Neon/AuthProvider.ts"; @@ -355,6 +356,7 @@ const builtinAuth = Layer.mergeAll( AwsAuth, AxiomAuth, CloudflareAuth, + ForgejoAuth, GitHubAuth, HetznerAuth, NeonAuth, diff --git a/packages/alchemy/src/Forgejo/ApiToken.ts b/packages/alchemy/src/Forgejo/ApiToken.ts new file mode 100644 index 0000000000..c1fa6f55a8 --- /dev/null +++ b/packages/alchemy/src/Forgejo/ApiToken.ts @@ -0,0 +1,231 @@ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { ForgejoCredentials, optional, paginate } from "./Client.ts"; +import type * as Forgejo from "./Providers.ts"; + +/** + * Repository restriction for a Forgejo API token. + */ +export interface ApiTokenRepository { + /** + * User or organization that owns the repository. + */ + readonly owner: string; + /** + * Repository name. + */ + readonly name: string; +} + +/** + * Desired settings for a Forgejo API token. + */ +export interface ApiTokenProps { + /** + * User that owns the generated token. + */ + readonly username: string; + /** + * Human-readable token name. + */ + readonly name: string; + /** + * Permission scopes granted to the token. + */ + readonly scopes?: readonly string[]; + /** + * Repositories the token may access. Omit for unrestricted repository + * access. + */ + readonly repositories?: readonly ApiTokenRepository[]; +} + +/** + * Observed attributes of a Forgejo API token. + */ +export interface ApiTokenAttributes { + /** + * Stable numeric token identifier. + */ + readonly tokenId: number; + /** + * Generated bearer token. Forgejo only returns this value during creation. + */ + readonly token: Redacted.Redacted; + /** + * Last eight characters of the generated token. + */ + readonly tokenLastEight: string; + /** + * Token creation timestamp. + */ + readonly createdAt: string; +} + +/** + * A Forgejo API access-token resource. + */ +export interface ApiToken extends Resource< + "Forgejo.ApiToken", + ApiTokenProps, + ApiTokenAttributes, + never, + Forgejo.Providers +> {} + +/** + * An API access token for a Forgejo user. + * + * Creating one uses Forgejo's admin user-token endpoints, so the provider + * credential must belong to an administrator. Forgejo returns the token's + * plaintext only in the create response: it is exposed as a redacted output + * and can never be recovered afterwards, so any change to the token's + * identity or scopes replaces it. + * + * ### Creating a Token + * **Example:** Basic Token + * ```typescript + * const token = yield* Forgejo.ApiToken("ci", { + * username: "ci-bot", + * name: "ci", + * }); + * ``` + * + * **Example:** Scoped, Repository-Restricted Token + * ```typescript + * yield* Forgejo.ApiToken("deploy", { + * username: "ci-bot", + * name: "deploy", + * scopes: ["write:repository", "read:organization"], + * repositories: [{ owner: "acme", name: "api" }], + * }); + * ``` + * + * ### Passing the Token On + * **Example:** Store the Token as an Actions Secret + * ```typescript + * const token = yield* Forgejo.ApiToken("ci", { + * username: "ci-bot", + * name: "ci", + * }); + * + * yield* Forgejo.Secret("forgejo-token", { + * owner: "acme", + * repository: "api", + * name: "FORGEJO_TOKEN", + * value: token.token, + * }); + * ``` + * + * @resource + */ +export const ApiToken = Resource("Forgejo.ApiToken"); + +interface ApiAccessToken { + readonly id: number; + readonly name: string; + readonly sha1?: string; + readonly token_last_eight: string; + readonly created_at: string; +} + +const tokensPath = (username: string) => + `/admin/users/${encodeURIComponent(username)}/tokens`; + +const tokenPath = (username: string, tokenId: number) => + `${tokensPath(username)}/${encodeURIComponent(String(tokenId))}`; + +const listTokens = Effect.fn(function* (username: string) { + const client = yield* ForgejoCredentials; + return yield* paginate(client, tokensPath(username)); +}); + +/** + * Raised when Forgejo accepts a token creation but omits the generated + * secret, which can never be recovered from a later read. + */ +export class MissingGeneratedToken extends Error { + constructor() { + super( + "Forgejo did not return the generated API token in the create response.", + ); + this.name = "MissingGeneratedToken"; + } +} + +/** + * Provider layer implementing the Forgejo API-token lifecycle. + */ +export const ApiTokenProvider = () => + Provider.succeed(ApiToken, { + stables: ["tokenId", "token"], + diff: ({ news, olds }) => { + if (!isResolved(news) || olds === undefined) return Effect.void; + return Effect.succeed( + news.username !== olds.username || + news.name !== olds.name || + JSON.stringify(news.scopes ?? []) !== + JSON.stringify(olds.scopes ?? []) || + JSON.stringify(news.repositories ?? []) !== + JSON.stringify(olds.repositories ?? []) + ? { action: "replace" as const, deleteFirst: true } + : undefined, + ); + }, + // Tokens are enumerable only per user, and the set of users is not + // derivable from the credential, so account-wide enumeration is not + // offered rather than partially claimed. + list: () => Effect.succeed([]), + read: Effect.fn(function* ({ olds, output }) { + if (output === undefined) return undefined; + const tokens = yield* listTokens(olds.username); + return tokens.some((token) => token.id === output.tokenId) + ? output + : undefined; + }), + reconcile: Effect.fn(function* ({ news, output }) { + // Observe: a token we already generated is unchanged and its plaintext + // is unrecoverable, so an existing one is kept as-is. + if (output !== undefined) { + const tokens = yield* listTokens(news.username); + if (tokens.some((token) => token.id === output.tokenId)) return output; + } + + const client = yield* ForgejoCredentials; + const created = yield* client.request( + "POST", + tokensPath(news.username), + { + body: { + name: news.name, + scopes: news.scopes === undefined ? undefined : [...news.scopes], + repositories: + news.repositories === undefined + ? undefined + : news.repositories.map(({ owner, name }) => ({ owner, name })), + }, + }, + ); + if (created.sha1 === undefined) { + return yield* Effect.fail(new MissingGeneratedToken()); + } + return { + tokenId: created.id, + token: Redacted.make(created.sha1), + tokenLastEight: created.token_last_eight, + createdAt: created.created_at, + }; + }), + delete: Effect.fn(function* ({ olds, output }) { + const client = yield* ForgejoCredentials; + yield* optional( + client.request( + "DELETE", + tokenPath(olds.username, output.tokenId), + ), + ); + }), + }); diff --git a/packages/alchemy/src/Forgejo/AuthProvider.ts b/packages/alchemy/src/Forgejo/AuthProvider.ts new file mode 100644 index 0000000000..fc7aa8f8dd --- /dev/null +++ b/packages/alchemy/src/Forgejo/AuthProvider.ts @@ -0,0 +1,100 @@ +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { getEnvRedactedRequired, getEnvRequired } from "../Auth/Env.ts"; +import { + makeStoredAuthProvider, + storedSecret, + storedValueText, + type StoredAuthConfig, +} from "../Auth/StoredAuthProvider.ts"; + +export const FORGEJO_AUTH_PROVIDER_NAME = "Forgejo"; + +export type ForgejoAuthConfig = StoredAuthConfig; + +/** + * Credentials resolved for a Forgejo instance, from either the selected + * profile or the CI environment. + */ +export type ForgejoResolvedCredentials = { + type: "token"; + /** Instance origin or API v1 base URL, as entered. */ + baseUrl: string; + token: Redacted.Redacted; + source: { type: ForgejoAuthConfig["method"] | "env"; details?: string }; +}; + +/** + * Forgejo is self-hosted, so — unlike a single-tenant SaaS — the instance + * URL is part of the credential rather than a constant. It is collected + * alongside the token and stored with it, so one profile names both which + * instance to talk to and how to authenticate against it. + */ +const validateBaseUrl = (value: string): string | undefined => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:" + ? undefined + : "Must be an http(s) URL."; + } catch { + return "Must be a valid URL, e.g. https://git.example.com"; + } +}; + +const forgejoAuth = makeStoredAuthProvider({ + provider: FORGEJO_AUTH_PROVIDER_NAME, + storageKey: "forgejo-stored", + fields: [ + { + name: "baseUrl", + label: "Forgejo Instance URL", + description: + "Origin of your Forgejo instance, e.g. https://git.example.com", + placeholder: "https://git.example.com", + validate: validateBaseUrl, + }, + { + name: "token", + label: "Forgejo Access Token", + description: + "Settings -> Applications -> Access Tokens on your instance.", + secret: true, + }, + ], + toResolved: (values) => ({ + type: "token", + baseUrl: storedValueText(values.baseUrl) ?? "", + token: storedSecret(values.token) ?? Redacted.make(""), + source: { type: "stored" }, + }), + readEnvironment: Effect.all({ + baseUrl: getEnvRequired("FORGEJO_URL"), + token: getEnvRedactedRequired("FORGEJO_TOKEN"), + }).pipe( + Effect.map(({ baseUrl, token }) => ({ + type: "token" as const, + baseUrl, + token, + source: { type: "env" as const }, + })), + ), + environment: [ + { + name: "FORGEJO_URL", + description: "Forgejo instance origin or API v1 base URL.", + required: true, + }, + { + name: "FORGEJO_TOKEN", + description: "Forgejo access token.", + required: true, + secret: true, + }, + ], +}); + +/** + * Layer that registers the Forgejo `AuthProvider` into the `AuthProviders` + * registry, making it configurable with `alchemy profile edit --add Forgejo`. + */ +export const ForgejoAuth = forgejoAuth.layer; diff --git a/packages/alchemy/src/Forgejo/BranchProtection.ts b/packages/alchemy/src/Forgejo/BranchProtection.ts new file mode 100644 index 0000000000..b1fb0cb31d --- /dev/null +++ b/packages/alchemy/src/Forgejo/BranchProtection.ts @@ -0,0 +1,247 @@ +import * as Effect from "effect/Effect"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + ForgejoCredentials, + ignoreInaccessible, + optional, + paginate, +} from "./Client.ts"; +import { listAccessibleRepositories } from "./Lists.ts"; +import type * as Forgejo from "./Providers.ts"; + +/** + * Desired Forgejo branch-protection rule settings. + */ +export interface BranchProtectionProps { + /** + * Repository owner. + */ + readonly owner: string; + /** + * Repository name. + */ + readonly repository: string; + /** + * Rule name and endpoint identity. + */ + readonly ruleName: string; + /** + * Required approving reviews. + */ + readonly requiredApprovals?: number; + /** + * Require signed commits. + */ + readonly requireSignedCommits?: boolean; + /** + * Require passing status checks. + */ + readonly enableStatusCheck?: boolean; + /** + * Required status-check contexts. + */ + readonly statusCheckContexts?: readonly string[]; + /** + * Prevent merging after a rejected review. + */ + readonly blockOnRejectedReviews?: boolean; + /** + * Prevent merging when branch is stale. + */ + readonly blockOnOutdatedBranch?: boolean; + /** + * Apply protection to administrators. + */ + readonly applyToAdmins?: boolean; + /** + * Users allowed to push. + */ + readonly pushWhitelistUsernames?: readonly string[]; + /** + * Teams allowed to push. + */ + readonly pushWhitelistTeams?: readonly string[]; +} + +/** + * Observed Forgejo branch-protection attributes. + */ +export interface BranchProtectionAttributes { + /** + * Rule name. + */ + readonly ruleName: string; +} + +/** + * A Forgejo branch-protection resource. + */ +export interface BranchProtection extends Resource< + "Forgejo.BranchProtection", + BranchProtectionProps, + BranchProtectionAttributes, + never, + Forgejo.Providers +> {} + +/** + * A branch-protection rule on a Forgejo repository. + * + * The rule name is the endpoint's identity and may be a glob, so `main` and + * `release/*` are separate rules. Changing it replaces the resource. + * + * ### Protecting a Branch + * **Example:** Require Reviews on the Default Branch + * ```typescript + * yield* Forgejo.BranchProtection("main", { + * owner: "acme", + * repository: "api", + * ruleName: "main", + * requiredApprovals: 2, + * blockOnRejectedReviews: true, + * blockOnOutdatedBranch: true, + * }); + * ``` + * + * **Example:** Require Status Checks + * ```typescript + * yield* Forgejo.BranchProtection("release", { + * owner: "acme", + * repository: "api", + * ruleName: "release/*", + * enableStatusCheck: true, + * statusCheckContexts: ["ci/build", "ci/test"], + * applyToAdmins: true, + * }); + * ``` + * + * ### Restricting Who Can Push + * **Example:** Limit Pushes to a Team + * ```typescript + * yield* Forgejo.BranchProtection("main", { + * owner: "acme", + * repository: "api", + * ruleName: "main", + * pushWhitelistTeams: ["platform"], + * pushWhitelistUsernames: ["release-bot"], + * }); + * ``` + * + * @resource + */ +export const BranchProtection = Resource( + "Forgejo.BranchProtection", +); + +interface ApiBranchProtection { + readonly rule_name: string; +} + +const collection = ( + props: Pick, +) => + `/repos/${encodeURIComponent(props.owner)}/${encodeURIComponent(props.repository)}/branch_protections`; + +const rulePath = (props: BranchProtectionProps) => + `${collection(props)}/${encodeURIComponent(props.ruleName)}`; + +const bodyOf = (props: BranchProtectionProps) => ({ + rule_name: props.ruleName, + required_approvals: props.requiredApprovals, + require_signed_commits: props.requireSignedCommits, + enable_status_check: props.enableStatusCheck, + status_check_contexts: props.statusCheckContexts, + block_on_rejected_reviews: props.blockOnRejectedReviews, + block_on_outdated_branch: props.blockOnOutdatedBranch, + apply_to_admins: props.applyToAdmins, + push_whitelist_usernames: props.pushWhitelistUsernames, + push_whitelist_teams: props.pushWhitelistTeams, +}); + +/** + * Provider layer implementing branch-protection lifecycle. + */ +export const BranchProtectionProvider = () => + Provider.succeed(BranchProtection, { + stables: ["ruleName"], + diff: ({ news, olds }) => + Effect.succeed( + isResolved(news) && + olds !== undefined && + (news.owner !== olds.owner || + news.repository !== olds.repository || + news.ruleName !== olds.ruleName) + ? { action: "replace" as const } + : undefined, + ), + list: Effect.fn(function* () { + const client = yield* ForgejoCredentials; + const repositories = yield* listAccessibleRepositories(); + const rules = yield* Effect.forEach( + repositories, + (repository) => + ignoreInaccessible( + paginate( + client, + collection({ + owner: repository.owner.login, + repository: repository.name, + }), + ), + [] as readonly ApiBranchProtection[], + ), + { concurrency: 8 }, + ); + return rules.flat().map((rule) => ({ ruleName: rule.rule_name })); + }), + read: Effect.fn(function* ({ olds }) { + const client = yield* ForgejoCredentials; + const observed = yield* optional( + client.request("GET", rulePath(olds)), + ); + return observed === undefined + ? undefined + : { ruleName: observed.rule_name }; + }), + reconcile: Effect.fn(function* ({ news }) { + const client = yield* ForgejoCredentials; + + // Observe: the rule name is the endpoint identity, so live state alone + // decides whether this creates or updates. + const observed = yield* optional( + client.request("GET", rulePath(news)), + ); + + if (observed === undefined) { + const created = yield* client + .request("POST", collection(news), { + body: bodyOf(news), + }) + .pipe( + // A concurrent create wins the race; converge onto the rule that + // is already there. + Effect.catchTag("ForgejoConflict", () => + client.request("PATCH", rulePath(news), { + body: bodyOf(news), + }), + ), + ); + return { ruleName: created.rule_name }; + } + + const updated = yield* client.request( + "PATCH", + rulePath(news), + { + body: bodyOf(news), + }, + ); + return { ruleName: updated.rule_name }; + }), + delete: Effect.fn(function* ({ olds }) { + const client = yield* ForgejoCredentials; + yield* optional(client.request("DELETE", rulePath(olds))); + }), + }); diff --git a/packages/alchemy/src/Forgejo/Client.ts b/packages/alchemy/src/Forgejo/Client.ts new file mode 100644 index 0000000000..71fe7f2295 --- /dev/null +++ b/packages/alchemy/src/Forgejo/Client.ts @@ -0,0 +1,497 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type { AuthError, NeedsReauth } from "../Auth/AuthProvider.ts"; +import { resolveProviderConfig } from "../Auth/Resolve.ts"; +import { UserFacingError } from "../UserFacingError.ts"; +import { + FORGEJO_AUTH_PROVIDER_NAME, + type ForgejoAuthConfig, + type ForgejoResolvedCredentials, +} from "./AuthProvider.ts"; + +/** + * Configuration used to connect to a Forgejo instance. + */ +export interface ForgejoClientOptions { + /** + * Forgejo origin or API v1 base URL. + */ + readonly baseUrl: string; + /** + * Forgejo access token. + */ + readonly token: string | Redacted.Redacted; +} + +/** + * Context shared by every Forgejo API failure. + */ +export interface ForgejoErrorContext { + /** + * HTTP method of the failed request. + */ + readonly method: string; + /** + * API path of the failed request, relative to the API v1 base URL. + */ + readonly path: string; + /** + * Response body returned by Forgejo. + */ + readonly body: string; +} + +/** + * The requested resource does not exist. Lifecycle operations treat this as a + * successful no-op when deleting, and as "needs creating" when reconciling. + */ +export class ForgejoNotFound extends Data.TaggedError( + "ForgejoNotFound", +) {} + +/** + * The credential is missing, malformed, or expired. + */ +export class ForgejoUnauthorized extends Data.TaggedError( + "ForgejoUnauthorized", +) {} + +/** + * The credential is valid but lacks permission for this operation. Forgejo + * returns this for administrator-only endpoints reached with a user token. + */ +export class ForgejoForbidden extends Data.TaggedError( + "ForgejoForbidden", +) {} + +/** + * The resource already exists, or the request conflicts with current state. + * Reconcilers treat this as a create race and re-observe. + */ +export class ForgejoConflict extends Data.TaggedError( + "ForgejoConflict", +) {} + +/** + * Forgejo rejected the request payload. + */ +export class ForgejoValidationError extends Data.TaggedError( + "ForgejoValidationError", +) {} + +/** + * Forgejo returned a 5xx response. + */ +export class ForgejoServerError extends Data.TaggedError("ForgejoServerError")< + ForgejoErrorContext & { + /** + * HTTP status returned by Forgejo. + */ + readonly status: number; + } +> {} + +/** + * Forgejo returned an unsuccessful status that maps to no more specific tag. + */ +export class ForgejoRequestError extends Data.TaggedError( + "ForgejoRequestError", +)< + ForgejoErrorContext & { + /** + * HTTP status returned by Forgejo. + */ + readonly status: number; + } +> {} + +/** + * The request never produced a usable response: a connection failure, an + * invalid URL, or an undecodable body. + */ +export class ForgejoTransportError extends Data.TaggedError( + "ForgejoTransportError", +)<{ + /** + * HTTP method of the failed request. + */ + readonly method: string; + /** + * API path of the failed request, relative to the API v1 base URL. + */ + readonly path: string; + /** + * Underlying transport or decoding failure. + */ + readonly cause: unknown; +}> {} + +/** + * Every failure a Forgejo API request can produce. + */ +export type ForgejoError = + | ForgejoNotFound + | ForgejoUnauthorized + | ForgejoForbidden + | ForgejoConflict + | ForgejoValidationError + | ForgejoServerError + | ForgejoRequestError + | ForgejoTransportError; + +/** + * Normalize a Forgejo origin into its API v1 base URL. + */ +export const normalizeBaseUrl = (baseUrl: string): string => { + const normalized = baseUrl.replace(/\/+$/, ""); + return normalized.endsWith("/api/v1") ? normalized : `${normalized}/api/v1`; +}; + +/** + * Query parameters accepted by a Forgejo API request. + */ +export type ForgejoQuery = Readonly< + Record +>; + +/** + * Options accepted by the Forgejo client's request method. + */ +export interface ForgejoRequestOptions { + /** + * JSON request body. + */ + readonly body?: unknown; + /** + * Query parameters appended to the request URL. Entries whose value is + * `undefined` are omitted. + */ + readonly query?: ForgejoQuery; +} + +/** + * Authenticated client for the Forgejo REST API. + */ +export interface ForgejoClient { + /** + * Normalized Forgejo API base URL. + */ + readonly baseUrl: string; + /** + * Perform an authenticated API request and decode its JSON response. + * + * Resolves to `undefined` for empty responses, and fails with a tagged + * error for every unsuccessful status. + */ + readonly request: ( + method: string, + path: string, + options?: ForgejoRequestOptions, + ) => Effect.Effect; +} + +/** + * Credentials and client available to all Forgejo providers. + */ +export class ForgejoCredentials extends Context.Service< + ForgejoCredentials, + ForgejoClient +>()("Forgejo::Credentials") {} + +const statusError = ( + status: number, + context: ForgejoErrorContext, +): ForgejoError => { + if (status === 401) return new ForgejoUnauthorized(context); + if (status === 403) return new ForgejoForbidden(context); + if (status === 404) return new ForgejoNotFound(context); + if (status === 409) return new ForgejoConflict(context); + if (status === 422) return new ForgejoValidationError(context); + if (status >= 500) return new ForgejoServerError({ ...context, status }); + return new ForgejoRequestError({ ...context, status }); +}; + +const withQuery = (path: string, query: ForgejoQuery | undefined): string => { + if (query === undefined) return path; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) params.set(key, String(value)); + } + const search = params.toString(); + return search === "" ? path : `${path}?${search}`; +}; + +const makeClient = ( + options: ForgejoClientOptions, + httpClient: HttpClient.HttpClient, +): ForgejoClient => { + const baseUrl = normalizeBaseUrl(options.baseUrl); + const token = + typeof options.token === "string" + ? options.token + : Redacted.value(options.token); + + return { + baseUrl, + request: ( + method: string, + path: string, + requestOptions?: ForgejoRequestOptions, + ) => + Effect.gen(function* () { + const url = `${baseUrl}${withQuery(path, requestOptions?.query)}`; + const base = HttpClientRequest.make(method as "GET")(url).pipe( + HttpClientRequest.setHeaders({ + Accept: "application/json", + Authorization: `token ${token}`, + }), + ); + const request = + requestOptions?.body === undefined + ? base + : HttpClientRequest.bodyJsonUnsafe(base, requestOptions.body); + + const response = yield* httpClient.execute(request); + const text = yield* response.text; + + if (response.status < 200 || response.status >= 300) { + return yield* statusError(response.status, { + method, + path, + body: text, + }); + } + // Forgejo answers many mutations with `204 No Content`; callers that + // ignore the result type this as `void`. + if (text.length === 0) return undefined as T; + return yield* Effect.try({ + try: () => JSON.parse(text) as T, + catch: (cause) => new ForgejoTransportError({ method, path, cause }), + }); + }).pipe( + Effect.catchTag( + "HttpClientError", + (cause) => new ForgejoTransportError({ method, path, cause }), + ), + ), + }; +}; + +/** + * Resolve a request that is allowed to be missing, mapping a not-found + * failure to `undefined`. + */ +export const optional = ( + effect: Effect.Effect, +): Effect.Effect, R> => + effect.pipe( + Effect.catchTag("ForgejoNotFound", () => Effect.succeed(undefined)), + ); + +/** + * Resolve a request that is allowed to be missing or inaccessible. + * + * Account-wide enumeration walks resources the credential may not be able to + * read; a single inaccessible repository or organization must not abort the + * whole sweep. + */ +export const ignoreInaccessible = ( + effect: Effect.Effect, + fallback: A, +): Effect.Effect< + A, + Exclude, + R +> => + effect.pipe( + Effect.catchTag(["ForgejoNotFound", "ForgejoForbidden"], () => + Effect.succeed(fallback), + ), + ); + +/** + * Largest page size Forgejo accepts on its paginated list endpoints. + */ +const PAGE_LIMIT = 50; + +/** + * Upper bound on pages walked by {@link paginate}, so a server that never + * reports a short page cannot spin forever. + */ +const MAX_PAGES = 100; + +/** + * Walk every page of a Forgejo list endpoint. + * + * Forgejo paginates list responses (30 entries by default), so a single + * request silently truncates enumeration. Paging stops at the first short + * page, or at {@link MAX_PAGES}. + */ +export const paginate = ( + client: ForgejoClient, + path: string, + options?: { + /** + * Additional query parameters sent with every page request. + */ + readonly query?: ForgejoQuery; + }, +): Effect.Effect => { + const go = ( + page: number, + accumulated: readonly T[], + ): Effect.Effect => + client + .request("GET", path, { + query: { ...options?.query, page, limit: PAGE_LIMIT }, + }) + .pipe( + Effect.flatMap((items) => { + const combined = + items === undefined ? accumulated : [...accumulated, ...items]; + return items === undefined || + items.length < PAGE_LIMIT || + page >= MAX_PAGES + ? Effect.succeed(combined) + : go(page + 1, combined); + }), + ); + + return go(1, []); +}; + +/** + * Build a credentials layer from a Forgejo URL and access token. + */ +export const fromToken = (options: ForgejoClientOptions) => + Layer.effect( + ForgejoCredentials, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + return makeClient(options, httpClient); + }), + ); + +/** + * Raised when environment authentication is requested without the required + * variables. + */ +export class MissingForgejoEnvironment extends Data.TaggedError( + "MissingForgejoEnvironment", +)<{ + /** + * Names of the environment variables that were not set. + */ + readonly missing: readonly string[]; +}> { + /** + * Human-readable description of the missing configuration. + */ + override get message(): string { + return `Set ${this.missing.join(" and ")} to use Forgejo providers.`; + } +} + +/** + * Build a credentials layer from `FORGEJO_URL` and `FORGEJO_TOKEN`. + */ +export const fromEnv = () => + Layer.effect( + ForgejoCredentials, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const baseUrl = yield* Effect.sync(() => process.env.FORGEJO_URL); + const token = yield* Effect.sync(() => process.env.FORGEJO_TOKEN); + const missing = [ + ...(baseUrl === undefined ? ["FORGEJO_URL"] : []), + ...(token === undefined ? ["FORGEJO_TOKEN"] : []), + ]; + if (baseUrl === undefined || token === undefined) { + return yield* new MissingForgejoEnvironment({ missing }); + } + return makeClient({ baseUrl, token }, httpClient); + }), + ); + +/** + * Raised when neither the selected profile nor the CI environment yields a + * usable Forgejo credential. + */ +export class UnresolvedForgejoCredentials extends Data.TaggedError( + "UnresolvedForgejoCredentials", +)<{ + /** + * Where resolution was attempted, e.g. `profile 'default'`. + */ + readonly source: string; + /** + * Underlying auth-provider failure. + */ + readonly cause: unknown; +}> { + readonly [UserFacingError] = true; + + /** + * Human-readable description of the failed resolution. + */ + override get message(): string { + return ( + `Failed to resolve Forgejo credentials from ${this.source}. ` + + "Run `alchemy profile edit --add Forgejo`, or set FORGEJO_URL and " + + "FORGEJO_TOKEN." + ); + } +} + +/** + * Build a credentials layer from the selected alchemy profile, falling back + * to `FORGEJO_URL` / `FORGEJO_TOKEN` in CI. + * + * This is what `providers()` uses when no explicit `{ baseUrl, token }` is + * passed, so `alchemy profile edit --add Forgejo` is enough to authenticate + * a stack. + */ +export const fromAuthProvider = () => + Layer.effect( + ForgejoCredentials, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const { profileName, resolve } = yield* resolveProviderConfig< + ForgejoAuthConfig, + ForgejoResolvedCredentials + >(FORGEJO_AUTH_PROVIDER_NAME); + + // `resolve` is a union of the environment-branch and profile-branch + // effects, whose error channels differ. Widen it to their common + // supertype: piping the union directly infers `unknown` requirements, + // which silently poisons `StackServices` for every consumer. + const resolved: Effect.Effect< + ForgejoResolvedCredentials, + AuthError | NeedsReauth + > = resolve; + + const credentials = yield* resolved.pipe( + Effect.mapError( + (cause) => + new UnresolvedForgejoCredentials({ + source: + profileName === undefined + ? "the CI environment" + : `profile '${profileName}'`, + cause, + }), + ), + Effect.orDie, + ); + + return makeClient( + { baseUrl: credentials.baseUrl, token: credentials.token }, + httpClient, + ); + }), + ); diff --git a/packages/alchemy/src/Forgejo/Label.ts b/packages/alchemy/src/Forgejo/Label.ts new file mode 100644 index 0000000000..c0a4c3b93f --- /dev/null +++ b/packages/alchemy/src/Forgejo/Label.ts @@ -0,0 +1,232 @@ +import * as Effect from "effect/Effect"; +import { isResolved } from "../Diff.ts"; +import * as Provider from "../Provider.ts"; +import { Resource } from "../Resource.ts"; +import { + ForgejoCredentials, + ignoreInaccessible, + optional, + paginate, +} from "./Client.ts"; +import { listAccessibleRepositories } from "./Lists.ts"; +import type * as Forgejo from "./Providers.ts"; + +/** + * Desired repository issue and pull-request label. + */ +export interface LabelProps { + /** + * Repository owner. + */ + readonly owner: string; + /** + * Repository name. + */ + readonly repository: string; + /** + * Label name. + */ + readonly name: string; + /** + * Hex label color without `#`. + */ + readonly color: string; + /** + * Label description. + */ + readonly description?: string; + /** + * Mark as exclusive. + */ + readonly exclusive?: boolean; + /** + * Archive the label. + */ + readonly isArchived?: boolean; +} + +/** + * Observed repository label attributes. + */ +export interface LabelAttributes { + /** + * Stable numeric label ID. + */ + readonly labelId: number; + /** + * Label name. + */ + readonly name: string; + /** + * Label color. + */ + readonly color: string; +} + +/** + * A Forgejo repository label resource, usable by issues and pull requests. + */ +export interface Label extends Resource< + "Forgejo.Label", + LabelProps, + LabelAttributes, + never, + Forgejo.Providers +> {} + +/** + * An issue and pull-request label on a Forgejo repository. + * + * A label that already exists under the same name is adopted rather than + * duplicated, so importing a repository's existing labels is safe. + * + * ### Creating a Label + * **Example:** Basic Label + * ```typescript + * yield* Forgejo.Label("bug", { + * owner: "acme", + * repository: "api", + * name: "bug", + * color: "d73a4a", + * }); + * ``` + * + * **Example:** Exclusive Label + * ```typescript + * yield* Forgejo.Label("priority-high", { + * owner: "acme", + * repository: "api", + * name: "priority/high", + * color: "b60205", + * description: "Drop everything", + * exclusive: true, + * }); + * ``` + * + * @resource + */ +export const Label = Resource(attributes: A) => ({ + id: "nuke", + fqn: "nuke", + instanceId: "", + olds: attributes as never, + output: attributes as never, + session: { + emit: () => Effect.void, + done: () => Effect.void, + note: () => Effect.void, + }, + bindings: [] as never, + force: true, +}); + +test.provider("nuke can delete a webhook from its attributes alone", (stack) => + Effect.gen(function* () { + reset(); + + yield* stack.deploy( + Webhook("Hook", { + owner: "acme", + repository: "api", + url: "https://deploy.example/hooks", + }), + ); + expect(hooks.size).toBe(1); + + const provider = yield* Provider.findProvider(Webhook); + const listed = yield* provider.list(); + expect(listed).toHaveLength(1); + expect(listed[0]!.owner).toBe("acme"); + expect(listed[0]!.repository).toBe("api"); + + yield* provider.delete(nukeDelete(listed[0]!)); + expect(hooks.size).toBe(0); + }), +); + +test.provider("nuke can delete a label from its attributes alone", (stack) => + Effect.gen(function* () { + reset(); + + yield* stack.deploy( + Label("Bug", { + owner: "acme", + repository: "api", + name: "bug", + color: "d73a4a", + }), + ); + expect(labels.size).toBe(1); + + const provider = yield* Provider.findProvider(Label); + const listed = yield* provider.list(); + expect(listed).toHaveLength(1); + expect(listed[0]!.owner).toBe("acme"); + expect(listed[0]!.repository).toBe("api"); + + yield* provider.delete(nukeDelete(listed[0]!)); + expect(labels.size).toBe(0); + }), +); + +test.provider( + "nuke can delete a branch-protection rule from its attributes alone", + (stack) => + Effect.gen(function* () { + reset(); + + yield* stack.deploy( + BranchProtection("Main", { + owner: "acme", + repository: "api", + ruleName: "main", + }), + ); + expect(rules.size).toBe(1); + + const provider = yield* Provider.findProvider(BranchProtection); + const listed = yield* provider.list(); + expect(listed).toHaveLength(1); + expect(listed[0]!.owner).toBe("acme"); + expect(listed[0]!.repository).toBe("api"); + + yield* provider.delete(nukeDelete(listed[0]!)); + expect(rules.size).toBe(0); + }), +); diff --git a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts index 6ba8e98549..535a25109f 100644 --- a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts +++ b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts @@ -500,7 +500,11 @@ test.provider( requiredApprovals: 1, }), ); - expect(created).toEqual({ ruleName: "main" }); + expect(created).toEqual({ + owner: "alice", + repository: "alchemy", + ruleName: "main", + }); expect( server.find("POST", "/repos/alice/alchemy/branch_protections")?.body, ).toMatchObject({ diff --git a/packages/alchemy/test/Forgejo/Webhook.test.ts b/packages/alchemy/test/Forgejo/Webhook.test.ts index 0244294ce2..98d4094971 100644 --- a/packages/alchemy/test/Forgejo/Webhook.test.ts +++ b/packages/alchemy/test/Forgejo/Webhook.test.ts @@ -77,13 +77,13 @@ test.provider( reset(); // Stand in for a create whose state write never landed: the hook exists - // on the instance but alchemy has no record of it. Forgejo accepts - // several hooks pointing at one URL, so creating unconditionally would - // add a second on every retry. + // on the instance, with exactly the config we asked for, but alchemy has + // no record of it. Forgejo accepts several hooks pointing at one URL, so + // creating unconditionally would add a second on every retry. hooks.set(1, { id: 1, config: { url: "https://deploy.example/hooks", content_type: "json" }, - events: ["push"], + events: ["push", "pull_request"], }); nextId = 2; @@ -103,6 +103,40 @@ test.provider( }), ); +test.provider( + "keeps two hooks on one URL apart when their events differ", + (stack) => + Effect.gen(function* () { + reset(); + + // Same repository, same delivery URL, different events — legitimate, and + // matching on URL alone would collapse both resources onto one hook with + // each deploy overwriting the other's events. + yield* stack.deploy( + Effect.gen(function* () { + yield* Webhook("Push", { + owner: "acme", + repository: "api", + url: "https://deploy.example/hooks", + events: ["push"], + }); + yield* Webhook("Pull", { + owner: "acme", + repository: "api", + url: "https://deploy.example/hooks", + events: ["pull_request"], + }); + }), + ); + + expect(hooks.size).toBe(2); + expect([...hooks.values()].map((hook) => hook.events).sort()).toEqual([ + ["pull_request"], + ["push"], + ]); + }), +); + test.provider("creates, updates and deletes a webhook", (stack) => Effect.gen(function* () { reset(); From 082e3e85e20985e0a70cc6cafc8ebad97215f1f1 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 31 Aug 2026 23:53:17 -0600 Subject: [PATCH 4/9] fix(forgejo): page until empty, and drop Input from webhook props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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`, 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. --- .../alchemy/src/Forgejo/BranchProtection.ts | 2 +- packages/alchemy/src/Forgejo/Client.ts | 16 ++++++-- packages/alchemy/src/Forgejo/Label.ts | 2 +- packages/alchemy/src/Forgejo/Webhook.ts | 10 ++--- .../alchemy/test/Forgejo/ApiToken.test.ts | 11 ++++- packages/alchemy/test/Forgejo/Client.test.ts | 41 ++++++++++++++++--- .../alchemy/test/Forgejo/NukeContract.test.ts | 21 +++++++--- .../test/Forgejo/ProviderLifecycles.test.ts | 25 +++++++---- packages/alchemy/test/Forgejo/Webhook.test.ts | 14 +++++-- packages/alchemy/test/Forgejo/support/mock.ts | 14 +++++++ 10 files changed, 121 insertions(+), 35 deletions(-) diff --git a/packages/alchemy/src/Forgejo/BranchProtection.ts b/packages/alchemy/src/Forgejo/BranchProtection.ts index fbfc1b1487..806303825a 100644 --- a/packages/alchemy/src/Forgejo/BranchProtection.ts +++ b/packages/alchemy/src/Forgejo/BranchProtection.ts @@ -215,7 +215,7 @@ const bodyOf = (props: BranchProtectionProps) => { */ export const BranchProtectionProvider = () => Provider.succeed(BranchProtection, { - stables: ["ruleName"], + stables: ["ruleName", "owner", "repository"], diff: ({ news, olds }) => Effect.succeed( isResolved(news) && diff --git a/packages/alchemy/src/Forgejo/Client.ts b/packages/alchemy/src/Forgejo/Client.ts index b57e0c9bb0..37637dfe94 100644 --- a/packages/alchemy/src/Forgejo/Client.ts +++ b/packages/alchemy/src/Forgejo/Client.ts @@ -360,9 +360,11 @@ const MAX_PAGES = 100; * Walk every page of a Forgejo list endpoint. * * Forgejo paginates list responses (30 entries by default), so a single - * request silently truncates enumeration. Paging stops at the first short - * page; hitting {@link MAX_PAGES} fails with {@link ForgejoPaginationLimit} - * rather than returning a list that only looks complete. + * request silently truncates enumeration. Paging stops at the first empty + * page — not the first short one, since the instance may clamp the page size + * below {@link PAGE_LIMIT}. Hitting {@link MAX_PAGES} fails with + * {@link ForgejoPaginationLimit} rather than returning a list that only looks + * complete. */ export const paginate = ( client: ForgejoClient, @@ -386,7 +388,13 @@ export const paginate = ( Effect.flatMap((items) => { const combined = items === undefined ? accumulated : [...accumulated, ...items]; - if (items === undefined || items.length < PAGE_LIMIT) { + // Stop only on an empty page, never on a short one. Forgejo clamps + // the requested `limit` to the instance's `[api] MAX_RESPONSE_ITEMS`, + // so on a server whose administrator lowered that below PAGE_LIMIT + // every full page looks short — treating short as "last" would end + // enumeration after page one and silently report a partial list as + // complete. + if (items === undefined || items.length === 0) { return Effect.succeed(combined); } return page >= MAX_PAGES diff --git a/packages/alchemy/src/Forgejo/Label.ts b/packages/alchemy/src/Forgejo/Label.ts index 2c677694c8..9bcf4e609b 100644 --- a/packages/alchemy/src/Forgejo/Label.ts +++ b/packages/alchemy/src/Forgejo/Label.ts @@ -176,7 +176,7 @@ const observe = Effect.fn(function* ( */ export const LabelProvider = () => Provider.succeed(Label, { - stables: ["labelId"], + stables: ["labelId", "owner", "repository"], diff: ({ news, olds }) => Effect.succeed( isResolved(news) && diff --git a/packages/alchemy/src/Forgejo/Webhook.ts b/packages/alchemy/src/Forgejo/Webhook.ts index 98752444ac..df231d7e6a 100644 --- a/packages/alchemy/src/Forgejo/Webhook.ts +++ b/packages/alchemy/src/Forgejo/Webhook.ts @@ -1,7 +1,6 @@ import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import { isResolved } from "../Diff.ts"; -import type { Input } from "../Input.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { @@ -28,7 +27,7 @@ export interface WebhookProps { /** * Delivery URL. */ - readonly url: Input; + readonly url: string; /** * Forgejo event names to deliver. */ @@ -193,8 +192,7 @@ const observe = Effect.fn(function* ( [] as readonly ApiHook[], ); return hooks.find( - (hook) => - urlOf(hook) === (props.url as string) && sameEvents(hook, props.events), + (hook) => urlOf(hook) === props.url && sameEvents(hook, props.events), ); }); @@ -208,7 +206,7 @@ const bodyOf = (props: WebhookProps) => ({ ? undefined : Redacted.value(props.authorizationHeader), config: { - url: props.url as string, + url: props.url, content_type: props.contentType ?? "json", ...(props.secret === undefined ? {} @@ -221,7 +219,7 @@ const bodyOf = (props: WebhookProps) => ({ */ export const WebhookProvider = () => Provider.succeed(Webhook, { - stables: ["webhookId"], + stables: ["webhookId", "owner", "repository"], diff: ({ news, olds }) => { if (!isResolved(news) || olds === undefined) return Effect.void; return Effect.succeed( diff --git a/packages/alchemy/test/Forgejo/ApiToken.test.ts b/packages/alchemy/test/Forgejo/ApiToken.test.ts index b747e0ab3b..c81b5082d9 100644 --- a/packages/alchemy/test/Forgejo/ApiToken.test.ts +++ b/packages/alchemy/test/Forgejo/ApiToken.test.ts @@ -4,7 +4,13 @@ import { expect } from "alchemy-test"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; -import { json, mockForgejo, noContent, status } from "./support/mock.ts"; +import { + json, + jsonList, + mockForgejo, + noContent, + status, +} from "./support/mock.ts"; interface StoredToken { readonly id: number; @@ -25,7 +31,8 @@ const server = mockForgejo((request) => { if (match === null) return status(404, "not found"); if (request.method === "GET") { - return json( + return jsonList( + request, [...tokens.values()].map( ({ id, name, scopes, token_last_eight, created_at }) => ({ id, diff --git a/packages/alchemy/test/Forgejo/Client.test.ts b/packages/alchemy/test/Forgejo/Client.test.ts index 52f4258646..0b038a45ff 100644 --- a/packages/alchemy/test/Forgejo/Client.test.ts +++ b/packages/alchemy/test/Forgejo/Client.test.ts @@ -227,11 +227,17 @@ describe("ignoreInaccessible", () => { }); describe("paginate", () => { - test("walks every page until a short page is returned", async () => { + test("walks every page until an empty page is returned", async () => { const page1 = Array.from({ length: 50 }, (_, index) => ({ id: index })); const page2 = [{ id: 50 }, { id: 51 }]; const server = mockForgejo((request) => - json(request.query.page === "1" ? page1 : page2), + json( + request.query.page === "1" + ? page1 + : request.query.page === "2" + ? page2 + : [], + ), ); const items = await run(server.layer, (client) => @@ -242,17 +248,42 @@ describe("paginate", () => { expect(server.requests.map((request) => request.query.page)).toEqual([ "1", "2", + "3", ]); }); - test("stops after a single short page", async () => { - const server = mockForgejo(() => json([{ id: 1 }])); + test("keeps paging when the instance clamps the page size", async () => { + // Forgejo clamps `limit` to `[api] MAX_RESPONSE_ITEMS`. On an instance + // where that is below PAGE_LIMIT every full page looks short, so stopping + // at the first short page would report page one as the whole list. + const clamped = 20; + const server = mockForgejo((request) => { + const page = Number(request.query.page); + return json( + page > 2 + ? [] + : Array.from({ length: clamped }, (_, index) => ({ + id: (page - 1) * clamped + index, + })), + ); + }); + + const items = await run(server.layer, (client) => + paginate<{ id: number }>(client, "/user/orgs"), + ); + + expect(items).toHaveLength(40); + expect(server.requests).toHaveLength(3); + }); + + test("stops after a single empty page", async () => { + const server = mockForgejo(() => json([])); const items = await run(server.layer, (client) => paginate<{ id: number }>(client, "/user/orgs"), ); - expect(items).toEqual([{ id: 1 }]); + expect(items).toEqual([]); expect(server.requests).toHaveLength(1); }); diff --git a/packages/alchemy/test/Forgejo/NukeContract.test.ts b/packages/alchemy/test/Forgejo/NukeContract.test.ts index 2b2d79a544..06c76bd13e 100644 --- a/packages/alchemy/test/Forgejo/NukeContract.test.ts +++ b/packages/alchemy/test/Forgejo/NukeContract.test.ts @@ -9,7 +9,13 @@ import * as Test from "@/Test/Alchemy"; import { expect } from "alchemy-test"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { json, mockForgejo, noContent, status } from "./support/mock.ts"; +import { + json, + jsonList, + mockForgejo, + noContent, + status, +} from "./support/mock.ts"; /** * Account-wide teardown (`alchemy nuke`) enumerates straight from the cloud, @@ -35,16 +41,18 @@ const reset = () => { server.reset(); }; -const server = mockForgejo(({ method, path, body }) => { +const server = mockForgejo((request) => { + const { method, path, body } = request; const fields = body as Record | undefined; if (method === "GET" && path === "/user/repos") { - return json([{ owner: { login: "acme" }, name: "api" }]); + return jsonList(request, [{ owner: { login: "acme" }, name: "api" }]); } if (path === "/repos/acme/api/hooks") { if (method === "GET") { - return json( + return jsonList( + request, [...hooks.values()].map((hook) => ({ id: hook.id, url: hook.url, @@ -72,7 +80,8 @@ const server = mockForgejo(({ method, path, body }) => { if (path === "/repos/acme/api/labels") { if (method === "GET") { - return json( + return jsonList( + request, [...labels.values()].map((label) => ({ id: label.id, name: label.name, @@ -98,7 +107,7 @@ const server = mockForgejo(({ method, path, body }) => { } if (path === "/repos/acme/api/branch_protections") { - if (method === "GET") return json([...rules.values()]); + if (method === "GET") return jsonList(request, [...rules.values()]); if (method === "POST") { const rule = { rule_name: String(fields?.rule_name) }; rules.set(rule.rule_name, rule); diff --git a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts index 535a25109f..ba089ecec9 100644 --- a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts +++ b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts @@ -11,7 +11,13 @@ import * as Test from "@/Test/Alchemy"; import { expect } from "alchemy-test"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { json, mockForgejo, noContent, status } from "./support/mock.ts"; +import { + json, + jsonList, + mockForgejo, + noContent, + status, +} from "./support/mock.ts"; interface StoredOrganization { readonly id: number; @@ -69,11 +75,12 @@ const labelPayload = (label: StoredLabel) => ({ color: label.color, }); -const server = mockForgejo(({ method, path, body }) => { +const server = mockForgejo((request) => { + const { method, path, body } = request; const payload = body as Record | undefined; if (method === "GET" && path === "/user/orgs") { - return json([...organizations.values()]); + return jsonList(request, [...organizations.values()]); } const adminOrgs = path.match(/^\/admin\/users\/([^/]+)\/orgs$/); @@ -109,7 +116,8 @@ const server = mockForgejo(({ method, path, body }) => { const orgTeams = path.match(/^\/orgs\/([^/]+)\/teams$/); if (orgTeams !== null) { if (method === "GET") { - return json( + return jsonList( + request, [...teams.values()] .filter((team) => team.organization === orgTeams[1]) .map(teamPayload), @@ -145,7 +153,8 @@ const server = mockForgejo(({ method, path, body }) => { const teamMembers = path.match(/^\/teams\/(\d+)\/members$/); if (method === "GET" && teamMembers !== null) { - return json( + return jsonList( + request, [...members] .filter((key) => key.startsWith(`${teamMembers[1]}:`)) .map((key) => ({ login: key.split(":")[1] })), @@ -173,7 +182,8 @@ const server = mockForgejo(({ method, path, body }) => { const labelCollection = path.match(/^\/repos\/([^/]+\/[^/]+)\/labels$/); if (labelCollection !== null) { if (method === "GET") { - return json( + return jsonList( + request, [...labels.values()] .filter((label) => label.repository === labelCollection[1]) .map(labelPayload), @@ -216,7 +226,8 @@ const server = mockForgejo(({ method, path, body }) => { ); if (ruleCollection !== null) { if (method === "GET") { - return json( + return jsonList( + request, [...rules.values()].filter( (rule) => rule.repository === ruleCollection[1], ), diff --git a/packages/alchemy/test/Forgejo/Webhook.test.ts b/packages/alchemy/test/Forgejo/Webhook.test.ts index 98d4094971..2737173af2 100644 --- a/packages/alchemy/test/Forgejo/Webhook.test.ts +++ b/packages/alchemy/test/Forgejo/Webhook.test.ts @@ -3,7 +3,13 @@ import * as Test from "@/Test/Alchemy"; import { expect } from "alchemy-test"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { json, mockForgejo, noContent, status } from "./support/mock.ts"; +import { + json, + jsonList, + mockForgejo, + noContent, + status, +} from "./support/mock.ts"; interface StoredHook { readonly id: number; @@ -28,11 +34,13 @@ const payload = (hook: StoredHook) => ({ events: hook.events, }); -const server = mockForgejo(({ method, path, body }) => { +const server = mockForgejo((request) => { + const { method, path, body } = request; const fields = body as Record | undefined; if (path === "/repos/acme/api/hooks") { - if (method === "GET") return json([...hooks.values()].map(payload)); + if (method === "GET") + return jsonList(request, [...hooks.values()].map(payload)); if (method === "POST") { const hook: StoredHook = { id: nextId++, diff --git a/packages/alchemy/test/Forgejo/support/mock.ts b/packages/alchemy/test/Forgejo/support/mock.ts index 790f77df42..07b8ea2b7d 100644 --- a/packages/alchemy/test/Forgejo/support/mock.ts +++ b/packages/alchemy/test/Forgejo/support/mock.ts @@ -121,6 +121,20 @@ export const mockForgejo = ( export const json = (body: unknown, status = 200) => Response.json(body, { status }); +/** + * Build a paginated list response, honouring the request's `page` and `limit`. + * + * Forgejo returns an empty array once a list is exhausted, and `paginate` + * relies on that to stop. A mock that serves the whole list for every page + * would never terminate, so list routes must slice like the real API. + */ +export const jsonList = (request: RecordedRequest, items: readonly T[]) => { + const page = Number(request.query.page ?? "1"); + const limit = Number(request.query.limit ?? String(items.length)); + const start = (page - 1) * limit; + return json(items.slice(start, start + limit)); +}; + /** * Build an empty `204 No Content` response. */ From db356006b265a923af7c399e310777f298591074 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 31 Aug 2026 23:57:03 -0600 Subject: [PATCH 5/9] fix(forgejo): converge only on drift, and correct endpoint mismatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/alchemy/src/Forgejo/ApiToken.ts | 32 ++++++++++-- .../alchemy/src/Forgejo/BranchProtection.ts | 49 +++++++++++++------ packages/alchemy/src/Forgejo/Label.ts | 18 ++++--- packages/alchemy/src/Forgejo/Organization.ts | 32 +++++++----- packages/alchemy/src/Forgejo/Repository.ts | 30 +++++------- packages/alchemy/src/Forgejo/Secret.ts | 22 +++------ packages/alchemy/src/Forgejo/Settings.ts | 46 +++++++++++++++++ packages/alchemy/src/Forgejo/Team.ts | 22 ++++++--- packages/alchemy/src/Forgejo/Webhook.ts | 15 +++++- .../test/Forgejo/ProviderLifecycles.test.ts | 4 +- .../test/Forgejo/ResourceSchemas.test.ts | 3 +- website/src/content/docs/forgejo/setup.mdx | 1 + 12 files changed, 195 insertions(+), 79 deletions(-) create mode 100644 packages/alchemy/src/Forgejo/Settings.ts diff --git a/packages/alchemy/src/Forgejo/ApiToken.ts b/packages/alchemy/src/Forgejo/ApiToken.ts index 671f1dd9c5..e432970ded 100644 --- a/packages/alchemy/src/Forgejo/ApiToken.ts +++ b/packages/alchemy/src/Forgejo/ApiToken.ts @@ -133,6 +133,19 @@ interface ApiAccessToken { readonly created_at: string; } +/** Order-insensitive comparison of two optional string lists. */ +const sameSet = ( + a: readonly string[] | undefined, + b: readonly string[] | undefined, +): boolean => { + const left = [...(a ?? [])].sort(); + const right = [...(b ?? [])].sort(); + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +}; + const tokensPath = (username: string) => `/admin/users/${encodeURIComponent(username)}/tokens`; @@ -176,13 +189,24 @@ export const ApiTokenProvider = () => stables: ["tokenId", "token"], diff: ({ news, olds }) => { if (!isResolved(news) || olds === undefined) return Effect.void; + // Replacing a token deletes the old one before minting the new one + // (Forgejo rejects a duplicate token name), so every consumer of the + // old value breaks in between. The trigger must therefore fire on a + // genuine change only, never on a cosmetic reorder. + const sameScopes = sameSet(news.scopes, olds.scopes); + const sameRepositories = sameSet( + news.repositories?.map( + (repository) => `${repository.owner}/${repository.name}`, + ), + olds.repositories?.map( + (repository) => `${repository.owner}/${repository.name}`, + ), + ); return Effect.succeed( news.username !== olds.username || news.name !== olds.name || - JSON.stringify(news.scopes ?? []) !== - JSON.stringify(olds.scopes ?? []) || - JSON.stringify(news.repositories ?? []) !== - JSON.stringify(olds.repositories ?? []) + !sameScopes || + !sameRepositories ? { action: "replace" as const, deleteFirst: true } : undefined, ); diff --git a/packages/alchemy/src/Forgejo/BranchProtection.ts b/packages/alchemy/src/Forgejo/BranchProtection.ts index 806303825a..1bb721c504 100644 --- a/packages/alchemy/src/Forgejo/BranchProtection.ts +++ b/packages/alchemy/src/Forgejo/BranchProtection.ts @@ -9,6 +9,7 @@ import { paginate, } from "./Client.ts"; import { listAccessibleRepositories } from "./Lists.ts"; +import { matchesDesired } from "./Settings.ts"; import type * as Forgejo from "./Providers.ts"; /** @@ -167,6 +168,17 @@ export const BranchProtection = Resource( interface ApiBranchProtection { readonly rule_name: string; + readonly required_approvals?: number; + readonly require_signed_commits?: boolean; + readonly enable_status_check?: boolean; + readonly status_check_contexts?: readonly string[]; + readonly block_on_rejected_reviews?: boolean; + readonly block_on_outdated_branch?: boolean; + readonly apply_to_admins?: boolean; + readonly push_whitelist_usernames?: readonly string[]; + readonly push_whitelist_teams?: readonly string[]; + readonly enable_push?: boolean; + readonly enable_push_whitelist?: boolean; } const collection = ( @@ -236,10 +248,16 @@ export const BranchProtectionProvider = () => owner: repository.owner.login, repository: repository.name, }; + // This is the one list endpoint Forgejo does not paginate: it + // accepts no `page`/`limit` and returns every rule at once. return ignoreInaccessible( - paginate(client, collection(props)), - [] as readonly ApiBranchProtection[], + client.request( + "GET", + collection(props), + ), + undefined as readonly ApiBranchProtection[] | undefined, ).pipe( + Effect.map((found) => found ?? []), Effect.map((found) => found.map((rule) => attributesOf(props, rule)), ), @@ -272,23 +290,26 @@ export const BranchProtectionProvider = () => }) .pipe( // A concurrent create wins the race; converge onto the rule that - // is already there. - Effect.catchTag("ForgejoConflict", () => - client.request("PATCH", rulePath(news), { - body: bodyOf(news), - }), + // is already there. This endpoint declares 403/422/423 for an + // existing rule, not 409, so the conflict arrives under those. + Effect.catchTag( + ["ForgejoForbidden", "ForgejoValidationError"], + () => + client.request("PATCH", rulePath(news), { + body: bodyOf(news), + }), ), ); return attributesOf(news, created); } - const updated = yield* client.request( - "PATCH", - rulePath(news), - { - body: bodyOf(news), - }, - ); + // Sync only when the live rule differs from what was declared. + const desired = bodyOf(news); + const updated = matchesDesired(observed, desired) + ? observed + : yield* client.request("PATCH", rulePath(news), { + body: desired, + }); return attributesOf(news, updated); }), delete: Effect.fn(function* ({ output }) { diff --git a/packages/alchemy/src/Forgejo/Label.ts b/packages/alchemy/src/Forgejo/Label.ts index 9bcf4e609b..9735a7c613 100644 --- a/packages/alchemy/src/Forgejo/Label.ts +++ b/packages/alchemy/src/Forgejo/Label.ts @@ -9,6 +9,7 @@ import { paginate, } from "./Client.ts"; import { listAccessibleRepositories } from "./Lists.ts"; +import { matchesDesired } from "./Settings.ts"; import type * as Forgejo from "./Providers.ts"; /** @@ -120,6 +121,9 @@ interface ApiLabel { readonly id: number; readonly name: string; readonly color: string; + readonly description?: string; + readonly exclusive?: boolean; + readonly is_archived?: boolean; } const collection = (props: Pick) => @@ -230,13 +234,13 @@ export const LabelProvider = () => return attributesOf(news, created); } - const updated = yield* client.request( - "PATCH", - path(news, observed.id), - { - body: bodyOf(news), - }, - ); + // Sync only when the live label differs from what was declared. + const desired = bodyOf(news); + const updated = matchesDesired(observed, desired) + ? observed + : yield* client.request("PATCH", path(news, observed.id), { + body: desired, + }); return attributesOf(news, updated); }), delete: Effect.fn(function* ({ output }) { diff --git a/packages/alchemy/src/Forgejo/Organization.ts b/packages/alchemy/src/Forgejo/Organization.ts index f4d67454f3..68ede0b4a1 100644 --- a/packages/alchemy/src/Forgejo/Organization.ts +++ b/packages/alchemy/src/Forgejo/Organization.ts @@ -4,6 +4,7 @@ import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { type ForgejoClient, ForgejoCredentials, optional } from "./Client.ts"; import { listAccessibleOrganizations } from "./Lists.ts"; +import { matchesDesired } from "./Settings.ts"; import type * as Forgejo from "./Providers.ts"; /** @@ -29,7 +30,7 @@ export interface OrganizationProps { /** * Visibility. */ - readonly visibility?: string; + readonly visibility?: "public" | "limited" | "private"; /** * Website. */ @@ -121,6 +122,12 @@ export const Organization = Resource("Forgejo.Organization", { interface ApiOrganization { readonly id: number; readonly username: string; + readonly description?: string; + readonly full_name?: string; + readonly visibility?: string; + readonly website?: string; + readonly email?: string; + readonly location?: string; } /** @@ -207,21 +214,24 @@ export const OrganizationProvider = () => }, ) .pipe( - // A concurrent create wins the race; adopt what is there. - Effect.catchTag("ForgejoConflict", () => - client.request("GET", path(news)), + // A concurrent create wins the race; adopt what is there. The + // admin endpoint declares 403/422 for a duplicate, not 409, so + // the conflict surfaces under those tags. + Effect.catchTag( + ["ForgejoValidationError", "ForgejoForbidden"], + () => client.request("GET", path(news)), ), ); return attributesOf(client, created); } - const updated = yield* client.request( - "PATCH", - path(news), - { - body: settingsOf(news), - }, - ); + // Sync only when the live organization differs from what was declared. + const desired = settingsOf(news); + const updated = matchesDesired(observed, desired) + ? observed + : yield* client.request("PATCH", path(news), { + body: desired, + }); return attributesOf(client, updated); }), delete: Effect.fn(function* ({ olds }) { diff --git a/packages/alchemy/src/Forgejo/Repository.ts b/packages/alchemy/src/Forgejo/Repository.ts index e3dfd44027..d7bf87203f 100644 --- a/packages/alchemy/src/Forgejo/Repository.ts +++ b/packages/alchemy/src/Forgejo/Repository.ts @@ -3,6 +3,7 @@ import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; import { ForgejoCredentials, optional, paginate } from "./Client.ts"; +import { matchesDesired } from "./Settings.ts"; import type * as Forgejo from "./Providers.ts"; /** @@ -67,18 +68,27 @@ export interface RepositoryProps { readonly defaultBranch?: string; /** * Initialize the repository on creation. + * + * Create-only: Forgejo's edit endpoint cannot change it, so altering this + * on an existing repository has no effect and does not replace it. */ readonly autoInit?: boolean; /** * Comma-separated gitignore templates used on creation. + * + * Create-only; see {@link autoInit}. */ readonly gitignores?: string; /** * License template used on creation. + * + * Create-only; see {@link autoInit}. */ readonly license?: string; /** * README template used on creation. + * + * Create-only; see {@link autoInit}. */ readonly readme?: string; /** @@ -87,6 +97,8 @@ export interface RepositoryProps { readonly template?: boolean; /** * Git object format used on creation. + * + * Create-only; see {@link autoInit}. */ readonly objectFormatName?: "sha1" | "sha256"; /** @@ -294,22 +306,6 @@ const settingsOf = (props: RepositoryProps) => ({ template: props.template, }); -/** - * Whether the observed repository already satisfies every managed setting. - * - * Forgejo rejects edits to an archived repository, so re-issuing an unchanged - * `PATCH` on every deploy would make `archived: true` a one-way trap. - */ -const settingsMatch = ( - observed: ApiRepository, - desired: ReturnType, -): boolean => { - const live = observed as unknown as Record; - return Object.entries(desired).every( - ([key, value]) => value === undefined || live[key] === value, - ); -}; - const toAttributes = (repository: ApiRepository): RepositoryAttributes => ({ repoId: repository.id, fullName: repository.full_name, @@ -401,7 +397,7 @@ export const RepositoryProvider = () => // Sync settings against what was observed, not against `olds`, and // skip the call entirely when the live repository already matches. const desired = settingsOf(news); - const updated = settingsMatch(observed, desired) + const updated = matchesDesired(observed, desired) ? observed : yield* client.request( "PATCH", diff --git a/packages/alchemy/src/Forgejo/Secret.ts b/packages/alchemy/src/Forgejo/Secret.ts index 5e611a3a98..29b20ee415 100644 --- a/packages/alchemy/src/Forgejo/Secret.ts +++ b/packages/alchemy/src/Forgejo/Secret.ts @@ -287,20 +287,14 @@ export const SecretProvider = () => { concurrency: 8 }, ); - const userSecrets = yield* ignoreInaccessible( - paginate(client, "/user/actions/secrets"), - [] as readonly ApiSecret[], - ); - - return [ - ...repositorySecrets.flat(), - ...organizationSecrets.flat(), - ...userSecrets.map((secret) => ({ - scope: { kind: "user" as const }, - name: secret.name, - updatedAt: secret.created_at ?? "", - })), - ]; + // User-scoped secrets are deliberately absent: Forgejo exposes + // `/user/actions/secrets/{name}` for PUT and DELETE but has no + // collection endpoint to enumerate them, unlike the repository, + // organization, and user-variable collections. Requesting one would + // 404 on every sweep and be silently swallowed, which reads as if it + // worked. A user-scoped secret therefore has to be destroyed through + // the stack that declared it. + return [...repositorySecrets.flat(), ...organizationSecrets.flat()]; }), reconcile: Effect.fn(function* ({ news }) { const client = yield* ForgejoCredentials; diff --git a/packages/alchemy/src/Forgejo/Settings.ts b/packages/alchemy/src/Forgejo/Settings.ts new file mode 100644 index 0000000000..819667523e --- /dev/null +++ b/packages/alchemy/src/Forgejo/Settings.ts @@ -0,0 +1,46 @@ +/** + * Observed-versus-desired comparison shared by the Forgejo reconcilers. + * + * Not exported from `index.ts` — this is internal scaffolding, not part of + * the provider's public surface. + */ + +const sameArray = ( + observed: readonly unknown[], + desired: readonly unknown[], +): boolean => { + if (observed.length !== desired.length) return false; + // Order is not meaningful for any of the lists Forgejo accepts here + // (permission units, status-check contexts, push whitelists, topics), so + // compare them as sets rather than sequences. + const left = [...observed].map(String).sort(); + const right = [...desired].map(String).sort(); + return left.every((value, index) => value === right[index]); +}; + +/** + * Whether the live resource already satisfies every managed setting. + * + * A `undefined` entry in `desired` means the prop was omitted, which leaves + * that setting unmanaged rather than resetting it — so it is skipped. Every + * other entry must equal what was observed, otherwise the caller issues its + * update call. + * + * Skipping a matching update is not just an optimization: Forgejo rejects + * edits to an archived repository, so re-sending an unchanged payload on + * every deploy would make `archived: true` a permanent deploy failure. + */ +export const matchesDesired = ( + observed: unknown, + desired: Readonly>, +): boolean => { + const live = observed as Record; + return Object.entries(desired).every(([key, value]) => { + if (value === undefined) return true; + const current = live[key]; + if (Array.isArray(value)) { + return Array.isArray(current) && sameArray(current, value); + } + return current === value; + }); +}; diff --git a/packages/alchemy/src/Forgejo/Team.ts b/packages/alchemy/src/Forgejo/Team.ts index c90cae848f..4dacec7a1a 100644 --- a/packages/alchemy/src/Forgejo/Team.ts +++ b/packages/alchemy/src/Forgejo/Team.ts @@ -9,6 +9,7 @@ import { paginate, } from "./Client.ts"; import { listAccessibleOrganizations } from "./Lists.ts"; +import { matchesDesired } from "./Settings.ts"; import type * as Forgejo from "./Providers.ts"; /** @@ -26,7 +27,7 @@ export interface TeamProps { /** * Repository permission. */ - readonly permission?: string; + readonly permission?: "read" | "write" | "admin"; /** * Description. */ @@ -105,6 +106,11 @@ export const Team = Resource("Forgejo.Team"); interface ApiTeam { readonly id: number; readonly name: string; + readonly description?: string; + readonly permission?: string; + readonly includes_all_repositories?: boolean; + readonly can_create_org_repo?: boolean; + readonly units?: readonly string[]; } const collection = (props: Pick) => @@ -201,13 +207,13 @@ export const TeamProvider = () => return attributesOf(created); } - const updated = yield* client.request( - "PATCH", - path(observed.id), - { - body: bodyOf(news), - }, - ); + // Sync only when the live team differs from what was declared. + const desired = bodyOf(news); + const updated = matchesDesired(observed, desired) + ? observed + : yield* client.request("PATCH", path(observed.id), { + body: desired, + }); return attributesOf(updated); }), delete: Effect.fn(function* ({ output }) { diff --git a/packages/alchemy/src/Forgejo/Webhook.ts b/packages/alchemy/src/Forgejo/Webhook.ts index df231d7e6a..81150d80e4 100644 --- a/packages/alchemy/src/Forgejo/Webhook.ts +++ b/packages/alchemy/src/Forgejo/Webhook.ts @@ -34,6 +34,10 @@ export interface WebhookProps { readonly events?: readonly string[]; /** * Secret used to sign webhook deliveries. + * + * Forgejo only overwrites the stored secret when the field is present in + * the request, so removing this prop leaves the previously-set secret in + * place rather than clearing it. Set it to an empty string to clear. */ readonly secret?: Redacted.Redacted; /** @@ -50,6 +54,8 @@ export interface WebhookProps { readonly branchFilter?: string; /** * Optional Authorization header sent with deliveries. + * + * Cannot be cleared by removing the prop; see {@link secret}. */ readonly authorizationHeader?: Redacted.Redacted; } @@ -197,7 +203,6 @@ const observe = Effect.fn(function* ( }); const bodyOf = (props: WebhookProps) => ({ - type: "forgejo", active: props.active ?? true, events: props.events ?? [...DEFAULT_EVENTS], branch_filter: props.branchFilter, @@ -264,10 +269,16 @@ export const WebhookProvider = () => // re-run after a failed state write both converge onto one hook. const observed = yield* observe(news, output?.webhookId); + // `CreateHookOption` carries `type`; `EditHookOption` does not. const hook = yield* client.request( observed === undefined ? "POST" : "PATCH", observed === undefined ? path : `${path}/${observed.id}`, - { body: bodyOf(news) }, + { + body: + observed === undefined + ? { type: "forgejo", ...bodyOf(news) } + : bodyOf(news), + }, ); return attributesOf(news, hook); }), diff --git a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts index ba089ecec9..a0cf539aa2 100644 --- a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts +++ b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts @@ -411,7 +411,9 @@ test.provider("adopts a team that already exists by name", (stack) => // creating a duplicate team. expect(output).toMatchObject({ teamId: 7 }); expect(server.count("POST", "/orgs/acme/teams")).toBe(0); - expect(server.count("PATCH", "/teams/7")).toBe(1); + // The adopted team already matches what was declared, so adoption issues + // no write at all. + expect(server.count("PATCH", "/teams/7")).toBe(0); }), ); diff --git a/packages/alchemy/test/Forgejo/ResourceSchemas.test.ts b/packages/alchemy/test/Forgejo/ResourceSchemas.test.ts index 2eede87d15..ff80751a96 100644 --- a/packages/alchemy/test/Forgejo/ResourceSchemas.test.ts +++ b/packages/alchemy/test/Forgejo/ResourceSchemas.test.ts @@ -405,8 +405,9 @@ test.provider("uses the webhook create and edit schemas", (stack) => ({ method, pathname }) => method === "PATCH" && pathname.endsWith("/hooks/11"), ); + // `CreateHookOption` carries `type`; `EditHookOption` does not, so the + // edit body must not send it. expect(edit?.body).toEqual({ - type: "forgejo", active: true, events: ["push"], config: { diff --git a/website/src/content/docs/forgejo/setup.mdx b/website/src/content/docs/forgejo/setup.mdx index 1c77d29e61..4dfc4b1eda 100644 --- a/website/src/content/docs/forgejo/setup.mdx +++ b/website/src/content/docs/forgejo/setup.mdx @@ -122,6 +122,7 @@ stack code can branch on it without inspecting HTTP status codes: | `ForgejoServerError` | Forgejo returned 5xx | | `ForgejoRequestError` | Any other unsuccessful status | | `ForgejoTransportError` | Connection failure or undecodable body | +| `ForgejoPaginationLimit` | A list ran past the page cap, so enumeration would be incomplete | ## Next steps From fc39357fde48a752de64ec9c2e5a65de5bc425f2 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 31 Aug 2026 23:59:12 -0600 Subject: [PATCH 6/9] fix(forgejo): keep a non-admin failure from reading as a missing org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/alchemy/src/Forgejo/Organization.ts | 18 ++++++++++++- .../test/Forgejo/ProviderLifecycles.test.ts | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/alchemy/src/Forgejo/Organization.ts b/packages/alchemy/src/Forgejo/Organization.ts index 68ede0b4a1..176f837a6d 100644 --- a/packages/alchemy/src/Forgejo/Organization.ts +++ b/packages/alchemy/src/Forgejo/Organization.ts @@ -217,9 +217,25 @@ export const OrganizationProvider = () => // A concurrent create wins the race; adopt what is there. The // admin endpoint declares 403/422 for a duplicate, not 409, so // the conflict surfaces under those tags. + // + // Those tags also cover genuine failures — a non-administrator + // credential gets the same 403 — so recover only if the + // organization actually turned up. Otherwise re-fail with the + // original error: reporting "not found" for what is really "your + // token is not an administrator" replaces the clearest diagnosis + // with the most misleading one. Effect.catchTag( ["ForgejoValidationError", "ForgejoForbidden"], - () => client.request("GET", path(news)), + (cause) => + optional( + client.request("GET", path(news)), + ).pipe( + Effect.flatMap((existing) => + existing === undefined + ? Effect.fail(cause) + : Effect.succeed(existing), + ), + ), ), ); return attributesOf(client, created); diff --git a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts index a0cf539aa2..42e168d68d 100644 --- a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts +++ b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts @@ -11,6 +11,7 @@ import * as Test from "@/Test/Alchemy"; import { expect } from "alchemy-test"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; import { json, jsonList, @@ -52,6 +53,8 @@ const members = new Set(); const labels = new Map(); const rules = new Map(); let nextId = 1; +/** Simulates a credential that is not an instance administrator. */ +let forbidAdminOrgs = false; const reset = () => { organizations.clear(); @@ -60,6 +63,7 @@ const reset = () => { labels.clear(); rules.clear(); nextId = 1; + forbidAdminOrgs = false; server.reset(); }; @@ -85,6 +89,7 @@ const server = mockForgejo((request) => { const adminOrgs = path.match(/^\/admin\/users\/([^/]+)\/orgs$/); if (method === "POST" && adminOrgs !== null) { + if (forbidAdminOrgs) return status(403, "must be an administrator"); const username = String(payload?.username); const organization: StoredOrganization = { id: nextId++, @@ -311,6 +316,28 @@ test.provider("creates and then updates an organization", (stack) => }), ); +test.provider( + "surfaces a non-admin credential rather than reporting the org missing", + (stack) => + Effect.gen(function* () { + reset(); + // Forgejo answers a non-administrator with the same 403 it uses for a + // duplicate, so the create-race recovery must not swallow it: with no + // organization to fall back to, the permissions error has to survive. + forbidAdminOrgs = true; + + const result = yield* Effect.result( + stack.deploy( + Organization("Acme", { owner: "alice", username: "acme" }), + ), + ); + + forbidAdminOrgs = false; + expect(Result.isFailure(result)).toBe(true); + expect(JSON.stringify(result)).toContain("ForgejoForbidden"); + }), +); + test.provider("adopts an organization that already exists", (stack) => Effect.gen(function* () { reset(); From f0165f09239dcc2e4f468342d52581506cca7453 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 1 Sep 2026 00:56:38 -0600 Subject: [PATCH 7/9] fix(forgejo): unblock destroy on a racing org delete, and name failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../alchemy/src/Forgejo/BranchProtection.ts | 16 ++- packages/alchemy/src/Forgejo/Client.ts | 98 ++++++++++++-- packages/alchemy/src/Forgejo/Organization.ts | 14 +- packages/alchemy/src/Forgejo/Settings.ts | 8 +- packages/alchemy/test/Forgejo/Client.test.ts | 38 ++++++ .../test/Forgejo/ProviderLifecycles.test.ts | 121 ++++++++++++++++++ 6 files changed, 281 insertions(+), 14 deletions(-) diff --git a/packages/alchemy/src/Forgejo/BranchProtection.ts b/packages/alchemy/src/Forgejo/BranchProtection.ts index 1bb721c504..0d9dc67342 100644 --- a/packages/alchemy/src/Forgejo/BranchProtection.ts +++ b/packages/alchemy/src/Forgejo/BranchProtection.ts @@ -79,6 +79,10 @@ export interface BranchProtectionProps { /** * Whether the push whitelist is enforced. * + * Forgejo only keeps this on while {@link enablePush} is on too — with + * direct pushes disabled there is nothing for a whitelist to permit, so + * asking for both records this as off, matching what the instance stores. + * * @default true when a push whitelist is set, otherwise left unmanaged */ readonly enablePushWhitelist?: boolean; @@ -206,6 +210,14 @@ const bodyOf = (props: BranchProtectionProps) => { (props.pushWhitelistUsernames?.length ?? 0) > 0 || (props.pushWhitelistTeams?.length ?? 0) > 0; const whitelistDefault = hasPushWhitelist ? true : undefined; + const enablePush = props.enablePush ?? whitelistDefault; + // Forgejo stores `enable_push_whitelist` as false whenever `enable_push` is + // false, on create and on edit alike. Asking for a `true` it will not keep + // never converges: every reconcile observes false, sees drift, and re-issues + // the same edit. Apply the server's own rule here instead. An omitted prop + // is `undefined`, which `&&` passes through, so it stays unmanaged. + const enablePushWhitelist = + (props.enablePushWhitelist ?? whitelistDefault) && enablePush === true; return { rule_name: props.ruleName, required_approvals: props.requiredApprovals, @@ -217,8 +229,8 @@ const bodyOf = (props: BranchProtectionProps) => { apply_to_admins: props.applyToAdmins, push_whitelist_usernames: props.pushWhitelistUsernames, push_whitelist_teams: props.pushWhitelistTeams, - enable_push: props.enablePush ?? whitelistDefault, - enable_push_whitelist: props.enablePushWhitelist ?? whitelistDefault, + enable_push: enablePush, + enable_push_whitelist: enablePushWhitelist, }; }; diff --git a/packages/alchemy/src/Forgejo/Client.ts b/packages/alchemy/src/Forgejo/Client.ts index 37637dfe94..9d0aeceb4e 100644 --- a/packages/alchemy/src/Forgejo/Client.ts +++ b/packages/alchemy/src/Forgejo/Client.ts @@ -46,20 +46,42 @@ export interface ForgejoErrorContext { readonly body: string; } +/** + * Render the request that failed, plus whatever Forgejo said about it. + * + * Nothing else gives these errors a message, so without this a failed + * `alchemy destroy` reports `ForgejoServerError:` and nothing more. + */ +const describe = ( + error: ForgejoErrorContext & { readonly status?: number }, +): string => { + const status = error.status === undefined ? "" : ` -> ${error.status}`; + const body = error.body === "" ? "" : `: ${error.body}`; + return `${error.method} ${error.path}${status}${body}`; +}; + /** * The requested resource does not exist. Lifecycle operations treat this as a * successful no-op when deleting, and as "needs creating" when reconciling. */ export class ForgejoNotFound extends Data.TaggedError( "ForgejoNotFound", -) {} +) { + override get message(): string { + return describe(this); + } +} /** * The credential is missing, malformed, or expired. */ export class ForgejoUnauthorized extends Data.TaggedError( "ForgejoUnauthorized", -) {} +) { + override get message(): string { + return describe(this); + } +} /** * The credential is valid but lacks permission for this operation. Forgejo @@ -67,7 +89,11 @@ export class ForgejoUnauthorized extends Data.TaggedError( */ export class ForgejoForbidden extends Data.TaggedError( "ForgejoForbidden", -) {} +) { + override get message(): string { + return describe(this); + } +} /** * The resource already exists, or the request conflicts with current state. @@ -75,14 +101,45 @@ export class ForgejoForbidden extends Data.TaggedError( */ export class ForgejoConflict extends Data.TaggedError( "ForgejoConflict", -) {} +) { + override get message(): string { + return describe(this); + } +} /** * Forgejo rejected the request payload. */ export class ForgejoValidationError extends Data.TaggedError( "ForgejoValidationError", -) {} +) { + override get message(): string { + return describe(this); + } +} + +/** + * The operation cannot proceed until something that depends on the target is + * gone — Forgejo's equivalent of a dependency violation. + * + * Forgejo answers it with a `500` rather than a conflict status, so it is + * recognized by response body — see {@link DEPENDENCY_VIOLATION}. Lifecycle + * operations retry it rather than failing the deploy. + */ +export class ForgejoDependencyViolation extends Data.TaggedError( + "ForgejoDependencyViolation", +)< + ForgejoErrorContext & { + /** + * HTTP status returned by Forgejo. + */ + readonly status: number; + } +> { + override get message(): string { + return describe(this); + } +} /** * Forgejo returned a 5xx response. @@ -94,7 +151,11 @@ export class ForgejoServerError extends Data.TaggedError("ForgejoServerError")< */ readonly status: number; } -> {} +> { + override get message(): string { + return describe(this); + } +} /** * Forgejo returned an unsuccessful status that maps to no more specific tag. @@ -108,7 +169,11 @@ export class ForgejoRequestError extends Data.TaggedError( */ readonly status: number; } -> {} +> { + override get message(): string { + return describe(this); + } +} /** * The request never produced a usable response: a connection failure, an @@ -129,7 +194,11 @@ export class ForgejoTransportError extends Data.TaggedError( * Underlying transport or decoding failure. */ readonly cause: unknown; -}> {} +}> { + override get message(): string { + return `${this.method} ${this.path}: ${String(this.cause)}`; + } +} /** * A list endpoint returned more pages than {@link MAX_PAGES} allows. @@ -170,6 +239,7 @@ export type ForgejoError = | ForgejoForbidden | ForgejoConflict | ForgejoValidationError + | ForgejoDependencyViolation | ForgejoServerError | ForgejoRequestError | ForgejoTransportError @@ -234,6 +304,16 @@ export class ForgejoCredentials extends Context.Service< ForgejoClient >()("Forgejo::Credentials") {} +/** + * Body Forgejo returns for a 5xx that is really a dependency violation, and + * the only signal separating one from a genuine server fault. + * + * Deleting an organization that still owns repositories answers `500` with + * `{"message":"user still has ownership of repositories [uid: 16]"}` + * (observed on Forgejo 16.0.3). + */ +const DEPENDENCY_VIOLATION = /still has ownership of repositories/i; + const statusError = ( status: number, context: ForgejoErrorContext, @@ -243,6 +323,8 @@ const statusError = ( if (status === 404) return new ForgejoNotFound(context); if (status === 409) return new ForgejoConflict(context); if (status === 422) return new ForgejoValidationError(context); + if (status >= 500 && DEPENDENCY_VIOLATION.test(context.body)) + return new ForgejoDependencyViolation({ ...context, status }); if (status >= 500) return new ForgejoServerError({ ...context, status }); return new ForgejoRequestError({ ...context, status }); }; diff --git a/packages/alchemy/src/Forgejo/Organization.ts b/packages/alchemy/src/Forgejo/Organization.ts index 176f837a6d..248f1ca290 100644 --- a/packages/alchemy/src/Forgejo/Organization.ts +++ b/packages/alchemy/src/Forgejo/Organization.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; @@ -252,6 +253,17 @@ export const OrganizationProvider = () => }), delete: Effect.fn(function* ({ olds }) { const client = yield* ForgejoCredentials; - yield* optional(client.request("DELETE", path(olds))); + // Forgejo refuses to delete an organization that still owns + // repositories, and the engine deletes independent resources + // concurrently — so an organization that loses the race against its own + // repositories fails the destroy outright, succeeding only on a re-run. + // Retry until the repositories are gone. + yield* optional(client.request("DELETE", path(olds))).pipe( + Effect.retry({ + while: (error) => error._tag === "ForgejoDependencyViolation", + schedule: Schedule.exponential("200 millis"), + times: 6, + }), + ); }), }); diff --git a/packages/alchemy/src/Forgejo/Settings.ts b/packages/alchemy/src/Forgejo/Settings.ts index 819667523e..e6f79daff5 100644 --- a/packages/alchemy/src/Forgejo/Settings.ts +++ b/packages/alchemy/src/Forgejo/Settings.ts @@ -26,9 +26,11 @@ const sameArray = ( * other entry must equal what was observed, otherwise the caller issues its * update call. * - * Skipping a matching update is not just an optimization: Forgejo rejects - * edits to an archived repository, so re-sending an unchanged payload on - * every deploy would make `archived: true` a permanent deploy failure. + * Skipping a matching update keeps a converged resource quiet: a no-op `PATCH` + * still counts as a write, bumping the resource's timestamps and reporting an + * update on a deploy that had nothing to do. It is not a guard against + * archived repositories — contrary to what Gitea's model suggests, Forgejo + * 16.0.3 accepts and applies a `PATCH` to a repository with `archived: true`. */ export const matchesDesired = ( observed: unknown, diff --git a/packages/alchemy/test/Forgejo/Client.test.ts b/packages/alchemy/test/Forgejo/Client.test.ts index 0b038a45ff..7729871519 100644 --- a/packages/alchemy/test/Forgejo/Client.test.ts +++ b/packages/alchemy/test/Forgejo/Client.test.ts @@ -1,5 +1,6 @@ import { ForgejoCredentials, + ForgejoDependencyViolation, ForgejoForbidden, ForgejoNotFound, ForgejoServerError, @@ -133,6 +134,43 @@ describe("Forgejo client", () => { } }); + test("maps a 5xx that is really a dependency violation to its own tag", async () => { + // Forgejo 16.0.3 refuses to delete an organization that still owns + // repositories with this 500 — there is no conflict status to key off, so + // the body is the only signal. + const server = mockForgejo(() => + status( + 500, + '{"message":"user still has ownership of repositories [uid: 16]"}', + ), + ); + + const result = await runResult(server.layer, (client) => + client.request("DELETE", "/orgs/acme"), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(ForgejoDependencyViolation); + expect(result.failure).toMatchObject({ status: 500 }); + } + }); + + test("names the failed request in the error message", async () => { + // None of these errors carry a message field, so without an explicit + // getter a failed destroy reports a bare tag and nothing else. + const server = mockForgejo(() => status(500, "boom")); + + const result = await runResult(server.layer, (client) => + client.request("DELETE", "/orgs/acme"), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure.message).toBe("DELETE /orgs/acme -> 500: boom"); + } + }); + test("maps a transport failure to a ForgejoTransportError tag", async () => { const failing = Layer.succeed( HttpClient.HttpClient, diff --git a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts index 42e168d68d..1b490b7d83 100644 --- a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts +++ b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts @@ -55,6 +55,8 @@ const rules = new Map(); let nextId = 1; /** Simulates a credential that is not an instance administrator. */ let forbidAdminOrgs = false; +/** Simulates repositories the organization still owns on its first deletes. */ +let organizationDeletesBlocked = 0; const reset = () => { organizations.clear(); @@ -64,6 +66,7 @@ const reset = () => { rules.clear(); nextId = 1; forbidAdminOrgs = false; + organizationDeletesBlocked = 0; server.reset(); }; @@ -113,6 +116,15 @@ const server = mockForgejo((request) => { return json(existing); } if (method === "DELETE") { + if (organizationDeletesBlocked > 0) { + organizationDeletesBlocked -= 1; + // Verbatim shape of the failure Forgejo 16.0.3 returns while the + // organization still owns repositories: a 500, not a conflict status. + return status( + 500, + '{"message":"user still has ownership of repositories [uid: 16]"}', + ); + } organizations.delete(org[1]!); return noContent(); } @@ -388,6 +400,30 @@ test.provider("deletes an organization when removal is opted in", (stack) => }), ); +test.provider( + "retries an organization delete while it still owns repositories", + (stack) => + Effect.gen(function* () { + reset(); + + yield* stack.deploy( + Organization("Acme", { owner: "alice", username: "acme" }).pipe( + destroy(), + ), + ); + server.reset(); + + // The engine deletes independent resources concurrently, so an + // organization races the repositories it owns. Failing out on the first + // rejection makes destroy succeed only on a re-run. + organizationDeletesBlocked = 2; + yield* stack.destroy(); + + expect(server.count("DELETE", "/orgs/acme")).toBe(3); + expect(organizations.has("acme")).toBe(false); + }), +); + test.provider("creates, updates and deletes a team", (stack) => Effect.gen(function* () { reset(); @@ -577,3 +613,88 @@ test.provider( expect(rules.size).toBe(0); }), ); + +test.provider( + "enables push enforcement when a whitelist is declared", + (stack) => + Effect.gen(function* () { + reset(); + + yield* stack.deploy( + BranchProtection("Main", { + owner: "alice", + repository: "alchemy", + ruleName: "main", + pushWhitelistUsernames: ["release-bot"], + }), + ); + + // A whitelist Forgejo is not enforcing silently permits everyone. + expect( + server.find("POST", "/repos/alice/alchemy/branch_protections")?.body, + ).toMatchObject({ + push_whitelist_usernames: ["release-bot"], + enable_push: true, + enable_push_whitelist: true, + }); + }), +); + +test.provider("leaves the push flags alone when none are declared", (stack) => + Effect.gen(function* () { + reset(); + + yield* stack.deploy( + BranchProtection("Main", { + owner: "alice", + repository: "alchemy", + ruleName: "main", + }), + ); + + // Omitted props stay unmanaged, so neither flag may reach the wire at + // all. Asserted as absence rather than a falsy value: `toMatchObject` + // ignores extra keys, so emitting `enable_push_whitelist: false` here + // would start managing a setting nobody declared and still pass every + // other test in this file. + const body = server.find( + "POST", + "/repos/alice/alchemy/branch_protections", + )?.body; + // Pins the rule down first — absence assertions hold vacuously on the + // `undefined` an unsent create would leave behind. + expect(body).toMatchObject({ rule_name: "main" }); + expect(body).not.toHaveProperty("enable_push"); + expect(body).not.toHaveProperty("enable_push_whitelist"); + }), +); + +test.provider( + "records a push whitelist as unenforced when direct pushes are off", + (stack) => + Effect.gen(function* () { + reset(); + + yield* stack.deploy( + BranchProtection("Main", { + owner: "alice", + repository: "alchemy", + ruleName: "main", + pushWhitelistUsernames: ["release-bot"], + enablePush: false, + }), + ); + + // Forgejo stores `enable_push_whitelist: false` whenever `enable_push` + // is false. Asking for a `true` it will not keep leaves a desired state + // the instance cannot hold, so every later deploy would observe drift + // and re-issue the same edit forever. + expect( + server.find("POST", "/repos/alice/alchemy/branch_protections")?.body, + ).toMatchObject({ + push_whitelist_usernames: ["release-bot"], + enable_push: false, + enable_push_whitelist: false, + }); + }), +); From 967f67e2eeb381fae1523d6565799ffa3f3437ef Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 1 Sep 2026 02:22:00 -0600 Subject: [PATCH 8/9] fix(forgejo): refuse unrecoverable tokens and owner transfers, split hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/alchemy/src/Forgejo/ApiToken.ts | 56 ++++++++++++++++++- packages/alchemy/src/Forgejo/Lists.ts | 18 ++++++ packages/alchemy/src/Forgejo/Organization.ts | 54 +++++++++++++++++- packages/alchemy/src/Forgejo/Webhook.ts | 53 ++++++++++++++---- .../alchemy/test/Forgejo/ApiToken.test.ts | 38 +++++++++++++ .../test/Forgejo/ProviderLifecycles.test.ts | 34 +++++++++++ packages/alchemy/test/Forgejo/Webhook.test.ts | 50 +++++++++++++++++ 7 files changed, 286 insertions(+), 17 deletions(-) diff --git a/packages/alchemy/src/Forgejo/ApiToken.ts b/packages/alchemy/src/Forgejo/ApiToken.ts index e432970ded..1f3ecb1888 100644 --- a/packages/alchemy/src/Forgejo/ApiToken.ts +++ b/packages/alchemy/src/Forgejo/ApiToken.ts @@ -157,6 +157,40 @@ const listTokens = Effect.fn(function* (username: string) { return yield* paginate(client, tokensPath(username)); }); +/** + * Raised when a token of this name already exists but no state row does. + * + * Forgejo returns a token's secret exactly once, at creation, so a token + * whose state row was lost cannot be adopted — the secret is unrecoverable, + * and Forgejo refuses a second token of the same name. Creating blindly would + * fail on every subsequent deploy with a duplicate-name rejection that says + * nothing about how to recover, so the situation is named instead: it needs + * an operator to decide whether the live token is still in use. + */ +export class UnrecoverableApiToken extends Data.TaggedError( + "UnrecoverableApiToken", +)<{ + /** + * User the token belongs to. + */ + readonly username: string; + /** + * Name of the token that already exists. + */ + readonly name: string; + /** + * Numeric ID of the existing token. + */ + readonly tokenId: number; +}> { + /** + * Human-readable description of the unrecoverable token, naming the way out. + */ + override get message(): string { + return `Forgejo already has an API token named '${this.name}' for user '${this.username}' (id ${this.tokenId}), but no state records it. Its secret was only returned when it was created and cannot be read back. Delete that token if it is no longer in use and deploy again, or give this resource a different name.`; + } +} + /** * Raised when Forgejo accepts a token creation but omits the generated * secret, which can never be recovered from a later read. @@ -225,9 +259,25 @@ export const ApiTokenProvider = () => reconcile: Effect.fn(function* ({ news, output }) { // Observe: a token we already generated is unchanged and its plaintext // is unrecoverable, so an existing one is kept as-is. - if (output !== undefined) { - const tokens = yield* listTokens(news.username); - if (tokens.some((token) => token.id === output.tokenId)) return output; + const tokens = yield* listTokens(news.username); + if ( + output !== undefined && + tokens.some((token) => token.id === output.tokenId) + ) { + return output; + } + + // Without a state row, a token already holding this name is not ours to + // replace and not possible to adopt — its secret is gone. Creating here + // would be rejected for the duplicate name on this deploy and every one + // after it, so say what actually happened instead. + const conflict = tokens.find((token) => token.name === news.name); + if (conflict !== undefined) { + return yield* new UnrecoverableApiToken({ + username: news.username, + name: news.name, + tokenId: conflict.id, + }); } const client = yield* ForgejoCredentials; diff --git a/packages/alchemy/src/Forgejo/Lists.ts b/packages/alchemy/src/Forgejo/Lists.ts index 8d7dc342a5..929d296c48 100644 --- a/packages/alchemy/src/Forgejo/Lists.ts +++ b/packages/alchemy/src/Forgejo/Lists.ts @@ -32,6 +32,12 @@ export interface ListedOrganization { /** * List every repository accessible to the provider credential, walking all * pages. + * + * Despite the endpoint's "repos that the authenticated user owns" summary, + * Forgejo 16.0.3 returns organization-owned repositories here too, as long as + * the credential is a member of the organization — verified against a live + * instance. The bound is membership, not ownership; see + * {@link listAccessibleOrganizations} for what that leaves out. */ export const listAccessibleRepositories = Effect.fn(function* () { const client = yield* ForgejoCredentials; @@ -41,6 +47,18 @@ export const listAccessibleRepositories = Effect.fn(function* () { /** * List every organization visible to the provider credential, walking all * pages. + * + * Scoped deliberately to the credential's own memberships. {@link Organization} + * takes an `owner` distinct from the credential, so an administrator can + * create an organization it is not a member of, and that organization — plus + * its repositories, labels, hooks, and Actions state — is invisible here. + * + * `/admin/orgs` would close that gap and is not used, because these lists are + * what `alchemy unsafe nuke` deletes: enumerating every organization on the + * instance would put every unrelated user's organizations in a nuke's path. + * Forgejo has no resource tags, so there is no way to narrow such a list back + * down to what Alchemy created. Under-reporting a resource is recoverable; + * deleting a stranger's organization is not. */ export const listAccessibleOrganizations = Effect.fn(function* () { const client = yield* ForgejoCredentials; diff --git a/packages/alchemy/src/Forgejo/Organization.ts b/packages/alchemy/src/Forgejo/Organization.ts index 248f1ca290..1833d69b03 100644 --- a/packages/alchemy/src/Forgejo/Organization.ts +++ b/packages/alchemy/src/Forgejo/Organization.ts @@ -1,3 +1,4 @@ +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import { isResolved } from "../Diff.ts"; @@ -120,6 +121,39 @@ export const Organization = Resource("Forgejo.Organization", { defaultRemovalPolicy: "retain", }); +/** + * Raised when a deployed organization's `owner` is changed. + * + * Forgejo creates an organization under a user account but offers no endpoint + * to hand it to another one; ownership moves by editing the Owners team. The + * login identifies the organization globally, so a changed `owner` would + * otherwise resolve to the same organization and converge as though the + * transfer had happened. + */ +export class UnsupportedOwnerChange extends Data.TaggedError( + "UnsupportedOwnerChange", +)<{ + /** + * Login of the organization whose owner was changed. + */ + readonly username: string; + /** + * Owner recorded in state. + */ + readonly from: string; + /** + * Owner the resource now declares. + */ + readonly to: string; +}> { + /** + * Human-readable description of the unsupported transfer, naming the way out. + */ + override get message(): string { + return `Organization '${this.username}' is recorded as owned by '${this.from}' and cannot be transferred to '${this.to}': Forgejo has no ownership-transfer API. Change the organization's Owners team membership in Forgejo and restore the original 'owner', or remove and re-create the organization under the new owner.`; + } +} + interface ApiOrganization { readonly id: number; readonly username: string; @@ -175,11 +209,15 @@ const observe = Effect.fn(function* ( export const OrganizationProvider = () => Provider.succeed(Organization, { stables: ["organizationId"], + // Only the login identifies a different organization. `owner` names the + // account the create was issued under, and Forgejo exposes no ownership + // transfer, so replacing on it would tear down and re-adopt the very same + // organization — see the guard in `reconcile`. diff: ({ news, olds }) => Effect.succeed( isResolved(news) && olds !== undefined && - (news.owner !== olds.owner || news.username !== olds.username) + news.username !== olds.username ? { action: "replace" as const } : undefined, ), @@ -197,9 +235,21 @@ export const OrganizationProvider = () => ? undefined : attributesOf(client, observed); }), - reconcile: Effect.fn(function* ({ news }) { + reconcile: Effect.fn(function* ({ news, olds }) { const client = yield* ForgejoCredentials; + // An organization's login is globally unique, so a changed `owner` still + // resolves to the same organization. Forgejo has no ownership-transfer + // endpoint, so there is no way to honor the change: converging silently + // would report success for something that never happened. + if (olds !== undefined && olds.owner !== news.owner) { + return yield* new UnsupportedOwnerChange({ + username: news.username, + from: olds.owner, + to: news.owner, + }); + } + // Observe: live state decides whether this is a create or a settings // sync, so an adopted organization converges the same way as one we // provisioned ourselves. diff --git a/packages/alchemy/src/Forgejo/Webhook.ts b/packages/alchemy/src/Forgejo/Webhook.ts index 81150d80e4..b1fc1dd2d9 100644 --- a/packages/alchemy/src/Forgejo/Webhook.ts +++ b/packages/alchemy/src/Forgejo/Webhook.ts @@ -133,6 +133,8 @@ interface ApiHook { readonly id: number; readonly url: string; readonly updated_at?: string; + readonly active?: boolean; + readonly branch_filter?: string; readonly config?: Readonly>; readonly events?: readonly string[]; } @@ -169,21 +171,50 @@ const sameEvents = ( }; /** - * Locate the live hook, by ID when one is already known and otherwise by - * delivery URL *and* event set within the repository. + * Whether a live hook is the one this resource declares. + * + * Every field Forgejo lets a hook differ by has to take part. Forgejo accepts + * several hooks on one URL — verified on 16.0.3, which happily created two + * with the same URL *and* the same events — so any field left out of this + * comparison is a field two `Webhook` resources may legitimately differ by + * while both match the same live hook. They would then share one hook, and + * each deploy would overwrite the other's configuration. + * + * Compared against the same defaults {@link bodyOf} sends, so a hook this + * provider just created matches the props that created it. + */ +const matchesIdentity = ( + hook: ApiHook, + props: Pick< + WebhookProps, + "url" | "events" | "active" | "branchFilter" | "contentType" + >, +): boolean => + urlOf(hook) === props.url && + sameEvents(hook, props.events) && + (hook.active ?? true) === (props.active ?? true) && + (hook.branch_filter ?? "") === (props.branchFilter ?? "") && + (hook.config?.content_type ?? "json") === (props.contentType ?? "json"); + +/** + * Locate the live hook, by ID when one is already known and otherwise by its + * full declared identity within the repository. * * Forgejo happily accepts several hooks pointing at the same URL, so creating * unconditionally would turn a create whose state write failed into a * duplicate on every retry. Matching an existing hook adopts it instead. - * - * The event set is part of the match because the URL alone is not unique: two - * `Webhook` resources may legitimately target one URL in one repository with - * different events (say `push` versus `pull_request`), and matching on URL - * alone would collapse them onto a single hook, each deploy overwriting the - * other's configuration. */ const observe = Effect.fn(function* ( - props: Pick, + props: Pick< + WebhookProps, + | "owner" + | "repository" + | "url" + | "events" + | "active" + | "branchFilter" + | "contentType" + >, webhookId: number | undefined, ) { const client = yield* ForgejoCredentials; @@ -197,9 +228,7 @@ const observe = Effect.fn(function* ( paginate(client, hooksPath(props)), [] as readonly ApiHook[], ); - return hooks.find( - (hook) => urlOf(hook) === props.url && sameEvents(hook, props.events), - ); + return hooks.find((hook) => matchesIdentity(hook, props)); }); const bodyOf = (props: WebhookProps) => ({ diff --git a/packages/alchemy/test/Forgejo/ApiToken.test.ts b/packages/alchemy/test/Forgejo/ApiToken.test.ts index c81b5082d9..ee00c49790 100644 --- a/packages/alchemy/test/Forgejo/ApiToken.test.ts +++ b/packages/alchemy/test/Forgejo/ApiToken.test.ts @@ -4,6 +4,7 @@ import { expect } from "alchemy-test"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; import { json, jsonList, @@ -119,3 +120,40 @@ test.provider( expect(tokens.size).toBe(0); }), ); + +test.provider("refuses to mint a second token of the same name", (stack) => + Effect.gen(function* () { + tokens.clear(); + nextId = 1; + server.reset(); + // A create that succeeded against Forgejo but whose state write never + // landed: the token is live, and its secret went out in the create + // response that was lost. It cannot be read back and Forgejo will not + // issue a second token under the name, so this must be reported rather + // than retried into a duplicate-name rejection on every future deploy. + tokens.set(7, { + id: 7, + name: "automation", + scopes: ["read:repository"], + token_last_eight: "existing", + created_at: "2026-01-01T00:00:00Z", + }); + nextId = 8; + + const result = yield* Effect.result( + stack.deploy( + ApiToken("Automation", { + username: "alice", + name: "automation", + scopes: ["read:repository"], + }), + ), + ); + + expect(Result.isFailure(result)).toBe(true); + expect(JSON.stringify(result)).toContain("UnrecoverableApiToken"); + // The live token is left exactly as found; nothing was created or revoked. + expect(tokens.size).toBe(1); + expect(server.count("POST", "/admin/users/alice/tokens")).toBe(0); + }), +); diff --git a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts index 1b490b7d83..171c5648e3 100644 --- a/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts +++ b/packages/alchemy/test/Forgejo/ProviderLifecycles.test.ts @@ -350,6 +350,40 @@ test.provider( }), ); +test.provider("refuses to transfer an organization to another owner", (stack) => + Effect.gen(function* () { + reset(); + + const created = yield* stack.deploy( + Organization("Acme", { owner: "alice", username: "acme" }).pipe( + destroy(), + ), + ); + server.reset(); + + // An organization's login is globally unique, so the "new" organization a + // replacement would create is the one that already exists. Left as a + // replacement, the deploy adopts it back and reports success for an + // ownership transfer Forgejo has no way to perform — and with removal + // opted in, the old generation's delete then takes out the organization + // the new state points at. + const result = yield* Effect.result( + stack.deploy( + Organization("Acme", { owner: "bob", username: "acme" }).pipe( + destroy(), + ), + ), + ); + + expect(Result.isFailure(result)).toBe(true); + expect(JSON.stringify(result)).toContain("UnsupportedOwnerChange"); + // The organization is untouched: not deleted, not re-created. + expect(organizations.get("acme")?.id).toBe(created.organizationId); + expect(server.count("DELETE", "/orgs/acme")).toBe(0); + expect(server.count("POST", "/admin/users/bob/orgs")).toBe(0); + }), +); + test.provider("adopts an organization that already exists", (stack) => Effect.gen(function* () { reset(); diff --git a/packages/alchemy/test/Forgejo/Webhook.test.ts b/packages/alchemy/test/Forgejo/Webhook.test.ts index 2737173af2..b11d2a912c 100644 --- a/packages/alchemy/test/Forgejo/Webhook.test.ts +++ b/packages/alchemy/test/Forgejo/Webhook.test.ts @@ -15,6 +15,8 @@ interface StoredHook { readonly id: number; config: Record; events: string[]; + active: boolean; + branch_filter: string; } const hooks = new Map(); @@ -32,6 +34,10 @@ const payload = (hook: StoredHook) => ({ updated_at: "2026-01-02T00:00:00Z", config: hook.config, events: hook.events, + // Forgejo reports both on every hook it lists, `branch_filter` as an empty + // string when unset — verified against 16.0.3. + active: hook.active, + branch_filter: hook.branch_filter, }); const server = mockForgejo((request) => { @@ -46,6 +52,8 @@ const server = mockForgejo((request) => { id: nextId++, config: { ...(fields?.config as Record) }, events: [...((fields?.events as string[]) ?? [])], + active: (fields?.active as boolean | undefined) ?? true, + branch_filter: (fields?.branch_filter as string | undefined) ?? "", }; hooks.set(hook.id, hook); return json(payload(hook), 201); @@ -60,6 +68,9 @@ const server = mockForgejo((request) => { if (method === "PATCH") { hook.config = { ...hook.config, ...(fields?.config as object) }; hook.events = [...((fields?.events as string[]) ?? hook.events)]; + hook.active = (fields?.active as boolean | undefined) ?? hook.active; + hook.branch_filter = + (fields?.branch_filter as string | undefined) ?? hook.branch_filter; return json(payload(hook)); } if (method === "DELETE") { @@ -92,6 +103,8 @@ test.provider( id: 1, config: { url: "https://deploy.example/hooks", content_type: "json" }, events: ["push", "pull_request"], + active: true, + branch_filter: "", }); nextId = 2; @@ -145,6 +158,43 @@ test.provider( }), ); +test.provider( + "keeps two hooks on one URL and event set apart when their config differs", + (stack) => + Effect.gen(function* () { + reset(); + + // Forgejo 16.0.3 accepts both of these — same repository, same URL, same + // events, differing only in delivery config. Matching on URL and events + // alone would hand the second resource the first one's hook, leaving one + // live hook behind two state rows, each deploy undoing the other. + yield* stack.deploy( + Effect.gen(function* () { + yield* Webhook("Live", { + owner: "acme", + repository: "api", + url: "https://deploy.example/hooks", + events: ["push"], + }); + yield* Webhook("Staged", { + owner: "acme", + repository: "api", + url: "https://deploy.example/hooks", + events: ["push"], + active: false, + contentType: "form", + }); + }), + ); + + expect(hooks.size).toBe(2); + expect([...hooks.values()].map((hook) => hook.active).sort()).toEqual([ + false, + true, + ]); + }), +); + test.provider("creates, updates and deletes a webhook", (stack) => Effect.gen(function* () { reset(); From 8c673c22e24867f6158746d02ab1912e039aa6de Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 1 Sep 2026 20:31:11 -0600 Subject: [PATCH 9/9] refactor(forgejo): consume the generated distilled SDK 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. --- distilled | 2 +- packages/alchemy/package.json | 1 + packages/alchemy/src/Forgejo/ApiToken.ts | 72 +- .../alchemy/src/Forgejo/BranchProtection.ts | 132 ++-- packages/alchemy/src/Forgejo/Client.ts | 624 ------------------ packages/alchemy/src/Forgejo/Credentials.ts | 181 +++++ packages/alchemy/src/Forgejo/Label.ts | 84 +-- packages/alchemy/src/Forgejo/Lists.ts | 37 +- packages/alchemy/src/Forgejo/Organization.ts | 99 ++- packages/alchemy/src/Forgejo/Pagination.ts | 88 +++ packages/alchemy/src/Forgejo/Providers.ts | 24 +- packages/alchemy/src/Forgejo/Repository.ts | 169 ++--- packages/alchemy/src/Forgejo/Secret.ts | 156 +++-- packages/alchemy/src/Forgejo/Team.ts | 82 +-- packages/alchemy/src/Forgejo/TeamMember.ts | 53 +- packages/alchemy/src/Forgejo/Variable.ts | 242 ++++--- packages/alchemy/src/Forgejo/Webhook.ts | 104 ++- packages/alchemy/src/Forgejo/index.ts | 6 +- .../alchemy/test/Forgejo/AuthProvider.test.ts | 29 +- packages/alchemy/test/Forgejo/Client.test.ts | 341 ---------- .../alchemy/test/Forgejo/Pagination.test.ts | 107 +++ .../test/Forgejo/ProviderLifecycles.test.ts | 2 +- packages/alchemy/test/Forgejo/Sdk.test.ts | 195 ++++++ packages/alchemy/test/Forgejo/support/mock.ts | 5 + packages/alchemy/tsconfig.json | 3 + pnpm-lock.yaml | 22 + tsconfig.json | 10 +- website/src/content/docs/forgejo/index.mdx | 7 +- website/src/content/docs/forgejo/setup.mdx | 33 +- 29 files changed, 1259 insertions(+), 1651 deletions(-) delete mode 100644 packages/alchemy/src/Forgejo/Client.ts create mode 100644 packages/alchemy/src/Forgejo/Credentials.ts create mode 100644 packages/alchemy/src/Forgejo/Pagination.ts delete mode 100644 packages/alchemy/test/Forgejo/Client.test.ts create mode 100644 packages/alchemy/test/Forgejo/Pagination.test.ts create mode 100644 packages/alchemy/test/Forgejo/Sdk.test.ts diff --git a/distilled b/distilled index cb11c5bf19..ea65bd7712 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit cb11c5bf19151b529a6b96497f1c8251e327d618 +Subproject commit ea65bd7712c1498b4ee8a46e6415d994fba3854d diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index 8486760797..00defaec5b 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -435,6 +435,7 @@ "@distilled.cloud/cloudflare": "workspace:*", "@distilled.cloud/core": "workspace:*", "@distilled.cloud/fly-io": "workspace:*", + "@distilled.cloud/forgejo": "workspace:*", "@distilled.cloud/hetzner": "workspace:*", "@distilled.cloud/neon": "workspace:*", "@distilled.cloud/planetscale": "workspace:*", diff --git a/packages/alchemy/src/Forgejo/ApiToken.ts b/packages/alchemy/src/Forgejo/ApiToken.ts index 1f3ecb1888..d2eb363e2d 100644 --- a/packages/alchemy/src/Forgejo/ApiToken.ts +++ b/packages/alchemy/src/Forgejo/ApiToken.ts @@ -1,10 +1,11 @@ +import { Services } from "@distilled.cloud/forgejo"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { ForgejoCredentials, optional, paginate } from "./Client.ts"; +import { paginate } from "./Pagination.ts"; import type * as Forgejo from "./Providers.ts"; /** @@ -125,14 +126,6 @@ export interface ApiToken extends Resource< */ export const ApiToken = Resource("Forgejo.ApiToken"); -interface ApiAccessToken { - readonly id: number; - readonly name: string; - readonly sha1?: string; - readonly token_last_eight: string; - readonly created_at: string; -} - /** Order-insensitive comparison of two optional string lists. */ const sameSet = ( a: readonly string[] | undefined, @@ -146,16 +139,8 @@ const sameSet = ( ); }; -const tokensPath = (username: string) => - `/admin/users/${encodeURIComponent(username)}/tokens`; - -const tokenPath = (username: string, tokenId: number) => - `${tokensPath(username)}/${encodeURIComponent(String(tokenId))}`; - -const listTokens = Effect.fn(function* (username: string) { - const client = yield* ForgejoCredentials; - return yield* paginate(client, tokensPath(username)); -}); +const listTokens = (username: string) => + paginate(Services.admin.adminListUserAccessTokens, { username }); /** * Raised when a token of this name already exists but no state row does. @@ -280,22 +265,24 @@ export const ApiTokenProvider = () => }); } - const client = yield* ForgejoCredentials; - const created = yield* client.request( - "POST", - tokensPath(news.username), - { - body: { - name: news.name, - scopes: news.scopes === undefined ? undefined : [...news.scopes], - repositories: - news.repositories === undefined - ? undefined - : news.repositories.map(({ owner, name }) => ({ owner, name })), - }, - }, - ); - if (created.sha1 === undefined) { + const created = yield* Services.admin.adminCreateUserAccessToken({ + username: news.username, + name: news.name, + scopes: news.scopes === undefined ? undefined : [...news.scopes], + repositories: + news.repositories === undefined + ? undefined + : news.repositories.map(({ owner, name }) => ({ owner, name })), + }); + // The SDK hands the generated secret out Redacted; a plain string is + // only ever seen from a mock that bypasses the protocol's wrapping. + const secret = + created.sha1 === undefined + ? undefined + : Redacted.isRedacted(created.sha1) + ? created.sha1 + : Redacted.make(created.sha1); + if (secret === undefined) { return yield* new MissingGeneratedToken({ username: news.username, name: news.name, @@ -303,19 +290,18 @@ export const ApiTokenProvider = () => } return { tokenId: created.id, - token: Redacted.make(created.sha1), + token: secret, tokenLastEight: created.token_last_eight, createdAt: created.created_at, }; }), delete: Effect.fn(function* ({ olds, output }) { if (output === undefined) return; - const client = yield* ForgejoCredentials; - yield* optional( - client.request( - "DELETE", - tokenPath(olds.username, output.tokenId), - ), - ); + yield* Services.admin + .adminDeleteUserAccessToken({ + username: olds.username, + token: String(output.tokenId), + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); }), }); diff --git a/packages/alchemy/src/Forgejo/BranchProtection.ts b/packages/alchemy/src/Forgejo/BranchProtection.ts index 0d9dc67342..71b37b6afa 100644 --- a/packages/alchemy/src/Forgejo/BranchProtection.ts +++ b/packages/alchemy/src/Forgejo/BranchProtection.ts @@ -1,13 +1,9 @@ +import { Services } from "@distilled.cloud/forgejo"; +import type { BranchProtection as ApiBranchProtection } from "@distilled.cloud/forgejo/repository"; import * as Effect from "effect/Effect"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { - ForgejoCredentials, - ignoreInaccessible, - optional, - paginate, -} from "./Client.ts"; import { listAccessibleRepositories } from "./Lists.ts"; import { matchesDesired } from "./Settings.ts"; import type * as Forgejo from "./Providers.ts"; @@ -170,29 +166,9 @@ export const BranchProtection = Resource( "Forgejo.BranchProtection", ); -interface ApiBranchProtection { - readonly rule_name: string; - readonly required_approvals?: number; - readonly require_signed_commits?: boolean; - readonly enable_status_check?: boolean; - readonly status_check_contexts?: readonly string[]; - readonly block_on_rejected_reviews?: boolean; - readonly block_on_outdated_branch?: boolean; - readonly apply_to_admins?: boolean; - readonly push_whitelist_usernames?: readonly string[]; - readonly push_whitelist_teams?: readonly string[]; - readonly enable_push?: boolean; - readonly enable_push_whitelist?: boolean; -} - -const collection = ( +const target = ( props: Pick, -) => - `/repos/${encodeURIComponent(props.owner)}/${encodeURIComponent(props.repository)}/branch_protections`; - -const rulePath = ( - props: Pick, -) => `${collection(props)}/${encodeURIComponent(props.ruleName)}`; +) => ({ owner: props.owner, repo: props.repository }); const attributesOf = ( props: Pick, @@ -203,7 +179,15 @@ const attributesOf = ( ruleName: rule.rule_name, }); -const bodyOf = (props: BranchProtectionProps) => { +const copy = (list: readonly string[] | undefined) => + list === undefined ? undefined : [...list]; + +/** + * The settings both the create and the edit endpoint accept. `rule_name` is + * the endpoint identity, added by create alone — `EditBranchProtectionOption` + * does not carry it. + */ +const settingsOf = (props: BranchProtectionProps) => { // A whitelist is inert unless its enable flags are on, so declaring one // turns them on by default — otherwise the rule silently permits everyone. const hasPushWhitelist = @@ -219,21 +203,34 @@ const bodyOf = (props: BranchProtectionProps) => { const enablePushWhitelist = (props.enablePushWhitelist ?? whitelistDefault) && enablePush === true; return { - rule_name: props.ruleName, required_approvals: props.requiredApprovals, require_signed_commits: props.requireSignedCommits, enable_status_check: props.enableStatusCheck, - status_check_contexts: props.statusCheckContexts, + status_check_contexts: copy(props.statusCheckContexts), block_on_rejected_reviews: props.blockOnRejectedReviews, block_on_outdated_branch: props.blockOnOutdatedBranch, apply_to_admins: props.applyToAdmins, - push_whitelist_usernames: props.pushWhitelistUsernames, - push_whitelist_teams: props.pushWhitelistTeams, + push_whitelist_usernames: copy(props.pushWhitelistUsernames), + push_whitelist_teams: copy(props.pushWhitelistTeams), enable_push: enablePush, enable_push_whitelist: enablePushWhitelist, }; }; +const observe = ( + props: Pick, +) => + Services.repository + .repoGetBranchProtection({ ...target(props), name: props.ruleName }) + .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); + +const edit = (props: BranchProtectionProps) => + Services.repository.repoEditBranchProtection({ + ...target(props), + name: props.ruleName, + ...settingsOf(props), + }); + /** * Provider layer implementing branch-protection lifecycle. */ @@ -251,7 +248,6 @@ export const BranchProtectionProvider = () => : undefined, ), list: Effect.fn(function* () { - const client = yield* ForgejoCredentials; const repositories = yield* listAccessibleRepositories(); const rules = yield* Effect.forEach( repositories, @@ -261,74 +257,66 @@ export const BranchProtectionProvider = () => repository: repository.name, }; // This is the one list endpoint Forgejo does not paginate: it - // accepts no `page`/`limit` and returns every rule at once. - return ignoreInaccessible( - client.request( - "GET", - collection(props), - ), - undefined as readonly ApiBranchProtection[] | undefined, - ).pipe( - Effect.map((found) => found ?? []), - Effect.map((found) => - found.map((rule) => attributesOf(props, rule)), - ), - ); + // accepts no `page`/`limit` and returns every rule at once. A + // repository the credential cannot read is skipped rather than + // failing the whole sweep. + return Services.repository + .repoListBranchProtection(target(props)) + .pipe( + Effect.catchTag(["NotFound", "Forbidden"], () => + Effect.succeed([] as readonly ApiBranchProtection[]), + ), + Effect.map((found) => + found.map((rule) => attributesOf(props, rule)), + ), + ); }, { concurrency: 8 }, ); return rules.flat(); }), read: Effect.fn(function* ({ olds }) { - const client = yield* ForgejoCredentials; - const observed = yield* optional( - client.request("GET", rulePath(olds)), - ); + const observed = yield* observe(olds); return observed === undefined ? undefined : attributesOf(olds, observed); }), reconcile: Effect.fn(function* ({ news }) { - const client = yield* ForgejoCredentials; - // Observe: the rule name is the endpoint identity, so live state alone // decides whether this creates or updates. - const observed = yield* optional( - client.request("GET", rulePath(news)), - ); + const observed = yield* observe(news); if (observed === undefined) { - const created = yield* client - .request("POST", collection(news), { - body: bodyOf(news), + const created = yield* Services.repository + .repoCreateBranchProtection({ + ...target(news), + rule_name: news.ruleName, + ...settingsOf(news), }) .pipe( // A concurrent create wins the race; converge onto the rule that // is already there. This endpoint declares 403/422/423 for an // existing rule, not 409, so the conflict arrives under those. - Effect.catchTag( - ["ForgejoForbidden", "ForgejoValidationError"], - () => - client.request("PATCH", rulePath(news), { - body: bodyOf(news), - }), + Effect.catchTag(["Forbidden", "UnprocessableEntity"], () => + edit(news), ), ); return attributesOf(news, created); } // Sync only when the live rule differs from what was declared. - const desired = bodyOf(news); - const updated = matchesDesired(observed, desired) + const updated = matchesDesired(observed, settingsOf(news)) ? observed - : yield* client.request("PATCH", rulePath(news), { - body: desired, - }); + : yield* edit(news); return attributesOf(news, updated); }), delete: Effect.fn(function* ({ output }) { if (output === undefined) return; - const client = yield* ForgejoCredentials; // Address the rule from `output` alone: account-wide teardown has no // state row, so it passes the Attributes shape as `olds` too. - yield* optional(client.request("DELETE", rulePath(output))); + yield* Services.repository + .repoDeleteBranchProtection({ + ...target(output), + name: output.ruleName, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); }), }); diff --git a/packages/alchemy/src/Forgejo/Client.ts b/packages/alchemy/src/Forgejo/Client.ts deleted file mode 100644 index 9d0aeceb4e..0000000000 --- a/packages/alchemy/src/Forgejo/Client.ts +++ /dev/null @@ -1,624 +0,0 @@ -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Redacted from "effect/Redacted"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import type { AuthError, NeedsReauth } from "../Auth/AuthProvider.ts"; -import { resolveProviderConfig } from "../Auth/Resolve.ts"; -import { UserFacingError } from "../UserFacingError.ts"; -import { - FORGEJO_AUTH_PROVIDER_NAME, - type ForgejoAuthConfig, - type ForgejoResolvedCredentials, -} from "./AuthProvider.ts"; - -/** - * Configuration used to connect to a Forgejo instance. - */ -export interface ForgejoClientOptions { - /** - * Forgejo origin or API v1 base URL. - */ - readonly baseUrl: string; - /** - * Forgejo access token. - */ - readonly token: string | Redacted.Redacted; -} - -/** - * Context shared by every Forgejo API failure. - */ -export interface ForgejoErrorContext { - /** - * HTTP method of the failed request. - */ - readonly method: string; - /** - * API path of the failed request, relative to the API v1 base URL. - */ - readonly path: string; - /** - * Response body returned by Forgejo. - */ - readonly body: string; -} - -/** - * Render the request that failed, plus whatever Forgejo said about it. - * - * Nothing else gives these errors a message, so without this a failed - * `alchemy destroy` reports `ForgejoServerError:` and nothing more. - */ -const describe = ( - error: ForgejoErrorContext & { readonly status?: number }, -): string => { - const status = error.status === undefined ? "" : ` -> ${error.status}`; - const body = error.body === "" ? "" : `: ${error.body}`; - return `${error.method} ${error.path}${status}${body}`; -}; - -/** - * The requested resource does not exist. Lifecycle operations treat this as a - * successful no-op when deleting, and as "needs creating" when reconciling. - */ -export class ForgejoNotFound extends Data.TaggedError( - "ForgejoNotFound", -) { - override get message(): string { - return describe(this); - } -} - -/** - * The credential is missing, malformed, or expired. - */ -export class ForgejoUnauthorized extends Data.TaggedError( - "ForgejoUnauthorized", -) { - override get message(): string { - return describe(this); - } -} - -/** - * The credential is valid but lacks permission for this operation. Forgejo - * returns this for administrator-only endpoints reached with a user token. - */ -export class ForgejoForbidden extends Data.TaggedError( - "ForgejoForbidden", -) { - override get message(): string { - return describe(this); - } -} - -/** - * The resource already exists, or the request conflicts with current state. - * Reconcilers treat this as a create race and re-observe. - */ -export class ForgejoConflict extends Data.TaggedError( - "ForgejoConflict", -) { - override get message(): string { - return describe(this); - } -} - -/** - * Forgejo rejected the request payload. - */ -export class ForgejoValidationError extends Data.TaggedError( - "ForgejoValidationError", -) { - override get message(): string { - return describe(this); - } -} - -/** - * The operation cannot proceed until something that depends on the target is - * gone — Forgejo's equivalent of a dependency violation. - * - * Forgejo answers it with a `500` rather than a conflict status, so it is - * recognized by response body — see {@link DEPENDENCY_VIOLATION}. Lifecycle - * operations retry it rather than failing the deploy. - */ -export class ForgejoDependencyViolation extends Data.TaggedError( - "ForgejoDependencyViolation", -)< - ForgejoErrorContext & { - /** - * HTTP status returned by Forgejo. - */ - readonly status: number; - } -> { - override get message(): string { - return describe(this); - } -} - -/** - * Forgejo returned a 5xx response. - */ -export class ForgejoServerError extends Data.TaggedError("ForgejoServerError")< - ForgejoErrorContext & { - /** - * HTTP status returned by Forgejo. - */ - readonly status: number; - } -> { - override get message(): string { - return describe(this); - } -} - -/** - * Forgejo returned an unsuccessful status that maps to no more specific tag. - */ -export class ForgejoRequestError extends Data.TaggedError( - "ForgejoRequestError", -)< - ForgejoErrorContext & { - /** - * HTTP status returned by Forgejo. - */ - readonly status: number; - } -> { - override get message(): string { - return describe(this); - } -} - -/** - * The request never produced a usable response: a connection failure, an - * invalid URL, or an undecodable body. - */ -export class ForgejoTransportError extends Data.TaggedError( - "ForgejoTransportError", -)<{ - /** - * HTTP method of the failed request. - */ - readonly method: string; - /** - * API path of the failed request, relative to the API v1 base URL. - */ - readonly path: string; - /** - * Underlying transport or decoding failure. - */ - readonly cause: unknown; -}> { - override get message(): string { - return `${this.method} ${this.path}: ${String(this.cause)}`; - } -} - -/** - * A list endpoint returned more pages than {@link MAX_PAGES} allows. - * - * Enumeration powers account-wide operations such as `alchemy nuke`, so a - * silently truncated list would under-report and leave resources behind. - */ -export class ForgejoPaginationLimit extends Data.TaggedError( - "ForgejoPaginationLimit", -)<{ - /** - * API path being enumerated. - */ - readonly path: string; - /** - * Number of pages walked before giving up. - */ - readonly pages: number; - /** - * Entries requested per page. - */ - readonly limit: number; -}> { - /** - * Human-readable description of the incomplete enumeration. - */ - override get message(): string { - return `Listing ${this.path} exceeded ${this.pages} pages of ${this.limit} entries; enumeration would be incomplete.`; - } -} - -/** - * Every failure a Forgejo API request can produce. - */ -export type ForgejoError = - | ForgejoNotFound - | ForgejoUnauthorized - | ForgejoForbidden - | ForgejoConflict - | ForgejoValidationError - | ForgejoDependencyViolation - | ForgejoServerError - | ForgejoRequestError - | ForgejoTransportError - | ForgejoPaginationLimit; - -/** - * Normalize a Forgejo origin into its API v1 base URL. - */ -export const normalizeBaseUrl = (baseUrl: string): string => { - const normalized = baseUrl.replace(/\/+$/, ""); - return normalized.endsWith("/api/v1") ? normalized : `${normalized}/api/v1`; -}; - -/** - * Query parameters accepted by a Forgejo API request. - */ -export type ForgejoQuery = Readonly< - Record ->; - -/** - * Options accepted by the Forgejo client's request method. - */ -export interface ForgejoRequestOptions { - /** - * JSON request body. - */ - readonly body?: unknown; - /** - * Query parameters appended to the request URL. Entries whose value is - * `undefined` are omitted. - */ - readonly query?: ForgejoQuery; -} - -/** - * Authenticated client for the Forgejo REST API. - */ -export interface ForgejoClient { - /** - * Normalized Forgejo API base URL. - */ - readonly baseUrl: string; - /** - * Perform an authenticated API request and decode its JSON response. - * - * Resolves to `undefined` for empty responses, and fails with a tagged - * error for every unsuccessful status. - */ - readonly request: ( - method: string, - path: string, - options?: ForgejoRequestOptions, - ) => Effect.Effect; -} - -/** - * Credentials and client available to all Forgejo providers. - */ -export class ForgejoCredentials extends Context.Service< - ForgejoCredentials, - ForgejoClient ->()("Forgejo::Credentials") {} - -/** - * Body Forgejo returns for a 5xx that is really a dependency violation, and - * the only signal separating one from a genuine server fault. - * - * Deleting an organization that still owns repositories answers `500` with - * `{"message":"user still has ownership of repositories [uid: 16]"}` - * (observed on Forgejo 16.0.3). - */ -const DEPENDENCY_VIOLATION = /still has ownership of repositories/i; - -const statusError = ( - status: number, - context: ForgejoErrorContext, -): ForgejoError => { - if (status === 401) return new ForgejoUnauthorized(context); - if (status === 403) return new ForgejoForbidden(context); - if (status === 404) return new ForgejoNotFound(context); - if (status === 409) return new ForgejoConflict(context); - if (status === 422) return new ForgejoValidationError(context); - if (status >= 500 && DEPENDENCY_VIOLATION.test(context.body)) - return new ForgejoDependencyViolation({ ...context, status }); - if (status >= 500) return new ForgejoServerError({ ...context, status }); - return new ForgejoRequestError({ ...context, status }); -}; - -const withQuery = (path: string, query: ForgejoQuery | undefined): string => { - if (query === undefined) return path; - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(query)) { - if (value !== undefined) params.set(key, String(value)); - } - const search = params.toString(); - return search === "" ? path : `${path}?${search}`; -}; - -const makeClient = ( - options: ForgejoClientOptions, - httpClient: HttpClient.HttpClient, -): ForgejoClient => { - const baseUrl = normalizeBaseUrl(options.baseUrl); - const token = - typeof options.token === "string" - ? options.token - : Redacted.value(options.token); - - return { - baseUrl, - request: ( - method: string, - path: string, - requestOptions?: ForgejoRequestOptions, - ) => - Effect.gen(function* () { - const url = `${baseUrl}${withQuery(path, requestOptions?.query)}`; - const base = HttpClientRequest.make(method as "GET")(url).pipe( - HttpClientRequest.setHeaders({ - Accept: "application/json", - Authorization: `token ${token}`, - }), - ); - const request = - requestOptions?.body === undefined - ? base - : HttpClientRequest.bodyJsonUnsafe(base, requestOptions.body); - - const response = yield* httpClient.execute(request); - const text = yield* response.text; - - if (response.status < 200 || response.status >= 300) { - return yield* statusError(response.status, { - method, - path, - body: text, - }); - } - // Forgejo answers many mutations with `204 No Content`; callers that - // ignore the result type this as `void`. - if (text.length === 0) return undefined as T; - return yield* Effect.try({ - try: () => JSON.parse(text) as T, - catch: (cause) => new ForgejoTransportError({ method, path, cause }), - }); - }).pipe( - Effect.catchTag( - "HttpClientError", - (cause) => new ForgejoTransportError({ method, path, cause }), - ), - ), - }; -}; - -/** - * Resolve a request that is allowed to be missing, mapping a not-found - * failure to `undefined`. - */ -export const optional = ( - effect: Effect.Effect, -): Effect.Effect, R> => - effect.pipe( - Effect.catchTag("ForgejoNotFound", () => Effect.succeed(undefined)), - ); - -/** - * Resolve a request that is allowed to be missing or inaccessible. - * - * Account-wide enumeration walks resources the credential may not be able to - * read; a single inaccessible repository or organization must not abort the - * whole sweep. - */ -export const ignoreInaccessible = ( - effect: Effect.Effect, - fallback: A, -): Effect.Effect< - A, - Exclude, - R -> => - effect.pipe( - Effect.catchTag(["ForgejoNotFound", "ForgejoForbidden"], () => - Effect.succeed(fallback), - ), - ); - -/** - * Largest page size Forgejo accepts on its paginated list endpoints. - */ -const PAGE_LIMIT = 50; - -/** - * Upper bound on pages walked by {@link paginate}, so a server that never - * reports a short page cannot spin forever. - */ -const MAX_PAGES = 100; - -/** - * Walk every page of a Forgejo list endpoint. - * - * Forgejo paginates list responses (30 entries by default), so a single - * request silently truncates enumeration. Paging stops at the first empty - * page — not the first short one, since the instance may clamp the page size - * below {@link PAGE_LIMIT}. Hitting {@link MAX_PAGES} fails with - * {@link ForgejoPaginationLimit} rather than returning a list that only looks - * complete. - */ -export const paginate = ( - client: ForgejoClient, - path: string, - options?: { - /** - * Additional query parameters sent with every page request. - */ - readonly query?: ForgejoQuery; - }, -): Effect.Effect => { - const go = ( - page: number, - accumulated: readonly T[], - ): Effect.Effect => - client - .request("GET", path, { - query: { ...options?.query, page, limit: PAGE_LIMIT }, - }) - .pipe( - Effect.flatMap((items) => { - const combined = - items === undefined ? accumulated : [...accumulated, ...items]; - // Stop only on an empty page, never on a short one. Forgejo clamps - // the requested `limit` to the instance's `[api] MAX_RESPONSE_ITEMS`, - // so on a server whose administrator lowered that below PAGE_LIMIT - // every full page looks short — treating short as "last" would end - // enumeration after page one and silently report a partial list as - // complete. - if (items === undefined || items.length === 0) { - return Effect.succeed(combined); - } - return page >= MAX_PAGES - ? new ForgejoPaginationLimit({ - path, - pages: MAX_PAGES, - limit: PAGE_LIMIT, - }) - : go(page + 1, combined); - }), - ); - - return go(1, []); -}; - -/** - * Build a credentials layer from a Forgejo URL and access token. - */ -export const fromToken = (options: ForgejoClientOptions) => - Layer.effect( - ForgejoCredentials, - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - return makeClient(options, httpClient); - }), - ); - -/** - * Raised when environment authentication is requested without the required - * variables. - */ -export class MissingForgejoEnvironment extends Data.TaggedError( - "MissingForgejoEnvironment", -)<{ - /** - * Names of the environment variables that were not set. - */ - readonly missing: readonly string[]; -}> { - /** - * Human-readable description of the missing configuration. - */ - override get message(): string { - return `Set ${this.missing.join(" and ")} to use Forgejo providers.`; - } -} - -/** - * Build a credentials layer from `FORGEJO_URL` and `FORGEJO_TOKEN`. - */ -export const fromEnv = () => - Layer.effect( - ForgejoCredentials, - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const baseUrl = yield* Effect.sync(() => process.env.FORGEJO_URL); - const token = yield* Effect.sync(() => process.env.FORGEJO_TOKEN); - const missing = [ - ...(baseUrl === undefined ? ["FORGEJO_URL"] : []), - ...(token === undefined ? ["FORGEJO_TOKEN"] : []), - ]; - if (baseUrl === undefined || token === undefined) { - return yield* new MissingForgejoEnvironment({ missing }); - } - return makeClient({ baseUrl, token }, httpClient); - }), - ); - -/** - * Raised when neither the selected profile nor the CI environment yields a - * usable Forgejo credential. - */ -export class UnresolvedForgejoCredentials extends Data.TaggedError( - "UnresolvedForgejoCredentials", -)<{ - /** - * Where resolution was attempted, e.g. `profile 'default'`. - */ - readonly source: string; - /** - * Underlying auth-provider failure. - */ - readonly cause: unknown; -}> { - readonly [UserFacingError] = true; - - /** - * Human-readable description of the failed resolution. - */ - override get message(): string { - return ( - `Failed to resolve Forgejo credentials from ${this.source}. ` + - "Run `alchemy profile edit --add Forgejo`, or set FORGEJO_URL and " + - "FORGEJO_TOKEN." - ); - } -} - -/** - * Build a credentials layer from the selected alchemy profile, falling back - * to `FORGEJO_URL` / `FORGEJO_TOKEN` in CI. - * - * This is what `providers()` uses when no explicit `{ baseUrl, token }` is - * passed, so `alchemy profile edit --add Forgejo` is enough to authenticate - * a stack. - */ -export const fromAuthProvider = () => - Layer.effect( - ForgejoCredentials, - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const { profileName, resolve } = yield* resolveProviderConfig< - ForgejoAuthConfig, - ForgejoResolvedCredentials - >(FORGEJO_AUTH_PROVIDER_NAME); - - // `resolve` is a union of the environment-branch and profile-branch - // effects, whose error channels differ. Widen it to their common - // supertype: piping the union directly infers `unknown` requirements, - // which silently poisons `StackServices` for every consumer. - const resolved: Effect.Effect< - ForgejoResolvedCredentials, - AuthError | NeedsReauth - > = resolve; - - const credentials = yield* resolved.pipe( - Effect.mapError( - (cause) => - new UnresolvedForgejoCredentials({ - source: - profileName === undefined - ? "the CI environment" - : `profile '${profileName}'`, - cause, - }), - ), - Effect.orDie, - ); - - return makeClient( - { baseUrl: credentials.baseUrl, token: credentials.token }, - httpClient, - ); - }), - ); diff --git a/packages/alchemy/src/Forgejo/Credentials.ts b/packages/alchemy/src/Forgejo/Credentials.ts new file mode 100644 index 0000000000..92dc839c37 --- /dev/null +++ b/packages/alchemy/src/Forgejo/Credentials.ts @@ -0,0 +1,181 @@ +import { + type Config, + Credentials, + credentials, + normalizeBaseUrl, +} from "@distilled.cloud/forgejo"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import type { AuthError, NeedsReauth } from "../Auth/AuthProvider.ts"; +import { resolveProviderConfig } from "../Auth/Resolve.ts"; +import { UserFacingError } from "../UserFacingError.ts"; +import { + FORGEJO_AUTH_PROVIDER_NAME, + type ForgejoAuthConfig, + type ForgejoResolvedCredentials, +} from "./AuthProvider.ts"; + +export { + API_PATH, + Credentials, + CredentialsFromEnv, + credentials, + normalizeBaseUrl, + type Config as CredentialsConfig, +} from "@distilled.cloud/forgejo"; + +/** + * Configuration used to connect to a Forgejo instance. + */ +export interface ForgejoClientOptions { + /** + * Forgejo origin or API v1 base URL. + */ + readonly baseUrl: string; + /** + * Forgejo access token. + */ + readonly token: string | Redacted.Redacted; +} + +/** + * The instance origin a resolved credential points at, without the API + * prefix — what web URLs for the instance's pages are built from. + * + * Forgejo's organization representation carries no `html_url`, unlike its + * repository representation, so links to an organization are derived from + * the instance rather than read off the response. + */ +export const originOf = (config: Config): string => + config.apiBaseUrl.replace(/\/api\/v1$/, ""); + +/** + * Build a credentials layer from a Forgejo URL and access token. + */ +export const fromToken = ( + options: ForgejoClientOptions, +): Layer.Layer => credentials(options); + +/** + * Raised when environment authentication is requested without the required + * variables. + */ +export class MissingForgejoEnvironment extends Data.TaggedError( + "MissingForgejoEnvironment", +)<{ + /** + * Names of the environment variables that were not set. + */ + readonly missing: readonly string[]; +}> { + /** + * Human-readable description of the missing configuration. + */ + override get message(): string { + return `Set ${this.missing.join(" and ")} to use Forgejo providers.`; + } +} + +/** + * Build a credentials layer from `FORGEJO_URL` and `FORGEJO_TOKEN`. + */ +export const fromEnv = () => + Layer.effect( + Credentials, + Effect.gen(function* () { + const baseUrl = yield* Effect.sync(() => process.env.FORGEJO_URL); + const token = yield* Effect.sync(() => process.env.FORGEJO_TOKEN); + const missing = [ + ...(baseUrl === undefined ? ["FORGEJO_URL"] : []), + ...(token === undefined ? ["FORGEJO_TOKEN"] : []), + ]; + if (baseUrl === undefined || token === undefined) { + return yield* new MissingForgejoEnvironment({ missing }); + } + return Effect.succeed({ + token: Redacted.make(token), + apiBaseUrl: normalizeBaseUrl(baseUrl), + }); + }), + ); + +/** + * Raised when neither the selected profile nor the CI environment yields a + * usable Forgejo credential. + */ +export class UnresolvedForgejoCredentials extends Data.TaggedError( + "UnresolvedForgejoCredentials", +)<{ + /** + * Where resolution was attempted, e.g. `profile 'default'`. + */ + readonly source: string; + /** + * Underlying auth-provider failure. + */ + readonly cause: unknown; +}> { + readonly [UserFacingError] = true; + + /** + * Human-readable description of the failed resolution. + */ + override get message(): string { + return ( + `Failed to resolve Forgejo credentials from ${this.source}. ` + + "Run `alchemy profile edit --add Forgejo`, or set FORGEJO_URL and " + + "FORGEJO_TOKEN." + ); + } +} + +/** + * Build a credentials layer from the selected alchemy profile, falling back + * to `FORGEJO_URL` / `FORGEJO_TOKEN` in CI. + * + * This is what `providers()` uses when no explicit `{ baseUrl, token }` is + * passed, so `alchemy profile edit --add Forgejo` is enough to authenticate + * a stack. + * + * Maps onto `@distilled.cloud/forgejo`'s `{ token, apiBaseUrl }` shape. + */ +export const fromAuthProvider = () => + Layer.effect( + Credentials, + Effect.gen(function* () { + const { profileName, resolve } = yield* resolveProviderConfig< + ForgejoAuthConfig, + ForgejoResolvedCredentials + >(FORGEJO_AUTH_PROVIDER_NAME); + + // `resolve` is a union of the environment-branch and profile-branch + // effects, whose error channels differ. Widen it to their common + // supertype: piping the union directly infers `unknown` requirements, + // which silently poisons `StackServices` for every consumer. + const resolved: Effect.Effect< + ForgejoResolvedCredentials, + AuthError | NeedsReauth + > = resolve; + + return yield* resolved.pipe( + Effect.map((creds): Config => ({ + token: creds.token, + apiBaseUrl: normalizeBaseUrl(creds.baseUrl), + })), + Effect.mapError( + (cause) => + new UnresolvedForgejoCredentials({ + source: + profileName === undefined + ? "the CI environment" + : `profile '${profileName}'`, + cause, + }), + ), + Effect.orDie, + Effect.cached, + ); + }), + ); diff --git a/packages/alchemy/src/Forgejo/Label.ts b/packages/alchemy/src/Forgejo/Label.ts index 9735a7c613..fb0ea4722d 100644 --- a/packages/alchemy/src/Forgejo/Label.ts +++ b/packages/alchemy/src/Forgejo/Label.ts @@ -1,14 +1,11 @@ +import { Services } from "@distilled.cloud/forgejo"; +import type { Label as ApiLabel } from "@distilled.cloud/forgejo/issue"; import * as Effect from "effect/Effect"; import { isResolved } from "../Diff.ts"; import * as Provider from "../Provider.ts"; import { Resource } from "../Resource.ts"; -import { - ForgejoCredentials, - ignoreInaccessible, - optional, - paginate, -} from "./Client.ts"; import { listAccessibleRepositories } from "./Lists.ts"; +import { paginate } from "./Pagination.ts"; import { matchesDesired } from "./Settings.ts"; import type * as Forgejo from "./Providers.ts"; @@ -117,20 +114,10 @@ export interface Label extends Resource< */ export const Label = Resource