diff --git a/README.md b/README.md index a642b66..63c3ead 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ techDebtReport { // Set a base URL for tickets to automatically generate links (Optional) baseTicketUrl.set("https://jira.myproject.com/tickets/") + + // Enable Git metadata like last modified date and author (Optional, default is false) + enableGitMetadata.set(true) } ``` @@ -99,6 +102,7 @@ techDebtReport { - **Consolidated HTML Report**: A clean, easy-to-read summary of all technical debt from all modules in your project. - **Suppress Support**: Optionally collect and visualize suppressed rules (e.g., `@Suppress("MagicNumber")`) in the report. - **TODO/FIXME Comments Support**: Optionally collect and visualize `TODO` and `FIXME` comments from your source code. +- **Git Metadata Support**: Optionally collect and visualize Git information (Author and Last Modified date) for each tech debt item. - **Priority Levels**: Support for `LOW`, `MEDIUM`, and `HIGH` priority levels (and `NONE`). - **Ticket Linking**: Keep track of related tickets in your issue tracking system. If `baseTicketUrl` is configured, tickets will automatically become clickable links in the report. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3165dbe..cb71cba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ junit-platform = "1.10.1" ksp = "2.3.4" android-gradle-plugin = "8.13.2" maven-publish = "0.35.0" +jgit = "7.5.0.202512021534-r" detekt = "1.23.4" ktfmt = "0.16.0" @@ -23,6 +24,7 @@ kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-p junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junit-platform" } +jgit = { group = "org.eclipse.jgit", name = "org.eclipse.jgit", version.ref = "jgit" } [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/samples/sample-jvm/assets/report.html b/samples/sample-jvm/assets/report.html index 803c0c5..8affdf1 100644 --- a/samples/sample-jvm/assets/report.html +++ b/samples/sample-jvm/assets/report.html @@ -168,6 +168,10 @@ .info-value { font-size: 14px; color: #333; + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; } .ticket { @@ -239,8 +243,18 @@

Annotated Tech Debt

-
PriorityHIGH
-
Source Setmain
+
Priority +
HIGH
+
+
Source Set +
main
+
+
Last Modified +
2026-01-18 11:39:12
+
+
Author +
Igor Escodro
+
@@ -255,9 +269,21 @@

Annotated Tech Debt

-
Ticket26
-
PriorityHIGH
-
Source Setmain
+
Ticket + +
+
Priority +
HIGH
+
+
Source Set +
main
+
+
Last Modified +
2026-01-31 17:34:25
+
+
Author +
Igor Escodro
+
@@ -272,9 +298,15 @@

Annotated Tech Debt

-
Ticket23
-
PriorityMEDIUM
-
Source Setmain
+
Ticket + +
+
Priority +
MEDIUM
+
+
Source Set +
main
+
@@ -289,9 +321,21 @@

Annotated Tech Debt

-
Ticket20
-
PriorityLOW
-
Source Setmain
+
Ticket + +
+
Priority +
LOW
+
+
Source Set +
main
+
+
Last Modified +
2026-01-31 17:34:25
+
+
Author +
Igor Escodro
+
@@ -306,8 +350,18 @@

Annotated Tech Debt

-
PriorityNONE
-
Source Setmain
+
Priority +
NONE
+
+
Source Set +
main
+
+
Last Modified +
2026-01-10 19:56:05
+
+
Author +
Igor Escodro
+
@@ -322,7 +376,15 @@

Comments

-
Locationsrc/main/kotlin/com/escodro/sample/Sample.kt:20
+
Location +
src/main/kotlin/com/escodro/sample/Sample.kt
+
+
Last Modified +
2026-01-31 09:38:49
+
+
Author +
Igor Escodro
+
@@ -336,7 +398,15 @@

Comments

-
Locationsrc/main/kotlin/com/escodro/sample/Sample.kt:27
+
Location +
src/main/kotlin/com/escodro/sample/Sample.kt
+
+
Last Modified +
2026-01-31 09:38:49
+
+
Author +
Igor Escodro
+
@@ -350,7 +420,15 @@

Comments

-
Locationsrc/main/kotlin/com/escodro/sample/Sample.kt:35
+
Location +
src/main/kotlin/com/escodro/sample/Sample.kt
+
+
Last Modified +
2026-01-31 09:38:49
+
+
Author +
Igor Escodro
+
@@ -364,7 +442,15 @@

Comments

-
Locationsrc/main/kotlin/com/escodro/sample/Sample.kt:39
+
Location +
src/main/kotlin/com/escodro/sample/Sample.kt
+
+
Last Modified +
2026-01-31 09:38:49
+
+
Author +
Igor Escodro
+
@@ -380,8 +466,15 @@

Suppressed Rules

-
Source Setmain
-
Symbolcom.escodro.sample.Sample.suppressedFunction
+
Source Set +
main
+
+
Last Modified +
2026-01-25 09:17:04
+
+
Author +
Igor Escodro
+
diff --git a/samples/sample-jvm/build.gradle.kts b/samples/sample-jvm/build.gradle.kts index 3cdbb5c..7ca82d2 100644 --- a/samples/sample-jvm/build.gradle.kts +++ b/samples/sample-jvm/build.gradle.kts @@ -16,5 +16,6 @@ techDebtReport { outputFile.set(layout.projectDirectory.file("assets/report.html")) collectSuppress.set(true) collectComments.set(true) + enableGitMetadata.set(true) baseTicketUrl.set("https://github.com/igorescodro/tech-debt/issues/") } diff --git a/techdebt-gradle-plugin/build.gradle.kts b/techdebt-gradle-plugin/build.gradle.kts index 36fec68..c36a296 100644 --- a/techdebt-gradle-plugin/build.gradle.kts +++ b/techdebt-gradle-plugin/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(libs.kotlin.gradle.plugin) implementation(libs.ksp.gradle.plugin) implementation(libs.android.gradle.plugin) + implementation(libs.jgit) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/GenerateTechDebtReportTask.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/GenerateTechDebtReportTask.kt index 4a587a9..72b2052 100644 --- a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/GenerateTechDebtReportTask.kt +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/GenerateTechDebtReportTask.kt @@ -3,14 +3,17 @@ package com.escodro.techdebt.gradle import com.escodro.techdebt.gradle.model.TechDebtItem import com.escodro.techdebt.gradle.parser.CommentParser import com.escodro.techdebt.gradle.parser.GeneratedTechDebtParser +import com.escodro.techdebt.gradle.parser.GitParser import com.escodro.techdebt.gradle.report.HtmlReportGenerator import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction @@ -24,12 +27,18 @@ abstract class GenerateTechDebtReportTask : DefaultTask() { /** Whether to collect TODO/FIXME comments. Defaults to `false`. */ @get:Input abstract val collectComments: Property + /** Whether to enable Git metadata (e.g. last modified date). Defaults to `false`. */ + @get:Input abstract val enableGitMetadata: Property + /** * Map of project directory to project path. Used to resolve the module name for TODO comments * without accessing the Project object at execution time. */ @get:Input abstract val projectPathByDirectory: MapProperty + /** The root directory of the project. Used to resolve Git metadata. */ + @get:Internal abstract val rootProjectDirectory: DirectoryProperty + /** The source files to scan for TODO comments. */ @get:InputFiles @get:Optional abstract val sourceFiles: ConfigurableFileCollection @@ -43,25 +52,29 @@ abstract class GenerateTechDebtReportTask : DefaultTask() { private val jsonParser: GeneratedTechDebtParser = GeneratedTechDebtParser() - private val commentParser: CommentParser = CommentParser() - @TaskAction fun generate() { val allItems = mutableListOf() allItems += jsonParser.parse(jsonFiles) + val rootProjectDir = rootProjectDirectory.get().asFile if (collectComments.get()) { allItems += - commentParser.parse( - sourceFiles = sourceFiles, - projectPaths = projectPathByDirectory.get() - ) + CommentParser() + .parse(sourceFiles = sourceFiles, projectPaths = projectPathByDirectory.get()) } val aggregatedItems = aggregateItems(allItems) + val itemsWithMetadata = + if (enableGitMetadata.get()) { + GitParser(rootProjectDir).parse(aggregatedItems) + } else { + aggregatedItems + } + val sortedItems = - aggregatedItems.sortedWith(compareBy({ it.moduleName }, { it.priorityOrder })) + itemsWithMetadata.sortedWith(compareBy({ it.moduleName }, { it.priorityOrder })) writeReport(sortedItems) } diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/TechDebtExtension.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/TechDebtExtension.kt index 207e5c8..f3fd7ae 100644 --- a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/TechDebtExtension.kt +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/TechDebtExtension.kt @@ -18,6 +18,9 @@ abstract class TechDebtExtension { /** Whether to collect TODO/FIXME comments. Defaults to `false`. */ abstract val collectComments: Property + /** Whether to enable Git metadata (e.g. last modified date). Defaults to `false`. */ + abstract val enableGitMetadata: Property + /** * The base URL for the tickets. If set, the ticket property in the HTML report will be a link. */ diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/TechDebtPlugin.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/TechDebtPlugin.kt index 9a9394a..fcf7ce1 100644 --- a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/TechDebtPlugin.kt +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/TechDebtPlugin.kt @@ -16,6 +16,7 @@ class TechDebtPlugin : Plugin { project.extensions.create("techDebtReport", TechDebtExtension::class.java) extension.collectSuppress.convention(false) extension.collectComments.convention(false) + extension.enableGitMetadata.convention(false) val reportTask: TaskProvider = project.tasks.register( @@ -23,10 +24,12 @@ class TechDebtPlugin : Plugin { GenerateTechDebtReportTask::class.java ) { task -> task.collectComments.set(extension.collectComments) + task.enableGitMetadata.set(extension.enableGitMetadata) task.baseTicketUrl.set(extension.baseTicketUrl) task.projectPathByDirectory.set( project.allprojects.associate { it.projectDir.absolutePath to it.path } ) + task.rootProjectDirectory.set(project.rootProject.layout.projectDirectory) task.outputFile.set( extension.outputFile.convention( project.layout.buildDirectory.file( diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/model/TechDebtItem.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/model/TechDebtItem.kt index de570e2..ade3f8b 100644 --- a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/model/TechDebtItem.kt +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/model/TechDebtItem.kt @@ -12,6 +12,9 @@ import kotlinx.serialization.Serializable * @property priority the priority of the tech debt * @property sourceSet the source set where the tech debt is located * @property type the type of the tech debt item + * @property lastModified the last time the tech debt was modified + * @property location the location of the tech debt in the source code + * @property author the author of the tech debt */ @Serializable data class TechDebtItem( @@ -21,7 +24,10 @@ data class TechDebtItem( val ticket: String, val priority: String, val sourceSet: String, - val type: TechDebtItemType = TechDebtItemType.TECH_DEBT + val type: TechDebtItemType = TechDebtItemType.TECH_DEBT, + val lastModified: String? = null, + val location: String? = null, + val author: String? = null, ) { /** * Returns the priority order for the tech debt item. diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/CommentParser.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/CommentParser.kt index 8e25170..a8b0ffb 100644 --- a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/CommentParser.kt +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/CommentParser.kt @@ -38,14 +38,16 @@ internal class CommentParser { val description = if (content.isEmpty()) type else "$type: $content" val relativePath = file.relativeTo(File(projectDir)).invariantSeparatorsPath + val location = "$relativePath:${index + 1}" items += TechDebtItem( moduleName = projectPath, - sourceSet = "$relativePath:${index + 1}", + sourceSet = relativePath, name = "", description = description, ticket = "", priority = "", + location = location, type = TechDebtItemType.COMMENT ) } diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/GeneratedTechDebtParser.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/GeneratedTechDebtParser.kt index 5dd92a4..8c55970 100644 --- a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/GeneratedTechDebtParser.kt +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/GeneratedTechDebtParser.kt @@ -19,11 +19,13 @@ internal class GeneratedTechDebtParser { if (file.exists()) { val items = Json.decodeFromString>(file.readText()) val updatedItems = - if (items.any { it.sourceSet == "unknown" }) { - val sourceSet = resolveSourceSet(file.absolutePath) - items.map { it.copy(sourceSet = sourceSet) } - } else { - items + items.map { item -> + if (shouldResolveSourceSet(item.sourceSet)) { + val sourceSet = resolveSourceSet(file.absolutePath) + item.copy(sourceSet = sourceSet) + } else { + item + } } allItems.addAll(updatedItems) } @@ -31,13 +33,23 @@ internal class GeneratedTechDebtParser { return allItems } + private fun shouldResolveSourceSet(sourceSet: String): Boolean { + return sourceSet == SOURCE_SET_UNKNOWN || sourceSet == SOURCE_SET_MAIN + } + private fun resolveSourceSet(path: String): String { val parts = path.split("/") val kspIndex = parts.indexOf("ksp") - return if (kspIndex != -1 && kspIndex + 1 < parts.size) { - parts[kspIndex + 1] - } else { - "unknown" + if (kspIndex != -1 && kspIndex + 1 < parts.size) { + val sourceSetName = parts[kspIndex + 1] + return sourceSetName } + return SOURCE_SET_UNKNOWN + } + + private companion object { + + private const val SOURCE_SET_MAIN = "main" + private const val SOURCE_SET_UNKNOWN = "unknown" } } diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/GitParser.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/GitParser.kt new file mode 100644 index 0000000..552cb91 --- /dev/null +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/GitParser.kt @@ -0,0 +1,116 @@ +package com.escodro.techdebt.gradle.parser + +import com.escodro.techdebt.gradle.model.TechDebtItem +import java.io.File +import java.io.IOException +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import org.apache.log4j.Logger +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.errors.GitAPIException +import org.eclipse.jgit.blame.BlameResult +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.storage.file.FileRepositoryBuilder + +/** + * Metadata processor responsible for enriching [TechDebtItem] with Git information, such as the + * last modified date. + * + * @param rootProjectDirectory the root directory of the project + */ +internal class GitParser(private val rootProjectDirectory: File) { + + private val logger = Logger.getLogger(GitParser::class.java) + + private val sourceFileResolver = SourceFileResolver(rootProjectDirectory) + + private val blameCache = mutableMapOf() + + /** + * Enriches the given [TechDebtItem]s with Git information. + * + * @param items the list of tech debt items to be enriched + * @return the list of enriched tech debt items + */ + fun parse(items: List): List { + val repository = findRepository(rootProjectDirectory) ?: return items + + return repository.use { repo -> + val git = Git(repo) + items.map { item -> + val gitInfo = getGitInfo(git, item) + item.copy(lastModified = gitInfo?.lastModified, author = gitInfo?.author) + } + } + } + + private fun getGitInfo(git: Git, item: TechDebtItem): GitInfo? { + val sourceFile = sourceFileResolver.resolve(item.location, item.moduleName) + val lineNumber = getLineNumber(item.location) + if (sourceFile == null || !sourceFile.exists() || lineNumber == null) { + return null + } + + val relativePath = + sourceFile.relativeTo(rootProjectDirectory).path.replace(File.separatorChar, '/') + + return try { + val blame = + blameCache.getOrPut(relativePath) { + git.blame().setFilePath(relativePath).setFollowFileRenames(true).call() + } + extractGitInfo(blame, lineNumber) + } catch (e: IOException) { + logger.error("Failed to get Git info for file: $relativePath at line: $lineNumber", e) + null + } catch (e: GitAPIException) { + logger.error("Failed to get Git blame for file: $relativePath at line: $lineNumber", e) + null + } + } + + private fun extractGitInfo(blame: BlameResult?, lineNumber: Int): GitInfo? { + val commit = blame?.getSourceCommit(lineNumber - 1) ?: return null + val authorIdent = commit.authorIdent + val date = authorIdent.whenAsInstant + return GitInfo( + lastModified = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneId.systemDefault()) + .format(date), + author = authorIdent.name, + ) + } + + private fun findRepository(directory: File): Repository? = + try { + val gitDir = FileRepositoryBuilder().readEnvironment().findGitDir(directory).gitDir + if (gitDir == null) { + null + } else { + FileRepositoryBuilder().setGitDir(gitDir).build() + } + } catch (e: IOException) { + logger.error("Failed to find Git repository in directory: ${directory.absolutePath}", e) + null + } catch (e: IllegalArgumentException) { + logger.error("Invalid Git repository in directory: ${directory.absolutePath}", e) + null + } + + private fun getLineNumber(location: String?): Int? { + if (location == null || !location.contains(":")) return null + return location.substringAfterLast(":").toIntOrNull() + } +} + +/** + * Data class representing Git metadata information. + * + * @param lastModified the last modified date of the file + * @param author the author of the last modification + */ +private data class GitInfo( + val lastModified: String?, + val author: String?, +) diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/SourceFileResolver.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/SourceFileResolver.kt new file mode 100644 index 0000000..5281f4b --- /dev/null +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/parser/SourceFileResolver.kt @@ -0,0 +1,46 @@ +package com.escodro.techdebt.gradle.parser + +import java.io.File + +/** + * Resolver responsible for mapping a source set string (path:line) to a physical [File] on disk. + */ +internal class SourceFileResolver(private val rootProjectDirectory: File) { + + /** + * Resolves the source file for the given source set and module name. + * + * @param sourceSet the source set string (e.g., "src/main/kotlin/MyFile.kt:10" or "main") + * @param moduleName the name of the module (e.g., ":app") + * @return the resolved [File], or `null` if it couldn't be found + */ + fun resolve(sourceSet: String?, moduleName: String): File? { + val path = sourceSet?.substringBeforeLast(":") + + if (sourceSet == "unknown" || path == null) return null + + val file = File(path) + val resolvedFile = + when { + file.isAbsolute && file.exists() -> file + File(rootProjectDirectory, path).exists() -> File(rootProjectDirectory, path) + else -> resolveInModule(moduleName, path) ?: fallbackSearch(path) + } + return resolvedFile + } + + private fun resolveInModule(moduleName: String, path: String): File? { + val moduleRelativePath = moduleName.removePrefix(":").replace(":", "/") + val moduleDir = File(rootProjectDirectory, moduleRelativePath) + + return if (moduleDir.exists() && moduleDir.isDirectory) { + val fileInModule = File(moduleDir, path) + if (fileInModule.exists()) fileInModule else null + } else { + null + } + } + + private fun fallbackSearch(path: String): File? = + rootProjectDirectory.walkTopDown().firstOrNull { it.isFile && it.path.endsWith(path) } +} diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/report/CardGenerator.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/report/CardGenerator.kt index ab00b34..7d8720c 100644 --- a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/report/CardGenerator.kt +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/report/CardGenerator.kt @@ -61,8 +61,11 @@ internal class CardGenerator { ) { +item.sourceSet } - if (item.type == TechDebtItemType.SUPPRESS) { - infoGroup("Symbol") { +item.name } + if (item.lastModified != null) { + infoGroup("Last Modified") { +item.lastModified } + } + if (item.author != null) { + infoGroup("Author") { +item.author } } } } @@ -72,7 +75,7 @@ internal class CardGenerator { private fun FlowContent.infoGroup(label: String, block: FlowContent.() -> Unit) { div(classes = "info-group") { span(classes = "info-label") { +label } - span(classes = "info-value") { block() } + div(classes = "info-value") { block() } } } diff --git a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/report/ReportStyle.kt b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/report/ReportStyle.kt index e17575b..00393fe 100644 --- a/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/report/ReportStyle.kt +++ b/techdebt-gradle-plugin/src/main/kotlin/com/escodro/techdebt/gradle/report/ReportStyle.kt @@ -236,6 +236,10 @@ internal class ReportStyle { .info-value { font-size: 14px; color: #333; + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; } .ticket { diff --git a/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/CommentParserTest.kt b/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/CommentParserTest.kt index d6587c4..f77a8a0 100644 --- a/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/CommentParserTest.kt +++ b/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/CommentParserTest.kt @@ -5,6 +5,7 @@ import java.io.File import org.gradle.testfixtures.ProjectBuilder import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -12,7 +13,12 @@ internal class CommentParserTest { @TempDir lateinit var tempDir: File - private val parser = CommentParser() + private lateinit var parser: CommentParser + + @BeforeEach + fun setup() { + parser = CommentParser() + } @Test fun `test parser collects TODOs from source files`() { @@ -43,7 +49,8 @@ internal class CommentParserTest { val item = items.first() assertEquals(":project", item.moduleName) assertEquals("TODO: My task", item.description) - assertEquals("src/main/kotlin/com/example/MyClass.kt:3", item.sourceSet) + assertEquals("src/main/kotlin/com/example/MyClass.kt", item.sourceSet) + assertEquals("src/main/kotlin/com/example/MyClass.kt:3", item.location) assertEquals(TechDebtItemType.COMMENT, item.type) } diff --git a/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/GeneratedTechDebtParserTest.kt b/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/GeneratedTechDebtParserTest.kt index ecd95c3..41f6c29 100644 --- a/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/GeneratedTechDebtParserTest.kt +++ b/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/GeneratedTechDebtParserTest.kt @@ -79,4 +79,35 @@ internal class GeneratedTechDebtParserTest { assertEquals(1, items.size) assertEquals("customSourceSet", items.first().sourceSet) } + + @Test + fun `test parser does not resolve sourceSet when it is a bare filename`() { + val project = ProjectBuilder.builder().withProjectDir(tempDir).build() + val kspDir = File(tempDir, "build/generated/ksp/main/resources/techdebt") + kspDir.mkdirs() + val jsonFile = + File(kspDir, "report.json").apply { + writeText( + """ + [ + { + "moduleName": ":app", + "name": "com.example.MyClass", + "description": "Test debt", + "ticket": "JIRA-123", + "priority": "HIGH", + "sourceSet": "MyFile.kt" + } + ] + """ + .trimIndent() + ) + } + + val jsonFiles = project.files(jsonFile) + val items = parser.parse(jsonFiles) + + assertEquals(1, items.size) + assertEquals("MyFile.kt", items.first().sourceSet) + } } diff --git a/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/GitParserTest.kt b/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/GitParserTest.kt new file mode 100644 index 0000000..c380eff --- /dev/null +++ b/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/GitParserTest.kt @@ -0,0 +1,191 @@ +package com.escodro.techdebt.gradle.parser + +import com.escodro.techdebt.gradle.model.TechDebtItem +import java.io.File +import org.eclipse.jgit.api.Git +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +internal class GitParserTest { + + @TempDir lateinit var tempDir: File + + @Test + fun `test git parser extracts information correctly`() { + // Initialize a git repository in the temp directory + val git = Git.init().setDirectory(tempDir).call() + val file = File(tempDir, "TestFile.kt") + file.writeText("content") + git.add().addFilepattern("TestFile.kt").call() + val commit = + git.commit().setMessage("Initial commit").setAuthor("Junie", "junie@example.com").call() + + val parser = GitParser(tempDir) + val item = + TechDebtItem( + moduleName = ":app", + name = "Test", + description = "Test description", + ticket = "T-123", + priority = "HIGH", + sourceSet = "main", // Realistic sourceSet name + location = file.absolutePath + ":1" + ) + + val results = parser.parse(listOf(item)) + + assertEquals(1, results.size) + val result = results.first() + assertEquals("Junie", result.author) + assertNotNull(result.lastModified) + } + + @Test + fun `test git parser returns original items if no repository found`() { + val parser = GitParser(tempDir) + val file = File(tempDir, "TestFile.kt") + file.writeText("content") + val item = + TechDebtItem( + moduleName = ":app", + name = "Test", + description = "Test description", + ticket = "T-123", + priority = "HIGH", + sourceSet = "TestFile.kt:1", + location = file.absolutePath + ":1" + ) + + val results = parser.parse(listOf(item)) + + assertEquals(1, results.size) + assertNull(results.first().author) + assertNull(results.first().lastModified) + } + + @Test + fun `test git parser uses cache for multiple items in same file`() { + val git = Git.init().setDirectory(tempDir).call() + val file = File(tempDir, "TestFile.kt") + file.writeText("line 1\nline 2") + git.add().addFilepattern("TestFile.kt").call() + git.commit().setMessage("Initial commit").setAuthor("Junie", "junie@example.com").call() + + val parser = GitParser(tempDir) + val item1 = + TechDebtItem( + moduleName = ":app", + name = "Test 1", + description = "Desc 1", + ticket = "T-1", + priority = "HIGH", + sourceSet = "TestFile.kt:1", + location = file.absolutePath + ":1" + ) + val item2 = + TechDebtItem( + moduleName = ":app", + name = "Test 2", + description = "Desc 2", + ticket = "T-2", + priority = "LOW", + sourceSet = "TestFile.kt:2", + location = file.absolutePath + ":2" + ) + + val results = parser.parse(listOf(item1, item2)) + + assertEquals(2, results.size) + assertEquals("Junie", results[0].author) + assertEquals("Junie", results[1].author) + } + + @Test + fun `test git parser returns null if line number is missing`() { + val git = Git.init().setDirectory(tempDir).call() + val file = File(tempDir, "MainFile.kt") + file.writeText("content") + git.add().addFilepattern("MainFile.kt").call() + git.commit().setMessage("Initial commit").setAuthor("Junie", "junie@example.com").call() + + val parser = GitParser(tempDir) + val item = + TechDebtItem( + moduleName = ":app", + name = "Test", + description = "Test description", + ticket = "T-123", + priority = "HIGH", + sourceSet = "main", + location = file.absolutePath // No line number here + ) + + val results = parser.parse(listOf(item)) + + assertEquals(1, results.size) + assertNull(results.first().author) + } + + @Test + fun `test git parser handles OS-specific paths`() { + // Initialize a git repository + val git = Git.init().setDirectory(tempDir).call() + + // Create a file in a subdirectory to have a relative path + val subDir = File(tempDir, "subdir").apply { mkdir() } + val file = File(subDir, "TestFile.kt") + file.writeText("content") + + // In Git, the path must be forward-slash separated + git.add().addFilepattern("subdir/TestFile.kt").call() + git.commit().setMessage("Initial commit").setAuthor("Junie", "junie@example.com").call() + + val parser = GitParser(tempDir) + val item = + TechDebtItem( + moduleName = ":app", + name = "Test", + description = "Test description", + ticket = "T-123", + priority = "HIGH", + sourceSet = "TestFile.kt:1", + location = file.absolutePath + ":1" + ) + + val results = parser.parse(listOf(item)) + assertEquals("Junie", results.first().author) + } + + @Test + fun `test git parser does not return info for uncommitted changes`() { + val git = Git.init().setDirectory(tempDir).call() + val file = File(tempDir, "TestFile.kt") + file.writeText("committed content\n") + git.add().addFilepattern("TestFile.kt").call() + git.commit().setMessage("Initial commit").setAuthor("Junie", "junie@example.com").call() + + // Add a new line that is NOT committed + file.appendText("uncommitted content") + + val parser = GitParser(tempDir) + val item = + TechDebtItem( + moduleName = ":app", + name = "Test", + description = "Test description", + ticket = "T-123", + priority = "HIGH", + sourceSet = "main", + location = file.absolutePath + ":2" + ) + + val results = parser.parse(listOf(item)) + + assertEquals(1, results.size) + assertNull(results.first().author) + assertNull(results.first().lastModified) + } +} diff --git a/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/SourceFileResolverTest.kt b/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/SourceFileResolverTest.kt new file mode 100644 index 0000000..3b753f8 --- /dev/null +++ b/techdebt-gradle-plugin/src/test/kotlin/com/escodro/techdebt/gradle/parser/SourceFileResolverTest.kt @@ -0,0 +1,85 @@ +package com.escodro.techdebt.gradle.parser + +import java.io.File +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +internal class SourceFileResolverTest { + + @TempDir lateinit var tempDir: File + + @Test + fun `test resolver finds file by absolute path`() { + val file = File(tempDir, "MyFile.kt").apply { writeText("content") } + val resolver = SourceFileResolver(tempDir) + + val resolved = resolver.resolve(file.absolutePath + ":10", ":app") + + assertNotNull(resolved) + assertEquals(file.absolutePath, resolved?.absolutePath) + } + + @Test + fun `test resolver finds file by relative path from root`() { + val subDir = File(tempDir, "src/main/kotlin").apply { mkdirs() } + val file = File(subDir, "MyFile.kt").apply { writeText("content") } + val resolver = SourceFileResolver(tempDir) + + val resolved = resolver.resolve("src/main/kotlin/MyFile.kt:10", ":app") + + assertNotNull(resolved) + assertEquals(file.absolutePath, resolved?.absolutePath) + } + + @Test + fun `test resolver finds file in module directory`() { + val moduleDir = File(tempDir, "app/src/main/kotlin").apply { mkdirs() } + val file = File(moduleDir, "MyFile.kt").apply { writeText("content") } + val resolver = SourceFileResolver(tempDir) + + // The path in sourceSet might be relative to the module + val resolved = resolver.resolve("src/main/kotlin/MyFile.kt:10", ":app") + + assertNotNull(resolved) + assertEquals(file.absolutePath, resolved?.absolutePath) + } + + @Test + fun `test resolver falls back to walkTopDown`() { + val deepDir = File(tempDir, "some/very/deep/path").apply { mkdirs() } + val file = File(deepDir, "DeepFile.kt").apply { writeText("content") } + val resolver = SourceFileResolver(tempDir) + + val resolved = resolver.resolve("DeepFile.kt:10", ":any") + + assertNotNull(resolved) + assertEquals(file.absolutePath, resolved?.absolutePath) + } + + @Test + fun `test resolver returns null for unknown sourceSet`() { + val resolver = SourceFileResolver(tempDir) + val resolved = resolver.resolve("unknown", ":app") + assertNull(resolved) + } + + @Test + fun `test resolver handles main sourceSet by falling back to search`() { + val subDir = File(tempDir, "src/main/kotlin").apply { mkdirs() } + val file = File(subDir, "MainFile.kt").apply { writeText("content") } + val resolver = SourceFileResolver(tempDir) + + // When KSP says "main", we hope to find the file by searching for the module name or + // something + // In this case, "main" as sourceSet won't match any file name unless we have a file named + // "main" + // But if we pass a real file name that KSP sometimes fails to give full path for: + val resolved = resolver.resolve("MainFile.kt", ":app") + + assertNotNull(resolved) + assertEquals(file.absolutePath, resolved?.absolutePath) + } +} diff --git a/techdebt-processor/src/main/kotlin/com/escodro/techdebt/extension/KSAnnotationExtensions.kt b/techdebt-processor/src/main/kotlin/com/escodro/techdebt/extension/KSAnnotationExtensions.kt index 5c8f851..985c702 100644 --- a/techdebt-processor/src/main/kotlin/com/escodro/techdebt/extension/KSAnnotationExtensions.kt +++ b/techdebt-processor/src/main/kotlin/com/escodro/techdebt/extension/KSAnnotationExtensions.kt @@ -1,8 +1,11 @@ package com.escodro.techdebt.extension +import com.google.devtools.ksp.symbol.FileLocation import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSDeclaration import com.google.devtools.ksp.symbol.KSFile +import com.google.devtools.ksp.symbol.Location /** * Returns the name of the given symbol, which can be a declaration or a file. @@ -16,3 +19,23 @@ fun getSymbolName(symbol: KSAnnotated): String = is KSFile -> symbol.fileName else -> "unknown" } + +/** + * Returns the source location of the given annotation. + * + * @param annotation the annotation to get the source location + * @param sourceSet the source set name + * @return the source location + */ +fun getAnnotationLocation(annotation: KSAnnotation, sourceSet: String): String = + getLocation(annotation.location, sourceSet) + +private fun getLocation(location: Location, sourceSet: String): String { + val sourceLocation = + if (location is FileLocation) { + "${location.filePath}:${location.lineNumber}" + } else { + sourceSet + } + return sourceLocation +} diff --git a/techdebt-processor/src/main/kotlin/com/escodro/techdebt/processor/SuppressSymbolProcessor.kt b/techdebt-processor/src/main/kotlin/com/escodro/techdebt/processor/SuppressSymbolProcessor.kt index ecd1f83..a2187ee 100644 --- a/techdebt-processor/src/main/kotlin/com/escodro/techdebt/processor/SuppressSymbolProcessor.kt +++ b/techdebt-processor/src/main/kotlin/com/escodro/techdebt/processor/SuppressSymbolProcessor.kt @@ -1,5 +1,6 @@ package com.escodro.techdebt.processor +import com.escodro.techdebt.extension.getAnnotationLocation import com.escodro.techdebt.extension.getSymbolName import com.escodro.techdebt.report.TechDebtItem import com.escodro.techdebt.report.TechDebtItemType @@ -31,9 +32,6 @@ internal class SuppressSymbolProcessor { resolver.getSymbolsWithAnnotation(Suppress::class.qualifiedName!!).forEach { symbol -> if (!symbol.validate()) return@forEach - val ksFile = symbol as? KSFile ?: (symbol as? KSDeclaration)?.containingFile - ksFile?.let { allOriginatingFiles.add(it) } - val suppressAnnotations = symbol.annotations .filter { @@ -54,6 +52,13 @@ internal class SuppressSymbolProcessor { } val name = getSymbolName(symbol) + val sourceLocation = + getAnnotationLocation( + annotation = suppressAnnotations.first(), + sourceSet = sourceSet + ) + val ksFile = symbol as? KSFile ?: (symbol as? KSDeclaration)?.containingFile + ksFile?.let { allOriginatingFiles.add(it) } ruleNames.forEach { rule -> allItems.add( @@ -64,6 +69,7 @@ internal class SuppressSymbolProcessor { ticket = "", priority = "", sourceSet = sourceSet, + location = sourceLocation, type = TechDebtItemType.SUPPRESS ) ) diff --git a/techdebt-processor/src/main/kotlin/com/escodro/techdebt/processor/TechDebtSymbolProcessor.kt b/techdebt-processor/src/main/kotlin/com/escodro/techdebt/processor/TechDebtSymbolProcessor.kt index aca5ace..4b6474a 100644 --- a/techdebt-processor/src/main/kotlin/com/escodro/techdebt/processor/TechDebtSymbolProcessor.kt +++ b/techdebt-processor/src/main/kotlin/com/escodro/techdebt/processor/TechDebtSymbolProcessor.kt @@ -1,6 +1,7 @@ package com.escodro.techdebt.processor import com.escodro.techdebt.TechDebt +import com.escodro.techdebt.extension.getAnnotationLocation import com.escodro.techdebt.extension.getSymbolName import com.escodro.techdebt.report.TechDebtItem import com.google.devtools.ksp.processing.Resolver @@ -40,9 +41,6 @@ internal class TechDebtSymbolProcessor { return@forEach } - val ksFile = symbol as? KSFile ?: (symbol as? KSDeclaration)?.containingFile - ksFile?.let { allOriginatingFiles.add(it) } - val annotation = symbol.annotations.firstOrNull { it.annotationType.resolve().declaration.qualifiedName?.asString() == @@ -59,6 +57,10 @@ internal class TechDebtSymbolProcessor { } val name = getSymbolName(symbol) + val sourceLocation = + getAnnotationLocation(annotation = annotation, sourceSet = sourceSet) + val ksFile = symbol as? KSFile ?: (symbol as? KSDeclaration)?.containingFile + ksFile?.let { allOriginatingFiles.add(it) } allItems.add( TechDebtItem( @@ -67,7 +69,8 @@ internal class TechDebtSymbolProcessor { description = args["description"]?.toString().orEmpty(), ticket = args["ticket"]?.toString().orEmpty(), priority = priority, - sourceSet = sourceSet + sourceSet = sourceSet, + location = sourceLocation, ) ) } diff --git a/techdebt-processor/src/main/kotlin/com/escodro/techdebt/report/TechDebtItem.kt b/techdebt-processor/src/main/kotlin/com/escodro/techdebt/report/TechDebtItem.kt index 0b1dcf8..bdae6cb 100644 --- a/techdebt-processor/src/main/kotlin/com/escodro/techdebt/report/TechDebtItem.kt +++ b/techdebt-processor/src/main/kotlin/com/escodro/techdebt/report/TechDebtItem.kt @@ -12,6 +12,9 @@ import kotlinx.serialization.Serializable * @property priority the priority of the tech debt * @property sourceSet the source set where the tech debt is located * @property type the type of the tech debt item + * @property lastModified the last time the tech debt was modified + * @property location the location of the tech debt in the source code + * @property author the author of the tech debt */ @Serializable data class TechDebtItem( @@ -21,7 +24,10 @@ data class TechDebtItem( val ticket: String, val priority: String, val sourceSet: String, - val type: TechDebtItemType = TechDebtItemType.TECH_DEBT + val type: TechDebtItemType = TechDebtItemType.TECH_DEBT, + val lastModified: String? = null, + val location: String? = null, + val author: String? = null, ) /** Represents the type of technical debt item. */