[ghai] Move AI review into LLM plugin module

(cherry picked from commit 5d96d51f28b0e63c660b56843245f40a1603355e)


(cherry picked from commit 2f43f1000ec5b565f6390a6e00388a2836300539)

IJ-CR-148445

GitOrigin-RevId: 9802819a1282435cdde7677313a5d57a5f3c0d01
This commit is contained in:
Chris Lemaire
2024-09-19 18:15:58 +02:00
committed by intellij-monorepo-bot
parent c1a22690f7
commit df57020a83
27 changed files with 56 additions and 1318 deletions

View File

@@ -21,7 +21,6 @@
<dependencies>
<plugin id="com.intellij.modules.lang"/>
<plugin id="Git4Idea"/>
<plugin id="com.intellij.ml.llm"/>
<module name="intellij.platform.collaborationTools"/>
</dependencies>

View File

@@ -80,9 +80,8 @@
<orderEntry type="module" module-name="intellij.platform.util.coroutines" />
<orderEntry type="library" scope="TEST" name="JUnit4" level="project" />
<orderEntry type="library" scope="TEST" name="kotlinx-coroutines-test" level="project" />
<orderEntry type="module" module-name="intellij.ml.llm.core" />
<orderEntry type="module" module-name="intellij.ml.llm.core.privacy" />
<orderEntry type="module" module-name="intellij.ml.llm.privacy" />
<orderEntry type="library" scope="TEST" name="io.mockk" level="project" />
<orderEntry type="library" scope="TEST" name="io.mockk.jvm" level="project" />
<orderEntry type="module" module-name="intellij.platform.ide.ui" />
</component>
</module>

View File

@@ -1,7 +1,6 @@
<idea-plugin package="org.jetbrains.plugins.github">
<content>
<module name="intellij.vcs.github/ai"/>
<module name="intellij.vcs.github/tracker"/>
</content>
@@ -14,6 +13,10 @@
<extensionPoint qualifiedName="intellij.vcs.github.commentViewModelProvider"
interface="org.jetbrains.plugins.github.pullrequest.ui.diff.GHPRAICommentViewModelProvider"
dynamic="true"/>
<extensionPoint qualifiedName="intellij.vcs.github.commentComponentFactory"
interface="org.jetbrains.plugins.github.pullrequest.ui.editor.GHPRAICommentComponentFactory"
dynamic="true"/>
<extensionPoint qualifiedName="com.intellij.vcs.github.gistContentsCollector"
interface="org.jetbrains.plugins.github.GithubGistContentsCollector"
dynamic="true"/>

View File

@@ -1,22 +0,0 @@
<idea-plugin package="org.jetbrains.plugins.github.ai">
<dependencies>
<plugin id="com.intellij.ml.llm"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<toolWindow id="PR AI Assistant" icon="org.jetbrains.plugins.github.GithubIcons.PullRequestsToolWindow"
anchor="right" doNotActivateOnStart="true" canCloseContents="false"
factoryClass="org.jetbrains.plugins.github.ai.assistedReview.GHPRAIReviewToolwindowFactory"/>
</extensions>
<extensions defaultExtensionNs="intellij.vcs.github">
<commentViewModelProvider implementation="org.jetbrains.plugins.github.ai.assistedReview.GHPRAIReviewCommentViewModelProvider"/>
</extensions>
<actions>
<action class="org.jetbrains.plugins.github.ai.assistedReview.GHPRAIReviewOpenAction"
internal="true">
<add-to-group group-id="Github.PullRequest.StatusChecks.AdditionalActions"/>
</action>
</actions>
</idea-plugin>

View File

@@ -1,8 +0,0 @@
tab.title.pr.ai.assistant=PR AI Assistant
review.buddy.llm.discussion.summarize.display=Summarize the discussion
request.ai.review.in.review.toolwindow=Request AI review in Review Toolwindow
line=Line {0}:
pull.request.summary=Pull Request Summary
more.details=More details
hide.details=Hide details
tab.title.github.review.buddy=GitHub Review Buddy

View File

