Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

## [Unreleased]

## [0.1.0]

### Changed

- Stability update

## [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.0

# 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
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.intellij.ide.plugins.DynamicPluginListener
import com.intellij.openapi.Disposable
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
Expand All @@ -26,12 +29,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 @@ -50,7 +67,7 @@
connection.subscribe(GitRepository.GIT_REPO_CHANGE, GitRepositoryChangeListener { repository ->


val gitResult = initializeGit(project)
val gitResult = initializeGit(project, scope)
val repositoryManager = gitResult.first
val gitInfo = gitResult.second

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

initializeGit(project, scope) can throw (e.g., when no Git remote is configured). Since this call is inside the repository change listener without a try/catch, an exception here can abort the listener and spam IDE logs. Wrap this initialization in error handling and fall back to a safe state (e.g., skip starting services / set RepositoryManager to NoGitRepository).

Copilot uses AI. Check for mistakes.

Expand All @@ -60,19 +77,30 @@

AiAgentService.getService(project)

GlobalScope.launch {
Api().listTools()
scope.launch {
try {
service<Api>().listTools()
} catch (e: Exception) {

Check warning on line 83 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#L83

The caught exception is too generic. Prefer catching specific exceptions to the case that is currently handled.
Logger.warn("Failed to load tools: ${e.message}")
}
}

if (repository.currentBranch != null) {
CoroutineScope(Dispatchers.Default).launch {
scope.launch {
repositoryManager.handleStateChange()
}
} else {
repositoryManager.notifyDidChangeConfig()
}
})

// Perform initial startup initialization without waiting for a git change event
val gitRepository = GitProvider.getRepository(project)
if (gitRepository != null) {
val repositoryManager = project.service<RepositoryManager>()
scope.launch { repositoryManager.open(gitRepository) }
}

connection.subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
private fun handle(events: List<VFileEvent>) {
val now = System.currentTimeMillis()
Expand All @@ -95,12 +123,11 @@
})
}

@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) }
scope.launch { repositoryManager.open(gitRepository) }

val remote = gitRepository?.remotes?.firstOrNull()
?: throw IllegalStateException("No remote found in the Git repository")

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

initializeGit throws IllegalStateException when no remote is found. Since this runs inside the Git repo change listener, it can surface as an unhandled exception and destabilize the plugin for repos without remotes (or during transient Git states). Handle the "no remote" case gracefully (e.g., return a sentinel result / skip initialization and update plugin state) instead of throwing here.

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.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 @@
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)

Check warning on line 56 in src/main/kotlin/com/codacy/intellij/plugin/services/api/Api.kt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/main/kotlin/com/codacy/intellij/plugin/services/api/Api.kt#L56

