Skip to content

Repository files navigation

codeberg4s

A Scala 3 client for the Codeberg / Forgejo REST API v1.

  • Future-based public API — no effect system leaks into your code, and no effect system is added to your classpath.
  • Works against any Forgejo or Gitea-compatible instance. Codeberg is the default base URI, not a hardcoded one.
  • Two error rails. Use exceptions if that suits your codebase, or typed Either values if it does not. Same implementation underneath.
  • Illegal requests are unrepresentable. Owners, repository names, branches, labels and page sizes are validated types with Either-returning constructors, so a value that would forge a request path is rejected before a client is involved.
  • Pagination you cannot get wrong by accident. No operation returns an unbounded List; every listing hands back a Page[A] that says whether another page exists.
  • Small dependency footprint — sttp client4 and jsoniter-scala. That is the list.

Status: pre-release, 0.1.0 in progress. All 439 in-scope operations are implemented on both rails — the whole Forgejo v1 API except admin, activitypub and package, which PLAN.md puts out of scope for v1. docs/ROADMAP.md tracks phases; docs/API_INVENTORY.md has the endpoint-level checklist and the honest percentage. Nothing is published to Maven Central yet — see "Install".

Install

Nothing is published to Maven Central yet. The build is configured for it — five artifacts under com.worxbend, currently at 0.1.0-SNAPSHOT — but until the 0.1.0 tag is cut these coordinates resolve only against a local publish.

// MilldefmvnDeps=Seq(mvn"com.worxbend::codeberg4s-client:0.1.0")
// sbt
libraryDependencies +="com.worxbend"%%"codeberg4s-client"%"0.1.0"

Requires a Java 25 runtime

The jars are compiled for Java 25 (class-file major version 69), the current long-term-support release. A Java 21 or Java 17 JVM cannot load them: it fails at class-load time with an UnsupportedClassVersionError naming "class file version 69.0", which says nothing about which library caused it. Check what you are on with java -version before adding the dependency.

This is deliberate, and it does narrow who can adopt the library — see CONTRIBUTING.md for the same requirement on the build side. Java 25 is a policy floor, not a technical one: the lowest release the source actually compiles against is Java 21, because SttpHttpPort calls java.net.http.HttpClient.shutdown() and that method was added in Java 21. If a Java 21 baseline would unblock you, open an issue and say so — moving the floor down is a one-line change to build.mill.

codeberg4s-client pulls in -transport, -codec, -core and -domain transitively. Depend on a narrower one if you want less: codeberg4s-domain is the models and the error ADT with no dependencies at all, which is enough to write code that handles a CodebergError without linking a HTTP client.

Quick start

importcom.worxbend.codeberg4s.CodebergClientimportcom.worxbend.codeberg4s.CodebergConfigimportcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.auth.Authimportcom.worxbend.codeberg4s.repositories.Ownerimportcom.worxbend.codeberg4s.repositories.RepoNameimportscala.concurrent.ExecutionContextimportscala.concurrent.FuturegivenExecutionContext=ExecutionContext.global
// The client owns an HTTP connection pool and a scheduler thread.// Build one per instance you talk to, for the lifetime of the application.valclient:CodebergClient=CodebergClient(CodebergConfig(Auth.Anonymous))
valstars:Either[ValidationError, Future[Long]] =for
owner <-Owner.from("forgejo")
name <-RepoName.from("forgejo")
yield client.repos.get(owner, name).map(_.starsCount)
// ... and at shutdown:
client.close()

Authenticating is a different Auth and nothing else. A token is validated on the way in, so a blank or control-character-bearing string never reaches a request header:

importcom.worxbend.codeberg4s.CodebergConfigimportcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.auth.ApiTokenimportcom.worxbend.codeberg4s.auth.Authvalconfig:Either[ValidationError, CodebergConfig] =ApiToken
.from(sys.env.getOrElse("CODEBERG_TOKEN", ""))
.map(token =>CodebergConfig(Auth.Token(token)))

CodebergConfig(auth) fills in Codeberg's base URI, the default retry policy, the default user agent, a page size of 30 and 10 s / 30 s timeouts. Copy the result to change one field — see Configuration.

The nine resource groups

Everything is grouped the way the API's own tags are.

AccessorGroup
client.versionGET /version — what software the instance runs
client.reposrepositories, branches, tags, commits, releases, topics, forks, contents
client.usersthe current account, accounts by name, search, follows, keys
client.issuesissues, comments, labels, milestones
client.pullspull requests, merge, reviews, commits, changed files
client.organizationsorganisations, teams, membership
client.notificationsthe notification inbox, per-thread and per-repository
client.miscmarkdown rendering, instance settings, the signing key

