Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions docs/design/sonar-mark/program.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,8 @@ before attempting any network call. Missing required parameters are reported via
- `SonarMark-Cli-Help` — `Run` prints help and returns when `context.Help` is true
- `SonarMark-Server-Connect` — `ProcessSonarAnalysis` validates the `--server` parameter
- `SonarMark-Server-Auth` — `SonarQubeClient` is created with the token from context
- `SonarMark-Server-QualityGate` — `GetQualityResultByBranchAsync` fetches quality gate status
- `SonarMark-Server-Issues` — quality result includes the issues collection
- `SonarMark-Server-HotSpots` — quality result includes the hot-spots collection
- `SonarMark-Server-ProjectKey` — `ProcessSonarAnalysis` validates the `--project-key` parameter
- `SonarMark-Server-Branch` — branch is passed through to `GetQualityResultByBranchAsync`
- `SonarMark-Report-Markdown` — `File.WriteAllText` writes the markdown report when `--report` is set
- `SonarMark-Validation-Run` — `Run` delegates to `Validation.Run` when `context.Validate` is true
- `SonarMark-Report-Depth` — `context.ReportDepth` is passed to `ToMarkdown` and shown in help text
- `SonarMark-Enforce-ExitCode` — `context.ExitCode` is returned from `Main`
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ output and parsed by tools that understand that format.

## Satisfies Requirements

- `SonarMark-Server-QualityGate` — `QualityGateStatus` and `Conditions` hold the quality gate data
- `SonarMark-Server-Issues` — `Issues` holds the list of fetched issues
- `SonarMark-Server-HotSpots` — `HotSpots` holds the list of fetched hot-spots
- `SonarMark-Report-Markdown` — `ToMarkdown` generates the markdown report content
- `SonarMark-Report-Depth` — the `depth` parameter controls heading levels
- `SonarMark-Report-QualityGate` — quality gate status and conditions are included in the report
Expand Down
4 changes: 0 additions & 4 deletions docs/design/sonar-mark/sonar-integration/sonar-qube-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ errors from network errors and report them appropriately.

## Satisfies Requirements

- `SonarMark-Server-Connect` — establishes HTTP connections to the SonarQube/SonarCloud server
- `SonarMark-Server-Auth` — applies token-based Basic Authentication to all requests
- `SonarMark-Server-QualityGate` — fetches quality gate status and conditions from the API
- `SonarMark-Server-Issues` — fetches issues with pagination from the API
- `SonarMark-Server-HotSpots` — fetches security hot-spots with pagination from the API
- `SonarMark-Server-ProjectKey` — passes the project key as a query parameter on all requests
- `SonarMark-Server-Branch` — passes the branch name as an optional query parameter
19 changes: 12 additions & 7 deletions src/DemaConsulting.SonarMark/SelfTest/Validation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ namespace DemaConsulting.SonarMark.SelfTest;
/// </summary>
internal static class Validation
{
/// <summary>
/// MIME type for JSON content used in mock HTTP responses.
/// </summary>
private const string JsonContentType = "application/json";

/// <summary>
/// Special project key used for mock validation data.
/// </summary>
Expand Down Expand Up @@ -64,7 +69,7 @@ public static void Run(Context context)
};

// Create mock HTTP client factory
var mockFactory = (string? _) => new SonarQubeClient(CreateMockHttpClient(), false);
var mockFactory = (string? _) => new SonarQubeClient(CreateMockHttpClient(), true);

// Run core functionality tests
RunQualityGateRetrievalTest(context, testResults, mockFactory);
Expand Down Expand Up @@ -461,7 +466,7 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
Content = new StringContent(json, Encoding.UTF8, JsonContentType)
});
}

Expand Down Expand Up @@ -493,7 +498,7 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
Content = new StringContent(json, Encoding.UTF8, JsonContentType)
});
}

Expand Down Expand Up @@ -526,7 +531,7 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
Content = new StringContent(json, Encoding.UTF8, JsonContentType)
});
}

Expand All @@ -549,7 +554,7 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
Content = new StringContent(json, Encoding.UTF8, JsonContentType)
});
}

Expand All @@ -572,7 +577,7 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
Content = new StringContent(json, Encoding.UTF8, JsonContentType)
});
}

