Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@

## [Unreleased]

## [0.1.1]

### Changed

- Fixed a bug where the plugin would not properly run local analysis on saved files.
- The plugin now groups issues for the same line into a single notification, improving readability and reducing clutter in the IDE.

## [0.1.0]

### Changed

- Improved exception handling and logging for better debugging and user experience.
- Changed notifications about cli not generating result files to info level logs, to reduce noise in the IDE.
- Fixed a bug where the plugin would not properly use cached resources

## [0.0.12]

### Changed
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pluginGroup = com.codacy.intellij.plugin
pluginName = codacy-intellij-plugin
pluginRepositoryUrl = https://github.com/codacy/codacy-intellij-extension
# SemVer format -> https://semver.org
pluginVersion = 0.0.12
pluginVersion = 0.1.1

# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 223
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.intellij.ide.plugins.*
class PluginStateListener : DynamicPluginListener {

override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
StartupListener.cancelAllScopes()
Telemetry.track(ExtensionUnloadedEvent)

super.beforePluginUnload(pluginDescriptor, isUpdate)
Expand Down
128 changes: 104 additions & 24 deletions src/main/kotlin/com/codacy/intellij/plugin/listeners/StartupListener.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,28 @@
import com.codacy.intellij.plugin.services.api.Api
import com.codacy.intellij.plugin.services.cli.CodacyCliService
import com.codacy.intellij.plugin.services.common.Config
import com.codacy.intellij.plugin.services.common.Logger
import com.codacy.intellij.plugin.services.common.GitRemoteParser
import com.codacy.intellij.plugin.services.common.IconUtils
import com.codacy.intellij.plugin.services.git.GitProvider
import com.codacy.intellij.plugin.services.git.RepositoryManager
import com.codacy.intellij.plugin.telemetry.ExtensionInstalledEvent
import com.codacy.intellij.plugin.telemetry.Telemetry
import com.codacy.intellij.plugin.views.invalidateAnalysisCacheForHash
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
import com.intellij.ide.plugins.DynamicPluginListener
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.startup.StartupActivity
import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.openapi.vfs.newvfs.BulkFileListener
import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiManager
import git4idea.repo.GitRepository
import git4idea.repo.GitRepositoryChangeListener
import kotlinx.coroutines.*
Expand All @@ -26,12 +35,26 @@

companion object {
private const val MIN_REFRESH_INTERVAL_MS = 5000L
private val activeScopes = java.util.Collections.synchronizedList(mutableListOf<CoroutineScope>())

fun cancelAllScopes() {
synchronized(activeScopes) {
activeScopes.forEach { it.cancel() }
activeScopes.clear()
}
}
}

private var lastTriggeredTime = 0L

@OptIn(DelicateCoroutinesApi::class)
override fun runActivity(project: Project) {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
activeScopes.add(scope)
Disposer.register(project, Disposable {
scope.cancel()
activeScopes.remove(scope)
})

// Check for first installation
val config = Config.instance
if (config.state.isFirstRun) {
Expand All @@ -48,30 +71,53 @@
connection.subscribe(DynamicPluginListener.TOPIC, PluginStateListener())

connection.subscribe(GitRepository.GIT_REPO_CHANGE, GitRepositoryChangeListener { repository ->
val gitResult = initializeGit(project, scope)

if (gitResult == null) {
Logger.info("No git remote found, initializing services without remote info.")
CodacyCliService.getServiceWithoutRemote(project)
AiAgentService.getServiceWithoutRemote(project)
} else {
Comment on lines +76 to +80

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling CodacyCliService.getServiceWithoutRemote() / AiAgentService.getServiceWithoutRemote() here can permanently initialize those services with empty org/repo (both services’ initService methods only set repo context on first initialization). If a remote becomes available later in the session, subsequent getService(project) calls may not update the context. Consider not instantiating services until remote info exists, or ensure services can be re-initialized with real repo data once available.

Copilot uses AI. Check for mistakes.
val repositoryManager = gitResult.first
val gitInfo = gitResult.second

val gitResult = initializeGit(project)
val repositoryManager = gitResult.first
val gitInfo = gitResult.second
CodacyCliService.getService(
Provider.fromString(gitInfo.provider), gitInfo.organization, gitInfo.repository, project,
)

CodacyCliService.getService(
Provider.fromString(gitInfo.provider), gitInfo.organization, gitInfo.repository, project,
)
AiAgentService.getService(project)

AiAgentService.getService(project)
scope.launch {
try {
service<Api>().listTools()
} catch (e: Exception) {
Logger.warn("Failed to load tools: ${e.message}")
}
}

GlobalScope.launch {
Api().listTools()
if (repository.currentBranch != null) {
scope.launch {
repositoryManager.handleStateChange()
}
} else {
repositoryManager.notifyDidChangeConfig()
}
}
})

if (repository.currentBranch != null) {
CoroutineScope(Dispatchers.Default).launch {
repositoryManager.handleStateChange()
}
// Perform initial startup initialization without waiting for a git change event
val gitRepository = GitProvider.getRepository(project)
if (gitRepository != null) {
val gitResult = initializeGit(project, scope)
if (gitResult == null) {
CodacyCliService.getServiceWithoutRemote(project)
AiAgentService.getServiceWithoutRemote(project)
} else {
repositoryManager.notifyDidChangeConfig()
val (_, gitInfo) = gitResult
CodacyCliService.getService(Provider.fromString(gitInfo.provider), gitInfo.organization, gitInfo.repository, project)
AiAgentService.getService(project)
}
})
}

connection.subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
private fun handle(events: List<VFileEvent>) {
Expand All @@ -91,20 +137,18 @@

override fun after(events: List<VFileEvent>) {
handle(events)
triggerAnalysisForSavedFiles(project, events)
}
})
}

@OptIn(DelicateCoroutinesApi::class)
private fun initializeGit(project: Project): Pair<RepositoryManager, GitRemoteParser.GitRemoteInfo> {
private fun initializeGit(project: Project, scope: CoroutineScope): Pair<RepositoryManager, GitRemoteParser.GitRemoteInfo>? {
val gitRepository = GitProvider.getRepository(project)
val repositoryManager = project.service<RepositoryManager>()
if (gitRepository != null && repositoryManager.currentRepository != gitRepository)
GlobalScope.launch { repositoryManager.open(gitRepository) }

val remote = gitRepository?.remotes?.firstOrNull()
?: throw IllegalStateException("No remote found in the Git repository")
scope.launch { repositoryManager.open(gitRepository) }

val remote = gitRepository?.remotes?.firstOrNull() ?: return null
val gitInfo = GitRemoteParser.parseGitRemote(remote.firstUrl!!)

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initializeGit dereferences remote.firstUrl!!, but firstUrl can be null (e.g., remote with no URLs configured). This will throw an NPE and break startup; consider using remote.firstUrl ?: return null (or selecting from pushUrls/fetchUrls) and treat it as “no remote info” like the earlier null-return path.

Suggested change
val gitInfo = GitRemoteParser.parseGitRemote(remote.firstUrl!!)
val remoteUrl = remote.firstUrl ?: return null
val gitInfo = GitRemoteParser.parseGitRemote(remoteUrl)

Copilot uses AI. Check for mistakes.

return Pair(repositoryManager, gitInfo)
Comment on lines +151 to 154
Expand All @@ -114,7 +158,43 @@
// E.g. if the user deletes CLI.sh, re-initialization of the project will check for the
// presence of the CLI and mark its state as not-installed
private fun onProjectFileSystemChange(project: Project, events: List<VFileEvent>) {
CodacyCliService.getService(project)
AiAgentService.getService(project)
if (GitProvider.getRepository(project) == null) return
try {
CodacyCliService.getService(project)
AiAgentService.getService(project)
} catch (e: IllegalStateException) {
Logger.info("Git provider or remote not available.")
}
}

private fun triggerAnalysisForSavedFiles(project: Project, events: List<VFileEvent>) {
val basePath = project.basePath ?: return
val savedFiles = events
.filterIsInstance<VFileContentChangeEvent>()
.mapNotNull { it.file }
.filter { vf ->
vf.isValid &&
!vf.isDirectory &&
vf.path.startsWith(basePath) &&
!vf.path.contains("/.codacy/")
}

if (savedFiles.isEmpty()) return

ApplicationManager.getApplication().invokeLater {
if (project.isDisposed) return@invokeLater

Check warning on line 185 in src/main/kotlin/com/codacy/intellij/plugin/listeners/StartupListener.kt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/main/kotlin/com/codacy/intellij/plugin/listeners/StartupListener.kt#L185

Expression with labels increase complexity and affect maintainability.
val psiManager = PsiManager.getInstance(project)
val documentManager = PsiDocumentManager.getInstance(project)
val daemon = DaemonCodeAnalyzer.getInstance(project)
for (vf in savedFiles) {
val psiFile = psiManager.findFile(vf) ?: continue
// cli.analyze reads from disk, so the cached entry (keyed by document hash)
// reflects the pre-save disk content. Drop it so the next pass re-runs the CLI.
documentManager.getDocument(psiFile)?.let { doc ->
invalidateAnalysisCacheForHash(doc.text.hashCode())
}
daemon.restart(psiFile)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ class AiAgentService {

return service
}

fun getServiceWithoutRemote(project: Project): AiAgentService {
val pathsBehaviour = PathsBehaviour.Factory.build()
val service = project.getService(AiAgentService::class.java)
service.initService(Provider.GITHUB, "", "", project, pathsBehaviour)
return service
Comment on lines +82 to +86

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getServiceWithoutRemote calls initService with empty org/repo. Since initService only assigns provider/organization/repository the first time (isServiceInstantiated guard), this can lock the service into a blank repo context and prevent later getService(project) from updating to the actual remote when it becomes available. Consider supporting re-initialization of the repo context (or deferring instantiation until remote info exists).

Copilot uses AI. Check for mistakes.
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL

@Service
@Service(Service.Level.APP)
class Api {

private val config = service<Config>()
Expand Down Expand Up @@ -47,15 +47,13 @@ class Api {
Gson().fromJson(response, responseClass)
}
} else {
Logger.error("Failed to fetch data: HTTP response code $responseCode")
if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED)
config.storeApiToken("")
throw Exception("Failed to fetch data: HTTP response code $responseCode")
}
Comment on lines 49 to 53

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makeRequest now throws on non-200 responses and also rethrows on any exception, removing the previous “return empty instance” fallback. Several callers (e.g., PullRequest.refresh() in services/git/PullRequest.kt) call API methods from a GlobalScope.launch without try/catch, which will now surface as unhandled coroutine exceptions and can destabilize the plugin. Consider either restoring a safe fallback per endpoint, or auditing call sites to handle exceptions explicitly (and updating UI/state accordingly).

Copilot uses AI. Check for mistakes.
}
catch (e: Exception) {
Comment thread
lventura-codacy marked this conversation as resolved.
Logger.error("Failed to fetch data: ${e.message}")
responseClass.newInstance()
throw Exception("Failed to fetch data: ${e.message}", e)
}
Comment thread
lventura-codacy marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -131,7 +129,7 @@ class Api {
return try {
makeRequest(endpointUrl, GetUserProfileResponse::class.java).data
} catch (e: Exception) {
Logger.error("Failed to fetch user profile: ${e.message}")
Logger.warn("Failed to fetch user profile: ${e.message}")
null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.codacy.intellij.plugin.services.common.Config.Companion.CODACY_TOOLS_
import com.codacy.intellij.plugin.services.common.Config.Companion.CODACY_YAML_NAME
import com.codacy.intellij.plugin.services.common.GitRemoteParser
import com.codacy.intellij.plugin.services.common.IconUtils
import com.codacy.intellij.plugin.services.common.Logger
import com.codacy.intellij.plugin.services.common.OsType
import com.codacy.intellij.plugin.services.common.SystemDetectionService
import com.codacy.intellij.plugin.services.git.GitProvider
Expand Down Expand Up @@ -83,7 +84,7 @@ class CodacyCliService() {

data object NOT_INSTALLED : CodacyCliState {
override fun toString() = "Not Installed"
override val icon: Icon = AllIcons.General.Gear
override val icon: Icon = IconUtils.CodacyIcon
}
}

Expand All @@ -96,7 +97,8 @@ class CodacyCliService() {
private lateinit var cliBehaviour: CodacyCliBehaviour
private lateinit var pathsBehaviour: PathsBehaviour

private var isServiceInstantiated: Boolean = false
var isServiceInstantiated: Boolean = false
private set

private val cliStateListeners = mutableListOf<() -> Unit>()

Expand Down Expand Up @@ -168,6 +170,10 @@ class CodacyCliService() {
)
}

fun getServiceWithoutRemote(project: Project): CodacyCliService {
return getService(Provider.GITHUB, "", "", project)
}
Comment on lines +173 to +175

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getServiceWithoutRemote initializes the service with empty org/repo. Because initService only sets provider/organization/repository when isServiceInstantiated is false (see initService guard earlier in this file), calling this once can permanently prevent later re-initialization with the real Git remote when it becomes available. Consider allowing initService to update repo context on subsequent calls, or avoid marking the service as instantiated until remote data exists.

Copilot uses AI. Check for mistakes.

fun getService(
provider: Provider,
organization: String,
Expand Down Expand Up @@ -492,16 +498,11 @@ class CodacyCliService() {
}

if (fileOutput.isFailure) {
notificationManager.createNotification(
"CLI tools has not generated a result file",
"Current document will not be analyzed, please try again",
NotificationType.ERROR
).notify(project)
return@withTempFile
Logger.info("No analysis result file found for file: $file. Is it a supported file type?")
}

results = fileOutput.getOrNull()
.let { SarifUtil.readReport(StringReader(it)).runs }
?.let { SarifUtil.readReport(StringReader(it)).runs }
?.let(::processSarifResults)
?: emptyList()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import com.codacy.intellij.plugin.telemetry.Telemetry
import com.intellij.credentialStore.CredentialAttributes
import com.intellij.credentialStore.Credentials
import com.intellij.ide.passwordSafe.PasswordSafe
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.*
import com.intellij.util.SlowOperations
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.*

Expand All @@ -18,10 +21,11 @@ import java.util.*
storages = [Storage("CodacyPluginSettings.xml")]
)
@Service
class Config : PersistentStateComponent<Config.State> {
class Config : PersistentStateComponent<Config.State>, Disposable {

private var state = State()
private var apiToken: String? = null
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

companion object {
const val CODACY_DIRECTORY_NAME: String = ".codacy"
Expand Down Expand Up @@ -78,7 +82,6 @@ class Config : PersistentStateComponent<Config.State> {
}
}

@OptIn(DelicateCoroutinesApi::class)
fun storeApiToken(token: String?) {
val credentialAttributes = createCredentialAttributes()
if (token.isNullOrBlank()) {
Expand All @@ -92,8 +95,8 @@ class Config : PersistentStateComponent<Config.State> {
log.info("API Token stored")

if (state.userId == null) {
GlobalScope.launch {
val userProfile = Api().getUserProfile()
serviceScope.launch {
val userProfile = service<Api>().getUserProfile()

if (userProfile?.id != null && state.userId == null) {
state.userId = userProfile.id
Expand Down Expand Up @@ -122,4 +125,8 @@ class Config : PersistentStateComponent<Config.State> {

private fun createCredentialAttributes(): CredentialAttributes =
CredentialAttributes("Codacy", "")

override fun dispose() {
serviceScope.cancel()
}
}
Loading