fix: enumerate repositories on the route the credential entitles [patch] - #115
Conversation
GitHubProvider.GetRepositoriesAsync called GET /users/{login}/repos alone,
which is public-only even with a token, while AzureDevOpsProvider's
equivalent returns everything its token can see. Two implementations of one
IGitHostingProvider method diverged in coverage with no signal a caller could
read, and an organisation's private repositories were invisible to a
credential that could plainly see them.
GitHub publishes no single endpoint that both honours Owner and reveals what
a credential is entitled to, so the route is now chosen from what the owner
is: GET /orgs/{org}/repos for an organisation, GET /user/repos with
affiliation=owner once GET /user has confirmed the configured owner is the
credential's own account, and the existing public route for any other user,
which is genuinely all GitHub offers there.
The owner's type is read from GET /users/{login} rather than inferred from
GET /orgs/{login}/repos answering 404. The inference is cheaper and wrong: a
token without read:org, or one not authorised for an org enforcing SSO, is
answered 404 for an organisation that exists, and that would quietly demote
it back to the public-only route under a condition nothing would report.
An unauthenticated provider skips both probes, because without a credential
every route collapses to the same public answer. A credential with no user
identity - a GitHub App installation token, whose GET /user is answered 403 -
falls back to the public route rather than turning a call that previously
succeeded into a thrown exception; a 401 still propagates.
IGitHostingProvider.GetRepositoriesAsync now states the coverage every
provider promises, and names the one case where the public-only fallback
applies, instead of leaving each provider to describe itself and the caller
to compare them.
Fixes #100
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTqLSCKAY2VyL5ozhCvhgA
SonarCloud's analysis of the new tests raised MSTEST0037 on the four handler.Requests.Count checks, suggesting Assert.HasCount. Adopted, which also drops the .Count dereference from each assertion. MSTEST0054 (TestContext.CancellationToken over TestContext.CancellationTokenSource.Token) is left alone on the same grounds dccc7a8 recorded: the older form is used at 37 call sites in this file alone and hundreds across the suite, so converting the five new lines would make them the only outliers. The pre-existing Assert.AreEqual(2, handler.Requests.Count) in LeavesAnInjectedHandlerUndisposedAndUsableForASecondCallAsync is untouched, since it is not this PR's line to change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WTqLSCKAY2VyL5ozhCvhgA
|
SonarCloud's quality gate passed (100% coverage on new code, 0 duplication, 0 hotspots) but reported 9 new issues, all in the tests this PR adds, across two rules. Both were already decided in this repo by dccc7a8, so I followed that split rather than inventing a new one. MSTEST0037 — fixed ( MSTEST0054 — deliberately not fixed (5 findings). Re-verified after the change: full suite 608 passed / 0 failed, Generated by Claude Code |
|
Merges origin/main, which brought in PR #115's GitHub repository enumeration routing. Our own OwnerKind-based routing on this branch solved the same problem and is dropped in favour of upstream's approach, per the owner's decision. Upstream infers the owner's account type from GET /users/{login} rather than taking an explicit kind from the caller, and it handles three cases ours did not: an unauthenticated provider skips the type probe entirely and calls the public route directly; a GitHub App installation token whose GET /user answers 403 falls back to the public route instead of failing; and the routing decision is never inferred from a 404, which an SSO-blocked organisation answers just as an absent one would, so ours risked silently under-reporting an organisation's repositories. Removed the GitHubOwnerKind enum, the OwnerKind property, and their tests and documentation. The worktree verbs, the X-GitHub-SSO authorisation URL in failure messages, and GitHubDeviceFlow are untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>



Fixes #100
The problem
GitHubProvider.GetRepositoriesAsynccalledGET /users/{login}/reposand nothing else. That endpoint is public-only even when a token is supplied, whileAzureDevOpsProvider.GetRepositoriesAsyncreturns everything its token can see. Two implementations of oneIGitHostingProvidermethod diverged in coverage with no signal a caller could read — an organisation's private repositories were invisible to a credential that could plainly see them, and enumeration is the entry point to almost everything else in the hosting layer.The change
GitHub publishes no single endpoint that both honours
Ownerand reveals what a credential is entitled to, so the route is chosen from what the owner is:GET /orgs/{org}/reposGET /user/repos?affiliation=ownerGET /users/{login}/repos(unchanged)affiliation=ownerrather than the unfiltered default, which would also return repositories the account merely collaborates on or reaches through an organisation, reporting another owner's work under this owner's name.Why the type probe, and not a 404 inference
The owner's type is read from
GET /users/{login}rather than inferred fromGET /orgs/{login}/reposanswering404. The inference is the cheaper probe and the wrong one: a token withoutread:org, or one not authorised for an organisation that enforces SSO, is answered404by that route for an organisation that plainly exists — and the inference would quietly demote it back to the public-only user route, reinstating the exact under-reporting this change removes, under a condition nothing would report.GET /users/{login}is a public endpoint whosetypeno credential's scope can change.Two cases that cost nothing and regress nothing
GET /useris answered403— falls back to the public route rather than turning a call that previously succeeded into a thrownGitHostingAuthenticationException. A401still propagates, because a credential GitHub rejects outright is a failure the caller has to see.Contract
IGitHostingProvider.GetRepositoriesAsyncnow states the coverage every provider promises, and names the one case where the public-only fallback applies, instead of leaving each provider to describe its own behaviour and the caller to compare them.Testing
Five new tests through the existing
FakeHttpMessageHandler/Handlerseam, all usingRespondToPathso the route is a condition the code has to satisfy to get its scripted response rather than an assertion made afterwards on a recorded URI:/user/repos, with the owner deliberately cased differently from the reported login403onGET /user→ public-route fallbackEach was mutation-checked by substitution (per
CLAUDE.md), and the tree was re-verified clean after every run:OrdinalIgnoreCase→Ordinalon the login comparisonRepositoryAffiliation.Owner→.Allcatch (ForbiddenException)→catch (NotFoundException)The affiliation assertion was tightened from
StringAssert.Contains(query, "affiliation=owner")to an exact match during that pass —.Allserializes asowner,collaborator,…, so theContainsform passed under mutation and was proving nothing.Full suite: 608 passed, 0 failed.
dotnet build -c Release: clean, zero warnings, no suppressions added.🤖 Generated with Claude Code
https://claude.ai/code/session_01WTqLSCKAY2VyL5ozhCvhgA
Generated by Claude Code