Skip to content

Commit bbd8e20

Browse files
CLI-247 PostToolUse SQAA analysis callback
1 parent a3f18c4 commit bbd8e20

4 files changed

Lines changed: 273 additions & 0 deletions

File tree

src/cli/command-tree.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ import { apiCommand, apiExtraHelpText, type ApiCommandOptions } from './commands
4141
import { GENERIC_HTTP_METHODS } from '../sonarqube/client';
4242
import { claudePreToolUse } from './commands/callback/claude-pre-tool-use';
4343
import { agentPromptSubmit } from './commands/callback/agent-prompt-submit';
44+
import {
45+
agentPostToolUse,
46+
type AgentPostToolUseOptions,
47+
} from './commands/callback/agent-post-tool-use';
4448

4549
const DEFAULT_PAGE_SIZE = MAX_PAGE_SIZE;
4650

@@ -255,6 +259,12 @@ callbackCommand
255259
.description('UserPromptSubmit handler: scan prompt for secrets before sending')
256260
.anonymousAction(() => agentPromptSubmit());
257261

262+
callbackCommand
263+
.command('agent-post-tool-use')
264+
.description('PostToolUse handler: run SQAA analysis after agent edits or writes a file')
265+
.requiredOption('--project <key>', 'SonarQube Cloud project key')
266+
.anonymousAction((options: AgentPostToolUseOptions) => agentPostToolUse(options));
267+
258268
// Hidden flush command — only registered when running as a telemetry worker.
259269
if (process.env[TELEMETRY_FLUSH_MODE_ENV]) {
260270
COMMAND_TREE.command('flush-telemetry', { hidden: true }).anonymousAction(flushTelemetry);
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) 2026 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
// PostToolUse callback handler — runs SQAA analysis after the agent edits or writes a file.
22+
// Replaces the bash/PowerShell logic that was previously embedded in the hook script.
23+
24+
import { existsSync, readFileSync } from 'node:fs';
25+
import { relative } from 'node:path';
26+
import { resolveAuth } from '../../../lib/auth-resolver';
27+
import logger from '../../../lib/logger';
28+
import { SonarQubeClient } from '../../../sonarqube/client';
29+
import type { SqaaIssue } from '../../../sonarqube/client';
30+
import { readStdinJson } from './stdin';
31+
32+
interface PostToolUsePayload {
33+
tool_name?: string;
34+
tool_input?: { file_path?: string };
35+
}
36+
37+
export interface AgentPostToolUseOptions {
38+
project?: string;
39+
}
40+
41+
export async function agentPostToolUse(options: AgentPostToolUseOptions): Promise<void> {
42+
let payload: PostToolUsePayload;
43+
try {
44+
payload = await readStdinJson<PostToolUsePayload>();
45+
} catch {
46+
return; // unparseable stdin — non-blocking
47+
}
48+
49+
const toolName = payload.tool_name;
50+
if (toolName !== 'Edit' && toolName !== 'Write') return;
51+
52+
const filePath = payload.tool_input?.file_path;
53+
if (!filePath || !existsSync(filePath)) return;
54+
55+
const auth = await resolveAuth().catch(() => null);
56+
if (auth?.connectionType !== 'cloud' || !auth.orgKey) return;
57+
58+
const projectKey = options.project;
59+
if (!projectKey) return;
60+
61+
try {
62+
const fileContent = readFileSync(filePath, 'utf-8');
63+
const filePath_ = relative(process.cwd(), filePath);
64+
const client = new SonarQubeClient(auth.serverUrl, auth.token);
65+
66+
const response = await client.analyzeFile({
67+
organizationKey: auth.orgKey,
68+
projectKey,
69+
filePath: filePath_,
70+
fileContent,
71+
});
72+
73+
const text = formatSqaaResult(response.issues, response.errors);
74+
process.stdout.write(
75+
JSON.stringify({
76+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text },
77+
}) + '\n',
78+
);
79+
} catch (err) {
80+
logger.debug(`PostToolUse SQAA analysis failed: ${(err as Error).message}`);
81+
}
82+
}
83+
84+
function formatSqaaResult(
85+
issues: SqaaIssue[],
86+
errors?: Array<{ code: string; message: string }> | null,
87+
): string {
88+
const lines: string[] = [];
89+
90+
if (issues.length === 0) {
91+
lines.push('SQAA analysis completed — no issues found.');
92+
} else {
93+
lines.push(`SQAA analysis found ${issues.length} issue${issues.length === 1 ? '' : 's'}:`);
94+
issues.forEach((issue, idx) => {
95+
const location = issue.textRange ? ` (line ${issue.textRange.startLine})` : '';
96+
lines.push(` [${idx + 1}] ${issue.message}${location} [${issue.rule}]`);
97+
});
98+
}
99+
100+
if (errors && errors.length > 0) {
101+
lines.push('SQAA errors:');
102+
errors.forEach((e) => lines.push(` [${e.code}] ${e.message}`));
103+
}
104+
105+
return lines.join('\n');
106+
}

