Skip to content

test: share every non provider specific test through Base, and declare what differs - #126

Merged
HarshMN2345 merged 20 commits into
mainfrom
test/tighten-base-assertions
Jul 30, 2026
Merged

test: share every non provider specific test through Base, and declare what differs#126
HarshMN2345 merged 20 commits into
mainfrom
test/tighten-base-assertions

Conversation

@HarshMN2345

@HarshMN2345 HarshMN2345 commented Jul 30, 2026

Copy link
Copy Markdown
Member

Follow-up to #124 on two pieces of review feedback: the shared tests leaned on fallbacks that let them pass without pinning provider behaviour down, and the adapter classes were still large.

Every test that is not genuinely provider specific now lives in Base, and what differs per provider is declared rather than duplicated.

file before after
tests/VCS/Adapter/GitHubTest.php 738 245
tests/VCS/Adapter/GitLabTest.php 683 266
tests/VCS/Adapter/GiteaTest.php 635 147
tests/VCS/Adapter/GogsTest.php 132 75
tests/VCS/Base.php 1319 2537

No test is defined in more than one adapter class any more, and every adapter runs more tests than before: gitlab 84 → 102, gitea 80 → 102, gogs → 102, github 69 → 73 active.

Fallbacks and looseness

Base was guessing at provider shapes at runtime, so a wrong shape fell through to the next branch instead of failing:

was now
owner read as owner.login, else namespace.path, else fail ownerOf(), overridden by GitLab, which reports a namespace
visibility as a private bool, else a visibility string isPrivate(), overridden by GitLab, which reports a string
pull request number as iid ?? number ?? 0 pullRequestNumberOf(), overridden by GitLab
user handle as username ?? login ?? '' $userHandleField
pushed_at accepted as null or a timestamp asserted as a timestamp; the null branch turned out to be unreachable for every provider, GitHub included
pull request state accepted as open or opened $openPullRequestState
webhook header names asserted only to be non-empty asserted exactly, which is what covers the Forgejo and Gogs overrides
supported webhook scopes asserted only to be non-empty asserted as the exact set
a failed clone accepted if it exited non-zero or checked nothing out asserted on the checkout being empty, which is the only real signal - on Gitea and Gogs the command exits zero

What moved into Base

Repositories, files, trees, contents, branches, tags, commits, commit statuses, comments, pull requests, clone commands, searching, getUser, getOwnerName, presigned URLs, check runs, listNamespaces, both live webhook round trips, and the webhook payload parsing.

Three mechanisms made that possible:

  • Capabilities. GitHubTest carried 22 markTestSkipped methods and GogsTest 16, all saying "this provider has no such API" in four lines each. They are now declarations - $supportsPullRequestCreation, $supportsCommitStatusLookup, $supportsTags, $supportsCheckRuns, $supportsWebhookDelivery, $createsEmptyRepositories and so on - and the shared tests skip themselves.
  • Hooks for what genuinely differs. signWebhookPayload() (GitHub prefixes its HMAC, Gitea sends a plain one, GitLab sends the secret verbatim), ownerOf(), isPrivate(), pullRequestNumberOf().
  • Payload builders. All three adapters' getEvent() already return the same normalized keys, so the only provider-specific part of those tests was the payload going in. Each adapter builds a push and a pull request payload from shared EVENT_* facts; Base owns the assertions. GitHub signals a created branch with a flag and GitLab with an all-zero sha, which is exactly what the builders express.

Adapter changes

Sharing a test repeatedly meant running it against a provider for the first time, which surfaced four inconsistencies:

  • createRepository never checked its status code on Gitea, Gogs and GitHub, so an error response was returned as if it were a created repository. GitLab already threw. The old Gitea test hid this: it wrapped $this->fail() in catch (\Exception), which swallowed the failure it was meant to report.
  • GitLab::getEvent() returned an empty array for a payload that is not valid JSON while GitHub and Gitea throw, making a malformed payload indistinguishable from an unhandled event. Unsupported event names still return an empty array.
  • '.', './' and 'src//' resolved differently per provider. Fix GitLab adapter treating './' as a literal path instead of repository root #121 fixed the path handling in GitLab only, so the same call returned the root listing there and nothing on Gitea. normalizeRepositoryPath() moves onto Git, and Gitea and GitHub normalize too.
  • GitHub::getPullRequest returned the 404 body for a pull request that does not exist, where Gitea and GitLab throw.

Git also gained defaults that report a capability as unsupported for getRepositoryPresignedUrl(), the three check run methods and listNamespaces(). They are deliberately not abstract, so adapters that do not offer them - including any being written against this class - keep working.

