Uh oh!
There was an error while loading. Please reload this page.
fix(sonar): add --sonar-branch so attest sonar finds scans on non-main branches - #1119
Conversation
… path SonarQube's project_analyses/search endpoint defaults to the project's main branch. GetProjectAnalysisFromRevision never sent a branch, so `kosli attest sonar --sonar-project-key --sonar-revision` could not find an analysis on any other branch: the search came back empty and the user was told the revision was wrong. Same defect as #861, in the sibling path that fix did not cover. Both lookups now build the URL through analysesSearchURL, so the rule exists once. Leaving the two copies in place is how #861 came to be fixed on one path and left broken on the other. The not-found error now says what was searched. An empty result from SonarQube is indistinguishable from a permissions problem, which is what cost the customer in #1116 several days while their token was correct throughout. Tests use the real responses from that customer's instance, and pin the backwards-compatible contract: no branch parameter at all when none is known, rather than an empty one. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The project-key/revision path has no CE task to read the branch from, so the branch can only come from the user. --sonar-branch supplies it, and is mutually exclusive with --pull-request: a pull request scan is not a branch scan, and the branch would otherwise be silently ignored. --pull-request's help text names the new exclusion, since enforcing a rule without documenting it is worse than neither. Unset, the request to SonarQube is byte-for-byte what it was. empty-flag-audit-coverage.json is regenerated (CI enforces it), and the audit's spec.json gains the flag because audit.py refuses to run until every command-and-flag pair is covered. categories.json gains it as "identity", matching --sonar-project-key and --sonar-revision. Case 113's golden was already stale on main: it predates a reword of that error and its suite only runs when SONARQUBE is set, so nothing caught the drift. Corrected here as a drive-by rather than left wrong. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude finished @AlexKantor87's task in 5m 6s —— View job Review — PR #1119 ( |
| Check | Result |
|---|---|
go build ./... | clean |
go vet ./... | clean (type-checks the new test files too) |
go mod tidy → git status go.mod go.sum | no diff |
go test ./internal/sonar | not run — blocked by the sandbox permission prompt in this environment, not by a failure. The package tests are self-contained (httptest, no local Kosli server), so they should be runnable in CI. |
Verified against the code, not the description
The three commits since the last round hold up under checking:
c013aff6— I traced the claim end to end.Branch.Typeisjson:"type,omitempty"(sonar.go:50);GetTaskIDwritesBranchonly insideif matched(sonar.go:615-628) and returnsnilon no match. So{Name: …, Type: ""}survives, andTestGetSonarResults_NoMatchingTask_KeepsSuppliedBranchnow pins both halves —TaskID == ""proving no task matched, then name-stands/type-empty. TheomitMatchingTaskswitch keeps the decoy in place, so the fixture still exercises selection-by-analysis-ID.f18a14d8— both tables aret.Runsubtests withdefer server.Close()inside the closure and the capture vars still scoped per-case. The injection row can no longer be disarmed by an earlierFatal.124e0d97—ps=500is a strict superset at offset 0, both loopsbreakon first match (sonar.go:430-437,448-454), andproject_analyses/searchis date-DESC, so the newest matching analysis is still the one selected on either path. No behaviour change beyond the wider window. Correspondingly, the error advice for the branch-known case no longer asserts the revision and branch are both wrong.
Also checked: GetQualityGate keys on analysisId (sonar.go:533), so setting Branch before the lookup cannot reach it; hack/empty-flag-audit/spec.json probes one flag at a time against the baseline set, so sonar-branch and pull-request never collide in the audit despite the new exclusion; goldens 16 and 113 match the format string byte for byte; the NewSonarConfig call in attestSonar.go:245 has the argument order right.
Findings
Two, both non-blocking, posted inline:
ps=500has no test coverage (internal/sonar/sonar.go:168) — the one piece of new production behaviour here that nothing pins. One case-independent line inside_BranchParam's existing handler covers it.NewSonarConfigis now eight positional strings (internal/sonar/sonar.go:139) —revision, pullRequest, branchadjacent and interchangeable to the compiler. Recorded, not requested; it belongs in its own commit.
Nothing else. I have no security or performance concerns: the branch reaches the URL through url.Values, which the injection row pins; ps=500 grows one response fivefold in the worst case and nothing in this path buffers unboundedly beyond what the decoder already does.
Worth saying
The two decisions carried over from the last round are the right ones and are better documented than most merged code. Pinning Type rather than Name in the payload assertion is the harder and more honest call — Name would have been tautological against this fixture, and a comment claiming otherwise is exactly the failure mode you set out to avoid. _ProjectKeyPath_WithoutBranch asserting --sonar-branch in the message, rather than the whole sentence, gets the durable half of the contract without adding a third rewording victim; and the pair-with-a-failing-half structure is what makes the passing half evidence about SonarQube rather than about the fake.
Deferring paging.total and the api/ce/activity permission/pagination problem to their own issue is correct scoping — both are the same class of defect (discarding SonarQube's own signal that its answer is incomplete) and neither is caused by this change. ps=500 is a fair interim narrowing given it is provably safe.
· branch 1116-sonar-branch
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
api/project_analyses/search returns the 100 newest analyses by default (SearchAction.define calls addPagingParams(100, 500), unchanged from 6.7 to master) and the CLI sent no ps, so a revision older than the last 100 analyses of its branch was reported as not found. ps=500 is the endpoint's maximum; ps=501 is a 400. Safe by construction: p is untouched so this is still page 1 at offset 0, results are ordered date-DESC so a larger page appends older rows, and both callers break on the first match — no different or wrong match is reachable. The shared helper means the CE-task path sends it too, where the key is unique. This narrows the window rather than closing it. The response carries paging.total, the full pre-pagination count, and ProjectAnalyses discards it — so the CLI still cannot tell "no such revision" from "not in the window I searched". That needs a struct field, a changed error path and its own tests, and is filed separately. Until then the advice no longer blames a revision and branch that may both be correct. Only the branch-known variant changes; both goldens assert the no-branch text. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both tables called t.Fatalf inside a bare loop, which ends the whole test function rather than the case: fault-injected into the first case, the other three never ran, including the only assertion that a branch name cannot smuggle in another query parameter. Named parametric tables in this repo wrap in a subtest — 106 of the 110 do, and two of the four that do not were these. server.Close() is deferred immediately after NewServer rather than moved to the end of the closure, where a Fatal would skip it and leak the listener silently. The captured variables stay inside the closure: hoisting them would let a case whose handler is never hit read the previous case's values and pass spuriously. No t.Parallel() — there is none anywhere in this repo, and these run in 0.00s. Also from review: the unscoped end-to-end test now asserts the error names --sonar-branch, so it fails for the reason it claims rather than for any reason at all; and the payload branch is pinned by its type, which only the matched CE task can supply. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AlexKantor87
commented
Aug 21, 2026
Review round addressed — all five threads replied to and resolved, in Taken: subtests for both parametric tables (the bare loops' Declined one: a comment noting that the CLI already blocks Two things recorded rather than fixed, so they are not lost:
Verified locally: |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Review caught this PR's own description contradicting its code. The description argued against re-instating the branch after GetTaskID partly because that would produce a branch with a name and no type — but setting the branch before the lookup, which is what scopes the search, produces exactly that whenever no task matches: GetTaskID rewrites the branch only inside its match block, and type is omitempty. That case is reachable, not exotic. api/ce/activity needs administrative permission, so an ordinary Execute-Analysis token gets an error body that decodes to zero tasks, and the list is unpaginated besides. Keeping the behaviour: the branch the user gave us is better payload data than nothing, and only a task can supply a type. Pinned here so it is a deliberate choice, with the flawed argument retracted from the PR description rather than left to be rediscovered. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Fixes#1116. Replaces #1117, which grew well beyond the defect and was closed.
The defect
GetProjectAnalysisFromRevisioncalledapi/project_analyses/searchwith onlyproject. SonarQube defaults that endpoint to the project's main branch, sokosli attest sonar --sonar-project-key --sonar-revisioncould not find an analysis on any other branch. The search came back empty and the user was told the revision was wrong.Same defect as #861, in the sibling path that fix did not cover. #861's fix cannot be reused as-is:
sonarResults.Branchis only ever populated from a CE task, and this path is searching for the analysis by revision, so there is nothing to forward. The CLI cannot infer the branch here — it has to be told.The change
13 lines of production code:
--sonar-branchsupplies the branch on the project-key/revision path and is forwarded toproject_analyses/search. Unset, the request is byte-for-byte what it was.analysesSearchURL. Leaving the two copies in place is exactly how kosli attest sonar fails for non-main branches: missing branch param in project_analyses/search #861 was fixed on one path and left broken on the other for six months.--pull-request, and--pull-request's help text names the new exclusion.Data files:
empty-flag-audit-coverage.jsonis regenerated (CI enforces it); the audit'sspec.jsongains the flag becauseaudit.pyrefuses to start until every command-and-flag pair is covered;categories.jsongains it asidentity.Deliberately not in this PR
The previous attempt included these; each was reviewed and dropped:
GetTaskID. It cannot affect the defect — the lookup has already returned, andGetQualityGatekeys onanalysisId. It only changes payload semantics in the sub-case where the matched CE task reports no branch. If the flag should be authoritative for the payload, that deserves its own issue.branch.namewith nobranch.type). That argument was wrong, and review caught it. Setting the branch before the lookup — which this PR does, because that is what scopes the search — produces exactly that shape whenever no CE task matches, sinceGetTaskIDonly rewrites the branch inside its match block andtypeisomitempty. That is reachable:api/ce/activityneeds administrative permission, so an ordinary CI token gets an error body that decodes to zero tasks. The behaviour is kept — the branch the user supplied is better payload data than nothing, and only a task can supply a type — and is now pinned byTestGetSonarResults_NoMatchingTask_KeepsSuppliedBranchso it is a deliberate choice rather than a side effect. The two remaining reasons for dropping the re-instate block stand on their own.--sonar-branchis ignored on thereport-task.txt/--sonar-ce-task-urlpaths. It fires on the ordinary metadata path for anyone who setsKOSLI_SONAR_BRANCHin their environment, on a path the defect never touched, andlogger.Warnoutput is compared as part of golden test output (cmd/kosli/testHelpers.go:69-71,112), which makes it a trap for the next test to pass the flag there. Note the related decision that is worth keeping: no mutual exclusion against--sonar-ce-task-url, because case 14 asserts that project-key-path flags are silently ignored when scanner metadata exists, so erroring on only the new flag would be inconsistent with its three siblings.func Fuzzin the repository, and it exercisedurl.Values.Encode()— the standard library — on the premise that someone might later refactor to string concatenation. The two properties worth keeping are now four rows of an ordinary table test.api/ce/activityis deliberately not scoped by branch: SonarQube's API takes nobranchparameter there (it exists onapi/ce/analysis_status), and its tasks carrybranchper row, so it spans branches by design.Tests
In
internal/sonar/sonar_test.go, alongside the existing #861 pair and following the package's conventions (httptest, raw assertions, standalone functions):_PassesBranch— the branch is forwarded, the analysis is found_BranchParam— table: value arrives verbatim; an unset and an empty branch send no parameter at all rather than an empty one; a branch namedx&project=othercannot alter theprojectparameter_NotFoundError— the error points at the flag when no branch was given, and names the branch when one wasTestGetSonarResults_ProjectKeyPath_WithBranch/_WithoutBranch— end-to-end, and the failing half is what makes the passing half evidence about SonarQube rather than about a lenient fake_BranchIgnoredForPullRequest— no branch alongside a pull requestVerified locally beyond the committed tests, with nothing added to the repo for it: fuzzing over the branch-name encoding (320k executions clean, then deleted) and mutation testing on
internal/sonarwith gremlins — every mutant on the new guards killed, package efficacy 65.2% → 68.6% and mutator coverage 58.9% → 90.5% againstmain.make lintclean.Case 113's golden was already stale on
main— it predates a reword of that error, and its suite only runs whenSONARQUBEis set, so nothing caught the drift. Corrected here; flagging it so it does not read as scope creep.Accepted with eyes open
MuXRequiredFlagskeys onflag.Changed, and config/env binding applies values throughcmd.Flags().Set(cmd/kosli/root.go:672), which marks a flag changed. SoKOSLI_SONAR_BRANCHexported across a pipeline makes every--pull-requestattestation in it fail, even where the flag was never typed. Kept as-is deliberately: it is byte-identical in shape to the--sonar-revision/--pull-requestexclusion two lines above, and a pipeline attesting both branch and PR scans already hits that one viaKOSLI_SONAR_REVISION. Consistency with the sibling beats a special case for the new flag.TestGetSonarResults_BranchIgnoredForPullRequestcurrently proves the pull-request guard only by accident: the fake'sce/activitytasks carry nopullRequest, so nothing matches andGetTaskIDnever touches the branch. A more realistic fixture would makeGetTaskIDnil the branch itself, at which point the test would pass with the guard deleted. Known and accepted; fixing it properly means a new fixture and new assertions.Follow-up worth its own issue
api/ce/activityrequires administrative permission, so an ordinary Execute-Analysis + Browse CI token gets a 403.ActivityResponseis the only response struct in this file with noErrorsfield, andGetTaskIDonly returns an error when JSON decoding fails — so{"errors":[...]}decodes cleanly to zero tasks and every project-key run silently publishes with an emptytaskId,statusand project name. Strictly pre-existing and out of scope here, but it should not be lost.Customer impact
ADCB's pipeline currently lists branches and matches the revision itself, then attests with
kosli attest generic, losing the nativesonarattestation type. That workaround is markedREMOVE WHEN FIXEDin their repo. They pinKOSLI_CLI_VERSIONto major2, so they pick this up automatically on release.🤖 Generated with Claude Code