diff --git a/api/v1/mapping/src/commonMain/kotlin/Mappings.kt b/api/v1/mapping/src/commonMain/kotlin/Mappings.kt index 20fc6d21a..14c1ded85 100644 --- a/api/v1/mapping/src/commonMain/kotlin/Mappings.kt +++ b/api/v1/mapping/src/commonMain/kotlin/Mappings.kt @@ -36,6 +36,7 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.ExtendedRepositoryType as Api import org.eclipse.apoapsis.ortserver.api.v1.model.Identifier as ApiIdentifier import org.eclipse.apoapsis.ortserver.api.v1.model.InfrastructureService as ApiInfrastructureService import org.eclipse.apoapsis.ortserver.api.v1.model.Issue as ApiIssue +import org.eclipse.apoapsis.ortserver.api.v1.model.IssueWithIdentifier as ApiIssueWithIdentifier import org.eclipse.apoapsis.ortserver.api.v1.model.JiraNotificationConfiguration as ApiJiraNotificationConfiguration import org.eclipse.apoapsis.ortserver.api.v1.model.JiraRestClientConfiguration as ApiJiraRestClientConfiguration import org.eclipse.apoapsis.ortserver.api.v1.model.JobConfigurations as ApiJobConfigurations @@ -88,6 +89,7 @@ import org.eclipse.apoapsis.ortserver.model.EvaluatorJob import org.eclipse.apoapsis.ortserver.model.EvaluatorJobConfiguration import org.eclipse.apoapsis.ortserver.model.InfrastructureService import org.eclipse.apoapsis.ortserver.model.InfrastructureServiceDeclaration +import org.eclipse.apoapsis.ortserver.model.IssueWithIdentifier import org.eclipse.apoapsis.ortserver.model.JiraNotificationConfiguration import org.eclipse.apoapsis.ortserver.model.JiraRestClientConfiguration import org.eclipse.apoapsis.ortserver.model.JobConfigurations @@ -472,6 +474,8 @@ fun ApiScannerJobConfiguration.mapToModel() = ScannerJobConfiguration( fun Secret.mapToApi() = ApiSecret(name, description) +fun IssueWithIdentifier.mapToApi() = ApiIssueWithIdentifier(issue.mapToApi(), identifier?.mapToApi()) + fun VulnerabilityWithIdentifier.mapToApi() = ApiVulnerabilityWithIdentifier(vulnerability.mapToApi(), identifier.mapToApi()) diff --git a/api/v1/model/src/commonMain/kotlin/IssueWithIdentifier.kt b/api/v1/model/src/commonMain/kotlin/IssueWithIdentifier.kt new file mode 100644 index 000000000..c97796650 --- /dev/null +++ b/api/v1/model/src/commonMain/kotlin/IssueWithIdentifier.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.api.v1.model + +import kotlinx.serialization.Serializable + +/** + * A union data class to associate a [Issue] with an [Identifier]. + */ +@Serializable +data class IssueWithIdentifier( + val issue: Issue, + val identifier: Identifier? +) diff --git a/core/src/main/kotlin/api/RunsRoute.kt b/core/src/main/kotlin/api/RunsRoute.kt index 0f40b0478..b62c54dea 100644 --- a/core/src/main/kotlin/api/RunsRoute.kt +++ b/core/src/main/kotlin/api/RunsRoute.kt @@ -39,6 +39,7 @@ import org.eclipse.apoapsis.ortserver.api.v1.mapping.mapToApi import org.eclipse.apoapsis.ortserver.api.v1.mapping.mapToModel import org.eclipse.apoapsis.ortserver.api.v1.model.SortDirection import org.eclipse.apoapsis.ortserver.api.v1.model.SortProperty +import org.eclipse.apoapsis.ortserver.core.apiDocs.getIssuesByRunId import org.eclipse.apoapsis.ortserver.core.apiDocs.getLogsByRunId import org.eclipse.apoapsis.ortserver.core.apiDocs.getOrtRunById import org.eclipse.apoapsis.ortserver.core.apiDocs.getPackagesByRunId @@ -52,11 +53,13 @@ import org.eclipse.apoapsis.ortserver.dao.QueryParametersException import org.eclipse.apoapsis.ortserver.logaccess.LogFileService import org.eclipse.apoapsis.ortserver.logaccess.LogLevel import org.eclipse.apoapsis.ortserver.logaccess.LogSource +import org.eclipse.apoapsis.ortserver.model.IssueWithIdentifier import org.eclipse.apoapsis.ortserver.model.OrtRun import org.eclipse.apoapsis.ortserver.model.VulnerabilityWithIdentifier import org.eclipse.apoapsis.ortserver.model.authorization.RepositoryPermission import org.eclipse.apoapsis.ortserver.model.repositories.OrtRunRepository import org.eclipse.apoapsis.ortserver.model.runs.Package +import org.eclipse.apoapsis.ortserver.services.IssueService import org.eclipse.apoapsis.ortserver.services.PackageService import org.eclipse.apoapsis.ortserver.services.ReportStorageService import org.eclipse.apoapsis.ortserver.services.RepositoryService @@ -68,6 +71,7 @@ import org.koin.ktor.ext.inject * API for the run's endpoint. This endpoint provides information related to ORT runs and their results. */ fun Route.runs() = route("runs/{runId}") { + val issueService by inject() val ortRunRepository by inject() val repositoryService by inject() val vulnerabilityService by inject() @@ -115,6 +119,22 @@ fun Route.runs() = route("runs/{runId}") { } } + route("issues") { + get(getIssuesByRunId) { + call.forRun(ortRunRepository) { ortRun -> + requirePermission(RepositoryPermission.READ_ORT_RUNS.roleName(ortRun.repositoryId)) + + val pagingOptions = call.pagingOptions(SortProperty("timestamp", SortDirection.DESCENDING)) + + val issueForOrtRun = issueService.listForOrtRunId(ortRun.id, pagingOptions.mapToModel()) + + val pagedResponse = issueForOrtRun.mapToApi(IssueWithIdentifier::mapToApi) + + call.respond(HttpStatusCode.OK, pagedResponse) + } + } + } + route("vulnerabilities") { get(getVulnerabilitiesByRunId) { call.forRun(ortRunRepository) { ortRun -> diff --git a/core/src/main/kotlin/apiDocs/RunsDocs.kt b/core/src/main/kotlin/apiDocs/RunsDocs.kt index b64fe3fff..416a07a5c 100644 --- a/core/src/main/kotlin/apiDocs/RunsDocs.kt +++ b/core/src/main/kotlin/apiDocs/RunsDocs.kt @@ -27,6 +27,8 @@ import kotlinx.datetime.Clock import org.eclipse.apoapsis.ortserver.api.v1.model.ExtendedRepositoryType import org.eclipse.apoapsis.ortserver.api.v1.model.Identifier +import org.eclipse.apoapsis.ortserver.api.v1.model.Issue +import org.eclipse.apoapsis.ortserver.api.v1.model.IssueWithIdentifier import org.eclipse.apoapsis.ortserver.api.v1.model.OrtRun import org.eclipse.apoapsis.ortserver.api.v1.model.OrtRunStatus import org.eclipse.apoapsis.ortserver.api.v1.model.Package @@ -34,6 +36,7 @@ import org.eclipse.apoapsis.ortserver.api.v1.model.PagedResponse import org.eclipse.apoapsis.ortserver.api.v1.model.PagingData import org.eclipse.apoapsis.ortserver.api.v1.model.ProcessedDeclaredLicense import org.eclipse.apoapsis.ortserver.api.v1.model.RemoteArtifact +import org.eclipse.apoapsis.ortserver.api.v1.model.Severity import org.eclipse.apoapsis.ortserver.api.v1.model.SortDirection import org.eclipse.apoapsis.ortserver.api.v1.model.SortProperty import org.eclipse.apoapsis.ortserver.api.v1.model.VcsInfo @@ -150,6 +153,50 @@ val getLogsByRunId: OpenApiRoute.() -> Unit = { } } +val getIssuesByRunId: OpenApiRoute.() -> Unit = { + operationId = "GetIssuesByRunId" + summary = "Get the issues of an ORT run." + tags = listOf("Issues") + + request { + pathParameter("runId") { + description = "The ID of the ORT run." + } + + standardListQueryParameters() + } + + response { + HttpStatusCode.OK to { + description = "Success." + jsonBody> { + example("Get issues for an ORT run") { + value = PagedResponse( + listOf( + Issue( + message = "An issue", + severity = Severity.ERROR, + source = "source", + timestamp = Clock.System.now() + ) + ), + PagingData( + limit = 20, + offset = 0, + totalCount = 1, + sortProperties = listOf(SortProperty("timestamp", SortDirection.DESCENDING)) + ) + ) + } + } + } + + HttpStatusCode.NotFound to { + description = "The ORT run does not exist." + } + } +} + val getVulnerabilitiesByRunId: OpenApiRoute.() -> Unit = { operationId = "GetVulnerabilitiesByRunId" summary = "Get the vulnerabilities found in an ORT run." diff --git a/core/src/main/kotlin/di/Module.kt b/core/src/main/kotlin/di/Module.kt index 5b261b7cd..534db8cef 100644 --- a/core/src/main/kotlin/di/Module.kt +++ b/core/src/main/kotlin/di/Module.kt @@ -65,6 +65,7 @@ import org.eclipse.apoapsis.ortserver.secrets.SecretStorage import org.eclipse.apoapsis.ortserver.services.AuthorizationService import org.eclipse.apoapsis.ortserver.services.DefaultAuthorizationService import org.eclipse.apoapsis.ortserver.services.InfrastructureServiceService +import org.eclipse.apoapsis.ortserver.services.IssueService import org.eclipse.apoapsis.ortserver.services.OrganizationService import org.eclipse.apoapsis.ortserver.services.PackageService import org.eclipse.apoapsis.ortserver.services.ProductService @@ -124,6 +125,7 @@ fun ortServerModule(config: ApplicationConfig) = module { single { RepositoryService(get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { SecretService(get(), get(), get(), get()) } single { VulnerabilityService(get()) } + single { IssueService(get()) } single { PackageService(get()) } singleOf(::ReportStorageService) singleOf(::InfrastructureServiceService) diff --git a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt index c404bcde3..6c2378d24 100644 --- a/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt +++ b/core/src/test/kotlin/api/RunsRouteIntegrationTest.kt @@ -49,15 +49,18 @@ import java.io.File import java.io.IOException import java.util.EnumSet +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlinx.datetime.Clock import kotlinx.datetime.Instant import org.eclipse.apoapsis.ortserver.api.v1.mapping.mapToApi +import org.eclipse.apoapsis.ortserver.api.v1.model.IssueWithIdentifier import org.eclipse.apoapsis.ortserver.api.v1.model.Jobs import org.eclipse.apoapsis.ortserver.api.v1.model.Package as ApiPackage import org.eclipse.apoapsis.ortserver.api.v1.model.PagedResponse +import org.eclipse.apoapsis.ortserver.api.v1.model.SortDirection.DESCENDING import org.eclipse.apoapsis.ortserver.api.v1.model.VulnerabilityWithIdentifier import org.eclipse.apoapsis.ortserver.config.ConfigManager import org.eclipse.apoapsis.ortserver.core.shouldHaveBody @@ -73,11 +76,13 @@ import org.eclipse.apoapsis.ortserver.model.OrtRun import org.eclipse.apoapsis.ortserver.model.OrtRunStatus import org.eclipse.apoapsis.ortserver.model.PluginConfiguration import org.eclipse.apoapsis.ortserver.model.RepositoryType +import org.eclipse.apoapsis.ortserver.model.Severity import org.eclipse.apoapsis.ortserver.model.authorization.RepositoryPermission import org.eclipse.apoapsis.ortserver.model.repositories.OrtRunRepository import org.eclipse.apoapsis.ortserver.model.runs.AnalyzerConfiguration import org.eclipse.apoapsis.ortserver.model.runs.Environment import org.eclipse.apoapsis.ortserver.model.runs.Identifier +import org.eclipse.apoapsis.ortserver.model.runs.Issue import org.eclipse.apoapsis.ortserver.model.runs.Package import org.eclipse.apoapsis.ortserver.model.runs.ProcessedDeclaredLicense import org.eclipse.apoapsis.ortserver.model.runs.RemoteArtifact @@ -97,6 +102,7 @@ import org.eclipse.apoapsis.ortserver.utils.test.Integration import org.ossreviewtoolkit.utils.common.ArchiveType import org.ossreviewtoolkit.utils.common.unpack +@Suppress("LargeClass") class RunsRouteIntegrationTest : AbstractIntegrationTest({ tags(Integration) @@ -535,6 +541,240 @@ class RunsRouteIntegrationTest : AbstractIntegrationTest({ } } + "GET /runs/{runId}/issues" should { + "require RepositoryPermission.READ_ORT_RUNS" { + val ortRun = dbExtension.fixtures.createOrtRun( + repositoryId = repositoryId, + revision = "revision", + jobConfigurations = JobConfigurations() + ) + + requestShouldRequireRole(RepositoryPermission.READ_ORT_RUNS.roleName(repositoryId)) { + get("/api/v1/runs/${ortRun.id}/issues") + } + } + + "handle a non-existing ORT run" { + integrationTestApplication { + val response = superuserClient.get("/api/v1/runs/12345/issues") + + response shouldHaveStatus HttpStatusCode.NotFound + } + } + + "return and empty list of issues if no issues exist" { + integrationTestApplication { + val ortRun = dbExtension.fixtures.createOrtRun( + repositoryId = repositoryId, + revision = "revision", + jobConfigurations = JobConfigurations() + ) + + val response = superuserClient.get("/api/v1/runs/${ortRun.id}/issues") + + response.status shouldBe HttpStatusCode.OK + val pagedIssues = response.body>() + + pagedIssues.pagination.totalCount shouldBe 0 + pagedIssues.data shouldHaveSize 0 + + // Applies a default sort order + pagedIssues.pagination.sortProperties.firstOrNull()?.name shouldBe "timestamp" + pagedIssues.pagination.sortProperties.firstOrNull()?.direction shouldBe DESCENDING + } + } + + "return a paginated list of issues including analyzer issues" { + integrationTestApplication { + val ortRun = dbExtension.fixtures.createOrtRun( + repositoryId = repositoryId, + revision = "revision", + jobConfigurations = JobConfigurations() + ) + + val analyzerJob = dbExtension.fixtures.createAnalyzerJob(ortRun.id) + + val now = Clock.System.now() + dbExtension.fixtures.analyzerRunRepository.create( + analyzerJobId = analyzerJob.id, + startTime = now.toDatabasePrecision(), + endTime = now.toDatabasePrecision(), + environment = Environment( + ortVersion = "1.0", + javaVersion = "11.0.16", + os = "Linux", + processors = 8, + maxMemory = 8321499136, + variables = emptyMap(), + toolVersions = emptyMap() + ), + config = AnalyzerConfiguration( + allowDynamicVersions = true, + enabledPackageManagers = emptyList(), + disabledPackageManagers = emptyList(), + packageManagers = emptyMap(), + skipExcluded = true + ), + projects = emptySet(), + packages = emptySet(), + issues = mapOf( + Identifier("Maven", "namespace", "name", "1.0.0") to listOf( + Issue( + timestamp = now.minus(1.hours).toDatabasePrecision(), + source = "Maven", + message = "Issue 1", + severity = Severity.ERROR, + affectedPath = "path" + ), + Issue( + timestamp = now.toDatabasePrecision(), + source = "Maven", + message = "Issue 2", + severity = Severity.WARNING, + affectedPath = "path" + ) + ) + ), + dependencyGraphs = emptyMap() + ) + + val response = superuserClient.get("/api/v1/runs/${ortRun.id}/issues?limit=1") + + response.status shouldBe HttpStatusCode.OK + val pagedIssues = response.body>() + + with(pagedIssues.pagination) { + totalCount shouldBe 2 + offset shouldBe 0 + limit shouldBe 1 + + // Default sort order applied? + sortProperties.firstOrNull()?.name shouldBe "timestamp" + sortProperties.firstOrNull()?.direction shouldBe DESCENDING + } + + with(pagedIssues.data) { + shouldHaveSize(1) + with(first()) { + with(issue) { + timestamp.epochSeconds shouldBe now.epochSeconds + source shouldBe "Maven" + message shouldBe "Issue 2" + severity shouldBe org.eclipse.apoapsis.ortserver.api.v1.model.Severity.WARNING + affectedPath shouldBe "path" + } + + with(identifier) { + this?.type shouldBe "Maven" + this?.namespace shouldBe "namespace" + this?.name shouldBe "name" + this?.version shouldBe "1.0.0" + } + } + } + } + } + + "return a paginated list of issues including advisor issues" { + integrationTestApplication { + val ortRun = dbExtension.fixtures.createOrtRun( + repositoryId = repositoryId, + revision = "revision", + jobConfigurations = JobConfigurations() + ) + + val advisorJob = dbExtension.fixtures.createAdvisorJob(ortRun.id) + + val now = Clock.System.now() + dbExtension.fixtures.advisorRunRepository.create( + advisorJobId = advisorJob.id, + startTime = now.toDatabasePrecision(), + endTime = now.toDatabasePrecision(), + environment = Environment( + ortVersion = "1.0", + javaVersion = "11.0.16", + os = "Linux", + processors = 8, + maxMemory = 8321499136, + variables = emptyMap(), + toolVersions = emptyMap() + ), + config = AdvisorConfiguration( + config = mapOf( + "VulnerableCode" to PluginConfiguration( + options = mapOf("serverUrl" to "https://public.vulnerablecode.io"), + secrets = mapOf("apiKey" to "key") + ) + ) + ), + results = mapOf( + Identifier("Maven", "namespace", "name", "1.0.0") to listOf( + AdvisorResult( + advisorName = "Advisor", + capabilities = listOf("vulnerabilities"), + startTime = now.toDatabasePrecision(), + endTime = now.toDatabasePrecision(), + issues = listOf( + Issue( + timestamp = now.minus(1.hours).toDatabasePrecision(), + source = "Advisor", + message = "Issue 1", + severity = Severity.ERROR, + affectedPath = "path" + ), + Issue( + timestamp = now.toDatabasePrecision(), + source = "Advisor", + message = "Issue 2", + severity = Severity.WARNING, + affectedPath = "path" + ) + ), + defects = emptyList(), + vulnerabilities = emptyList() + ) + ) + ) + ) + + val response = superuserClient.get("/api/v1/runs/${ortRun.id}/issues?limit=1") + + response.status shouldBe HttpStatusCode.OK + val pagedIssues = response.body>() + + with(pagedIssues.pagination) { + totalCount shouldBe 2 + offset shouldBe 0 + limit shouldBe 1 + + // Default sort order applied? + sortProperties.firstOrNull()?.name shouldBe "timestamp" + sortProperties.firstOrNull()?.direction shouldBe DESCENDING + } + + with(pagedIssues.data) { + shouldHaveSize(1) + with(first()) { + with(issue) { + timestamp.epochSeconds shouldBe now.epochSeconds + source shouldBe "Advisor" + message shouldBe "Issue 2" + severity shouldBe org.eclipse.apoapsis.ortserver.api.v1.model.Severity.WARNING + affectedPath shouldBe "path" + } + + with(identifier) { + this?.type shouldBe "Maven" + this?.namespace shouldBe "namespace" + this?.name shouldBe "name" + this?.version shouldBe "1.0.0" + } + } + } + } + } + } + "GET /runs/{runId}/packages" should { "show the packages found in an ORT run" { integrationTestApplication { diff --git a/dao/src/main/kotlin/tables/runs/shared/IssuesTable.kt b/dao/src/main/kotlin/tables/runs/shared/IssuesTable.kt index 7828b7dde..5cb8879a3 100644 --- a/dao/src/main/kotlin/tables/runs/shared/IssuesTable.kt +++ b/dao/src/main/kotlin/tables/runs/shared/IssuesTable.kt @@ -21,7 +21,6 @@ package org.eclipse.apoapsis.ortserver.dao.tables.runs.shared import org.eclipse.apoapsis.ortserver.dao.utils.SortableEntityClass import org.eclipse.apoapsis.ortserver.dao.utils.SortableTable -import org.eclipse.apoapsis.ortserver.dao.utils.toDatabasePrecision import org.eclipse.apoapsis.ortserver.dao.utils.transformToDatabasePrecision import org.eclipse.apoapsis.ortserver.model.Severity import org.eclipse.apoapsis.ortserver.model.runs.Issue diff --git a/dao/src/main/kotlin/utils/Extensions.kt b/dao/src/main/kotlin/utils/Extensions.kt index eeab496c2..3852827e2 100644 --- a/dao/src/main/kotlin/utils/Extensions.kt +++ b/dao/src/main/kotlin/utils/Extensions.kt @@ -30,8 +30,8 @@ import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult import org.eclipse.apoapsis.ortserver.model.util.OrderDirection import org.jetbrains.exposed.dao.LongEntity -import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.AbstractQuery +import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.SizedIterable diff --git a/model/src/commonMain/kotlin/IssueWithIdentifier.kt b/model/src/commonMain/kotlin/IssueWithIdentifier.kt new file mode 100644 index 000000000..7bade868b --- /dev/null +++ b/model/src/commonMain/kotlin/IssueWithIdentifier.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.model + +import org.eclipse.apoapsis.ortserver.model.runs.Identifier +import org.eclipse.apoapsis.ortserver.model.runs.Issue + +/** + * A union data class to associate an [Issue] with an [Identifier]. + */ +data class IssueWithIdentifier( + val issue: Issue, + val identifier: Identifier? +) diff --git a/services/hierarchy/src/main/kotlin/IssueService.kt b/services/hierarchy/src/main/kotlin/IssueService.kt new file mode 100644 index 000000000..1167e9cee --- /dev/null +++ b/services/hierarchy/src/main/kotlin/IssueService.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2024 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.services + +import kotlinx.datetime.Instant + +import org.eclipse.apoapsis.ortserver.dao.QueryParametersException +import org.eclipse.apoapsis.ortserver.dao.dbQuery +import org.eclipse.apoapsis.ortserver.dao.tables.AdvisorJobsTable +import org.eclipse.apoapsis.ortserver.dao.tables.AnalyzerJobsTable +import org.eclipse.apoapsis.ortserver.dao.tables.OrtRunsIssuesTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.advisor.AdvisorResultsIssuesTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.advisor.AdvisorResultsTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.advisor.AdvisorRunsIdentifiersTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.advisor.AdvisorRunsTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.analyzer.AnalyzerRunsIdentifiersIssuesTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.analyzer.AnalyzerRunsTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.shared.IdentifiersIssuesTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.shared.IdentifiersTable +import org.eclipse.apoapsis.ortserver.dao.tables.runs.shared.IssuesTable +import org.eclipse.apoapsis.ortserver.model.IssueWithIdentifier +import org.eclipse.apoapsis.ortserver.model.Severity +import org.eclipse.apoapsis.ortserver.model.runs.Identifier +import org.eclipse.apoapsis.ortserver.model.runs.Issue +import org.eclipse.apoapsis.ortserver.model.util.ListQueryParameters +import org.eclipse.apoapsis.ortserver.model.util.ListQueryResult +import org.eclipse.apoapsis.ortserver.model.util.OrderDirection +import org.eclipse.apoapsis.ortserver.model.util.OrderDirection.DESCENDING +import org.eclipse.apoapsis.ortserver.model.util.OrderField + +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.Query +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.alias +import org.jetbrains.exposed.sql.innerJoin +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.union + +/** + * A service to manage and get information about issues. + * + * TODO: Add a query to collect the issues during an analyzer run and add it to the combined query. + */ +class IssueService(private val db: Database) { + suspend fun listForOrtRunId( + ortRunId: Long, + parameters: ListQueryParameters = ListQueryParameters.DEFAULT + ): ListQueryResult = db.dbQuery { + val ortRunIssuesQuery = createOrtRunIssuesQuery(ortRunId) + val analyzerIssuesQuery = createAnalyzerIssuesQuery(ortRunId) + val advisorIssuesQuery = createAdvisorIssuesQuery(ortRunId) + + val combinedQuery = + ortRunIssuesQuery + .union(analyzerIssuesQuery) + .union(advisorIssuesQuery) + + val subQueryAlias = combinedQuery.alias("combined_query").selectAll() + + // There always has to be some sort order defined, else the rows would be returned in random order + // and tests that rely on a deterministic order would fail. + val sortFields = parameters.sortFields.ifEmpty { + listOf(OrderField("timestamp", DESCENDING)) + } + + // For sorting by column names of the combined query, + // we need to find the corresponding columns in the sub query. + val orders = sortFields.map { sortField -> + val column = subQueryAlias.set.fields.firstOrNull { + it.toString() == sortField.name || + it.toString() == "org.jetbrains.exposed.sql.Alias." + sortField.name + } + ?: throw QueryParametersException("Field for sorting not found in query alias: '${sortField.name}'.") + column to sortField.direction.toSortOrder() + }.toTypedArray() + + val orderedQuery = subQueryAlias.orderBy(*orders) + + val paginatedQuery = parameters.limit?.let { orderedQuery.limit(it).offset(parameters.offset ?: 0) } + ?: orderedQuery + + ListQueryResult( + paginatedQuery.map(ResultRow::toIssueWithIdentifier), + parameters, + combinedQuery.count() + ) + } + + private fun createOrtRunIssuesQuery(ortRunId: Long) = (IssuesTable innerJoin OrtRunsIssuesTable).select( + IssuesTable.timestamp, + IssuesTable.issueSource, + IssuesTable.message, + IssuesTable.severity, + IssuesTable.affectedPath, + Op.nullOp().alias("\"type\""), + Op.nullOp().alias("\"namespace\""), + Op.nullOp().alias("\"name\""), + Op.nullOp().alias("\"version\"") + ).where { OrtRunsIssuesTable.ortRunId eq ortRunId } + + private fun createAnalyzerIssuesQuery(ortRunId: Long): Query { + val analyzerIssuesIdentifiersJoin = ( + IssuesTable innerJoin + IdentifiersIssuesTable innerJoin + AnalyzerRunsIdentifiersIssuesTable innerJoin + AnalyzerRunsTable innerJoin + AnalyzerJobsTable innerJoin + IdentifiersTable + ) + + return analyzerIssuesIdentifiersJoin.select( + IssuesTable.timestamp, + IssuesTable.issueSource, + IssuesTable.message, + IssuesTable.severity, + IssuesTable.affectedPath, + IdentifiersTable.type, + IdentifiersTable.name, + IdentifiersTable.namespace, + IdentifiersTable.version + ).where { AnalyzerJobsTable.ortRunId eq ortRunId } + } + + private fun createAdvisorIssuesQuery(ortRunId: Long): Query { + val advisorIssuesIdentifiersJoin = IssuesTable + .innerJoin(AdvisorResultsIssuesTable, { IssuesTable.id }, { issueId }) + .innerJoin(AdvisorResultsTable, { AdvisorResultsIssuesTable.advisorResultId }, { AdvisorResultsTable.id }) + .innerJoin(AdvisorRunsTable, { AdvisorResultsTable.advisorRunIdentifierId }, { AdvisorRunsTable.id }) + .innerJoin(AdvisorJobsTable, { AdvisorRunsTable.advisorJobId }, { AdvisorJobsTable.id }) + .innerJoin(AdvisorRunsIdentifiersTable, { AdvisorRunsTable.id }, { advisorRunId }) + .innerJoin(IdentifiersTable, { AdvisorRunsIdentifiersTable.identifierId }, { IdentifiersTable.id }) + + return advisorIssuesIdentifiersJoin.select( + IssuesTable.timestamp, + IssuesTable.issueSource, + IssuesTable.message, + IssuesTable.severity, + IssuesTable.affectedPath, + IdentifiersTable.type, + IdentifiersTable.name, + IdentifiersTable.namespace, + IdentifiersTable.version + ).where { AdvisorJobsTable.ortRunId eq ortRunId } + } +} + +@Suppress("ComplexCondition") +private fun ResultRow.toIssueWithIdentifier(): IssueWithIdentifier { + // The exposed library seems not to fully support fields on alias queries when unions are used. + // Use the field indexes in order to extract the values from the ResultRow instead of the field names. + // Disadvantage: More fragility, losing type safety at compile time. + val columns = fieldIndex.keys.toList() + + val issue = Issue( + timestamp = this[columns[0]] as Instant, + source = this[columns[1]] as String, + message = this[columns[2]] as String, + severity = this[columns[3]] as Severity, + affectedPath = this[columns[4]] as String? + ) + + val type = this[columns[5]] as String? + val name = this[columns[6]] as String? + val namespace = this[columns[7]] as String? + val version = this[columns[8]] as String? + + return if (type == null || name == null || namespace == null || version == null) { + IssueWithIdentifier(issue, null) + } else { + IssueWithIdentifier(issue, Identifier(type, namespace, name, version)) + } +} + +/** + * Convert this [OrderDirection] constant to the corresponding [SortOrder]. + */ +fun OrderDirection.toSortOrder(): SortOrder = + when (this) { + OrderDirection.ASCENDING -> SortOrder.ASC + OrderDirection.DESCENDING -> SortOrder.DESC + } diff --git a/services/hierarchy/src/test/kotlin/IssueServiceTest.kt b/services/hierarchy/src/test/kotlin/IssueServiceTest.kt new file mode 100644 index 000000000..3c46f5eb8 --- /dev/null +++ b/services/hierarchy/src/test/kotlin/IssueServiceTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The ORT Server Authors (See ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.eclipse.apoapsis.ortserver.services + +import io.kotest.core.spec.style.WordSpec +import io.kotest.matchers.shouldBe + +import org.eclipse.apoapsis.ortserver.model.util.OrderDirection + +import org.jetbrains.exposed.sql.SortOrder + +class IssueServiceTest : WordSpec() { + + init { + "OrderDirection.toSortOrder" should { + "return SortOrder.ASC when OrderDirection is ASCENDING" { + OrderDirection.ASCENDING.toSortOrder() shouldBe SortOrder.ASC + } + + "return SortOrder.DESC when OrderDirection is DESCENDING" { + OrderDirection.DESCENDING.toSortOrder() shouldBe SortOrder.DESC + } + } + } +}