diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index bb42f827e1..cc073aa2ca 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", @@ -429,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/Alchemist/Session.ts b/packages/alchemy/src/Alchemist/Session.ts index 3abab50aaf..57690e71e5 100644 --- a/packages/alchemy/src/Alchemist/Session.ts +++ b/packages/alchemy/src/Alchemist/Session.ts @@ -23,6 +23,7 @@ import { AwsAuth } from "../AWS/AuthProvider.ts"; import { AxiomAuth } from "../Axiom/AuthProvider.ts"; import { CloudflareAuth } from "../Cloudflare/Auth/AuthProvider.ts"; import { FlyAuth } from "../Fly/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"; @@ -370,6 +371,7 @@ const builtinAuth = Layer.mergeAll( AxiomAuth, CloudflareAuth, FlyAuth, + 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..d2eb363e2d --- /dev/null +++ b/packages/alchemy/src/Forgejo/ApiToken.ts @@ -0,0 +1,307 @@ +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 { paginate } from "./Pagination.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"); + +/** 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 listTokens = (username: string) => + paginate(Services.admin.adminListUserAccessTokens, { 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. + */ +export class MissingGeneratedToken extends Data.TaggedError( + "MissingGeneratedToken", +)<{ + /** + * User the token was generated for. + */ + readonly username: string; + /** + * Name of the token Forgejo was asked to create. + */ + readonly name: string; +}> { + /** + * Human-readable description of the unusable create response. + */ + override get message(): string { + return `Forgejo did not return the generated API token '${this.name}' for user '${this.username}' in the create response.`; + } +} + +/** + * 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; + // 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 || + !sameScopes || + !sameRepositories + ? { 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. + 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 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, + }); + } + return { + tokenId: created.id, + token: secret, + tokenLastEight: created.token_last_eight, + createdAt: created.created_at, + }; + }), + delete: Effect.fn(function* ({ olds, output }) { + if (output === undefined) return; + yield* Services.admin + .adminDeleteUserAccessToken({ + username: olds.username, + token: String(output.tokenId), + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/Forgejo/AuthProvider.ts b/packages/alchemy/src/Forgejo/AuthProvider.ts new file mode 100644 index 0000000000..34c6858138 --- /dev/null +++ b/packages/alchemy/src/Forgejo/AuthProvider.ts @@ -0,0 +1,99 @@ +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, + 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..71b37b6afa --- /dev/null +++ b/packages/alchemy/src/Forgejo/BranchProtection.ts @@ -0,0 +1,322 @@ +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 { listAccessibleRepositories } from "./Lists.ts"; +import { matchesDesired } from "./Settings.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. + * + * Forgejo only enforces a whitelist when {@link enablePushWhitelist} is on, + * which defaults to `true` whenever this or {@link pushWhitelistTeams} is + * non-empty. + */ + readonly pushWhitelistUsernames?: readonly string[]; + /** + * Teams allowed to push. + * + * See {@link pushWhitelistUsernames} for how the whitelist is enabled. + */ + readonly pushWhitelistTeams?: readonly string[]; + /** + * Whether direct pushes to the branch are permitted at all. + * + * @default true when a push whitelist is set, otherwise left unmanaged + */ + readonly enablePush?: boolean; + /** + * 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; +} + +/** + * Observed Forgejo branch-protection attributes. + */ +export interface BranchProtectionAttributes { + /** + * Repository owner. Carried on the attributes so account-wide teardown, + * which has no state row to read props from, can still address the rule. + */ + readonly owner: string; + /** + * Repository name. + */ + readonly repository: string; + /** + * 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 + * Declaring a whitelist enables push-whitelist enforcement automatically; + * set `enablePush` or `enablePushWhitelist` explicitly to override. + * + * **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", +); + +const target = ( + props: Pick, +) => ({ owner: props.owner, repo: props.repository }); + +const attributesOf = ( + props: Pick, + rule: ApiBranchProtection, +): BranchProtectionAttributes => ({ + owner: props.owner, + repository: props.repository, + ruleName: rule.rule_name, +}); + +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 = + (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 { + required_approvals: props.requiredApprovals, + require_signed_commits: props.requireSignedCommits, + enable_status_check: props.enableStatusCheck, + 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: 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. + */ +export const BranchProtectionProvider = () => + Provider.succeed(BranchProtection, { + stables: ["ruleName", "owner", "repository"], + 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 repositories = yield* listAccessibleRepositories(); + const rules = yield* Effect.forEach( + repositories, + (repository) => { + const props = { + 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. 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 observed = yield* observe(olds); + return observed === undefined ? undefined : attributesOf(olds, observed); + }), + reconcile: Effect.fn(function* ({ news }) { + // Observe: the rule name is the endpoint identity, so live state alone + // decides whether this creates or updates. + const observed = yield* observe(news); + + if (observed === undefined) { + 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(["Forbidden", "UnprocessableEntity"], () => + edit(news), + ), + ); + return attributesOf(news, created); + } + + // Sync only when the live rule differs from what was declared. + const updated = matchesDesired(observed, settingsOf(news)) + ? observed + : yield* edit(news); + return attributesOf(news, updated); + }), + delete: Effect.fn(function* ({ output }) { + if (output === undefined) return; + // Address the rule from `output` alone: account-wide teardown has no + // state row, so it passes the Attributes shape as `olds` too. + yield* Services.repository + .repoDeleteBranchProtection({ + ...target(output), + name: output.ruleName, + }) + .pipe(Effect.catchTag("NotFound", () => Effect.void)); + }), + }); 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 new file mode 100644 index 0000000000..fb0ea4722d --- /dev/null +++ b/packages/alchemy/src/Forgejo/Label.ts @@ -0,0 +1,243 @@ +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 { listAccessibleRepositories } from "./Lists.ts"; +import { paginate } from "./Pagination.ts"; +import { matchesDesired } from "./Settings.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; + /** + * Repository owner. Carried on the attributes so account-wide teardown, + * which has no state row to read props from, can still address the label. + */ + readonly owner: string; + /** + * Repository name. + */ + readonly repository: string; + /** + * 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