@@ -1,11 +1,14 @@
// 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.pullrequest.ui.comment
package org.jetbrains.plugins.github.ai
import com.intellij.collaboration.ui.codereview.comment.CodeReviewSubmittableTextViewModel
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.jetbrains.plugins.github.ai.assistedReview.GHPRAICommentChatViewModel
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
interface GHPRAICommentViewModel {
val key: Any
val textHtml: StateFlow<String>
@@ -28,4 +31,19 @@ interface GHPRAICommentViewModel {
* Disacrd the comment
*/
fun reject()
}
@ApiStatus.Internal
interface GHPRAICommentChatViewModel {
val messages: SharedFlow<GHPRAIReviewCommentChatMessage>
val newMessageVm: GHPRAIChatNewCommentViewModel
fun destroy()
fun summarize()
}
@ApiStatus.Internal
interface GHPRAIChatNewCommentViewModel : CodeReviewSubmittableTextViewModel {
fun submit()
fun summarize()
}

View File

@@ -1,8 +1,10 @@
// 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.assistedReview
package org.jetbrains.plugins.github.ai
import kotlinx.coroutines.flow.SharedFlow
import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
interface GHPRAIReviewCommentChatViewModel {
/**
* Should emit both my messages and chat responses (in MD)
@@ -20,4 +22,5 @@ interface GHPRAIReviewCommentChatViewModel {
suspend fun summarizeDiscussion()
}
@ApiStatus.Internal
data class GHPRAIReviewCommentChatMessage(val message: String, val isResponse: Boolean)

View File

@@ -1,23 +0,0 @@
// 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.DynamicBundle
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.NotNull
import org.jetbrains.annotations.PropertyKey
import java.util.function.Supplier
object GithubAIBundle {
private const val BUNDLE: @NonNls String = "messages.GithubAIBundle"
private val INSTANCE: DynamicBundle = DynamicBundle(GithubAIBundle::class.java, BUNDLE)
fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any?): @Nls String {
return INSTANCE.getMessage(key, *params)
}
@NotNull
fun messagePointer(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any?): Supplier<String> {
return INSTANCE.getLazyMessage(key, *params)
}
}

View File

@@ -1,37 +0,0 @@
// 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.assistedReview
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class FileReviewAiResponse(
val summary: String,
val comments: List<ReviewCommentAiResponse>,
)
@Serializable
data class NamedFileReviewAiResponse(
val filename: String,
val summary: String,
val comments: List<ReviewCommentAiResponse>,
)
@Serializable
data class ReviewCommentAiResponse(
@SerialName("line_number")
val lineNumber: Int,
val reasoning: String,
val comment: String,
)
fun FileReviewAiResponse.sorted() = FileReviewAiResponse(
summary,
comments.sortedBy { it.lineNumber }
)
fun NamedFileReviewAiResponse.sorted() = NamedFileReviewAiResponse(
filename,
summary,
comments.sortedBy { it.lineNumber }
)

View File

@@ -1,115 +0,0 @@
// 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.assistedReview
import com.intellij.collaboration.async.combineState
import com.intellij.collaboration.ui.codereview.comment.CodeReviewSubmittableTextViewModelBase
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.collaboration.util.SingleCoroutineLauncher
import com.intellij.openapi.project.Project
import com.intellij.platform.util.coroutines.childScope
import git4idea.changes.GitTextFilePatchWithHistory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.*
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIComment
import org.jetbrains.plugins.github.ai.assistedReview.model.accept
import org.jetbrains.plugins.github.ai.assistedReview.model.discard
import org.jetbrains.plugins.github.ai.assistedReview.model.mapToLocation
import org.jetbrains.plugins.github.pullrequest.comment.convertToHtml
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
class GHPRAIReviewCommentDiffViewModel internal constructor(
private val project: Project,
parentCs: CoroutineScope,
private val reviewVm: GHPRAiAssistantReviewViewModel,
private val data: GHPRAIComment,
change: RefComparisonChange,
diffData: GitTextFilePatchWithHistory,
) : GHPRAICommentViewModel {
private val cs = parentCs.childScope(javaClass.name)
private val taskLauncher = SingleCoroutineLauncher(cs)
private val dataState = MutableStateFlow(data)
override val key: Any = data.id
override val location: DiffLineLocation? = data.position.mapToLocation(change.revisionNumberAfter.asString(), diffData)
override val isVisible: StateFlow<Boolean> = data.accepted.combineState(data.rejected) { acc, rej ->
!acc && !rej
}
override val textHtml: StateFlow<String> by lazy {
MutableStateFlow(data.textHtml.convertToHtml(project))
}
override val isBusy: StateFlow<Boolean> = taskLauncher.busy
override val chatVm: MutableStateFlow<GHPRAICommentChatViewModel?> = MutableStateFlow(null)
override fun startChat() {
chatVm.getAndUpdate {
GHPRAICommentChatViewModel(project, cs) {
reviewVm.startThreadOnComment(data.id)
}.also {
it.newMessageVm.requestFocus()
}
}?.destroy()
}
override fun accept() {
data.accept()
}
override fun reject() {
data.discard()
}
fun update(data: GHPRAIComment) {
dataState.value = data
}
}
class GHPRAICommentChatViewModel(private val project: Project, parentCs: CoroutineScope, chatCreator: suspend () -> GHPRAIReviewCommentChatViewModel) {
private val cs = parentCs.childScope()
private val chatState: StateFlow<GHPRAIReviewCommentChatViewModel?> = flow {
val chat = chatCreator()
emit(chat)
}.stateIn(cs, SharingStarted.Eagerly, null)
@OptIn(ExperimentalCoroutinesApi::class)
val messages: SharedFlow<GHPRAIReviewCommentChatMessage> = chatState.flatMapLatest {
it?.messages?.map {
it.copy(message = it.message.convertToHtml(project))
} ?: flow {}
}.shareIn(cs, SharingStarted.Eagerly, Int.MAX_VALUE)
val newMessageVm: GHPRAIChatNewCommentViewModel = GHPRAIChatNewCommentViewModel(project, cs, "", chatState)
fun destroy() {
cs.cancel()
}
fun summarize() {
newMessageVm.summarize()
}
}
class GHPRAIChatNewCommentViewModel(
project: Project, cs: CoroutineScope, initialText: String,
private val chatState: StateFlow<GHPRAIReviewCommentChatViewModel?>,
)
: CodeReviewSubmittableTextViewModelBase(project, cs, initialText) {
fun submit() {
submit { toSubmit ->
text.value = ""
chatState.value?.sendMessage(toSubmit)
}
}
fun summarize() {
submit {
chatState.value?.summarizeDiscussion()
}
}
}

View File

@@ -1,29 +0,0 @@
// 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.assistedReview
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import git4idea.changes.GitTextFilePatchWithHistory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
import org.jetbrains.plugins.github.pullrequest.ui.diff.GHPRAICommentViewModelProvider
class GHPRAIReviewCommentViewModelProvider : GHPRAICommentViewModelProvider {
@OptIn(ExperimentalCoroutinesApi::class)
override fun getComments(
project: Project,
dataProvider: GHPRDataProvider,
change: RefComparisonChange,
diffData: GitTextFilePatchWithHistory,
): Flow<List<GHPRAICommentViewModel>> =
project.service<GHPRAIReviewToolwindowViewModel>().requestedReview
.flatMapLatest { reviewVmOrNull ->
val reviewVm = reviewVmOrNull ?: return@flatMapLatest flowOf(listOf())
reviewVm.getAICommentsForDiff(change, diffData)
}
}

View File

@@ -1,32 +0,0 @@
// 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.assistedReview
import com.intellij.ml.llm.MLLlmIcons
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
import org.jetbrains.plugins.github.pullrequest.action.GHPRActionKeys
class GHPRAIReviewOpenAction : AnAction("Open Review Buddy", null, MLLlmIcons.AiAssistantColored) {
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = false
e.project ?: return
e.getData(GHPRActionKeys.PULL_REQUEST_DATA_PROVIDER) ?: return
e.getData(GHPRActionKeys.PULL_REQUEST_REPOSITORY) ?: return
e.presentation.isEnabledAndVisible = true
}
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val dataProvider = e.getData(GHPRActionKeys.PULL_REQUEST_DATA_PROVIDER) ?: return
val gitRepository = e.getData(GHPRActionKeys.PULL_REQUEST_REPOSITORY) ?: return
project.service<GHPRAIReviewToolwindowViewModel>()
.requestReview(dataProvider, gitRepository)
}
}

View File

@@ -1,304 +0,0 @@
// 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.assistedReview
import com.intellij.collaboration.async.collectScoped
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.ui.*
import com.intellij.collaboration.ui.layout.SizeRestrictedSingleComponentLayout
import com.intellij.collaboration.ui.util.DimensionRestrictions
import com.intellij.collaboration.ui.util.bindIconIn
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.EDT
import com.intellij.openapi.observable.util.addMouseHoverListener
import com.intellij.ui.JBColor
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBLayeredPane
import com.intellij.ui.hover.HoverListener
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import org.jetbrains.plugins.github.ai.GithubAIBundle
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIComment
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIReview
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIReviewFile
import java.awt.*
import java.awt.event.ActionListener
import javax.swing.*
import javax.swing.event.ChangeListener
object GHPRAIReviewReviewComponentFactory {
fun create(scope: CoroutineScope, vm: GHPRAiAssistantReviewViewModel): JComponent {
val reviewPanel = VerticalListPanel(10).apply {
border = JBUI.Borders.empty(8)
}
val stickyHeaderComponents = mutableListOf<Pair<JComponent, () -> JComponent>>()
val isLoading = MutableStateFlow(true)
scope.launchNow(Dispatchers.EDT) {
isLoading.collect {
if (it) {
reviewPanel.removeAll()
reviewPanel.add(LoadingTextLabel())
}
reviewPanel.revalidate()
reviewPanel.repaint()
}
}
scope.launchNow(Dispatchers.EDT) {
vm.state.collectScoped { review ->
val cs = this
if (review == null) {
return@collectScoped
}
isLoading.value = false
reviewPanel.removeAll()
stickyHeaderComponents.clear()
/*when (it) {
is AiReviewSummaryReceived -> {
reviewPanel.add(createAiResponseComponent(vm, it.summary, emptyList(), stickyHeaderComponents))
reviewPanel.add(JLabel("Loading comments..."))
}
is AiReviewCompleted -> {
reviewPanel.add(createAiResponseComponent(vm, it.summary, it.sortedFilesResponse, stickyHeaderComponents))
}
is AiReviewFailed -> {
reviewPanel.add(JLabel("Error during AI review: ${it.error}"))
}
else -> {
error("Invalid code path")
}
}*/
reviewPanel.add(cs.createAiResponseComponent(vm, review, stickyHeaderComponents))
reviewPanel.revalidate()
reviewPanel.repaint()
}
}
return withStickyHeader(
ScrollPaneFactory.createScrollPane(reviewPanel, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER),
stickyHeaderComponents
)
}
private fun withStickyHeader(
scrollPane: JScrollPane,
componentsToCheckViewport: List<Pair<JComponent, () -> JComponent>>,
): JComponent {
val stickyPanel = BorderLayoutPanel().apply {
border = JBUI.Borders.empty(5, 8, 0, 8)
}
val viewPortListener = ChangeListener {
val result = componentsToCheckViewport.lastOrNull { (component, _) ->
component.bounds.y < scrollPane.viewport.viewPosition.y
}
if (result != null) {
val provider = result.second
stickyPanel.removeAll()
val content = provider()
stickyPanel.addToCenter(content)
stickyPanel.setBounds(0, 0, scrollPane.width - JBUI.scale(12), JBUI.scale(35))
}
else {
stickyPanel.setBounds(0, 0, 0, 0)
stickyPanel.removeAll()
}
stickyPanel.revalidate()
stickyPanel.repaint()
}
scrollPane.viewport.addChangeListener(viewPortListener)
val contentPanel: JComponent = object : JBLayeredPane() {
override fun getPreferredSize(): Dimension = scrollPane.preferredSize
override fun doLayout() {
scrollPane.setBounds(0, 0, width, height)
}
}.apply {
isFocusable = false
add(scrollPane, JLayeredPane.DEFAULT_LAYER, 0)
add(stickyPanel, JLayeredPane.POPUP_LAYER, 1)
}
return contentPanel
}
private fun CoroutineScope.createAiResponseComponent(vm: GHPRAiAssistantReviewViewModel,
review: GHPRAIReview,
stickyHeaderComponents: MutableList<Pair<JComponent, () -> JComponent>>): JComponent {
return VerticalListPanel(5).apply {
add(createSummaryComponent(review.ideaHtml, review.summaryHtml))
val files = review.files
for (fileReview in files) {
val fileReviewComponent = createFileReviewComponent(vm, fileReview) ?: continue
stickyHeaderComponents.add(fileReviewComponent to {
createSingleFileComponent(vm, fileReview)
})
add(fileReviewComponent)
}
if (!review.reviewCompleted) {
add(LoadingTextLabel())
}
}
}
private fun CoroutineScope.createSummaryComponent(idea: String, summary: String?): JComponent {
return VerticalListPanel(5).apply {
border = JBUI.Borders.empty(10)
add(JLabel(GithubAIBundle.message("pull.request.summary")).apply {
font = font.deriveFont(Font.BOLD, 16f)
})
add(SimpleHtmlPane(idea))
if(summary != null){
val collapsed = MutableStateFlow(true)
val link = ActionLink("") {
collapsed.update { !it }
}
val summaryPane = SimpleHtmlPane(summary)
launchNow {
collapsed.collect {
if (it) {
link.text = GithubAIBundle.message("more.details")
link.setIcon(AllIcons.Actions.ArrowExpand, true)
summaryPane.isVisible = false
}
else {
link.text = GithubAIBundle.message("hide.details")
link.setIcon(AllIcons.Actions.ArrowCollapse, true)
summaryPane.isVisible = true
}
}
}
add(link)
add(summaryPane)
}
}
}
private fun CoroutineScope.createFileReviewComponent(vm: GHPRAiAssistantReviewViewModel, file: GHPRAIReviewFile): JComponent {
return VerticalListPanel(5).apply {
add(createSingleFileComponent(vm, file))
file.comments.let {
createFileHighlightsComponent(vm, file, it)
}.also(::add)
}
}
private fun createSingleFileComponent(vm: GHPRAiAssistantReviewViewModel, file: GHPRAIReviewFile): JComponent {
val singleFilePanelContent = BorderLayoutPanel().apply {
border = JBUI.Borders.empty(5, 10)
background = JBColor(0xEBECF0, 0x494B57)
val fileLink = ActionLink(file.req.path.name, ActionListener {
vm.showDiffFor(file.req.changeToNavigate)
}).apply {
foreground = UIUtil.getLabelForeground()
setIcon(file.req.path.fileType.icon)
minimumSize = Dimension(0, 0)
}
addToCenter(fileLink)
}
val singleFilePanel = ClippingRoundedPanel(arcRadius = 5, layoutManager = BorderLayout()).apply {
add(singleFilePanelContent, BorderLayout.CENTER)
}
singleFilePanel.addMouseHoverListener(null, object : HoverListener() {
override fun mouseExited(component: Component) {
singleFilePanelContent.background = JBColor(0xEBECF0, 0x494B57)
singleFilePanelContent.repaint()
}
override fun mouseMoved(component: Component, x: Int, y: Int) {
}
override fun mouseEntered(component: Component, x: Int, y: Int) {
singleFilePanelContent.background = Color(223, 225, 229)
singleFilePanelContent.repaint()
}
})
return singleFilePanel
}
private fun CoroutineScope.createFileHighlightsComponent(vm: GHPRAiAssistantReviewViewModel, file: GHPRAIReviewFile, highlights: List<GHPRAIComment>): JComponent {
return VerticalListPanel(5).apply {
border = JBUI.Borders.emptyLeft(5)
for (highlight in highlights) {
add(createAiCommentComponent(vm, file.req.changeToNavigate, highlight))
}
}
}
private fun CoroutineScope.createAiCommentComponent(vm: GHPRAiAssistantReviewViewModel, change: RefComparisonChange, comment: GHPRAIComment): JComponent {
val cs = this
val icon = JBLabel(AllIcons.General.InspectionsEye).apply {
bindIconIn(cs, comment.accepted.combine(comment.rejected) { acc, rej ->
if (acc) {
AllIcons.General.InspectionsOK
}
else if (rej) {
AllIcons.General.InspectionsPause
}
else {
AllIcons.General.InspectionsEye
}
})
}
val lineLink = ActionLink(GithubAIBundle.message("line", comment.position.lineIndex)).apply {
addActionListener {
vm.showDiffFor(change, comment.position.lineIndex)
}
}
val commentPanelL = SizeRestrictedSingleComponentLayout()
val commentPane = SimpleHtmlPane(comment.textHtml)
val commentPanel = JPanel(commentPanelL).apply {
isOpaque = false
add(commentPane)
}
val iconAndLine = JPanel(BorderLayout()).apply {
add(
HorizontalListPanel(5).apply {
add(icon)
add(lineLink)
},
BorderLayout.NORTH
)
}
val reasonComponent = SimpleHtmlPane(comment.reasoningHtml).apply {
foreground = UIUtil.getContextHelpForeground()
}
cs.launchNow {
comment.accepted.combine(comment.rejected) { acc, rej -> acc || rej }.collect { hide ->
reasonComponent.isVisible = !hide
commentPanelL.maxSize =
if (hide) DimensionRestrictions.LinesHeight(commentPanel, 1)
else DimensionRestrictions.None
commentPane.foreground = if (hide) UIUtil.getContextHelpForeground() else UIUtil.getLabelForeground()
}
}
return BorderLayoutPanel().apply {
addToLeft(iconAndLine)
addToCenter(VerticalListPanel(5).apply {
border = JBUI.Borders.emptyLeft(5)
add(commentPanel)
add(reasonComponent)
})
}
}
}

