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 - #1117
fix(sonar): add --sonar-branch so attest sonar finds scans on non-main branches#1117AlexKantor87 wants to merge 11 commits into
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. This is the same defect as #861, in the sibling code path that fix did not cover. Forward sonarResults.Branch the same way #861 did. Nothing populates Branch on this path yet, so behaviour is unchanged until the --sonar-branch flag lands. Tests use the real responses from the customer instance in #1116, and pin the backwards-compatible contract: no branch param at all when no branch is known. 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 forwarded to project_analyses/search, which makes `kosli attest sonar --sonar-project-key` work for a scan on any branch. Unset, nothing changes. Mutually exclusive with --pull-request: a PR scan is not a branch scan, and the branch would be silently ignored. NewSonarConfig takes the branch as a ninth positional parameter rather than moving to an options struct. The constructor is overdue that refactor, but this fix is blocking a customer and an exported-API change unrelated to the defect belongs in its own PR, where it cannot be conflated with a behaviour change. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty result from project_analyses/search is indistinguishable from a permissions problem. In #1116 that cost the customer several days chasing token types and Browse permissions while their token was correct throughout: the analysis was on another branch and the search only ever looked at the main one. Name the scope in the error — the branch that was searched, or that only the main branch was, because no --sonar-branch was given. Golden for the SonarQube Server suite's case 113 was already stale (it predates an earlier reword of this message and that suite only runs with SONARQUBE set); brought in line with the message the code now produces. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Branch names routinely contain characters that are special in a query string, and the #1116 branch (release/uat) is one of them. FuzzBranchParam pins two properties for any branch name: it round-trips to SonarQube byte-for-byte, and it cannot alter another query parameter — a branch called "x&project=other" must not change which project is searched. url.Values.Encode() already gives us both; the fuzz test is what keeps them. Verified by temporarily rewriting the URL build as string concatenation: seeds "a&b=c", "x&project=other", "a#b" and "a?b" all fail, so a later refactor that reintroduces the injection is caught. 1.1M executions clean over 60s. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one The activity list spans every branch of a project, so the end-to-end test now serves a decoy task for a different analysis ahead of the real one, and asserts the task ID, status and branch type that come back. Mutation testing showed the single-task fake let both halves of GetTaskID's match condition be negated without any test noticing; both mutants are now killed. Also documents the CE task winning over the flag: --sonar-branch supplies a branch name only, and the branch type can only come from the task. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude finished @AlexKantor87's task in 5m 41s —— View job Review — round 3 (at |
| Check | Result |
|---|---|
go build ./... | clean |
go vet ./... | clean (this compiles the test files too, so the new tests build) |
go mod tidy + git diff --exit-code go.mod go.sum | clean |
I could not run go test — the sandbox refused the command, so the sonar unit tests and the fuzz seeds are unverified by me. Everything below is from reading.
Prior threads — all resolved at HEAD
| Thread | State |
|---|---|
Missing empty-flag-audit-coverage.json entry | fixed (20cacfd9), plus spec.json and categories.json |
| Branch leaking into PR payloads | fixed — guard at sonar.go:219, results.Branch == nil asserted at sonar_branch_test.go:203 |
api/ce/activity branch scoping | answered with evidence (no branch param exists on that endpoint); correctly not forwarded |
| "Check the branch is correct" with no branch given | reworded, both goldens updated |
| Task erasing the user's branch | fixed at sonar.go:242, pinned by TestGetSonarResults_TaskWithoutBranch_KeepsSuppliedBranch |
| Warning landing in golden output ahead of case 27's error | fixed (3832c1c9), pinned by TestGetSonarResults_NoTaskThenFailure_DoesNotWarn |
| Case 20 coupled to SonarCloud's activity window | fixed (f44ff094) — analysisID != "" gate, pinned by TestGetSonarResults_PullRequestNoTask_DoesNotWarn; case 13 carries the note |
analysesSearchURL duplication | fixed (201a2a95) — both lookups share it, GetProjectAnalysisFromAnalysisID is now a pure refactor |
--pull-request help not naming the exclusion | fixed (195f34ca) |
I checked the audit spec change specifically for the failure mode a new MuX'd flag could cause: flags_to_test now lists both pull-request and sonar-branch, but flag_values is consumed one flag at a time against a baseline that contains neither, so the probe never sends the forbidden pair. Same shape as the pre-existing sonar-revision/pull-request pair.
This round
Two nits, both posted inline, neither blocking:
sonar.go:219— the two branch guards spell the same-looking condition two ways (sc.pullRequestvssonarResults.PullRequest). Both are correct for their position; only the second can be the results field. Spelling both the same way removes the invitation to unify them wrongly later.sonar.go:310— the missing-task warning names task ID and status, butproject.Nameis also only set insideGetTaskID's match block, so the payload loses that too on this path. One more noun in the sentence, and an assertion inTestGetSonarResults_NoMatchingTask_Warnsto hold it.
Nothing else found. In particular I checked and found no problem with: GetQualityGate (keyed on analysisId, branch-independent, so branch scoping cannot skew it); the Branch/Type JSON tags (both omitempty, so a name-only branch serialises cleanly); backwards compatibility (with the flag unset, analysesSearchURL emits exactly the pre-PR query on both paths, and TestGetProjectAnalysisFromRevision_EmptyBranchNameNotSent asserts absence rather than emptiness); and the new MuX ordering against case 30's golden (--sonar-revision defaults to the commit SHA without being Changed, so the revision/PR check passes first and the branch/PR check produces the expected message).
Worth saying
The analysesSearchURL extraction is the change that matters most beyond this bug: two copies of "scope the search to the branch" is what let #861 and #1116 be fixed six months apart, and the comment now records that reason at the one place it lives. The pairing of WithBranch and WithoutBranch against the same fake is good evidence discipline — it's what makes the passing test a claim about the fix rather than about a lenient stub. And the two corrections this round, on warning placement and on a comment that described intent rather than code, were both taken cleanly with the reasoning written down.
· branch sonar-branch-flag-1116
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.
CI's TestEmptyFlagAuditCoversEveryCommandAndFlag caught the new flag as unaudited, which is what that test is for. Regenerated the coverage file with UPDATE_AUDIT_COVERAGE=1 as it documents (one line added, no churn), and added the flag to spec.json's attest sonar entry — audit.py hard-stops until the spec covers every combination, so without it the tool would refuse to run for the next person. The audit itself needs a local server and has not been re-run, so results.tsv is one row short of the current CLI until someone runs it. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
…lence Four points from review, each red-first: - A branch is no longer set alongside a pull request. The CLI blocks that flag combination, but the library could still produce it, because the branch is only cleared when GetTaskID matches a task and api/ce/activity is a bounded recent-activity list that need not contain one. - The supplied branch now survives a task that reports no branch. SonarQube omits it for main-branch tasks and older self-hosted Servers omit it more widely, so a run could use --sonar-branch to find the analysis and still publish an attestation without it. The task stays authoritative for the type. - GetTaskID warns when no task matched, instead of leaving TaskID and Status empty without a word — the same silent gap this PR exists to remove. - --sonar-branch warns when it is ignored, on the report-task.txt and --sonar-ce-task-url paths where the branch comes from the scan task. No mutual exclusion with --sonar-ce-task-url: this command already ignores --sonar-project-key, --sonar-revision and --sonar-server-url when scanner metadata exists (case 14), so erroring on only the new flag would be inconsistent. The not-found error no longer asks the user to check a branch they never gave. api/ce/activity is deliberately not scoped by branch: SonarQube's own client sends no such parameter there (branch exists on api/ce/analysis_status), and its tasks carry branch per row, so it spans branches by design. Forwarding one would have been an unknown parameter for SonarQube to reject. Mutation testing: all seven mutants on the new guards killed; package efficacy 67.96% -> 74.34%, mutator coverage 88.03% -> 91.13%. 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.
Uh oh!
There was an error while loading. Please reload this page.
CI caught the warning added in c50a9c5 firing ahead of an error: case 27 gives a pull-request ID that does not exist, so no compute engine task matches, and the run printed "the attestation will carry no task ID" immediately before "pull request 1 not found" — noise ahead of a better message, about an attestation that was never going to be published. Moved it to the successful return, conditioned on the payload actually having no task ID. Errors return before it, so it can no longer precede one, and it now describes what is being returned rather than what one lookup did. The failing case is pinned as a unit test, so the next person does not need CI to find it. 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.
Both analyses lookups had their own copy of "scope the search to the branch when we have one", added six months apart by the fixes for #861 and #1116. That duplication is why the second issue existed: the rule was fixed on one path and left broken on the other. Extracted analysesSearchURL so there is one copy to get right, and so FuzzBranchParam pins the encoding for both paths. Behaviour-preserving: no test changed, and mutation testing still kills both guard mutants, now at one site instead of two. Also from review: the comment above the branch-fallback said the task was authoritative for the branch *type*, which undersold it — the task's branch wins outright when it reports one. Comment now says what the code does. And categories.json gains sonar-branch as "identity", alongside sonar-project-key, sonar-revision and pull-request; report.py tolerates its absence, so this only keeps the generated table from reading as though nobody considered the flag. 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.
Review changed my mind on the scope of this warning. With an analysis ID in hand, SonarQube has just returned that analysis, so its compute engine task missing from api/ce/activity is surprising and worth saying. On the pull-request path there is no analysis ID and the match can only be on the PR key, which a recent-first, page-bounded list drops as a matter of course — so the warning was firing on the ordinary case, and by the comment above it that was the documented expectation. It also tied case 20 to SonarQube's activity window, since the warning is compared as part of the golden. Case 13 still is, and now says so. Rejected the alternative of demoting it to Debug: off by default means the one person who needs it is the one who will not see it. Also from review: a word on why reaching the CE-path block is what makes the ignored-flag warning true, since readFile populating sc.CETaskUrl is not visible from there. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--sonar-branch documented the mutual exclusion from its side and --pull-request did not, so a user reading --help from the --pull-request entry found out by hitting the error. A help string I made wrong, missed in review replies rather than declined. Also records why the ignored-flag warning is deliberately emitted before the lookups that can fail, where the missing-task warning is not: "the flag you passed is ignored" is true whether or not the run succeeds, while a warning that describes the payload would be false on a run that never publishes one. Refs #1116 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| // can only come from the user (#1116). Set it on the results, which is the | ||
| // one mechanism both analyses lookups use to scope their search. A pull | ||
| // request scan is not a branch scan, so the branch is not carried there. | ||
| if sc.branch != "" && sc.pullRequest == "" { |
There was a problem hiding this comment.
Nit, consistency: this guard reads sc.pullRequest == "" while the sibling guard 23 lines below reads sonarResults.PullRequest == "". Both are correct — at this point sonarResults.PullRequest has only ever been assigned from sc.pullRequest (line 193-195), whereas after GetTaskID the results field can additionally have been set from the matched task, which is exactly why the later guard must read the results field. But two spellings of what looks like the same condition, in two blocks that exist for the same reason, invites someone to "unify" them in the wrong direction later.
Cheapest fix is to spell both the same way, since sonarResults.PullRequest is already populated here:
| ifsc.branch!=""&&sc.pullRequest=="" { | |
| ifsc.branch!=""&&sonarResults.PullRequest=="" { |
| // PR key, which a recent-first, page-bounded list drops as a matter of course — | ||
| // warning there would be noise about the ordinary case. | ||
| if analysisID != "" && sonarResults.TaskID == "" { | ||
| logger.Warn("no SonarQube compute engine task was found for analysis %s of project %s: the attestation carries no task ID or scan status", analysisID, project.Key) |
There was a problem hiding this comment.
The warning slightly under-reports what the unmatched task costs. GetTaskID sets project.Name = task.ComponentName only inside the match block (sonar.go:662), so on this exact path the attestation also carries an empty project name — on the project-key path nothing else ever sets it. TestGetSonarResults_NoMatchingTask_Warns would show it: results.Project.Name == "" there, while the happy-path test gets "customer project".
Since the sentence exists to say what the payload is missing, naming the third thing costs nothing:
| logger.Warn("no SonarQube compute engine task was found for analysis %s of project %s: the attestation carries no task ID or scan status", analysisID, project.Key) | |
| logger.Warn("no SonarQube compute engine task was found for analysis %s of project %s: the attestation carries no task ID, scan status or project name", analysisID, project.Key) |
Worth an assertion on results.Project.Name in that test either way, so the claim in the message stays tied to something.
Fixes#1116
What was wrong
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.This is the same defect as #861, in the sibling code path that fix did not cover. #861's fix cannot simply be reused:
sonarResults.Branchis only ever populated from a CE task response, and the revision path has no task — it is searching for the analysis by revision — so there is nothing to forward. The CLI cannot infer the branch here, so it has to be told.What this does
--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, so this is backwards compatible.--pull-request: a PR scan is not a branch scan, and the branch would otherwise be silently ignored.--sonar-branchwas given. This is the part that cost the customer several days: an empty result from SonarQube is indistinguishable from a permissions problem, and their token and permissions were correct throughout.The branch is set on
sonarResults.Branchrather than threaded as a new argument, so both analyses lookups keep one mechanism, andbranchnow appears in the attestation payload for this path too. Where a CE task is found later in the flow it still wins — the flag supplies a name only, and the branch type can only come from the task.The constructor decision
NewSonarConfigtakes the branch as a ninth positional parameter rather than moving to an options struct. Nine positional params is overdue that refactor, but this fix is blocking a live customer, and changing an exported API for reasons unrelated to the defect belongs in its own PR where it cannot be conflated with a behaviour change. Noted as a follow-up.Testing
Unit tests use the real responses from the customer instance in #1116 (
release/uat,masternever analysed). They cover: branch forwarded when set; nobranchparameter at all when unset or empty (asserting absence, not emptiness); the analysis found on a non-main branch when the main branch has none; a branch analysis with a different revision not returned; and the error text in both branch states. The two pre-existing #861 tests still pass.Fuzzing.
FuzzBranchParampins two properties for any branch name: it round-trips to SonarQube byte-for-byte, and it cannot alter another query parameter — a branch calledx&project=othermust not change which project is searched.url.Values.Encode()already gives us both; the fuzz test is what keeps them. Verified by temporarily rewriting the URL build as string concatenation: seedsa&b=c,x&project=other,a#banda?ball fail, so a later refactor that reintroduces the injection is caught. 1.1M executions clean over 60s.Mutation testing with gremlins 0.6.0, scoped to
internal/sonar:mainEvery mutant in the new branch handling is killed, including the one worth worrying about — negating
Branch.Name != ""in the guard, which a happy-path-only test would miss. Mutator coverage rises 29 points because the new end-to-end test reaches code the package never executed before; the newly-visible survivors are pre-existing gaps inGetQualityGateand the CE wait loop, not new ones.Two survivors adjacent to this change were killed: mutation testing showed a single-task fake let both halves of
GetTaskID's match condition be negated unnoticed, so the test now serves a decoy task for a different analysis ahead of the real one and asserts the task ID, status and branch type that come back. Three survivors remain atsonar.go:615, inGetTaskID's pull-request match clause, which has no end-to-end test — a separate gap, noted rather than papered over.Note the gremlins run needs
--timeout-coefficient=60; at the default, most mutants time out spuriously on a loaded machine.Not run locally:
make test_integration. It needsKOSLI_API_TOKEN_PRODandKOSLI_SONAR_API_TOKEN, neither of which is available in my environment, and the setup script prompts interactively. Relying on CI for it. Twocmd/kosligolden strings are updated for the new error text — including case 113 in the SonarQube Server suite, whose golden was already stale (it predates an earlier reword of this message, and that suite only runs withSONARQUBEset).make lintclean.Customer impact
ADCB's pipeline currently works around this by listing branches and matching the revision itself, then attesting with
kosli attest generic, which loses 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