Skip to content

fix(sonar): add --sonar-branch so attest sonar finds scans on non-main branches - #1117

Open
AlexKantor87 wants to merge 11 commits into
mainfrom
sonar-branch-flag-1116
Open

fix(sonar): add --sonar-branch so attest sonar finds scans on non-main branches#1117
AlexKantor87 wants to merge 11 commits into
mainfrom
sonar-branch-flag-1116

Conversation

@AlexKantor87

Copy link
Copy Markdown
Contributor

Fixes#1116

What was wrong

GetProjectAnalysisFromRevision called api/project_analyses/search with only project. SonarQube defaults that endpoint to the project's main 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. #861's fix cannot simply be reused: sonarResults.Branch is 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-branch supplies the branch on the project-key/revision path, and is forwarded to project_analyses/search. Unset, the request is byte-for-byte what it was, so this is backwards compatible.
  • Mutually exclusive with --pull-request: a PR scan is not a branch scan, and the branch would otherwise be silently ignored.
  • The "not found" error now says which branch was searched — or that only the main branch was, because no --sonar-branch was 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.Branch rather than threaded as a new argument, so both analyses lookups keep one mechanism, and branch now 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

NewSonarConfig takes 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, master never analysed). They cover: branch forwarded when set; nobranch parameter 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.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.

Mutation testing with gremlins 0.6.0, scoped to internal/sonar:

KilledLivedNot coveredEfficacyMutator coverage
main43234665.15%58.93%
this branch70331467.96%88.03%

Every 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 in GetQualityGate and 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 at sonar.go:615, in GetTaskID'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 needs KOSLI_API_TOKEN_PROD and KOSLI_SONAR_API_TOKEN, neither of which is available in my environment, and the setup script prompts interactively. Relying on CI for it. Two cmd/kosli golden 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 with SONARQUBE set).

make lint clean.

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 native sonar attestation type. That workaround is marked REMOVE WHEN FIXED in their repo. They pin KOSLI_CLI_VERSION to major 2, so they pick this up automatically on release.

🤖 Generated with Claude Code

AlexKantor87and others added 5 commits August 20, 2026 19:14
… 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

claudeBot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @AlexKantor87's task in 5m 41s —— View job


Review — round 3 (at 195f34ca)

  • Static analysis (go build, go vet, go mod tidy)
  • Read the current diff
  • Verify prior review threads are resolved at HEAD
  • Review new/changed code and tests
  • Post review

Static analysis

CheckResult
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.sumclean

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

ThreadState
Missing empty-flag-audit-coverage.json entryfixed (20cacfd9), plus spec.json and categories.json
Branch leaking into PR payloadsfixed — guard at sonar.go:219, results.Branch == nil asserted at sonar_branch_test.go:203
api/ce/activity branch scopinganswered with evidence (no branch param exists on that endpoint); correctly not forwarded
"Check the branch is correct" with no branch givenreworded, both goldens updated
Task erasing the user's branchfixed at sonar.go:242, pinned by TestGetSonarResults_TaskWithoutBranch_KeepsSuppliedBranch
Warning landing in golden output ahead of case 27's errorfixed (3832c1c9), pinned by TestGetSonarResults_NoTaskThenFailure_DoesNotWarn
Case 20 coupled to SonarCloud's activity windowfixed (f44ff094) — analysisID != "" gate, pinned by TestGetSonarResults_PullRequestNoTask_DoesNotWarn; case 13 carries the note
analysesSearchURL duplicationfixed (201a2a95) — both lookups share it, GetProjectAnalysisFromAnalysisID is now a pure refactor
--pull-request help not naming the exclusionfixed (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.pullRequest vs sonarResults.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, but project.Name is also only set inside GetTaskID's match block, so the payload loses that too on this path. One more noun in the sentence, and an assertion in TestGetSonarResults_NoMatchingTask_Warns to 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

Comment threadcmd/kosli/attestSonar.go
Comment threadinternal/sonar/sonar.go
Comment threadinternal/sonar/sonar.go Outdated
Comment threadinternal/sonar/sonar.go Outdated
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>
Comment threadinternal/sonar/sonar.go
…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>
Comment threadinternal/sonar/sonar.go Outdated
Comment threadinternal/sonar/sonar.go Outdated
Comment threadinternal/sonar/sonar.go Outdated
Comment threadhack/empty-flag-audit/spec.json
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>
Comment threadinternal/sonar/sonar.go Outdated
Comment threadinternal/sonar/sonar.go Outdated
Comment threadinternal/sonar/sonar.go
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>
Comment threadcmd/kosli/root.go
Comment threadinternal/sonar/sonar.go Outdated
AlexKantor87and others added 2 commits August 20, 2026 21:06
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 == "" {

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.

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:

Suggested change
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)

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.

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:

Suggested change
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.

@mbevc1mbevc1 added go Pull requests that update go code fix labels Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fixgoPull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

attest sonar: --sonar-project-key cannot find analyses on non-main branches (same as #861, other code path)

2 participants

@AlexKantor87@mbevc1