diff --git a/README.md b/README.md
index ce401d1..592417b 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,73 @@
Todo Checker
============
+The Todo Checker plugin looks for the pattern `(?i)(TODO|FIXME)\\s+($jiraProject-\\d+)` (e.g. "TODO MYPROJECT-123: some
+comment") in text files. If any of the referenced Jira issues are resolved, it will report the file together with the
+issue owner or reporter. There should be no TODO markers in the source code for resolved issues.
+
+## Configure Todo Checker in Gradle
+
+```kotlin
+todoChecker {
+ jiraUrl.set(URI.create("https://jira.mycompany.com"))
+ jiraUsername.set(providers.environmentVariable("TODOCHECKER_USERNAME"))
+ jiraPassword.set(providers.environmentVariable("TODOCHECKER_PASSWORD"))
+ jiraProject.set("MYPROJECT")
+}
+```
+
+## Execute the task
+
+```shell
+./gradlew checkTodo
+```
+
+## Configuration
+
+You can use the following settings to configure the plugin.
+
+| Parameter | Default value | Required | Description |
+|-------------------------|-------------------------------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| directory | project directory | no | The directory to scan for TODO markers. |
+| exclusions | empty list | no | A file containing a Java Glob per line for files that the plugin should NOT scan. |
+| inclusions | empty list | no | A file containing a Java Glob per line for files that the plugin should always scan.
Otherwise, files that don't match will be tested for text content. |
+| jiraUrl | - | yes | The Jira URL. |
+| jiraUsername | - | no | The username to connect to Jira (use this OR jiraPersonalAccessToken). May also be passed as gradle property, e.g. set in gradle.properties. |
+| jiraPassword | - | no | The password to connect to Jira (use this OR jiraPersonalAccessToken). May also be passed as gradle property, e.g. set in gradle.properties. |
+| jiraPersonalAccessToken | - | no | Personal access token (may be used instead of username+password). May also be passed as gradle property, e.g. set in gradle.properties. |
+| jiraProject | - | yes | The Jira project key to match TODO markers. |
+| jiraResolvedStatuses | "Done" status category | no | A list of statuses in which an issue is considered resolved.
If not set, all issues that have a "Done" status category are considered resolved. |
+| todoRegex | ```(?i)(TODO\|FIXME)\s+(?$jiraProject-\d+)``` | no | The regex used for searching TODOs and recognizing associated JIRA tickets. Must contain a named capture group "ticket" which is used to extract the matched JIRA ticket. |
+
+### Exclusion file example
+
+An exclusion file looks as follows.
+
+```text
+**/.gradle/**
+.git/**
+.idea/**
+**/node_modules/**
+**/build/**
+```
+
+### Inclusion file example
+
+An inclusion file could look like this.
+
+```text
+**.java
+**.jsp
+**.js
+**.kt
+**.kts
+**.ts
+**.gradle
+**.properties
+**.xml
+**.go
+```
+
License
-------
Todo Checker is released under the [MIT License](LICENSE).
diff --git a/build.gradle.kts b/build.gradle.kts
index e69de29..874615a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -0,0 +1,63 @@
+import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
+
+plugins {
+ `java-gradle-plugin`
+ alias(libs.plugins.detekt)
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.spotless)
+}
+
+group = "ch.ergon.todochecker"
+
+gradlePlugin {
+ plugins {
+ register("todoChecker") {
+ id = "ch.ergon.todochecker"
+ implementationClass = "ch.ergon.todochecker.TodoCheckerPlugin"
+ }
+ }
+}
+
+dependencies {
+ compileOnly(libs.jira.rest.java.client)
+}
+
+dependencyLocking {
+ lockAllConfigurations()
+}
+
+kotlin {
+ jvmToolchain(11)
+}
+
+tasks.withType {
+ compilerOptions {
+ allWarningsAsErrors.set(true)
+ }
+}
+
+java {
+ consistentResolution {
+ useCompileClasspathVersions()
+ }
+}
+
+detekt {
+ buildUponDefaultConfig = true
+}
+
+spotless {
+ kotlin {
+ ktlint()
+ }
+}
+
+tasks.register("resolveAndLockAll") {
+ doFirst {
+ require(gradle.startParameter.isWriteDependencyLocks)
+ }
+
+ doLast {
+ configurations.filter { it.isCanBeResolved }.forEach { it.resolve() }
+ }
+}
diff --git a/gradle.lockfile b/gradle.lockfile
new file mode 100644
index 0000000..34c7674
--- /dev/null
+++ b/gradle.lockfile
@@ -0,0 +1,99 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+com.atlassian.event:atlassian-event:4.1.3=compileClasspath,compileOnlyDependenciesMetadata
+com.atlassian.httpclient:atlassian-httpclient-api:2.1.5=compileClasspath,compileOnlyDependenciesMetadata
+com.atlassian.httpclient:atlassian-httpclient-library:2.1.5=compileClasspath,compileOnlyDependenciesMetadata
+com.atlassian.jira:jira-rest-java-client-api:5.2.5=compileClasspath,compileOnlyDependenciesMetadata
+com.atlassian.jira:jira-rest-java-client-core:5.2.5=compileClasspath,compileOnlyDependenciesMetadata
+com.atlassian.sal:sal-api:4.4.4=compileClasspath,compileOnlyDependenciesMetadata
+com.beust:jcommander:1.82=detekt
+com.google.code.findbugs:jsr305:3.0.2=compileClasspath,compileOnlyDependenciesMetadata
+com.google.errorprone:error_prone_annotations:2.5.1=compileClasspath,compileOnlyDependenciesMetadata
+com.google.guava:failureaccess:1.0.1=compileClasspath,compileOnlyDependenciesMetadata
+com.google.guava:guava:30.1.1-jre=compileClasspath,compileOnlyDependenciesMetadata
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=compileClasspath,compileOnlyDependenciesMetadata
+com.google.j2objc:j2objc-annotations:1.3=compileClasspath,compileOnlyDependenciesMetadata
+commons-codec:commons-codec:1.15=compileClasspath,compileOnlyDependenciesMetadata
+commons-logging:commons-logging:1.2=compileClasspath,compileOnlyDependenciesMetadata
+io.atlassian.util.concurrent:atlassian-util-concurrent:4.0.1=compileClasspath,compileOnlyDependenciesMetadata
+io.github.davidburstrom.contester:contester-breakpoint:0.2.0=detekt
+io.github.detekt.sarif4k:sarif4k-jvm:0.4.0=detekt
+io.github.detekt.sarif4k:sarif4k:0.4.0=detekt
+io.gitlab.arturbosch.detekt:detekt-api:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-cli:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-core:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-metrics:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-parser:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-psi-utils:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-report-html:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-report-md:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-report-sarif:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-report-txt:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-report-xml:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-complexity:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-coroutines:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-documentation:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-empty:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-errorprone:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-exceptions:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-naming:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-performance:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules-style:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-rules:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-tooling:1.23.0=detekt
+io.gitlab.arturbosch.detekt:detekt-utils:1.23.0=detekt
+jakarta.annotation:jakarta.annotation-api:1.3.5=compileClasspath,compileOnlyDependenciesMetadata
+jakarta.ws.rs:jakarta.ws.rs-api:2.1.6=compileClasspath,compileOnlyDependenciesMetadata
+joda-time:joda-time:2.9.9=compileClasspath,compileOnlyDependenciesMetadata
+net.java.dev.jna:jna:5.6.0=detekt,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.apache.commons:commons-lang3:3.5=compileClasspath,compileOnlyDependenciesMetadata
+org.apache.httpcomponents:httpasyncclient-cache:4.1.4=compileClasspath,compileOnlyDependenciesMetadata
+org.apache.httpcomponents:httpasyncclient:4.1.4=compileClasspath,compileOnlyDependenciesMetadata
+org.apache.httpcomponents:httpclient-cache:4.5.13=compileClasspath,compileOnlyDependenciesMetadata
+org.apache.httpcomponents:httpclient:4.5.13=compileClasspath,compileOnlyDependenciesMetadata
+org.apache.httpcomponents:httpcore-nio:4.4.10=compileClasspath,compileOnlyDependenciesMetadata
+org.apache.httpcomponents:httpcore:4.4.13=compileClasspath,compileOnlyDependenciesMetadata
+org.apache.httpcomponents:httpmime:4.5.13=compileClasspath,compileOnlyDependenciesMetadata
+org.checkerframework:checker-qual:3.8.0=compileClasspath,compileOnlyDependenciesMetadata
+org.codehaus.jettison:jettison:1.5.4=compileClasspath,compileOnlyDependenciesMetadata
+org.glassfish.hk2.external:jakarta.inject:2.6.1=compileClasspath,compileOnlyDependenciesMetadata
+org.glassfish.hk2:osgi-resource-locator:1.0.3=compileClasspath,compileOnlyDependenciesMetadata
+org.glassfish.jersey.core:jersey-client:2.35=compileClasspath,compileOnlyDependenciesMetadata
+org.glassfish.jersey.core:jersey-common:2.35=compileClasspath,compileOnlyDependenciesMetadata
+org.glassfish.jersey.media:jersey-media-jaxb:2.35=compileClasspath,compileOnlyDependenciesMetadata
+org.glassfish.jersey.media:jersey-media-json-jettison:2.35=compileClasspath,compileOnlyDependenciesMetadata
+org.jetbrains.intellij.deps:trove4j:1.0.20200330=detekt,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-compiler-embeddable:1.8.21=detekt
+org.jetbrains.kotlin:kotlin-compiler-embeddable:1.8.22=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-daemon-embeddable:1.8.21=detekt
+org.jetbrains.kotlin:kotlin-daemon-embeddable:1.8.22=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-klib-commonizer-embeddable:1.8.22=kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinCompilerClasspath,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-reflect:1.8.21=detekt
+org.jetbrains.kotlin:kotlin-script-runtime:1.8.21=detekt
+org.jetbrains.kotlin:kotlin-script-runtime:1.8.22=kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath
+org.jetbrains.kotlin:kotlin-scripting-common:1.8.22=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
+org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.8.22=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
+org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.8.22=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
+org.jetbrains.kotlin:kotlin-scripting-jvm:1.8.22=kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest
+org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=detekt
+org.jetbrains.kotlin:kotlin-stdlib-common:1.8.22=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.21=detekt
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.21=detekt
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.jetbrains.kotlin:kotlin-stdlib:1.8.21=detekt
+org.jetbrains.kotlin:kotlin-stdlib:1.8.22=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.jetbrains.kotlinx:kotlinx-html-jvm:0.8.1=detekt
+org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.4.1=detekt
+org.jetbrains.kotlinx:kotlinx-serialization-core:1.4.1=detekt
+org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.4.1=detekt
+org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1=detekt
+org.jetbrains:annotations:13.0=apiDependenciesMetadata,compileClasspath,detekt,implementationDependenciesMetadata,kotlinCompilerClasspath,kotlinCompilerPluginClasspathMain,kotlinCompilerPluginClasspathTest,kotlinKlibCommonizerClasspath,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath
+org.slf4j:slf4j-api:1.7.36=compileClasspath,compileOnlyDependenciesMetadata
+org.snakeyaml:snakeyaml-engine:2.6=detekt
+org.springframework:spring-beans:5.3.25=compileClasspath,compileOnlyDependenciesMetadata
+org.springframework:spring-core:5.3.25=compileClasspath,compileOnlyDependenciesMetadata
+org.springframework:spring-jcl:5.3.25=compileClasspath,compileOnlyDependenciesMetadata
+empty=annotationProcessor,detektPlugins,intransitiveDependenciesMetadata,kotlinCompilerPluginClasspath,kotlinNativeCompilerPluginClasspath,kotlinScriptDef,kotlinScriptDefExtensions,testAnnotationProcessor,testApiDependenciesMetadata,testCompileOnlyDependenciesMetadata,testIntransitiveDependenciesMetadata,testKotlinScriptDef,testKotlinScriptDefExtensions
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..9dbb8c2
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,2 @@
+version=1.0.0-SNAPSHOT
+kotlin.code.style=official
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..fd5caff
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,13 @@
+[versions]
+detekt = "1.23.0"
+jira-rest-java-client = "5.2.5"
+kotlin = "1.8.22"
+spotless = "6.19.0"
+
+[libraries]
+jira-rest-java-client = { module = "com.atlassian.jira:jira-rest-java-client-core", version.ref = "jira-rest-java-client" }
+
+[plugins]
+detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
+kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
+spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
diff --git a/settings-gradle.lockfile b/settings-gradle.lockfile
new file mode 100644
index 0000000..709a43f
--- /dev/null
+++ b/settings-gradle.lockfile
@@ -0,0 +1,4 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+empty=incomingCatalogForLibs0
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 267624b..e49429b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1 +1,10 @@
rootProject.name = "todo-checker"
+
+dependencyResolutionManagement {
+ repositories {
+ mavenCentral()
+ maven {
+ url = uri("https://packages.atlassian.com/mvn/maven-external/")
+ }
+ }
+}
diff --git a/src/main/kotlin/ch/ergon/todochecker/CreateOutput.kt b/src/main/kotlin/ch/ergon/todochecker/CreateOutput.kt
new file mode 100644
index 0000000..e9f1592
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/CreateOutput.kt
@@ -0,0 +1,51 @@
+package ch.ergon.todochecker
+
+import com.atlassian.jira.rest.client.api.domain.Issue
+import java.nio.file.Path
+
+internal fun createOutput(
+ users: Map>,
+ todos: Map>,
+): String =
+ users.entries.asSequence()
+ .map { entry -> createOutputForUser(entry, todos) }
+ .joinToString(System.lineSeparator() + System.lineSeparator())
+
+private fun createOutputForUser(
+ entry: Map.Entry>,
+ todos: Map>,
+): String =
+ createUsername(entry.key) +
+ System.lineSeparator() +
+ createIssueList(entry.value, todos)
+
+private fun createUsername(user: JiraUser?): String =
+ user?.run { "$displayName:" } ?: "Unknown User:"
+
+private fun createIssueList(
+ issues: Set,
+ todos: Map>,
+): String =
+ issues.asSequence()
+ .map { issue -> createOutputForIssue(issue, todos) }
+ .joinToString(System.lineSeparator())
+
+private fun createOutputForIssue(
+ issue: Issue,
+ todos: Map>,
+): String =
+ createIssueKey(issue) +
+ System.lineSeparator() +
+ createFileList(issue, todos)
+
+private fun createIssueKey(issue: Issue): String = "- ${issue.key}"
+
+private fun createFileList(
+ issue: Issue,
+ todos: Map>,
+): String =
+ todos[JiraIssueKey(issue.key)].orEmpty().asSequence()
+ .map(::createOutputForFile)
+ .joinToString(System.lineSeparator())
+
+private fun createOutputForFile(path: Path): String = " - $path"
diff --git a/src/main/kotlin/ch/ergon/todochecker/JiraCredentials.kt b/src/main/kotlin/ch/ergon/todochecker/JiraCredentials.kt
new file mode 100644
index 0000000..d50c235
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/JiraCredentials.kt
@@ -0,0 +1,17 @@
+package ch.ergon.todochecker
+
+import java.io.Serializable
+
+sealed interface JiraCredentials {
+ data class UsernamePassword(val username: String, val password: String) : JiraCredentials, Serializable {
+ companion object {
+ private const val serialVersionUID: Long = 1
+ }
+ }
+
+ data class PersonalAccessToken(val token: String) : JiraCredentials, Serializable {
+ companion object {
+ private const val serialVersionUID: Long = 1
+ }
+ }
+}
diff --git a/src/main/kotlin/ch/ergon/todochecker/JiraIssueKey.kt b/src/main/kotlin/ch/ergon/todochecker/JiraIssueKey.kt
new file mode 100644
index 0000000..0d7538e
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/JiraIssueKey.kt
@@ -0,0 +1,3 @@
+package ch.ergon.todochecker
+
+internal data class JiraIssueKey(val value: String)
diff --git a/src/main/kotlin/ch/ergon/todochecker/JiraRepository.kt b/src/main/kotlin/ch/ergon/todochecker/JiraRepository.kt
new file mode 100644
index 0000000..bf8c51c
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/JiraRepository.kt
@@ -0,0 +1,63 @@
+package ch.ergon.todochecker
+
+import com.atlassian.jira.rest.client.api.IssueRestClient
+import com.atlassian.jira.rest.client.api.JiraRestClient
+import com.atlassian.jira.rest.client.api.domain.Issue
+import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory
+import org.codehaus.jettison.json.JSONObject
+import java.net.URI
+
+internal class JiraRepository(
+ private val url: URI,
+ private val credentials: JiraCredentials,
+ private val resolvedStatuses: List,
+) {
+ /**
+ * For a set of issues (specified by key) finds the associated users (either owner or reporter).
+ * Returns a mapping from user to their set of issue. If an issue has no owner or reporter, the key of the map is null.
+ */
+ fun getUsersForTodo(issues: Set): Map> =
+ createRestClient()
+ .use { client ->
+ issues.asSequence()
+ .map(client.issueClient::fetchIssue)
+ .filter(::isResolved)
+ .groupByTo(mutableMapOf(), Issue::getOwnerOrReporter)
+ .mapValues { it.value.toSet() }
+ }
+
+ private fun createRestClient(): JiraRestClient =
+ when (credentials) {
+ is JiraCredentials.UsernamePassword ->
+ AsynchronousJiraRestClientFactory()
+ .createWithBasicHttpAuthentication(url, credentials.username, credentials.password)
+
+ is JiraCredentials.PersonalAccessToken ->
+ AsynchronousJiraRestClientFactory()
+ .createWithAuthenticationHandler(url) {
+ it.setHeader("Authorization", "Bearer ${credentials.token}")
+ }
+ }
+
+ private fun isResolved(issue: Issue): Boolean =
+ if (resolvedStatuses.isEmpty()) {
+ issue.status.statusCategory.name == "Done"
+ } else {
+ resolvedStatuses.any { it == issue.status.name }
+ }
+}
+
+private fun IssueRestClient.fetchIssue(key: JiraIssueKey): Issue =
+ getIssue(key.value).claim()
+
+private fun Issue.getOwnerOrReporter(): JiraUser? {
+ val owner = getFieldByName("Owner")?.value?.let {
+ JiraUser((it as JSONObject).displayName)
+ }
+ return owner ?: reporter?.displayName?.let {
+ JiraUser(it)
+ }
+}
+
+private val JSONObject.displayName: String
+ get() = getString("displayName")
diff --git a/src/main/kotlin/ch/ergon/todochecker/JiraUser.kt b/src/main/kotlin/ch/ergon/todochecker/JiraUser.kt
new file mode 100644
index 0000000..b896f2c
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/JiraUser.kt
@@ -0,0 +1,3 @@
+package ch.ergon.todochecker
+
+internal data class JiraUser(val displayName: String)
diff --git a/src/main/kotlin/ch/ergon/todochecker/TodoCheckerExtension.kt b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerExtension.kt
new file mode 100644
index 0000000..a4dd324
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerExtension.kt
@@ -0,0 +1,53 @@
+package ch.ergon.todochecker
+
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import java.net.URI
+
+interface TodoCheckerExtension {
+ /**
+ * The directory to be scanned by the checker.
+ */
+ val directory: DirectoryProperty
+
+ /**
+ * The config file for exclusions.
+ */
+ val exclusions: RegularFileProperty
+
+ /**
+ * The config file for inclusions.
+ */
+ val inclusions: RegularFileProperty
+
+ /**
+ * The Jira URL that the plugin is connected to.
+ */
+ val jiraUrl: Property
+ val jiraUsername: Property
+ val jiraPassword: Property
+ val jiraPersonalAccessToken: Property
+
+ /**
+ * The Jira project key.
+ */
+ val jiraProject: Property
+
+ /**
+ * The Jira statuses considered for reporting.
+ */
+ val jiraResolvedStatuses: ListProperty
+
+ /**
+ * The regex used for searching TODOs and recognizing associated JIRA issue.
+ * Must contain a named capture group "ticket" which is used to extract the matched JIRA ticket.
+ */
+ val todoRegex: Property
+
+ /**
+ * Whether the gradle build fails on found TODOs with resolved JIRA issue.
+ */
+ val failOnResolvedTodos: Property
+}
diff --git a/src/main/kotlin/ch/ergon/todochecker/TodoCheckerPlugin.kt b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerPlugin.kt
new file mode 100644
index 0000000..85a2247
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerPlugin.kt
@@ -0,0 +1,44 @@
+package ch.ergon.todochecker
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.artifacts.Configuration
+import org.gradle.api.file.RegularFile
+import org.gradle.api.provider.Provider
+
+private const val EXTENSION_NAME = "todoChecker"
+private const val TASK_NAME = "checkTodo"
+private const val OUTPUT_FILE_NAME = "todochecker.txt"
+
+class TodoCheckerPlugin : Plugin {
+ override fun apply(project: Project) = with(project) {
+ createExtension()
+ createTask()
+ }
+}
+
+private fun Project.createExtension() {
+ val extension = extensions.create(EXTENSION_NAME, TodoCheckerExtension::class.java)
+ extension.directory.convention(layout.projectDirectory)
+}
+
+private fun Project.createTask() {
+ tasks.register(TASK_NAME, TodoCheckerTask::class.java) {
+ it.jiraConfiguration.from(createConfiguration())
+ it.outputFile.convention(outputFile())
+ }
+}
+
+private fun Project.createConfiguration(): Configuration =
+ configurations.create("jira") {
+ it.isVisible = false
+ it.isCanBeConsumed = false
+ it.isCanBeResolved = true
+ it.defaultDependencies { dependencies ->
+ dependencies.add(project.dependencies.create("com.atlassian.jira:jira-rest-java-client-core:5.2.5"))
+ dependencies.add(project.dependencies.create("io.atlassian.fugue:fugue:4.7.2"))
+ }
+ }
+
+private fun Project.outputFile(): Provider =
+ layout.buildDirectory.file(OUTPUT_FILE_NAME)
diff --git a/src/main/kotlin/ch/ergon/todochecker/TodoCheckerTask.kt b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerTask.kt
new file mode 100644
index 0000000..cc1675f
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerTask.kt
@@ -0,0 +1,60 @@
+package ch.ergon.todochecker
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.Project
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.TaskAction
+import org.gradle.workers.WorkerExecutor
+import javax.inject.Inject
+
+abstract class TodoCheckerTask @Inject constructor(
+ private val workerExecutor: WorkerExecutor,
+) : DefaultTask() {
+ init {
+ // We always want to run this task
+ outputs.upToDateWhen { false }
+ }
+
+ @get:InputFiles
+ abstract val jiraConfiguration: ConfigurableFileCollection
+
+ @get:OutputFile
+ abstract val outputFile: RegularFileProperty
+
+ @TaskAction
+ fun checkTodo() {
+ val extension = project.extensions.getByType(TodoCheckerExtension::class.java)
+ val workQueue = workerExecutor.classLoaderIsolation {
+ it.classpath.from(jiraConfiguration)
+ }
+
+ workQueue.submit(TodoCheckerWorkAction::class.java) {
+ it.outputFile.set(this@TodoCheckerTask.outputFile)
+ it.directory.set(extension.directory)
+ it.exclusions.set(extension.exclusions)
+ it.inclusions.set(extension.inclusions)
+ it.jiraUrl.set(extension.jiraUrl)
+ it.jiraCredentials.set(getCredentials(project, extension))
+ it.jiraProject.set(extension.jiraProject)
+ it.jiraResolvedStatuses.set(extension.jiraResolvedStatuses)
+ it.todoRegex.set(extension.todoRegex)
+ it.failOnResolvedTodos.set(extension.failOnResolvedTodos)
+ }
+ }
+
+ private fun getCredentials(project: Project, extension: TodoCheckerExtension): JiraCredentials {
+ val token = project.findProperty("jiraPersonalAccessToken") as String?
+ ?: extension.jiraPersonalAccessToken.getOrNull()
+ return if (token != null) {
+ JiraCredentials.PersonalAccessToken(token)
+ } else {
+ JiraCredentials.UsernamePassword(
+ username = project.findProperty("jiraUsername") as String? ?: extension.jiraUsername.get(),
+ password = project.findProperty("jiraPassword") as String? ?: extension.jiraPassword.get(),
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/ch/ergon/todochecker/TodoCheckerWorkAction.kt b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerWorkAction.kt
new file mode 100644
index 0000000..71ca17f
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerWorkAction.kt
@@ -0,0 +1,75 @@
+package ch.ergon.todochecker
+
+import org.gradle.api.GradleException
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.logging.Logging
+import org.gradle.workers.WorkAction
+import java.io.IOException
+import java.nio.file.Files
+
+abstract class TodoCheckerWorkAction : WorkAction {
+ private val logger = Logging.getLogger(TodoCheckerWorkAction::class.java)
+
+ override fun execute() {
+ val directory = parameters.directory.asFile.get().toPath()
+ val exclusions = readExclusions(parameters.exclusions)
+ val inclusions = readInclusions(parameters.inclusions)
+ logger.info("Scanning \"$directory\" for Todo")
+
+ val todo = todoScannerFor(
+ directory = directory,
+ exclusions = exclusions,
+ inclusions = inclusions,
+ jiraProject = parameters.jiraProject.get(),
+ todoRegex = parameters.todoRegex.getOrNull(),
+ ).scan()
+ val jiraRepository = JiraRepository(
+ parameters.jiraUrl.get(),
+ parameters.jiraCredentials.get(),
+ parameters.jiraResolvedStatuses.getOrElse(emptyList()),
+ )
+
+ val users = jiraRepository.getUsersForTodo(todo.keys)
+ logger.info("Found ${users.size} users with Todo")
+ val output = createOutput(users, todo)
+
+ if (output.isNotBlank()) {
+ logger.lifecycle(
+ """
+Todo for resolved issues found:
+$output
+ """,
+ )
+
+ val file = parameters.outputFile.asFile.get().toPath()
+
+ try {
+ Files.writeString(file, output)
+ logger.info("Todo written to file $file")
+ } catch (e: IOException) {
+ logger.error("Error writing to file $file", e)
+ }
+
+ if (parameters.failOnResolvedTodos.getOrElse(false)) {
+ throw GradleException("Todos with resolved issues found")
+ }
+ } else {
+ logger.lifecycle("No Todo for resolved issues found")
+ }
+ }
+
+ private fun readExclusions(exclusions: RegularFileProperty): List =
+ exclusions.map { f ->
+ f.asFile.useLines { it.toListWithoutComments() }
+ }.getOrElse(emptyList())
+
+ private fun readInclusions(inclusions: RegularFileProperty): List =
+ inclusions.map { f ->
+ f.asFile.useLines { it.toListWithoutComments() }
+ }.getOrElse(emptyList())
+
+ private fun Sequence.toListWithoutComments(): List = map(String::trim)
+ .filter(String::isNotBlank)
+ .filter { !it.startsWith("#") }
+ .toList()
+}
diff --git a/src/main/kotlin/ch/ergon/todochecker/TodoCheckerWorkParameters.kt b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerWorkParameters.kt
new file mode 100644
index 0000000..0fd4125
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/TodoCheckerWorkParameters.kt
@@ -0,0 +1,21 @@
+package ch.ergon.todochecker
+
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.provider.Property
+import org.gradle.workers.WorkParameters
+import java.net.URI
+
+internal interface TodoCheckerWorkParameters : WorkParameters {
+ val outputFile: RegularFileProperty
+ val directory: DirectoryProperty
+ val exclusions: RegularFileProperty
+ val inclusions: RegularFileProperty
+ val jiraUrl: Property
+ val jiraCredentials: Property
+ val jiraProject: Property
+ val jiraResolvedStatuses: ListProperty
+ val todoRegex: Property
+ val failOnResolvedTodos: Property
+}
diff --git a/src/main/kotlin/ch/ergon/todochecker/TodoScanner.kt b/src/main/kotlin/ch/ergon/todochecker/TodoScanner.kt
new file mode 100644
index 0000000..e62485a
--- /dev/null
+++ b/src/main/kotlin/ch/ergon/todochecker/TodoScanner.kt
@@ -0,0 +1,134 @@
+package ch.ergon.todochecker
+
+import org.gradle.api.logging.Logging
+import java.io.IOException
+import java.io.UncheckedIOException
+import java.nio.file.FileSystems
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.function.Function
+import java.util.function.Predicate
+import java.util.regex.Pattern
+import java.util.stream.Collectors
+
+internal fun todoScannerFor(
+ directory: Path,
+ exclusions: List,
+ inclusions: List,
+ jiraProject: String,
+ todoRegex: String?,
+): TodoScanner =
+ TodoScanner(directory, exclusions, inclusions, jiraProject, todoRegex)
+
+internal class TodoScanner internal constructor(
+ private val directory: Path,
+ exclusions: List,
+ inclusions: List,
+ private val jiraProject: String,
+ private val todoRegex: String?,
+) {
+ private val logger = Logging.getLogger(TodoScanner::class.java)
+ private val exclusions: String
+ private val inclusions: String
+
+ init {
+ this.exclusions = "glob:{" + exclusions.joinToString(separator = ",") + "}"
+ this.inclusions = "glob:{" + inclusions.joinToString(separator = ",") + "}"
+ }
+
+ /**
+ * Walks over all files in the directory and returns a mapping between Jira issues and files.
+ */
+ fun scan(): Map> =
+ Files.walk(directory).use { stream ->
+ stream.map { path -> directory.relativize(path) }
+ .filter { path -> Files.isRegularFile(path) }
+ .filter(::notExcluded)
+ .filter(isIncluded().or(hasTextContent()))
+ .flatMap { path -> getTodoForFile(path).entries.stream() }
+ .collect(
+ Collectors.groupingBy(
+ { (key) -> key },
+ Collectors.mapping(
+ { (_, value) -> value },
+ Collectors.toSet(),
+ ),
+ ),
+ )
+ }
+
+ private fun notExcluded(file: Path): Boolean {
+ val excluded = FileSystems.getDefault()
+ .getPathMatcher(exclusions)
+ .matches(file)
+ if (excluded) {
+ logger.info("Skipping file \"$file\": Excluded")
+ }
+ return !excluded
+ }
+
+ private fun isIncluded(): Predicate =
+ Predicate { path ->
+ FileSystems.getDefault()
+ .getPathMatcher(inclusions)
+ .matches(path)
+ }
+
+ private fun hasTextContent(): Predicate =
+ Predicate { path: Path ->
+ val contentType = Files.probeContentType(path)
+ when {
+ contentType == null -> {
+ logger.warn("Skipping file \"$path\": Unknown content type")
+ false
+ }
+
+ !contentType.startsWith("text/") -> {
+ logger.warn("Skipping file \"$path\": Not a text file ($contentType)")
+ false
+ }
+
+ else -> true
+ }
+ }
+
+ /**
+ * Gets all Todo for a specific file and returns a Map<"ABC-123", Filename>.
+ */
+ private fun getTodoForFile(file: Path): Map =
+ try {
+ Files.lines(file).use {
+ it.flatMap { line -> getTodoForLine(line).stream() }
+ .collect(
+ Collectors.toMap(
+ Function.identity(),
+ { file },
+ { existing, _ -> existing },
+ ),
+ )
+ }
+ } catch (e: IOException) {
+ logger.warn("Skipping file \"$file\": ${e.message}")
+ mapOf()
+ } catch (e: UncheckedIOException) {
+ logger.warn("Skipping file \"$file\": ${e.message}")
+ mapOf()
+ }
+
+ /**
+ * Finds all lines containing our common issue format which is enforced by the ide.
+ * The format is case insensitive and starts with either TODO or FIXME
+ * followed by at least one whitespace character and .
+ */
+ private fun getTodoForLine(line: String): List {
+ val pattern = Pattern.compile(todoRegex ?: "(?i)(TODO|FIXME)\\s+(?$jiraProject-\\d+)")
+ val matcher = pattern.matcher(line)
+ val todo: MutableList = ArrayList()
+
+ while (matcher.find()) {
+ todo.add(JiraIssueKey(matcher.group("ticket")))
+ }
+
+ return todo
+ }
+}