Skip to content

fix: defects found by a full review of internal/ and main.go - #48

Merged
clouddrove-ci merged 7 commits into
masterfrom
fix/codebase-review
Aug 19, 2026
Merged

fix: defects found by a full review of internal/ and main.go#48
clouddrove-ci merged 7 commits into
masterfrom
fix/codebase-review

Conversation

@clouddrove-ci

Copy link
Copy Markdown
Member

A high-effort review across all of internal/ and main.go (~11k lines), rather than a single diff. It found 15 defects; all are fixed here with regression tests, plus two more found alongside.

Four of these mean a feature could not work at all. Everything from #44 and #46 shipped in v0.2.1 with green CI, because the tests encoded the same wrong assumptions the code did.

Blocking

mirror_objects was inoperable against GitHub, GitLab, and Bitbucket. The engine passed the rendered destination name, which is owner-relative because CloneURL and EnsureRepo prepend the owner themselves. Every destination pull request and comment endpoint interpolates that value as a fully qualified path, so a GitHub destination asked for /repos/widget/pulls instead of /repos/acme/widget/pulls and every call 404d. Remote gains QualifiedPath; each provider implements the convention its own ListRepos reports. The existing test asserted the broken convention, which is why it passed.

Azure DevOps failed the opposite way. ListRepos reports project/name and every URL already carries the project as its own segment, so interpolating the qualified path whole produced a duplicated project and 404d for every repository. Its pull request routes use the repository segment alone.

Mirroring several repositories at once could abort the process. internal/prsync wrote pull request records into the shared GitState from inside the engine's worker pool, bypassing the lock the engine uses for its own access and documents as necessary. Two repositories finishing together is a concurrent map write, which Go turns into an unrecoverable crash that the worker's own recover cannot catch. GitState now guards its own maps; there is a race-detector test.

A private Docker Hub repository always failed. Authenticate logged in and discarded the response, so the JWT the Hub API authenticates with was never kept and every listing fell back to basic auth, which that API rejects. The failure surfaced as a 404 blamed on the image.

Important

  • A destination pull request stayed open forever once its source merged. Sources are listed open-only, so a finished pull request stops appearing rather than arriving with a final state, making the entire close path unreachable — while the mirror push pruned the branch beneath it. Vanished records are reconciled and closed with a note that claims only what an open listing can know.
  • GitHub keeps discussion and review comments in separate id spaces reached through different routes; both were addressed through the discussion route, so any pull request with a mirrored inline comment 404d on every run after the first. Ids now carry their space, and a bare id from an existing state file still resolves correctly.
  • A conversation that failed part way discarded the ids it had already written, so the next run reposted every comment, forever. A repository whose pull request pass failed was recorded as fully mirrored, so it was skipped and never retried until an unrelated ref moved.
  • One failed fetch of a pull request head deleted that branch from the destination, pulling the head out from under a mirrored pull request.
  • The image cron could overlap itself on a Syncer with per-run state and an unlocked state map — the exact hazard the git cron documents and guards against. Both loops now skip rather than overlap, validate the schedule before the first run rather than after a full sync, and install the signal handler before the first run so a shutdown can still persist.
  • A malformed exclude glob was ignored entirely, mirroring a repository the rule existed to keep out. Patterns are validated at config load and a bad pattern now drops rather than mirrors.
  • A Slack notification could be lost whole — one oversized failure line produced a block that was empty or over the limit, and Slack rejects both. Found while fixing it: truncate counted bytes where Slack counts characters, so it trimmed non-ASCII text to a third of the limit and could split a rune.
  • A failed state save on the fail-fast path was silent, so everything copied before the abort was copied again next run with no explanation.

Found alongside the review

  • A high-severity advisory in brace-expansion, which the Azure DevOps extension pinned to exactly the affected version via overrides. The pin that once fixed a problem had become the problem, and nothing could move it. govulncheck reports the Go side clean.
  • Node 20 deprecation on every CI run: actions/checkout and actions/setup-go moved to v7. The release, helm, and extension publish workflows are deliberately untouched — no pull request exercises them, and a broken publish surfaces at the worst moment.

Testing

go vet clean, go test -race ./... passes. Every fix has a regression test that fails against the old behaviour, including a concurrent-writer test for the state race and a test asserting the destination receives a qualified path.

Note

This is all unreleased work except the Docker Hub, cron, filter, and Slack fixes, which affect v0.2.1 and earlier. Worth a release once reviewed.

The Azure DevOps extension pinned brace-expansion to 5.0.8 through an
overrides entry, and 5.0.8 is the version the current advisory covers: a
denial of service through unbounded intermediate arrays that bypasses the
earlier CVE-2026-14257 mitigation. The pin that once fixed a problem had
become the problem, and nothing would move it because an override is
absolute.

