SonarCloud register GitHub repository via API
Going to fully automate SonarCloud in GitHub Actions
Ideally for any new project all we need to do is something like:
- uses: actions/sonar
with:
dotnet: '6.0'
token: ${{ secrets.SONAR_TOKEN }}
And everything else will be handled automatically
And if with github action itself everything is convinient then the only missing part is addition of sonar cloud project
Set of API call to register new repository in sonarcloud
export SONAR_URL="https://sonarcloud.io"
export SONAR_TOKEN="************"
export SONAR_ORG="acme"
export GITHUB_ORG="contoso"
export REPO="demo"
export KEY="contoso_demo"
Projects
Search
curl -s -u "$SONAR_TOKEN:" "$SONAR_URL/api/projects/search?organization=$SONAR_ORG&q=$REPO"
200 found
{
"paging": { "pageIndex": 1, "pageSize": 100, "total": 1 },
"components": [
{
"organization": "acme",
"key": "contoso_demo",
"name": "demo",
"qualifier": "TRK",
"visibility": "private",
"lastAnalysisDate": "2021-11-17T22:18:11+0100",
"revision": "27ea89546a654e4cf09b63719ee5b6daf72979c0"
}
]
}
200 not found
{
"paging": { "pageIndex": 1, "pageSize": 100, "total": 0 },
"components": []
}
400
{
"errors": [
{
"msg": "The 'organization' parameter is missing"
}
]
}
Create
curl -s -X POST -u "$SONAR_TOKEN:" "$SONAR_URL/api/projects/create" -d "name=$REPO&project=${GITHUB_ORG}_$REPO&organization=$SONAR_ORG&visibility=private"
200 found
{
"project": {
"key": "contoso_demo",
"name": "demo",
"qualifier": "TRK",
"visibility": "private"
}
}
400
{
"errors": [
{
"msg": "Could not create Project, key already exists: contoso_demo"
}
]
}
Branches
List
curl -s -X POST -u "$SONAR_TOKEN:" "$SONAR_URL/api/project_branches/list" -d "project=${GITHUB_ORG}_$REPO"
200 found
{
"branches": [
{
"name": "main",
"isMain": false,
"type": "SHORT",
"mergeBranch": "master",
"status": {
"qualityGateStatus": "OK",
"bugs": 0,
"vulnerabilities": 0,
"codeSmells": 0
},
"analysisDate": "2021-11-18T08:57:05+0100",
"commit": {
"sha": "27ea89546a654e4cf09b63719ee5b6daf72979c0"
}
},
{
"name": "master",
"isMain": true,
"type": "LONG",
"status": {}
}
]
}
404
{
"errors": [
{
"msg": "Component key 'contoso_demo' not found"
}
]
}
Delete
curl -s -X POST -u "$SONAR_TOKEN:" "$SONAR_URL/api/project_branches/delete" -d "project=${GITHUB_ORG}_$REPO&branch=main"
200 found
empty response
404
{
"errors": [
{
"msg": "Branch 'main' not found for project 'contoso_demo'"
}
]
}
Rename
curl -s -X POST -u "$SONAR_TOKEN:" "$SONAR_URL/api/project_branches/rename" -d "project=${GITHUB_ORG}_$REPO&name=main"
200
empty response
404
{
"errors": [
{
"msg": "Project key 'contoso_demo' not found"
}
]
}
In my case I had repo with main
bracnh and sonar expects master
so need to delete main branch and rename master to main
Note: seems like api does not care about method and everywhere post can be used
Short note, how to get main branch name from git:
git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'
So the action can be something like:
name: 'dotnet sonar'
description: 'GitHub Action DotNet SonarCloud'
inputs:
dotnet_version:
description: 'dotnet version of your project'
required: true
default: '6.0'
dotnet_test:
description: 'determines whether "dotnet test" or "dotnet build" will be run'
required: false
default: true
github_token:
description: 'github token for sonar, usually secrets.GITHUB_TOKEN'
required: true
sonar_enabled:
description: 'switch to turn on/off sonar scanner, usefull for debugging'
required: false
default: true
# Removed in favor to actions-sonar-register
# sonar_key:
# description: 'sonar project key, it will be given to you after adding project to sonarcloud.io'
# required: false
sonar_org:
description: 'sonar project org'
required: true
sonar_url:
description: 'sonar url, to where data will be sent'
required: false
default: 'https://sonarcloud.io'
sonar_token:
description: 'sonar token, usually it will be secrets.SONAR_TOKEN'
required: false
sonar_coverage_report:
description: 'path to sonar reports'
required: false
default: '**/TestResults/**/coverage.opencover.xml'
sonar_tests_report:
description: 'tests report'
required: false
default: '**/TestResults/*.trx'
sonar_coverage_exclusions:
description: 'optional, comma separated list of paths to exclude from coverage'
required: false
default: '**Test*.cs'
checkout:
description: 'checkout sources'
required: false
default: true
runs:
using: 'composite'
steps:
- uses: actions/checkout@v2
if: inputs.checkout
with:
fetch-depth: 0
- uses: ./github/actions/actions-sonar-register@main
id: register
if: inputs.sonar_enabled
with:
token: ${{ inputs.sonar_token }}
- uses: actions/setup-dotnet@v1
with:
dotnet-version: ${{ inputs.dotnet_version }}
- uses: actions/setup-java@v1
if: inputs.sonar_enabled
with:
java-version: 1.11
# required by sonar scanner dotnet tool
- uses: actions/setup-dotnet@v1
with:
dotnet-version: '5.0'
- uses: actions/cache@v1
if: inputs.sonar_enabled
with:
path: ~\sonar\cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- uses: actions/cache@v1
if: inputs.sonar_enabled
id: cache-sonar-scanner
with:
path: .\.sonar\scanner
key: ${{ runner.os }}-sonar-scanner
restore-keys: ${{ runner.os }}-sonar-scanner
- name: sonar install
if: inputs.sonar_enabled && steps.cache-sonar-scanner.outputs.cache-hit != 'true'
shell: bash
run: mkdir -p ./.sonar/scanner && dotnet tool update dotnet-sonarscanner --tool-path ./.sonar/scanner
- name: sonar begin
if: inputs.sonar_enabled
shell: bash
run: ./.sonar/scanner/dotnet-sonarscanner begin /k:"${{ steps.register.outputs.key }}" /o:"${{ inputs.sonar_org }}" /d:sonar.login="${{ inputs.sonar_token }}" /d:"sonar.host.url=${{ inputs.sonar_url }}" /d:sonar.cs.opencover.reportsPaths="${{ inputs.sonar_coverage_report }}" -d:sonar.cs.vstest.reportsPaths="${{ inputs.sonar_tests_report }}" /d:sonar.coverage.exclusions="${{ inputs.sonar_coverage_exclusions }}"
env:
GITHUB_TOKEN: ${{ inputs.github_token }}
- name: dotnet build
if: inputs.sonar_enabled
shell: bash
run: dotnet build
- name: dotnet test
if: inputs.sonar_enabled && inputs.dotnet_test
shell: bash
run: dotnet test --logger=trx --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: sonar end
if: inputs.sonar_enabled
shell: bash
run: ./.sonar/scanner/dotnet-sonarscanner end /d:sonar.login="${{inputs.sonar_token}}"
env:
GITHUB_TOKEN: ${{ inputs.github_token }}
Which allow use it as simple as:
name: main
on:
push:
branches: [main]
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: ./github/actions/actions-dotnet-sonar
with:
sonar_token: ${{ secrets.SONAR_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
dotnet_version: '6.0'
sonar_org: demo
# sonar_key: demo # automated with dedicated sonar registration action
And here is starting point for sonar registration action:
import axios, { AxiosInstance, AxiosError } from 'axios'
export const SONAR_URL = 'https://sonarcloud.io'
export class SonarCloudClient {
private axios: AxiosInstance
public constructor(private org: string, private token: string) {
this.axios = axios.create({
baseURL: SONAR_URL,
timeout: 5000,
auth: { username: this.token, password: '' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
}
public async register(githubOrganization: string, repositoryName: string, mainBranchName: string): Promise<void> {
const project = `${githubOrganization}_${repositoryName}`
try {
const { components } = await this.searchProjects(this.org, repositoryName)
const found = components.find(({ key, name }) => key === project && name === repositoryName)
if (!found) {
await this.createProject(repositoryName, project, this.org)
}
const { branches } = await this.getBranches(project)
if (branches.find(({ name, isMain }) => name === mainBranchName && isMain === false)) {
await this.deleteBranch(project, mainBranchName)
}
await this.renameBranch(project, mainBranchName)
} catch (error: Error | AxiosError | any) {
SonarCloudClient.handle(error)
}
}
private async searchProjects(organization: string, q: string): Promise<SonarSearchResponse> {
const { data } = await this.axios.get<SonarSearchResponse>('/api/projects/search', { params: { organization, q } })
return data
}
private async createProject(name: string, project: string, organization: string): Promise<void> {
const params = new URLSearchParams()
params.append('name', name)
params.append('project', project)
params.append('organization', organization)
await this.axios.post('/api/projects/create', params)
}
private async getBranches(project: string): Promise<SonarBranchesListResponse> {
const { data } = await this.axios.get<SonarBranchesListResponse>('/api/project_branches/list', { params: { project } })
return data
}
private async deleteBranch(project: string, branch: string): Promise<void> {
const params = new URLSearchParams()
params.append('project', project)
params.append('branch', branch)
await this.axios.post('/api/project_branches/delete', params)
}
private async renameBranch(project: string, name: string): Promise<void> {
await this.axios.post('/api/project_branches/rename', null, { params: { project, name } })
}
private static handle(error: Error | AxiosError | any) {
if (axios.isAxiosError(error)) {
const message = (error.response?.data as SonarErrorResponse)?.errors?.map((error) => error?.msg)?.shift()
throw new Error(message || error?.message)
} else {
throw error
}
}
}
export interface SonarSearchResponse {
components: {
key: string
name: string
}[]
}
export interface SonarBranchesListResponse {
branches: {
name: string
isMain: boolean
}[]
}
export interface SonarErrorResponse {
errors: { msg: string }[]
}