tests/integration/specs/analyze/analyze-sqaa.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,32 @@ describe('analyze sqaa', () => {
137137
{ timeout: 15000 },
138138
);
139139

140+
it(
141+
'exits with code 0 and silently skips SQAA when --branch is provided but no project is registered',
142+
async () => {
143+
const server = await harness
144+
.newFakeServer()
145+
.withAuthToken(VALID_TOKEN)
146+
.withSqaaResponse({ issues: [] })
147+
.start();
148+
149+
// Cloud connection with orgKey but no extension registered → no projectKey → skip
150+
harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG);
151+
152+
harness.cwd.writeFile('src/index.ts', 'const x = 1;');
153+
154+
const result = await harness.run('analyze sqaa --file src/index.ts --branch main');
155+
156+
expect(result.exitCode).toBe(0);
157+
// --branch is ignored, no API call is made
158+
const sqaaCalls = server
159+
.getRecordedRequests()
160+
.filter((r) => r.path === '/a3s-analysis/analyses');
161+
expect(sqaaCalls).toHaveLength(0);
162+
},
163+
{ timeout: 15000 },
164+
);
165+
140166
it(
141167
'calls SQAA API and reports no issues found for clean file',
142168
async () => {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) 2026 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
22+
import * as fs from 'node:fs';
23+
import * as authResolver from '../../src/lib/auth-resolver';
24+
import * as stdinModule from '../../src/cli/commands/callback/stdin';
25+
import * as clientModule from '../../src/sonarqube/client';
26+
import { agentPostToolUse } from '../../src/cli/commands/callback/agent-post-tool-use';
27+
28+
const TEST_FILE = '/sonar-test/src/main.ts';
29+
30+
describe('agentPostToolUse', () => {
31+
let stdoutSpy: ReturnType<typeof spyOn>;
32+
let resolveAuthSpy: ReturnType<typeof spyOn>;
33+
let readStdinJsonSpy: ReturnType<typeof spyOn>;
34+
let existsSyncSpy: ReturnType<typeof spyOn>;
35+
let readFileSyncSpy: ReturnType<typeof spyOn>;
36+
let analyzeFileSpy: ReturnType<typeof spyOn>;
37+
38+
beforeEach(() => {
39+
stdoutSpy = spyOn(process.stdout, 'write').mockImplementation(() => true);
40+
resolveAuthSpy = spyOn(authResolver, 'resolveAuth').mockResolvedValue({
41+
token: 'tok',
42+
serverUrl: 'https://sonarcloud.io',
43+
connectionType: 'cloud',
44+
orgKey: 'myorg',
45+
});
46+
readStdinJsonSpy = spyOn(stdinModule, 'readStdinJson').mockResolvedValue({
47+
tool_name: 'Edit',
48+
tool_input: { file_path: TEST_FILE },
49+
});
50+
existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(true);
51+
readFileSyncSpy = spyOn(fs, 'readFileSync').mockReturnValue('const x = 1;');
52+
analyzeFileSpy = spyOn(clientModule.SonarQubeClient.prototype, 'analyzeFile').mockResolvedValue(
53+
{ id: 'analysis-id', issues: [], errors: null },
54+
);
55+
});
56+
57+
afterEach(() => {
58+
stdoutSpy.mockRestore();
59+
resolveAuthSpy.mockRestore();
60+
readStdinJsonSpy.mockRestore();
61+
existsSyncSpy.mockRestore();
62+
readFileSyncSpy.mockRestore();
63+
analyzeFileSpy.mockRestore();
64+
});
65+
66+
it('writes additionalContext JSON when analysis returns no issues', async () => {
67+
await agentPostToolUse({ project: 'my-project' });
68+
69+
expect(stdoutSpy).toHaveBeenCalledTimes(1);
70+
const output = JSON.parse((stdoutSpy.mock.calls[0][0] as string).trim());
71+
expect(output.hookSpecificOutput.hookEventName).toBe('PostToolUse');
72+
expect(output.hookSpecificOutput.additionalContext).toContain('no issues');
73+
});
74+
75+
it('includes issue details in additionalContext when issues are found', async () => {
76+
analyzeFileSpy.mockResolvedValue({
77+
id: 'analysis-id',
78+
issues: [
79+
{
80+
rule: 'java:S1234',
81+
message: 'Fix this',
82+
textRange: { startLine: 10, endLine: 10, startOffset: 0, endOffset: 5 },
83+
},
84+
],
85+
errors: null,
86+
});
87+
88+
await agentPostToolUse({ project: 'my-project' });
89+
90+
const output = JSON.parse((stdoutSpy.mock.calls[0][0] as string).trim());
91+
expect(output.hookSpecificOutput.additionalContext).toContain('Fix this');
92+
expect(output.hookSpecificOutput.additionalContext).toContain('java:S1234');
93+
});
94+
95+
it('returns without output when tool_name is not Edit or Write', async () => {
96+
readStdinJsonSpy.mockResolvedValue({ tool_name: 'Read', tool_input: { file_path: TEST_FILE } });
97+
98+
await agentPostToolUse({ project: 'my-project' });
99+
100+
expect(analyzeFileSpy).not.toHaveBeenCalled();
101+
expect(stdoutSpy).not.toHaveBeenCalled();
102+
});
103+
104+
it('returns without output when connection is not cloud', async () => {
105+
resolveAuthSpy.mockResolvedValue({
106+
token: 'tok',
107+
serverUrl: 'https://sonar.example.com',
108+
connectionType: 'on-premise',
109+
});
110+
111+
await agentPostToolUse({ project: 'my-project' });
112+
113+
expect(analyzeFileSpy).not.toHaveBeenCalled();
114+
expect(stdoutSpy).not.toHaveBeenCalled();
115+
});
116+
117+
it('returns without output when project key is not provided', async () => {
118+
await agentPostToolUse({});
119+
120+
expect(analyzeFileSpy).not.toHaveBeenCalled();
121+
expect(stdoutSpy).not.toHaveBeenCalled();
122+
});
123+
124+
it('returns without output when auth is unavailable', async () => {
125+
resolveAuthSpy.mockResolvedValue(null);
126+
127+
await agentPostToolUse({ project: 'my-project' });
128+
129+
expect(analyzeFileSpy).not.toHaveBeenCalled();
130+
});
131+
});

0 commit comments

Comments
 (0)