Bumped to 5.0.9, the first patched release and the current latest, with the
lockfile regenerated to match.
Every run warned that actions/checkout and actions/setup-go target Node 20,
which GitHub now force-runs on Node 24 and will stop supporting. Moved to
checkout v7, setup-go v7, and upload-artifact v7 in ci.yml and
action-smoke.yml, which this pull request exercises.

The release, helm publish, and Azure DevOps extension workflows are left
alone deliberately. They run only on a tag or a manual dispatch, so no pull
request proves a bump there, and a broken publish workflow is discovered at
the worst possible moment. They are worth updating in a change that can be
dispatched and watched.
Three defects that each made a whole path unusable.

The engine handed prsync the rendered destination name, which is owner
relative because CloneURL and EnsureRepo prepend the owner themselves.
Every destination pull request and comment endpoint interpolates that value
as a fully qualified path, so a GitHub destination built "/repos/widget/pulls"
instead of "/repos/acme/widget/pulls" and answered 404 for every call.
mirror_objects could not work at all against GitHub, GitLab, or Bitbucket.
Remote gains QualifiedPath, each provider implements the convention its own
ListRepos reports, and the engine qualifies the name before passing it on.
The existing tests passed because their fake used the broken convention.

Azure DevOps went wrong the other way: ListRepos reports project/name and
every URL already carries the project as its own segment, so interpolating
the qualified path whole produced a duplicated project and 404ed on every
repository. Its pull request routes now use the repository segment alone.

internal/prsync wrote pull request records into the shared GitState from
inside the engine's worker pool, bypassing the lock the engine uses for its
own access and documents as necessary. Two repositories finishing together
was a concurrent map write, which aborts the process and cannot be
recovered by the worker that caused it. GitState now guards its own maps,
so no caller has to remember.
Authenticate logged in and threw the response away, so the JWT the Hub API
authenticates with was never kept. Every later listing fell back to HTTP
basic auth, which that API does not accept: a private repository with
watch_tags on answered 404 after three retries, reported against the image
rather than against the credentials.

The session token is stored and sent as a bearer credential, ahead of a
configured personal access token, with basic auth left as a last resort for
any deployment still relying on it. An anonymous registry sends nothing.
…ilure

Sources are listed open only, so a merged or closed pull request does not
arrive with a finished state: it stops appearing. The close path was
therefore unreachable, and a destination pull request stayed open forever
while the mirror push pruned the branch under it. Records the source no
longer lists are now reconciled and closed with a note that says only what
is knowable from an open listing.

GitHub keeps discussion comments and review comments in separate id spaces
addressed through different routes, and both were updated through the
issues route. Every pull request with a mirrored inline comment failed its
conversation sync with a 404 on every run after the first, or edited an
unrelated comment if the ids collided. The id now carries which space it
belongs to, and a bare id from an older state file still resolves to the
discussion route.

A conversation that failed part way discarded the ids of the comments it
had already posted, because the state write came after the error return, so
the next run reposted all of them, forever. What succeeded is recorded
before giving up.

The repository fingerprint was recorded before the pull request pass ran,
so a failed pass was marked done and skipped on the next run until an
unrelated branch or tag happened to move. It is recorded after, and only
when the pass had no failures.

A pull request head that failed to fetch was dropped from the keep set,
which pruned it from the cache and then deleted it at the destination. One
transient network error removed a correct branch and pulled the head out
from under its mirrored pull request. The previous copy is kept instead.
…limits

The image sync cron had no SkipIfStillRunning, which the git cron has along
with a comment explaining the hazard. A run that outlives its interval
overlapped itself on a Syncer that keeps per run state and a state map with
no locking: the second run's report was voided by the first, and two
writers on that map abort the process. Both cron loops now also register
the schedule before the first run, so a typo is caught immediately instead
of after a full sync of every image, and install the signal handler before
the first run, so a SIGTERM during it lets the run finish and persist
rather than killing the process with nothing saved.

A malformed exclude glob was silently ignored, so a repository somebody
wrote a rule to keep out was mirrored to the destination. Patterns are
validated at config load, and a pattern that cannot be parsed now drops the
repository rather than mirroring it.

A Slack section block could be emitted empty, and could exceed the size
limit, from a single oversized failure line such as a transport error
carrying a multi kilobyte HTML body. Either one makes Slack answer 400 and
the entire notification is lost, which matters most for the failure alert
that is the reason the integration exists. Sections are bounded at both
ends, and truncate now counts characters rather than bytes so it neither
trims non-ASCII text far short of the limit nor splits a rune.

A failed state save on the fail fast path was discarded, so every image
copied before the abort was re-pulled and re-pushed on the next run with
nothing in the output to say why. The same failure is fatal on the normal
exit path; it is at least reported here.
@clouddrove-ci
clouddrove-ci merged commit 29ac323 into master Aug 19, 2026
3 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