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
+
+
+
Last Modified
+
2026-01-18 11:39:12
+
+
@@ -255,9 +269,21 @@ Annotated Tech Debt
-
-
PriorityHIGH
-
Source Setmain
+
+
+
+
Last Modified
+
2026-01-31 17:34:25
+
+
@@ -272,9 +298,15 @@ Annotated Tech Debt
-
-
PriorityMEDIUM
-
Source Setmain
+
+
+
@@ -289,9 +321,21 @@ Annotated Tech Debt
-
-
PriorityLOW
-
Source Setmain
+
+
+
+
Last Modified
+
2026-01-31 17:34:25
+
+
@@ -306,8 +350,18 @@ Annotated Tech Debt
-
PriorityNONE
-
Source Setmain
+
+
+
Last Modified
+
2026-01-10 19:56:05
+
+
@@ -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
+
+
@@ -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
+
+
@@ -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
+
+
@@ -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
+
+
@@ -380,8 +466,15 @@ Suppressed Rules
-
Source Setmain
-
Symbolcom.escodro.sample.Sample.suppressedFunction
+
+
Last Modified
+
2026-01-25 09:17:04
+
+
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. */