[github] Refresh PR-on-current-branch state on push (IJPL-249925)

GitOrigin-RevId: 44c504a162a28b576bbd59e59a59de06f1571f5d
This commit is contained in:
natalia.ponomareva
2026-08-11 10:07:42 +00:00
committed by intellij-monorepo-bot
parent 4776cf09a5
commit e6ce52e589
8 changed files with 121 additions and 9 deletions
@@ -3952,7 +3952,6 @@ a:git4idea.remote.GitRepositoryHostingService
git4idea.remote.InteractiveGitHttpAuthDataProvider
- a:getAuthData(java.awt.Component):com.intellij.util.AuthData
f:git4idea.remote.hosting.GitAsyncExtensionsKt
- sf:currentRemoteBranchFlow(git4idea.repo.GitRepository):kotlinx.coroutines.flow.Flow
- sf:discoverServers(kotlinx.coroutines.flow.Flow,kotlinx.coroutines.flow.Flow,I,kotlin.jvm.functions.Function2):kotlinx.coroutines.flow.Flow
- bs:discoverServers$default(kotlinx.coroutines.flow.Flow,kotlinx.coroutines.flow.Flow,I,kotlin.jvm.functions.Function2,I,java.lang.Object):kotlinx.coroutines.flow.Flow
- sf:gitRemotesStateIn(com.intellij.openapi.project.Project,kotlinx.coroutines.CoroutineScope,kotlinx.coroutines.flow.SharingStarted):kotlinx.coroutines.flow.StateFlow
@@ -6,6 +6,8 @@ import git4idea.repo.GitRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
interface HostedGitRepositoriesManager<M : HostedGitRepositoryMapping> {
val knownRepositoriesState: StateFlow<Set<M>>
@@ -19,9 +21,24 @@ fun <M : HostedGitRepositoryMapping> HostedGitRepositoriesManager<M>.findKnownRe
it.remote.repository == repository
}
/**
* Emits the hosted repository mapping and the remote branch tracked by the current local branch (the branch whose
* pull/merge request the current branch is reviewed against), or `null` when the current branch tracks no hosted branch.
*
* Re-emits not only when the tracked branch changes, but also when its tip commit moves (e.g. after a push): review
* features rely on this to re-resolve "which PR/MR corresponds to the current branch" without requiring a branch switch.
* [GitRemoteBranch] equality ignores the commit it points to, so the tip hash is folded into the dedup key explicitly.
*/
fun <M : HostedGitRepositoryMapping> HostedGitRepositoriesManager<M>.findHostedRemoteBranchTrackedByCurrent(repository: GitRepository)
: Flow<Pair<M, GitRemoteBranch>?> =
knownRepositoriesState.combine(repository.currentRemoteBranchFlow()) { repositories, branch ->
knownRepositoriesState.combine(
repository.infoFlow()
.map { info ->
val branch = info.findFirstRemoteBranchTrackedByCurrent()
branch to branch?.let { info.remoteBranchesWithHashes[it] }
}
.distinctUntilChanged()
) { repositories, (branch, _) ->
if (branch == null) {
null
}
@@ -89,11 +89,6 @@ private fun GitRepositoryManager.collectRemotes(): Set<GitRemoteUrlCoordinates>
}.toSet()
}
fun GitRepository.currentRemoteBranchFlow(): Flow<GitRemoteBranch?> =
infoFlow()
.map { it.findFirstRemoteBranchTrackedByCurrent() }
.distinctUntilChanged()
fun GitRepoInfo.findFirstRemoteBranchTrackedByCurrent(): GitRemoteBranch? {
val currentBranch = currentBranch ?: return null
return branchTrackInfos.find { it.localBranch == currentBranch }?.remoteBranch
@@ -0,0 +1,69 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package git4idea.remote.hosting
import com.intellij.openapi.vcs.Executor.cd
import com.intellij.testFramework.common.waitUntil
import git4idea.GitRemoteBranch
import git4idea.push.GitPushOperationBaseTest
import git4idea.remote.GitRemoteUrlCoordinates
import git4idea.repo.GitRepository
import git4idea.test.git
import git4idea.test.makeCommit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.mockito.Mockito.mock
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.time.Duration.Companion.seconds
class CurrentReviewBranchFlowTest : GitPushOperationBaseTest() {
private lateinit var repository: GitRepository
private lateinit var manager: HostedGitRepositoriesManager<HostedGitRepositoryMapping>
override fun setUp() {
super.setUp()
val trinity = setupRepositories(projectPath, "parent", "bro")
repository = trinity.projectRepo
cd(projectPath)
refresh()
updateRepositories()
// A single hosted mapping for the "origin" remote of the test repository.
val origin = repository.remotes.single { it.name == "origin" }
val mapping = object : HostedGitRepositoryMapping {
override val repository: HostedRepositoryCoordinates = mock(HostedRepositoryCoordinates::class.java)
override val remote: GitRemoteUrlCoordinates = GitRemoteUrlCoordinates(origin.firstUrl!!, origin, this@CurrentReviewBranchFlowTest.repository)
}
manager = object : HostedGitRepositoriesManager<HostedGitRepositoryMapping> {
override val knownRepositoriesState = MutableStateFlow(setOf(mapping))
}
}
fun `test re-emits the review branch when the current branch tip moves on push`() = runBlocking {
val emissions = CopyOnWriteArrayList<Pair<HostedGitRepositoryMapping, GitRemoteBranch>?>()
val job = launch(Dispatchers.Default) {
manager.findHostedRemoteBranchTrackedByCurrent(repository).collect { emissions.add(it) }
}
try {
// Initial resolution: the current branch (master) tracks origin/master.
waitUntil("initial review branch is resolved", timeout = 10.seconds) { emissions.isNotEmpty() }
assertEquals("master", emissions.last()?.second?.nameForRemoteOperations)
val countBeforePush = emissions.size
// A push advances origin/master's tip while the tracked branch identity stays the same.
makeCommit("file.txt")
repository.git("push origin master")
updateRepositories()
// The moved tip must produce a fresh emission (this is what the fix restores).
waitUntil("review branch re-emitted after push", timeout = 10.seconds) { emissions.size > countBeforePush }
assertEquals("master", emissions.last()?.second?.nameForRemoteOperations)
}
finally {
job.cancel()
}
}
}
@@ -12,8 +12,10 @@ import com.intellij.collaboration.util.SingleCoroutineLauncher
import com.intellij.platform.util.coroutines.childScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.map
import org.jetbrains.plugins.github.api.GHGQLRequests
import org.jetbrains.plugins.github.api.GHRepositoryCoordinates
@@ -42,6 +44,10 @@ internal class GHPRListLoader(
private val refreshRequests = MutableSharedFlow<Unit>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val updateRequests = MutableSharedFlow<Updated<GHPullRequestShort>>(replay = 1)
private val listUpdatedRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val listUpdated: Flow<Unit> = listUpdatedRequests.asSharedFlow()
private val loader = GraphQLListLoader.startIn(
cs,
{ it.id },
@@ -76,12 +82,14 @@ internal class GHPRListLoader(
fun refresh() {
refreshRequests.tryEmit(Unit)
listUpdatedRequests.tryEmit(Unit)
}
fun reload() {
cs.launchNow {
reloadRequests.emit(Unit)
}
listUpdatedRequests.tryEmit(Unit)
}
companion object {
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jetbrains.annotations.ApiStatus
@@ -118,9 +119,13 @@ abstract class GHPRConnectedProjectViewModelBase(
private val prOnCurrentBranchRefreshSignal =
MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
// Re-run the lookup on an explicit refresh and whenever the PR list is reloaded/refreshed, so a PR opened outside
// the IDE (or otherwise missed by the initial lookup) is picked up on the next refresh without switching branches.
private val prOnCurrentBranchRefresh = merge(prOnCurrentBranchRefreshSignal, dataContext.listLoader.listUpdated)
override val prOnCurrentBranch: StateFlow<ComputedResult<GHPRIdentifier?>?> =
repoManager.findHostedRemoteBranchTrackedByCurrent(connection.repo.gitRepository)
.combineTransform(prOnCurrentBranchRefreshSignal.withInitial(Unit)) { projectAndBranch, _ ->
.combineTransform(prOnCurrentBranchRefresh.withInitial(Unit)) { projectAndBranch, _ ->
if (projectAndBranch == null) {
emit(ComputedResult.success(null))
}
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import org.jetbrains.annotations.ApiStatus
@@ -126,9 +127,13 @@ abstract class GitLabConnectedProjectViewModelBase(
protected val mergeRequestCreatedSignal: MutableSharedFlow<Unit> = MutableSharedFlow()
// Re-run the lookup on MR creation and whenever the MR list is reloaded/refreshed, so an MR opened outside the IDE
// (or otherwise missed by the initial lookup) is picked up on the next refresh without switching branches.
private val mergeRequestOnCurrentBranchRefresh = merge(mergeRequestCreatedSignal, listVm.listUpdated)
private val mergeRequestOnCurrentBranch: Flow<String?> =
projectsManager.findHostedRemoteBranchTrackedByCurrent(connection.repo.gitRepository)
.combine(mergeRequestCreatedSignal.withInitial(Unit)) { repoAndBranch, _ ->
.combine(mergeRequestOnCurrentBranchRefresh.withInitial(Unit)) { repoAndBranch, _ ->
val (targetRepo, branch) = repoAndBranch ?: return@combine null
try {
findOpenReviewIdByBranch(connection, branch.nameForRemoteOperations, targetRepo.repository.projectPath.fullPath())
@@ -12,8 +12,11 @@ import com.intellij.openapi.diagnostic.Logger
import com.intellij.platform.util.coroutines.childScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
@@ -40,6 +43,12 @@ interface GitLabMergeRequestsListViewModel : ReviewListViewModel {
val loading: Flow<Boolean>
val error: Flow<Throwable?>
/**
* Emits whenever the list is reloaded or refreshed. Consumers can use this to re-run branch-scoped lookups
* (e.g. "which MR corresponds to the current branch") when the user refreshes the merge request list.
*/
val listUpdated: Flow<Unit>
fun requestMore()
}
@@ -56,6 +65,9 @@ internal class GitLabMergeRequestsListViewModelImpl(
private val scope = parentCs.childScope("GL MR List VM")
private val requestMoreLauncher = SingleCoroutineLauncher(scope.childScope("Request More"))
private val listUpdatedSignal = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val listUpdated: Flow<Unit> = listUpdatedSignal.asSharedFlow()
private val loaderFlow: Flow<ReloadablePotentiallyInfiniteListLoader<GitLabMergeRequestShortRestDTO>> =
filterVm.searchState
.combine(tokenRefreshFlow.withInitial(Unit)) { search, _ -> search }
@@ -75,12 +87,14 @@ internal class GitLabMergeRequestsListViewModelImpl(
}
override fun refresh() {
listUpdatedSignal.tryEmit(Unit)
scope.launch {
loaderFlow.first().refresh()
}
}
override fun reload() {
listUpdatedSignal.tryEmit(Unit)
scope.launch {
loaderFlow.first().reload()
}