diff --git a/plugins/gitlab/gitlab-core/resources/graphql/query/getMergeRequestMetrics.graphql b/plugins/gitlab/gitlab-core/resources/graphql/query/getMergeRequestMetrics.graphql new file mode 100644 index 000000000000..b7dd36bb0d0f --- /dev/null +++ b/plugins/gitlab/gitlab-core/resources/graphql/query/getMergeRequestMetrics.graphql @@ -0,0 +1,23 @@ +query($projectId: ID!, $username: String!) { + project(fullPath: $projectId) { + allMRCount: mergeRequests(state: all) { + count + } + + openMRCount: mergeRequests(state: opened) { + count + } + + openAssignedMRCount: mergeRequests(state: opened, assigneeUsername: $username) { + count + } + + openAuthoredMRCount: mergeRequests(state: opened, authorUsername: $username) { + count + } + + openReviewAssignedMRCount: mergeRequests(state: opened, reviewerUsername: $username) { + count + } + } +} \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/resources/graphql/query/getProjectIsForked.graphql b/plugins/gitlab/gitlab-core/resources/graphql/query/getProjectIsForked.graphql new file mode 100644 index 000000000000..6918ae9f0688 --- /dev/null +++ b/plugins/gitlab/gitlab-core/resources/graphql/query/getProjectIsForked.graphql @@ -0,0 +1,5 @@ +query($projectId: ID!) { + project(fullPath: $projectId) { + isForked + } +} \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/resources/intellij.vcs.gitlab.xml b/plugins/gitlab/gitlab-core/resources/intellij.vcs.gitlab.xml index 450807902923..35ef9e5729e4 100644 --- a/plugins/gitlab/gitlab-core/resources/intellij.vcs.gitlab.xml +++ b/plugins/gitlab/gitlab-core/resources/intellij.vcs.gitlab.xml @@ -70,12 +70,10 @@ - - - + + + + diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/GitLabGQLQuery.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/GitLabGQLQuery.kt index 62b4774fcf93..7a0f61ac8600 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/GitLabGQLQuery.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/GitLabGQLQuery.kt @@ -9,6 +9,8 @@ enum class GitLabGQLQuery(val filePath: String) { GET_CURRENT_USER("graphql/query/getCurrentUser.graphql"), @SinceGitLab("12.0") GET_MERGE_REQUEST("graphql/query/getMergeRequest.graphql"), + @SinceGitLab("14.0", note = "No exact version") + GET_MERGE_REQUEST_METRICS("graphql/query/getMergeRequestMetrics.graphql"), @SinceGitLab("13.1") FIND_MERGE_REQUESTS("graphql/query/findProjectMergeRequests.graphql"), @SinceGitLab("14.7") @@ -21,6 +23,8 @@ enum class GitLabGQLQuery(val filePath: String) { GET_PROJECT_LABELS("graphql/query/getProjectLabels.graphql"), @SinceGitLab("15.2") GET_PROJECT_WORK_ITEMS("graphql/query/getProjectWidgets.graphql"), + @SinceGitLab("16.9") + GET_PROJECT_IS_FORKED("graphql/query/getProjectIsForked.graphql"), @SinceGitLab("13.0") GET_MEMBER_PROJECTS_FOR_SNIPPETS("graphql/query/getMemberProjectsForSnippets.graphql"), @SinceGitLab("13.0") diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/dto/GitLabProjectIsForkedDTO.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/dto/GitLabProjectIsForkedDTO.kt new file mode 100644 index 000000000000..b3be4ad7d188 --- /dev/null +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/dto/GitLabProjectIsForkedDTO.kt @@ -0,0 +1,13 @@ +// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.plugins.gitlab.api.dto + +import com.fasterxml.jackson.annotation.JsonIgnore +import org.jetbrains.plugins.gitlab.api.SinceGitLab + +@SinceGitLab("12.0") +data class GitLabProjectIsForkedDTO( + private val forkedFromProject: Unit? +) { + @JsonIgnore + val isForked: Boolean = forkedFromProject != null +} \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/request/GitLabProjectApi.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/request/GitLabProjectApi.kt index 57ffa45cc4ca..fab7f755a867 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/request/GitLabProjectApi.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/api/request/GitLabProjectApi.kt @@ -30,6 +30,34 @@ suspend fun GitLabApi.GraphQL.findProject(project: GitLabProjectCoordinates): Ht } } +@SinceGitLab("16.9") +private suspend fun GitLabApi.GraphQL.isProjectForked(project: GitLabProjectCoordinates): HttpResponse { + val parameters = mapOf( + "projectId" to project.projectPath.fullPath(), + ) + val request = gitLabQuery(GitLabGQLQuery.GET_PROJECT_IS_FORKED, parameters) + return withErrorStats(GitLabGQLQuery.GET_PROJECT_IS_FORKED) { + loadResponse(request, "project", "isForked") + } +} + +@SinceGitLab("12.0") +private suspend fun GitLabApi.Rest.isProjectForked(project: GitLabProjectCoordinates): HttpResponse { + val uri = project.restApiUri + val request = request(uri).GET().build() + return withErrorStats(GitLabApiRequestName.REST_GET_PROJECT_IS_FORKED) { + loadJsonValue(request) + } +} + +suspend fun GitLabApi.isProjectForked(project: GitLabProjectCoordinates): Boolean = + if (getMetadata().version < GitLabVersion(16, 9)) { + rest.isProjectForked(project).body().isForked + } + else { + graphQL.isProjectForked(project).body() + } + @SinceGitLab("13.1", note = "No exact version") fun GitLabApi.GraphQL.createAllProjectLabelsFlow(project: GitLabProjectCoordinates): Flow> = ApiPageUtil.createGQLPagesFlow { page -> diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/api/dto/GitLabMergeRequestMetricsDTO.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/api/dto/GitLabMergeRequestMetricsDTO.kt new file mode 100644 index 000000000000..b7937dd142a6 --- /dev/null +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/api/dto/GitLabMergeRequestMetricsDTO.kt @@ -0,0 +1,15 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.plugins.gitlab.mergerequest.api.dto + +import com.intellij.collaboration.api.dto.GraphQLFragment + +@GraphQLFragment("/graphql/query/getMergeRequestMetrics.graphql") +data class GitLabMergeRequestMetricsDTO( + val allMRCount: Count, + val openMRCount: Count, + val openAssignedMRCount: Count, + val openAuthoredMRCount: Count, + val openReviewAssignedMRCount: Count +) { + data class Count(val count: Int) +} \ No newline at end of file diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/api/request/GitLabMergeRequestsApi.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/api/request/GitLabMergeRequestsApi.kt index f379241bbcbd..c5f63731dbd8 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/api/request/GitLabMergeRequestsApi.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/mergerequest/api/request/GitLabMergeRequestsApi.kt @@ -7,12 +7,14 @@ import com.intellij.collaboration.api.graphql.loadResponse import com.intellij.collaboration.api.json.loadJsonValue import com.intellij.collaboration.util.resolveRelative import com.intellij.collaboration.util.withQuery +import org.jetbrains.annotations.ApiStatus import org.jetbrains.plugins.gitlab.api.* import org.jetbrains.plugins.gitlab.api.dto.GitLabGraphQLMutationResultDTO import org.jetbrains.plugins.gitlab.api.dto.GitLabReviewerDTO import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO import org.jetbrains.plugins.gitlab.mergerequest.api.dto.GitLabMergeRequestByBranchDTO import org.jetbrains.plugins.gitlab.mergerequest.api.dto.GitLabMergeRequestDTO +import org.jetbrains.plugins.gitlab.mergerequest.api.dto.GitLabMergeRequestMetricsDTO import org.jetbrains.plugins.gitlab.mergerequest.api.dto.GitLabMergeRequestRebaseDTO import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabMergeRequestState import org.jetbrains.plugins.gitlab.mergerequest.data.asApiParameter @@ -41,6 +43,22 @@ suspend fun GitLabApi.GraphQL.loadMergeRequest( } } +@ApiStatus.Internal +@SinceGitLab("14.0", note = "No exact version") +suspend fun GitLabApi.GraphQL.getMergeRequestMetrics( + project: GitLabProjectCoordinates, + username: String, +): HttpResponse { + val parameters = mapOf( + "projectId" to project.projectPath.fullPath(), + "username" to username + ) + val request = gitLabQuery(GitLabGQLQuery.GET_MERGE_REQUEST_METRICS, parameters) + return withErrorStats(GitLabGQLQuery.GET_MERGE_REQUEST_METRICS) { + loadResponse(request, "project") + } +} + @SinceGitLab("13.1") suspend fun GitLabApi.GraphQL.findMergeRequestsByBranch( project: GitLabProjectCoordinates, diff --git a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/util/gitLabStatistics.kt b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/util/gitLabStatistics.kt index 21b10ff35210..abcd7b2f4d6e 100644 --- a/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/util/gitLabStatistics.kt +++ b/plugins/gitlab/gitlab-core/src/org/jetbrains/plugins/gitlab/util/gitLabStatistics.kt @@ -8,19 +8,29 @@ import com.intellij.internal.statistic.eventLog.events.EventId2 import com.intellij.internal.statistic.eventLog.events.EventPair import com.intellij.internal.statistic.service.fus.collectors.ApplicationUsagesCollector import com.intellij.internal.statistic.service.fus.collectors.CounterUsagesCollector +import com.intellij.internal.statistic.service.fus.collectors.ProjectUsagesCollector +import com.intellij.internal.statistic.utils.StatisticsUtil.roundToPowerOfTwo +import com.intellij.openapi.components.Service import com.intellij.openapi.components.service +import com.intellij.openapi.components.serviceAsync +import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus -import org.jetbrains.plugins.gitlab.api.GitLabEdition -import org.jetbrains.plugins.gitlab.api.GitLabGQLQuery -import org.jetbrains.plugins.gitlab.api.GitLabServerMetadata -import org.jetbrains.plugins.gitlab.api.GitLabVersion +import org.jetbrains.plugins.gitlab.GitLabProjectsManager +import org.jetbrains.plugins.gitlab.api.* +import org.jetbrains.plugins.gitlab.api.request.isProjectForked +import org.jetbrains.plugins.gitlab.authentication.accounts.GitLabAccount import org.jetbrains.plugins.gitlab.authentication.accounts.GitLabAccountManager +import org.jetbrains.plugins.gitlab.authentication.accounts.GitLabProjectDefaultAccountHolder +import org.jetbrains.plugins.gitlab.mergerequest.api.dto.GitLabMergeRequestMetricsDTO +import org.jetbrains.plugins.gitlab.mergerequest.api.request.getMergeRequestMetrics import org.jetbrains.plugins.gitlab.mergerequest.ui.filters.GitLabMergeRequestsFiltersValue @ApiStatus.Internal object GitLabStatistics { - //region State + //region Application private val STATE_GROUP = EventLogGroup("vcs.gitlab", 1) private val ACCOUNTS_EVENT: EventId2 = @@ -38,8 +48,62 @@ object GitLabStatistics { } //endregion + //region Project + private val PROJECT_METRICS_GROUP = EventLogGroup( + "vcs.gitlab.project", 1, + recorder = "FUS", + description = "Collects metrics about GitLab merge requests in a project." + ) + + private val PROJECT_METRICS_MR_STATISTICS_ALL = PROJECT_METRICS_GROUP.registerEvent( + "mr.statistics.all", + EventFields.Int("value", description = "Total number of MRs in project (rounded up to the first power of 2)."), + description = "#MR statistics: open." + ) + private val PROJECT_METRICS_MR_STATISTICS_OPEN = PROJECT_METRICS_GROUP.registerEvent( + "mr.statistics.open", + EventFields.Int("value", description = "Total number of open MRs in project (rounded up to the first power of 2)."), + description = "#MR statistics: open." + ) + private val PROJECT_METRICS_MR_STATISTICS_OPEN_AUTHOR = PROJECT_METRICS_GROUP.registerEvent( + "mr.statistics.open.author", + EventFields.Int("value", + description = "Total number of open MRs in project authored by the current user (rounded up to the first power of 2)."), + description = "#MR statistics: open > author." + ) + private val PROJECT_METRICS_MR_STATISTICS_OPEN_ASSIGNEE = PROJECT_METRICS_GROUP.registerEvent( + "mr.statistics.open.assignee", + EventFields.Int("value", + description = "Total number of open MRs in project assigned to the current user (rounded up to the first power of 2)."), + description = "#MR statistics: open > assignee." + ) + private val PROJECT_METRICS_MR_STATISTICS_OPEN_REVIEW_ASSIGNED = PROJECT_METRICS_GROUP.registerEvent( + "mr.statistics.open.reviewer", + EventFields.Int("value", + description = "Total number of open MRs in project assigned to the current user as reviewer (rounded up to the first power of 2)."), + description = "#MR statistics: open > reviewer." + ) + + internal class GitLabProjectMetricsCollector : ProjectUsagesCollector() { + override fun getGroup(): EventLogGroup = PROJECT_METRICS_GROUP + + override suspend fun collect(project: Project): Set { + val metricsLoader = project.serviceAsync() + val metrics = metricsLoader.getMetrics() ?: return emptySet() + + return setOfNotNull( + PROJECT_METRICS_MR_STATISTICS_ALL.metric(roundToPowerOfTwo(metrics.allMRCount.count)), + PROJECT_METRICS_MR_STATISTICS_OPEN.metric(roundToPowerOfTwo(metrics.openMRCount.count)), + PROJECT_METRICS_MR_STATISTICS_OPEN_AUTHOR.metric(roundToPowerOfTwo(metrics.openAuthoredMRCount.count)), + PROJECT_METRICS_MR_STATISTICS_OPEN_ASSIGNEE.metric(roundToPowerOfTwo(metrics.openAssignedMRCount.count)), + PROJECT_METRICS_MR_STATISTICS_OPEN_REVIEW_ASSIGNED.metric(roundToPowerOfTwo(metrics.openReviewAssignedMRCount.count)), + ) + } + } + //endregion + //region Counters - private val COUNTERS_GROUP = EventLogGroup("vcs.gitlab.counters", version = 23) + private val COUNTERS_GROUP = EventLogGroup("vcs.gitlab.counters", version = 24) /** * Server metadata was fetched @@ -263,6 +327,7 @@ object GitLabStatistics { enum class GitLabApiRequestName { REST_GET_CURRENT_USER, + REST_GET_PROJECT_IS_FORKED, REST_GET_PROJECT_NAMESPACE, REST_GET_PROJECT_USERS, REST_GET_COMMIT, @@ -289,12 +354,13 @@ enum class GitLabApiRequestName { GQL_GET_CURRENT_USER, GQL_GET_MERGE_REQUEST, GQL_FIND_MERGE_REQUEST, + GQL_GET_MERGE_REQUEST_METRICS, GQL_GET_MERGE_REQUEST_COMMITS, GQL_GET_MERGE_REQUEST_DISCUSSIONS, GQL_GET_PROJECT, GQL_GET_PROJECT_LABELS, - GQL_GET_PROJECT_REPOSITORY, GQL_GET_PROJECT_WORK_ITEMS, + GQL_GET_PROJECT_IS_FORKED, GQL_GET_MEMBER_PROJECTS_FOR_CLONE, GQL_GET_MEMBER_PROJECTS_FOR_SNIPPETS, GQL_TOGGLE_MERGE_REQUEST_DISCUSSION_RESOLVE, @@ -319,11 +385,13 @@ enum class GitLabApiRequestName { GitLabGQLQuery.GET_CURRENT_USER -> GQL_GET_CURRENT_USER GitLabGQLQuery.GET_MERGE_REQUEST -> GQL_GET_MERGE_REQUEST GitLabGQLQuery.FIND_MERGE_REQUESTS -> GQL_FIND_MERGE_REQUEST + GitLabGQLQuery.GET_MERGE_REQUEST_METRICS -> GQL_GET_MERGE_REQUEST_METRICS GitLabGQLQuery.GET_MERGE_REQUEST_COMMITS -> GQL_GET_MERGE_REQUEST_COMMITS GitLabGQLQuery.GET_MERGE_REQUEST_DISCUSSIONS -> GQL_GET_MERGE_REQUEST_DISCUSSIONS GitLabGQLQuery.GET_PROJECT -> GQL_GET_PROJECT GitLabGQLQuery.GET_PROJECT_LABELS -> GQL_GET_PROJECT_LABELS GitLabGQLQuery.GET_PROJECT_WORK_ITEMS -> GQL_GET_PROJECT_WORK_ITEMS + GitLabGQLQuery.GET_PROJECT_IS_FORKED -> GQL_GET_PROJECT_IS_FORKED GitLabGQLQuery.GET_MEMBER_PROJECTS_FOR_CLONE -> GQL_GET_MEMBER_PROJECTS_FOR_CLONE GitLabGQLQuery.GET_MEMBER_PROJECTS_FOR_SNIPPETS -> GQL_GET_MEMBER_PROJECTS_FOR_SNIPPETS GitLabGQLQuery.TOGGLE_MERGE_REQUEST_DISCUSSION_RESOLVE -> GQL_TOGGLE_MERGE_REQUEST_DISCUSSION_RESOLVE @@ -345,3 +413,56 @@ enum class GitLabApiRequestName { } } +@Service(Service.Level.PROJECT) +internal class GitLabMetricsLoader(private val project: Project) { + companion object { + private val LOG = logger() + } + + private suspend fun getApi(serverPath: GitLabServerPath): Pair? { + val accountManager = serviceAsync() + val defaultAccountManager = project.serviceAsync() + + val account = defaultAccountManager.account.takeIf { it?.server == serverPath } + ?: accountManager.accountsState.value.firstOrNull { it.server == serverPath } + ?: return null + val token = accountManager.findCredentials(account) ?: return null + return service().getClient(serverPath, token) to account + } + + private suspend fun chooseRepo(): GitLabProjectMapping? { + val repositoriesManager = project.serviceAsync() + val repositoriesState = repositoriesManager.knownRepositoriesState + + val knownRepos = repositoriesState.value + + if (knownRepos.isEmpty()) return null + if (knownRepos.size == 1) return knownRepos.single() + + val nonFork = withContext(Dispatchers.IO) { + knownRepos.firstOrNull { repo -> + val (api, _) = getApi(repo.repository.serverPath) ?: return@firstOrNull false + !api.isProjectForked(repo.repository) + } + } + if (nonFork != null) return nonFork + + return knownRepos.find { it.gitRemote.name == "origin" } + ?: knownRepos.firstOrNull() + } + + suspend fun getMetrics(): GitLabMergeRequestMetricsDTO? { + try { + val chosenRepoMapping = chooseRepo() ?: return null + + val (api, account) = getApi(chosenRepoMapping.repository.serverPath) ?: return null + return api.graphQL.getMergeRequestMetrics(chosenRepoMapping.repository, account.name).body() + } + catch (e: Exception) { + LOG.warn("Failed to load metrics", e) + return null + } + } +} + +