Expand Down Expand Up @@ -620,7 +625,7 @@ public void Dispose()
Directory.Delete(DirectoryPath, true);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
{
// Ignore cleanup errors during disposal
}
Expand Down
47 changes: 27 additions & 20 deletions src/DemaConsulting.SonarMark/SonarIntegration/SonarQubeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ private async Task<string> GetProjectNameByKeyAsync(
CancellationToken cancellationToken)
{
// Build API URL for component information
var url = $"{serverUrl.TrimEnd('/')}/api/components/show?component={projectKey}";
var url = $"{serverUrl.TrimEnd('/')}/api/components/show?component={Uri.EscapeDataString(projectKey)}";

// Fetch component data from server
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -177,7 +177,7 @@ private async Task<string> GetProjectNameByKeyAsync(
CancellationToken cancellationToken)
{
// Build API URL with project key and optional branch parameter
var url = $"{serverUrl.TrimEnd('/')}/api/qualitygates/project_status?projectKey={projectKey}";
var url = $"{serverUrl.TrimEnd('/')}/api/qualitygates/project_status?projectKey={Uri.EscapeDataString(projectKey)}";
if (!string.IsNullOrWhiteSpace(branch))
{
url += $"&branch={Uri.EscapeDataString(branch)}";
Expand Down Expand Up @@ -272,7 +272,7 @@ private async Task<List<SonarIssue>> GetIssuesAsync(
{
// Build base API URL with project key and issue statuses filter
var baseUrl =
$"{serverUrl.TrimEnd('/')}/api/issues/search?componentKeys={projectKey}&issueStatuses=OPEN,CONFIRMED&ps=100";
$"{serverUrl.TrimEnd('/')}/api/issues/search?componentKeys={Uri.EscapeDataString(projectKey)}&issueStatuses=OPEN,CONFIRMED&ps=100";
if (!string.IsNullOrWhiteSpace(branch))
{
baseUrl += $"&branch={Uri.EscapeDataString(branch)}";
Expand Down Expand Up @@ -339,7 +339,7 @@ private async Task<List<SonarHotSpot>> GetHotSpotsAsync(
CancellationToken cancellationToken)
{
// Build base API URL with project key and page size
var baseUrl = $"{serverUrl.TrimEnd('/')}/api/hotspots/search?projectKey={projectKey}&ps=100";
var baseUrl = $"{serverUrl.TrimEnd('/')}/api/hotspots/search?projectKey={Uri.EscapeDataString(projectKey)}&ps=100";
if (!string.IsNullOrWhiteSpace(branch))
{
baseUrl += $"&branch={Uri.EscapeDataString(branch)}";
Expand Down Expand Up @@ -401,31 +401,38 @@ private async Task<List<T>> FetchPaginatedAsync<T>(
break;
}

var pageIndex = pagingElement.TryGetProperty("pageIndex", out var pageIndexElement)
? pageIndexElement.GetInt32()
: pageNumber;
var pageSize = pagingElement.TryGetProperty("pageSize", out var pageSizeElement)
? pageSizeElement.GetInt32()
: 100;
var total = pagingElement.TryGetProperty("total", out var totalElement)
? totalElement.GetInt32()
: 0;

// Stop when all pages have been retrieved
if (total > pageIndex * pageSize)
{
pageNumber++;
}
else
if (!HasMorePages(pagingElement, pageNumber))
{
break;
}

pageNumber++;
}
while (true);

return allItems;
}

/// <summary>
/// Determines whether additional pages remain based on the paging element of a SonarQube API response.
/// </summary>
/// <param name="pagingElement">The paging JSON element from the API response.</param>
/// <param name="pageNumber">The current page number (used as fallback when pageIndex is absent).</param>
/// <returns>True if more pages remain; otherwise false.</returns>
private static bool HasMorePages(JsonElement pagingElement, int pageNumber)
{
var pageIndex = pagingElement.TryGetProperty("pageIndex", out var pageIndexElement)
? pageIndexElement.GetInt32()
: pageNumber;
var pageSize = pagingElement.TryGetProperty("pageSize", out var pageSizeElement)
? pageSizeElement.GetInt32()
: 100;
var total = pagingElement.TryGetProperty("total", out var totalElement)
? totalElement.GetInt32()
: 0;
return total > pageIndex * pageSize;
}

/// <summary>
/// Parses hot-spots from a JSON array element
/// </summary>
Expand Down