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
+ }
+ }
+}
+
+