[gitlab] ability to delete discussion notes

GitOrigin-RevId: c9f5a0351c9552742d0f7ded493cfb5bba1dd3e8
This commit is contained in:
Ivan Semenov
2023-01-17 12:52:46 +01:00
committed by intellij-monorepo-bot
parent f962821f0d
commit 2bea8cb7e5
18 changed files with 252 additions and 32 deletions

View File

@@ -25,5 +25,6 @@ fragment note on Note {
resolved
userPermissions {
resolveNote
adminNote
}
}

View File

@@ -0,0 +1,5 @@
mutation($noteId: NoteID!) {
destroyNote(input: {id: $noteId}) {
errors
}
}

View File

@@ -11,6 +11,7 @@ import com.intellij.collaboration.api.json.JsonHttpApiHelper
import com.intellij.collaboration.api.json.loadJsonList
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.io.HttpSecurityUtil
import org.jetbrains.plugins.gitlab.api.dto.GitLabGraphQLMutationResultDTO
import java.net.http.HttpResponse
class GitLabApi private constructor(httpHelper: HttpApiHelper)
@@ -45,4 +46,13 @@ class GitLabApi private constructor(httpHelper: HttpApiHelper)
requestConfigurer = requestConfigurer)
}
}
}
@Throws(GitLabGraphQLMutationException::class)
fun <R : GitLabGraphQLMutationResultDTO> HttpResponse<out R?>.getResultOrThrow(): R {
val result = body()
if (result == null) throw GitLabGraphQLMutationEmptyResultException()
val errors = result.errors
if (errors != null) throw GitLabGraphQLMutationErrorException(errors)
return result
}

View File

@@ -7,4 +7,5 @@ object GitLabGQLQueries {
const val getProjectLabels = "graphql/query/getProjectLabels.graphql"
const val getMergeRequestDiscussions = "graphql/query/getMergeRequestDiscussions.graphql"
const val toggleMergeRequestDiscussionResolve = "graphql/query/toggleMergeRequestDiscussionResolve.graphql"
const val destroyNote = "graphql/query/destroyNote.graphql"
}

View File

@@ -0,0 +1,5 @@
// Copyright 2000-2023 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
class GitLabGraphQLMutationEmptyResultException
: GitLabGraphQLMutationException("Mutation returned empty result")

View File

@@ -0,0 +1,5 @@
// Copyright 2000-2023 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
class GitLabGraphQLMutationErrorException(val errors: List<String>)
: GitLabGraphQLMutationException("Mutation execution returned errors: $errors")

View File

@@ -0,0 +1,4 @@
// Copyright 2000-2023 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
abstract class GitLabGraphQLMutationException(message: String?) : Exception(message)

View File

@@ -0,0 +1,6 @@
// Copyright 2000-2023 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
open class GitLabGraphQLMutationResultDTO(
val errors: List<String>?
)

View File

@@ -34,6 +34,7 @@ data class GitLabNoteDTO(
)
data class UserPermissions(
val resolveNote: Boolean
val resolveNote: Boolean,
val adminNote: Boolean
)
}

View File

@@ -10,6 +10,7 @@ import org.jetbrains.plugins.gitlab.api.GitLabApi
import org.jetbrains.plugins.gitlab.api.GitLabGQLQueries
import org.jetbrains.plugins.gitlab.api.GitLabProjectCoordinates
import org.jetbrains.plugins.gitlab.api.dto.GitLabDiscussionDTO
import org.jetbrains.plugins.gitlab.api.dto.GitLabGraphQLMutationResultDTO
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabMergeRequestId
import java.net.http.HttpResponse
@@ -39,4 +40,15 @@ suspend fun GitLabApi.changeMergeRequestDiscussionResolve(
)
val request = gqlQuery(project.serverPath.gqlApiUri, GitLabGQLQueries.toggleMergeRequestDiscussionResolve, parameters)
return loadGQLResponse(request, GitLabDiscussionDTO::class.java, "discussionToggleResolve", "discussion")
}
suspend fun GitLabApi.deleteNote(
project: GitLabProjectCoordinates,
noteId: String
): HttpResponse<out GitLabGraphQLMutationResultDTO?> {
val parameters = mapOf(
"noteId" to noteId
)
val request = gqlQuery(project.serverPath.gqlApiUri, GitLabGQLQueries.destroyNote, parameters)
return loadGQLResponse(request, GitLabGraphQLMutationResultDTO::class.java, "destroyNote")
}

View File