Verification

All five adapter suites, the linter and CodeQL pass. composer check (PHPStan level 8) is clean.

Worth a reviewer's attention:

  • createRepository, getPullRequest and GitLab::getEvent now throw where they previously returned a value. That is the intended fix, but it is a behaviour change for callers.
  • Centralising check runs means the four non-GitHub adapters each report 10 skipped tests for an API only GitHub has. Easy to move back if that trade is not wanted.
  • Cleanup retries a deletion three times and then fails the test with the repositories it could not remove. Both stricter and more lenient versions were tried first: throwing immediately failed a passing test on a transient GitLab 500, and swallowing hid leaks.
  • getEvent()['action'] reports synchronized on Gitea and synchronize on GitHub and GitLab. Left alone here since it changes behaviour for consumers, but a consumer switching on synchronize silently misses Gitea.

GitHub and Gitea both throw on a payload that isn't valid JSON, while
GitLab returned an empty array - indistinguishable from an event it simply
doesn't handle. That inconsistency was the only reason the invalid-payload
test had to live in the Gitea class rather than the shared base.

Unsupported event names still return an empty array, as before.
Base tests papered over provider differences with fallbacks, so they
passed without pinning anything down: owner was read as owner.login or
namespace.path or bust, visibility as a bool or a string, a pull request
number as iid or number or 0, a user handle as username or login or empty,
pushed_at as null or a timestamp, and a pull request state as open or
opened.

Each of those is now a declared per-provider fact - an overridable helper
or a static - so every adapter asserts exactly what its provider reports
and a wrong shape fails instead of falling through.

The same declarations let these move into Base, which is where the
duplication actually was:

- webhook header names, which also replaces two Base tests that only
  checked the names were non-empty
- getOwnerName with a missing, zero and null repository id
- createRepository with an invalid name, and clone commands for a tag
- searchRepositories matching by name, now also covering GitHub
- getEvent on a malformed payload, now also covering GitLab

Tests defined in more than one adapter class: 13 -> 8. The rest are
genuinely provider specific - webhook payloads, signature schemes,
presigned URL shapes.
Comment thread tests/VCS/Base.php Outdated
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This follow-up centralizes provider-independent adapter tests and makes provider differences explicit.

  • Adds shared capability declarations, payload builders, provider-specific hooks, and stricter assertions.
  • Adds resilient repository cleanup that retries failures, attempts every repository, and reports persistent leaks.
  • Normalizes repository paths across GitHub, GitLab, and Gitea.
  • Makes malformed GitLab events and failed GitHub pull-request lookups throw.
  • Adds optional unsupported-operation defaults to the shared Git adapter.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
tests/VCS/Base.php Centralizes the adapter contract tests and fixes the previously reported cleanup paths by protecting partial setup, independently attempting every deletion, retrying transient failures, and surfacing persistent failures.
tests/VCS/Adapter/GitHubTest.php Replaces duplicated shared tests with GitHub capability declarations and provider-specific webhook payload and signature hooks.
tests/VCS/Adapter/GitLabTest.php Declares GitLab-specific response shapes, capabilities, webhook behavior, and payload construction for the shared suite.
tests/VCS/Adapter/GiteaTest.php Reduces duplicated tests to Gitea-specific capabilities and event-payload behavior.
src/VCS/Adapter/Git.php Adds default unsupported capability methods and shared repository-path normalization.
src/VCS/Adapter/Git/GitHub.php Applies shared path normalization and rejects failed pull-request lookups.
src/VCS/Adapter/Git/GitLab.php Uses shared path normalization and rejects malformed JSON event payloads.
src/VCS/Adapter/Git/Gitea.php Applies shared normalization to repository content paths.

Reviews (15): Last reviewed commit: "Default every capability to supported in..." | Re-trigger Greptile

…ehave

Three assertions were written from assumption rather than observation, and
CI corrected two of them:

- pushed_at was tolerated as null or a timestamp, and I had pinned the null
  case to GitHub. GitHub actually reports a timestamp for a repository with
  no commits too, so the null branch was never reachable for any provider -
  every adapter now has to report a parseable timestamp.
- a failed clone was accepted if the command exited non-zero or checked out
  nothing. On Gitea and Gogs it exits zero, because the command sets up a
  local repository before discovering the remote is missing, so the checkout
  being empty is the only real signal and is now the assertion.
- supported webhook scopes were only asserted to be non-empty; the exact set
  is now declared per provider, with the installation scope only on GitHub.