The examples in this section all assume the following are in scope:

importcom.worxbend.codeberg4s.CodebergClientimportcom.worxbend.codeberg4s.repositories.Ownerimportcom.worxbend.codeberg4s.repositories.RepoNameimportscala.concurrent.ExecutionContextgivenExecutionContext=ExecutionContext.global
defclient:CodebergClientdefowner:Owner// Owner.from("forgejo")defname:RepoName// RepoName.from("forgejo")

client.version

importcom.worxbend.codeberg4s.ServerVersionimportscala.concurrent.Futurevalversion:Future[String] = client.version.get().map((v: ServerVersion) => v.raw)

Useful as a liveness probe: it is the one endpoint every Forgejo answers anonymously.

client.repos

Eleven operations. Listings take a PageParams and return one Page.

importcom.worxbend.codeberg4s.paging.PageParamsimportcom.worxbend.codeberg4s.repositories.Releaseimportscala.concurrent.FuturevallatestTags:Future[Vector[String]] =
client.repos
.listReleases(owner, name, PageParams.First)
.map(page => page.items.map((release: Release) => release.tagName.value))

getContents is the one union in the API: the same path returns a file object or an array of directory entries, so it decodes to an ADT rather than to a nullable record. docs/HAZARDS.md §3 has the captured payloads.

importcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.repositories.ContentEntryimportcom.worxbend.codeberg4s.repositories.ContentPathimportcom.worxbend.codeberg4s.repositories.RepositoryContentimportscala.concurrent.Futurevalreadme:Either[ValidationError, Future[Option[String]]] =ContentPath
.from("README.md")
.map: path =>
client.repos.getContents(owner, name, path).map:caseRepositoryContent.File(ContentEntry.File(_, content, _)) => content.flatMap(_.text)
caseRepositoryContent.File(_) =>NonecaseRepositoryContent.Directory(_) =>None

client.users

Eight operations, in two families. /user/… means "whoever the configured credentials are" and needs a token; /users/{username}/… names an account.

importcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.paging.Pageimportcom.worxbend.codeberg4s.paging.PageParamsimportcom.worxbend.codeberg4s.repositories.Repositoryimportcom.worxbend.codeberg4s.users.Usernameimportscala.concurrent.Futurevalme:Future[String] = client.users.current().map(_.login)
valtheirRepos:Either[ValidationError, Future[Page[Repository]]] =Username.from("earl-warren").map(who => client.users.repositories(who, PageParams.First))

Do not assume the anonymous paths are anonymous. The pinned spec carries no per-endpoint security information at all, and codeberg.org answers 401 to an anonymous GET /users/{username}/followers — measured, not guessed (docs/HAZARDS.md §2).

client.issues

Ten operations. Filters are a value, not a pile of Option parameters:

importcom.worxbend.codeberg4s.issues.IssueQueryimportcom.worxbend.codeberg4s.issues.StateFilterimportcom.worxbend.codeberg4s.paging.PageParamsimportscala.concurrent.Futurevalquery:IssueQuery=IssueQuery.Empty.withState(StateFilter.Open).authoredBy("earl-warren")
valtitles:Future[Vector[String]] =
client.issues.list(owner, name, query, PageParams.First).map(page => page.items.map(_.title))

Creating goes through a validated command, so an empty title is a ValidationError rather than a 422 from the server:

importcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.issues.CreateIssueimportcom.worxbend.codeberg4s.issues.Issueimportscala.concurrent.Futurevalfiled:Either[ValidationError, Future[Issue]] =CreateIssue
.of("Retry storm on 429")
.map(_.withBody("Backoff ignores Retry-After when the header is a date."))
.map(command => client.issues.create(owner, name, command))

An issue's state is LifecycleState, an ADT — Closed carries the closing timestamp, so "closed" and "when" cannot get out of step.

client.pulls

Eight operations. head is PullRequestHead, which is either a branch in this repository or the owner:branch form a fork needs; there is no way to pass one where the other was meant:

importcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.pulls.CreatePullRequestimportcom.worxbend.codeberg4s.pulls.PullRequestimportcom.worxbend.codeberg4s.pulls.PullRequestHeadimportcom.worxbend.codeberg4s.repositories.BranchNameimportscala.concurrent.Futurevalopened:Either[ValidationError, Future[PullRequest]] =for
from <-BranchName.from("feature/stream-pages").map(PullRequestHead.branch)
into <-BranchName.from("main")
command <-CreatePullRequest.of("Stream pages instead of buffering", from, into)
yield client.pulls.create(owner, name, command)

