diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2597cf7..e95aeb2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 @@ -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" } \ No newline at end of file +intellij = { id = "org.jetbrains.intellij.platform", version.ref = "intellij" } diff --git a/plugin-idea/build.gradle.kts b/plugin-idea/build.gradle.kts index 7b1b299..df08faf 100644 --- a/plugin-idea/build.gradle.kts +++ b/plugin-idea/build.gradle.kts @@ -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 { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/PushAndCreatePrAction.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/PushAndCreatePrAction.kt index 16da882..563098f 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/PushAndCreatePrAction.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/PushAndCreatePrAction.kt @@ -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 { @@ -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) { @@ -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 @@ -57,7 +70,7 @@ 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 ) @@ -65,13 +78,13 @@ class PushAndCreatePrAction : AnAction("Push and Create PR"), DumbAware { 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 ) @@ -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() + } +} diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/AiPullRequestService.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/AiPullRequestService.kt index db25437..f83a561 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/AiPullRequestService.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/AiPullRequestService.kt @@ -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( diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProvider.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProvider.kt index 7980934..0967795 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProvider.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProvider.kt @@ -1,15 +1,16 @@ 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( @@ -17,10 +18,12 @@ class BitbucketPullRequestProvider( 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, @@ -28,55 +31,71 @@ class BitbucketPullRequestProvider( 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(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() + } + 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(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, @@ -84,6 +103,7 @@ class BitbucketPullRequestProvider( val state: String, val open: Boolean, val closed: Boolean, + val locked: Boolean, @SerialName("fromRef") val fromRef: Ref, @SerialName("toRef") val toRef: Ref ) @@ -96,7 +116,7 @@ class BitbucketPullRequestProvider( @Serializable data class BitbucketRepoRef( - val bitbucketProjectRef: BitbucketProjectRef, + val project: BitbucketProjectRef, val slug: String ) @@ -121,6 +141,18 @@ class BitbucketPullRequestProvider( val id: Long? = null ) + @Serializable + data class BitbucketErrorResponse( + val errors: List = emptyList() + ) + + @Serializable + data class BitbucketError( + val context: String? = null, + val message: String? = null, + val exceptionName: String? = null + ) + @Serializable data class Links( val self: List = emptyList() @@ -130,4 +162,6 @@ class BitbucketPullRequestProvider( data class Link( val href: String ) + + class BitbucketApiException(message: String) : RuntimeException(message) } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParser.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParser.kt index cdf221c..23ec8e0 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParser.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParser.kt @@ -1,5 +1,7 @@ package org.openprojectx.ai.plugin.pr +import java.net.URI + object GitRemoteParser { fun parse(remoteUrl: String): RepositoryRef { @@ -12,8 +14,8 @@ object GitRemoteParser { private fun parseBitbucket(remoteUrl: String): RepositoryRef? { val ssh = Regex("""git@([^:]+):([^/]+)/(.+?)(\.git)?$""") - val httpsScm = Regex("""https?://(.+?)/scm/([^/]+)/(.+?)(\.git)?$""", RegexOption.IGNORE_CASE) - val httpsPlain = Regex("""https?://([^/]+)/([^/]+)/(.+?)(\.git)?$""", RegexOption.IGNORE_CASE) + parseBitbucketHttp(remoteUrl)?.let { return it } + parseBitbucketSshUri(remoteUrl)?.let { return it } ssh.matchEntire(remoteUrl)?.let { m -> val host = m.groupValues[1] @@ -23,34 +25,69 @@ object GitRemoteParser { provider = GitHostingProviderType.BITBUCKET, host = host, projectKey = m.groupValues[2], - repoSlug = m.groupValues[3] + repoSlug = trimGitSuffix(m.groupValues[3]), + apiBaseUrl = "https://$host" ) } - httpsScm.matchEntire(remoteUrl)?.let { m -> + return null + } + + private fun parseBitbucketHttp(remoteUrl: String): RepositoryRef? { + val uri = runCatching { URI(remoteUrl) }.getOrNull() ?: return null + val scheme = uri.scheme?.lowercase() ?: return null + if (scheme != "http" && scheme != "https") return null + uri.host ?: return null + + val segments = uri.path.trim('/').split('/').filter { it.isNotBlank() } + val scmIndex = segments.indexOfFirst { it.equals("scm", ignoreCase = true) } + if (scmIndex >= 0 && segments.size > scmIndex + 2) { + val contextPath = segments.take(scmIndex).joinToString("/") return RepositoryRef( provider = GitHostingProviderType.BITBUCKET, - host = m.groupValues[1], - projectKey = m.groupValues[2], - repoSlug = m.groupValues[3] + host = hostWithPort(uri), + projectKey = segments[scmIndex + 1], + repoSlug = trimGitSuffix(segments[scmIndex + 2]), + apiBaseUrl = buildApiBaseUrl(uri, contextPath) ) } - httpsPlain.matchEntire(remoteUrl)?.let { m -> - val host = m.groupValues[1] - if (!looksLikeBitbucketHost(host)) return null - + if (segments.size >= 2 && looksLikeBitbucketHost(uri.host.orEmpty())) { return RepositoryRef( provider = GitHostingProviderType.BITBUCKET, - host = host, - projectKey = m.groupValues[2], - repoSlug = m.groupValues[3] + host = hostWithPort(uri), + projectKey = segments[0], + repoSlug = trimGitSuffix(segments[1]), + apiBaseUrl = buildApiBaseUrl(uri, "") ) } return null } + private fun parseBitbucketSshUri(remoteUrl: String): RepositoryRef? { + val uri = runCatching { URI(remoteUrl) }.getOrNull() ?: return null + if (!uri.scheme.equals("ssh", ignoreCase = true)) return null + val host = uri.host ?: return null + val segments = uri.path.trim('/').split('/').filter { it.isNotBlank() } + val hasScmSegment = segments.firstOrNull().equals("scm", ignoreCase = true) + if (!looksLikeBitbucketHost(host) && !hasScmSegment && uri.port != 7999) return null + val normalizedSegments = if (segments.firstOrNull().equals("scm", ignoreCase = true)) { + segments.drop(1) + } else { + segments + } + if (normalizedSegments.size < 2) return null + + return RepositoryRef( + provider = GitHostingProviderType.BITBUCKET, + host = host, + projectKey = normalizedSegments[0], + repoSlug = trimGitSuffix(normalizedSegments[1]), + apiBaseUrl = "https://$host" + ) + } + private fun parseGitHub(remoteUrl: String): RepositoryRef? { val ssh = Regex("""git@github\.com:([^/]+)/(.+?)(\.git)?$""", RegexOption.IGNORE_CASE) val https = Regex("""https?://github\.com/([^/]+)/(.+?)(\.git)?$""", RegexOption.IGNORE_CASE) @@ -110,4 +147,17 @@ object GitRemoteParser { private fun looksLikeBitbucketHost(host: String): Boolean { return host.contains("bitbucket", ignoreCase = true) } + + private fun trimGitSuffix(value: String): String { + return value.removeSuffix(".git") + } + + private fun buildApiBaseUrl(uri: URI, contextPath: String): String { + val base = "${uri.scheme}://${hostWithPort(uri)}" + return if (contextPath.isBlank()) base else "$base/$contextPath" + } + + private fun hostWithPort(uri: URI): String { + return if (uri.port > 0) "${uri.host}:${uri.port}" else uri.host + } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/Models.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/Models.kt index 7c1afe8..6ccbf6d 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/Models.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/Models.kt @@ -17,7 +17,8 @@ data class RepositoryRef( val provider: GitHostingProviderType, val host: String, val projectKey: String, - val repoSlug: String + val repoSlug: String, + val apiBaseUrl: String = "https://$host" ) enum class GitHostingProviderType { diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/PullRequestOptionsPanel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/PullRequestOptionsPanel.kt index 6dfa66a..dc6db06 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/PullRequestOptionsPanel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/pr/PullRequestOptionsPanel.kt @@ -38,11 +38,11 @@ class PullRequestOptionsPanel( fun getOptions(): PullRequestUiOptions { return PullRequestUiOptions( createAfterPush = createPrCheckBox.isSelected, - targetBranch = targetBranchField.text.trim().ifBlank { "main" } + targetBranch = targetBranchField.text.trim() ) } private fun updateState() { targetBranchField.isEnabled = createPrCheckBox.isSelected } -} \ No newline at end of file +} diff --git a/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProviderTest.kt b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProviderTest.kt new file mode 100644 index 0000000..4a4178f --- /dev/null +++ b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/BitbucketPullRequestProviderTest.kt @@ -0,0 +1,171 @@ +package org.openprojectx.ai.plugin.pr + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandler +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class BitbucketPullRequestProviderTest { + + @Test + fun `creates pull request using bitbucket server api contract and bearer auth`() = runBlocking { + var capturedUrl = "" + var capturedMethod = HttpMethod.Get + var capturedAuthorization = "" + var capturedBody = "" + val client = testClient { request -> + capturedUrl = request.url.toString() + capturedMethod = request.method + capturedAuthorization = request.headers[HttpHeaders.Authorization].orEmpty() + capturedBody = request.body.readText() + respond( + content = """ + { + "id": 42, + "links": { + "self": [ + { "href": "https://git.example.com/projects/PROJ/repos/my-repo/pull-requests/42" } + ] + } + } + """.trimIndent(), + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + val provider = BitbucketPullRequestProvider(client, PullRequestAuth(token = "abc123")) + + val result = provider.createPullRequest( + PullRequestRequest( + repository = repository(), + sourceBranch = "feature/test", + targetBranch = "main", + title = "Add thing", + description = "Implementation details" + ) + ) + + assertEquals("https://git.example.com/bitbucket/rest/api/1.0/projects/PROJ/repos/my-repo/pull-requests", capturedUrl) + assertEquals(HttpMethod.Post, capturedMethod) + assertEquals("Bearer abc123", capturedAuthorization) + assertEquals("https://git.example.com/projects/PROJ/repos/my-repo/pull-requests/42", result.url) + assertEquals("42", result.id) + + val payload = Json.parseToJsonElement(capturedBody).jsonObject + assertEquals("Add thing", payload["title"]?.jsonPrimitive?.content) + assertEquals("Implementation details", payload["description"]?.jsonPrimitive?.content) + assertEquals("OPEN", payload["state"]?.jsonPrimitive?.content) + assertEquals(true, payload["open"]?.jsonPrimitive?.boolean) + assertEquals(false, payload["closed"]?.jsonPrimitive?.boolean) + assertEquals(false, payload["locked"]?.jsonPrimitive?.boolean) + + val fromRef = payload.getValue("fromRef").jsonObject + assertEquals("refs/heads/feature/test", fromRef["id"]?.jsonPrimitive?.content) + val fromRepo = fromRef.getValue("repository").jsonObject + assertEquals("my-repo", fromRepo["slug"]?.jsonPrimitive?.content) + assertEquals("PROJ", fromRepo.getValue("project").jsonObject["key"]?.jsonPrimitive?.content) + assertTrue("bitbucketProjectRef" !in fromRepo) + + val toRef = payload.getValue("toRef").jsonObject + assertEquals("refs/heads/main", toRef["id"]?.jsonPrimitive?.content) + assertEquals("PROJ", toRef.getValue("repository").jsonObject.getValue("project").jsonObject["key"]?.jsonPrimitive?.content) + } + + @Test + fun `uses basic auth when token is absent`() = runBlocking { + var capturedAuthorization = "" + val client = testClient { request -> + capturedAuthorization = request.headers[HttpHeaders.Authorization].orEmpty() + respond( + content = """{"id":7,"links":{"self":[{"href":"https://git.example.com/pr/7"}]}}""", + status = HttpStatusCode.Created, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + val provider = BitbucketPullRequestProvider( + client, + PullRequestAuth(username = "alice", password = "secret") + ) + + provider.createPullRequest( + PullRequestRequest( + repository = repository(), + sourceBranch = "feature/test", + targetBranch = "main", + title = "Title", + description = "Description" + ) + ) + + assertEquals("Basic YWxpY2U6c2VjcmV0", capturedAuthorization) + } + + @Test + fun `raises readable bitbucket error message`() = runBlocking { + val client = testClient { + respond( + content = """{"errors":[{"context":null,"message":"A pull request already exists","exceptionName":"DuplicatePullRequestException"}]}""", + status = HttpStatusCode.Conflict, + headers = headersOf(HttpHeaders.ContentType, "application/json") + ) + } + val provider = BitbucketPullRequestProvider(client, PullRequestAuth(token = "abc123")) + + val error = assertFailsWith { + provider.createPullRequest( + PullRequestRequest( + repository = repository(), + sourceBranch = "feature/test", + targetBranch = "main", + title = "Title", + description = "Description" + ) + ) + } + + assertEquals("A pull request already exists", error.message) + } + + private fun repository(): RepositoryRef { + return RepositoryRef( + provider = GitHostingProviderType.BITBUCKET, + host = "git.example.com", + projectKey = "PROJ", + repoSlug = "my-repo", + apiBaseUrl = "https://git.example.com/bitbucket" + ) + } + + private fun testClient(handler: MockRequestHandler): HttpClient { + return HttpClient(MockEngine(handler)) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + } + + private fun OutgoingContent.readText(): String { + return when (this) { + is TextContent -> text + is OutgoingContent.ByteArrayContent -> bytes().decodeToString() + else -> error("Unsupported request body type in test: ${javaClass.name}") + } + } +} diff --git a/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParserTest.kt b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParserTest.kt new file mode 100644 index 0000000..8fa8289 --- /dev/null +++ b/plugin-idea/src/test/kotlin/org/openprojectx/ai/plugin/pr/GitRemoteParserTest.kt @@ -0,0 +1,82 @@ +package org.openprojectx.ai.plugin.pr + +import kotlin.test.Test +import kotlin.test.assertEquals + +class GitRemoteParserTest { + + @Test + fun `parses bitbucket server https scm remote`() { + val repo = GitRemoteParser.parse("https://bitbucket.example.com/scm/PROJ/my-repo.git") + + assertEquals(GitHostingProviderType.BITBUCKET, repo.provider) + assertEquals("bitbucket.example.com", repo.host) + assertEquals("PROJ", repo.projectKey) + assertEquals("my-repo", repo.repoSlug) + assertEquals("https://bitbucket.example.com", repo.apiBaseUrl) + } + + @Test + fun `parses bitbucket server https scm remote with context path`() { + val repo = GitRemoteParser.parse("https://git.example.com/bitbucket/scm/PROJ/my-repo.git") + + assertEquals(GitHostingProviderType.BITBUCKET, repo.provider) + assertEquals("git.example.com", repo.host) + assertEquals("PROJ", repo.projectKey) + assertEquals("my-repo", repo.repoSlug) + assertEquals("https://git.example.com/bitbucket", repo.apiBaseUrl) + } + + @Test + fun `strips user info from bitbucket server https remote`() { + val repo = GitRemoteParser.parse("https://alice@git.example.com/bitbucket/scm/PROJ/my-repo.git") + + assertEquals(GitHostingProviderType.BITBUCKET, repo.provider) + assertEquals("git.example.com", repo.host) + assertEquals("https://git.example.com/bitbucket", repo.apiBaseUrl) + } + + @Test + fun `parses bitbucket server ssh remote on default server port`() { + val repo = GitRemoteParser.parse("ssh://git@git.example.com:7999/PROJ/my-repo.git") + + assertEquals(GitHostingProviderType.BITBUCKET, repo.provider) + assertEquals("git.example.com", repo.host) + assertEquals("PROJ", repo.projectKey) + assertEquals("my-repo", repo.repoSlug) + assertEquals("https://git.example.com", repo.apiBaseUrl) + } + + @Test + fun `parses bitbucket server ssh scm remote`() { + val repo = GitRemoteParser.parse("ssh://git@git.example.com/scm/PROJ/my-repo.git") + + assertEquals(GitHostingProviderType.BITBUCKET, repo.provider) + assertEquals("git.example.com", repo.host) + assertEquals("PROJ", repo.projectKey) + assertEquals("my-repo", repo.repoSlug) + assertEquals("https://git.example.com", repo.apiBaseUrl) + } + + @Test + fun `parses existing bitbucket scp-like remote`() { + val repo = GitRemoteParser.parse("git@bitbucket.example.com:PROJ/my-repo.git") + + assertEquals(GitHostingProviderType.BITBUCKET, repo.provider) + assertEquals("bitbucket.example.com", repo.host) + assertEquals("PROJ", repo.projectKey) + assertEquals("my-repo", repo.repoSlug) + assertEquals("https://bitbucket.example.com", repo.apiBaseUrl) + } + + @Test + fun `does not classify github as bitbucket`() { + val repo = GitRemoteParser.parse("git@github.com:OpenProjectX/ai-test-plugin.git") + + assertEquals(GitHostingProviderType.GITHUB, repo.provider) + assertEquals("github.com", repo.host) + assertEquals("OpenProjectX", repo.projectKey) + assertEquals("ai-test-plugin", repo.repoSlug) + assertEquals("https://github.com", repo.apiBaseUrl) + } +}