Also stops the two search tests orphaning their first repository when the
second one fails to create, and asserts a commit url points at the
repository it came from.
Comment thread tests/VCS/Base.php Outdated
The cleanup helper caught every throwable, so an auth, transport or
provider failure during teardown would pass silently and leave the
repository behind for later runs to trip over. Adapters carry the HTTP
status as the exception code, so a repository that was never created is a
404 and stays tolerated; everything else surfaces.
Comment thread tests/VCS/Base.php Outdated
Rethrowing on the first deletion left the second repository untouched, so
surfacing one failure created another leak. Cleanup now takes every
repository, skips the ones that were never created, and reports whatever
actually failed once all of them have been attempted.
GitLab answered a commit status POST with 403 in CI: the test read the
latest commit directly and wrote to it straight away, while the shared
commit status test that waits through getLatestCommitEventually passed in
the same run. The two GitLab commit status tests and Gitea's tag test now
use that helper too, which is what every other test in the suite does.
Reporting a cleanup failure from finally overwrote whatever the test body
had already raised. Cleanup is now two things: deleteRepositories, asserted
on the path where the test passed, and discardRepositories, silent on the
path where a failure is already on its way out.
Three adapters implement getRepositoryPresignedUrl() but nothing declared
it, so the shared tests could not call it. A default on Git makes the
contract reachable while leaving adapters that do not offer archives -
Gogs, and anything being written against this class - working as they are.
Following review: the adapter classes were still carrying tests that only
looked provider specific. Moved to Base, with the parts that genuinely
differ declared rather than duplicated:

- both webhook round trips, through an awaitWebhook() helper. This also
  puts them near the end of a run again, which they lost when earlier
  tests moved to Base and inherited tests started running after a
  subclass's own
- validateWebhookEvent, via a signWebhookPayload() each adapter implements:
  GitHub prefixes its HMAC, Gitea sends a plain one, GitLab sends the
  secret verbatim
- presigned urls, commit statuses, createTag, tree with a slash in the
  branch name, search pagination, listTags on a commit-less repository,
  hasAccessToAllRepositories, getInstallationRepository, getOwnerName with
  an id that does not exist

Two provider facts came out of running it rather than reading the code:
Gitea reports a newly opened pull request as 'synchronized' and GitLab as
'synchronize', because both follow the opened event with a sync for the
head they just pushed and the catcher keeps only the last delivery.

No test is defined in two adapter classes any more. GiteaTest 634 -> 300
lines, GitLabTest 682 -> 461. What stays adapter specific is genuinely
provider bound: hand-built getEvent payloads, GitHub's check runs,
pagination, blob SHA and case sensitivity, GitLab's namespaces and path
sentinels, Gitea's avatar host and delete-then-read.
CI answered both: GitHub redirects archive downloads to codeload, whose
paths read legacy.tar.gz and legacy.zip rather than tarball and zipball,
so the shared defaults already described it and the override was wrong.
And getOwnerName ignores the repository id entirely on GitHub, resolving
the owner from the installation, so an id that does not exist has no
meaning there - skipped like the other getOwnerName cases.
GitHubTest carried 22 skip methods and GogsTest 16, all saying the same
thing in four lines each: this provider does not have that API. They are
now capabilities on Base - pull request creation and lookup, commit
statuses and reading them back, tags, user lookup, repository languages,
webhook delivery, resolving an owner from a repository id, rejecting
invalid repository names - and the shared tests skip themselves when a
capability is missing.

GitHubTest 738 -> 661 lines, GogsTest 141 -> 82.

Cleanup also became one policy. A transient GitLab 500 while deleting a
repository failed testListRepositoryContents in CI even though the test
itself had passed, and 49 other finally blocks could do the same. Cleanup
is best effort everywhere now; deleting is asserted by the delete tests.
Check runs were 276 of GitHubTest's lines. They are a GitHub-only API, so
Git declares them the way it declares presigned urls - a default that
reports them unsupported - and the tests live in Base behind a
supportsCheckRuns capability. Any adapter that gains check runs inherits
the tests instead of copying them.

GitHub's last two overrides went the same way:

- getLatestCommit only differed by asserting the author avatar and profile
  url, now two capabilities, since GitLab reports neither and Gitea reports
  an avatar but no url
- updateCommitStatus only differed by not reading the status back, so
  writing is gated on supportsCommitStatuses and reading on
  supportsCommitStatusLookup. GitHub gains the write test it used to
  override away

