Skip to content

Commit e248bae

Browse files
committed
feat(codereview): add issue tracker integration #453
Introduce issue tracker support in code review, including new services, configuration dialogs, and related UI updates.
1 parent 1c25072 commit e248bae

File tree

20 files changed

+1098
-13
lines changed

20 files changed

+1098
-13
lines changed

mpp-core/src/androidMain/kotlin/cc/unitmesh/agent/platform/GitOperations.android.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,8 @@ actual class GitOperations actual constructor(private val projectPath: String) {
4545
actual suspend fun getDiff(base: String, target: String): GitDiffInfo? {
4646
return null
4747
}
48+
49+
actual suspend fun getRemoteUrl(remoteName: String): String? {
50+
return null
51+
}
4852
}

mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/platform/GitOperations.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,11 @@ expect class GitOperations(projectPath: String) {
6060
* @return true 表示支持,false 表示不支持
6161
*/
6262
fun isSupported(): Boolean
63+
64+
/**
65+
* 获取 git remote URL
66+
* @param remoteName remote 名称(默认为 "origin")
67+
* @return remote URL,如果获取失败返回 null
68+
*/
69+
suspend fun getRemoteUrl(remoteName: String = "origin"): String?
6370
}

mpp-core/src/iosMain/kotlin/cc/unitmesh/agent/platform/GitOperations.ios.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,9 @@ actual class GitOperations actual constructor(private val projectPath: String) {
3939
}
4040

4141
actual fun isSupported(): Boolean = false
42+
43+
actual suspend fun getRemoteUrl(remoteName: String): String? {
44+
return null
45+
}
4246
}
4347

mpp-core/src/jsMain/kotlin/cc/unitmesh/agent/platform/GitOperations.js.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,26 @@ actual class GitOperations actual constructor(private val projectPath: String) {
153153

154154
actual fun isSupported(): Boolean = isNodeJs
155155

156+
actual suspend fun getRemoteUrl(remoteName: String): String? {
157+
if (!isNodeJs) {
158+
return null
159+
}
160+
161+
return try {
162+
val output = execGitCommand("git remote get-url $remoteName")
163+
val url = output.trim()
164+
if (url.isNotBlank()) {
165+
logger.info { "Remote '$remoteName' URL: $url" }
166+
url
167+
} else {
168+
null
169+
}
170+
} catch (e: Throwable) {
171+
logger.warn(e) { "Failed to get remote URL: ${e.message}" }
172+
null
173+
}
174+
}
175+
156176
// Private helper methods
157177

158178
private fun parseCommitLine(line: String): GitCommitInfo? {

mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/platform/GitOperations.jvm.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,31 @@ actual class GitOperations actual constructor(private val projectPath: String) {
238238

239239
actual fun isSupported(): Boolean = true
240240

241+
actual suspend fun getRemoteUrl(remoteName: String): String? = withContext(Dispatchers.IO) {
242+
try {
243+
val command = listOf("git", "remote", "get-url", remoteName)
244+
245+
val process = ProcessBuilder(command)
246+
.directory(File(projectPath))
247+
.redirectErrorStream(true)
248+
.start()
249+
250+
val output = process.inputStream.bufferedReader().readText().trim()
251+
val exitCode = process.waitFor()
252+
253+
if (exitCode != 0 || output.isBlank()) {
254+
logger.debug { "No remote URL found for '$remoteName'" }
255+
return@withContext null
256+
}
257+
258+
logger.info { "Remote '$remoteName' URL: $output" }
259+
output
260+
} catch (e: Exception) {
261+
logger.warn(e) { "Failed to get remote URL: ${e.message}" }
262+
null
263+
}
264+
}
265+
241266
// Private helper methods
242267

243268
private fun parseCommitLine(line: String): GitCommitInfo? {

mpp-core/src/wasmJsMain/kotlin/cc/unitmesh/agent/platform/GitOperations.wasmJs.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,27 @@ actual class GitOperations actual constructor(private val projectPath: String) {
377377
false
378378
}
379379
}
380+
381+
actual suspend fun getRemoteUrl(remoteName: String): String? {
382+
initialize()
383+
384+
val module = lg2Module ?: return null
385+
386+
return try {
387+
commandOutputBuffer.clear()
388+
val exitCode = module.callMain(jsArrayOf("remote", "get-url", remoteName)).await<JsNumber>().toInt()
389+
390+
if (exitCode != 0) {
391+
WasmConsole.warn("git remote get-url failed with exit code: $exitCode")
392+
return null
393+
}
394+
395+
commandOutputBuffer.firstOrNull()?.trim()?.takeIf { it.isNotBlank() }
396+
} catch (e: Throwable) {
397+
WasmConsole.error("Failed to get remote URL: ${e.message}")
398+
null
399+
}
400+
}
380401
}
381402

382403
fun debugObj(obj: LibGit2Module): Unit = js("console.log(obj)")

mpp-ui/src/androidMain/kotlin/cc/unitmesh/devins/ui/config/ConfigManager.android.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,19 @@ actual object ConfigManager {
176176
val wrapper = load()
177177
return wrapper.getLastWorkspace()
178178
}
179+
180+
actual suspend fun saveIssueTracker(issueTracker: IssueTrackerConfig) {
181+
val wrapper = load()
182+
val configFile = wrapper.configFile
183+
184+
val updatedConfigFile = configFile.copy(issueTracker = issueTracker)
185+
save(updatedConfigFile)
186+
}
187+
188+
actual suspend fun getIssueTracker(): IssueTrackerConfig? {
189+
val wrapper = load()
190+
return wrapper.getIssueTracker()
191+
}
179192

180193
actual suspend fun loadToolConfig(): ToolConfigFile =
181194
withContext(Dispatchers.IO) {

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/codereview/CodeReviewModels.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cc.unitmesh.devins.ui.compose.agent.codereview
33
import cc.unitmesh.agent.linter.LintFileResult
44
import cc.unitmesh.agent.diff.ChangeType
55
import cc.unitmesh.agent.diff.DiffHunk
6+
import cc.unitmesh.agent.tracker.IssueInfo
67
import kotlinx.serialization.Serializable
78

89
/**
@@ -131,5 +132,7 @@ data class CommitInfo(
131132
val author: String,
132133
val timestamp: Long,
133134
val date: String,
134-
val message: String
135+
val message: String,
136+
val issueInfo: IssueInfo? = null, // Issue information extracted from commit message
137+
val isLoadingIssue: Boolean = false // Whether issue info is being loaded
135138
)

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/codereview/CodeReviewPage.kt

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import cc.unitmesh.devins.ui.compose.icons.AutoDevComposeIcons
1010
import cc.unitmesh.devins.workspace.Workspace
1111
import cc.unitmesh.devins.workspace.WorkspaceManager
1212
import cc.unitmesh.llm.KoogLLMService
13+
import kotlinx.coroutines.launch
1314

1415
/**
1516
* Code Review Page - Entry point for the Side-by-Side code review UI (redesigned)
@@ -47,6 +48,7 @@ fun CodeReviewPage(
4748
onRefresh = { viewModel.refresh() },
4849
workspace = currentWorkspace,
4950
onBack = onBack,
51+
viewModel = viewModel
5052
)
5153
},
5254
modifier = modifier,
@@ -76,8 +78,12 @@ fun CodeReviewPage(
7678
private fun CodeReviewTopBar(
7779
onRefresh: () -> Unit,
7880
workspace: Workspace?,
79-
onBack: () -> Unit
81+
onBack: () -> Unit,
82+
viewModel: CodeReviewViewModel
8083
) {
84+
var showIssueTrackerDialog by remember { mutableStateOf(false) }
85+
val scope = rememberCoroutineScope()
86+
8187
TopAppBar(
8288
title = {
8389
Column {
@@ -108,6 +114,15 @@ private fun CodeReviewTopBar(
108114
}
109115
},
110116
actions = {
117+
// Issue Tracker Settings button
118+
IconButton(onClick = { showIssueTrackerDialog = true }) {
119+
Icon(
120+
imageVector = AutoDevComposeIcons.Settings,
121+
contentDescription = "Issue Tracker Settings",
122+
tint = MaterialTheme.colorScheme.onSurface
123+
)
124+
}
125+
111126
IconButton(onClick = onRefresh) {
112127
Icon(
113128
imageVector = AutoDevComposeIcons.Refresh,
@@ -121,4 +136,30 @@ private fun CodeReviewTopBar(
121136
titleContentColor = MaterialTheme.colorScheme.onSurface
122137
)
123138
)
139+
140+
// Issue Tracker Configuration Dialog
141+
if (showIssueTrackerDialog) {
142+
val currentConfig = remember {
143+
mutableStateOf<cc.unitmesh.devins.ui.config.IssueTrackerConfig?>(null)
144+
}
145+
146+
LaunchedEffect(Unit) {
147+
currentConfig.value = cc.unitmesh.devins.ui.config.ConfigManager.getIssueTracker()
148+
}
149+
150+
IssueTrackerConfigDialog(
151+
onDismiss = { showIssueTrackerDialog = false },
152+
onConfigured = {
153+
// Reload issue service when configuration changes
154+
scope.launch {
155+
try {
156+
viewModel.reloadIssueService()
157+
} catch (e: Exception) {
158+
// Handle error
159+
}
160+
}
161+
},
162+
initialConfig = currentConfig.value
163+
)
164+
}
124165
}

mpp-ui/src/commonMain/kotlin/cc/unitmesh/devins/ui/compose/agent/codereview/CodeReviewViewModel.kt

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ open class CodeReviewViewModel(
4444
// Non-AI analysis components (extracted for testability)
4545
private val codeAnalyzer = CodeAnalyzer(workspace)
4646
private val lintExecutor = LintExecutor()
47+
48+
// Issue tracker service
49+
private val issueService = IssueService(workspace.rootPath ?: "")
4750

4851
// State
4952
private val _state = MutableStateFlow(CodeReviewState())
@@ -81,6 +84,8 @@ open class CodeReviewViewModel(
8184
CoroutineScope(Dispatchers.Default).launch {
8285
try {
8386
codeReviewAgent = initializeCodingAgent()
87+
// Initialize issue service
88+
issueService.initialize(if (gitOps.isSupported()) gitOps else null)
8489
if (gitOps.isSupported()) {
8590
loadCommitHistory()
8691
} else {
@@ -146,6 +151,8 @@ open class CodeReviewViewModel(
146151

147152
if (commits.isNotEmpty()) {
148153
loadCommitDiffInternal(commits[0].hash)
154+
// Load issue info for first commit asynchronously
155+
loadIssueForCommit(0)
149156
}
150157

151158
} catch (e: Exception) {
@@ -192,6 +199,12 @@ open class CodeReviewViewModel(
192199
isLoadingMore = false
193200
)
194201
}
202+
203+
// Load issue info for newly loaded commits asynchronously
204+
val startIndex = currentCount
205+
additionalCommits.forEachIndexed { offset, _ ->
206+
loadIssueForCommit(startIndex + offset)
207+
}
195208

196209
} catch (e: Exception) {
197210
updateState {
@@ -940,6 +953,82 @@ open class CodeReviewViewModel(
940953
}
941954
}
942955

956+
/**
957+
* Load issue information for a specific commit asynchronously
958+
*
959+
* @param commitIndex Index of the commit in commitHistory
960+
*/
961+
private fun loadIssueForCommit(commitIndex: Int) {
962+
val commit = currentState.commitHistory.getOrNull(commitIndex) ?: return
963+
964+
// Skip if already loaded or loading
965+
if (commit.issueInfo != null || commit.isLoadingIssue) {
966+
return
967+
}
968+
969+
// Mark as loading
970+
updateCommitAtIndex(commitIndex) { it.copy(isLoadingIssue = true) }
971+
972+
// Load issue asynchronously
973+
scope.launch {
974+
try {
975+
val issueDeferred = issueService.getIssueAsync(commit.hash, commit.message)
976+
val issueInfo = issueDeferred.await()
977+
978+
// Update commit with issue info
979+
updateCommitAtIndex(commitIndex) {
980+
it.copy(issueInfo = issueInfo, isLoadingIssue = false)
981+
}
982+
} catch (e: Exception) {
983+
AutoDevLogger.error("CodeReviewViewModel") {
984+
"Failed to load issue for commit ${commit.shortHash}: ${e.message}"
985+
}
986+
updateCommitAtIndex(commitIndex) { it.copy(isLoadingIssue = false) }
987+
}
988+
}
989+
}
990+
991+
/**
992+
* Update a commit at a specific index
993+
*/
994+
private fun updateCommitAtIndex(index: Int, update: (CommitInfo) -> CommitInfo) {
995+
val commits = currentState.commitHistory
996+
if (index !in commits.indices) return
997+
998+
val updatedCommits = commits.toMutableList()
999+
updatedCommits[index] = update(updatedCommits[index])
1000+
1001+
updateState { it.copy(commitHistory = updatedCommits) }
1002+
}
1003+
1004+
/**
1005+
* Reload issue service (called when configuration changes)
1006+
*/
1007+
suspend fun reloadIssueService() {
1008+
try {
1009+
AutoDevLogger.info("CodeReviewViewModel") {
1010+
"Reloading issue service..."
1011+
}
1012+
issueService.reload(if (gitOps.isSupported()) gitOps else null)
1013+
1014+
// Reload issues for all commits
1015+
currentState.commitHistory.forEachIndexed { index, commit ->
1016+
// Reset issue info
1017+
updateCommitAtIndex(index) { it.copy(issueInfo = null, isLoadingIssue = false) }
1018+
// Reload
1019+
loadIssueForCommit(index)
1020+
}
1021+
1022+
AutoDevLogger.info("CodeReviewViewModel") {
1023+
"Issue service reloaded successfully"
1024+
}
1025+
} catch (e: Exception) {
1026+
AutoDevLogger.error("CodeReviewViewModel") {
1027+
"Failed to reload issue service: ${e.message}"
1028+
}
1029+
}
1030+
}
1031+
9431032
/**
9441033
* Cleanup when ViewModel is disposed
9451034
*/

0 commit comments

Comments
 (0)