@@ -3,19 +3,19 @@ package org.jetbrains.plugins.gitlab.mergerequest.data
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.childScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.jetbrains.plugins.gitlab.api.GitLabProjectConnection
import org.jetbrains.plugins.gitlab.api.dto.GitLabDiscussionDTO
import org.jetbrains.plugins.gitlab.api.dto.GitLabNoteDTO
import org.jetbrains.plugins.gitlab.mergerequest.api.request.changeMergeRequestDiscussionResolve
import java.util.*
interface GitLabDiscussion {
val id: String
val createdAt: Date
val notes: Flow<List<GitLabNote>>
@@ -25,15 +25,18 @@ interface GitLabDiscussion {
suspend fun changeResolvedState()
}
@OptIn(ExperimentalCoroutinesApi::class)
class LoadedGitLabDiscussion(
parentCs: CoroutineScope,
private val connection: GitLabProjectConnection,
private val eventSink: suspend (GitLabDiscussionEvent) -> Unit,
private val discussion: GitLabDiscussionDTO
) : GitLabDiscussion {
init {
require(discussion.notes.isNotEmpty()) { "Discussion with empty notes" }
}
override val id: String = discussion.id
override val createdAt: Date = discussion.createdAt
private val cs = parentCs.childScope(CoroutineExceptionHandler { _, e ->
@@ -41,10 +44,38 @@ class LoadedGitLabDiscussion(
})
private val operationsGuard = Mutex()
private val events = MutableSharedFlow<GitLabNoteEvent>()
private val loadedNotes = MutableStateFlow(discussion.notes)
override val notes: Flow<List<GitLabNote>> = loadedNotes.map { notes ->
notes.map { LoadedGitLabNote(it) }
private val loadedNotes = MutableSharedFlow<List<GitLabNoteDTO>>(1).apply {
tryEmit(discussion.notes)
}
override val notes: Flow<List<GitLabNote>> = loadedNotes.transformLatest<List<GitLabNoteDTO>, List<GitLabNote>> { loadedNotes ->
coroutineScope {
val notesCs = this
val notes = Collections.synchronizedMap(LinkedHashMap<String, LoadedGitLabNote>())
loadedNotes.associateByTo(notes, GitLabNoteDTO::id) { noteData ->
LoadedGitLabNote(notesCs, connection, { events.emit(it) }, noteData)
}
emit(notes.values.toList())
events.collectLatest {
when (it) {
is GitLabNoteEvent.NoteDeleted -> {
notes.remove(it.noteId)?.destroy()
}
}
emit(notes.values.toList())
}
awaitCancellation()
}
}.transform {
if (it.isEmpty()) {
eventSink(GitLabDiscussionEvent.DiscussionDeleted(id))
currentCoroutineContext().cancel()
}
emit(it)
}.shareIn(cs, SharingStarted.Lazily, 1)
private val firstNote = loadedNotes.map { it.first() }
@@ -61,8 +92,16 @@ class LoadedGitLabDiscussion(
connection.apiClient
.changeMergeRequestDiscussionResolve(connection.repo.repository, discussion.id, !resolved).body()!!
}
loadedNotes.value = result.notes
updateNotes(result.notes)
}
}
}
private suspend fun updateNotes(notes: List<GitLabNoteDTO>) {
loadedNotes.emit(notes)
}
suspend fun destroy() {
cs.coroutineContext[Job]!!.cancelAndJoin()
}
}

View File

@@ -0,0 +1,6 @@
// Copyright 2000-2023 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.data
sealed interface GitLabDiscussionEvent {
class DiscussionDeleted(val discussionId: String) : GitLabDiscussionEvent
}

View File