View File

@@ -1,340 +0,0 @@
// 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.assistedReview
import ai.grazie.model.llm.chat.v5.LLMChat
import ai.grazie.model.task.data.TaskStreamData
import com.intellij.collaboration.async.mapDataToModel
import com.intellij.collaboration.async.nestedDisposable
import com.intellij.collaboration.async.stateInNow
import com.intellij.collaboration.async.withInitial
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
import com.intellij.collaboration.util.ChangesSelection
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.collaboration.util.filePath
import com.intellij.diff.util.Side
import com.intellij.ml.llm.core.grazieAPI.GrazieApiClient
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.PrivacySafeTaskCall
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddyDiscussionCommentTaskCallBuilder
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddyReviewAllFilesTaskCallBuilder
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddyReviewFileTaskCallBuilder
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddySummarizeDiscussionTaskCallBuilder
import com.intellij.ml.llm.grazieAPIAdapters.tasksFacade.ReviewBuddySummarizeTaskCallBuilder
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.progress.coroutineToIndicator
import com.intellij.openapi.project.Project
import com.intellij.openapi.vcs.FilePath
import com.intellij.platform.util.coroutines.childScope
import git4idea.changes.GitTextFilePatchWithHistory
import git4idea.changes.createVcsChange
import git4idea.repo.GitRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.jetbrains.plugins.github.ai.GithubAIBundle
import org.jetbrains.plugins.github.ai.assistedReview.llm.waitForCompletion
import org.jetbrains.plugins.github.ai.assistedReview.model.AIComment
import org.jetbrains.plugins.github.ai.assistedReview.model.AIFileReviewResponse
import org.jetbrains.plugins.github.ai.assistedReview.model.AIFileReviewsPartiallyReceived
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewCompleted
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewFailed
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewRequested
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewResponse
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewResponseState
import org.jetbrains.plugins.github.ai.assistedReview.model.AIReviewSummaryReceived
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIComment
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAICommentPosition
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIReview
import org.jetbrains.plugins.github.ai.assistedReview.model.GHPRAIReviewFile
import org.jetbrains.plugins.github.ai.assistedReview.model.ReviewFileAIData
import org.jetbrains.plugins.github.pullrequest.comment.convertToHtml
import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider
import org.jetbrains.plugins.github.pullrequest.ui.toolwindow.model.GHPRToolWindowViewModel
import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.forEach
import kotlin.collections.set
import kotlin.coroutines.cancellation.CancellationException
/**
* Represents the results of an AI-reviewed PR.
*/
class GHPRAiAssistantReviewViewModel internal constructor(
private val project: Project,
private val cs: CoroutineScope,
private val dataProvider: GHPRDataProvider,
private val gitRepository: GitRepository,
) {
private val prId = dataProvider.id
private val changesData = dataProvider.changesData
val state: MutableStateFlow<GHPRAIReview?> = MutableStateFlow(null)
private val commentChatInitialChats = ConcurrentHashMap<AIComment, LLMChat>()
private val commentChats = ConcurrentHashMap<AIComment, GHPRAIReviewCommentChatViewModel>()
private val grazieClient = GrazieApiClient.getClient()
init {
loadReview()
}
fun showDiffFor(change: RefComparisonChange, line: Int? = null) {
cs.launch(Dispatchers.Main) {
val projectVm = project.serviceAsync<GHPRToolWindowViewModel>().projectVm.value ?: return@launch
projectVm.openPullRequestDiff(prId, true)
val changes = dataProvider.changesData.loadChanges().changes
val location = line?.let { DiffLineLocation(Side.RIGHT, it) }
val selection = ChangesSelection.Precise(changes, change, location)
val disposable = nestedDisposable()
projectVm.acquireDiffViewModel(prId, disposable).showDiffFor(selection)
}
}
fun startThreadOnComment(comment: AIComment): GHPRAIReviewCommentChatViewModel {
val initialChat = commentChatInitialChats[comment] ?: error("Initial chat comment context not found")
return commentChats.getOrPut(comment) { AiAssistantReviewCommentChat(comment, initialChat) }
}
private fun startReview(
files: List<ReviewFileAIData>,
runQueryPerFile: Boolean = true,
): AIReviewResponse {
val state = MutableStateFlow<AIReviewResponseState>(AIReviewRequested)
val reviewRequestScope = cs.childScope("Review requested")
reviewRequestScope.launch {
val reviewSummaryCompletableMessage = getReviewSummary(files)
val summary: String
try {
summary = reviewSummaryCompletableMessage.waitForCompletion()
}
catch (_: CancellationException) {
state.emit(AIReviewFailed(error = "Cancelled"))
return@launch
}
catch (e: Exception) {
state.emit(AIReviewFailed(error = e.localizedMessage))
return@launch
}
state.emit(AIReviewSummaryReceived(summary = summary))
reviewRequestScope.launch {
val fileReviews = if (runQueryPerFile) {
val reviewedFiles = mutableListOf<AIFileReviewResponse>()
files.forEach { file ->
reviewedFiles.add(getFileReview(summary, file))
state.emit(AIFileReviewsPartiallyReceived(summary, reviewedFiles.toList()))
}
reviewedFiles
}
else {
getAllFileReviews(summary, files)
}
state.emit(AIReviewCompleted(summary, fileReviews))
}
throw CancellationException("Summary received")
}
return AIReviewResponse(state)
}
private suspend fun getReviewSummary(files: List<ReviewFileAIData>): Flow<TaskStreamData> {
val task = ReviewBuddySummarizeTaskCallBuilder(files.map { it.toTaskData() }).build()
return grazieClient.sendTaskRequest(project, task) ?: error("Failed to send task")
}
private suspend fun getFileReview(summary: String, file: ReviewFileAIData): AIFileReviewResponse {
val taskBuilder = ReviewBuddyReviewFileTaskCallBuilder(summary, file.toTaskData())
val task = taskBuilder.build()
val response = grazieClient.sendTaskRequest(project, task)?.waitForCompletion() ?: error("Failed to send task")
val jsonResponse = extractJsonFromResponse(response)
val parsedResponse = try {
parseFileReview(jsonResponse)
}
catch (e: Exception) {
thisLogger().warn("Failed to parse JSON response: $jsonResponse", e)
FileReviewAiResponse(summary = "Failed to parse JSON response: ${e.localizedMessage}", comments = emptyList())
}
val chat = LLMChat.build {
messages(taskBuilder.asChat())
assistant(response)
}
val aiComments = parsedResponse.comments.map { it.toModelComment() }
aiComments.forEach { commentChatInitialChats[it] = chat }
return AIFileReviewResponse(
file.rawLocalPath,
parsedResponse.summary,
highlights = emptyList(),
comments = aiComments
)
}
private suspend fun getAllFileReviews(summary: String, files: List<ReviewFileAIData>): List<AIFileReviewResponse> {
val task = ReviewBuddyReviewAllFilesTaskCallBuilder(summary, files.map { it.toTaskData() }).build()
val response = grazieClient.sendTaskRequest(project, task) ?: error("Failed to send task")
val jsonResponse = extractJsonFromResponse(response.waitForCompletion())
val parsedResponse = try {
parseAllFileReviews(jsonResponse)
}
catch (e: Exception) {
thisLogger().warn("Failed to parse JSON response: $jsonResponse", e)
emptyList()
}
return parsedResponse.map { fileResponse ->
AIFileReviewResponse(
fileResponse.filename,
fileResponse.summary,
highlights = emptyList(),
comments = fileResponse.comments.map { it.toModelComment() }
)
}
}
private fun parseFileReview(jsonResponse: String): FileReviewAiResponse =
Json.decodeFromString<FileReviewAiResponse>(jsonResponse).sorted()
private fun parseAllFileReviews(jsonResponse: String): List<NamedFileReviewAiResponse> =
Json.decodeFromString<List<NamedFileReviewAiResponse>>(jsonResponse).map { it.sorted() }
private fun ReviewCommentAiResponse.toModelComment(): AIComment =
AIComment(lineNumber, reasoning, comment)
val comments: StateFlow<Collection<GHPRAIComment>?> =
state.map {
it?.files?.map { it.comments }?.flatten().orEmpty()
}.stateInNow(cs, emptyList())
fun getAICommentsForDiff(change: RefComparisonChange, diffData: GitTextFilePatchWithHistory): Flow<List<GHPRAIReviewCommentDiffViewModel>> =
state
.map { st -> st?.files?.find { it.req.rawLocalPath == change.filePath.let(::toLocalPath) }?.comments.orEmpty() }
.mapDataToModel({ it.id }, { createAiComment(it, change, diffData) }, { update(it) })
private fun CoroutineScope.createAiComment(comment: GHPRAIComment, change: RefComparisonChange, diffData: GitTextFilePatchWithHistory): GHPRAIReviewCommentDiffViewModel =
GHPRAIReviewCommentDiffViewModel(project, this@createAiComment, this@GHPRAiAssistantReviewViewModel, comment, change, diffData)
fun toLocalPath(filePath: FilePath): String =
filePath.path.removePrefix(gitRepository.root.path).removePrefix("/")
private fun loadReview() {
cs.launch {
changesData.changesNeedReloadSignal.withInitial(Unit).collectLatest {
val changes = changesData.loadChanges().changes
changesData.ensureAllRevisionsFetched()
val files = changes.mapNotNull {
val filePath = it.filePathAfter ?: return@mapNotNull null
val localFilePath = toLocalPath(filePath)
val (before, after) = withContext(Dispatchers.IO) {
coroutineToIndicator {
val change = it.createVcsChange(project)
change.beforeRevision?.content to change.afterRevision?.content
}
}
ReviewFileAIData(filePath, localFilePath, it, before, after)
}
val response = startReview(files)
response.state.collect {
when (it) {
AIReviewRequested -> Unit
is AIReviewSummaryReceived -> {
val (idea, sum) = splitSummary(it.summary)
state.value = GHPRAIReview(idea.convertToHtml(project), sum?.convertToHtml(project), reviewCompleted = false)
}
is AIFileReviewsPartiallyReceived -> {
val (idea, sum) = splitSummary(it.summary)
val filesRes = buildViewModel(it.reviewedFiles, files)
state.value = GHPRAIReview(idea.convertToHtml(project), sum?.convertToHtml(project), filesRes, reviewCompleted = false)
}
is AIReviewCompleted -> {
val (idea, sum) = splitSummary(it.summary)
val sortedFilesResponse = it.sortedFilesResponse
val filesRes = buildViewModel(sortedFilesResponse, files)
state.value = GHPRAIReview(idea.convertToHtml(project), sum?.convertToHtml(project), filesRes, reviewCompleted = true)
}
is AIReviewFailed -> Unit
}
}
}
}
}
/**
* Store already created [GHPRAIReviewFile] between [buildViewModel] calls.
*/
private val alreadyBuiltFileReviews = ConcurrentHashMap<AIFileReviewResponse, GHPRAIReviewFile>()
private fun buildViewModel(
fileReviewResponse: List<AIFileReviewResponse>,
files: List<ReviewFileAIData>,
): List<GHPRAIReviewFile> {
val filesRes = fileReviewResponse.mapNotNull { fileReview ->
val req = files.find { it.rawLocalPath == fileReview.file } ?: return@mapNotNull null
alreadyBuiltFileReviews.getOrPut(fileReview) { buildReviewFile(fileReview, req) }
}
return filesRes
}
private fun buildReviewFile(fileReview: AIFileReviewResponse, req: ReviewFileAIData): GHPRAIReviewFile {
val comments = fileReview.comments.map { comment ->
GHPRAIComment(
comment,
GHPRAICommentPosition(fileReview.file, comment.lineNumber - 1),
comment.comment.convertToHtml(project),
comment.reasoning.convertToHtml(project),
comment.comment,
comment.reasoning
)
}
return GHPRAIReviewFile(fileReview.file, fileReview.summary, comments, req)
}
private fun splitSummary(summary: String): Pair<String, String?> =
summary.removePrefix("## Pull Request Summary").split("## Main idea").let {
if (it.size < 2) return it.first().trim() to null else it[1].trim() to it.first().trim()
}
private inner class AiAssistantReviewCommentChat(
private val aiComment: AIComment,
initialChat: LLMChat,
) : GHPRAIReviewCommentChatViewModel {
private var chat = initialChat
override val messages: MutableSharedFlow<GHPRAIReviewCommentChatMessage> = MutableSharedFlow(replay = Int.MAX_VALUE)
override suspend fun sendMessage(message: String) {
val task = ReviewBuddyDiscussionCommentTaskCallBuilder(aiComment.lineNumber, message, chat).build()
processDiscussionMessage(message, task)
}
override suspend fun summarizeDiscussion() {
val message = GithubAIBundle.message("review.buddy.llm.discussion.summarize.display")
val task = ReviewBuddySummarizeDiscussionTaskCallBuilder(chat).build()
processDiscussionMessage(message, task)
}
private suspend fun processDiscussionMessage(message: String, task: PrivacySafeTaskCall) {
messages.emit(GHPRAIReviewCommentChatMessage(message, isResponse = false))
val response = grazieClient.sendTaskRequest(project, task) ?: error("Failed to send task")
cs.childScope("AI assistant response").launch {
val response = response.waitForCompletion()
messages.emit(GHPRAIReviewCommentChatMessage(response, isResponse = true))
}
}
}
}

