Skip to content

Replace Octokit with GitHubApiClient over HttpClient - #407

Merged
rdeago merged 8 commits into
Tenacom:mainfrom
rdeago:403-github-httpclient
Oct 22, 2026
Merged

rdeago merged 8 commits into
Tenacom:mainfrom
rdeago:403-github-httpclient

Conversation

@rdeago

@rdeago rdeago commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Checklist of related issues / discussions

Proposed changes

GitHubServerAdapter reached the GitHub REST API through Octokit, whose base address is always https://api.github.com. A release on a GitHub Enterprise Server could not reach its own API. The other hosts that #402 adds have no Octokit either.

GitHubApiClient, in src/Buildvana.Tool/Services/ServerAdapters/Internal/GitHub/, replaces Octokit. It is a small client over HttpClient, with the operations GitHubServerAdapter calls:

  • get the repository, for IsPrivateRepositoryAsync;
  • create a draft release, generate its notes, edit it, and delete it;
  • check whether a tag exists, and delete it;
  • upload a release asset.

The API base URL follows the host of the origin remote. For github.com, it is https://api.github.com. Any other host is taken for a GitHub Enterprise Server, at https://<host>/api/v3.

Every request carries Accept: application/vnd.github+json, X-GitHub-Api-Version: 2022-11-28, Authorization: Bearer <token>, and a User-Agent with the name and version of bv. The request and response models are records, serialized through the source-generated GitHubJsonContext. An answer outside the 2xx range fails the build with the status code and the message of the body. A 404 on the tag lookup means that the tag does not exist.

GitHubServerAdapter holds one GitHubApiClient for its whole life. The service provider disposes the adapter, and the adapter disposes the client.

Where the code differs from the issue

  • Upload URL. The issue derives the upload address: https://uploads.github.com for github.com, https://<host>/api/uploads otherwise. GitHubApiClient uses the upload_url that GitHub states in the answer to the creation of the release, as Octokit's UploadAsset does. The server then names its own upload address, and the client derives nothing it cannot measure.
  • Asset label. The upload sends label in the query string, next to name. Octokit set the label with a second request, PATCH .../releases/assets/{id}, which is gone.
  • Tag lookup. TagExistsAsync asks for git/ref/tags/<tag>, singular. Octokit asked for git/refs/tags/<tag>. When no tag matches exactly, that endpoint answers with every tag whose name starts with <tag>, so a missing tag could look present. The delete stays on git/refs/tags/<tag>, the only endpoint that deletes a reference.
  • API version. X-GitHub-Api-Version stays 2022-11-28 instead of the more recent 2026-03-10, for compatibility with GitHub Enterprise Server 3.19 and 3.20. The remarks of GitHubApiClient.ApiVersion hold the reason and the possible version switch dates.

Tests

StubHttpMessageHandler answers by method and URL, with a status code, and records the method, the headers, and the body of each request.

GitHubApiClientTests covers every operation: the URL, the method, the headers, the body sent, and the value read from the answer. The error path covers a refusal of every operation, an answer without a message, an unreadable answer, an unreachable host, a timeout, and cancellation.

Risks

  • A difference between what Octokit sent and what GitHubApiClient sends is invisible to a unit test. The first release after the merge will be the final test. A failure in the asset upload or in the publication of the release comes after the NuGet push. The rollback of bv release then removes the commits, the tag, and the GitHub release, but the packages stay on the feed.
  • The GitHub Enterprise Server base URL is derived from GitHub's documentation, not measured. Buildvana has no such server to test against.

Additional changes

  • IGitService. ServerRelease.DisposeAsync disposed GitService, a singleton that the service provider owns and disposes again at the end of the run. Every consumer held a GitService, so nothing told a borrowed service from an owned one. IGitService does not extend IDisposable, the service provider registers GitService under it, and every consumer depends on it. ServerRelease no longer disposes it.
  • GitHubServerAdapter.IsCloudBuild. Its <value> documentation said that the property always returns false. The property returns true, and the documentation says so.
  • DotNetReleaseIndex.ReadJsonAsync. It returned from inside an await using block, within a try block. The coverage report then counted the closing brace of the try block as a line that no test runs. The method now returns after the try block, as the methods of GitHubApiClient do.

Types of changes

This pull request introduces the following types of changes:

  • Bug fix
  • New feature
  • Test addition / update (no changes to non-test code)
  • Refactor (no changes in public API syntax or semantics)
  • Performance improvement (no changes in public API syntax or semantics)
  • Documentation update (not including changelog)
  • Dependency addition / update
  • Changes to toolchain configuration
  • Changes to CI (workflows, bot / app configurations)
  • Changes to repository files (.gitattributes, .gitignore)
  • Other

Breaking changes

This pull request introduces breaking changes:

  • Yes
  • No

Checklist

  • For all types of changes:
  • For code changes only:
    • The project builds on my machine, via the provided build script, with zero warnings
    • I have added tests that prove my feature works / my fix is effective
    • I have added / modified XML documentation according to changes in code
    • I have checked that all the links I added or modified in XML documentation point to their intended destination
  • For documentation changes only:
    • I have checked and proofread documentation locally
    • I have checked that all the links I added or modified point to their intended destination

🤖 Generated with Claude Code

