Skip to content

fix: enumerate repositories on the route the credential entitles [patch] - #115

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-epshvp
Sep 21, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-epshvp

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #100

The problem

GitHubProvider.GetRepositoriesAsync called GET /users/{login}/repos and nothing else. That endpoint is public-only even when a token is supplied, while AzureDevOpsProvider.GetRepositoriesAsync returns everything its token can see. Two implementations of one IGitHostingProvider method 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 Owner and reveals what a credential is entitled to, so the route is chosen from what the owner is:

Owner Route Coverage
Organisation GET /orgs/{org}/repos Entitled set, private included
The credential's own user account GET /user/repos?affiliation=owner Entitled set, private included
Any other user GET /users/{login}/repos (unchanged) Public only — all GitHub offers

affiliation=owner rather 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 from GET /orgs/{login}/repos answering 404. The inference is the cheaper probe and the wrong one: a token without read:org, or one not authorised for an organisation that enforces SSO, is answered 404 by 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 whose type no credential's scope can change.

Two cases that cost nothing and regress nothing

  • Unauthenticated providers skip both probes and go straight to the public route. Without a credential every route collapses to the same public answer, so probing would spend requests distinguishing identical ones. The existing unauthenticated tests are unchanged as a result.
  • 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 GitHostingAuthenticationException. A 401 still propagates, because a credential GitHub rejects outright is a failure the caller has to see.

Contract

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 its own behaviour and the caller to compare them.

Testing

Five new tests through the existing FakeHttpMessageHandler/Handler seam, all using RespondToPath so 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:

  • organisation → org route, returning a repository the public route is scripted not to answer
  • the credential's own account → /user/repos, with the owner deliberately cased differently from the reported login
  • a different user → public-route fallback
  • no credential → exactly one request, no probes
  • 403 on GET /user → public-route fallback

Each was mutation-checked by substitution (per CLAUDE.md), and the tree was re-verified clean after every run:

Mutation Fails
Force the public route for every owner all 4 routing tests
Always probe, including unauthenticated the no-probe test + 6 existing tests
OrdinalIgnoreCase → Ordinal on the login comparison the own-account test
RepositoryAffiliation.Owner → .All the own-account test
catch (ForbiddenException) → catch (NotFoundException) the no-user-identity test

The affiliation assertion was tightened from StringAssert.Contains(query, "affiliation=owner") to an exact match during that pass — .All serializes as owner,collaborator,…, so the Contains form 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

matt-edmondson and others added 2 commits September 21, 2026 09:34
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

Copy link
Copy Markdown
Contributor Author

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 (4386508). Four Assert.AreEqual(n, handler.Requests.Count) checks became Assert.HasCount(n, handler.Requests). The pre-existing occurrence in LeavesAnInjectedHandlerUndisposedAndUsableForASecondCallAsync is untouched — it isn't this PR's line to change.

MSTEST0054 — deliberately not fixed (5 findings). TestContext.CancellationToken over TestContext.CancellationTokenSource.Token, left alone on exactly the grounds dccc7a8 recorded: the older form is used at 37 call sites in this file alone and hundreds across the suite, so converting only the five new lines would make them the sole outliers. Converting all of them is a suite-wide change that doesn't belong in a fix for #100.

Re-verified after the change: full suite 608 passed / 0 failed, dotnet build -c Release clean with zero warnings. The HasCount form was re-checked as load-bearing by re-running the "always probe, even unauthenticated" mutation — IssuesNoOwnerTypeProbeWithoutACredentialAsync still fails under it.


Generated by Claude Code

@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit a36b846 into main Sep 21, 2026
14 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nice-davinci-epshvp branch September 21, 2026 10:30
matt-edmondson added a commit that referenced this pull request Sep 22, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitHubProvider.GetRepositoriesAsync cannot see private repositories, so the two hosting providers answer the same contract very differently

1 participant