View File

@@ -1,34 +0,0 @@
// 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.assistedReview
import com.intellij.openapi.application.EDT
import com.intellij.util.ui.components.BorderLayoutPanel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.plugins.github.ai.GithubAIBundle
import javax.swing.JComponent
import javax.swing.JLabel
object GHPRAIReviewToolwindowComponentFactory {
fun create(scope: CoroutineScope, vm: GHPRAIReviewToolwindowViewModel): JComponent {
val panel = BorderLayoutPanel().apply {
add(JLabel(GithubAIBundle.message("request.ai.review.in.review.toolwindow")))
}
scope.launch {
vm.requestedReview.collect {
withContext(Dispatchers.EDT) {
panel.removeAll()
if (it != null) {
panel.addToCenter(GHPRAIReviewReviewComponentFactory.create(scope, it))
}
panel.revalidate()
panel.repaint()
}
}
}
return panel
}
}

View File

@@ -1,71 +0,0 @@
// 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.assistedReview
import com.intellij.collaboration.ui.toolwindow.dontHideOnEmptyContent
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.wm.impl.content.ToolWindowContentUi
import com.intellij.platform.util.coroutines.childScope
import com.intellij.ui.content.ContentFactory
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.*
import org.jetbrains.plugins.github.ai.GithubAIBundle
internal class GHPRAIReviewToolwindowFactory : ToolWindowFactory, DumbAware {
override fun init(toolWindow: ToolWindow) {
toolWindow.setStripeShortTitleProvider(GithubAIBundle.messagePointer("tab.title.pr.ai.assistant"))
}
override suspend fun manage(toolWindow: ToolWindow, toolWindowManager: ToolWindowManager) {
toolWindow.project.serviceAsync<GHPRAIReviewToolwindowController>().manageIconInToolbar(toolWindow)
}
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
project.service<GHPRAIReviewToolwindowController>().manageContent(toolWindow)
}
override fun shouldBeAvailable(project: Project): Boolean =
ApplicationManager.getApplication().isInternal
}
@Service(Service.Level.PROJECT)
private class GHPRAIReviewToolwindowController(private val project: Project, parentCs: CoroutineScope) {
private val cs = parentCs.childScope(Dispatchers.Main)
suspend fun manageIconInToolbar(toolWindow: ToolWindow) {
coroutineScope {
val vm = project.serviceAsync<GHPRAIReviewToolwindowViewModel>()
launch {
vm.activationRequests.collect {
withContext(Dispatchers.EDT) {
toolWindow.activate(null)
}
}
}
}
}
@RequiresEdt
fun manageContent(toolWindow: ToolWindow) {
toolWindow.component.putClientProperty(ToolWindowContentUi.HIDE_ID_LABEL, "true")
// so it's not closed when all content is removed
cs.launch {
val vm = project.serviceAsync<GHPRAIReviewToolwindowViewModel>()
toolWindow.dontHideOnEmptyContent()
val displayName = GithubAIBundle.message("tab.title.github.review.buddy")
val component = GHPRAIReviewToolwindowComponentFactory.create(cs, vm)
toolWindow.contentManager.addContent(ContentFactory.getInstance().createContent(component, displayName, false))
}
}
}

