@defra/fcp-sfd-frontend-engine 0.2.8 → 0.2.10
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/README.md +28 -1
- package/dist/index.cjs +40 -1
- package/dist/index.js +40 -1
- package/package.json +3 -2
- package/scripts/sonar-scan.js +552 -0
- package/src/mappers/address-mapper.js +32 -0
- package/src/mappers/customer-name-mapper.js +16 -0
- package/src/mappers/mappers.js +5 -1
- package/src/mappers/personal-business-details-mapper.js +1 -1
package/README.md
CHANGED
|
@@ -45,4 +45,31 @@ Docker-based local development support will be used so engine changes can be pic
|
|
|
45
45
|
- prefer shared reusable modules over duplication
|
|
46
46
|
- avoid premature abstraction
|
|
47
47
|
- maintain clear ownership boundaries between the engine and applications
|
|
48
|
-
- evolve incrementally
|
|
48
|
+
- evolve incrementally
|
|
49
|
+
|
|
50
|
+
## SonarQube Cloud scan
|
|
51
|
+
|
|
52
|
+
Run a local scan against [SonarCloud](https://sonarcloud.io/project/overview?id=defra_fcp-sfd-frontend-engine) for the current git branch. See the [DEFRA SonarCloud guide](https://github.com/DEFRA/cdp-documentation/blob/main/how-to/sonarcloud.md) for organisation access and CI setup.
|
|
53
|
+
|
|
54
|
+
### Setup
|
|
55
|
+
|
|
56
|
+
1. Log in to [SonarQube Cloud](https://sonarcloud.io) with your DEFRA GitHub account
|
|
57
|
+
2. Go to **My Account → Security → Generate Tokens** and create a personal token
|
|
58
|
+
3. Add `SONAR_TOKEN=<your-token>` to your `.env` file
|
|
59
|
+
|
|
60
|
+
### Run
|
|
61
|
+
|
|
62
|
+
Generate test coverage first, then scan:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm test
|
|
66
|
+
npm run sonar
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The script uploads results for the current branch and prints:
|
|
70
|
+
|
|
71
|
+
- Quality gate pass/fail and failed conditions
|
|
72
|
+
- Open issues on new code (when the gate fails)
|
|
73
|
+
- **Accepted / false-positive issues without comment** — DEFRA quality gates require a justification comment on each suppressed issue; add comments in SonarCloud under the issue **Activity** tab
|
|
74
|
+
|
|
75
|
+
Exit code is `0` when the gate passes and all suppressed issues are commented, `1` otherwise.
|
package/dist/index.cjs
CHANGED
|
@@ -54,9 +54,48 @@ var mapPersonalBusinessDetails = (value) => {
|
|
|
54
54
|
};
|
|
55
55
|
};
|
|
56
56
|
|
|
57
|
+
// src/mappers/address-mapper.js
|
|
58
|
+
var mapAddress = (address = {}) => {
|
|
59
|
+
return {
|
|
60
|
+
lookup: {
|
|
61
|
+
pafOrganisationName: address.pafOrganisationName,
|
|
62
|
+
buildingNumberRange: address.buildingNumberRange,
|
|
63
|
+
flatName: address.flatName,
|
|
64
|
+
buildingName: address.buildingName,
|
|
65
|
+
dependentLocality: address.dependentLocality,
|
|
66
|
+
doubleDependentLocality: address.doubleDependentLocality,
|
|
67
|
+
street: address.street,
|
|
68
|
+
county: address.county,
|
|
69
|
+
uprn: address.uprn
|
|
70
|
+
},
|
|
71
|
+
manual: {
|
|
72
|
+
line1: address.line1,
|
|
73
|
+
line2: address.line2,
|
|
74
|
+
line3: address.line3,
|
|
75
|
+
line4: address.line4,
|
|
76
|
+
line5: address.line5
|
|
77
|
+
},
|
|
78
|
+
city: address.city,
|
|
79
|
+
postcode: address.postalCode,
|
|
80
|
+
country: address.country
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/mappers/customer-name-mapper.js
|
|
85
|
+
var mapCustomerName = (name = {}) => {
|
|
86
|
+
return {
|
|
87
|
+
userName: [
|
|
88
|
+
name.first,
|
|
89
|
+
name.last
|
|
90
|
+
].filter(Boolean).join(" ")
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
57
94
|
// src/mappers/mappers.js
|
|
58
95
|
var mappers = {
|
|
59
|
-
mapPersonalBusinessDetails
|
|
96
|
+
personalBusinessDetails: mapPersonalBusinessDetails,
|
|
97
|
+
address: mapAddress,
|
|
98
|
+
customerName: mapCustomerName
|
|
60
99
|
};
|
|
61
100
|
|
|
62
101
|
// src/schemas/business/business-sbi-schema.js
|
package/dist/index.js
CHANGED
|
@@ -17,9 +17,48 @@ var mapPersonalBusinessDetails = (value) => {
|
|
|
17
17
|
};
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
// src/mappers/address-mapper.js
|
|
21
|
+
var mapAddress = (address = {}) => {
|
|
22
|
+
return {
|
|
23
|
+
lookup: {
|
|
24
|
+
pafOrganisationName: address.pafOrganisationName,
|
|
25
|
+
buildingNumberRange: address.buildingNumberRange,
|
|
26
|
+
flatName: address.flatName,
|
|
27
|
+
buildingName: address.buildingName,
|
|
28
|
+
dependentLocality: address.dependentLocality,
|
|
29
|
+
doubleDependentLocality: address.doubleDependentLocality,
|
|
30
|
+
street: address.street,
|
|
31
|
+
county: address.county,
|
|
32
|
+
uprn: address.uprn
|
|
33
|
+
},
|
|
34
|
+
manual: {
|
|
35
|
+
line1: address.line1,
|
|
36
|
+
line2: address.line2,
|
|
37
|
+
line3: address.line3,
|
|
38
|
+
line4: address.line4,
|
|
39
|
+
line5: address.line5
|
|
40
|
+
},
|
|
41
|
+
city: address.city,
|
|
42
|
+
postcode: address.postalCode,
|
|
43
|
+
country: address.country
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// src/mappers/customer-name-mapper.js
|
|
48
|
+
var mapCustomerName = (name = {}) => {
|
|
49
|
+
return {
|
|
50
|
+
userName: [
|
|
51
|
+
name.first,
|
|
52
|
+
name.last
|
|
53
|
+
].filter(Boolean).join(" ")
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
20
57
|
// src/mappers/mappers.js
|
|
21
58
|
var mappers = {
|
|
22
|
-
mapPersonalBusinessDetails
|
|
59
|
+
personalBusinessDetails: mapPersonalBusinessDetails,
|
|
60
|
+
address: mapAddress,
|
|
61
|
+
customerName: mapCustomerName
|
|
23
62
|
};
|
|
24
63
|
|
|
25
64
|
// src/schemas/business/business-sbi-schema.js
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra/fcp-sfd-frontend-engine",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Shared frontend engine used by both the internal and external frontend applications.",
|
|
5
5
|
"main": "./dist/index.cjs",
|
|
6
6
|
"exports": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"lint:fix": "eslint --fix",
|
|
15
15
|
"build": "tsup",
|
|
16
16
|
"test": "npm run build && vitest run --coverage",
|
|
17
|
-
"test:watch": "vitest"
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"sonar": "node scripts/sonar-scan.js"
|
|
18
19
|
},
|
|
19
20
|
"repository": {
|
|
20
21
|
"type": "git",
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { execFileSync, spawn } from 'node:child_process'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const SONARCLOUD_BASE_URL = 'https://sonarcloud.io'
|
|
6
|
+
const BORDER = '═'.repeat(51)
|
|
7
|
+
const THIN_BORDER = '─'.repeat(51)
|
|
8
|
+
const MAX_ISSUES_DISPLAYED = 30
|
|
9
|
+
|
|
10
|
+
const SEVERITY_ORDER = ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO']
|
|
11
|
+
const SEVERITY_ICONS = {
|
|
12
|
+
BLOCKER: '🔴',
|
|
13
|
+
CRITICAL: '🟠',
|
|
14
|
+
MAJOR: '🟡',
|
|
15
|
+
MINOR: '🔵',
|
|
16
|
+
INFO: '⚪'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const HOTSPOT_ICONS = {
|
|
20
|
+
HIGH: '🔴',
|
|
21
|
+
MEDIUM: '🟠',
|
|
22
|
+
LOW: '🟡'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const METRIC_LABELS = {
|
|
26
|
+
new_reliability_rating: 'Reliability Rating',
|
|
27
|
+
new_security_rating: 'Security Rating',
|
|
28
|
+
new_maintainability_rating: 'Maintainability Rating',
|
|
29
|
+
new_coverage: 'Coverage on New Code',
|
|
30
|
+
new_duplicated_lines_density: 'Duplication on New Code',
|
|
31
|
+
new_violations: 'New Issues',
|
|
32
|
+
new_security_hotspots_reviewed: 'Security Hotspots Reviewed',
|
|
33
|
+
new_blocker_violations: 'Blocker Issues',
|
|
34
|
+
new_critical_violations: 'Critical Issues',
|
|
35
|
+
false_positive_issues_without_comment: 'False Positives without Comment',
|
|
36
|
+
accepted_issues_without_comment: 'Accepted Issues without Comment'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ACCEPTED_ISSUE_STATUSES = ['FALSE_POSITIVE', 'ACCEPTED']
|
|
40
|
+
const CHANGELOG_FETCH_CONCURRENCY = 8
|
|
41
|
+
|
|
42
|
+
const COMPARATOR_SYMBOLS = {
|
|
43
|
+
GT: '>',
|
|
44
|
+
LT: '<',
|
|
45
|
+
EQ: '=',
|
|
46
|
+
NE: '≠'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parseKeyValue = (content) =>
|
|
50
|
+
Object.fromEntries(
|
|
51
|
+
content
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map((line) => line.trim())
|
|
54
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
55
|
+
.map((line) => {
|
|
56
|
+
const idx = line.indexOf('=')
|
|
57
|
+
return idx > 0 ? [line.slice(0, idx).trim(), line.slice(idx + 1).trim()] : null
|
|
58
|
+
})
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const getCurrentBranch = () =>
|
|
63
|
+
execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' }).trim()
|
|
64
|
+
|
|
65
|
+
const runScanner = (sonarToken, cwd, branch) =>
|
|
66
|
+
new Promise((resolve, reject) => {
|
|
67
|
+
const args = [
|
|
68
|
+
'run',
|
|
69
|
+
'--rm',
|
|
70
|
+
'--name',
|
|
71
|
+
'sonar-scan',
|
|
72
|
+
'-v',
|
|
73
|
+
`${cwd}:/usr/src`,
|
|
74
|
+
'-e',
|
|
75
|
+
`SONAR_TOKEN=${sonarToken}`,
|
|
76
|
+
'sonarsource/sonar-scanner-cli',
|
|
77
|
+
'-Dsonar.issuesReport.console.enable=true',
|
|
78
|
+
'-Dsonar.qualitygate.wait=true',
|
|
79
|
+
`-Dsonar.branch.name=${branch}`,
|
|
80
|
+
'-Dsonar.verbose=true'
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
84
|
+
const message =
|
|
85
|
+
'Code scan in progress using sonar-scanner-cli (to view logs in real-time a Docker client can be used e.g. Docker Desktop)'
|
|
86
|
+
let i = 0
|
|
87
|
+
|
|
88
|
+
const spinner = setInterval(() => {
|
|
89
|
+
process.stdout.write(`\r ${frames[i++ % frames.length]} ${message}`)
|
|
90
|
+
}, 80)
|
|
91
|
+
|
|
92
|
+
const child = spawn('docker', args, { stdio: 'ignore' })
|
|
93
|
+
|
|
94
|
+
child.on('error', (err) => {
|
|
95
|
+
clearInterval(spinner)
|
|
96
|
+
process.stdout.write('\r')
|
|
97
|
+
reject(err)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Always resolve with the exit code — a non-zero exit may simply mean the
|
|
101
|
+
// quality gate failed (analysis was still uploaded). We check the gate
|
|
102
|
+
// status via the API after the scan and exit accordingly.
|
|
103
|
+
child.on('close', (code) => {
|
|
104
|
+
clearInterval(spinner)
|
|
105
|
+
process.stdout.write(`\r ${message}\n`)
|
|
106
|
+
console.log('\n ✔ Code scan complete. See below for the results.\n')
|
|
107
|
+
resolve(code)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const sonarcloudFetch = async (path, sonarToken) => {
|
|
112
|
+
const url = `${SONARCLOUD_BASE_URL}${path}`
|
|
113
|
+
|
|
114
|
+
const response = await fetch(url, {
|
|
115
|
+
headers: {
|
|
116
|
+
Authorization: `Bearer ${sonarToken}`
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const body = await response.text()
|
|
122
|
+
throw new Error(`SonarCloud API error ${response.status}: ${response.statusText} (${url})${body ? ` — ${body}` : ''}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return response.json()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fetchQualityGate = (projectKey, sonarToken, branch) =>
|
|
129
|
+
sonarcloudFetch(
|
|
130
|
+
`/api/qualitygates/project_status?projectKey=${encodeURIComponent(projectKey)}&branch=${encodeURIComponent(branch)}`,
|
|
131
|
+
sonarToken
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const fetchMeasures = (projectKey, sonarToken, branch) =>
|
|
135
|
+
sonarcloudFetch(
|
|
136
|
+
`/api/measures/component?component=${encodeURIComponent(projectKey)}&branch=${encodeURIComponent(branch)}&metricKeys=new_violations,accepted_issues,security_hotspots,new_coverage,new_duplicated_lines_density`,
|
|
137
|
+
sonarToken
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const fetchIssues = (projectKey, sonarToken, branch) =>
|
|
141
|
+
sonarcloudFetch(
|
|
142
|
+
`/api/issues/search?componentKeys=${encodeURIComponent(projectKey)}&branch=${encodeURIComponent(branch)}&resolved=false&inNewCodePeriod=true&ps=500&statuses=OPEN,CONFIRMED,REOPENED`,
|
|
143
|
+
sonarToken
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
const fetchSecurityHotspots = (projectKey, sonarToken, branch) =>
|
|
147
|
+
sonarcloudFetch(
|
|
148
|
+
`/api/hotspots/search?projectKey=${encodeURIComponent(projectKey)}&branch=${encodeURIComponent(branch)}&inNewCodePeriod=true&ps=500&status=TO_REVIEW`,
|
|
149
|
+
sonarToken
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const fetchAcceptedIssues = async (projectKey, sonarToken) => {
|
|
153
|
+
const issueStatuses = ACCEPTED_ISSUE_STATUSES.join(',')
|
|
154
|
+
const pageSize = 500
|
|
155
|
+
let page = 1
|
|
156
|
+
const issues = []
|
|
157
|
+
|
|
158
|
+
while (true) {
|
|
159
|
+
const response = await sonarcloudFetch(
|
|
160
|
+
`/api/issues/search?componentKeys=${encodeURIComponent(projectKey)}&issueStatuses=${issueStatuses}&ps=${pageSize}&p=${page}`,
|
|
161
|
+
sonarToken
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
issues.push(...(response.issues ?? []))
|
|
165
|
+
|
|
166
|
+
const total = response.total ?? issues.length
|
|
167
|
+
if (issues.length >= total || (response.issues ?? []).length < pageSize) break
|
|
168
|
+
page++
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return issues
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fetchIssueChangelog = (issueKey, sonarToken) =>
|
|
175
|
+
sonarcloudFetch(`/api/issues/changelog?issue=${encodeURIComponent(issueKey)}`, sonarToken)
|
|
176
|
+
|
|
177
|
+
const issueHasComment = (changelogResponse) => {
|
|
178
|
+
const entries = changelogResponse?.changelog ?? []
|
|
179
|
+
|
|
180
|
+
return entries.some((entry) =>
|
|
181
|
+
(entry.items ?? []).some((item) => {
|
|
182
|
+
if (item.field !== 'comment') return false
|
|
183
|
+
|
|
184
|
+
const value = item.newValue ?? item.newString ?? ''
|
|
185
|
+
return String(value).trim().length > 0
|
|
186
|
+
})
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const mapConcurrent = async (items, fn, concurrency = CHANGELOG_FETCH_CONCURRENCY) => {
|
|
191
|
+
const results = new Array(items.length)
|
|
192
|
+
let nextIndex = 0
|
|
193
|
+
|
|
194
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
195
|
+
while (nextIndex < items.length) {
|
|
196
|
+
const index = nextIndex++
|
|
197
|
+
results[index] = await fn(items[index], index)
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
await Promise.all(workers)
|
|
202
|
+
return results
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const findAcceptedIssuesWithoutComment = async (projectKey, sonarToken) => {
|
|
206
|
+
const acceptedIssues = await fetchAcceptedIssues(projectKey, sonarToken)
|
|
207
|
+
if (acceptedIssues.length === 0) return []
|
|
208
|
+
|
|
209
|
+
const issuesWithCommentFlags = await mapConcurrent(acceptedIssues, async (issue) => {
|
|
210
|
+
const changelog = await fetchIssueChangelog(issue.key, sonarToken)
|
|
211
|
+
return { issue, hasComment: issueHasComment(changelog) }
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
return issuesWithCommentFlags.filter(({ hasComment }) => !hasComment)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const getMeasureValue = (measures, key) => {
|
|
218
|
+
const measure = measures.find((m) => m.metric === key)
|
|
219
|
+
if (!measure) return 'N/A'
|
|
220
|
+
|
|
221
|
+
return measure.value ?? measure.periods?.[0]?.value ?? 'N/A'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const formatPercent = (value) => (value === 'N/A' ? 'N/A' : `${parseFloat(value).toFixed(1)}%`)
|
|
225
|
+
|
|
226
|
+
const row = (label, value) => ` ${` ${label}`.padEnd(28)}${value}`
|
|
227
|
+
|
|
228
|
+
const extractFilePath = (component, projectKey) => {
|
|
229
|
+
const prefix = `${projectKey}:`
|
|
230
|
+
return component.startsWith(prefix) ? component.slice(prefix.length) : component
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const printFailedConditions = (qualityGate) => {
|
|
234
|
+
const conditions = qualityGate.projectStatus?.conditions ?? []
|
|
235
|
+
const failed = conditions.filter((c) => c.status === 'ERROR')
|
|
236
|
+
|
|
237
|
+
if (failed.length === 0) return
|
|
238
|
+
|
|
239
|
+
console.log(THIN_BORDER)
|
|
240
|
+
console.log(' ⛔ Failed Conditions')
|
|
241
|
+
|
|
242
|
+
for (const condition of failed) {
|
|
243
|
+
const label = METRIC_LABELS[condition.metricKey] ?? condition.metricKey
|
|
244
|
+
const comparator = COMPARATOR_SYMBOLS[condition.comparator] ?? condition.comparator
|
|
245
|
+
|
|
246
|
+
const actual =
|
|
247
|
+
condition.metricKey.includes('coverage') || condition.metricKey.includes('duplicat')
|
|
248
|
+
? formatPercent(condition.actualValue)
|
|
249
|
+
: condition.actualValue
|
|
250
|
+
|
|
251
|
+
const threshold =
|
|
252
|
+
condition.metricKey.includes('coverage') || condition.metricKey.includes('duplicat')
|
|
253
|
+
? formatPercent(condition.errorThreshold)
|
|
254
|
+
: condition.errorThreshold
|
|
255
|
+
|
|
256
|
+
console.log(` ${label}: ${actual} (threshold ${comparator} ${threshold})`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const printIssues = (issuesResponse, projectKey) => {
|
|
261
|
+
const issues = issuesResponse?.issues ?? []
|
|
262
|
+
const total = issuesResponse?.total ?? issues.length
|
|
263
|
+
|
|
264
|
+
if (issues.length === 0) return
|
|
265
|
+
|
|
266
|
+
// Sort by severity
|
|
267
|
+
issues.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity))
|
|
268
|
+
|
|
269
|
+
// Group by file
|
|
270
|
+
const byFile = new Map()
|
|
271
|
+
|
|
272
|
+
for (const issue of issues) {
|
|
273
|
+
const filePath = extractFilePath(issue.component, projectKey)
|
|
274
|
+
if (!byFile.has(filePath)) byFile.set(filePath, [])
|
|
275
|
+
byFile.get(filePath).push(issue)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const issuesUrl = `${SONARCLOUD_BASE_URL}/project/issues?id=${encodeURIComponent(projectKey)}&resolved=false&inNewCodePeriod=true`
|
|
279
|
+
|
|
280
|
+
console.log(`\n${BORDER}`)
|
|
281
|
+
console.log(` 🐛 Issues (${total} total)`)
|
|
282
|
+
console.log(BORDER)
|
|
283
|
+
|
|
284
|
+
let displayed = 0
|
|
285
|
+
|
|
286
|
+
for (const [filePath, fileIssues] of [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
287
|
+
if (displayed >= MAX_ISSUES_DISPLAYED) break
|
|
288
|
+
|
|
289
|
+
console.log(`\n 📄 ${filePath}`)
|
|
290
|
+
|
|
291
|
+
for (const issue of fileIssues) {
|
|
292
|
+
if (displayed >= MAX_ISSUES_DISPLAYED) break
|
|
293
|
+
|
|
294
|
+
const icon = SEVERITY_ICONS[issue.severity] ?? '⚪'
|
|
295
|
+
const rule = issue.rule ? ` (${issue.rule})` : ''
|
|
296
|
+
|
|
297
|
+
console.log(` ${icon} L${issue.line ?? '?'} ${issue.message}${rule}`)
|
|
298
|
+
|
|
299
|
+
const issueUrl = `${SONARCLOUD_BASE_URL}/project/issues?id=${encodeURIComponent(projectKey)}&open=${encodeURIComponent(issue.key)}`
|
|
300
|
+
console.log(` ${issueUrl}`)
|
|
301
|
+
|
|
302
|
+
displayed++
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (total > MAX_ISSUES_DISPLAYED) {
|
|
307
|
+
console.log(`\n ... and ${total - MAX_ISSUES_DISPLAYED} more`)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log(THIN_BORDER)
|
|
311
|
+
console.log(` 🔗 ${issuesUrl}`)
|
|
312
|
+
console.log(`${BORDER}\n`)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const formatIssueStatus = (status) => {
|
|
316
|
+
if (status === 'FALSE_POSITIVE') return 'False positive'
|
|
317
|
+
if (status === 'ACCEPTED') return 'Accepted'
|
|
318
|
+
return status ?? 'Unknown'
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const printAcceptedIssuesWithoutComment = (issuesWithoutComment, projectKey) => {
|
|
322
|
+
if (issuesWithoutComment.length === 0) return false
|
|
323
|
+
|
|
324
|
+
const issuesUrl = `${SONARCLOUD_BASE_URL}/project/issues?id=${encodeURIComponent(projectKey)}&issueStatuses=${ACCEPTED_ISSUE_STATUSES.join(',')}`
|
|
325
|
+
|
|
326
|
+
console.log(`\n${BORDER}`)
|
|
327
|
+
console.log(` 💬 Accepted / False Positive Issues without Comment (${issuesWithoutComment.length})`)
|
|
328
|
+
console.log(BORDER)
|
|
329
|
+
console.log(' DEFRA quality gates require a justification comment on each accepted issue.')
|
|
330
|
+
console.log(' Add a comment in SonarCloud under the issue Activity tab.\n')
|
|
331
|
+
|
|
332
|
+
const byFile = new Map()
|
|
333
|
+
|
|
334
|
+
for (const { issue } of issuesWithoutComment) {
|
|
335
|
+
const filePath = extractFilePath(issue.component, projectKey)
|
|
336
|
+
if (!byFile.has(filePath)) byFile.set(filePath, [])
|
|
337
|
+
byFile.get(filePath).push(issue)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let displayed = 0
|
|
341
|
+
|
|
342
|
+
for (const [filePath, fileIssues] of [...byFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
343
|
+
if (displayed >= MAX_ISSUES_DISPLAYED) break
|
|
344
|
+
|
|
345
|
+
console.log(`\n 📄 ${filePath}`)
|
|
346
|
+
|
|
347
|
+
for (const issue of fileIssues) {
|
|
348
|
+
if (displayed >= MAX_ISSUES_DISPLAYED) break
|
|
349
|
+
|
|
350
|
+
const icon = SEVERITY_ICONS[issue.severity] ?? '⚪'
|
|
351
|
+
const rule = issue.rule ? ` (${issue.rule})` : ''
|
|
352
|
+
const status = formatIssueStatus(issue.issueStatus ?? issue.status)
|
|
353
|
+
|
|
354
|
+
console.log(` ${icon} [${status}] L${issue.line ?? '?'} ${issue.message}${rule}`)
|
|
355
|
+
|
|
356
|
+
const issueUrl = `${SONARCLOUD_BASE_URL}/project/issues?id=${encodeURIComponent(projectKey)}&open=${encodeURIComponent(issue.key)}`
|
|
357
|
+
console.log(` ${issueUrl}`)
|
|
358
|
+
|
|
359
|
+
displayed++
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (issuesWithoutComment.length > MAX_ISSUES_DISPLAYED) {
|
|
364
|
+
console.log(`\n ... and ${issuesWithoutComment.length - MAX_ISSUES_DISPLAYED} more`)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
console.log(THIN_BORDER)
|
|
368
|
+
console.log(` 🔗 ${issuesUrl}`)
|
|
369
|
+
console.log(`${BORDER}\n`)
|
|
370
|
+
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const printHotspots = (hotspotsResponse, projectKey) => {
|
|
375
|
+
const hotspots = hotspotsResponse?.hotspots ?? []
|
|
376
|
+
|
|
377
|
+
if (hotspots.length === 0) return
|
|
378
|
+
|
|
379
|
+
const total = hotspotsResponse?.paging?.total ?? hotspots.length
|
|
380
|
+
|
|
381
|
+
console.log(`\n${BORDER}`)
|
|
382
|
+
console.log(` 🔥 Security Hotspots (${total} to review)`)
|
|
383
|
+
console.log(BORDER)
|
|
384
|
+
|
|
385
|
+
let displayed = 0
|
|
386
|
+
|
|
387
|
+
for (const hotspot of hotspots) {
|
|
388
|
+
if (displayed >= MAX_ISSUES_DISPLAYED) break
|
|
389
|
+
|
|
390
|
+
const filePath = extractFilePath(hotspot.component, projectKey)
|
|
391
|
+
const icon = HOTSPOT_ICONS[hotspot.vulnerabilityProbability] ?? '🟡'
|
|
392
|
+
const probability = hotspot.vulnerabilityProbability ?? 'UNKNOWN'
|
|
393
|
+
|
|
394
|
+
console.log(`\n 📄 ${filePath}`)
|
|
395
|
+
console.log(` ${icon} [${probability}] L${hotspot.line ?? '?'} ${hotspot.message}`)
|
|
396
|
+
|
|
397
|
+
const hotspotUrl = `${SONARCLOUD_BASE_URL}/security_hotspots?id=${encodeURIComponent(projectKey)}&hotspots=${encodeURIComponent(hotspot.key)}`
|
|
398
|
+
console.log(` ${hotspotUrl}`)
|
|
399
|
+
|
|
400
|
+
displayed++
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (total > MAX_ISSUES_DISPLAYED) {
|
|
404
|
+
console.log(`\n ... and ${total - MAX_ISSUES_DISPLAYED} more`)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const hotspotsUrl = `${SONARCLOUD_BASE_URL}/security_hotspots?id=${encodeURIComponent(projectKey)}&inNewCodePeriod=true`
|
|
408
|
+
console.log(THIN_BORDER)
|
|
409
|
+
console.log(` 🔗 ${hotspotsUrl}`)
|
|
410
|
+
console.log(`${BORDER}\n`)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const printSummary = (qualityGate, measuresResponse, projectKey, branch) => {
|
|
414
|
+
const measures = measuresResponse.component?.measures ?? []
|
|
415
|
+
const status = qualityGate.projectStatus?.status
|
|
416
|
+
|
|
417
|
+
const passed = status === 'OK'
|
|
418
|
+
const statusLabel = passed ? '✅ PASSED' : status === 'WARN' ? '⚠️ WARN' : '❌ FAILED'
|
|
419
|
+
|
|
420
|
+
// Issues
|
|
421
|
+
const newIssues = getMeasureValue(measures, 'new_violations')
|
|
422
|
+
const acceptedIssues = getMeasureValue(measures, 'accepted_issues')
|
|
423
|
+
|
|
424
|
+
// Measures
|
|
425
|
+
const securityHotspots = getMeasureValue(measures, 'security_hotspots')
|
|
426
|
+
const coverageOnNew = formatPercent(getMeasureValue(measures, 'new_coverage'))
|
|
427
|
+
const duplicationOnNew = formatPercent(getMeasureValue(measures, 'new_duplicated_lines_density'))
|
|
428
|
+
|
|
429
|
+
const dashboardUrl = `${SONARCLOUD_BASE_URL}/summary/overall?id=${encodeURIComponent(projectKey)}`
|
|
430
|
+
|
|
431
|
+
console.log(`\n${BORDER}`)
|
|
432
|
+
console.log(` SonarCloud Quality Gate: ${statusLabel}`)
|
|
433
|
+
console.log(BORDER)
|
|
434
|
+
console.log(' Issues')
|
|
435
|
+
console.log(row('New Issues:', newIssues))
|
|
436
|
+
console.log(row('Accepted Issues:', acceptedIssues))
|
|
437
|
+
console.log(' Measures')
|
|
438
|
+
console.log(row('Security Hotspots:', securityHotspots))
|
|
439
|
+
console.log(row('Coverage on New Code:', coverageOnNew))
|
|
440
|
+
console.log(row('Duplication on New Code:', duplicationOnNew))
|
|
441
|
+
|
|
442
|
+
if (!passed) {
|
|
443
|
+
printFailedConditions(qualityGate)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
console.log(BORDER)
|
|
447
|
+
console.log(` 🔀 Branch: ${branch}`)
|
|
448
|
+
console.log(` 🔗 ${dashboardUrl}`)
|
|
449
|
+
console.log(`${BORDER}\n`)
|
|
450
|
+
|
|
451
|
+
return passed || status === 'WARN'
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const sonarScan = async () => {
|
|
455
|
+
const cwd = resolve('.')
|
|
456
|
+
|
|
457
|
+
// Load .env if present (mirrors `source .env` from the old npm script)
|
|
458
|
+
try {
|
|
459
|
+
const envVars = parseKeyValue(readFileSync(resolve(cwd, '.env'), 'utf8'))
|
|
460
|
+
for (const [key, value] of Object.entries(envVars)) {
|
|
461
|
+
process.env[key] ??= value
|
|
462
|
+
}
|
|
463
|
+
} catch {
|
|
464
|
+
// .env file is optional — SONAR_TOKEN may already be in the environment
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const sonarToken = process.env.SONAR_TOKEN
|
|
468
|
+
|
|
469
|
+
if (!sonarToken) {
|
|
470
|
+
console.error('Error: SONAR_TOKEN is not set. Add it to your .env file.')
|
|
471
|
+
process.exit(1)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Read project config from sonar-project.properties
|
|
475
|
+
const propsPath = resolve(cwd, 'sonar-project.properties')
|
|
476
|
+
const props = parseKeyValue(readFileSync(propsPath, 'utf8'))
|
|
477
|
+
const projectKey = props['sonar.projectKey']
|
|
478
|
+
|
|
479
|
+
if (!projectKey) {
|
|
480
|
+
console.error('Error: sonar.projectKey not found in sonar-project.properties')
|
|
481
|
+
process.exit(1)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Detect current branch so the scan targets it on SonarCloud
|
|
485
|
+
const branch = getCurrentBranch()
|
|
486
|
+
console.log(`\n🔀 Branch: ${branch}\n`)
|
|
487
|
+
|
|
488
|
+
// Run the scanner — resolves with exit code (0 = success, non-zero = quality
|
|
489
|
+
// gate failed or scan error). We always attempt to fetch the summary.
|
|
490
|
+
const scanCode = await runScanner(sonarToken, cwd, branch)
|
|
491
|
+
|
|
492
|
+
// Fetch quality gate + metrics and print summary
|
|
493
|
+
let qualityGate, measuresResponse
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
;[qualityGate, measuresResponse] = await Promise.all([
|
|
497
|
+
fetchQualityGate(projectKey, sonarToken, branch),
|
|
498
|
+
fetchMeasures(projectKey, sonarToken, branch)
|
|
499
|
+
])
|
|
500
|
+
} catch (apiErr) {
|
|
501
|
+
// API fetch failed — the scan likely didn't upload (e.g. auth error, network)
|
|
502
|
+
if (scanCode !== 0) {
|
|
503
|
+
console.error(`\nSonar scanner exited with code ${scanCode}. No results to display.`)
|
|
504
|
+
process.exit(scanCode)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
throw apiErr
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const passed = printSummary(qualityGate, measuresResponse, projectKey, branch)
|
|
511
|
+
|
|
512
|
+
let hasAcceptedIssuesWithoutComment = false
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const issuesWithoutComment = await findAcceptedIssuesWithoutComment(projectKey, sonarToken)
|
|
516
|
+
hasAcceptedIssuesWithoutComment = printAcceptedIssuesWithoutComment(issuesWithoutComment, projectKey)
|
|
517
|
+
} catch (acceptedErr) {
|
|
518
|
+
console.error(`\nCould not fetch accepted/false-positive issue comments: ${acceptedErr.message}`)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (!passed || hasAcceptedIssuesWithoutComment) {
|
|
522
|
+
// Fetch detailed issues and hotspots to help developers fix problems locally
|
|
523
|
+
try {
|
|
524
|
+
const measures = measuresResponse.component?.measures ?? []
|
|
525
|
+
const hotspotCount = getMeasureValue(measures, 'security_hotspots')
|
|
526
|
+
const shouldFetchHotspots = hotspotCount !== 'N/A' && parseInt(hotspotCount, 10) > 0
|
|
527
|
+
|
|
528
|
+
const fetches = [fetchIssues(projectKey, sonarToken, branch)]
|
|
529
|
+
|
|
530
|
+
if (shouldFetchHotspots) {
|
|
531
|
+
fetches.push(fetchSecurityHotspots(projectKey, sonarToken, branch))
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const [issuesResponse, hotspotsResponse] = await Promise.all(fetches)
|
|
535
|
+
|
|
536
|
+
printIssues(issuesResponse, projectKey)
|
|
537
|
+
|
|
538
|
+
if (hotspotsResponse) {
|
|
539
|
+
printHotspots(hotspotsResponse, projectKey)
|
|
540
|
+
}
|
|
541
|
+
} catch (detailErr) {
|
|
542
|
+
console.error(`\nCould not fetch issue details: ${detailErr.message}`)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
process.exit(1)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
sonarScan().catch((err) => {
|
|
550
|
+
console.error(`\nSonar scan failed: ${err.message}`)
|
|
551
|
+
process.exit(1)
|
|
552
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Takes the raw address data and maps it to a more usable format
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} address - The raw address data
|
|
5
|
+
*
|
|
6
|
+
* @returns {Object} Formatted address data
|
|
7
|
+
*/
|
|
8
|
+
export const mapAddress = (address = {}) => {
|
|
9
|
+
return {
|
|
10
|
+
lookup: {
|
|
11
|
+
pafOrganisationName: address.pafOrganisationName,
|
|
12
|
+
buildingNumberRange: address.buildingNumberRange,
|
|
13
|
+
flatName: address.flatName,
|
|
14
|
+
buildingName: address.buildingName,
|
|
15
|
+
dependentLocality: address.dependentLocality,
|
|
16
|
+
doubleDependentLocality: address.doubleDependentLocality,
|
|
17
|
+
street: address.street,
|
|
18
|
+
county: address.county,
|
|
19
|
+
uprn: address.uprn
|
|
20
|
+
},
|
|
21
|
+
manual: {
|
|
22
|
+
line1: address.line1,
|
|
23
|
+
line2: address.line2,
|
|
24
|
+
line3: address.line3,
|
|
25
|
+
line4: address.line4,
|
|
26
|
+
line5: address.line5
|
|
27
|
+
},
|
|
28
|
+
city: address.city,
|
|
29
|
+
postcode: address.postalCode,
|
|
30
|
+
country: address.country
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Takes the raw customer name data and maps it to a more usable format
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} value - The data from the DAL
|
|
5
|
+
*
|
|
6
|
+
* @returns {Object} Formatted customer name data
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const mapCustomerName = (name = {}) => {
|
|
10
|
+
return {
|
|
11
|
+
userName: [
|
|
12
|
+
name.first,
|
|
13
|
+
name.last
|
|
14
|
+
].filter(Boolean).join(' ')
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/mappers/mappers.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { mapPersonalBusinessDetails } from './personal-business-details-mapper.js'
|
|
2
|
+
import { mapAddress } from './address-mapper.js'
|
|
3
|
+
import { mapCustomerName } from './customer-name-mapper.js'
|
|
2
4
|
|
|
3
5
|
export const mappers = {
|
|
4
|
-
mapPersonalBusinessDetails
|
|
6
|
+
personalBusinessDetails: mapPersonalBusinessDetails,
|
|
7
|
+
address: mapAddress,
|
|
8
|
+
customerName: mapCustomerName
|
|
5
9
|
}
|