Changelog
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
Nothing yet.
0.1.0 — unreleased
First release. build.mill publishes 0.1.0-SNAPSHOT until the tag is cut;
this entry is the release note that tag will carry.
Nothing has been published to Maven Central yet, so "Changed" and "Fixed" below
are not a migration path from an earlier release — there is no earlier release.
They are there because the surface freezes at this tag: everything listed was
changed deliberately before the freeze, and anyone who built against a
0.1.0-SNAPSHOT jar in the meantime is the one audience that has to read them.
Added
- Public
FutureAPI.CodebergClientexposes nine accessors —repos,users,issues,pulls,organizations,notifications,misc,downloadsandversion— which between them reach 38 API classes and 439 REST operations against Codeberg, Forgejo or any Gitea-compatible instance. The base URI is configuration, not a constant. That is 439 of the 439 in-scope operations, 100 % (docs/API_INVENTORY.md§0), and 86.8 % of the 506 the pinned spec declares — the remaining 67 areadmin,activitypubandpackage, whichPLAN.md§0 puts out of scope for v1. - Two error rails over one code path. Every operation exists twice: the
convenience rail fails the
FuturewithCodebergException, and.attemptreturnsFuture[Either[CodebergError, A]]and never fails. Both are projections of the sameExec[F]pipeline, so they cannot drift. - A closed error ADT with call context.
CodebergErrorhas six cases —Transport,Api,DecodingFailed,Validation,RetriesExhausted,WalkTruncated. Every remote case carries aCallContext(a stable operation id, the HTTP method, the redacted URI, the server'sx-request-id, the attempt duration), so a caller can tell which call failed without correlating logs. Forgejo's error payloads are parsed intoApiErrorBodyagainst captured samples. - Link-header pagination. List operations return
Page[A]with the items, the total count and the next page parsed from the RFC 8288Linkheader rather than guessed from a page counter.paging.PageWalkprovides the sequentialall,foldandforeachdrivers, so walking every page is opt-in and never materialises the whole collection by accident. - Retry that honours the server.
RetryEngineretries429and5xxon idempotent methods only, with jittered exponential backoff, and prefers the server'sRetry-Afterover its own schedule when the policy allows it.POST,PATCHandDELETEare never retried automatically. - Tokens are redacted everywhere.
ApiTokenrenders as***intoStringand in string interpolation, and noCodebergError— including the URI captured inCallContext— can carry one. There are tests that assert it. - A
Telemetryport for request/response visibility. The library has no logging dependency and writes nothing to stdout. - A bounded response body.
CodebergConfigcarriesmaxResponseBodyBytes(16 MiB, every textual response) andmaxDownloadBodyBytes(50 MiB, the two ZIP-fetching operations underclient.downloads). Exceeding either isTransportCause.ResponseTooLarge, which is deliberately not retryable — a retryable oversize failure would have downloaded the same oversized body once per attempt. - Hexagonal module layout, published as five artifacts under
com.worxbend:codeberg4s-domain(no dependencies at all),codeberg4s-core,codeberg4s-codec(jsoniter-scala),codeberg4s-transport(sttp client4) andcodeberg4s-client. Namingcodeberg4s-clientpulls in the other four transitively. The jars are Java 25 bytecode (class-file major version 69); an older JVM cannot load them. - Verification. 3,658 unit tests, 54 golden fixtures captured from the live
API, scoverage thresholds enforced by
scripts/coverage-gate.sc, and averify.shgate that also enforces the architecture boundaries (nosttp,upickle,ujson,Future,ExecutionContext,Await,Promiseorblockingimported belowclient; nosttpincodec; no bare exceptions). Measured on the commit this entry describes:domain100.00 % statement and 100.00 % branch coverage,core96.59 % / 92.48 %,codec95.20 % / 91.47 %, and the CRAP gate reports a worst method of 28.0 over 2,217 methods against a limit of 30.
Changed
Every item here is a breaking change against the 0.1.0-SNAPSHOT builds, taken
now because the tag is what freezes the surface.
- Response models cannot be constructed from outside the library. All 131
response types —
Repository,Issue,PullRequest,User,Organization,NotificationThread,ServerVersion,ApiErrorBodyand the rest — have aprivate[codeberg4s]constructor, soapplyandcopyare unavailable to callers. Reading fields and pattern matching are unaffected. This is the deliberate price of not owing a major version every time Forgejo adds a field to a response. A test fixture that used to build one directly now has to obtain it from a client call or decode a recorded payload. CodebergErrorhas a sixth case,WalkTruncated(pagesVisited, resumeFrom), andPageWalk.all/fold/foreachnow fail with it when they hit the page cap. They previously returned the pages gathered so far, which a caller could not tell apart from a genuinely short collection. An exhaustivematchonCodebergErrorneeds the new clause.core.Paginationis removed, along with itslistAllandfoldPagesmethods.com.worxbend.codeberg4s.paging.PageWalkreplaced it:PageWalk.all(start)(fetch)andPageWalk.fold(start, zero)(fetch)(step).- A response body is bytes, not a
String.CodebergResponse.bodyis aResponseBodyandDecode[A].applytakes one. Ask it forbytes,textorisBlank. A test fake building a response writesResponseBody.utf8("[]"), orResponseBody.Emptyfor a204. - A JSON number is
JsonValue.Int64(Long)orJsonValue.Decimal(BigDecimal).JsonValue.Numsurvives as an object holding the constructors and an extractor, soNum(7)andcase Num(value)still compile, but it is no longer a type and no longer a case of the ADT. JsonFieldsholds the parser'sVector[(String, JsonValue)]rather than aMap.fields.underlyingbecomesfields.entriesorfields.toMap. No accessor changed, so a DTO that only callstext,number,nestedand friends needs no edit.UploadAssetandUploadAttachmentare built throughof/named/as, not through their constructors, andasvalidates the media type, so it answersEither[ValidationError, …]. Both also compare their content by its bytes now, as doBinaryResponse,RequestBody.BinaryandRequestBody.Multipart; code that relied on two byte-identical values staying distinct has to sayeq.CodebergConfiggainsmaxResponseBodyBytesandmaxDownloadBodyBytes, so a call to the full constructor needs two more arguments.CodebergConfig.DefaultMaxResponseBodyBytesandDefaultMaxDownloadBodyBytesreproduce whatCodebergConfig(auth)uses.UserTokenApi.RedactedBodyis removed. Every credential-bearing response is now redacted the same way by the pipeline; see Security below.- The jars require Java 25. They were Java 17 bytecode before. A Java 17 or
Java 21 JVM fails with
UnsupportedClassVersionError.
Security
- A base URI carrying credentials is rejected.
https://user:password@forge.example/api/v1used to be stored verbatim and concatenated into the redacted URI that everyCallContextcarries, so the password reached every log line written about a failed call.BaseUri.fromnow rejects user information, a query string and a fragment — without echoing the offending value — andRedaction.uristrips the same three parts independently, because test fakes call it with a plainStringit has not vetted. Embedded credentials were never sent as anAuthorizationheader by the JDK HTTP client underneath, so code relying on them was making anonymous requests and leaking the password at the same time. UseAuth.Basic(username, password). - Dot segments are rejected in every single-segment identifier.
Owner,RepoName,Username,OrgNameand eleven more promised in their Scaladoc that an accepted value could not forge a path, but accepted the exact values"."and"..". No working traversal against a real deployment is claimed here; the narrower claim stands on its own. Git itself forbids both as path components, so no legitimate name is lost. - A credential cannot reach a decode-failure snippet.
DecodingFailedcarries an excerpt of the body that did not match, which is what makes it diagnosable — except for the four responses whose success body is a live secret (a created access token, an OAuth2 client secret, and the Actions runner registration token in its several forms). Those now report*** (N bytes withheld). The decision moved intoApiPipelinebecauseTelemetry.onErrorfires while the attempt is being settled, before any endpoint could rewrite the failure. - A configured credential is applied after the caller's headers, which is
what its Scaladoc always claimed and the opposite of what the code did. No
endpoint in this library sets an
Authorizationheader today, so this was latent rather than live.Authorization,Proxy-AuthorizationandUser-Agentare also dropped from caller headers first, so a request cannot carry two credentials under two spellings of one name. - A response body is bounded — see
maxResponseBodyBytesabove. Before this, the only thing standing between the client and its heap was the read timeout multiplied by the peer's bandwidth.
Fixed
- The HTTP client the library created is now actually shut down.
CodebergClient.close()calledbackend.close(), and that call released nothing: sttp only ends a client it built itself if theExecutionContextit was handed is not also ajava.util.concurrent.Executor, and every ordinaryExecutionContextis one. An application building a client per instance leaked a connection pool and a selector thread per instance. The JDKHttpClientis now built here and ended withshutdown()— notclose(), which blocks until in-flight requests finish, whereasclose()on this library's client is documented to return promptly. - A retry waiting in backoff when the client closes now fails. It used to
be left with no outcome at all: not fulfilled, not failed, so an application
shutting down cleanly waited on that
Futurefor as long as the process lived. Such a call now fails withjava.util.concurrent.CancellationException— not aCodebergError, because closing a client while it is in use is a defect in the calling program and must not be laundered into something a caller would retry. RetriesExhaustedis reported only when the policy actually gave up. A call that met a retryable503and then a terminal404reportedRetriesExhausted(ctx, 2, Api(404)), so the same404produced two different error shapes depending on what preceded it. It now reports the bareApi(404).- A failing telemetry sink no longer fails the call it was observing.
- A duplicate JSON key has a settled meaning — the first occurrence wins, at every object width, with tests at both sides of the width threshold.
- A colour with a trailing
U+0085,U+2028orU+2029is rejected. Java regular expressions let$match before those line terminators andtrimdoes not remove them, soLabelColorused to accept the control character and quietly discard it.
Performance
Every figure below is from scripts/alloc-bench.sh on OpenJDK 64-Bit Server VM
25.0.4+7-LTS. B/op is heap bytes allocated per operation, the median of the
measured rounds. That harness is deliberately not part of verify.sh: it is a
measurement tool, not a gate. Read the header of scripts/alloc-bench.sc
before quoting any of this — in particular, wall-clock times on a working
machine vary by tens of percent between rounds and are a direction of travel,
not a figure.
- Decoding a 170,251-byte page of 50 repositories allocates 1,133,632 B/op,
down from 1,944,816 — 41.7 % less. Two changes account for it.
JsonFieldsused to copy the parser'sVector[(String, JsonValue)]into aMaponce per object at every nesting level, which was 819,600 bytes of the old total; it now reads the vector directly, scanning names for a narrow object and probing a hash index for one of eight fields or more. (The index is not decoration: a plain scan was measured first and made assembling oneRepository24 % slower than theMapit replaced.) Separately, a whole JSON number is aLongrather than aBigDecimal, worth 32.0 bytes per number — 33.7 % off a thousand-element array of nine-digit identifiers. - The end-to-end response path allocates 963,360 B/op against 1,303,928 for
the same decode done via a
String— 26.1 % less. A body used to be decoded from the socket's bytes into aStringby sttp and then encoded straight back into abyte[]by the parser. The saving is 340,568 bytes and the page is 170,251 bytes, so it is those two copies and essentially nothing else. The harness still measures both paths side by side (decode.page-50-bytesagainstdecode.page-50-viastring) so the claim can be re-checked. - A Forgejo timestamp parses in about 25.7 ns and 40 B/op, from about 680 ns
and roughly 1.5 KB.
OffsetDateTime.parseis a general RFC-3339 reader; Forgejo emits exactly one layout. The fast path reads that layout by index and answersNonefor anything else, so the JDK stays the authority on what is valid. The two paths were checked against each other over a million generated inputs. - Smaller allocations removed from paths every call walks: the redacted URI
is built once per call rather than once per attempt, and with one
StringBuilderrather than oneStringper percent-encoded octet; theLinkheader is parsed once per response rather than up to three times, and in linear rather than quadratic time;Telemetry.noOpno longer builds a varargsSeqper callback;FutureExec.attemptuses onetransformrather than amapand arecover, which is oneFutureand one executor dispatch instead of two; four hand-written element-decoding folds became one tail-recursive helper that stops at the first failure instead of walking the rest of the array.
Known limitations
- The mutation score is unproven.
scripts/mutate.shexists and the Stryker4s runner is proven against this build, but no run with the real test command has ever produced a score for this repository, so the ≥ 80 % target indocs/ROADMAP.mdis a target and not a result. - Duplication is tracked, not eliminated.
scripts/cpd.shreports 363 duplication groups at 40+ tokens (PMD 7.26.0), andverify.sh --with-slowpasses because it fails on an increase over that recorded number rather than on the existence of duplication.docs/LEDGER.md§ "Helpers awaiting promotion" names the ones with owners. - Walking every page goes through
paging.PageWalk, which takes the listing operation as an argument; there is nolistAllconvenience method on the client resource groups themselves. - The library reads a whole response into memory and never streams, so a large
artifact is bounded rather than chunked — see
maxDownloadBodyBytesabove. - ScalaCheck property suites carry the
Propertytag and are excluded from the default gate, and one of the three areasPLAN.md§6.3 names still has none: the page walker has example-based tests only.docs/ROADMAP.mdtracks the rest, anddocs/CONSTITUTION_MAPPING.mdis the authority on which quality runners are actually proven against Mill and Scala 3. - No binary-compatibility baseline — 0.1.0 is that baseline. MIMA is wired
in
build.milland covers all five artifacts, but it has nothing to compare against until 0.1.0 is on Maven Central, so it reports nothing until 0.1.1.RELEASING.md§ "Binary compatibility is checked by MIMA" is the procedure, and measures what MIMA does and does not see through the response models'private[codeberg4s]constructors. - Out of scope by design: OAuth2 token acquisition, ActivityPub federation, admin endpoints, attachment streaming above 50 MB, and Scala.js / Native.