View File

@@ -1,44 +0,0 @@
// 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.assistedReview
import com.intellij.openapi.components.Service
import com.intellij.openapi.project.Project
import com.intellij.platform.util.coroutines.childScope
import git4idea.repo.GitRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider
/**
* Represents the whole right-side tool window displaying AI assisted review results.
*/
@Service(Service.Level.PROJECT)
class GHPRAIReviewToolwindowViewModel(
private val project: Project, parentCs: CoroutineScope,
) {
private val cs = parentCs.childScope(Dispatchers.Main)
private val _activationRequests = MutableSharedFlow<Unit>(1)
internal val activationRequests: Flow<Unit> = _activationRequests.asSharedFlow()
private val _requestedReview = MutableSharedFlow<GHPRAiAssistantReviewViewModel>(1)
internal val requestedReview: StateFlow<GHPRAiAssistantReviewViewModel?> = _requestedReview.stateIn(cs, Eagerly, null)
fun activate() {
_activationRequests.tryEmit(Unit)
}
fun requestReview(dataProvider: GHPRDataProvider, gitRepository: GitRepository) {
activate()
cs.launch {
_requestedReview.emit(GHPRAiAssistantReviewViewModel(project, cs, dataProvider, gitRepository))
}
}
}

View File

@@ -1,14 +0,0 @@
// 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.assistedReview
fun extractJsonFromResponse(response: String): String {
val extractJsonRegex = Regex("```json\n([\\s\\S]*?)\n```")
val matches = extractJsonRegex.findAll(response)
val jsonString = matches.firstOrNull()?.value ?: response
val openSymbol = jsonString.indexOfFirst { it == '[' || it == '{' }
val closeSymbol = jsonString.indexOfLast { it == ']' || it == '}' }
if (openSymbol != -1 && closeSymbol != -1) {
return jsonString.substring(openSymbol, closeSymbol + 1)
}
return jsonString
}

View File

@@ -1,17 +0,0 @@
// 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.assistedReview.llm
import ai.grazie.model.task.data.TaskStreamData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
suspend fun Flow<TaskStreamData>.waitForCompletion(): String {
if (this is SharedFlow) {
error("Cannot wait for completion of a hot flow. It will never complete.")
}
val result = StringBuilder()
collect { result.append(it.content) }
return result.toString()
}

View File

@@ -1,71 +0,0 @@
// 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.assistedReview.model
import com.intellij.collaboration.util.RefComparisonChange
import com.intellij.ml.llm.core.grazieAPI.tasks.vcs.reviewBuddy.ReviewBuddyFileData
import com.intellij.openapi.vcs.FilePath
import kotlinx.coroutines.flow.StateFlow
data class ReviewFileAIData(
val path: FilePath,
val rawLocalPath: String,
val changeToNavigate: RefComparisonChange,
val contentBefore: String?,
val contentAfter: String?
) {
fun toTaskData() =
ReviewBuddyFileData(rawLocalPath, contentBefore, contentAfter)
}
sealed interface AIReviewResponseState
data object AIReviewRequested : AIReviewResponseState
data class AIReviewSummaryReceived(val summary: String) : AIReviewResponseState
data class AIFileReviewsPartiallyReceived(
val summary: String,
val reviewedFiles: List<AIFileReviewResponse>
) : AIReviewResponseState
data class AIReviewCompleted(
val summary: String,
val sortedFilesResponse: List<AIFileReviewResponse>
) : AIReviewResponseState
data class AIReviewFailed(
val error: String
) : AIReviewResponseState
class AIReviewResponse(
/**
* The normal flow is: [AIReviewRequested] &rarr; [AIReviewSummaryReceived] &rarr; [AIReviewCompleted].
*
* [AIReviewFailed] might occur after each state except after [AIReviewCompleted].
*/
val state: StateFlow<AIReviewResponseState>
)
data class AIFileReviewResponse(
val file: String,
val summary: String,
val highlights: List<AIComment>,
val comments: List<AIComment>,
)
data class AIComment(
val lineNumber: Int,
val reasoning: String,
val comment: String,
)
data class GHPRAIReview(val ideaHtml: String,
val summaryHtml: String?,
val files: List<GHPRAIReviewFile> = emptyList(),
val reviewCompleted: Boolean)
data class GHPRAIReviewFile(val file: String,
val summary: String,
val comments: List<GHPRAIComment>,
val req: ReviewFileAIData
)

View File

@@ -1,38 +0,0 @@
// 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.assistedReview.model
import com.intellij.collaboration.ui.codereview.diff.DiffLineLocation
import com.intellij.diff.util.Side
import git4idea.changes.GitTextFilePatchWithHistory
import kotlinx.coroutines.flow.MutableStateFlow
data class GHPRAIComment(
val id: AIComment,
val position: GHPRAICommentPosition,
val textHtml: String,
val reasoningHtml: String,
val text: String,
val reasoning: String
) {
val accepted: MutableStateFlow<Boolean> = MutableStateFlow(false)
val rejected: MutableStateFlow<Boolean> = MutableStateFlow(false)
}
fun GHPRAIComment.discard() {
accepted.value = false
rejected.value = true
}
fun GHPRAIComment.accept() {
accepted.value = true
rejected.value = false
}
fun GHPRAICommentPosition.mapToLocation(commitSha: String, diffData: GitTextFilePatchWithHistory, sideBias: Side? = null): DiffLineLocation? {
val commentData = this
if (!diffData.contains(commitSha, commentData.path)) return null
return diffData.mapLine(commitSha, commentData.lineIndex, sideBias ?: Side.RIGHT)
}
data class GHPRAICommentPosition(val path: String, val lineIndex: Int)

View File