Merging is a command too, and merge returns Future[Unit]: Forgejo answers 200 with no body, and inventing a PullRequest to return would mean guessing at post-merge state the server did not send.

importcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.pulls.MergePullRequestimportcom.worxbend.codeberg4s.pulls.MergeStyleimportcom.worxbend.codeberg4s.pulls.PullRequestNumberimportscala.concurrent.Futurevalmerged:Either[ValidationError, Future[Unit]] =PullRequestNumber
.from(1234L)
.map: number =>
client.pulls.merge(
owner,
name,
number,
MergePullRequest.using(MergeStyle.Squash).deletingSourceBranch,
)

PullRequestState is Open | Closed | Merged, folded from Forgejo's state string and its separate merged boolean. Reading a merged pull request as merely closed is the bug that shape prevents.

client.organizations

Ten operations, covering organisations, their repositories and members, and teams. Teams are rooted at /teams/{id} rather than under the organisation, which is why getTeam takes only an id:

importcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.organizations.OrgNameimportcom.worxbend.codeberg4s.organizations.Teamimportcom.worxbend.codeberg4s.paging.PageParamsimportscala.concurrent.FuturevalteamNames:Either[ValidationError, Future[Vector[String]]] =OrgName
.from("forgejo")
.map: org =>
client.organizations
.teams(org, PageParams.First)
.map(page => page.items.map((team: Team) => team.name))

A team's permission is TeamPermission, ordered, so authorisation checks read as permission.allows(TeamPermission.Write) rather than as string comparison.

client.notifications

Seven operations. All of them require a token — there is no anonymous inbox.

importcom.worxbend.codeberg4s.notifications.NotificationQueryimportcom.worxbend.codeberg4s.notifications.NotificationSubjectFilterimportcom.worxbend.codeberg4s.notifications.NotificationThreadimportcom.worxbend.codeberg4s.paging.PageParamsimportscala.concurrent.Futurevalunread:Future[Long] = client.notifications.unreadCount().map(_.value)
valpullThreads:Future[Vector[NotificationThread]] =
client.notifications
.list(
NotificationQuery.Empty.withSubjects(Vector(NotificationSubjectFilter.Pull)),
PageParams.First,
)
.map(_.items)

NotificationQuery.Empty is unread-only, matching the endpoint's own default. Marking read returns Future[Unit]:

importscala.concurrent.Futurevalcleared:Future[Unit] = client.notifications.markRepositoryRead(owner, name)

A notification's subject type is an open enum: a type this library has not seen decodes to NotificationSubjectType.Other(raw) instead of failing the page, because Forgejo adds subject types between releases.

client.misc