@@ -3,20 +3,21 @@ package org.jetbrains.plugins.gitlab.mergerequest.data
import com.intellij.collaboration.api.page.ApiPageUtil
import com.intellij.collaboration.api.page.foldToList
import com.intellij.collaboration.async.mapScoped
import com.intellij.util.childScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.jetbrains.plugins.gitlab.api.GitLabProjectConnection
import org.jetbrains.plugins.gitlab.api.dto.GitLabDiscussionDTO
import org.jetbrains.plugins.gitlab.mergerequest.api.request.loadMergeRequestDiscussions
import java.util.concurrent.ConcurrentHashMap
//TODO: granualar collection changes notifications
interface GitLabMergeRequestDiscussionsModel {
val userDiscussions: Flow<List<GitLabDiscussion>>
val systemDiscussions: Flow<List<GitLabDiscussionDTO>>
val userDiscussions: Flow<Collection<GitLabDiscussion>>
val systemDiscussions: Flow<Collection<GitLabDiscussionDTO>>
}
@OptIn(ExperimentalCoroutinesApi::class)
class GitLabMergeRequestDiscussionsModelImpl(
parentCs: CoroutineScope,
private val connection: GitLabProjectConnection,
@@ -24,25 +25,43 @@ class GitLabMergeRequestDiscussionsModelImpl(
) : GitLabMergeRequestDiscussionsModel {
private val cs = parentCs.childScope(Dispatchers.Default)
private val events = MutableSharedFlow<GitLabDiscussionEvent>(extraBufferCapacity = 64)
private val nonEmptyDiscussionsData = flow {
emit(loadNonEmptyDiscussions())
}.shareIn(cs, SharingStarted.Lazily, 1)
override val userDiscussions: Flow<Collection<GitLabDiscussion>> = nonEmptyDiscussionsData.transformLatest { loadedDiscussions ->
coroutineScope {
val discussionsCs = this
val discussions = ConcurrentHashMap<String, LoadedGitLabDiscussion>()
loadedDiscussions.associateByTo(discussions, GitLabDiscussionDTO::id) { noteData ->
LoadedGitLabDiscussion(discussionsCs, connection, { events.emit(it) }, noteData)
}
emit(discussions.values.toList())
events.collectLatest {
when (it) {
is GitLabDiscussionEvent.DiscussionDeleted -> {
discussions.remove(it.discussionId)?.destroy()
}
}
emit(discussions.values.toList())
}
awaitCancellation()
}
}.shareIn(cs, SharingStarted.Lazily, 1)
override val systemDiscussions: Flow<List<GitLabDiscussionDTO>> =
nonEmptyDiscussionsData.map { discussions ->
discussions.filter { it.notes.first().system }
}.shareIn(cs, SharingStarted.Lazily, 1)
private suspend fun loadNonEmptyDiscussions(): List<GitLabDiscussionDTO> =
ApiPageUtil.createGQLPagesFlow {
connection.apiClient.loadMergeRequestDiscussions(connection.repo.repository, mr, it)
}.map { discussions ->
discussions.filter { it.notes.isNotEmpty() }
}.foldToList()
.let { emit(it) }
}.shareIn(cs, SharingStarted.Lazily, 1)
override val userDiscussions: Flow<List<GitLabDiscussion>> =
nonEmptyDiscussionsData.mapScoped { discussions ->
val cs = this
discussions.filter { !it.notes.first().system }
.map { LoadedGitLabDiscussion(cs, connection, it) }
}
override val systemDiscussions: Flow<List<GitLabDiscussionDTO>> =
nonEmptyDiscussionsData.map { discussions ->
discussions.filter { it.notes.first().system }
}
}

View File

@@ -1,25 +1,63 @@
// Copyright 2000-2023 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.data
import com.intellij.openapi.diagnostic.logger
import com.intellij.util.childScope
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jetbrains.plugins.gitlab.api.GitLabProjectConnection
import org.jetbrains.plugins.gitlab.api.dto.GitLabNoteDTO
import org.jetbrains.plugins.gitlab.api.dto.GitLabUserDTO
import org.jetbrains.plugins.gitlab.api.getResultOrThrow
import org.jetbrains.plugins.gitlab.mergerequest.api.request.deleteNote
import java.util.*
interface GitLabNote {
val author: GitLabUserDTO
val createdAt: Date
val canAdmin: Boolean
val body: Flow<String>
suspend fun delete()
}
class LoadedGitLabNote(
note: GitLabNoteDTO
parentCs: CoroutineScope,
private val connection: GitLabProjectConnection,
private val eventSink: suspend (GitLabNoteEvent) -> Unit,
private val note: GitLabNoteDTO
) : GitLabNote {
private val cs = parentCs.childScope(CoroutineExceptionHandler { _, e ->
logger<GitLabDiscussion>().info(e.localizedMessage)
})
private val operationsGuard = Mutex()
override val author: GitLabUserDTO = note.author
override val createdAt: Date = note.createdAt
override val canAdmin: Boolean = note.userPermissions.adminNote
override val body: Flow<String> = flowOf(note.body)
override suspend fun delete() {
withContext(cs.coroutineContext) {
operationsGuard.withLock {
withContext(Dispatchers.IO) {
connection.apiClient.deleteNote(connection.repo.repository, note.id).getResultOrThrow()
}
}
withContext(NonCancellable) {
eventSink(GitLabNoteEvent.NoteDeleted(note.id))
}
}
}
suspend fun destroy() {
cs.coroutineContext[Job]!!.cancelAndJoin()
}
}

View File

@@ -0,0 +1,6 @@
// Copyright 2000-2023 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.data
sealed interface GitLabNoteEvent {
class NoteDeleted(val noteId: String) : GitLabNoteEvent
}

View File

