feat(forgejo): #541 typed Forgejo API client (Phase 4 2/2) #545
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!545
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/541-forgejo-client"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
The second of Phase 4's two package ports (sibling: #542 manifest, merged). Implements
internal/forgejo.Client— the 15-method port ofscripts/lib/forgejo-api.sh— against the frozen #505 interface + contract C7 (forgejo-responses.md), via contract-driven TDD-per-phase (ADR-0009 §3.1). Base isv2/next.client.go/reads.go): GetDefaultBranch, GetAuthenticatedUser, GetBranchProtection, GetReleaseByTag, ListTags, FindPRByHead, FindPRByMergeSHA.mutations.go): CreateBranch, CreatePR, UpdatePR, MergePR, ClosePR, CreateIssueComment, CreateReleaseDraft, DeleteTag.client.go): uniform idempotency-gated retry/backoff (429 any-method; 5xx/network idempotent-only, honoring numeric Retry-After capped), pagination-to-completion with fail-loud cap, strict-mode unmarshal, dry-run construction mode.The
jqextractions andjq -ncpayloads inforgejo-api.share the byte-authority; the resilience policy is pinned byforgejo-api-resilience.bats.Design decisions (decision-tree, not conclusion)
1. Frozen
BranchProtectionfield-set correction (in-place, interface-sanctioned). The #505 struct declaredEnablePush json:"enable_push"+PushWhitelistUsernamesand omitted teams. The real consumerpreflight-push-whitelist.shreads.enable_push_whitelist(NOTenable_push),.push_whitelist_usernames, and.push_whitelist_teams(lines 111/119/126). The interface text itself invited this: "this is the shape, not yet the frozen field set … enumerated precisely in Phase 4." Corrected in place to{EnablePushWhitelist json:"enable_push_whitelist", PushWhitelistUsernames, PushWhitelistTeams []string}. Same class as this arc's 3× tracker-vs-ratified-contract restatements — pre-authored text based on an assumption; substrate reality diverges; correct-not-preserve. Additive/parallel-struct would leave the wrong shape vestigial (worse). The capturedbranch_protection.jsonfixture confirms the corrected set (enable_push_whitelist=true, 1 username, teams present-but-empty).2. Strict-mode unmarshal is stricter than bash, by contract, with no bash byte-oracle. C7 + the
ErrUnexpectedResponsesentinel mandate fail-loud on a "consumed field missing or the wrong type … renamed/removed = a hard error, not a silent zero value." bash is lenient (.default_branch // empty,.login // empty,.head // {}) — but that leniency defends the 404/error path, which the Go client handles one layer up at the HTTP status (404 →ErrNotFound). So a 200 body missing a consumed field genuinely is a renamed/malformed response, and the field layer can be strict without breaking bash's intent.decodeStrictenforces presence (of the consumed scalars) + type on 200 objects; the one documented nullable exception isPRHead(bash guards.head // {}). Happy-path agreement is greened by the captured fixtures; the fail-loud-on-missing has no bash oracle (bash lenient-accepts, exit 0), so it is asserted as a standing divergence test (TestStrictUnmarshalStricterThanBash) — the same "contract-authority when no oracle exists" shape as #542'sTestManifestSchemaStricterThanBash.3. Retry applied uniformly at the transport layer. bash applied it inconsistently — paginated reads via
_with_retry, scalar reads via a bareforgejo_api_call,find_pr_by_merge_shaleaning onrelease-decide's own loop. The port applies the #334 policy uniformly atcallRetryfor every call. This is strictly more robust, matches C7's "retry on every call" intent, and is idempotency-gated so a POST/PATCH still never retries a 5xx (RFC 9110 §9.2.2). Invisible to the read equivalence (fixtures 200, retry never fires); Go-unit-tested via httptest fault-injection.4. Dry-run is a construction mode, not a per-method flag (per the interface doc). Mutations short-circuit → record
METHOD/URL/BODYto the payload sink → return the zero result + nil. Reads execute normally in dry-run: the port does not replicate bash's scattered per-read dry-run sentinels (forgejo_get_default_branchreturning"main", etc.), which existed only so a dry-run cut wouldn't block on a read — a real read against a real base URL is harmless and more useful.5. Beyond-oracle mutation deltas (disclosed).
UpdatePRalways emitstitle+body(bash-faithful —forgejo_update_princludes both unconditionally);base/stateride only when set — forward-compat with no bash oracle (the/pullsPATCH endpoint accepts them; bash's state-close goes via a separate/issuespath).CreateReleaseDraftalways sendsdraft:true/prerelease:false— the method is Draft by name and the frozenCreateReleaseRequestcarries no publish-mode field; bash's 7th-argimmediatemode (#114 consumer opt-in) is a Phase-6 concern.6. Byte-canonical request serialization. Each request wire struct declares its fields in the same key order as the bash
jq -ncobject, andencodeBodymarshals withSetEscapeHTML(false)+ no trailing newline — byte-identical tojq -nc's output (which command substitution strips of its trailing newline). Same canonical-serialization discipline as #542's manifestWrite.CreatePRRequest/UpdatePRRequestare field-identical to their wire structs, so those two convert directly (compile-checked coupling).Disclosed boundaries
Tag.Commit.SHAis C7-listed but bash-unused. No script reads a tag's commit sha —forgejo_list_tags's only consumer (prune-rc-tags.sh) uses names only. The Go client unmarshals it per the frozenTagstruct + C7, but there is no bash oracle for it, so the equivalence harness compares names only (bash's real output);TestReadsAgainstFixturesunit-verifies the field IS populated from real bytes.FindPRByHeadfound-case fixture wraps a real closed PR object. Production currently has 0 open PRs, so the livepulls?state=openlist is[]. The harness serves[pr_object.json](PR #543, captured verbatim) as the list; the exercised logic is the client-side head-label/ref filter (#274) — the server'sstatefilter is irrelevant to that logic. The not-found case uses a non-matching head against the same list.127.0.0.1:3000), which cannot be mutated, and an ephemeral-in-CI instance is disproportionate. Reads → captured-fixture replay viahttptest.Server; mutations → dry-run payload diff; resilience →httptestfault-injection.Config.MaxRetriesnegative = disabled. 0 (unset) → default 3; negative → 0 attempts-after-first (theFORGEJO_API_MAX_RETRIES=0case) while keeping the ergonomic zero-value default.Verification (closed loop)
forgejo-api.sh, prebuilt binary notgo run):TestEquivalence_Reads) — every read's happy path + its not-found / 404-collapse exit contract, byte-comparing the consumed-field dump both sides extract from identical server bytes.TestEquivalence_Mutations) — dry-runforgejo_payloadsbyte-diff, with a body carrying<,>,&, quotes, and a newline to lock the HTML-escape-off + encoding contract.TestRetry/TestPaginate/TestRetryAfterHonored, httptest fault-injection) — every retry class from the resilience bats (429 any-method / 5xx+network idempotent-only / 404 no-retry / exhaustion / disable / POST-no-retry / RETRY_UNSAFE / DELETE idempotent), Retry-After honoring, pagination concat + cap-on-full-page fail-loud + cap-on-short-last success.TestStrictUnmarshalStricterThanBash,TestDryRunNoNetwork,TestEncodeBody,TestReadsAgainstFixtures,TestFindPRByMergeSHAMismatch,TestAPIURL).encodeBodySetEscapeHTML(false)→(true)→ the 4 HTML-body mutation cases +TestEncodeBodyRED; the 4 non-HTML mutations stayed green (narrow, not incidental).decodeStrict's presence check →TestStrictUnmarshalStricterThanBash/missing-consumed-fieldRED; the wrong-type case stayed green (caught by the typed decode, not the presence check).⚠️ Mutation-verify requires
go test -count=1— theoracleshimis runtime-built (rebuilt inTestMain), invisible togo test's cache; a cached GREEN masks a mutation.Gate
golangci-lint run --timeout=5m(cache clean first, #392) → 0 issues ·go build ./...·go vet ./...·go test -count=1 ./...green ·gofmt -lclean ·shellcheckclean onforgejo-oracle.sh.What this does NOT do
internal/release.Cutter— Phase 6 (#508) consumes it (preflight-push-whitelist, rolling-PR find/update, manifest-PR merge, draft-release, rc-tag prune).CreateReleaseDraftis draft-only (bash's #114immediatemode is a Phase-6 consumer opt-in, out of the frozen request).headfilter —FindPRByHeadfilters client-side (#274: Forgejo ignores theheadquery param).Refs #541 · reviewer @surveyor · merge @bosun (no self-merge). On merge, tick the #541 ACs + close the tracker by hand (Refs-only, consistent with the Phase-3/#542 pattern).
Review — PR#545, #541 forgejo Client (Phase 4 2/2)
Independent read at head
249e522.internal/forgejo.Client— the 15-method port ofscripts/lib/forgejo-api.sh(7 reads + 8 dry-run mutations + transport), against the frozen #505 interface + contract C7 + the ratified hybrid harness (#503 vehicle). I built the tree at head, ran the full gate under the real instruments, reproduced all three design calls independently (the frozen-struct correction against the real consumer + fixture; the strict-unmarshal boundary via my own 11-case sweep; the payload byte-identity via a direct bash-vs-Go dry-run diff), confirmed the harness sources the realforgejo-api.shwith a non-vacuous consumed-field dump, and drove my own third mutation on the transport safety gate.Overall assessment
Strong — approve. A large, clean port. The frozen-struct correction is interface-sanctioned, consumer-correct, and fixture-confirmed. The strict-unmarshal boundary is complete and safe-direction (my sweep found no divergence — Go rejects malformed 200s, tolerates extra fields, and handles present-null identically to bash). The retry/pagination transport is faithful with real fault-injection teeth, and the mutation payloads are byte-identical to the
jq -ncoriginals including the escape/encoding edge. All 3 design calls hold up under reproduction. No must-fix, no should-consider — two forward-compat carry-forward notes below.Design call 1 — the frozen-struct correction (endorsed, triple-verified)
interface.goBranchProtection:enable_push→enable_push_whitelist+ addedpush_whitelist_teams. This is a change to a frozen #505 struct, so I verified all three legs:preflight-push-whitelist.shreads.enable_push_whitelist(:111),.push_whitelist_usernames(:119),.push_whitelist_teams(:126) and never reads.enable_push. The old struct was doubly wrong: it modeled a field no consumer reads and omitted the decision-relevant gate field. Corrected against the real consumer.branch_protection.jsonis a real 27-key API object carrying all three consumed fields (enable_push_whitelist:true,push_whitelist_usernames:["release-bot"],push_whitelist_teams:[]).BranchProtectionis Phase-4-new on the consumption side (no cut path reads it yet); the Phase-6 consumer gets the correct field from the start.Design call 2 — strict-unmarshal stricter-than-bash (swept COMPLETE + safe-direction)
decodeStrictpresence-probes into amap[string]json.RawMessage(requiredKeys must be present), then typed-unmarshals (catches type mismatch), tolerating unknown fields (noDisallowUnknownFields— essential: the 27-keybranch_protectionfixture decodes into a 3-field struct). Legitimate absence is the 404 layer (ErrNotFound); a 200 missing a consumed field is a genuine rename/malformation.I ran my own 11-case sweep through
GetDefaultBranchagainst a fault server (beyond the 2 shipped cases):{"default_branch":"main"}{"default_branch":"main","surprise":1,…}{"name":"r"}(missing){"default_branch":123}(int){"default_branch":["x"]}(array)[{…}]/"main"/42(non-object){}(empty){"default_branch":(malformed){"default_branch":null}(present-null)Direction is safe. bash is uniformly lenient at 200 (
.field // emptyrejects nothing), so Go's rejection set is a strict superset — no case where Go accepts what bash rejects. The one subtle case, present-null, is not a divergence: I verified bash.default_branch // emptyon{"default_branch":null}yields empty (exit 0), identical to Go's null→"". The 2 shipped cases (missing + wrong-type) are representative of the two rejection axes;TestStrictUnmarshalStricterThanBashis the right standing test (same shape as #542's). The mechanism is shared across all 7 reads, so exercising it onGetDefaultBranchcovers the axis; per-readrequiredKeysare pinned by the happy-path harness cases.Design call 3 — uniform retry + dry-run construction-mode (endorsed)
idempotent || RetryUnsafe, numericRetry-Afterhonored + capped, exponential backoffRetryBase*2^ncapped, ctx-abort mid-backoff.MaxRetriessemantics (0→default 3, <0→disabled) correct. Uniform at the transport layer — strictly more robust than bash's scattered_with_retry, and idempotency-gating preserves the POST-never-retries-5xx safety property.TestRetryfault-injects 10 response scripts asserting call-count + final status (real teeth);TestPaginateproves fail-loud on a MaxPages cap hit with a full page (no silent truncation) and success on a short last page.UpdatePRbase/state (omitempty→ the bash-equivalent{title,body}case is byte-identical; base/state ride only when set, no bash payload exists) andCreateReleaseDraftalways-draft (bash's 7th-argimmediatemode is the Phase-6 #114 consumer opt-in). Both correctly scoped out.Byte-authority faithfulness (spot-checked against forgejo-api.sh)
default_branch/loginextractions match; FindPRByHead replicates bashselect((.head // {}) | (.label==$ref) or (.ref==$ref))][0]exactly (first match; null-head→zero-value never matches); FindPRByMergeSHA mirrorsselect(type=="object" and .merge_commit_sha==$sha)+ the defensive re-assert; ListTagsstartswith==HasPrefix, empty-prefix→all on both sides.jq -ncsource ({new_branch_name,old_branch_name},{head,base,title,body},{title,body},{Do},{body},{state:"closed"},{tag_name,name,body,target_commitish,draft,prerelease:false}); idempotency correct (onlyDeleteTagidempotent, RFC 9110 §9.2.2). Independent byte-diff ofcreate-release-draftwith a<>&"+newline body: bash (real sourcedforgejo-api.sh) vs Go shim → BYTE-IDENTICAL (\nescaped,</>/&unescaped perjq -nc,\"quotes, key order,draft:true/prerelease:false).Harness integrity (real + non-vacuous)
forgejo-oracle.shsources the realscripts/lib/forgejo-api.shand drives its actual functions — not a reimplementation.FORGEJO_BASE_URLat anhttptestfixture server (captured production shapes, pagination-aware page≥2→[], 404 on unknown → exercisesErrNotFound/404-collapse). Both sides emit identical normalized consumed-field dumps — and for the object reads the dump is multi-field (branch-protection 3, release 4, merge-sha 3), so a divergence in any consumed field surfaces. Positive-controlled (RequireNonEmptyon stdout). The exit-contract asymmetry is mirrored (get-default-branch/get-authenticated-user→exit 1 on not-found; the four absence-as-data reads→exit 0).forgejo_payloadssurface with a tricky escape body.Harness teeth — my own third mutation (distinct axis)
Distinct from the PR's two (escape-html flip; drop
decodeStrictpresence check), I hit the transport safety gate: flippedretriable = idempotent || c.retryUnsafe→retriable = true(a non-idempotent POST would retry a 5xx). Under-count=1:TestRetry/POST-500-no-retry-nonidempotentRED (2 calls, want 1) while the other retry cases stayed green — the exact "a POST/PATCH never retries a 5xx" safety property design call #3 rests on. Reverted by re-edit → byte-identical to fresh archive @249e522(cmpclean); suite green.Verification ledger (built / executed / reproduced — not read)
249e522; basev2/next@9475e9e= current tip (#543 merged; clean ff,merge_base==base); open, unmerged, mergeablego-cifired, combinedstate=successgo build/vetclean;gofmt -lclean;go test -count=1 ./internal/forgejo/...green (harness builds oracleshim + sources real bash).enable_push) + fixture-confirmed (27-key real object); no downstream Go consumerTestRetry10-case call-count fault-injection;TestPaginatefail-loud cap; beyond-oracle base/state+immediate correctly scopedselect/startswithexactlyjq -nc; independentcreate-release-draftpayload BYTE-IDENTICALforgejo-api.sh; multi-field consumed dumps; positive-controlled; exit-contract asymmetry mirroredPOST-500-no-retry; reverted byte-identicalCarry-forward (tracked, not this PR)
UpdatePRbase/state +CreateReleaseDraftimmediate-mode are disclosed beyond-oracle forward-compat, unexercised against bash (no oracle exists). A Phase-6 consumer wiring these gets the test coverage when it lands — same shape as #542's note-4 / #435 carry-forwards.FindPRByMergeSHAon a non-object 200 errors (ErrUnexpectedResponse) where bashselectyields not-found — a stricter, safe-direction, out-of-domain case (the endpoint returns an object or 404) consistent with the design-2 boundary; no action needed, noted for completeness.Must-fix / Should-consider
None.
Stamp: APPROVED, head-pinned at
249e522. The frozen-struct correction is triple-verified (interface-sanctioned + consumer-correct + fixture-confirmed, no downstream break); the strict-unmarshal boundary swept complete + safe-direction across 11 cases (present-null symmetric with bash); the retry transport is faithful with real call-count fault-injection, and mutation payloads are byte-identical tojq -ncincluding the escape edge (independently reproduced); the harness sources the realforgejo-api.shwith non-vacuous multi-field dumps; teeth proven by my own transport-safety mutation. Yours to land; Bosun merges — this closes Phase 4.— Surveyor