diff --git a/docs/design/sonar-mark/program.md b/docs/design/sonar-mark/program.md index 98ea7a9..0e46f61 100644 --- a/docs/design/sonar-mark/program.md +++ b/docs/design/sonar-mark/program.md @@ -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` diff --git a/docs/design/sonar-mark/report-generation/sonar-quality-result.md b/docs/design/sonar-mark/report-generation/sonar-quality-result.md index 098a7d5..b70ca98 100644 --- a/docs/design/sonar-mark/report-generation/sonar-quality-result.md +++ b/docs/design/sonar-mark/report-generation/sonar-quality-result.md @@ -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 diff --git a/docs/design/sonar-mark/sonar-integration/sonar-qube-client.md b/docs/design/sonar-mark/sonar-integration/sonar-qube-client.md index fd5e56a..58fdbe7 100644 --- a/docs/design/sonar-mark/sonar-integration/sonar-qube-client.md +++ b/docs/design/sonar-mark/sonar-integration/sonar-qube-client.md @@ -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 diff --git a/src/DemaConsulting.SonarMark/SelfTest/Validation.cs b/src/DemaConsulting.SonarMark/SelfTest/Validation.cs index 43ce98e..636f38d 100644 --- a/src/DemaConsulting.SonarMark/SelfTest/Validation.cs +++ b/src/DemaConsulting.SonarMark/SelfTest/Validation.cs @@ -33,6 +33,11 @@ namespace DemaConsulting.SonarMark.SelfTest; /// internal static class Validation { + /// + /// MIME type for JSON content used in mock HTTP responses. + /// + private const string JsonContentType = "application/json"; + /// /// Special project key used for mock validation data. /// @@ -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); @@ -461,7 +466,7 @@ protected override Task 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) }); } @@ -493,7 +498,7 @@ protected override Task 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) }); } @@ -526,7 +531,7 @@ protected override Task 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) }); } @@ -549,7 +554,7 @@ protected override Task 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) }); } @@ -572,7 +577,7 @@ protected override Task 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) }); } @@ -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 } diff --git a/src/DemaConsulting.SonarMark/SonarIntegration/SonarQubeClient.cs b/src/DemaConsulting.SonarMark/SonarIntegration/SonarQubeClient.cs index c812aed..17aacd5 100644 --- a/src/DemaConsulting.SonarMark/SonarIntegration/SonarQubeClient.cs +++ b/src/DemaConsulting.SonarMark/SonarIntegration/SonarQubeClient.cs @@ -129,7 +129,7 @@ private async Task 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); @@ -177,7 +177,7 @@ private async Task 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)}"; @@ -272,7 +272,7 @@ private async Task> 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)}"; @@ -339,7 +339,7 @@ private async Task> 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)}"; @@ -401,31 +401,38 @@ private async Task> FetchPaginatedAsync( 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; } + /// + /// Determines whether additional pages remain based on the paging element of a SonarQube API response. + /// + /// The paging JSON element from the API response. + /// The current page number (used as fallback when pageIndex is absent). + /// True if more pages remain; otherwise false. + 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; + } + /// /// Parses hot-spots from a JSON array element ///