From c5665a6aa5a806d00e89d2c22148320e600878e5 Mon Sep 17 00:00:00 2001 From: Phokham Nonava Date: Fri, 7 Jun 2024 17:17:52 +0200 Subject: [PATCH] Add sources --- README.md | 67 +++++++++ build.gradle.kts | 63 ++++++++ gradle.lockfile | 99 +++++++++++++ gradle.properties | 2 + gradle/libs.versions.toml | 13 ++ settings-gradle.lockfile | 4 + settings.gradle.kts | 9 ++ .../ch/ergon/todochecker/CreateOutput.kt | 51 +++++++ .../ch/ergon/todochecker/JiraCredentials.kt | 17 +++ .../ch/ergon/todochecker/JiraIssueKey.kt | 3 + .../ch/ergon/todochecker/JiraRepository.kt | 63 ++++++++ .../kotlin/ch/ergon/todochecker/JiraUser.kt | 3 + .../ergon/todochecker/TodoCheckerExtension.kt | 53 +++++++ .../ch/ergon/todochecker/TodoCheckerPlugin.kt | 44 ++++++ .../ch/ergon/todochecker/TodoCheckerTask.kt | 60 ++++++++ .../todochecker/TodoCheckerWorkAction.kt | 75 ++++++++++ .../todochecker/TodoCheckerWorkParameters.kt | 21 +++ .../ch/ergon/todochecker/TodoScanner.kt | 134 ++++++++++++++++++ 18 files changed, 781 insertions(+) create mode 100644 gradle.lockfile create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 settings-gradle.lockfile create mode 100644 src/main/kotlin/ch/ergon/todochecker/CreateOutput.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/JiraCredentials.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/JiraIssueKey.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/JiraRepository.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/JiraUser.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/TodoCheckerExtension.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/TodoCheckerPlugin.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/TodoCheckerTask.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/TodoCheckerWorkAction.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/TodoCheckerWorkParameters.kt create mode 100644 src/main/kotlin/ch/ergon/todochecker/TodoScanner.kt 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 + } +}