@@ -19,6 +19,7 @@ import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.html.GeneratingProvider
import org.intellij.markdown.html.HtmlGenerator
import org.intellij.markdown.parser.LinkMap
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.NonNls
import java.net.URI
@@ -98,7 +99,8 @@ class GHMarkdownToHtmlConverter(private val project: Project?) {
}
}
internal fun String.convertToHtml(project: Project): @NlsSafe String {
@ApiStatus.Internal
fun String.convertToHtml(project: Project): @NlsSafe String {
val processedText = processIssueIdsMarkdown(project, this)
return GHMarkdownToHtmlConverter(project).convertMarkdown(processedText)
}

View File

@@ -31,7 +31,7 @@ import org.jetbrains.plugins.github.api.data.pullrequest.mapToLocation
import org.jetbrains.plugins.github.pullrequest.data.GHPRDataContext
import org.jetbrains.plugins.github.pullrequest.data.provider.GHPRDataProvider
import org.jetbrains.plugins.github.pullrequest.data.provider.threadsComputationFlow
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewCommentLocation
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewCommentPosition
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRThreadsViewModels

View File

@@ -29,7 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewCommentLocation
import org.jetbrains.plugins.github.pullrequest.ui.editor.GHPRAICommentEditorInlayRenderer

View File

@@ -2,20 +2,16 @@
package org.jetbrains.plugins.github.pullrequest.ui.editor
import com.intellij.CommonBundle
import com.intellij.collaboration.async.collectScoped
import com.intellij.collaboration.async.inverted
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.async.mapScoped
import com.intellij.collaboration.async.mapState
import com.intellij.collaboration.async.stateInNow
import com.intellij.collaboration.messages.CollaborationToolsBundle
import com.intellij.collaboration.ui.ComponentListPanelFactory
import com.intellij.collaboration.ui.HorizontalListPanel
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
import com.intellij.collaboration.ui.codereview.comment.CodeReviewAIUIUtil
import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentTextFieldFactory
import com.intellij.collaboration.ui.codereview.comment.CodeReviewCommentUIUtil
import com.intellij.collaboration.ui.codereview.comment.CommentInputActionsComponentFactory
@@ -24,23 +20,19 @@ import com.intellij.collaboration.ui.codereview.timeline.comment.CommentTextFiel
import com.intellij.collaboration.ui.codereview.timeline.thread.CodeReviewResolvableItemViewModel
import com.intellij.collaboration.ui.codereview.timeline.thread.TimelineThreadCommentsPanel
import com.intellij.collaboration.ui.util.*
import com.intellij.ml.llm.MLLlmIcons
import com.intellij.openapi.ui.MessageDialogBuilder
import com.intellij.ui.components.ActionLink
import com.intellij.util.IconUtil
import com.intellij.util.ui.JBUI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import org.jetbrains.plugins.github.i18n.GithubBundle
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel.CommentItem
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewThreadCommentComponentFactory
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRReviewThreadComponentFactory
import javax.swing.AbstractAction
import javax.swing.Action
import javax.swing.Icon
import javax.swing.JComponent
internal object GHPRReviewEditorComponentsFactory {
@@ -174,99 +166,4 @@ internal object GHPRReviewEditorComponentsFactory {
}
}
}
fun createAICommentIn(cs: CoroutineScope, userIcon: Icon, vm: GHPRAICommentViewModel): JComponent {
val textPane = SimpleHtmlPane().apply {
bindTextHtmlIn(cs, vm.textHtml)
}
val actions = HorizontalListPanel(CodeReviewCommentUIUtil.Actions.HORIZONTAL_GAP).apply {
add(CodeReviewCommentUIUtil.createDeleteCommentIconButton {
vm.reject()
}.apply {
bindDisabledIn(cs, vm.isBusy)
})
}
val title = CodeReviewTimelineUIUtil.createTitleTextPane("AI Assistant", null, null)
val commentPanel = CodeReviewChatItemUIUtil.build(CodeReviewChatItemUIUtil.ComponentType.COMPACT,
{ IconUtil.resizeSquared(MLLlmIcons.AiAssistantColored, it) },
textPane) {
iconTooltip = "AI Assistant"
maxContentWidth = null
withHeader(title, actions)
}
val doneAction = swingAction("Done") {
vm.accept()
}
val buttonsPanel = HorizontalListPanel(10).apply {
border = JBUI.Borders.empty(8, CodeReviewChatItemUIUtil.ComponentType.COMPACT.fullLeftShift)
ActionLink("Chat") {
vm.startChat()
}.apply {
isFocusPainted = false
bindDisabledIn(cs, vm.isBusy)
autoHideOnDisable = false
}.also(::add)
ActionLink(doneAction).apply {
isFocusPainted = false
bindDisabledIn(cs, vm.isBusy)
autoHideOnDisable = false
}.also(::add)
}
cs.launchNow {
vm.chatVm.collect {
buttonsPanel.isVisible = it == null
}
}
val chatPanel = VerticalListPanel(gap = 5).apply {
border = JBUI.Borders.empty(10)
}
cs.launchNow {
vm.chatVm.collectScoped { chatVm ->
chatPanel.isVisible = chatVm != null
val messagesPanel = VerticalListPanel(10)
if (chatVm != null) {
launchNow {
chatVm.messages.collect { comment ->
val commentPanel = CodeReviewChatItemUIUtil.build(CodeReviewChatItemUIUtil.ComponentType.SUPER_COMPACT,
{
if (comment.isResponse) IconUtil.resizeSquared(MLLlmIcons.AiAssistantColored, it)
else userIcon
},
SimpleHtmlPane(comment.message)) {
maxContentWidth = null
}
messagesPanel.add(commentPanel)
}
}
chatPanel.add(messagesPanel)
val submitShortcutText = CommentInputActionsComponentFactory.submitShortcutText
val summarizeAction = swingAction("Summarize") {
chatVm.summarize()
}
val input = CodeReviewCommentTextFieldFactory.createIn(cs, chatVm.newMessageVm, CommentInputActionsComponentFactory.Config(
primaryAction = MutableStateFlow(chatVm.newMessageVm.submitActionIn(cs, "Submit") {
chatVm.newMessageVm.submit()
}),
additionalActions = MutableStateFlow(listOf(doneAction, summarizeAction)),
submitHint = MutableStateFlow("$submitShortcutText to submit")
))
chatPanel.add(input)
}
}
}
val panel = VerticalListPanel().apply {
border = JBUI.Borders.empty(CodeReviewCommentUIUtil.getInlayPadding(CodeReviewChatItemUIUtil.ComponentType.COMPACT))
add(commentPanel)
add(chatPanel)
add(buttonsPanel)
}
return CodeReviewCommentUIUtil.createEditorInlayPanel(panel, CodeReviewAIUIUtil.AI_COLOR)
}
}

View File

@@ -4,7 +4,7 @@ package org.jetbrains.plugins.github.pullrequest.ui.editor
import com.intellij.collaboration.ui.codereview.editor.CodeReviewInlayModel
import com.intellij.collaboration.util.Hideable
import kotlinx.coroutines.flow.MutableStateFlow
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
sealed interface GHPREditorMappedComponentModel : CodeReviewInlayModel {

View File

@@ -1,12 +1,17 @@
// 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.pullrequest.ui.editor
import com.intellij.collaboration.async.extensionListFlow
import com.intellij.collaboration.ui.codereview.editor.CodeReviewComponentInlayRenderer
import com.intellij.collaboration.ui.util.bindContentIn
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.ui.components.panels.Wrapper
import kotlinx.coroutines.CoroutineScope
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRAICommentViewModel
import org.jetbrains.plugins.github.ai.GHPRAICommentViewModel
import org.jetbrains.plugins.github.pullrequest.ui.comment.GHPRCompactReviewThreadViewModel
import javax.swing.Icon
import javax.swing.JComponent
@ApiStatus.Internal
class GHPRReviewThreadEditorInlayRenderer internal constructor(cs: CoroutineScope, vm: GHPRCompactReviewThreadViewModel)
@@ -20,7 +25,18 @@ class GHPRNewCommentEditorInlayRenderer internal constructor(cs: CoroutineScope,
GHPRReviewEditorComponentsFactory.createNewCommentIn(cs, vm)
)
interface GHPRAICommentComponentFactory {
companion object {
val EP_NAME = ExtensionPointName<GHPRAICommentComponentFactory>("intellij.vcs.github.commentComponentFactory")
}
fun createAICommentIn(cs: CoroutineScope, userIcon: Icon, vm: GHPRAICommentViewModel): JComponent
}
internal class GHPRAICommentEditorInlayRenderer internal constructor(cs: CoroutineScope, userIcon: Icon, vm: GHPRAICommentViewModel)
: CodeReviewComponentInlayRenderer(
GHPRReviewEditorComponentsFactory.createAICommentIn(cs, userIcon, vm)
)
: CodeReviewComponentInlayRenderer(Wrapper().apply {
bindContentIn(cs, GHPRAICommentComponentFactory.EP_NAME.extensionListFlow()) { extensions ->
val extension = extensions.firstOrNull() ?: return@bindContentIn null
extension.createAICommentIn(cs, userIcon, vm)
}
})