From SonarQube Warning to ESLint Rule: Turning Code Quality Problems Into Guardrails
I remember the first time sonar-jira-tickets finished a run against a project I actually cared about. It printed a summary, sat still for a second, and then Jira had 70 new tickets in it. I'd fed it 572 raw SonarQube issues. Seventy tickets, not 572. My first reaction wasn't relief, it was mild panic that the tool had silently dropped 502 issues somewhere. It hadn't. It had just done the one thing raw issue counts never do on their own: told me which 70 problems actually existed, instead of how many times each one had been copied and pasted around the codebase.
That grouping step was the subject of the first article in this series. This one picks up right where that left off, because grouping issues is only useful if you then do something with the group. Fixing the 45 occurrences of one rule and closing the ticket gets you back to zero for a week. It does not stop the 46th occurrence from showing up the next time someone reaches for the same pattern out of habit. That took a second piece of tooling, on the other end of the pipeline.
From a pile of issues to a pile of tickets
The grouping logic in sonar-jira-tickets is deliberately dumb. No fuzzy matching, no similarity scoring, no attempt to guess that two differently worded messages are secretly the same problem. It groups by rule ID and nothing else:
export function groupByRule(issues) { const groups = new Map(); for (const issue of issues) { if (!groups.has(issue.rule)) { groups.set(issue.rule, { rule: issue.rule, message: issue.message, severity: issue.severity, occurrences: [], }); } groups.get(issue.rule).occurrences.push({ component: issue.component, line: issue.line, }); } return [...groups.values()].sort((a, b) => b.occurrences.length - a.occurrences.length); }
On the project with 572 issues, that produced 70 groups. On a second, older project with 1,086 issues, it produced 55 groups, and one rule alone accounted for 45 occurrences by itself. Same idea, different codebase, same shape of result: a small number of rules doing most of the damage, buried inside a large number of individual complaints.
What a ticket actually contains
Each Jira ticket built from a group lists every occurrence, file and line, with a code snippet pulled straight from the local checkout so nobody has to tab over to SonarQube to see what's being flagged. It's built as Atlassian Document Format and posted through POST /rest/api/3/issue, optionally parented under an Epic so a whole cleanup effort stays visible as one unit in a sprint board rather than 70 loose tickets nobody owns.
The idempotency is the part I'm most fond of, mostly because it's so unambitious. It would have been easy to reach for a content hash of the ticket body to detect duplicates. Instead each ticket gets a label like sonar-rule-typescript-S8786, and before creating anything the tool runs a JQL search for that label. If a ticket with that label already exists, nothing new gets created. Rerunning the tool against a project you scanned yesterday just confirms the tickets are still there. That's the whole mechanism. No hashing, no diffing ticket contents against previous runs, just a label lookup.
When a ticket is still too big
Sometimes a group is too large to be one useful piece of work. A rule with 60 occurrences spread across a dozen files isn't a ticket, it's a backlog wearing a ticket's clothes. A separate script that runs afterward handles that case by splitting an oversized ticket into Jira Subtasks, chunked at 25 occurrences by default. 25 isn't a number I derived from anything scientific, it's roughly what one engineer can plausibly finish without the subtask itself turning into a slog spanning multiple days. The point of splitting isn't neatness, it's making sure a ticket with 60 occurrences doesn't sit untouched in a backlog for months because nobody wants to be the person who claims it.
The other half: turning fixes into guardrails
None of that, on its own, prevents anything. Grouped or not, a ticket is still just a list of things to do. The actual guardrail lives somewhere else entirely: in eslint-plugin-sonarjs, wired into the project's eslint.config.mjs, mirroring SonarCloud's own rule set so the same patterns SonarQube would flag in CI get flagged locally, in the editor, before a commit even happens.
The discipline that made this config worth trusting is simple to state and a little tedious to follow: a rule only gets turned on after it's been verified against a real finding. Not "this rule sounds like a good idea," but "I ran this rule against our code and watched it fire on the exact pattern that got us a SonarQube ticket last month." Enabling rules speculatively is how you end up with a lint config nobody trusts, muted halfway by inline disables because a third of the rules were never checked against anything real.
Clearing the backlog before flipping the switch
The clearest example of that discipline was sonarjs/cognitive-complexity. It didn't start out covering the whole repo. It started scoped to a handful of files, enabled just there while we worked through a backlog of 31 flagged functions scattered across the whole codebase. Only once that backlog was cleared, all 31 functions brought under the complexity threshold, did the rule get turned on for every file in the project.
That ordering matters more than it looks like it should. Turning the rule on for the whole repo first would have meant 31 new lint failures appearing across the codebase overnight, in files nobody had touched that week, blocking unrelated pull requests for a mess someone else made. Fixing the pattern everywhere first meant the guardrail, when it finally went up, went up clean. Nobody hit it by surprise. It was already true of the whole codebase by the time it became enforced.
The gap ESLint can't close
Not everything SonarQube's quality gate checks can move into a lint rule. Duplication across files is the obvious one. ESLint works file by file, by design, so a block of logic copied and pasted into three different files is invisible to it no matter how the rules are configured. That gap gets covered by a separate tool, jscpd, configured with a 2.5% duplication threshold. That number isn't arbitrary either, it's deliberately set to match the project's own SonarQube quality gate threshold, so a local jscpd run and a CI SonarQube scan agree with each other instead of contradicting one another over two different definitions of "too much duplication."
The lesson
Grouping by rule tells you what actually needs fixing. Fixing the group, everywhere, before flipping on the equivalent lint rule is what makes the guardrail land without a fight. The ticket tooling and the lint config aren't two unrelated projects, they're two ends of the same pipeline: one finds and organizes the problem, the other makes sure the fixed version of the codebase is the only version anyone can commit again. The concrete habit I took out of that sequence is this: never turn on a rule for the whole repo until you've personally verified, on real code, that it fires on the exact pattern you're trying to kill, and never before the existing instances of that pattern are already gone.