git-repo-analyzer-test 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/copilot-instructions.md +108 -0
- package/.idea/aianalyzer.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/API_REFERENCE.md +244 -0
- package/ENHANCEMENTS.md +282 -0
- package/README.md +179 -0
- package/USAGE.md +189 -0
- package/analysis.txt +0 -0
- package/bin/cli.js +135 -0
- package/docs/SONARCLOUD_ANALYSIS_COVERED.md +144 -0
- package/docs/SonarCloud_Presentation_Points.md +81 -0
- package/docs/UI_IMPROVEMENTS.md +117 -0
- package/package-lock_cmd.json +542 -0
- package/package.json +44 -0
- package/package_command.json +16 -0
- package/public/analysis-options.json +31 -0
- package/public/images/README.txt +2 -0
- package/public/images/rws-logo.png +0 -0
- package/public/index.html +2433 -0
- package/repositories.example.txt +17 -0
- package/sample-repos.txt +20 -0
- package/src/analyzers/accessibility.js +47 -0
- package/src/analyzers/cicd-enhanced.js +113 -0
- package/src/analyzers/codeReview-enhanced.js +599 -0
- package/src/analyzers/codeReview-enhanced.js:Zone.Identifier +3 -0
- package/src/analyzers/codeReview.js +171 -0
- package/src/analyzers/codeReview.js:Zone.Identifier +3 -0
- package/src/analyzers/documentation-enhanced.js +137 -0
- package/src/analyzers/performance-enhanced.js +747 -0
- package/src/analyzers/performance-enhanced.js:Zone.Identifier +3 -0
- package/src/analyzers/performance.js +211 -0
- package/src/analyzers/performance.js:Zone.Identifier +3 -0
- package/src/analyzers/performance_cmd.js +216 -0
- package/src/analyzers/quality-enhanced.js +386 -0
- package/src/analyzers/quality-enhanced.js:Zone.Identifier +3 -0
- package/src/analyzers/quality.js +92 -0
- package/src/analyzers/quality.js:Zone.Identifier +3 -0
- package/src/analyzers/security-enhanced.js +512 -0
- package/src/analyzers/security-enhanced.js:Zone.Identifier +3 -0
- package/src/analyzers/snyk-ai.js:Zone.Identifier +3 -0
- package/src/analyzers/sonarcloud.js +928 -0
- package/src/analyzers/vulnerability.js +185 -0
- package/src/analyzers/vulnerability.js:Zone.Identifier +3 -0
- package/src/cli.js:Zone.Identifier +3 -0
- package/src/config.js +43 -0
- package/src/core/analyzerEngine.js +68 -0
- package/src/core/reportGenerator.js +21 -0
- package/src/gemini.js +321 -0
- package/src/github/client.js +124 -0
- package/src/github/client.js:Zone.Identifier +3 -0
- package/src/index.js +93 -0
- package/src/index_cmd.js +130 -0
- package/src/openai.js +297 -0
- package/src/report/generator.js +459 -0
- package/src/report/generator_cmd.js +459 -0
- package/src/report/pdf-generator.js +387 -0
- package/src/report/pdf-generator.js:Zone.Identifier +3 -0
- package/src/server.js +431 -0
- package/src/server.js:Zone.Identifier +3 -0
- package/src/server_cmd.js +434 -0
- package/src/sonarcloud/client.js +365 -0
- package/src/sonarcloud/scanner.js +171 -0
- package/src.zip +0 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SonarCloud API Client (free plan compatible).
|
|
3
|
+
*
|
|
4
|
+
* SonarCloud free plan does NOT expose a single "Overall Code" dashboard API.
|
|
5
|
+
* We use only what is available:
|
|
6
|
+
* 1. GET api/qualitygates/project_status?projectKey=... → overall PASS/FAIL + conditions
|
|
7
|
+
* 2. GET api/measures/component?component=...&metricKeys=... → individual metrics
|
|
8
|
+
* 3. GET api/issues/search (optional) → issues list
|
|
9
|
+
*
|
|
10
|
+
* We combine (1) + (2) into our own "Overall Summary" for the UI.
|
|
11
|
+
* Base URL from SONARCLOUD_HOST env.
|
|
12
|
+
* @see https://docs.sonarsource.com/sonarcloud/advanced-setup/web-api/
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import axios from 'axios';
|
|
16
|
+
import { config } from '../config.js';
|
|
17
|
+
|
|
18
|
+
const SONARCLOUD_BASE = config.sonarcloudHost || 'https://sonarcloud.io';
|
|
19
|
+
|
|
20
|
+
/** Metric keys documented as accessible on SonarCloud free plan (for measures/component). */
|
|
21
|
+
export const FREE_TIER_METRIC_KEYS = [
|
|
22
|
+
'ncloc',
|
|
23
|
+
'bugs',
|
|
24
|
+
'vulnerabilities',
|
|
25
|
+
'code_smells',
|
|
26
|
+
'coverage',
|
|
27
|
+
'duplicated_lines_density',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** True if value looks like a token/hash (don't use as project key or org). */
|
|
31
|
+
function looksLikeToken(value) {
|
|
32
|
+
if (!value || typeof value !== 'string') return true;
|
|
33
|
+
const s = value.trim();
|
|
34
|
+
if (s.length < 10) return false;
|
|
35
|
+
if (/^[a-f0-9]{32,64}$/i.test(s)) return true; // hex token/hash
|
|
36
|
+
if (/^[a-zA-Z0-9_-]{40,}$/.test(s) && !s.includes('_') && !/^[a-z0-9]+_[a-z0-9-]+$/i.test(s)) return true; // long token, not org_repo
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeToken(val) {
|
|
41
|
+
if (val == null || typeof val !== 'string') return '';
|
|
42
|
+
return val.replace(/^["'\s]+|["'\s]+$/g, '').trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class SonarCloudClient {
|
|
46
|
+
constructor(token) {
|
|
47
|
+
this.token = normalizeToken(token || config.sonarToken);
|
|
48
|
+
const useBasicAuth = /^(true|1|yes)$/i.test((process.env.SONAR_USE_BASIC_AUTH || '').trim());
|
|
49
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
50
|
+
if (this.token) {
|
|
51
|
+
if (useBasicAuth) {
|
|
52
|
+
headers.Authorization = 'Basic ' + Buffer.from(this.token + ':', 'utf8').toString('base64');
|
|
53
|
+
} else {
|
|
54
|
+
headers.Authorization = 'Bearer ' + this.token;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
this.client = axios.create({
|
|
58
|
+
baseURL: SONARCLOUD_BASE,
|
|
59
|
+
headers,
|
|
60
|
+
timeout: 15000,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derive SonarCloud project key from SONAR_ORGANIZATION + repo (from Git URL).
|
|
66
|
+
* No need to set SONAR_PROJECT_KEY in .env when org and repo are known.
|
|
67
|
+
* Format: {organization}_{repo} (e.g. jitendra1608-rws_vulnerable-node).
|
|
68
|
+
*/
|
|
69
|
+
getDerivedProjectKey(owner, repo) {
|
|
70
|
+
const org = (config.sonarOrgKey && !looksLikeToken(config.sonarOrgKey))
|
|
71
|
+
? config.sonarOrgKey
|
|
72
|
+
: owner;
|
|
73
|
+
const organization = org.trim().toLowerCase();
|
|
74
|
+
const repoSafe = (repo || '').replace(/\//g, '-').trim();
|
|
75
|
+
return `${organization}_${repoSafe}` || organization;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get list of project key candidates to try (for fallback when one 404s).
|
|
80
|
+
* Puts derived key (SONAR_ORGANIZATION + repo) first so no SONAR_PROJECT_KEY is required.
|
|
81
|
+
*/
|
|
82
|
+
getProjectKeyCandidates(owner, repo) {
|
|
83
|
+
const derived = this.getDerivedProjectKey(owner, repo);
|
|
84
|
+
const org = (config.sonarOrgKey && !looksLikeToken(config.sonarOrgKey))
|
|
85
|
+
? config.sonarOrgKey
|
|
86
|
+
: owner;
|
|
87
|
+
const orgLower = org.trim().toLowerCase();
|
|
88
|
+
const ownerSafe = owner.replace(/\//g, '-');
|
|
89
|
+
const repoSafe = repo.replace(/\//g, '-');
|
|
90
|
+
const fullSlug = `${ownerSafe}_${repoSafe}`.replace(/-/g, '_');
|
|
91
|
+
const fullSlugHyphen = `${ownerSafe}-${repoSafe}`;
|
|
92
|
+
const candidates = [
|
|
93
|
+
derived,
|
|
94
|
+
`${orgLower}_${repoSafe}`,
|
|
95
|
+
`${orgLower}-${repoSafe}`,
|
|
96
|
+
`${orgLower}_${fullSlug}`,
|
|
97
|
+
`${orgLower}_${fullSlugHyphen}`,
|
|
98
|
+
`${orgLower}_${ownerSafe}_${repoSafe}`,
|
|
99
|
+
`${orgLower}_${ownerSafe}-${repoSafe}`,
|
|
100
|
+
`${owner}_${repo}`.replace(/\//g, '-'),
|
|
101
|
+
`${owner}-${repo}`.replace(/\//g, '-'),
|
|
102
|
+
];
|
|
103
|
+
const explicitKey = config.sonarProjectKey || process.env.SONAR_PROJECT_KEY;
|
|
104
|
+
if (explicitKey && !looksLikeToken(explicitKey) && !candidates.includes(explicitKey)) {
|
|
105
|
+
candidates.unshift(explicitKey);
|
|
106
|
+
}
|
|
107
|
+
return [...new Set(candidates)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve SonarCloud project key from GitHub owner/repo.
|
|
112
|
+
* Uses SONAR_ORGANIZATION + repo to derive key (no SONAR_PROJECT_KEY required). Falls back to search API if needed.
|
|
113
|
+
*/
|
|
114
|
+
async resolveProjectKey(owner, repo) {
|
|
115
|
+
if (config.sonarOrgKey && !looksLikeToken(config.sonarOrgKey)) {
|
|
116
|
+
return this.getDerivedProjectKey(owner, repo);
|
|
117
|
+
}
|
|
118
|
+
const explicitKey = config.sonarProjectKey || process.env.SONAR_PROJECT_KEY;
|
|
119
|
+
if (explicitKey && !looksLikeToken(explicitKey)) return explicitKey;
|
|
120
|
+
|
|
121
|
+
const org = (config.sonarOrgKey && !looksLikeToken(config.sonarOrgKey))
|
|
122
|
+
? config.sonarOrgKey
|
|
123
|
+
: owner;
|
|
124
|
+
const orgLower = org.trim().toLowerCase();
|
|
125
|
+
const candidateKeys = this.getProjectKeyCandidates(owner, repo);
|
|
126
|
+
|
|
127
|
+
if (!this.token) return candidateKeys[0];
|
|
128
|
+
|
|
129
|
+
const ownerRepoSlug = `${owner}/${repo}`.toLowerCase();
|
|
130
|
+
const repoLower = repo.toLowerCase();
|
|
131
|
+
const ownerLower = owner.toLowerCase();
|
|
132
|
+
const findMatch = (projects) =>
|
|
133
|
+
projects.find(
|
|
134
|
+
(p) =>
|
|
135
|
+
p.key === `${orgLower}_${repo}`.replace(/\//g, '-') ||
|
|
136
|
+
p.key === `${orgLower}-${repo}`.replace(/\//g, '-') ||
|
|
137
|
+
p.name?.toLowerCase() === repoLower ||
|
|
138
|
+
p.key?.toLowerCase().endsWith(repoLower.replace(/\//g, '-')) ||
|
|
139
|
+
p.key?.toLowerCase().endsWith(repoLower.replace(/\//g, '_'))
|
|
140
|
+
) ||
|
|
141
|
+
projects.find(
|
|
142
|
+
(p) =>
|
|
143
|
+
(p.name && p.name.toLowerCase().includes(ownerRepoSlug)) ||
|
|
144
|
+
(p.name && p.name.toLowerCase().includes(ownerLower) && p.name.toLowerCase().includes(repoLower)) ||
|
|
145
|
+
(p.key && p.key.toLowerCase().includes(ownerLower) && p.key.toLowerCase().includes(repoLower.replace(/\//g, '-')))
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
for (const orgParam of [org.trim(), orgLower].filter((v, i, a) => a.indexOf(v) === i)) {
|
|
149
|
+
try {
|
|
150
|
+
const { data } = await this.client.get('/api/projects/search', {
|
|
151
|
+
params: { organization: orgParam },
|
|
152
|
+
});
|
|
153
|
+
const projects = data?.components || [];
|
|
154
|
+
const match = findMatch(projects);
|
|
155
|
+
if (match) return match.key;
|
|
156
|
+
} catch (_) {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Try each candidate with a lightweight API call; return first that exists
|
|
160
|
+
for (const key of candidateKeys) {
|
|
161
|
+
try {
|
|
162
|
+
await this.client.get('/api/measures/component', {
|
|
163
|
+
params: { component: key, metricKeys: 'ncloc' },
|
|
164
|
+
});
|
|
165
|
+
return key;
|
|
166
|
+
} catch (_) {
|
|
167
|
+
// 404 or other – try next
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return candidateKeys[0];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Search projects in an organization (to find exact key after a scan).
|
|
175
|
+
* @param {string} organization - SonarCloud organization key
|
|
176
|
+
* @returns {Promise<Array<{key: string, name: string}>>}
|
|
177
|
+
*/
|
|
178
|
+
async searchProjects(organization) {
|
|
179
|
+
try {
|
|
180
|
+
const { data } = await this.client.get('/api/projects/search', {
|
|
181
|
+
params: { organization },
|
|
182
|
+
});
|
|
183
|
+
return data?.components || [];
|
|
184
|
+
} catch (_) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fetch component measures (metrics) for a project.
|
|
191
|
+
* @param {string} projectKey - SonarCloud project key
|
|
192
|
+
* @param {string[]} metricKeys - e.g. ncloc, bugs, vulnerabilities, code_smells, quality_gate_status
|
|
193
|
+
* @param {string|null} [branch] - Optional branch (e.g. main, master); required for many SonarCloud projects
|
|
194
|
+
* @throws Error with response status on err.response.status (e.g. 401, 404) for caller to handle
|
|
195
|
+
*/
|
|
196
|
+
async getMeasures(projectKey, metricKeys, branch = null) {
|
|
197
|
+
const keys = metricKeys.join(',');
|
|
198
|
+
const params = { component: projectKey, metricKeys: keys };
|
|
199
|
+
if (branch) params.branch = branch;
|
|
200
|
+
try {
|
|
201
|
+
const { data } = await this.client.get('/api/measures/component', { params });
|
|
202
|
+
return data;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
const status = err.response?.status;
|
|
205
|
+
const message = err.response?.data?.message || err.message;
|
|
206
|
+
const e = new Error(message || `Request failed with status ${status}`);
|
|
207
|
+
e.responseStatus = status;
|
|
208
|
+
e.responseData = err.response?.data;
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetch measures without authentication (for public projects). Optionally pass branch.
|
|
215
|
+
*/
|
|
216
|
+
async getMeasuresWithoutAuth(projectKey, metricKeys, branch = null) {
|
|
217
|
+
const keys = metricKeys.join(',');
|
|
218
|
+
const params = { component: projectKey, metricKeys: keys };
|
|
219
|
+
if (branch) params.branch = branch;
|
|
220
|
+
try {
|
|
221
|
+
const { data } = await axios.get(SONARCLOUD_BASE + '/api/measures/component', {
|
|
222
|
+
params,
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
timeout: 15000,
|
|
225
|
+
});
|
|
226
|
+
return data;
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const e = new Error(err.response?.data?.message || err.message);
|
|
229
|
+
e.responseStatus = err.response?.status;
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Try getMeasures with a specific auth type (Bearer or Basic). Optionally pass branch (e.g. main, master).
|
|
236
|
+
*/
|
|
237
|
+
async getMeasuresWithAuth(projectKey, metricKeys, useBasic, branch = null) {
|
|
238
|
+
const keys = metricKeys.join(',');
|
|
239
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
240
|
+
if (this.token) {
|
|
241
|
+
headers.Authorization = useBasic
|
|
242
|
+
? 'Basic ' + Buffer.from(this.token + ':', 'utf8').toString('base64')
|
|
243
|
+
: 'Bearer ' + this.token;
|
|
244
|
+
}
|
|
245
|
+
const params = { component: projectKey, metricKeys: keys };
|
|
246
|
+
if (branch) params.branch = branch;
|
|
247
|
+
try {
|
|
248
|
+
const { data } = await axios.get(SONARCLOUD_BASE + '/api/measures/component', {
|
|
249
|
+
params,
|
|
250
|
+
headers,
|
|
251
|
+
timeout: 15000,
|
|
252
|
+
});
|
|
253
|
+
return data;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const status = err.response?.status;
|
|
256
|
+
const message = err.response?.data?.message || err.message;
|
|
257
|
+
const e = new Error(message || `Request failed with status ${status}`);
|
|
258
|
+
e.responseStatus = status;
|
|
259
|
+
throw e;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Fetch quality gate status for a project. Optional branch (e.g. main, master).
|
|
265
|
+
*/
|
|
266
|
+
async getQualityGateStatus(projectKey, branch = null) {
|
|
267
|
+
try {
|
|
268
|
+
const params = { projectKey };
|
|
269
|
+
if (branch) params.branch = branch;
|
|
270
|
+
const { data } = await this.client.get('/api/qualitygates/project_status', { params });
|
|
271
|
+
return data;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (err.response?.status === 404) return null;
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Free-plan compatible: fetch Quality Gate status + core metrics only.
|
|
280
|
+
* Combines api/qualitygates/project_status and api/measures/component (FREE_TIER_METRIC_KEYS).
|
|
281
|
+
* Use this to build our own "Overall Summary" for the UI (no "Overall Code" API on free plan).
|
|
282
|
+
* @param {string} projectKey
|
|
283
|
+
* @param {string|null} [branch] - e.g. 'master', 'main'
|
|
284
|
+
* @returns {{ qualityGate: object|null, measures: object|null }} Raw API responses or null on failure
|
|
285
|
+
*/
|
|
286
|
+
async fetchQualityGateAndMeasures(projectKey, branch = null) {
|
|
287
|
+
const params = { projectKey };
|
|
288
|
+
if (branch) params.branch = branch;
|
|
289
|
+
const metricKeys = FREE_TIER_METRIC_KEYS.join(',');
|
|
290
|
+
const measureParams = { component: projectKey, metricKeys };
|
|
291
|
+
if (branch) measureParams.branch = branch;
|
|
292
|
+
try {
|
|
293
|
+
const [qgRes, measuresRes] = await Promise.all([
|
|
294
|
+
this.client.get('/api/qualitygates/project_status', { params }).catch(() => ({ data: null })),
|
|
295
|
+
this.client.get('/api/measures/component', { params: measureParams }).catch(() => ({ data: null })),
|
|
296
|
+
]);
|
|
297
|
+
return {
|
|
298
|
+
qualityGate: qgRes.data?.projectStatus ? qgRes.data : null,
|
|
299
|
+
measures: measuresRes.data?.component?.measures || [],
|
|
300
|
+
};
|
|
301
|
+
} catch (_) {
|
|
302
|
+
return { qualityGate: null, measures: null };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Fetch issues (bugs, vulnerabilities, code smells) for a project. Optional branch. ps: page size (default 100).
|
|
308
|
+
*/
|
|
309
|
+
async getIssues(projectKey, options = {}) {
|
|
310
|
+
const { ps = 100, types = 'BUG,VULNERABILITY,CODE_SMELL', branch = null } = options;
|
|
311
|
+
try {
|
|
312
|
+
const params = {
|
|
313
|
+
componentKeys: projectKey,
|
|
314
|
+
ps,
|
|
315
|
+
types,
|
|
316
|
+
resolved: 'false',
|
|
317
|
+
};
|
|
318
|
+
if (branch) params.branch = branch;
|
|
319
|
+
const { data } = await this.client.get('/api/issues/search', { params });
|
|
320
|
+
return data;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (err.response?.status === 404) return { issues: [], total: 0 };
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Set project visibility to public so the report can be viewed without login and API can fetch metrics.
|
|
329
|
+
* Call after a scan so newly created projects are public by default.
|
|
330
|
+
*/
|
|
331
|
+
async updateProjectVisibility(projectKey, visibility = 'public') {
|
|
332
|
+
if (!this.token) return;
|
|
333
|
+
try {
|
|
334
|
+
await this.client.post(
|
|
335
|
+
'/api/projects/update_visibility',
|
|
336
|
+
new URLSearchParams({ project: projectKey, visibility }).toString(),
|
|
337
|
+
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
338
|
+
);
|
|
339
|
+
} catch (_) {
|
|
340
|
+
// Ignore (e.g. project not found yet, or no permission)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Create SonarCloud project via REST API if it doesn't exist.
|
|
346
|
+
*/
|
|
347
|
+
async createProject(projectKey, name, organization) {
|
|
348
|
+
if (!this.token) return;
|
|
349
|
+
try {
|
|
350
|
+
await this.client.post(
|
|
351
|
+
'/api/projects/create',
|
|
352
|
+
new URLSearchParams({
|
|
353
|
+
project: projectKey,
|
|
354
|
+
name: name || projectKey,
|
|
355
|
+
organization: (organization || config.sonarOrgKey || '').trim().toLowerCase(),
|
|
356
|
+
}).toString(),
|
|
357
|
+
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
|
358
|
+
);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
if (err.response?.status !== 400 && !/already exists/i.test(String(err.response?.data || err.message))) throw err;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export default SonarCloudClient;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SonarCloud Scanner – clone a Git repo and run SonarScanner (@sonar/scan) to analyze it on SonarCloud.
|
|
3
|
+
* Uses a persistent clone cache: if repo already cloned, pull latest and run scan; otherwise clone then scan.
|
|
4
|
+
* Runs the scanner via child_process so we can capture the real error output from SonarCloud.
|
|
5
|
+
* Uses the local node_modules scanner (not npx) to avoid ENOENT on missing %AppData%\npm.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import { createRequire } from 'module';
|
|
13
|
+
import { simpleGit } from 'simple-git';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
/** Resolved path to @sonar/scan bin script, or null if not installed. */
|
|
17
|
+
let scannerScriptPath = null;
|
|
18
|
+
try {
|
|
19
|
+
scannerScriptPath = require.resolve('@sonar/scan/bin/sonar-scanner.js');
|
|
20
|
+
} catch (_) {}
|
|
21
|
+
|
|
22
|
+
const SONARCLOUD_URL = 'https://sonarcloud.io';
|
|
23
|
+
const CLONE_DEPTH = 1; // shallow clone for speed
|
|
24
|
+
|
|
25
|
+
/** Cache root for cloned repos. WORK_DIR or SONAR_REPO_CACHE, else platform temp. */
|
|
26
|
+
export function getCacheRoot() {
|
|
27
|
+
const base = (process.env.WORK_DIR || process.env.SONAR_REPO_CACHE || '').trim()
|
|
28
|
+
|| path.join(os.tmpdir(), 'scans');
|
|
29
|
+
return path.resolve(base);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Derive a filesystem-safe cache key from repo URL (e.g. owner_repo).
|
|
34
|
+
* @param {string} repoUrl - e.g. https://github.com/owner/repo
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function getCacheKeyFromUrl(repoUrl) {
|
|
38
|
+
const normalized = repoUrl.replace(/\.git$/i, '').trim().replace(/^https?:\/\//, '');
|
|
39
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
40
|
+
const lastTwo = parts.slice(-2);
|
|
41
|
+
if (lastTwo.length < 2) return normalized.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
42
|
+
return lastTwo.join('_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get existing clone or clone repo into cache. If already cloned, pull latest.
|
|
47
|
+
* @param {string} repoUrl - e.g. https://github.com/owner/repo or https://github.com/owner/repo.git
|
|
48
|
+
* @param {string} [cacheKey] - optional, otherwise derived from repoUrl
|
|
49
|
+
* @returns {Promise<string>} Path to the repo directory (cached)
|
|
50
|
+
*/
|
|
51
|
+
export async function getOrCloneRepository(repoUrl, cacheKey) {
|
|
52
|
+
const normalized = repoUrl.replace(/\.git$/i, '').trim();
|
|
53
|
+
if (!normalized.includes('github.com') && !normalized.includes('gitlab.com') && !normalized.includes('bitbucket')) {
|
|
54
|
+
throw new Error('Only GitHub/GitLab/Bitbucket URLs are supported for clone.');
|
|
55
|
+
}
|
|
56
|
+
const key = (cacheKey || getCacheKeyFromUrl(repoUrl)).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
57
|
+
const cacheRoot = getCacheRoot();
|
|
58
|
+
const dir = path.join(cacheRoot, key);
|
|
59
|
+
fs.mkdirSync(cacheRoot, { recursive: true });
|
|
60
|
+
|
|
61
|
+
const gitDir = path.join(dir, '.git');
|
|
62
|
+
if (fs.existsSync(dir) && fs.existsSync(gitDir)) {
|
|
63
|
+
const git = simpleGit(dir);
|
|
64
|
+
try {
|
|
65
|
+
await git.pull();
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.warn('SonarCloud cache: git pull failed (using existing clone):', e?.message || e);
|
|
68
|
+
}
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (fs.existsSync(dir)) {
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
} catch (_) {}
|
|
76
|
+
}
|
|
77
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
78
|
+
const git = simpleGit();
|
|
79
|
+
await git.clone(normalized + '.git', dir, ['--depth', String(CLONE_DEPTH)]);
|
|
80
|
+
return dir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Clone a public Git repo to a temporary directory (one-off, not cached).
|
|
85
|
+
* @param {string} repoUrl - e.g. https://github.com/owner/repo or https://github.com/owner/repo.git
|
|
86
|
+
* @returns {Promise<string>} Path to the cloned repo
|
|
87
|
+
*/
|
|
88
|
+
export async function cloneRepository(repoUrl) {
|
|
89
|
+
const normalized = repoUrl.replace(/\.git$/i, '').trim();
|
|
90
|
+
if (!normalized.includes('github.com') && !normalized.includes('gitlab.com') && !normalized.includes('bitbucket')) {
|
|
91
|
+
throw new Error('Only GitHub/GitLab/Bitbucket URLs are supported for clone.');
|
|
92
|
+
}
|
|
93
|
+
const dir = path.join(os.tmpdir(), `sonar-scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
const git = simpleGit();
|
|
96
|
+
await git.clone(normalized + '.git', dir, ['--depth', String(CLONE_DEPTH)]);
|
|
97
|
+
return dir;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run SonarScanner via npx so we capture real stderr/stdout from SonarCloud.
|
|
102
|
+
* Writes sonar-project.properties and runs npx @sonar/scan with SONAR_TOKEN in env.
|
|
103
|
+
*/
|
|
104
|
+
export function runSonarScan(projectDir, { projectKey, projectName, organization, token }) {
|
|
105
|
+
const propsPath = path.join(projectDir, 'sonar-project.properties');
|
|
106
|
+
const props = [
|
|
107
|
+
`sonar.host.url=${SONARCLOUD_URL}`,
|
|
108
|
+
`sonar.organization=${organization}`,
|
|
109
|
+
`sonar.projectKey=${projectKey}`,
|
|
110
|
+
`sonar.projectName=${projectName || projectKey}`,
|
|
111
|
+
'sonar.sources=.',
|
|
112
|
+
].join('\n');
|
|
113
|
+
fs.writeFileSync(propsPath, props, 'utf8');
|
|
114
|
+
|
|
115
|
+
const env = {
|
|
116
|
+
...process.env,
|
|
117
|
+
SONAR_TOKEN: token || '',
|
|
118
|
+
SONAR_ORGANIZATION: organization,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Run the scanner from local node_modules with Node (avoids npx and missing %AppData%\npm on Windows).
|
|
122
|
+
const useLocalScanner = !!scannerScriptPath;
|
|
123
|
+
const cmd = useLocalScanner ? process.execPath : 'npx';
|
|
124
|
+
const args = useLocalScanner ? [scannerScriptPath] : ['@sonar/scan'];
|
|
125
|
+
const spawnOpts = {
|
|
126
|
+
cwd: projectDir,
|
|
127
|
+
env,
|
|
128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
};
|
|
130
|
+
if (!useLocalScanner && process.platform === 'win32') {
|
|
131
|
+
spawnOpts.shell = true; // so npx.cmd is found on Windows
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const child = spawn(cmd, args, spawnOpts);
|
|
136
|
+
let stdout = '';
|
|
137
|
+
let stderr = '';
|
|
138
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
139
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
140
|
+
child.on('error', (err) => {
|
|
141
|
+
reject(new Error(`Failed to start scanner: ${err.message}. Run: npm install @sonar/scan`));
|
|
142
|
+
});
|
|
143
|
+
child.on('close', (code) => {
|
|
144
|
+
if (code === 0) {
|
|
145
|
+
resolve();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const out = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n');
|
|
149
|
+
const lastLines = out.split('\n').slice(-15).join('\n');
|
|
150
|
+
const msg = out.length > 500 ? lastLines : out;
|
|
151
|
+
reject(new Error(msg || `Scanner exited with code ${code}`));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get or clone repo (cache), run SonarScanner for SonarCloud. Does not delete the clone (kept for next run).
|
|
158
|
+
* @param {string} repoUrl - Full Git repo URL (e.g. https://github.com/owner/repo)
|
|
159
|
+
* @param {string} projectKey - SonarCloud project key
|
|
160
|
+
* @param {string} projectName - Display name for the project
|
|
161
|
+
* @param {string} organization - SonarCloud organization
|
|
162
|
+
* @param {string} token - SonarCloud token
|
|
163
|
+
* @returns {Promise<void>}
|
|
164
|
+
*/
|
|
165
|
+
export async function cloneAndScan(repoUrl, projectKey, projectName, organization, token) {
|
|
166
|
+
const cacheKey = getCacheKeyFromUrl(repoUrl);
|
|
167
|
+
const dir = await getOrCloneRepository(repoUrl, cacheKey);
|
|
168
|
+
await runSonarScan(dir, { projectKey, projectName, organization, token });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export default { getCacheRoot, getCacheKeyFromUrl, getOrCloneRepository, cloneRepository, runSonarScan, cloneAndScan };
|
package/src.zip
ADDED
|
Binary file
|