Copilot generates code fast. SonarQube tells you that code has problems. Nothing connects the two. The developer has to read the Sonar report, interpret it, switch back to the editor, and manually ask Copilot to fix the thing Copilot wrote in the first place. This is a workflow gap, not a tooling gap, and it is the cheapest high-impact agent you can build right now.
The pattern is easy to picture. A PR gets flagged, some of the issues get fixed, the rest get waved through as "not a blocker", and one or two get misread. By the time a senior reviews it, the PR has baked-in quality debt that the tooling caught and a human dismissed. The human did not ignore it out of laziness. They ignored it because the feedback was a link to a dashboard, not an actionable change.
The Loop That Is Missing
The agent has one job: turn a Sonar issue into a prompt the developer can paste into Copilot without thinking. That is it. It is not rewriting the code. It is not opening PRs automatically. It is closing the gap between "here is a problem" and "here is a fix request with full context."
One licence check before you plan the weekend. Pull request analysis is not in SonarQube Community Build. It needs Developer Edition or above, and Community rejects sonar.pullrequest.key outright. So the free path is branch analysis: run the scanner on main, let the agent read the issues that appeared since the last analysis, and post the prompts on the merge commit instead of on the open PR. If you want the PR trigger without paying, the community branch plugin (mc1arke/sonarqube-community-branch-plugin) adds branch and PR analysis to Community Build. It is community maintained and unsupported by SonarSource, so read the compatibility matrix before you wire your team to it. Everything below is the same agent either way. Only the trigger and the place it comments change.
Why This One First
When someone asks me what their first agent should be, this is what I recommend for any team already using Sonar and Copilot. Three reasons:
- The input is structured. SonarQube has a clean API that returns issues with rule ID, severity, and file location. You are not parsing HTML or scraping.
- The output is a text blob. The agent produces a prompt. No code execution, no production risk, no runtime dependencies.
- The value is obvious in week one. Developers feel the time savings the first PR that has Sonar warnings.
Compare that to a bug triage agent or a code review agent. Both are valuable. Both take 10x the effort to get to usable quality. The SonarQube bridge is a weekend project that gets used every day.
What the Agent Actually Does
Trigger: the SonarQube webhook that fires when analysis finishes. On a PR if your edition does pull request analysis, on the branch if it does not. A GitHub Action that runs after Sonar completes works the same way.
Steps, in order:
- Pull the issue list for this PR from Sonar. Filter out INFO, keep BLOCKER, CRITICAL, MAJOR.
- For each issue, read the file at the flagged line range. Include 10 lines of context above and below.
- Group issues by file. One Copilot prompt per file, not per issue. Developers want to fix by file.
- Generate a structured prompt: the rule, why it matters, the snippet, the fix pattern. Include any project conventions from your knowledge base.
- Post a single comment on the PR with the prompts, collapsible per file.
What the Prompt Looks Like
## Fix prompt for `src/orders/order-summary.component.ts`
SonarQube flagged 3 issues in this file. Paste this into Copilot or Claude Code:
---
In `order-summary.component.ts`, fix the following issues while preserving existing behavior:
1. **Line 42 (typescript:S1481)**: unused local variable `totals`.
Remove the variable. If it was intended for debugging, delete it, do not comment it out. The team convention is to remove dead code rather than leave it behind.
2. **Line 67 (typescript:S4123)**: `await` should only be used with promises.
`formatAmount` returns a string, not a Promise. Drop the `await` keyword on that call.
3. **Line 88 (typescript:S6544)**: Promise returned in a context expecting `void`.
The async callback passed to `addEventListener` returns a Promise, which is unhandled. Wrap the body in a non-async IIFE or handle the returned Promise explicitly. Prefer the IIFE pattern since the team avoids bare async handlers on DOM events.
Do not modify any file other than `order-summary.component.ts`. Keep imports sorted alphabetically. Return only the full updated file content, no explanation.Notice what is in that prompt that is not in a vanilla Sonar comment: the rule description translated into human language, the team convention, the hard constraint on what not to modify, the output format. This is the part the developer used to do in their head. Now the agent does it.
Wiring It Up
This post pairs with the monorepo post. The agent reuses ReActAgent from libs/base_agent/ and lives in agents/sonar_bridge/. Under 100 lines of real code, most of it HTTP glue. The shape is what matters: a handful of typed tools, a playbook, and a build function the runtime calls.
# libs/tools/sonar.py
import os
import requests
SONAR_URL = os.environ["SONAR_URL"]
SONAR_TOKEN = os.environ["SONAR_TOKEN"]
PROJECT_KEY = os.environ["SONAR_PROJECT_KEY"]
def get_issues_for_pr(pr_key: str) -> list[dict]:
r = requests.get(
f"{SONAR_URL}/api/issues/search",
params={
# componentKeys is not optional. Without a project scope the
# search does not come back with this PR's issues.
"componentKeys": PROJECT_KEY,
"pullRequest": pr_key,
# Legacy taxonomy, still accepted. On SonarQube 10.2 and up
# the current filter is impactSeverities=HIGH,MEDIUM.
"severities": "BLOCKER,CRITICAL,MAJOR",
},
auth=(SONAR_TOKEN, ""),
timeout=15,
)
r.raise_for_status()
return r.json()["issues"]
# libs/tools/github.py
import os
import requests
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
def read_file_range(repo: str, ref: str, path: str, start: int, end: int) -> str:
# Returns the flagged range plus 10 lines of context on each side.
r = requests.get(
f"https://api.github.com/repos/{repo}/contents/{path}",
params={"ref": ref},
headers={
"Authorization": f"Bearer {GITHUB_TOKEN}",
"Accept": "application/vnd.github.raw",
},
timeout=15,
)
r.raise_for_status()
lines = r.text.splitlines()
lo = max(0, start - 11)
hi = min(len(lines), end + 10)
return "\n".join(lines[lo:hi])
def comment_on_pr(repo: str, pr_number: int, body: str) -> None:
r = requests.post(
f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments",
headers={"Authorization": f"Bearer {GITHUB_TOKEN}"},
json={"body": body},
timeout=15,
)
r.raise_for_status()Three tools, two files. The Sonar one scopes the search to the project and filters by severity before the agent sees the list, so low-priority noise never reaches the prompt or the token budget. The GitHub read returns a window around the flagged lines so the agent can reason about the fix in context, not just about the rule ID. The GitHub comment is a single write at the end of the run, enforced by the playbook rather than by the code, so the behavior is visible and changeable without a redeploy.
# agents/sonar_bridge/playbook.py
PLAYBOOK = """You are the Sonar-to-Copilot bridge agent.
Goal: turn a Sonar analysis report on a pull request into prompts a
developer can paste into Copilot or Claude Code without context-switching.
Process, in order:
1. Call get_issues_for_pr with the PR key from the trigger. Project
scope and severity are already handled by the tool.
2. Group the remaining issues by file path.
3. For each file, call read_file_range on the flagged line ranges to
pull the code into context.
4. For each file, synthesize one Copilot-ready prompt. Apply project
conventions retrieved from the knowledge base.
5. Call comment_on_pr once with all per-file prompts in a single
collapsible comment.
Hard rules:
- You generate prompts. You do not generate code.
- comment_on_pr is called at most once per run.
- If there are zero qualifying issues, exit without commenting.
"""The playbook is where the agent's behavior is specified. Notice what is in it: the process steps, the hard rules, the output format. What is not in it: the rule-by-rule translation guidance. That lives in the knowledge base, retrieved at run time, because team conventions change and I do not want to redeploy the agent every time they do.
# agents/sonar_bridge/agent.py
from libs.base_agent import ReActAgent, AgentConfig
from libs.memory import MemoryStack
from libs.telemetry import Telemetry
from libs.tools import sonar, github
from .playbook import PLAYBOOK
def build() -> ReActAgent:
return ReActAgent(
config=AgentConfig(
name="sonar_bridge",
version="0.1.0",
playbook=PLAYBOOK,
tools=[
sonar.get_issues_for_pr,
github.read_file_range,
github.comment_on_pr,
],
# Confidence in the prompt it drafted for a file. Below
# this, the agent lists the issue and writes no prompt.
confidence_threshold=0.85,
),
memory=MemoryStack.default(),
telemetry=Telemetry.default(),
)The last piece is the trigger. A FastAPI handler in apps/api/ receives the Sonar webhook, extracts the PR key, and calls build().run({"pr_key": ...}). The base agent does the rest: pulls similar past runs from the vector store, executes the ReAct loop, writes telemetry to Postgres, posts the comment on GitHub. The threshold sits above the library default because a wrong fix prompt costs more than a missing one. A developer who pastes a confident wrong prompt into Copilot ships the wrong change and thinks Sonar approved it. If you built the monorepo structure first, adding a new agent is mostly writing the three files above and the tests that go with them.
The Non-Obvious Part: Team Conventions
A Sonar issue out of context is a rule violation. A Sonar issue with team context is a specific directive. The agent needs to know things like "we prefer async/await over raw observables", "we never use var", "our null-check pattern is optional chaining, not ternary". This is where your knowledge base comes in, whether that is an Obsidian vault, a folder of markdown in the repo, or a Notion space.
The agent reads the rule, looks up the team convention for that rule type, and merges them. This is the difference between "add a null check" (generic) and "add a null check using optional chaining, the pattern we use in services/" (specific and consistent with the codebase).
What to Measure
If you ship this, track two numbers.
- Average time from Sonar report to fix commit. Before the agent, this was probably measured in days or never. After, it should be in hours.
- Percentage of flagged issues actually fixed before merge. Before the agent, measure your baseline, because it is usually lower than the team assumes. After, compare against that baseline and nothing else. A number from someone else's team is not a target.
Do not track "issues resolved." Developers can close issues by marking them as won't fix, and that number lies. Track actual code changes against flagged lines.
What Can Go Wrong
Two failure modes to plan for.
First, the agent generates prompts for issues that are actually false positives. Sonar has them. If the agent does not know which rules your team habitually suppresses, it produces noise. The fix is to feed the agent your suppression history so it learns which rules to downgrade or skip entirely. Same vector store, different query.
Second, developers start treating the prompt output as the fix. They paste it into Copilot, accept the suggestion, commit. No review. This defeats the whole point. The agent is a drafting tool, not a decision maker. Set the expectation early that the developer still owns the fix.
Copilot sped up writing code. SonarQube sped up finding its flaws. The gap between the two was a workflow, not a tool. That is where the cheapest useful agent lives.
References
- SonarQube Web API: https://docs.sonarsource.com/sonarqube/latest/extension-guide/web-api/
- GitHub Copilot documentation: https://docs.github.com/en/copilot
- Claude Code documentation. https://docs.claude.com/en/docs/claude-code/overview