Skip to content

Commit

Permalink
1. The CommandAutofixAction class in CommandAutofixAction.kt has …
Browse files Browse the repository at this point in the history
…been modified to improve the `isGitignore` function, making it more robust in checking if a file should be ignored based on `.gitignore` rules.

2. A new class `TestResultAutofixAction` has been added in `TestResultAutofixAction.kt`. This class:
   - Implements a new action for analyzing test results using AI.
   - Creates a custom application (`TestResultAutofixApp`) to handle the analysis and suggestion of fixes.
   - Uses OpenAI's API to analyze test failures and suggest code fixes.
   - Provides a user interface for displaying the analysis results and suggested fixes.

3. The `plugin.xml` file has been updated to include a new action:
   - "AI Analyze Test Result" has been added to the TestTreePopupMenu group.
  • Loading branch information
acharneski committed Jun 23, 2024
1 parent 22395fb commit bf292ee
Show file tree
Hide file tree
Showing 4 changed files with 279 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -465,20 +465,35 @@ class CommandAutofixAction : BaseAction() {
val tripleTilde = "`" + "``" // This is a workaround for the markdown parser when editing this file
fun isGitignore(file: VirtualFile): Boolean {
var currentDir = file.toNioPath().toFile().parentFile
while (currentDir != null && !currentDir.resolve(".git").exists()) {
currentDir ?: return false
while (!currentDir.resolve(".git").exists()) {
currentDir.resolve(".gitignore").let {
if (it.exists()) {
val gitignore = it.readText()
if (gitignore.split("\n").any { line ->
line.trim().isNotEmpty() &&
!line.startsWith("#") &&
file.name.matches(Regex(line.trim().trimEnd('/').replace(".", "\\.").replace("*", ".*")))
val pattern = line.trim().trimEnd('/').replace(".", "\\.").replace("*", ".*")
line.trim().isNotEmpty()
&& !line.startsWith("#")
&& file.name.trimEnd('/').matches(Regex(pattern))
}) {
return true
}
}
}
currentDir = currentDir.parentFile
currentDir = currentDir.parentFile ?: return false
}
currentDir.resolve(".gitignore").let {
if (it.exists()) {
val gitignore = it.readText()
if (gitignore.split("\n").any { line ->
val pattern = line.trim().trimEnd('/').replace(".", "\\.").replace("*", ".*")
line.trim().isNotEmpty()
&& !line.startsWith("#")
&& file.name.trimEnd('/').matches(Regex(pattern))
}) {
return true
}
}
}
return false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.vcs.VcsDataKeys
import com.intellij.openapi.vcs.changes.ChangeListManager
import com.simiacryptus.skyenet.core.platform.ApplicationServices
import com.simiacryptus.skyenet.core.platform.StorageInterface
import com.simiacryptus.skyenet.webui.application.ApplicationServer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
package com.github.simiacryptus.aicoder.actions.test

import com.github.simiacryptus.aicoder.AppServer
import com.github.simiacryptus.aicoder.actions.BaseAction
import com.github.simiacryptus.aicoder.actions.generic.CommandAutofixAction
import com.github.simiacryptus.aicoder.actions.generic.SessionProxyServer
import com.github.simiacryptus.aicoder.config.AppSettingsState
import com.github.simiacryptus.aicoder.util.IdeaOpenAIClient
import com.intellij.execution.testframework.AbstractTestProxy
import com.intellij.execution.testframework.sm.runner.SMTestProxy
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.PlatformDataKeys
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.vfs.VirtualFile
import com.simiacryptus.skyenet.core.platform.StorageInterface
import com.simiacryptus.skyenet.webui.application.ApplicationServer
import com.simiacryptus.skyenet.webui.session.SessionTask
import com.simiacryptus.skyenet.webui.application.ApplicationInterface
import com.simiacryptus.skyenet.webui.application.ApplicationSocketManager
import com.simiacryptus.skyenet.core.actors.ParsedActor
import com.simiacryptus.skyenet.core.actors.SimpleActor
import com.simiacryptus.skyenet.AgentPatterns
import com.simiacryptus.jopenai.util.JsonUtil
import com.simiacryptus.skyenet.core.platform.Session
import com.simiacryptus.skyenet.core.platform.User
import com.simiacryptus.skyenet.webui.session.SocketManager
import com.simiacryptus.skyenet.webui.util.MarkdownUtil.renderMarkdown
import org.jetbrains.annotations.NotNull
import java.awt.Desktop
import javax.swing.JOptionPane
import java.io.File
import java.nio.file.Path

class TestResultAutofixAction : BaseAction() {
companion object {
private val log = Logger.getInstance(TestResultAutofixAction::class.java)
private val tripleTilde = "`" + "``" // This is a workaround for the markdown parser when editing this file

fun getFiles(
virtualFiles: Array<out VirtualFile>?
): MutableSet<Path> {
val codeFiles = mutableSetOf<Path>() // Set to avoid duplicates
virtualFiles?.forEach { file ->
if(file.name.startsWith(".")) return@forEach
if(CommandAutofixAction.isGitignore(file)) return@forEach
if (file.isDirectory) {
codeFiles.addAll(getFiles(file.children))
} else {
codeFiles.add((file.toNioPath()))
}
}
return codeFiles
}
fun getProjectStructure(projectPath: VirtualFile?): String {
if (projectPath == null) return "Project path is null"
val root = Path.of(projectPath!!.path)

val codeFiles = getFiles(arrayOf(projectPath!!))
.filter { it.toFile().length() < 1024 * 1024 / 2 } // Limit to 0.5MB
.map { root.relativize(it) ?: it }.toSet()
val str = codeFiles
.asSequence()
.filter { root?.resolve(it)?.toFile()?.exists() == true }
.distinct().sorted()
.joinToString("\n") { path ->
"* ${path} - ${root?.resolve(path)?.toFile()?.length() ?: "?"} bytes".trim()
}
return str
}
}

override fun handle(e: AnActionEvent) {
val testProxy = e.getData(AbstractTestProxy.DATA_KEY) as? SMTestProxy ?: return
val dataContext = e.dataContext
val virtualFile = PlatformDataKeys.VIRTUAL_FILE_ARRAY.getData(dataContext)?.firstOrNull()
val root = findGitRoot(virtualFile)
Thread {
try {
val testInfo = getTestInfo(testProxy)
val projectStructure = getProjectStructure(root)
openAutofixWithTestResult(e, testInfo, projectStructure)
} catch (ex: Throwable) {
log.error("Error analyzing test result", ex)
JOptionPane.showMessageDialog(null, ex.message, "Error", JOptionPane.ERROR_MESSAGE)
}
}.start()
}

private fun findGitRoot(virtualFile: VirtualFile?): VirtualFile? {
var current: VirtualFile? = virtualFile
while (current != null) {
if (current.findChild(".git") != null) {
return current
}
current = current.parent
}
return null
}

override fun isEnabled(@NotNull e: AnActionEvent): Boolean {
val testProxy = e.getData(AbstractTestProxy.DATA_KEY)
return testProxy != null
}

private fun getTestInfo(testProxy: SMTestProxy): String {
val sb = StringBuilder()
sb.appendLine("Test Name: ${testProxy.name}")
sb.appendLine("Duration: ${testProxy.duration} ms")

if (testProxy.errorMessage != null) {
sb.appendLine("Error Message:")
sb.appendLine(testProxy.errorMessage)
}

if (testProxy.stacktrace != null) {
sb.appendLine("Stacktrace:")
sb.appendLine(testProxy.stacktrace)
}

return sb.toString()
}

private fun openAutofixWithTestResult(e: AnActionEvent, testInfo: String, projectStructure: String) {
val session = StorageInterface.newGlobalID()
SessionProxyServer.chats[session] = TestResultAutofixApp(session, testInfo, e.project?.basePath, projectStructure)
ApplicationServer.sessionAppInfoMap[session.toString()] = mapOf(
"applicationName" to "Test Result Autofix",
"singleInput" to false,
"stickyInput" to true,
"loadImages" to false,
"showMenubar" to false,
)

val server = AppServer.getServer(e.project)

Thread {
Thread.sleep(500)
try {
val uri = server.server.uri.resolve("/#$session")
log.info("Opening browser to $uri")
Desktop.getDesktop().browse(uri)
} catch (e: Throwable) {
log.warn("Error opening browser", e)
}
}.start()
}

inner class TestResultAutofixApp(
val session: Session,
val testInfo: String,
val projectPath: String?,
val projectStructure: String
) : ApplicationServer(
applicationName = "Test Result Autofix",
path = "/fixTest",
showMenubar = false,
) {
override val singleInput = true
override val stickyInput = false
override fun newSession(user: User?, session: Session): SocketManager {
val socketManager = super.newSession(user, session)
val ui = (socketManager as ApplicationSocketManager).applicationInterface
val task = ui.newTask()
task.add("Analyzing test result and suggesting fixes...")
Thread {
runAutofix(ui, task)
}.start()
return socketManager
}

private fun runAutofix(ui: ApplicationInterface, task: SessionTask) {
try {
val plan = ParsedActor(
resultClass = ParsedErrors::class.java,
prompt = """
You are a helpful AI that helps people with coding.
Given the response of a test failure, identify one or more distinct errors.
For each error:
1) predict the files that need to be fixed
2) predict related files that may be needed to debug the issue
Project structure:
$projectStructure
1) predict the files that need to be fixed
2) predict related files that may be needed to debug the issue
""".trimIndent(),
model = AppSettingsState.instance.defaultSmartModel()
).answer(listOf(testInfo), api = IdeaOpenAIClient.instance)

task.add(AgentPatterns.displayMapInTabs(
mapOf(
"Text" to renderMarkdown(plan.text, ui = ui),
"JSON" to renderMarkdown(
"${tripleTilde}json\n${JsonUtil.toJson(plan.obj)}\n$tripleTilde",
ui = ui
),
)
))

plan.obj.errors?.forEach { error ->
val filesToFix = (error.fixFiles ?: emptyList()) + (error.relatedFiles ?: emptyList())
val summary = filesToFix.joinToString("\n\n") { filePath ->
val file = File(projectPath, filePath)
if (file.exists()) {
"""
# $filePath
$tripleTilde${filePath.split('.').lastOrNull()}
${file.readText()}
$tripleTilde
""".trimIndent()
} else {
"# $filePath\nFile not found"
}
}

val response = SimpleActor(
prompt = """
You are a helpful AI that helps people with coding.
Suggest fixes for the following test failure:
$testInfo
Here are the relevant files:
$summary
Project structure:
$projectStructure
Response should use one or more code patches in diff format within ${tripleTilde}diff code blocks.
Each diff should be preceded by a header that identifies the file being modified.
The diff format should use + for line additions, - for line deletions.
The diff should include 2 lines of context before and after every change.
""".trimIndent(),
model = AppSettingsState.instance.defaultSmartModel()
).answer(listOf(error.message ?: ""), api = IdeaOpenAIClient.instance)

task.add("<div>${renderMarkdown(response)}</div>")
}
} catch (e: Exception) {
task.error(ui, e)
}
}
}

data class ParsedErrors(
val errors: List<ParsedError>? = null
)

data class ParsedError(
val message: String? = null,
val relatedFiles: List<String>? = null,
val fixFiles: List<String>? = null
)
}
7 changes: 6 additions & 1 deletion src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,11 @@
description="Open a chat session with the diff between HEAD and the working copy">
<add-to-group group-id="com.github.simiacryptus.aicoder.ui.VcsMenu" anchor="last"/>
</action>
<action id="com.github.simiacryptus.aicoder.actions.test.ChatWithTestResultAction"
class="com.github.simiacryptus.aicoder.actions.test.TestResultAutofixAction"
text="AI Analyze Test Result"
description="Open a chat session to analyze the selected test result">
<add-to-group group-id="TestTreePopupMenu" anchor="last"/>
</action>
</actions>

</idea-plugin>

0 comments on commit bf292ee

Please sign in to comment.