[gitlab] Add statistics for number of MRs in the project (IJPL-185734)

#IJPL-185734 Fixed

GitOrigin-RevId: 7613768d3b1288ef1be2cc0648bb8daa6e223e29
This commit is contained in:
Chris Lemaire
2025-05-19 14:19:36 +00:00
committed by intellij-monorepo-bot
parent fb4ad9f7f6
commit 6b96ffc3c3
9 changed files with 238 additions and 13 deletions
@@ -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
}
}
}
@@ -0,0 +1,5 @@
query($projectId: ID!) {
project(fullPath: $projectId) {
isForked
}
}
@@ -70,12 +70,10 @@
</extensions>
<extensions defaultExtensionNs="com.intellij.statistics">
<applicationUsagesCollector
implementation="org.jetbrains.plugins.gitlab.util.GitLabStatistics$GitLabAccountsStatisticsCollector"/>
<counterUsagesCollector
implementationClass="org.jetbrains.plugins.gitlab.util.GitLabStatistics$GitLabCountersCollector"/>
<notificationIdsHolder
implementation="org.jetbrains.plugins.gitlab.notification.GitLabNotificationIdsHolder"/>
<applicationUsagesCollector implementation="org.jetbrains.plugins.gitlab.util.GitLabStatistics$GitLabAccountsStatisticsCollector"/>
<counterUsagesCollector implementationClass="org.jetbrains.plugins.gitlab.util.GitLabStatistics$GitLabCountersCollector"/>
<projectUsagesCollector implementation="org.jetbrains.plugins.gitlab.util.GitLabStatistics$GitLabProjectMetricsCollector"/>
<notificationIdsHolder implementation="org.jetbrains.plugins.gitlab.notification.GitLabNotificationIdsHolder"/>
<actionCustomPlaceAllowlist id="GitLabActionCustomPlaceAllowlist"
places="GitLabMergeRequestListPopup;GitLabMergeRequestChangesTreePopup;GitLabMergeRequestDetailsPopup"/>
</extensions>
@@ -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")
@@ -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
}
@@ -30,6 +30,34 @@ suspend fun GitLabApi.GraphQL.findProject(project: GitLabProjectCoordinates): Ht
}
}
@SinceGitLab("16.9")
private suspend fun GitLabApi.GraphQL.isProjectForked(project: GitLabProjectCoordinates): HttpResponse<out Boolean> {
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<Boolean>(request, "project", "isForked")
}
}
@SinceGitLab("12.0")
private suspend fun GitLabApi.Rest.isProjectForked(project: GitLabProjectCoordinates): HttpResponse<out GitLabProjectIsForkedDTO> {
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<List<GitLabLabelDTO>> =
ApiPageUtil.createGQLPagesFlow { page ->
@@ -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)
}
@@ -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<out GitLabMergeRequestMetricsDTO?> {
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,
@@ -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<Int, Boolean> =
@@ -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<MetricEvent> {
val metricsLoader = project.serviceAsync<GitLabMetricsLoader>()
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<GitLabMetricsLoader>()
}
private suspend fun getApi(serverPath: GitLabServerPath): Pair<GitLabApi, GitLabAccount>? {
val accountManager = serviceAsync<GitLabAccountManager>()
val defaultAccountManager = project.serviceAsync<GitLabProjectDefaultAccountHolder>()
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<GitLabApiManager>().getClient(serverPath, token) to account
}
private suspend fun chooseRepo(): GitLabProjectMapping? {
val repositoriesManager = project.serviceAsync<GitLabProjectsManager>()
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
}
}
}