GitHubTest 738 -> 328 lines. What is left cannot be shared: hand-built
webhook payloads, GitHub's own listBranches signature, the git blob SHA,
case sensitive paths, installation-scoped getOwnerName, and language stats
that need the inconclusive handling.
Moving GitLab's path sentinel tests into Base showed the contract only held
for GitLab: '.' and './' returned the root listing there and nothing on
Gitea, because #121 fixed the path handling in one adapter. The same call
answered differently depending on the provider.

normalizeRepositoryPath() moves off GitLab onto Git, and Gitea and GitHub
normalize before building their contents urls, so a caller passing '.',
'./' or 'src//' gets the same answer everywhere. Gitea went from failing
all three sentinel tests to passing them.

listNamespaces moves to Base the same way check runs did - a default on Git
reporting it unsupported, plus a capability - so GitLabTest is 682 -> 367
lines.
Sharing testGetPullRequestWithInvalidNumber ran it against GitHub for the
first time, where it had been skipped, and GitHub returned the 404 body as
if it were a pull request while Gitea and GitLab both throw. Same shape as
createRepository not checking its status code.

Also waits for both branches to be listable before paging through them, so
GitHub not having indexed the second one yet stops failing the pagination
test.
None of them were really GitHub specific:

- getOwnerName differed only in which argument the provider reads, so the
  shared test passes both an installation id and a repository id and each
  adapter uses the one it resolves owners from
- language stats differ in when they appear, not what they are, so the wait
  and the inconclusive skip are behind computesLanguagesAsynchronously
- case sensitive paths and the git blob SHA are git behaviours: Gitea and
  GitLab both hold to them, confirmed by running it

GitHubTest 738 -> 263 lines. What is left is the hand-built webhook
payloads, its own listBranches signature, and the provider facts.
Every adapter's getEvent already reports the same normalized keys - branch,
branchCreated, branchDeleted, repositoryId, repositoryName, owner,
commitHash, headCommit*, affectedFiles, external - so the only thing that
was provider specific about these tests was the payload going in.

Base now owns the assertions and declares two builders each adapter fills
in from the same EVENT_* facts: a push payload and a pull request payload.
GitHub signals a created branch with a flag, GitLab with an all-zero sha,
and each shapes the owner and the pull request number its own way, which is
exactly what the builders express.

That covers push, pull request, branch created, branch deleted and external
pull requests for every provider from one place. GitLab keeps the two that
are its own semantics - matching checkout_sha and its action mapping - and
GitHub keeps installation events.

GitHubTest 738 -> 244 lines, GitLabTest 682 -> 265, GiteaTest 634 -> 146,
GogsTest 131 -> 74.
Review flagged that cleanup swallows every deletion failure, which is fair:
a leaked repository was invisible. Throwing instead is what the previous
round did, and a transient GitLab 500 during teardown then failed a test
that had actually passed.

Cleanup now stays out of the pass/fail decision and writes to the log when
it could not delete something, so a leak is visible without a provider
hiccup deciding whether a test failed. A repository that was never created
is still nothing to report.
Review wants a cleanup failure to affect the result, and it is right that a
repository left behind contaminates later runs. Making it throw is what an
earlier round did, and a transient GitLab 500 during teardown then failed a
test that had passed - which is why it became best effort.

Retrying resolves both: a hiccup is retried away, and a deletion that still
will not go through after three attempts fails the test with the names of
the repositories it could not remove. A repository that was never created
stays silent.
@HarshMN2345 HarshMN2345 changed the title test: assert per-provider facts in Base instead of accepting either shape test: share every non provider specific test through Base, and declare what differs Jul 30, 2026
Comment thread tests/VCS/Base.php Outdated

protected static bool $supportsCheckRuns = false;

protected static bool $supportsNamespaceListing = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Lets mark all flags enabled to maximum in base file. It's adapter's responsiblity to mark some support as limited or not supported.

That will help us find and address those in future more easily

Removing tests by hand left GogsTest with headings introducing skip methods
that are capabilities now, and GitHubTest with 44 blank lines at the end.
Also collapses the other gaps the deletions opened up.
Per review: Base should describe the whole contract, and an adapter should
be the thing that says where its provider falls short. Check runs,
namespace listing, installation repository lookup and commit author links
defaulted to unsupported, so a provider that cannot do them said nothing
and the gap was invisible.

They now default to supported, and each adapter states its own gaps -
GitLab has no check runs, installation lookup or author links, Gitea has no
check runs, namespaces or installation lookup and reports no author url.
Anything a provider does not support is a line in its own class from now on.
@HarshMN2345
HarshMN2345 merged commit 9f2c7a8 into main Jul 30, 2026
8 checks passed
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.

2 participants