[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: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
- <init>(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

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.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<Icon>,
content: JComponent,
init: Builder.() -> Unit): JComponent =
fun buildDynamic(
type: ComponentType,
iconValueProvider: (iconSize: Int) -> SingleValueModel<Icon>,
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<Icon>,
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 <T> 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

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(
dataFlow: Flow<D>,
componentFactory: CoroutineScope.(D) -> JComponent?,

View File

@@ -14,6 +14,10 @@
interface="org.jetbrains.plugins.github.ai.GHPRAIReviewExtension"
dynamic="true"/>
<extensionPoint qualifiedName="intellij.vcs.github.aiSummaryExtension"
interface="org.jetbrains.plugins.github.ai.GHPRAISummaryExtension"
dynamic="true"/>
<extensionPoint qualifiedName="com.intellij.vcs.github.gistContentsCollector"
interface="org.jetbrains.plugins.github.GithubGistContentsCollector"
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.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<GHPRAIReviewViewModel?>
val aiSummaryVm: StateFlow<GHPRAISummaryViewModel?>
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<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 {
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.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()
}

View File

@@ -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<GHPRToolWindowTab, GHPRToolWindowTabViewModel> {
private val cs = parentCs.childScope(javaClass.name)
@@ -161,6 +162,9 @@ class GHPRToolWindowProjectViewModel internal constructor(
fun acquireAIReviewViewModel(id: GHPRIdentifier, disposable: Disposable): StateFlow<GHPRAIReviewViewModel?> =
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 =
pullRequestsVms[id].acquireValue(disposable).infoVm