[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
This commit is contained in:
Chris Lemaire
2024-10-28 10:02:18 +01:00
committed by intellij-monorepo-bot
parent bc555ebd94
commit ae3fd256fb
8 changed files with 163 additions and 23 deletions

View File

@@ -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: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: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: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 *f:com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$Builder
- <init>(com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$ComponentType,kotlin.jvm.functions.Function1,javax.swing.JComponent):V - <init>(com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$ComponentType,kotlin.jvm.functions.Function1,javax.swing.JComponent):V
- f:build():javax.swing.JComponent - f:build():javax.swing.JComponent
- f:getHeader():com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$HeaderComponents - f:getHeader():com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$HeaderComponents
- f:getHoverHighlight():com.intellij.ui.JBColor
- f:getIconTooltip():java.lang.String - f:getIconTooltip():java.lang.String
- f:getMaxContentWidth():java.lang.Integer - f:getMaxContentWidth():java.lang.Integer
- f:setHeader(com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$HeaderComponents):V - f:setHeader(com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$HeaderComponents):V
- f:setHoverHighlight(com.intellij.ui.JBColor):V
- f:setIconTooltip(java.lang.String):V - f:setIconTooltip(java.lang.String):V
- f:setMaxContentWidth(java.lang.Integer):V - f:setMaxContentWidth(java.lang.Integer):V
- f:withHeader(javax.swing.JComponent,javax.swing.JComponent):com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$Builder - f:withHeader(javax.swing.JComponent,javax.swing.JComponent):com.intellij.collaboration.ui.codereview.CodeReviewChatItemUIUtil$Builder

View File

@@ -8,6 +8,7 @@ import com.intellij.collaboration.ui.VerticalListPanel
import com.intellij.collaboration.ui.codereview.avatar.Avatar import com.intellij.collaboration.ui.codereview.avatar.Avatar
import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentUIUtil import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentUIUtil
import com.intellij.collaboration.ui.util.CodeReviewColorUtil import com.intellij.collaboration.ui.util.CodeReviewColorUtil
import com.intellij.ui.JBColor
import com.intellij.ui.hover.HoverStateListener import com.intellij.ui.hover.HoverStateListener
import com.intellij.ui.scale.JBUIScale import com.intellij.ui.scale.JBUIScale
import com.intellij.util.ui.JBUI import com.intellij.util.ui.JBUI
@@ -76,8 +77,8 @@ object CodeReviewChatItemUIUtil {
SUPER_COMPACT { SUPER_COMPACT {
override val iconSize: Int = Avatar.Sizes.BASE override val iconSize: Int = Avatar.Sizes.BASE
override val iconGap: Int = 10 override val iconGap: Int = 10
override val paddingInsets: Insets = Insets(0,0,0,0) override val paddingInsets: Insets = Insets(0, 0, 0, 0)
override val inputPaddingInsets: Insets = Insets(0,0,0,0) override val inputPaddingInsets: Insets = Insets(0, 0, 0, 0)
}; };
/** /**
@@ -113,16 +114,20 @@ object CodeReviewChatItemUIUtil {
get() = iconSize + iconGap get() = iconSize + iconGap
} }
fun build(type: ComponentType, fun build(
iconProvider: (iconSize: Int) -> Icon, type: ComponentType,
content: JComponent, iconProvider: (iconSize: Int) -> Icon,
init: Builder.() -> Unit): JComponent = content: JComponent,
init: Builder.() -> Unit,
): JComponent =
buildDynamic(type, { iconSize -> SingleValueModel(iconProvider(iconSize)) }, content, init) buildDynamic(type, { iconSize -> SingleValueModel(iconProvider(iconSize)) }, content, init)
fun buildDynamic(type: ComponentType, fun buildDynamic(
iconValueProvider: (iconSize: Int) -> SingleValueModel<Icon>, type: ComponentType,
content: JComponent, iconValueProvider: (iconSize: Int) -> SingleValueModel<Icon>,
init: Builder.() -> Unit): JComponent = content: JComponent,
init: Builder.() -> Unit,
): JComponent =
Builder(type, iconValueProvider, content).apply(init).build() Builder(type, iconValueProvider, content).apply(init).build()
/** /**
@@ -131,7 +136,7 @@ object CodeReviewChatItemUIUtil {
class Builder( class Builder(
private val type: ComponentType, private val type: ComponentType,
private val iconValueProvider: (Int) -> SingleValueModel<Icon>, private val iconValueProvider: (Int) -> SingleValueModel<Icon>,
private val content: JComponent private val content: JComponent,
) { ) {
/** /**
* Tooltip for a main icon * Tooltip for a main icon
@@ -149,6 +154,11 @@ object CodeReviewChatItemUIUtil {
*/ */
var header: HeaderComponents? = null 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] * Helper fun to setup [HeaderComponents]
*/ */
@@ -175,7 +185,9 @@ object CodeReviewChatItemUIUtil {
actionsVisibleOnHover(it, header?.actions) actionsVisibleOnHover(it, header?.actions)
}.apply { }.apply {
border = JBUI.Borders.empty(type.paddingInsets) border = JBUI.Borders.empty(type.paddingInsets)
}.let { withHoverHighlight(it) } }.let {
withHoverHighlight(it, hoverHighlight)
}
private fun <T> JComponent.wrapIfNotNull(value: T?, block: (JComponent, T) -> JComponent): JComponent = let { private fun <T> JComponent.wrapIfNotNull(value: T?, block: (JComponent, T) -> JComponent): JComponent = let {
if (value != null) block(it, value) else it 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 { val highlighterPanel = JPanelWithBackground(BorderLayout()).apply {
isOpaque = false isOpaque = false
background = null background = null
@@ -228,7 +240,7 @@ object CodeReviewChatItemUIUtil {
override fun hoverChanged(component: Component, hovered: Boolean) { override fun hoverChanged(component: Component, hovered: Boolean) {
// TODO: extract to theme colors // TODO: extract to theme colors
component.background = if (hovered) { component.background = if (hovered) {
CodeReviewColorUtil.Review.Chat.hover hoverHighlight
} }
else { else {
null null

View File

@@ -321,6 +321,18 @@ fun <D> Wrapper.bindContentIn(
} }
} }
@ApiStatus.Internal
fun Wrapper.bindContent(
debugName: String, contentFlow: Flow<JComponent?>,
) {
showingScope(debugName) {
contentFlow.collect {
setContent(it)
repaint()
}
}
}
private suspend fun <D> Wrapper.bindContentImpl( private suspend fun <D> Wrapper.bindContentImpl(
dataFlow: Flow<D>, dataFlow: Flow<D>,
componentFactory: CoroutineScope.(D) -> JComponent?, componentFactory: CoroutineScope.(D) -> JComponent?,

View File

@@ -14,6 +14,10 @@
interface="org.jetbrains.plugins.github.ai.GHPRAIReviewExtension" interface="org.jetbrains.plugins.github.ai.GHPRAIReviewExtension"
dynamic="true"/> dynamic="true"/>
<extensionPoint qualifiedName="intellij.vcs.github.aiSummaryExtension"
interface="org.jetbrains.plugins.github.ai.GHPRAISummaryExtension"
dynamic="true"/>
<extensionPoint qualifiedName="com.intellij.vcs.github.gistContentsCollector" <extensionPoint qualifiedName="com.intellij.vcs.github.gistContentsCollector"
interface="org.jetbrains.plugins.github.GithubGistContentsCollector" interface="org.jetbrains.plugins.github.GithubGistContentsCollector"
dynamic="true"/> dynamic="true"/>

View File

@@ -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<Boolean>
val summaryHtml: StateFlow<GenerationState<String>>
val rating: StateFlow<Boolean?>
val hasKnownActivity: StateFlow<Boolean>
fun startGenerating()
fun stopGenerating()
fun rate(isUseful: Boolean)
sealed interface GenerationState<out T> {
data object NotStarted : GenerationState<Nothing>
data class NotStartedDueToError(val error: @Nls String) : GenerationState<Nothing>
data object Loading : GenerationState<Nothing>
data class Error(val error: Exception) : GenerationState<Nothing>
sealed interface WithValue<T> : GenerationState<T> {
val value: T
}
data class Step<T>(override val value: T) : WithValue<T>
data class Interrupted<T>(override val value: T) : WithValue<T>
data class Done<T>(override val value: T) : WithValue<T>
fun getOrNull(): T? = (this as? WithValue)?.value
fun isLoading(): Boolean = this is Loading || this is Step
fun <R> map(mapper: (T) -> R): GenerationState<R> = 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<GHPRAISummaryExtension>("intellij.vcs.github.aiSummaryExtension")
internal val singleFlow: Flow<GHPRAISummaryExtension?>
get() = EP.singleExtensionFlow()
}
fun provideSummaryVm(
cs: CoroutineScope,
project: Project,
dataContext: GHPRDataContext,
dataProvider: GHPRDataProvider,
): GHPRAISummaryViewModel?
fun createTimelineComponent(vm: GHPRAISummaryViewModel): JComponent
}

View File

@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.*
import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.github.ai.GHPRAIReviewExtension import org.jetbrains.plugins.github.ai.GHPRAIReviewExtension
import org.jetbrains.plugins.github.ai.GHPRAIReviewViewModel 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.config.GithubPullRequestsProjectUISettings
import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContext import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContext
import org.jetbrains.plugins.github.pullrequest.data.GHPRIdentifier import org.jetbrains.plugins.github.pullrequest.data.GHPRIdentifier
@@ -36,6 +38,7 @@ import org.jetbrains.plugins.github.pullrequest.ui.toolwindow.model.GHPRToolWind
@ApiStatus.Internal @ApiStatus.Internal
interface GHPRViewModelContainer { interface GHPRViewModelContainer {
val aiReviewVm: StateFlow<GHPRAIReviewViewModel?> val aiReviewVm: StateFlow<GHPRAIReviewViewModel?>
val aiSummaryVm: StateFlow<GHPRAISummaryViewModel?>
val infoVm: GHPRInfoViewModel val infoVm: GHPRInfoViewModel
val branchWidgetVm: GHPRBranchWidgetViewModel val branchWidgetVm: GHPRBranchWidgetViewModel
@@ -51,7 +54,7 @@ internal class GHPRViewModelContainerImpl(
dataContext: GHPRDataContext, dataContext: GHPRDataContext,
private val projectVm: GHPRToolWindowProjectViewModel, private val projectVm: GHPRToolWindowProjectViewModel,
private val pullRequestId: GHPRIdentifier, private val pullRequestId: GHPRIdentifier,
cancelWith: Disposable cancelWith: Disposable,
) : GHPRViewModelContainer { ) : GHPRViewModelContainer {
private val cs = parentCs.childScope(javaClass.name).cancelledWith(cancelWith) private val cs = parentCs.childScope(javaClass.name).cancelledWith(cancelWith)
@@ -71,6 +74,13 @@ internal class GHPRViewModelContainerImpl(
GHPRAIReviewExtension.EP.singleExtensionFlow() GHPRAIReviewExtension.EP.singleExtensionFlow()
.mapScoped { it?.provideReviewVm(project, this, dataContext, dataProvider) } .mapScoped { it?.provideReviewVm(project, this, dataContext, dataProvider) }
.stateIn(cs, SharingStarted.Eagerly, null) .stateIn(cs, SharingStarted.Eagerly, null)
override val aiSummaryVm: StateFlow<GHPRAISummaryViewModel?> =
GHPRAISummaryExtension.singleFlow
.mapScoped { extension ->
val cs = this@mapScoped
extension?.provideSummaryVm(cs, project, dataContext, dataProvider)
}
.stateIn(cs, SharingStarted.Eagerly, null)
private val branchStateVm by lazy { private val branchStateVm by lazy {
GHPRReviewBranchStateSharedViewModel(cs, dataContext, dataProvider) GHPRReviewBranchStateSharedViewModel(cs, dataContext, dataProvider)

View File

@@ -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.ErrorStatusPanelFactory
import com.intellij.collaboration.ui.codereview.list.error.ErrorStatusPresenter import com.intellij.collaboration.ui.codereview.list.error.ErrorStatusPresenter
import com.intellij.collaboration.ui.codereview.timeline.comment.CommentTextFieldFactory 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.bindTextHtmlIn
import com.intellij.collaboration.ui.util.bindTextIn import com.intellij.collaboration.ui.util.bindTextIn
import com.intellij.collaboration.ui.util.bindVisibilityIn 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 com.intellij.util.ui.update.UiNotifyConnector
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.jetbrains.plugins.github.ai.GHPRAISummaryExtension
import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException
import org.jetbrains.plugins.github.i18n.GithubBundle import org.jetbrains.plugins.github.i18n.GithubBundle
import org.jetbrains.plugins.github.pullrequest.ui.details.model.GHPRDetailsFull 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.ChangeEvent
import javax.swing.event.ChangeListener import javax.swing.event.ChangeListener
internal class GHPRFileEditorComponentFactory(private val cs: CoroutineScope, internal class GHPRFileEditorComponentFactory(
private val project: Project, private val cs: CoroutineScope,
private val projectVm: GHPRToolWindowProjectViewModel, private val project: Project,
private val timelineVm: GHPRTimelineViewModel, private val projectVm: GHPRToolWindowProjectViewModel,
private val initialDetails: GHPRDetailsFull) { private val timelineVm: GHPRTimelineViewModel,
private val initialDetails: GHPRDetailsFull,
) {
private val uiDisposable = cs.nestedDisposable() private val uiDisposable = cs.nestedDisposable()
@@ -99,6 +103,18 @@ internal class GHPRFileEditorComponentFactory(private val cs: CoroutineScope,
border = JBUI.Borders.empty(CodeReviewTimelineUIUtil.VERT_PADDING, 0) border = JBUI.Borders.empty(CodeReviewTimelineUIUtil.VERT_PADDING, 0)
add(header) 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(description)
add(timeline) add(timeline)
@@ -126,7 +142,8 @@ internal class GHPRFileEditorComponentFactory(private val cs: CoroutineScope,
} }
}) })
} }
UiNotifyConnector.doWhenFirstShown(scrollPane) { UiNotifyConnector.doWhenFirstShown(scrollPane)
{
timelineVm.requestMore() timelineVm.requestMore()
} }

View File

@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.github.ai.GHPRAIReviewViewModel 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.GHRepositoryConnection
import org.jetbrains.plugins.github.api.GHRepositoryCoordinates import org.jetbrains.plugins.github.api.GHRepositoryCoordinates
import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestShort import org.jetbrains.plugins.github.api.data.pullrequest.GHPullRequestShort
@@ -55,7 +56,7 @@ class GHPRToolWindowProjectViewModel internal constructor(
private val project: Project, private val project: Project,
parentCs: CoroutineScope, parentCs: CoroutineScope,
private val twVm: GHPRToolWindowViewModel, private val twVm: GHPRToolWindowViewModel,
connection: GHRepositoryConnection connection: GHRepositoryConnection,
) : ReviewToolwindowProjectViewModel<GHPRToolWindowTab, GHPRToolWindowTabViewModel> { ) : ReviewToolwindowProjectViewModel<GHPRToolWindowTab, GHPRToolWindowTabViewModel> {
private val cs = parentCs.childScope(javaClass.name) private val cs = parentCs.childScope(javaClass.name)
@@ -161,6 +162,9 @@ class GHPRToolWindowProjectViewModel internal constructor(
fun acquireAIReviewViewModel(id: GHPRIdentifier, disposable: Disposable): StateFlow<GHPRAIReviewViewModel?> = fun acquireAIReviewViewModel(id: GHPRIdentifier, disposable: Disposable): StateFlow<GHPRAIReviewViewModel?> =
pullRequestsVms[id].acquireValue(disposable).aiReviewVm pullRequestsVms[id].acquireValue(disposable).aiReviewVm
fun acquireAISummaryViewModel(id: GHPRIdentifier, disposable: Disposable): StateFlow<GHPRAISummaryViewModel?> =
pullRequestsVms[id].acquireValue(disposable).aiSummaryVm
fun acquireInfoViewModel(id: GHPRIdentifier, disposable: Disposable): GHPRInfoViewModel = fun acquireInfoViewModel(id: GHPRIdentifier, disposable: Disposable): GHPRInfoViewModel =
pullRequestsVms[id].acquireValue(disposable).infoVm pullRequestsVms[id].acquireValue(disposable).infoVm