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 }[]
}

Links