Exception is a too generic Exception. Prefer throwing specific exceptions that indicate a specific error case.
}
Comment thread
lventura-codacy marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -131,7 +129,7 @@
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 @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.codacy.intellij.plugin.services.git.RepositoryManager.RepositoryManag
import kotlinx.coroutines.*
import com.intellij.notification.*
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.progress.Task
Expand All @@ -32,7 +33,7 @@ class PullRequest(
var files: List<FileDetails> = mutableListOf()
var gates: QualitySettingsData? = null
private val refreshTimeout: TimeoutManager = TimeoutManager()
private val api = Api()
private val api = service<Api>()
private var repositoryManager: RepositoryManager? = null
Comment on lines 35 to 37

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

Api calls can now throw (via makeRequest), but PullRequest.refresh() / fetch*() paths currently don't have a top-level try/catch around the sequence of API calls. A single transient failure can abort the refresh coroutine and stop subsequent scheduled refreshes. Add appropriate exception handling around the refresh flow (and consider updating the state/notification) so refresh is resilient to network/API errors.

Copilot uses AI. Check for mistakes.

fun init(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@
import com.intellij.openapi.project.Project
import git4idea.repo.GitRepository
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

const val LOAD_RETRY_TIME: Long = 2 * 60 * 1000
const val MAX_LOAD_ATTEMPTS: Int = 5

@Service(Service.Level.PROJECT)
class RepositoryManager(private val project: Project) {
class RepositoryManager(private val project: Project) : Disposable {

Check warning on line 29 in src/main/kotlin/com/codacy/intellij/plugin/services/git/RepositoryManager.kt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/main/kotlin/com/codacy/intellij/plugin/services/git/RepositoryManager.kt#L29

Class 'RepositoryManager' with '17' functions detected. Defined threshold inside classes is set to '11'

enum class RepositoryManagerState {
Initializing,
Expand All @@ -51,32 +53,38 @@
private var loadAttempts: Int = 0
private val loadTimeout: TimeoutManager = TimeoutManager()
private val refreshTimeout: TimeoutManager = TimeoutManager()
private val api = Api()
private val config = Config()
private val api = service<Api>()
private val config = Config.instance
private var pullRequestInstance: PullRequest? = null

// Branch state management
private var enabledBranches: List<Branch> = emptyList()
private var branchState: BranchState = BranchState.OnUnknownBranch
private var lastBranchState: BranchState? = null

private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val openMutex = Mutex()

private var onDidUpdatePullRequestListeners = mutableListOf<() -> Unit>()
private var onDidLoadRepositoryListeners = mutableListOf<() -> Unit>()
private var onDidChangeStateListeners = mutableListOf<() -> Unit>()

@OptIn(DelicateCoroutinesApi::class)
suspend fun open(gitRepository: GitRepository) {
config.init()
if (config.storedApiToken.isNullOrBlank()) {
setNewState(RepositoryManagerState.NeedsAuthentication)
return
}

if (currentRepository != gitRepository) {
if (openMutex.withLock {
if (currentRepository != gitRepository) {
currentRepository = gitRepository
true
} else false
}) {
ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Opening repository", false) {
override fun run(indicator: com.intellij.openapi.progress.ProgressIndicator) {
GlobalScope.launch {
currentRepository = gitRepository
serviceScope.launch {

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

open() can still run multiple background jobs concurrently for different GitRepository instances. Since the coroutine uses the captured gitRepository and updates shared fields (repository, state, enabledBranches, etc.) without verifying that currentRepository still matches, a slower previous open() can overwrite state after a newer repository is opened. Consider tracking/cancelling the in-flight open Job when a new repository is set, or add a guard before applying results (e.g., bail out if currentRepository !== gitRepository).

Suggested change
serviceScope.launch {
serviceScope.launch {
// Bail out early if a newer repository has been opened while this job was queued/running
if (currentRepository != gitRepository) {
Logger.info("Repository changed during open; aborting outdated job")
return@launch
}

Copilot uses AI. Check for mistakes.
try {
Comment thread
lventura-codacy marked this conversation as resolved.
val remoteUrl = gitRepository.remotes.firstOrNull()?.pushUrls?.firstOrNull()

Expand All @@ -100,17 +108,16 @@
enabledBranches = branchesResponse.data
Logger.info("Fetched ${enabledBranches.size} enabled branches")
} catch (e: Exception) {
Logger.error("Failed to fetch enabled branches: ${e.message}")
Logger.warn("Failed to fetch enabled branches: ${e.message}")
enabledBranches = emptyList()
}

setNewState(RepositoryManagerState.Loaded)
notifyDidLoadRepository()
loadPullRequest()
}
} catch (e: Error) {
// TODO("error type (ApiError)")
Logger.error("Failed to parse Git remote: ${e.message}")
} catch (e: Exception) {
Logger.warn("Failed to parse Git remote: ${e.message}")
setNewState(RepositoryManagerState.NoRepository)
return@launch
Comment on lines 118 to 122

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

The catch (e: Exception) around the whole open() body will also catch API/network failures from api.getRepository(...), but the log message/state update treats them as a Git remote parsing problem (NoRepository). Split the try/catch so remote parsing errors are handled separately from API errors, and set a more accurate state/log message for request failures (or let them propagate to a higher-level handler).

Copilot uses AI. Check for mistakes.
}
Expand Down Expand Up @@ -157,7 +164,6 @@
!currentHeadAhead


@OptIn(DelicateCoroutinesApi::class)
private suspend fun loadPullRequest() {
if (loadTimeout.isTimeoutRunning()) loadTimeout.clearTimeout()
if (state != RepositoryManagerState.Loaded || repository == null) return
Expand All @@ -178,7 +184,7 @@

ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Loading pull request", false) {
override fun run(indicator: com.intellij.openapi.progress.ProgressIndicator) {
GlobalScope.launch {
serviceScope.launch {
try {
val repositoryPullRequests =
api.listRepositoryPullRequests(repo.provider, repo.owner, repo.name)
Expand All @@ -191,7 +197,7 @@

if (loadAttempts < MAX_LOAD_ATTEMPTS) {
loadTimeout.startTimeout(LOAD_RETRY_TIME) {
CoroutineScope(Dispatchers.Default).launch {
serviceScope.launch {
loadPullRequest()
}
}
Expand All @@ -210,7 +216,7 @@

setNewPullRequestState(PullRequestState.Loaded)
} catch (e: Exception) {
Logger.error("Error loading pull request: ${e.message}")
Logger.warn("Error loading pull request: ${e.message}")
}
}
}
Expand Down Expand Up @@ -319,7 +325,7 @@
setBranchState(BranchState.OnAnalysedBranch)
}
} catch (e: Exception) {
Logger.error("Failed to get repository analysis: ${e.message}")
Logger.warn("Failed to get repository analysis: ${e.message}")
setBranchState(BranchState.OnUnknownBranch)
}
} else {
Expand All @@ -341,4 +347,8 @@
}
}

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

}
Loading