Skip to content

Commit c94d112

Browse files
authored
Sonarqube extension implementation (#1)
1 parent 5cde6f5 commit c94d112

File tree

8 files changed

+1381
-1028
lines changed

8 files changed

+1381
-1028
lines changed

README.md

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,62 @@
1-
# sonarqube (Sourcegraph extension)
1+
# [Sonarqube](https://www.sonarqube.org/) Sourcegraph extension
22

3-
.
3+
Show Sonarqube issues when browsing files on Sourcegraph.
4+
5+
<p>
6+
<picture>
7+
<source srcset="https://raw.githubusercontent.com/sourcegraph/sourcegraph-sonarqube/main/images/screenshot_dark.png" media="(prefers-color-scheme: dark)">
8+
<source srcset="https://raw.githubusercontent.com/sourcegraph/sourcegraph-sonarqube/main/images/screenshot_light.png" media="(prefers-color-scheme: light)">
9+
<img src="https://raw.githubusercontent.com/sourcegraph/sourcegraph-sonarqube/main/images/screenshot_light.png" alt="Screenshot">
10+
</picture>
11+
</p>
12+
13+
➡️ Try it out on [github.com/apache/struts](https://sourcegraph.com/github.com/apache/struts/-/blob/apps/rest-showcase/src/main/java/org/demo/rest/example/OrdersController.java)
14+
15+
You can toggle decorations with the Sonarqube button in the action toolbar.
16+
Each decoration links to the issue on Sonarqube.
17+
18+
## Configuration
19+
20+
The extension can be configured through JSON in user, organization or global settings.
21+
22+
```jsonc
23+
{
24+
// Configure the extension to use a private Sonarqube instance.
25+
// By default, Sonarcloud is used.
26+
"sonarqube.instanceUrl": "https://sonarcloud.io/",
27+
// An API token to the Sonarqube instance, if needed.
28+
"sonarqube.apiToken": "...",
29+
30+
// The Sonarqube extension needs to map the repository on Sourcegraph to a project inside an organization on
31+
// Sonarqube. The default settings work for most projects on Sonarcloud, but if you have a custom setup, you
32+
// can configure the following settings.
33+
34+
// This regular expression is matched on the repository name. The values from the capture groups are
35+
// available in the templates below.
36+
"sonarqube.repositoryNamePattern": "(?:^|/)([^/]+)/([^/]+)$",
37+
// This template is used to form the Sonarqube organization key.
38+
// By default, the second-last part of the repository name (first capture group above) is used as-is.
39+
// E.g. "apache" from "github.com/apache/struts".
40+
"sonarqube.organizationKeyTemplate": "$1",
41+
// This template is used to form the Sonarqube project key.
42+
// By default, the second-last and last part of the repository name (first and second capture groups above)
43+
// are joined by an underscore.
44+
// E.g. "apache_struts" from "github.com/apache/struts".
45+
"sonarqube.projectKeyTemplate": "$1_$2",
46+
47+
// CORS headers are necessary for the extension to fetch data, but Sonarqube does not send them by default.
48+
// Here you can customize the URL to an HTTP proxy that adds CORS headers.
49+
// By default Sourcegraph's CORS proxy is used.
50+
"sonarqube.corsAnywhereUrl": "https://cors-anywhere.herokuapp.com"
51+
}
52+
```
53+
54+
## Limitations
55+
56+
The current commit viewed on Sourcegraph may not be analyzed on Sonarqube.
57+
If that is the case, the extension will fallback to the latest analysis available on Sonarqube on the default branch.
58+
This may result in decorations being off in some files if those files differ between the commit being viewed and the one analyzed on Sonarqube.
59+
60+
---
61+
62+
SONARQUBE is a trademark belonging to SonarSource SA.

images/screenshot_dark.png

231 KB
Loading

images/screenshot_light.png

234 KB
Loading

package.json

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,102 @@
11
{
2-
"$schema": "https://raw.githubusercontent.com/sourcegraph/sourcegraph/master/shared/src/schema/extension.schema.json",
2+
"$schema": "https://raw.githubusercontent.com/sourcegraph/sourcegraph/main/client/shared/src/schema/extension.schema.json",
33
"name": "sonarqube",
4-
"description": "",
4+
"description": "Show Sonarqube issues when browsing files on Sourcegraph",
55
"publisher": "sourcegraph",
6+
"icon": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 512 512'%3E%3Cdefs/%3E%3Crect width='512' height='512' fill='%23549dd0'/%3E%3Cdefs%3E%3Crect id='a' y='0' width='512' height='512'/%3E%3C/defs%3E%3CclipPath id='b'%3E%3Cuse xlink:href='%23a' overflow='visible'/%3E%3C/clipPath%3E%3Cpath d='M409 448h-22c0-179-148-325-330-325v-22c194 0 352 155 352 347' clip-path='url(%23b)' fill='%23fff'/%3E%3Cg%3E%3Cdefs%3E%3Crect id='c' y='0' width='512' height='512'/%3E%3C/defs%3E%3CclipPath id='d'%3E%3Cuse xlink:href='%23c' overflow='visible'/%3E%3C/clipPath%3E%3Cpath d='M424 329A335 335 0 00192 89l5-18a354 354 0 01245 253l-18 5z' clip-path='url(%23d)' fill='%23fff'/%3E%3C/g%3E%3Cg%3E%3Cdefs%3E%3Crect id='e' y='0' width='512' height='512'/%3E%3C/defs%3E%3CclipPath id='f'%3E%3Cuse xlink:href='%23e' overflow='visible'/%3E%3C/clipPath%3E%3Cpath d='M441 223c-27-60-74-113-132-148l8-12c60 36 109 91 138 154l-14 6z' clip-path='url(%23f)' fill='%23fff'/%3E%3C/g%3E%3C/svg%3E",
67
"activationEvents": [
78
"*"
89
],
9-
"wip": true,
10-
"categories": [],
11-
"tags": [],
10+
"wip": false,
11+
"categories": [
12+
"External services"
13+
],
14+
"tags": [
15+
"Sonarqube"
16+
],
1217
"contributes": {
13-
"actions": [],
18+
"actions": [
19+
{
20+
"id": "sonarqube.showIssuesOnCodeViews.toggle",
21+
"command": "updateConfiguration",
22+
"commandArguments": [
23+
[
24+
"sonarqube.showIssuesOnCodeViews"
25+
],
26+
"${!(config.sonarqube.showIssuesOnCodeViews !== false)}",
27+
null,
28+
"json"
29+
],
30+
"title": "${get(context, 'sonarqube.errorMessage') || (config.sonarqube.showIssuesOnCodeViews && \"Hide inline Sonarqube issues\" || \"Show inline Sonarqube issues\")}",
31+
"category": "Sonarqube",
32+
"actionItem": {
33+
"description": "${get(context, 'sonarqube.errorMessage') || (config.sonarqube.showIssuesOnCodeViews && \"Hide inline Sonarqube issues\" || \"Show inline Sonarqube issues\")}",
34+
"iconURL": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 512 512'%3E%3Cdefs/%3E%3Crect width='512' height='512' fill='%23${get(context, 'sonarqube.errorMessage') && \"999\" || \"549dd0\"}'/%3E%3Cdefs%3E%3Crect id='a' y='0' width='512' height='512'/%3E%3C/defs%3E%3CclipPath id='b'%3E%3Cuse xlink:href='%23a' overflow='visible'/%3E%3C/clipPath%3E%3Cpath d='M409 448h-22c0-179-148-325-330-325v-22c194 0 352 155 352 347' clip-path='url(%23b)' fill='%23fff'/%3E%3Cg%3E%3Cdefs%3E%3Crect id='c' y='0' width='512' height='512'/%3E%3C/defs%3E%3CclipPath id='d'%3E%3Cuse xlink:href='%23c' overflow='visible'/%3E%3C/clipPath%3E%3Cpath d='M424 329A335 335 0 00192 89l5-18a354 354 0 01245 253l-18 5z' clip-path='url(%23d)' fill='%23fff'/%3E%3C/g%3E%3Cg%3E%3Cdefs%3E%3Crect id='e' y='0' width='512' height='512'/%3E%3C/defs%3E%3CclipPath id='f'%3E%3Cuse xlink:href='%23e' overflow='visible'/%3E%3C/clipPath%3E%3Cpath d='M441 223c-27-60-74-113-132-148l8-12c60 36 109 91 138 154l-14 6z' clip-path='url(%23f)' fill='%23fff'/%3E%3C/g%3E%3C/svg%3E",
35+
"pressed": "config.sonarqube.showIssuesOnCodeViews"
36+
}
37+
}
38+
],
1439
"menus": {
15-
"editor/title": [],
40+
"editor/title": [
41+
{
42+
"action": "sonarqube.showIssuesOnCodeViews.toggle",
43+
"when": "resource"
44+
}
45+
],
1646
"commandPalette": []
1747
},
18-
"configuration": {}
48+
"configuration": {
49+
"type": "object",
50+
"properties": {
51+
"sonarqube.showIssuesOnCodeViews": {
52+
"description": "Whether to show Sonarqube issues inline on code views.",
53+
"type": "boolean",
54+
"default": true
55+
},
56+
"sonarqube.instanceUrl": {
57+
"description": "The URL to the Sonarqube instance.",
58+
"type": "string",
59+
"default": "https://sonarcloud.io/"
60+
},
61+
"sonarqube.apiToken": {
62+
"description": "The API authentication token for the Sonarqube instance, if needed.",
63+
"type": "string"
64+
},
65+
"sonarqube.corsAnywhereUrl": {
66+
"description": "The URL to a CORS proxy.",
67+
"type": "string",
68+
"default": "https://cors-anywhere.herokuapp.com"
69+
},
70+
"sonarqube.repositoryNamePattern": {
71+
"description": "Regular expression with that is matched on the repository name to extract the capture groups for organization and project key templates.",
72+
"type": "string",
73+
"format": "regex",
74+
"default": "(?:^|/)([^/]+)/([^/]+)$"
75+
},
76+
"sonarqube.organizationKeyTemplate": {
77+
"description": "Replace string to build the organization key from the repository name pattern, using $n references for capture groups. By default just uses the first capture group",
78+
"type": "string",
79+
"default": "$1"
80+
},
81+
"sonarqube.projectKeyTemplate": {
82+
"description": "Replace string to build the project key from the repository name pattern, using $n references for capture groups.",
83+
"type": "string",
84+
"default": "$1_$2"
85+
}
86+
}
87+
}
1988
},
2089
"version": "0.0.0-DEVELOPMENT",
2190
"license": "Apache-2.0",
2291
"main": "dist/sonarqube.js",
92+
"repository": {
93+
"type": "git",
94+
"url": "https://github.com/sourcegraph/sourcegraph-sonarqube"
95+
},
2396
"scripts": {
2497
"eslint": "eslint 'src/**/*.ts'",
2598
"typecheck": "tsc -p tsconfig.json",
26-
"build": "parcel build --out-file dist/sonarqube.js src/sonarqube.ts",
99+
"build": "parcel build --out-file dist/sonarqube.js src/sonarqube.ts --no-minify",
27100
"symlink-package": "mkdirp dist && lnfs ./package.json ./dist/package.json",
28101
"serve": "yarn run symlink-package && parcel serve --no-hmr --out-file dist/sonarqube.js src/sonarqube.ts",
29102
"watch:typecheck": "tsc -p tsconfig.json -w",
@@ -45,5 +118,8 @@
45118
"parcel-bundler": "^1.12.4",
46119
"sourcegraph": "^24.7.0",
47120
"typescript": "^4.0.3"
121+
},
122+
"dependencies": {
123+
"rxjs": "^6.6.3"
48124
}
49125
}

src/api.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
export interface ApiOptions {
2+
sonarqubeApiUrl: URL
3+
4+
/** API authentication token */
5+
apiToken?: string
6+
}
7+
8+
interface FetchIssuesOptions extends ApiOptions {
9+
componentKeys: string[]
10+
branch?: string
11+
}
12+
13+
type Qualifier = 'FIL' | 'UTS' | 'DIR' | 'TRK' | 'BRC'
14+
15+
interface Component {
16+
organization: string
17+
key: string
18+
name: string
19+
qualifier: Qualifier
20+
language: string
21+
project: string
22+
}
23+
24+
export type IssueType = 'BUG' | 'VULNERABILITY' | 'CODE_SMELL'
25+
26+
export type Severity = 'MINOR' | 'MAJOR' | 'CRITICAL' | 'BLOCKER'
27+
28+
export interface Issue {
29+
key: string
30+
rule: string
31+
severity: Severity
32+
component: string
33+
project: string
34+
line: number
35+
hash: string
36+
textRange: {
37+
startLine: number
38+
endLine: number
39+
startOffset: number
40+
endOffset: number
41+
}
42+
flows: []
43+
status: string
44+
message: string
45+
effort: string
46+
debt: string
47+
tags: string[]
48+
creationDate: string
49+
updateDate: string
50+
type: IssueType
51+
organization: string
52+
fromHotspot: false
53+
}
54+
55+
async function fetchApi(path: string, searchParameters: URLSearchParams, options: ApiOptions): Promise<any> {
56+
const url = new URL(path, options.sonarqubeApiUrl)
57+
url.search = searchParameters.toString()
58+
const headers = new Headers()
59+
if (options.apiToken) {
60+
headers.set('Authorization', 'Basic ' + btoa(options.apiToken + ':'))
61+
}
62+
const response = await fetch(url.href, { headers })
63+
if (!response.ok) {
64+
if (response.headers.get('Content-Type')?.includes('json')) {
65+
const { errors } = (await response.json()) as { errors: { msg: string }[] }
66+
if (errors.length === 1) {
67+
throw new Error(errors[0].msg)
68+
}
69+
throw new AggregateError(
70+
errors.map(error => new Error(error.msg)),
71+
errors.map(error => error.msg).join('\n')
72+
)
73+
}
74+
throw new Error(response.statusText)
75+
}
76+
const result = await response.json()
77+
return result
78+
}
79+
80+
export async function searchComponents(
81+
options: ApiOptions & { query: string; organization: string }
82+
): Promise<Component[]> {
83+
const searchParameters = new URLSearchParams()
84+
searchParameters.set('organization', options.organization)
85+
searchParameters.set('qualifiers', 'FIL,UTS')
86+
searchParameters.set('q', options.query)
87+
const result = await fetchApi('api/components/search', searchParameters, options)
88+
return result.components
89+
}
90+
91+
export async function searchIssues(options: FetchIssuesOptions): Promise<Issue[]> {
92+
const searchParameters = new URLSearchParams()
93+
// Comma-separated list of component keys. Retrieve issues associated to a specific list of components (and all
94+
// its descendants). A component can be a project, directory or file.
95+
searchParameters.set('componentKeys', options.componentKeys.join(','))
96+
if (options.branch) {
97+
searchParameters.set('branch', options.branch)
98+
}
99+
const result = await fetchApi('api/issues/search', searchParameters, options)
100+
return result.issues
101+
}
102+
103+
export interface Branch {
104+
name: string
105+
isMain: boolean
106+
/** Long-lived or short-lived */
107+
type: 'LONG' | 'SHORT'
108+
status: {
109+
qualityGateStatus: 'ERROR' | 'OK'
110+
bugs?: number
111+
vulnerabilities?: number
112+
codeSmells?: number
113+
}
114+
analysisDate: string
115+
commit: {
116+
sha: string
117+
author: {
118+
name: string
119+
login: string
120+
avatar: string
121+
}
122+
date: string
123+
message: string
124+
}
125+
}
126+
127+
export async function listBranches(options: { project: string } & ApiOptions): Promise<Branch[]> {
128+
const searchParameters = new URLSearchParams()
129+
searchParameters.set('project', options.project)
130+
const result = await fetchApi('api/project_branches/list', searchParameters, options)
131+
return result.branches
132+
}

0 commit comments

Comments
 (0)