rdeago and others added 6 commits October 21, 2026 23:11
GitHubServerAdapter reaches the GitHub REST API through Octokit, which fixes the API at api.github.com, so a release on GitHub Enterprise Server cannot reach its API. GitHubApiClient takes the API base URL from the host name of the repository, and sends every request through an HttpMessageHandler that a test can replace. An asset upload goes to the upload_url of its release, as in Octokit. It carries its label in the query string, which saves a second request. When no tag matches a name exactly, git/refs/tags/<name> answers with every tag whose name starts with it, so TagExistsAsync asks for git/ref/tags/<name>. StubHttpMessageHandler answers by method and URL, and records the headers and the body of each request, so that GitHubApiClientTests checks what GitHubApiClient sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ServerRelease.DisposeAsync disposed GitService, which the service provider owns and disposes again at the end of the run. Every consumer held a GitService, whose type exposes Dispose, so nothing told a borrowed service from an owned one. IGitService does not extend IDisposable, and the service provider registers GitService under it. A consumer that is itself disposable then holds no disposable field, and CA2213 has nothing to report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHubServerAdapter reached the GitHub REST API through an Octokit GitHubClient, whose base address is always api.github.com. A release on GitHub Enterprise Server then went to the wrong API. GitHubServerAdapter now holds one GitHubApiClient for its whole life, over an HttpClientHandler with automatic decompression. The service provider disposes the adapter, and the adapter disposes the client. The upload carries the label of the asset, so UploadReleaseAssetAsync sends no second request to set it. DeleteReleaseAsync asks TagExistsAsync whether the tag exists, where it caught the NotFoundException of Octokit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReSharper reported five warnings in GitHubApiClient.cs, GitHubApiClientTests.cs, and StubHttpRequest.cs. GitHubApiClient.SendAsync set HttpRequestMessage.Content in the initializer of a using declaration. A throwing setter would then leak the request. No test read StubHttpRequest.Method. TagExistsAsync_KeepsTheSlashesOfATagName asserts it, because the fallback answer of StubHttpMessageHandler matches any method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The <value> element of GitHubServerAdapter.IsCloudBuild said that the
property always returns false. The property returns true, as on every CI
adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`docs/tool-commands/release.md` did not say which GitHub API `bv release`
reaches. `GitHubApiClient` derives the address from the host of the `origin`
remote. Before a first release on a GitHub Enterprise Server, a user needs
to know the derived address. The `CHANGELOG.md` bullet records the move from
`api.github.com` alone to the API of the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rdeago rdeago added the enhancement [issue/PR] requests / implements new or improved functionality. label Oct 21, 2026
@github-actions github-actions Bot added area:build [issue/PR] affects project files and/or build settings. area:docs [issue/PR] affects documentation (excluding XML documentation that is part of source code). area:code [issue/PR] affects project code (excluding tests). area:deps [issue/PR] adds, updates, or removes dependencies. labels Oct 21, 2026
@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.12048% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.37%. Comparing base (db19ae2) to head (04ca2d0).

Files with missing lines Patch % Lines
...verAdapters/Internal/GitHub/GitHubServerAdapter.cs 0.00% 32 Missing ⚠️
...c/Buildvana.Tool/Subcommands/VersionShowCommand.cs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #407      +/-   ##
==========================================
+ Coverage   82.97%   83.37%   +0.39%     
==========================================
  Files         284      285       +1     
  Lines        8359     8473     +114     
  Branches     1645     1654       +9     
==========================================
+ Hits         6936     7064     +128     
+ Misses       1146     1132      -14     
  Partials      277      277              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rdeago rdeago left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Claude. Self-review.

⚠️ Changes requested.


One finding. It comes from the failed codecov/patch check, on the added file GitHubApiClient.cs.

rdeago and others added 2 commits October 22, 2026 01:12
GitHubApiClient.ReadErrorMessageAsync and DotNetReleaseIndex.ReadJsonAsync
returned from inside an await using block, within a try block. Control then
never reached the closing brace of the try block, and the coverage report
counted that brace as a line no test runs. Both methods now assign the result
inside the await using block and return after the try block, as
GitHubApiClient.ReadAsync does. A code review named ReadErrorMessageAsync
alone, and a search of src/ found the same shape in ReadJsonAsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHubApiClient.Describe read the URL of a failed request from
HttpRequestMessage.RequestUri, which is nullable. Every request has a URL, so
the null branch of the ?. operator could never run, and the coverage report
counted it as a partial line. GitHubApiClient.ApiUrl now builds the absolute URL of each request,
and SendCoreAsync, EnsureSuccessAsync and ReadAsync receive it alongside the
request. ApiUrl combines BaseUrl with the path as HttpClient combined
BaseAddress with a relative request URL. The requests go to the same
addresses, and HttpClient.BaseAddress is no longer set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rdeago rdeago left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review by Claude. Self-review.

👍 Approved.


No blocking findings. The two fixes for the finding of the previous round hold at the head commit.

@rdeago
rdeago merged commit 9762b2f into Tenacom:main Oct 22, 2026
6 of 7 checks passed
@rdeago
rdeago deleted the 403-github-httpclient branch October 22, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:build [issue/PR] affects project files and/or build settings. area:code [issue/PR] affects project code (excluding tests). area:deps [issue/PR] adds, updates, or removes dependencies. area:docs [issue/PR] affects documentation (excluding XML documentation that is part of source code). enhancement [issue/PR] requests / implements new or improved functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bv release talks to GitHub over HttpClient, and Octokit goes

1 participant