@@ -0,0 +1,34 @@
// Copyright 2000-2023 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.ui.comment
import com.intellij.util.childScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import org.jetbrains.plugins.gitlab.mergerequest.data.GitLabNote
import org.jetbrains.plugins.gitlab.util.SingleCoroutineLauncher
interface GitLabMergeRequestNoteActionsViewModel {
val busy: Flow<Boolean>
fun delete()
}
class GitLabMergeRequestNoteActionsViewModelImpl(parentCs: CoroutineScope, private val note: GitLabNote)
: GitLabMergeRequestNoteActionsViewModel {
private val taskLauncher = SingleCoroutineLauncher(parentCs.childScope())
override val busy: Flow<Boolean> = taskLauncher.busy
override fun delete() {
taskLauncher.launch {
try {
note.delete()
}
catch (e: Exception) {
if (e is CancellationException) throw e
//TODO: handle???
}
}
}
}

View File

@@ -18,6 +18,8 @@ interface GitLabMergeRequestNoteViewModel {
val author: GitLabUserDTO
val createdAt: Date
val actionsVm: GitLabMergeRequestNoteActionsViewModel?
val htmlBody: Flow<@Nls String>
}
@@ -31,6 +33,9 @@ class GitLabMergeRequestNoteViewModelImpl(
override val author: GitLabUserDTO = note.author
override val createdAt: Date = note.createdAt
override val actionsVm: GitLabMergeRequestNoteActionsViewModel? =
if (note.canAdmin) GitLabMergeRequestNoteActionsViewModelImpl(cs, note) else null
private val body: Flow<String> = note.body
override val htmlBody: Flow<String> = body.map { GitLabUIUtil.convertToHtml(it) }
.shareIn(cs, SharingStarted.Lazily, 1)

View File

@@ -8,9 +8,13 @@ import com.intellij.collaboration.ui.SimpleHtmlPane
import com.intellij.collaboration.ui.VerticalListPanel
import com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil
import com.intellij.collaboration.ui.codereview.CodeReviewTimelineUIUtil.Thread.Replies
import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentUIUtil
import com.intellij.collaboration.ui.icon.IconsProvider
import com.intellij.collaboration.ui.icon.OverlaidOffsetIconsIcon
import com.intellij.collaboration.ui.util.*
import com.intellij.collaboration.ui.util.bindDisabled
import com.intellij.collaboration.ui.util.bindIcon
import com.intellij.collaboration.ui.util.bindText
import com.intellij.collaboration.ui.util.bindVisibility
import com.intellij.ui.components.labels.LinkLabel
import com.intellij.ui.components.labels.LinkListener
import com.intellij.util.containers.nullize
@@ -44,6 +48,8 @@ object GitLabMergeRequestTimelineDiscussionComponentFactory {
}
}
val actionsPanel = createNoteActions(cs, item.mainNote)
val repliesPanel = VerticalListPanel().apply {
cs.launch {
item.replies.combine(item.repliesFolded) { replies, folded ->
@@ -69,7 +75,7 @@ object GitLabMergeRequestTimelineDiscussionComponentFactory {
return CodeReviewChatItemUIUtil.build(CodeReviewChatItemUIUtil.ComponentType.FULL,
{ avatarIconsProvider.getIcon(constAuthor, it) },
contentPanel) {
withHeader(createTitleTextPane(cs, item.author, item.date))
withHeader(createTitleTextPane(cs, item.author, item.date), actionsPanel)
}.let {
VerticalListPanel().apply {
add(it)
@@ -159,13 +165,30 @@ object GitLabMergeRequestTimelineDiscussionComponentFactory {
avatarIconsProvider: IconsProvider<GitLabUserDTO>,
vm: GitLabMergeRequestNoteViewModel): JComponent {
val contentPanel = createNoteTextPanel(cs, vm.htmlBody)
val actionsPanel = createNoteActions(cs, flowOf(vm))
return CodeReviewChatItemUIUtil.build(CodeReviewChatItemUIUtil.ComponentType.FULL_SECONDARY,
{ avatarIconsProvider.getIcon(vm.author, it) },
contentPanel) {
withHeader(createTitleTextPane(vm.author, vm.createdAt))
withHeader(createTitleTextPane(vm.author, vm.createdAt), actionsPanel)
}
}
private fun createNoteActions(cs: CoroutineScope, note: Flow<GitLabMergeRequestNoteViewModel>): JComponent {
val panel = HorizontalListPanel(CodeReviewCommentUIUtil.Actions.HORIZONTAL_GAP).apply {
cs.launch {
note.mapNotNull { it.actionsVm }.collect {
removeAll()
CodeReviewCommentUIUtil.createDeleteCommentIconButton { _ -> it.delete() }.apply {
bindDisabled(cs, it.busy)
}.also(::add)
repaint()
revalidate()
}
}
}
return panel
}
private fun createNoteTextPanel(cs: CoroutineScope, textFlow: Flow<@Nls String>): JComponent =
SimpleHtmlPane().apply {
bindText(cs, textFlow)