Merged PR 63702: Add Sonar Client to update permissions and tags in Sonar Projects to new team structure

Add Sonar Client to update permissions and tags in Sonar Projects to new team structure

Related work items: #125680
This commit is contained in:
Johannes Oenema Effectory
2025-11-05 15:18:52 +00:00
parent e30af22220
commit 91980817e0
66 changed files with 1586 additions and 1296 deletions

View File

@@ -0,0 +1,16 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SonarClient", "SonarClient\SonarClient.csproj", "{1DF21902-A886-4C02-AB59-B19CB291498D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1DF21902-A886-4C02-AB59-B19CB291498D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1DF21902-A886-4C02-AB59-B19CB291498D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1DF21902-A886-4C02-AB59-B19CB291498D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1DF21902-A886-4C02-AB59-B19CB291498D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,93 @@
using SonarClient.Repositories.Interfaces;
using Spectre.Console;
namespace SonarClient.Actions;
public class UpdatePermissionsAndTags(
IProjectRepository projectRepository,
IPermissionRepository permissionRepository,
IProjectTagRepository projectTagRepository)
{
private Dictionary<string, string> PermissionTemplateMapping { get; set; } = new();
public async Task Execute(string organizationId, string organizationKey)
{
var projects = await projectRepository.GetProjects(organizationId);
var permissionTemplates = await permissionRepository.GetPermissionTemplates(organizationKey);
PermissionTemplateMapping = permissionTemplates.ToDictionary(x => x.Name, x => x.Id);
var projectTeams = new List<ProjectTeam>();
foreach (var project in projects)
{
if (project.Tags.Length == 0)
{
AnsiConsole.MarkupLine($"[red]Project {project.Name} has zero tags[/]");
continue;
}
var oldTeam = project.Tags[0];
var newTeam = GetNewTeam(oldTeam);
if (newTeam == null)
{
continue;
}
projectTeams.Add(new ProjectTeam(project.Key, oldTeam, newTeam));
}
foreach (var teamProjects in projectTeams.GroupBy(p => p.OldTeam).ToDictionary(g => g.Key, g => g.ToList()))
{
AnsiConsole.MarkupLine($"Do you want to update permissions and tags for team: [yellow]{teamProjects.Key}[/] to [yellow]{GetNewTeam(teamProjects.Key)}[/]? It will be updated for the following projects:");
foreach (var project in teamProjects.Value)
{
AnsiConsole.MarkupLine($"[green]{Markup.Escape($"- {project.ProjectKey}")}[/]");
}
if (!await AnsiConsole.ConfirmAsync("Confirm"))
{
continue;
}
foreach (var project in teamProjects.Value)
{
await permissionRepository.ApplyTemplateToProject(project.ProjectKey, GetPermissionTemplateName(project.NewTeam));
await projectTagRepository.SetTags(project.ProjectKey, [project.NewTeam]);
AnsiConsole.MarkupLine($"[green]Updated permissions and tags for project: [yellow]{project.ProjectKey}[/][/]");
}
AnsiConsole.Clear();
}
}
private static string GetNewTeam(string oldTeam)
{
return oldTeam switch
{
"lime" => "platform",
"yellow" => "reporting",
"orange" => "reporting",
"red" => "surveying",
"pink" => "surveying",
"gray" => "surveying",
"blue" => "data-and-ai",
_ => null
};
}
private string GetPermissionTemplateName(string newTeam)
{
return newTeam switch
{
"platform" => PermissionTemplateMapping["Team Platform template"],
"reporting" => PermissionTemplateMapping["Team Reporting template"],
"surveying" => PermissionTemplateMapping["Team Surveying template"],
"data-and-ai" => PermissionTemplateMapping["Team Data and AI template"],
"managers" => PermissionTemplateMapping["Team Managers template"],
_ => throw new IndexOutOfRangeException($"Unknown team: {newTeam}")
};
}
private record ProjectTeam(string ProjectKey, string OldTeam, string NewTeam);
}

View File

@@ -0,0 +1,10 @@
namespace SonarClient.Constants;
public static class SonarConstants
{
public const string SonarApiV1ClientName = "SonarApiV1Client";
public const string SonarApiV1BaseAddress = "https://sonarcloud.io/";
public const string SonarApiV2ClientName = "SonarApiV2Client";
public const string SonarApiV2BaseAddress = "https://api.sonarcloud.io/";
}

View File

@@ -0,0 +1,8 @@
namespace SonarClient.Models;
public class Link
{
public string Name { get; set; }
public string Url { get; set; }
public string Type { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace SonarClient.Models;
public class Paging
{
public int PageIndex { get; set; }
public int PageSize { get; set; }
public int Total { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace SonarClient.Models;
public class PermissionTemplate
{
public string Id { get; set; }
public string Name { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace SonarClient.Models;
public class PermissionTemplateResponse
{
public List<PermissionTemplate> PermissionTemplates { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace SonarClient.Models;
public class Project
{
public string Id { get; set; }
public string LegacyId { get; set; }
public string Key { get; set; }
public string Name { get; set; }
public string Visibility { get; set; }
public string OrganizationId { get; set; }
public string Description { get; set; }
public string[] Tags { get; set; }
public Link[] Links { get; set; }
public string CreatedAt { get; set; }
public string UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace SonarClient.Models;
public class ProjectsSearchResponse
{
public Paging Page { get; set; }
public List<Project> Projects { get; set; }
}

View File

@@ -0,0 +1,59 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SonarClient.Actions;
using SonarClient.Constants;
using SonarClient.Repositories;
using SonarClient.Repositories.Interfaces;
using Spectre.Console;
using System.Net.Http.Headers;
using System.Text;
var builder = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.AddUserSecrets<Program>()
.AddEnvironmentVariables();
var configurationRoot = builder.Build();
var sonarConfiguration = configurationRoot.GetSection("sonar");
var token = sonarConfiguration["api_key"];
var organizationId = sonarConfiguration["organization_id"];
var organizationKey = sonarConfiguration["organization_key"];
var services = new ServiceCollection();
services.AddLogging(options =>
{
options.SetMinimumLevel(LogLevel.Warning);
options.AddConsole();
});
services.AddHttpClient(SonarConstants.SonarApiV1ClientName, c =>
{
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{token}:")));
c.BaseAddress = new Uri(SonarConstants.SonarApiV1BaseAddress);
});
services.AddHttpClient(SonarConstants.SonarApiV2ClientName, c =>
{
c.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
c.BaseAddress = new Uri(SonarConstants.SonarApiV2BaseAddress);
});
// Repositories
services.AddTransient<IProjectRepository, ProjectRepository>();
services.AddTransient<IPermissionRepository, PermissionRepository>();
services.AddTransient<IProjectTagRepository, ProjectTagRepository>();
// Actions
services.AddTransient<UpdatePermissionsAndTags>();
await using var serviceProvider = services.BuildServiceProvider();
var updatePermissionsAndTags = serviceProvider.GetRequiredService<UpdatePermissionsAndTags>();
AnsiConsole.Confirm("Are you sure you want to update permissions?", false);
await updatePermissionsAndTags.Execute(organizationId, organizationKey);
Console.WriteLine("Press any key to exit...");
Console.ReadKey();

View File

@@ -0,0 +1,9 @@
using SonarClient.Models;
namespace SonarClient.Repositories.Interfaces;
public interface IPermissionRepository
{
Task<List<PermissionTemplate>> GetPermissionTemplates(string organizationKey);
Task ApplyTemplateToProject(string projectKey, string templateId);
}

View File

@@ -0,0 +1,8 @@
using SonarClient.Models;
namespace SonarClient.Repositories.Interfaces;
public interface IProjectRepository
{
Task<List<Project>> GetProjects(string organizationId);
}

View File

@@ -0,0 +1,6 @@
namespace SonarClient.Repositories.Interfaces;
public interface IProjectTagRepository
{
Task SetTags(string projectKey, string[] tags);
}

View File

@@ -0,0 +1,28 @@
using SonarClient.Constants;
using SonarClient.Models;
using SonarClient.Repositories.Interfaces;
using System.Net.Http.Json;
namespace SonarClient.Repositories;
public class PermissionRepository(IHttpClientFactory httpClientFactory) : IPermissionRepository
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(SonarConstants.SonarApiV1ClientName);
public async Task<List<PermissionTemplate>> GetPermissionTemplates(string organizationKey)
{
var permissionTemplateResponse = await _httpClient.GetFromJsonAsync<PermissionTemplateResponse>($"/api/permissions/search_templates?organization={organizationKey}");
return permissionTemplateResponse.PermissionTemplates;
}
public async Task ApplyTemplateToProject(string projectKey, string templateId)
{
var content = new FormUrlEncodedContent([
new KeyValuePair<string, string>("projectKey", projectKey),
new KeyValuePair<string, string>("templateId", templateId)
]);
var response = await _httpClient.PostAsync("api/permissions/apply_template", content);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -0,0 +1,34 @@
using SonarClient.Constants;
using SonarClient.Models;
using SonarClient.Repositories.Interfaces;
using System.Net.Http.Json;
namespace SonarClient.Repositories;
public class ProjectRepository(IHttpClientFactory httpClientFactory) : IProjectRepository
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(SonarConstants.SonarApiV2ClientName);
public async Task<List<Project>> GetProjects(string organizationId)
{
var projects = new List<Project>();
var currentPage = 1;
ProjectsSearchResponse projectResponse;
do
{
projectResponse = await GetProjects(organizationId, currentPage);
projects.AddRange(projectResponse.Projects);
currentPage++;
} while (projects.Count < projectResponse.Page.Total);
return projects;
}
private async Task<ProjectsSearchResponse> GetProjects(string organizationId, int page)
{
return await _httpClient.GetFromJsonAsync<ProjectsSearchResponse>(
$"/projects/projects?organizationIds={organizationId}&pageIndex={page}"
) ?? throw new InvalidOperationException("Failed to retrieve projects from SonarCloud");
}
}

View File

@@ -0,0 +1,22 @@
using SonarClient.Constants;
using SonarClient.Repositories.Interfaces;
namespace SonarClient.Repositories;
public class ProjectTagRepository(IHttpClientFactory httpClientFactory) : IProjectTagRepository
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(SonarConstants.SonarApiV1ClientName);
public async Task SetTags(string projectKey, string[] tags)
{
var tagsCommaSeperated = string.Join(",", tags);
var content = new FormUrlEncodedContent([
new KeyValuePair<string, string>("project", projectKey),
new KeyValuePair<string, string>("tags", tagsCommaSeperated)
]);
var response = await _httpClient.PostAsync("api/project_tags/set", content);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<UserSecretsId>e28ed066-de2c-46f8-865f-4b0eda67be3c</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Spectre.Console" Version="0.53.0" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"sonar": {
"api_key": "",
"organization_id": "aa1160b9-e4ab-4d2a-9ce5-bac8d9b5fb06",
"organization_key": "effectory"
}
}

View File

@@ -0,0 +1,44 @@
# SonarClient Console Application
## Overview
SonarClient is a console application designed to interact with SonarQube/SonarCloud REST APIs. This tool
provides functionality to manage, query, and analyze code quality metrics and security findings from SonarQube
instances.
Depending on the operations, the application will reach out to v1 or v2 of the SonarQube API.
Documentation for the v1 API can be found here: https://sonarcloud.io/web_api
Documentation for the v2 API can be found here: https://api-docs.sonarsource.com/
## Features
- **Update Permissions And Tags**: Update permissions and tags for all projects. Added for migration to new team structure.
## Operations
- Retrieve all projects
- Set tags for a project
- Apply permission template on a project
## Usage
1. Retrieve a personal token from SonarCloud:
- Go to: https://sonarcloud.io/account/security
- Enter token name
- Click `Generate Token`
- Copy token
2. In Rider
- Right-click on the `SonarClient` project
- Select `Tools > .NET User Secrets`
- This will open `secrets.json`
- Add the following:
```json
{
"sonar": {
"api_key": "[add token here]"
}
}`
3. Run the application
4. When done revoke the token