Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ kotlinxSerialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-j
kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-contentneg = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }

# Libraries can be bundled together for easier import
Expand All @@ -30,4 +31,4 @@ kotlinxEcosystem = ["kotlinxDatetime", "kotlinxSerialization", "kotlinxCoroutine

[plugins]
kotlinPluginSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
intellij = { id = "org.jetbrains.intellij.platform", version.ref = "intellij" }
intellij = { id = "org.jetbrains.intellij.platform", version.ref = "intellij" }
3 changes: 3 additions & 0 deletions plugin-idea/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ dependencies {

implementation(platform("org.bsc.langgraph4j:langgraph4j-bom:1.8.13"))
implementation("org.bsc.langgraph4j:langgraph4j-core")

testImplementation(kotlin("test-junit5"))
testImplementation(libs.ktor.client.mock)
}

publishing {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.Task
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.DialogWrapper
import com.intellij.openapi.vcs.VcsDataKeys
import org.openprojectx.ai.plugin.pr.AiPullRequestService
import org.openprojectx.ai.plugin.pr.GitRepositoryContextService
import org.openprojectx.ai.plugin.pr.PullRequestOptionsPanel
import org.openprojectx.ai.plugin.pr.PullRequestSettingsState
import org.openprojectx.ai.plugin.pr.PullRequestUiOptions
import java.io.File
import javax.swing.JComponent

class PushAndCreatePrAction : AnAction("Push and Create PR"), DumbAware {

Expand All @@ -28,17 +32,21 @@ class PushAndCreatePrAction : AnAction("Push and Create PR"), DumbAware {
}

val settings = PullRequestSettingsState.getInstance(project).state
val targetBranch = Messages.showInputDialog(
project,
"Target branch for Pull Request:",
"Push and Create PR",
null,
settings.targetBranch,
null
)?.trim().orEmpty()

if (targetBranch.isBlank()) return
settings.targetBranch = targetBranch
val dialog = PushAndCreatePrDialog(
project = project,
initialCreateAfterPush = settings.createAfterPush,
initialTargetBranch = settings.targetBranch
)
if (!dialog.showAndGet()) return

val options = dialog.getOptions()
if (options.createAfterPush && options.targetBranch.isBlank()) {
Notifications.error(project, "Push and Create PR", "Target branch is required when creating a pull request.")
return
}

settings.createAfterPush = options.createAfterPush
settings.targetBranch = options.targetBranch.ifBlank { settings.targetBranch }
PullRequestSettingsState.getInstance(project).loadState(settings)

ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Push and Create PR", false) {
Expand All @@ -47,8 +55,13 @@ class PushAndCreatePrAction : AnAction("Push and Create PR"), DumbAware {
indicator.text = "Pushing ${repoContext.currentBranch} to origin..."
runGitPush(repoContext.repositoryRoot, repoContext.currentBranch)

if (!options.createAfterPush) {
Notifications.info(project, "Push and Create PR", "Push succeeded.")
return
}

indicator.text = "Collecting branch diff..."
val diff = GitRepositoryContextService.getDiffAgainstTarget(project, targetBranch)
val diff = GitRepositoryContextService.getDiffAgainstTarget(project, options.targetBranch)
if (diff.isBlank()) {
Notifications.warn(project, "Push and Create PR", "Push succeeded, but no branch diff found.")
return
Expand All @@ -57,21 +70,21 @@ class PushAndCreatePrAction : AnAction("Push and Create PR"), DumbAware {
indicator.text = "Generating branch summary..."
val summary = AiBranchDiffSummaryService(project).generate(
sourceBranch = repoContext.currentBranch,
targetBranch = targetBranch,
targetBranch = options.targetBranch,
diff = diff
)

indicator.text = "Creating pull request..."
val pr = AiPullRequestService(project).createAfterPush(
remoteUrl = repoContext.remoteUrl,
sourceBranch = repoContext.currentBranch,
targetBranch = targetBranch,
targetBranch = options.targetBranch,
diff = diff,
summaryComment = summary
)

ContextBoxStateService.getInstance(project).recordBranchSummary(
targetBranch = targetBranch,
targetBranch = options.targetBranch,
sourceBranch = repoContext.currentBranch,
summary = summary
)
Expand All @@ -95,3 +108,28 @@ class PushAndCreatePrAction : AnAction("Push and Create PR"), DumbAware {
}
}
}

private class PushAndCreatePrDialog(
project: Project,
initialCreateAfterPush: Boolean,
initialTargetBranch: String
) : DialogWrapper(project) {

private val optionsPanel = PullRequestOptionsPanel(
initialCreateAfterPush = initialCreateAfterPush,
initialTargetBranch = initialTargetBranch
)

init {
title = "Push"
init()
}

override fun createCenterPanel(): JComponent {
return optionsPanel.panel
}

fun getOptions(): PullRequestUiOptions {
return optionsPanel.getOptions()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ class AiPullRequestService(private val project: Project) {
val credential = GitCredentialHelper.resolve(remoteUrl)
val auth = PullRequestAuth(
token = settings.bitbucketPromptRepoToken.takeIf { it.isNotBlank() },
username = credential?.username,
password = credential?.password
username = settings.bitbucketPromptRepoUsername.takeIf { it.isNotBlank() } ?: credential?.username,
password = settings.bitbucketPromptRepoPassword.takeIf { it.isNotBlank() } ?: credential?.password
)

val prompt = PullRequestPromptBuilder.build(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,89 +1,109 @@
package org.openprojectx.ai.plugin.pr

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.Base64

class BitbucketPullRequestProvider(
private val http: HttpClient,
private val auth: PullRequestAuth
) : GitHostingProvider {

private val json = Json { ignoreUnknownKeys = true }

override suspend fun createPullRequest(request: PullRequestRequest): PullRequestResult {
val repo = request.repository
val apiUrl =
"https://${repo.host}/rest/api/1.0/projects/${repo.projectKey}/repos/${repo.repoSlug}/pull-requests"
"${repo.apiBaseUrl.trimEnd('/')}/rest/api/1.0/projects/${repo.projectKey}/repos/${repo.repoSlug}/pull-requests"

val payload = CreateBitbucketPrRequest(
title = request.title,
description = request.description,
state = "OPEN",
open = true,
closed = false,
locked = false,
fromRef = Ref(
id = "refs/heads/${request.sourceBranch}",
repository = BitbucketRepoRef(bitbucketProjectRef = BitbucketProjectRef(repo.projectKey), slug = repo.repoSlug)
repository = BitbucketRepoRef(project = BitbucketProjectRef(repo.projectKey), slug = repo.repoSlug)
),
toRef = Ref(
id = "refs/heads/${request.targetBranch}",
repository = BitbucketRepoRef(bitbucketProjectRef = BitbucketProjectRef(repo.projectKey), slug = repo.repoSlug)
repository = BitbucketRepoRef(project = BitbucketProjectRef(repo.projectKey), slug = repo.repoSlug)
)
)

val response: CreateBitbucketPrResponse = http.post(apiUrl) {
val response = http.post(apiUrl) {
applyAuthorizationHeader()
contentType(ContentType.Application.Json)
setBody(payload)
}.body()
}
val responseText = response.bodyAsText()
if (response.status.value !in 200..299) {
throw BitbucketApiException(bitbucketErrorMessage(responseText, response.status.value))
}
val prResponse = json.decodeFromString<CreateBitbucketPrResponse>(responseText)

return PullRequestResult(
url = response.links.self.firstOrNull()?.href.orEmpty(),
id = response.id?.toString()
url = prResponse.links.self.firstOrNull()?.href.orEmpty(),
id = prResponse.id?.toString()
)
}

override suspend fun addComment(repository: RepositoryRef, pullRequestId: String, text: String) {
val apiUrl =
"https://${repository.host}/rest/api/1.0/projects/${repository.projectKey}/repos/${repository.repoSlug}/pull-requests/$pullRequestId/comments"
"${repository.apiBaseUrl.trimEnd('/')}/rest/api/1.0/projects/${repository.projectKey}/repos/${repository.repoSlug}/pull-requests/$pullRequestId/comments"

http.post(apiUrl) {
val response = http.post(apiUrl) {
applyAuthorizationHeader()
contentType(ContentType.Application.Json)
setBody(CreateBitbucketCommentRequest(text = text))
}.body<CreateBitbucketCommentResponse>()
}
val responseText = response.bodyAsText()
if (response.status.value !in 200..299) {
throw BitbucketApiException(bitbucketErrorMessage(responseText, response.status.value))
}
}

private fun io.ktor.client.request.HttpRequestBuilder.applyAuthorizationHeader() {
when {
!auth.token.isNullOrBlank() -> {
header(HttpHeaders.Authorization, "Bearer ${auth.token}")
}
!auth.username.isNullOrBlank() && !auth.password.isNullOrBlank() -> {
val raw = "${auth.username}:${auth.password}"
val basic = Base64.getEncoder().encodeToString(raw.toByteArray(Charsets.UTF_8))
header(HttpHeaders.Authorization, "Basic $basic")
}
!auth.token.isNullOrBlank() -> {
header(HttpHeaders.Authorization, "Bearer ${auth.token}")
}
else -> {
error("Bitbucket authentication is required. Configure a token or ensure Git credentials are available for this repository.")
}
}
}

private fun bitbucketErrorMessage(responseText: String, statusCode: Int): String {
val parsed = runCatching { json.decodeFromString<BitbucketErrorResponse>(responseText) }.getOrNull()
val message = parsed?.errors?.firstOrNull()?.message?.takeIf { it.isNotBlank() }
return message ?: "Bitbucket API request failed with HTTP $statusCode: $responseText"
}

@Serializable
data class CreateBitbucketPrRequest(
val title: String,
val description: String,
val state: String,
val open: Boolean,
val closed: Boolean,
val locked: Boolean,
@SerialName("fromRef") val fromRef: Ref,
@SerialName("toRef") val toRef: Ref
)
Expand All @@ -96,7 +116,7 @@ class BitbucketPullRequestProvider(

@Serializable
data class BitbucketRepoRef(
val bitbucketProjectRef: BitbucketProjectRef,
val project: BitbucketProjectRef,
val slug: String
)

Expand All @@ -121,6 +141,18 @@ class BitbucketPullRequestProvider(
val id: Long? = null
)

@Serializable
data class BitbucketErrorResponse(
val errors: List<BitbucketError> = emptyList()
)

@Serializable
data class BitbucketError(
val context: String? = null,
val message: String? = null,
val exceptionName: String? = null
)

@Serializable
data class Links(
val self: List<Link> = emptyList()
Expand All @@ -130,4 +162,6 @@ class BitbucketPullRequestProvider(
data class Link(
val href: String
)

class BitbucketApiException(message: String) : RuntimeException(message)
}
Loading
Loading