[github] Add an action to generate title and description with AI

GitOrigin-RevId: f1fedd5c53cd42bc51b67426f148e7855e73121c
This commit is contained in:
Chris Lemaire
2024-08-30 16:24:25 +02:00
committed by intellij-monorepo-bot
parent aa2c2605c5
commit c792d53f08
4 changed files with 172 additions and 18 deletions

View File

@@ -6,6 +6,12 @@
<resource-bundle>messages.GithubBundle</resource-bundle>
<extensionPoints>
<extensionPoint qualifiedName="intellij.vcs.github.titleAndDescriptionGenerator"
interface="org.jetbrains.plugins.github.pullrequest.ui.toolwindow.create.GHPRTitleAndDescriptionGeneratorExtension"
dynamic="true"/>
</extensionPoints>
<extensions defaultExtensionNs="com.intellij">
<httpRequestHandler implementation="org.jetbrains.plugins.github.authentication.GHOAuthCallbackHandler"/>
@@ -212,5 +218,7 @@
</action>
<add-to-group group-id="Git.Experimental.Branch.Popup.Actions"/>
</group>
<group id="GitHub.Pull.Request.Create.Title.Actions"/>
</actions>
</idea-plugin>

View File

@@ -3,6 +3,7 @@ package org.jetbrains.plugins.github.pullrequest.ui.toolwindow.create
import com.intellij.collaboration.async.awaitCancelling
import com.intellij.collaboration.async.collectScoped
import com.intellij.collaboration.async.extensionListFlow
import com.intellij.collaboration.async.launchNow
import com.intellij.collaboration.ui.*
import com.intellij.collaboration.ui.CollaborationToolsUIUtil.defaultButton
@@ -21,10 +22,7 @@ import com.intellij.collaboration.ui.util.*
import com.intellij.collaboration.util.*
import com.intellij.icons.AllIcons
import com.intellij.ide.DataManager
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.EdtNoGetDataProvider
import com.intellij.openapi.actionSystem.*
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.editor.colors.EditorColorsManager
@@ -36,16 +34,14 @@ import com.intellij.ui.*
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBOptionButton
import com.intellij.ui.components.panels.Wrapper
import com.intellij.ui.util.preferredWidth
import com.intellij.util.ui.*
import com.intellij.vcs.log.VcsCommitMetadata
import com.intellij.vcs.log.util.VcsUserUtil
import com.intellij.vcsUtil.showAbove
import git4idea.ui.branch.MergeDirectionComponentFactory
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import net.miginfocom.layout.CC
import net.miginfocom.layout.LC
import net.miginfocom.swing.MigLayout
@@ -83,6 +79,7 @@ private const val TEXT_LINK_GAP = 8
@OptIn(FlowPreview::class)
internal object GHPRCreateComponentFactory {
fun createIn(cs: CoroutineScope, vm: GHPRCreateViewModel): JComponent = cs.create(vm)
private fun CoroutineScope.create(vm: GHPRCreateViewModel): JComponent {
@@ -151,7 +148,10 @@ internal object GHPRCreateComponentFactory {
}
}
private fun CoroutineScope.textPanel(vm: GHPRCreateViewModel): JPanel {
@OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.textPanel(vm: GHPRCreateViewModel): JComponent {
val cs = this
val titleField = CodeReviewCreateReviewUIUtil.createTitleEditor(vm.project, message("pull.request.create.title")).apply {
margins = JBUI.insets(EDITOR_MARGINS, EDITOR_MARGINS, 0, EDITOR_MARGINS)
launchNow {
@@ -215,7 +215,50 @@ internal object GHPRCreateComponentFactory {
}
}
}
return textPanel
return Wrapper().apply {
bindContentIn(cs, GHPRTitleAndDescriptionGeneratorExtension.EP_NAME.extensionListFlow()) { extensions ->
if (extensions.isEmpty()) {
return@bindContentIn textPanel
}
val extension = extensions.first()
val wrappedTextPanel = UiDataProvider.wrapComponent(textPanel) {
it[GHPRCreateTitleAndDescriptionGenerationViewModel.DATA_KEY] = vm.titleAndDescriptionGenerationVm.value
}
val actionManager = ActionManager.getInstance()
val actionGroup = actionManager.getAction("GitHub.Pull.Request.Create.Title.Actions") as ActionGroup
val toolbar = actionManager.createActionToolbar("GHCreationTitle", actionGroup, true).apply {
targetComponent = wrappedTextPanel
}
// Force an action's update with every new value for commits and template
cs.launchNow {
vm.titleAndDescriptionGenerationVm.collect {
if (it == null) {
toolbar.updateActionsAsync()
return@collect
}
it.isGenerating.collect {
toolbar.updateActionsAsync()
}
}
}
cs.launchNow {
vm.titleAndDescriptionGenerationVm.flatMapLatest { it?.generationFeedbackActivity ?: flowOf() }.collect {
extension.onGenerationDone(vm.project, descriptionField, it)
}
}
CodeReviewCreateReviewUIUtil.createGenerationToolbarOverlay(wrappedTextPanel, toolbar) {
(descriptionField as EditorEx).scrollPane.verticalScrollBar.preferredWidth
}
}
}
}
private fun CoroutineScope.directionSelector(vm: GHPRCreateViewModel): JComponent {

View File

@@ -0,0 +1,82 @@
// 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.toolwindow.create
import com.intellij.collaboration.util.SingleCoroutineLauncher
import com.intellij.openapi.actionSystem.DataKey
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project
import com.intellij.platform.util.coroutines.childScope
import com.intellij.vcs.log.VcsCommitMetadata
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.github.pullrequest.ui.toolwindow.create.GHPRTitleAndDescriptionGeneratorExtension.*
@ApiStatus.Internal
interface GHPRTitleAndDescriptionGeneratorExtension {
companion object {
val EP_NAME = ExtensionPointName<GHPRTitleAndDescriptionGeneratorExtension>("intellij.vcs.github.titleAndDescriptionGenerator")
}
sealed interface GenerationState
data class GenerationError(val e: Exception) : GenerationState
data class GenerationStep(val title: String, val description: String?) : GenerationState
data class GenerationDone(val score: (helpfulYesOrNo: Boolean) -> Unit) : GenerationState
fun generate(project: Project, commits: List<VcsCommitMetadata>, template: String?): Flow<GenerationState>
suspend fun onGenerationDone(project: Project, editor: Editor, score: (Boolean) -> Unit)
}
@ApiStatus.Internal
interface GHPRCreateTitleAndDescriptionGenerationViewModel {
companion object {
val DATA_KEY = DataKey.create<GHPRCreateTitleAndDescriptionGenerationViewModel>("GHPRCreateTitleAndDescriptionGenerationViewModel")
}
val isGenerating: StateFlow<Boolean>
val generationFeedbackActivity: SharedFlow<(helpfulYesOrNo: Boolean) -> Unit>
fun stopGeneration()
fun startGeneration()
}
internal class GHPRCreateTitleAndDescriptionGenerationViewModelImpl(
parentCs: CoroutineScope,
private val project: Project,
private val extension: GHPRTitleAndDescriptionGeneratorExtension,
private val commits: List<VcsCommitMetadata>,
private val template: String?,
private val setTitle: (String) -> Unit,
private val setDescription: (String) -> Unit,
) : GHPRCreateTitleAndDescriptionGenerationViewModel {
private val taskLauncher = SingleCoroutineLauncher(parentCs.childScope("Generate Title and Description"))
override val isGenerating: StateFlow<Boolean> = taskLauncher.busy
private val _generationFeedbackActivity = MutableSharedFlow<(helpfulYesOrNo: Boolean) -> Unit>()
override val generationFeedbackActivity: SharedFlow<(helpfulYesOrNo: Boolean) -> Unit> = _generationFeedbackActivity
override fun stopGeneration() {
taskLauncher.cancel()
}
override fun startGeneration() {
taskLauncher.launch {
extension.generate(project, commits, template).collect {
when (it) {
is GenerationError -> throw it.e
is GenerationStep -> {
setTitle(it.title)
if (it.description != null) setDescription(it.description)
}
is GenerationDone -> {
_generationFeedbackActivity.emit(it.score)
}
}
}
}
}
}

View File

@@ -67,6 +67,8 @@ internal interface GHPRCreateViewModel {
val descriptionText: StateFlow<String>
val templateLoadingState: StateFlow<ComputedResult<String?>>
val titleAndDescriptionGenerationVm: StateFlow<GHPRCreateTitleAndDescriptionGenerationViewModel?>
val assigneesVm: LabeledListPanelViewModel<GHUser>
val reviewersVm: LabeledListPanelViewModel<GHPullRequestRequestedReviewer>
val labelsVm: LabeledListPanelViewModel<GHLabel>
@@ -88,7 +90,7 @@ internal interface GHPRCreateViewModel {
val baseRepo: GHGitRepositoryMapping,
val baseBranch: GitRemoteBranch?,
val headRepo: GHGitRepositoryMapping?,
val headBranch: GitBranch?
val headBranch: GitBranch?,
)
sealed interface BranchesCheckResult {
@@ -108,13 +110,14 @@ internal interface GHPRCreateViewModel {
}
@OptIn(ExperimentalCoroutinesApi::class)
internal class GHPRCreateViewModelImpl(override val project: Project,
parentCs: CoroutineScope,
private val repositoryManager: GHHostedRepositoriesManager,
private val settings: GithubPullRequestsProjectUISettings,
private val dataContext: GHPRDataContext,
private val projectVm: GHPRToolWindowProjectViewModel)
: GHPRCreateViewModel, Disposable {
internal class GHPRCreateViewModelImpl(
override val project: Project,
parentCs: CoroutineScope,
private val repositoryManager: GHHostedRepositoriesManager,
private val settings: GithubPullRequestsProjectUISettings,
private val dataContext: GHPRDataContext,
private val projectVm: GHPRToolWindowProjectViewModel,
) : GHPRCreateViewModel, Disposable {
private val cs = parentCs.childScope(classAsCoroutineName())
override val avatarIconsProvider: GHAvatarIconsProvider = dataContext.avatarIconsProvider
private val repoData = dataContext.repositoryDataService
@@ -171,6 +174,24 @@ internal class GHPRCreateViewModelImpl(override val project: Project,
dataContext.repositoryDataService.loadTemplate()
}.stateIn(cs, SharingStarted.Lazily, ComputedResult.loading())
override val titleAndDescriptionGenerationVm =
GHPRTitleAndDescriptionGeneratorExtension.EP_NAME.extensionListFlow()
.mapNotNull { it.firstOrNull() }
.flatMapLatest { extension ->
changesVm.flatMapLatest { it?.getOrNull()?.reviewCommits ?: flowOf(null) }
.combine(templateLoadingState) { commits, templateResult ->
if (commits == null || templateResult.isInProgress) {
return@combine null
}
commits to templateResult.getOrNull()
}
.mapNullableScoped { (commits, templateResult) ->
GHPRCreateTitleAndDescriptionGenerationViewModelImpl(this, project, extension, commits, templateResult, ::setTitle, ::setDescription)
}
}
.stateIn(cs, SharingStarted.Eagerly, null)
override val assigneesVm: LabeledListPanelViewModel<GHUser> = MetadataListViewModel(cs) {
dataContext.repositoryDataService.loadIssuesAssignees()
}