From ae3fd256fb3746c87b47b7917dc5d838ca5222a0 Mon Sep 17 00:00:00 2001 From: Chris Lemaire Date: Mon, 28 Oct 2024 10:02:18 +0100 Subject: [PATCH] [ghai] Add a way to generate a summary of a PR with AI Add actions: - Generate a summary - Like the generated summary - Dislike the generated summary Adds UI: - A comment in timeline with a purple hover color - buttons for above actions (cherry picked from commit 6f734fb94f53a4efd8dd950b51d427a21f9ab117) (cherry picked from commit 3e0ae846aa29182724b7a350a2567bff89070351) IJ-CR-148445 GitOrigin-RevId: 51e1544114d334edfc5f001f9145e6d4a0c7e0ae --- .../api-dump-experimental.txt | 4 +- .../ui/codereview/CodeReviewChatItemUIUtil.kt | 40 ++++++---- .../collaboration/ui/util/swingBindings.kt | 12 +++ .../resources/META-INF/github-core-config.xml | 4 + .../github/ai/GHPRAISummaryViewModel.kt | 79 +++++++++++++++++++ .../ui/GHPRViewModelContainerImpl.kt | 12 ++- .../GHPRFileEditorComponentFactory.kt | 29 +++++-- .../model/GHPRToolWindowProjectViewModel.kt | 6 +- 8 files changed, 163 insertions(+), 23 deletions(-) create mode 100644 plugins/github/src/org/jetbrains/plugins/github/ai/GHPRAISummaryViewModel.kt diff --git a/platform/collaboration-tools/api-dump-experimental.txt b/platform/collaboration-tools/api-dump-experimental.txt index 9b4c644791a9..63250ae4ccf8 100644 --- a/platform/collaboration-tools/api-dump-experimental.txt +++ b/platform/collaboration-tools/api-dump-experimental.txt @@ -399,14 +399,16 @@ f:com.intellij.collaboration.api.json.JsonHttpApiHelperKt - f:build(com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$ComponentType,kotlin.jvm.functions.Function1,javax.swing.JComponent,kotlin.jvm.functions.Function1):javax.swing.JComponent - f:buildDynamic(com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$ComponentType,kotlin.jvm.functions.Function1,javax.swing.JComponent,kotlin.jvm.functions.Function1):javax.swing.JComponent - f:getTEXT_CONTENT_WIDTH():I -- f:withHoverHighlight(javax.swing.JComponent):javax.swing.JComponent +- f:withHoverHighlight(javax.swing.JComponent,com.intellij.ui.JBColor):javax.swing.JComponent *f:com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$Builder - (com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$ComponentType,kotlin.jvm.functions.Function1,javax.swing.JComponent):V - f:build():javax.swing.JComponent - f:getHeader():com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$HeaderComponents +- f:getHoverHighlight():com.intellij.ui.JBColor - f:getIconTooltip():java.lang.String - f:getMaxContentWidth():java.lang.Integer - f:setHeader(com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$HeaderComponents):V +- f:setHoverHighlight(com.intellij.ui.JBColor):V - f:setIconTooltip(java.lang.String):V - f:setMaxContentWidth(java.lang.Integer):V - f:withHeader(javax.swing.JComponent,javax.swing.JComponent):com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$Builder diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/CodeReviewChatItemUIUtil.kt b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/CodeReviewChatItemUIUtil.kt index d126c92e1fcb..042305faa857 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/CodeReviewChatItemUIUtil.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/ui/codereview/CodeReviewChatItemUIUtil.kt @@ -8,6 +8,7 @@ import com.intellij.collaboration.ui.VerticalListPanel import com.intellij.collaboration.ui.codereview.avatar.Avatar import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentUIUtil import com.intellij.collaboration.ui.util.CodeReviewColorUtil +import com.intellij.ui.JBColor import com.intellij.ui.hover.HoverStateListener import com.intellij.ui.scale.JBUIScale import com.intellij.util.ui.JBUI @@ -76,8 +77,8 @@ object CodeReviewChatItemUIUtil { SUPER_COMPACT { override val iconSize: Int = Avatar.Sizes.BASE override val iconGap: Int = 10 - override val paddingInsets: Insets = Insets(0,0,0,0) - override val inputPaddingInsets: Insets = Insets(0,0,0,0) + override val paddingInsets: Insets = Insets(0, 0, 0, 0) + override val inputPaddingInsets: Insets = Insets(0, 0, 0, 0) }; /** @@ -113,16 +114,20 @@ object CodeReviewChatItemUIUtil { get() = iconSize + iconGap } - fun build(type: ComponentType, - iconProvider: (iconSize: Int) -> Icon, - content: JComponent, - init: Builder.() -> Unit): JComponent = + fun build( + type: ComponentType, + iconProvider: (iconSize: Int) -> Icon, + content: JComponent, + init: Builder.() -> Unit, + ): JComponent = buildDynamic(type, { iconSize -> SingleValueModel(iconProvider(iconSize)) }, content, init) - fun buildDynamic(type: ComponentType, - iconValueProvider: (iconSize: Int) -> SingleValueModel, - content: JComponent, - init: Builder.() -> Unit): JComponent = + fun buildDynamic( + type: ComponentType, + iconValueProvider: (iconSize: Int) -> SingleValueModel, + content: JComponent, + init: Builder.() -> Unit, + ): JComponent = Builder(type, iconValueProvider, content).apply(init).build() /** @@ -131,7 +136,7 @@ object CodeReviewChatItemUIUtil { class Builder( private val type: ComponentType, private val iconValueProvider: (Int) -> SingleValueModel, - private val content: JComponent + private val content: JComponent, ) { /** * Tooltip for a main icon @@ -149,6 +154,11 @@ object CodeReviewChatItemUIUtil { */ var header: HeaderComponents? = null + /** + * The color to use as the background color on-hover. + */ + var hoverHighlight: JBColor = CodeReviewColorUtil.Review.Chat.hover + /** * Helper fun to setup [HeaderComponents] */ @@ -175,7 +185,9 @@ object CodeReviewChatItemUIUtil { actionsVisibleOnHover(it, header?.actions) }.apply { border = JBUI.Borders.empty(type.paddingInsets) - }.let { withHoverHighlight(it) } + }.let { + withHoverHighlight(it, hoverHighlight) + } private fun JComponent.wrapIfNotNull(value: T?, block: (JComponent, T) -> JComponent): JComponent = let { if (value != null) block(it, value) else it @@ -218,7 +230,7 @@ object CodeReviewChatItemUIUtil { } } - fun withHoverHighlight(comp: JComponent): JComponent { + fun withHoverHighlight(comp: JComponent, hoverHighlight: JBColor): JComponent { val highlighterPanel = JPanelWithBackground(BorderLayout()).apply { isOpaque = false background = null @@ -228,7 +240,7 @@ object CodeReviewChatItemUIUtil { override fun hoverChanged(component: Component, hovered: Boolean) { // TODO: extract to theme colors component.background = if (hovered) { - CodeReviewColorUtil.Review.Chat.hover + hoverHighlight } else { null diff --git a/platform/collaboration-tools/src/com/intellij/collaboration/ui/util/swingBindings.kt b/platform/collaboration-tools/src/com/intellij/collaboration/ui/util/swingBindings.kt index 04f114f6f1e7..c77d80b3aea3 100644 --- a/platform/collaboration-tools/src/com/intellij/collaboration/ui/util/swingBindings.kt +++ b/platform/collaboration-tools/src/com/intellij/collaboration/ui/util/swingBindings.kt @@ -321,6 +321,18 @@ fun Wrapper.bindContentIn( } } +@ApiStatus.Internal +fun Wrapper.bindContent( + debugName: String, contentFlow: Flow, +) { + showingScope(debugName) { + contentFlow.collect { + setContent(it) + repaint() + } + } +} + private suspend fun Wrapper.bindContentImpl( dataFlow: Flow, componentFactory: CoroutineScope.(D) -> JComponent?, diff --git a/plugins/github/resources/META-INF/github-core-config.xml b/plugins/github/resources/META-INF/github-core-config.xml index 5a588620fc66..6f429be0b834 100644 --- a/plugins/github/resources/META-INF/github-core-config.xml +++ b/plugins/github/resources/META-INF/github-core-config.xml @@ -14,6 +14,10 @@ interface="org.jetbrains.plugins.github.ai.GHPRAIReviewExtension" dynamic="true"/> + + diff --git a/plugins/github/src/org/jetbrains/plugins/github/ai/GHPRAISummaryViewModel.kt b/plugins/github/src/org/jetbrains/plugins/github/ai/GHPRAISummaryViewModel.kt new file mode 100644 index 000000000000..ee1e3617134f --- /dev/null +++ b/plugins/github/src/org/jetbrains/plugins/github/ai/GHPRAISummaryViewModel.kt @@ -0,0 +1,79 @@ +// 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.github.ai + +import com.intellij.collaboration.async.singleExtensionFlow +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.project.Project +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.annotations.Nls +import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContext +import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider +import javax.swing.JComponent + +/** + * Represents a summary that can be generated at the top of the timeline. + */ +@ApiStatus.Internal +interface GHPRAISummaryViewModel { + val isGenerating: StateFlow + + val summaryHtml: StateFlow> + + val rating: StateFlow + val hasKnownActivity: StateFlow + + fun startGenerating() + fun stopGenerating() + + fun rate(isUseful: Boolean) + + sealed interface GenerationState { + data object NotStarted : GenerationState + data class NotStartedDueToError(val error: @Nls String) : GenerationState + data object Loading : GenerationState + data class Error(val error: Exception) : GenerationState + + sealed interface WithValue : GenerationState { + val value: T + } + + data class Step(override val value: T) : WithValue + data class Interrupted(override val value: T) : WithValue + data class Done(override val value: T) : WithValue + + fun getOrNull(): T? = (this as? WithValue)?.value + fun isLoading(): Boolean = this is Loading || this is Step + + fun map(mapper: (T) -> R): GenerationState = when (this) { + is Step -> Step(mapper(value)) + is Interrupted -> Interrupted(mapper(value)) + is Done -> Done(mapper(value)) + is NotStarted -> this + is NotStartedDueToError -> this + is Loading -> this + is Error -> this + } + } +} + +@ApiStatus.Internal +interface GHPRAISummaryExtension { + companion object { + private val EP = ExtensionPointName.create("intellij.vcs.github.aiSummaryExtension") + + internal val singleFlow: Flow + get() = EP.singleExtensionFlow() + } + + fun provideSummaryVm( + cs: CoroutineScope, + project: Project, + dataContext: GHPRDataContext, + dataProvider: GHPRDataProvider, + ): GHPRAISummaryViewModel? + + fun createTimelineComponent(vm: GHPRAISummaryViewModel): JComponent +} diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/GHPRViewModelContainerImpl.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/GHPRViewModelContainerImpl.kt index 4687eff1c879..af5efc36b4b4 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/GHPRViewModelContainerImpl.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/GHPRViewModelContainerImpl.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.* import org.jetbrains.annotations.ApiStatus import org.jetbrains.plugins.github.ai.GHPRAIReviewExtension import org.jetbrains.plugins.github.ai.GHPRAIReviewViewModel +import org.jetbrains.plugins.github.ai.GHPRAISummaryExtension +import org.jetbrains.plugins.github.ai.GHPRAISummaryViewModel import org.jetbrains.plugins.github.pullrequest.config.GithubPullRequestsProjectUISettings import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContext import org.jetbrains.plugins.github.pullrequest.data.GHPRIdentifier @@ -36,6 +38,7 @@ import org.jetbrains.plugins.github.pullrequest.ui.toolwindow.model.GHPRToolWind @ApiStatus.Internal interface GHPRViewModelContainer { val aiReviewVm: StateFlow + val aiSummaryVm: StateFlow val infoVm: GHPRInfoViewModel val branchWidgetVm: GHPRBranchWidgetViewModel @@ -51,7 +54,7 @@ internal class GHPRViewModelContainerImpl( dataContext: GHPRDataContext, private val projectVm: GHPRToolWindowProjectViewModel, private val pullRequestId: GHPRIdentifier, - cancelWith: Disposable + cancelWith: Disposable, ) : GHPRViewModelContainer { private val cs = parentCs.childScope(javaClass.name).cancelledWith(cancelWith) @@ -71,6 +74,13 @@ internal class GHPRViewModelContainerImpl( GHPRAIReviewExtension.EP.singleExtensionFlow() .mapScoped { it?.provideReviewVm(project, this, dataContext, dataProvider) } .stateIn(cs, SharingStarted.Eagerly, null) + override val aiSummaryVm: StateFlow = + GHPRAISummaryExtension.singleFlow + .mapScoped { extension -> + val cs = this@mapScoped + extension?.provideSummaryVm(cs, project, dataContext, dataProvider) + } + .stateIn(cs, SharingStarted.Eagerly, null) private val branchStateVm by lazy { GHPRReviewBranchStateSharedViewModel(cs, dataContext, dataProvider) diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRFileEditorComponentFactory.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRFileEditorComponentFactory.kt index 26de6aae48fe..8c41cb308c3f 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRFileEditorComponentFactory.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/timeline/GHPRFileEditorComponentFactory.kt @@ -14,6 +14,7 @@ import com.intellij.collaboration.ui.codereview.comment.submitActionIn import com.intellij.collaboration.ui.codereview.list.error.ErrorStatusPanelFactory import com.intellij.collaboration.ui.codereview.list.error.ErrorStatusPresenter import com.intellij.collaboration.ui.codereview.timeline.comment.CommentTextFieldFactory +import com.intellij.collaboration.ui.util.bindContent import com.intellij.collaboration.ui.util.bindTextHtmlIn import com.intellij.collaboration.ui.util.bindTextIn import com.intellij.collaboration.ui.util.bindVisibilityIn @@ -38,6 +39,7 @@ import com.intellij.util.ui.UIUtil import com.intellij.util.ui.update.UiNotifyConnector import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* +import org.jetbrains.plugins.github.ai.GHPRAISummaryExtension import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException import org.jetbrains.plugins.github.i18n.GithubBundle import org.jetbrains.plugins.github.pullrequest.ui.details.model.GHPRDetailsFull @@ -52,11 +54,13 @@ import javax.swing.JPanel import javax.swing.event.ChangeEvent import javax.swing.event.ChangeListener -internal class GHPRFileEditorComponentFactory(private val cs: CoroutineScope, - private val project: Project, - private val projectVm: GHPRToolWindowProjectViewModel, - private val timelineVm: GHPRTimelineViewModel, - private val initialDetails: GHPRDetailsFull) { +internal class GHPRFileEditorComponentFactory( + private val cs: CoroutineScope, + private val project: Project, + private val projectVm: GHPRToolWindowProjectViewModel, + private val timelineVm: GHPRTimelineViewModel, + private val initialDetails: GHPRDetailsFull, +) { private val uiDisposable = cs.nestedDisposable() @@ -99,6 +103,18 @@ internal class GHPRFileEditorComponentFactory(private val cs: CoroutineScope, border = JBUI.Borders.empty(CodeReviewTimelineUIUtil.VERT_PADDING, 0) add(header) + + add(Wrapper().apply { + val summaryComponent = combine( + projectVm.acquireAISummaryViewModel(loadedDetails.value.id, uiDisposable), + GHPRAISummaryExtension.singleFlow + ) { summaryVm, extension -> + summaryVm?.let { extension?.createTimelineComponent(it) } + } + bindVisibilityIn(cs, summaryComponent.map { it != null }) + bindContent("${javaClass.name}.summaryComponent.content", summaryComponent) + }) + add(description) add(timeline) @@ -126,7 +142,8 @@ internal class GHPRFileEditorComponentFactory(private val cs: CoroutineScope, } }) } - UiNotifyConnector.doWhenFirstShown(scrollPane) { + UiNotifyConnector.doWhenFirstShown(scrollPane) + { timelineVm.requestMore() } diff --git a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/toolwindow/model/GHPRToolWindowProjectViewModel.kt b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/toolwindow/model/GHPRToolWindowProjectViewModel.kt index cc6af7b3d719..423f3b11cdd9 100644 --- a/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/toolwindow/model/GHPRToolWindowProjectViewModel.kt +++ b/plugins/github/src/org/jetbrains/plugins/github/pullrequest/ui/toolwindow/model/GHPRToolWindowProjectViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.jetbrains.annotations.ApiStatus import org.jetbrains.plugins.github.ai.GHPRAIReviewViewModel +import org.jetbrains.plugins.github.ai.GHPRAISummaryViewModel import org.jetbrains.plugins.github.api.GHRepositoryConnection import org.jetbrains.plugins.github.api.GHRepositoryCoordinates import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestShort @@ -55,7 +56,7 @@ class GHPRToolWindowProjectViewModel internal constructor( private val project: Project, parentCs: CoroutineScope, private val twVm: GHPRToolWindowViewModel, - connection: GHRepositoryConnection + connection: GHRepositoryConnection, ) : ReviewToolwindowProjectViewModel { private val cs = parentCs.childScope(javaClass.name) @@ -161,6 +162,9 @@ class GHPRToolWindowProjectViewModel internal constructor( fun acquireAIReviewViewModel(id: GHPRIdentifier, disposable: Disposable): StateFlow = pullRequestsVms[id].acquireValue(disposable).aiReviewVm + fun acquireAISummaryViewModel(id: GHPRIdentifier, disposable: Disposable): StateFlow = + pullRequestsVms[id].acquireValue(disposable).aiSummaryVm + fun acquireInfoViewModel(id: GHPRIdentifier, disposable: Disposable): GHPRInfoViewModel = pullRequestsVms[id].acquireValue(disposable).infoVm