Six operations: markdown rendering in two forms, the three settings/* endpoints, and the instance signing key.

importcom.worxbend.codeberg4s.miscellaneous.MarkdownModeimportcom.worxbend.codeberg4s.miscellaneous.MarkdownRenderRequestimportcom.worxbend.codeberg4s.miscellaneous.SigningKeyimportscala.concurrent.Futurevalhtml:Future[String] =
client.misc
.renderMarkdown(MarkdownRenderRequest.of("# codeberg4s").copy(mode =MarkdownMode.Gfm))
.map(_.html)
valpageSizeCeiling:Future[Long] = client.misc.apiSettings().map(_.maxResponseItems)
// None means the instance does not sign commits, which is a legitimate answer// and not an error — hence Option rather than a 404.valkey:Future[Option[SigningKey]] = client.misc.signingKey()

The two error rails

Every operation exists twice. The convenience rail fails the Future with a CodebergException, which carries the full CodebergError ADT — so nothing is lost by using it:

importcom.worxbend.codeberg4s.CodebergErrorimportcom.worxbend.codeberg4s.CodebergExceptionimportcom.worxbend.codeberg4s.repositories.Repositoryimportscala.concurrent.Futuredeffallback:Repositoryvalrepository:Future[Repository] =
client.repos.get(owner, name).recover:caseCodebergException(CodebergError.Api(_, 404, _)) => fallback

Any case you do not handle stays a failed Future, carrying the same value.

The typed rail never fails the Future:

importcom.worxbend.codeberg4s.CodebergErrorimportcom.worxbend.codeberg4s.repositories.Repositoryimportscala.concurrent.Futurevalattempted:Future[Either[CodebergError, Repository]] =
client.repos.attempt.get(owner, name)

Pick one per call site. .attempt is the convenience rail with its failure channel materialised, so the two cannot drift.

CodebergError is a closed family of six:

CaseMeansReaction
Transportnothing reached the serversafe to retry a safe method
Apithe server answered non-2xx; carries status and the parsed bodybranch on status
DecodingFaileda 2xx payload did not match the modelretrying will not help; path and snippet are what a bug report needs
Validationa smart constructor rejected an argumentfix the argument
RetriesExhaustedthe retry engine gave up; last is preservedsurface last
WalkTruncateda PageWalk hit its page cap with pages still to comewalk again from resumeFrom, or narrow the query

There is noRateLimited case. Forgejo reports rate limiting as an ordinary 429, so it arrives as Api(ctx, 429, body) — and the retry engine has usually already honoured Retry-After and given up before you see it, which arrives as RetriesExhausted wrapping that Api.

Every remote case carries a CallContext — operation id, method, redacted URI, optional request id, elapsed milliseconds — so you can tell which call failed without correlating logs. error.describe renders it, bounded and secret-free. Validation and WalkTruncated carry none, because neither of them is a request that reached a server.

Pagination

A repository can hold tens of thousands of issues, so no operation returns an unbounded collection by accident. One page at a time:

importcom.worxbend.codeberg4s.issues.Issueimportcom.worxbend.codeberg4s.issues.IssueQueryimportcom.worxbend.codeberg4s.paging.Pageimportcom.worxbend.codeberg4s.paging.PageParamsimportscala.concurrent.Futurevalfirst:Future[Page[Issue]] =
client.issues.list(owner, name, IssueQuery.Empty, PageParams.First)

A Page[A] carries items, the params that produced it, an optional totalCount from the x-total-count header, and nextPage / prevPage.

The clamp hazard — why items.size is the wrong end-of-pages test

The obvious loop is wrong:

// WRONG. Do not do this.// if (page.items.size < requestedSize) then "this was the last page"

Forgejo clamps limit to the instance's own maximum while echoing the value you asked for. Ask for 100 on an instance capped at 50 and you get 50 items back, with nothing in the body saying so. items.size < requested is then true on every page, and a loop written that way stops after the first one and silently reports a truncated result as complete. That is the worst kind of bug in a client library: it does not fail, it under-reports.

PageSize refuses anything above 50 for exactly this reason, but the instance maximum is configurable and client.misc.apiSettings().map(_.maxResponseItems) is where the real ceiling lives — so the guard is necessary, not sufficient.

The library decides "is there another page" from the response's rel="next"Link header and never from how many items came back. Use the same signal:

importcom.worxbend.codeberg4s.issues.Issueimportcom.worxbend.codeberg4s.issues.IssueQueryimportcom.worxbend.codeberg4s.paging.PageParamsimportscala.concurrent.Futuredefwalk(params: PageParams, seen: Vector[Issue]):Future[Vector[Issue]] =
client.issues.list(owner, name, IssueQuery.Empty, params).flatMap: page =>
page.nextPage matchcaseSome(following) if page.items.nonEmpty => walk(params.at(following), seen ++ page.items)
case _ =>Future.successful(seen ++ page.items)

Page.isLast is nextPage.isEmpty and says the same thing more briefly. The page.items.nonEmpty guard is not decoration: some instances advertise a next page forever, and without it the loop runs until the rate limit stops it.

Two more traps worth naming:

  • totalCount is Option, and None is not zero. Several Forgejo endpoints omit x-total-count entirely. Treat None as "unknown".
  • page and limit travel together. This library always sends both, because list endpoints that receive a lone limit have been observed to ignore it and return the entire collection — 862 forks, 5233 stargazers in the captured fixtures.

Or let PageWalk drive the loop, on any listing in the library:

importcom.worxbend.codeberg4s.paging.PageWalkPageWalk.all(PageParams.First): params =>
client.issues.list(owner, name, IssueQuery.Empty, params)

PageWalk.fold and PageWalk.foreach are the bounded-memory forms — reach for those on a repository with tens of thousands of issues.

A walk visits at most PageWalk.MaxPages (10 000) pages, so an instance that offers a next page forever cannot hang your process. Reaching that cap with the server still offering another page fails the Future with WalkTruncated(pagesVisited, resumeFrom) rather than handing back what it had gathered: a short answer shaped exactly like a complete one is the failure mode this whole section exists to prevent. resumeFrom is the window the walk was about to request, page size included, so continuing is PageWalk.all(resumeFrom). A listing whose last page happens to be the ten-thousandth and offers nothing further has ended naturally and succeeds.

Configuration

importcom.worxbend.codeberg4s.BaseUriimportcom.worxbend.codeberg4s.CodebergConfigimportcom.worxbend.codeberg4s.UserAgentimportcom.worxbend.codeberg4s.ValidationErrorimportcom.worxbend.codeberg4s.auth.Authimportcom.worxbend.codeberg4s.paging.PageSizeimportcom.worxbend.codeberg4s.retry.RetryPolicyimportscala.concurrent.duration.DurationIntvalselfHosted:Either[ValidationError, CodebergConfig] =for
base <-BaseUri.from("https://my-forgejo.example/api/v1")
agent <-UserAgent.from("my-app/1.0")
size <-PageSize.from(50)
yieldCodebergConfig(
baseUri = base,
auth =Auth.Anonymous,
retry =RetryPolicy.Default,
userAgent = agent,
defaultPageSize = size,
connectTimeout =10.seconds,
readTimeout =30.seconds,
maxResponseBodyBytes =CodebergConfig.DefaultMaxResponseBodyBytes,
maxDownloadBodyBytes =CodebergConfig.DefaultMaxDownloadBodyBytes,
)

Every field naming a domain concept is a validated type, so a misconfigured client fails at construction rather than on its first call. The timeouts and the two byte bounds are plain quantities and are taken as given. CodebergConfig.toString is safe to log: the credential types redact themselves.

Auth is Anonymous, Token(ApiToken) or Basic(username, Password).

Response size

This library reads a whole response into memory; it does not stream. So every request carries a byte bound, and a body that passes it is abandoned part-read as CodebergError.Transport(ctx, TransportCause.ResponseTooLarge(detail)).

  • maxResponseBodyBytes — 16 MiB, applied to every textual response. The largest JSON body Forgejo produces is a file's contents, a blob capped by the instance's default_max_blob_size (10 MiB on codeberg.org) and then base64-encoded, which costs four bytes per three; 16 MiB clears that.
  • maxDownloadBodyBytes — 50 MiB, applied only to client.downloads, which fetches ZIP archives. An artifact is whatever a workflow uploaded, so nothing about default_max_blob_size bounds it, and one shared number would have had to be either too small for ordinary artifacts or too large to bound JSON usefully.

Exceeding either bound is not retried. Repeating the call would download the oversized body once per attempt, which turns one oversized response into maxAttempts of them.

Retries

RetryPolicy.Default is 3 attempts, 250 ms base delay, 8 s ceiling, full jitter, and it honours Retry-After. RetryPolicy.Off disables retrying entirely.

Eligibility is decided per operation, not per policy:

  • Every GET is retried — they are safe.
  • POST and PATCH that create or edit something (issues.create, pulls.merge, issues.createComment, …) are never retried. Repeating them could file the same issue twice.
  • The three mark-read calls (notifications.markAllRead, markThreadRead, markRepositoryRead) are retried despite being PUT and PATCH: they carry no body and no query, and marking an already-read thread read again is a no-op.

Sharing an sttp backend

CodebergClient(config) creates and owns a backend, and close() shuts it down. If your application already has one:

importcom.worxbend.codeberg4s.CodebergClientimportcom.worxbend.codeberg4s.CodebergConfigimportsttp.client4.Backendimportscala.concurrent.ExecutionContextimportscala.concurrent.FuturedefsharedBackend:Backend[Future]
defconfig:CodebergConfiggivenExecutionContext=ExecutionContext.global
// close() will NOT close sharedBackend — you own it.valshared:CodebergClient=CodebergClient.usingBackend(config, sharedBackend)

sttp models the connect timeout as a property of the backend rather than of a request, so connectTimeout is ignored on this path — configure it on the backend. readTimeout is per request and is honoured either way.

Tokens are not logged, ever

ApiToken is a redacting type. Its toString is ***, string interpolation of it is ***, and reveal is the only way to get the material out — a method name you will notice in review. No CodebergError can contain a credential: the URI inside CallContext is redacted before the context is built, and CodebergException's message is CodebergError.describe, which is assembled only from that redacted context and from server-supplied text.

There are tests that assert exactly this, because a leaked token in an exception message is the failure mode that matters most in a library like this one.

Telemetry

The library has no logging dependency and writes nothing to stdout. If you want request visibility, implement the Telemetry port and pass it at construction:

importcom.worxbend.codeberg4s.CallContextimportcom.worxbend.codeberg4s.CodebergClientimportcom.worxbend.codeberg4s.CodebergConfigimportcom.worxbend.codeberg4s.CodebergErrorimportcom.worxbend.codeberg4s.auth.Authimportcom.worxbend.codeberg4s.core.Telemetryimportscala.concurrent.ExecutionContextimportscala.concurrent.FuturegivenExecutionContext=ExecutionContext.global
finalclassPrintingTelemetryextendsTelemetry[Future]:overridedefonRequest(ctx: CallContext):Future[Unit] =Future.successful(println(s"-> ${ctx.operation}${ctx.method.wireName}${ctx.uri}"))
overridedefonResponse(ctx: CallContext, status: Int):Future[Unit] =Future.successful(println(s"<- ${ctx.operation}$status in ${ctx.durationMs}ms"))
overridedefonError(ctx: CallContext, error: CodebergError):Future[Unit] =Future.successful(println(s"!! ${ctx.operation}: ${error.describe}"))
valobserved:CodebergClient=CodebergClient(CodebergConfig(Auth.Anonymous), PrintingTelemetry())

Three guarantees worth knowing:

  • A telemetry failure never fails the call it was observing. Instrumentation that breaks must not break the application it instruments.
  • Everything a callback receives is already redacted, so an implementation cannot leak a credential by logging what it is handed.
  • onRequest and onResponse fire once per attempt. A retried call produces several of each, which is how you see a retry storm. onError fires once per failed attempt and once more for the failure the caller finally receives.

Telemetry.noOp is the default and allocates nothing per call, so an unconfigured client is completely silent.

Development

./mill modules.__.compile # warnings are errors
./mill modules.__.test # unit tests (1072 today)
./mill modules.__.reformat # scalafmt
./mill modules.__.fix # scalafix
./verify.sh # the pre-handoff gate
./verify.sh --with-slow # plus duplication and CRAP analysis
./verify.sh --nightly # plus mutation testing

Every Scala block in this file compiles against the current sources under the project's own flags (-deprecation -feature -Wunused:all -Wvalue-discard -Wnonunit-statement -Werror). That check is manual today; wiring mdoc so the build enforces it is a Phase 4 line in docs/ROADMAP.md.

verify.sh runs format check, lint, a zero-warning compile, the unit suite, an architecture-boundary check and coverage, in that order. Its slow and nightly steps skip with a printed notice when their runner script is absent, so read the step output rather than trusting the exit code — see docs/CONSTITUTION_MAPPING.md for what is actually proven today.

Integration tests

modules/it is the environmentally-unsuitable boundary: it needs Docker or the live network, so verify.sh never runs it. Both suites tag every test Integration.

# Container suite — starts codeberg.org/forgejo/forgejo:12, bootstraps an admin,# a repository and a token, then exercises the client against it. Needs Docker.
./mill modules.it.test.testOnly com.worxbend.codeberg4s.it.ForgejoContainerSuite
# Override the image, e.g. to match what CI has cached:
FORGEJO_IT_IMAGE=codeberg.org/forgejo/forgejo:12 \
./mill modules.it.test.testOnly com.worxbend.codeberg4s.it.ForgejoContainerSuite
# Live read-only smoke against https://codeberg.org. Opt-in; without the# variable every test is *skipped*, not failed, so a disabled run is visibly# different from a run with nothing to do.
CODEBERG_IT=1 \
./mill modules.it.test.testOnly com.worxbend.codeberg4s.it.CodebergLiveSmokeSuite
# A token only widens the rate limit; the suite asserts nothing that needs one.
CODEBERG_IT=1 CODEBERG_IT_TOKEN=... \
./mill modules.it.test.testOnly com.worxbend.codeberg4s.it.CodebergLiveSmokeSuite
# Both, plus everything else in the module:
CODEBERG_IT=1 ./mill modules.it.test

The live suite is read-only by construction — six GETs against a repository it does not own — and must stay that way.

Design decisions

Recorded in docs/adr/. Start with ADR-0005, which explains why the API is Future-based, and ADR-0001, which explains why the models are hand-written rather than generated from the Swagger spec.

The measured divergences between the pinned Swagger spec and what Codeberg actually returns are in docs/HAZARDS.md. Two of the project's original assumptions turned out to be wrong there, which is why the models are built from captured fixtures rather than from the spec.

Changes

CHANGELOG.md, Keep-a-Changelog format, semantic versioning.

Licence

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages