From fb24cdbd840c6b5b964ec1cba03fadf4d6cd90a1 Mon Sep 17 00:00:00 2001 From: "Florine W. Dekker" Date: Fri, 12 Jan 2024 20:40:46 +0100 Subject: [PATCH] Rewrite error reporter completely --- README.md | 7 +- build.gradle.kts | 5 + gradle.properties | 1 + .../com/fwdekker/randomness/ErrorReporter.kt | 378 +++++++++++++++--- src/main/resources/randomness.properties | 17 +- src/main/resources/reporter/token.bin | 1 + 6 files changed, 344 insertions(+), 65 deletions(-) create mode 100644 src/main/resources/reporter/token.bin diff --git a/README.md b/README.md index d26664d8c..396ac11a1 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,10 @@ Please also check the [contribution guidelines](.github/CONTRIBUTING.md). ### 🔨 Build/run ```bash -$ gradlew runIde # Open a sandbox IntelliJ instance running the plugin -$ gradlew buildPlugin # Build an installable zip of the plugin -$ gradlew signPlugin # Sign built plugin +$ gradlew runIde # Open a sandbox IntelliJ instance running the plugin +$ gradlew buildPlugin # Build an installable zip of the plugin +$ gradlew buildPlugin -Pbuild.hotswap # Same as above, but allow hot-swapping the plugin during development +$ gradlew signPlugin # Sign built plugin ``` Signing the plugin requires specific environment variables to be set to refer to appropriate key files. See [Plugin Signing](https://plugins.jetbrains.com/docs/intellij/plugin-signing.html) for more information. diff --git a/build.gradle.kts b/build.gradle.kts index 90fdc8565..0f488c74f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation("com.github.sisyphsu:dateparser:${properties("dateparserVersion")}") implementation("com.github.curious-odd-man:rgxgen:${properties("rgxgenVersion")}") implementation("com.vdurmont:emoji-java:${properties("emojiVersion")}") + implementation("org.eclipse.mylyn.github:org.eclipse.egit.github.core:${properties("githubCore")}") api("org.jetbrains.kotlin:kotlin-reflect") testImplementation("org.assertj:assertj-swing-junit:${properties("assertjSwingVersion")}") @@ -87,6 +88,10 @@ tasks { updateSinceUntilBuild.set(false) // Set in `patchPluginXml` } + buildSearchableOptions { + enabled = !project.hasProperty("build.hotswap") + } + patchPluginXml { changeNotes.set(provider { changelog.renderItem( diff --git a/gradle.properties b/gradle.properties index bb9d28594..7e9b36bca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -37,6 +37,7 @@ dateparserVersion=1.0.11 detektVersion=1.23.3 dokkaVersion=1.9.10 emojiVersion=5.1.1 +githubCore=2.1.5 junitVersion=5.10.1 junitRunnerVersion=1.10.1 kotestVersion=5.8.0 diff --git a/src/main/kotlin/com/fwdekker/randomness/ErrorReporter.kt b/src/main/kotlin/com/fwdekker/randomness/ErrorReporter.kt index 21a19fdb1..47b05d5f1 100644 --- a/src/main/kotlin/com/fwdekker/randomness/ErrorReporter.kt +++ b/src/main/kotlin/com/fwdekker/randomness/ErrorReporter.kt @@ -1,107 +1,357 @@ +@file:Suppress("DEPRECATION") // Required for [ErrorReportSubmitter]. + package com.fwdekker.randomness +import com.fwdekker.randomness.GitHubReporter.Scrambler.IV +import com.fwdekker.randomness.GitHubReporter.Scrambler.KEY +import com.intellij.diagnostic.AbstractMessage import com.intellij.ide.BrowserUtil -import com.intellij.ide.DataManager -import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.notification.Notification +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.diagnostic.Attachment import com.intellij.openapi.diagnostic.ErrorReportSubmitter import com.intellij.openapi.diagnostic.IdeaLoggingEvent import com.intellij.openapi.diagnostic.SubmittedReportInfo +import com.intellij.openapi.diagnostic.SubmittedReportInfo.SubmissionStatus.DUPLICATE +import com.intellij.openapi.diagnostic.SubmittedReportInfo.SubmissionStatus.FAILED +import com.intellij.openapi.diagnostic.SubmittedReportInfo.SubmissionStatus.NEW_ISSUE +import com.intellij.openapi.extensions.PluginDescriptor import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task.Backgroundable +import com.intellij.openapi.util.SystemInfo import com.intellij.util.Consumer +import com.intellij.util.applyIf +import org.eclipse.egit.github.core.Issue +import org.eclipse.egit.github.core.RepositoryId +import org.eclipse.egit.github.core.client.GitHubClient +import org.eclipse.egit.github.core.service.IssueService import java.awt.Component -import java.net.URLEncoder -import java.nio.charset.StandardCharsets +import java.io.File +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec /** - * A report submitter that opens a pre-filled issue creation form on Randomness' GitHub repository. + * Reports exceptions on GitHub. * - * This class pertains to reports of exceptions that are not caught by the plugin and end up being shown to the user - * as a notification by the IDE. + * Heavily inspired by [Patrick Scheibe's error reporter](https://github.com/halirutan/Wolfram-Language-IntelliJ-Plugin-Archive/tree/e3dd72f9cd344d678ac892aaa7bf59abd84871e8/src/de/halirutan/mathematica/errorreporting). */ +@Suppress("detekt:MaxLineLength") // Necessary because of the long link in the docs above class ErrorReporter : ErrorReportSubmitter() { + /** + * Interacts with GitHub. + */ + private val github = GitHubReporter() + + /** * Returns the text that is displayed in the button to report the error. */ - override fun getReportActionText() = Bundle("reporter.report") + override fun getReportActionText(): String = Bundle("reporter.report") + + /** + * Returns the privacy notice text. + */ + override fun getPrivacyNoticeText() = Bundle("reporter.privacy_notice") /** * Submits the exception by opening the browser to create an issue on GitHub. * - * @param events ignored + * @param events the events to report * @param additionalInfo additional information provided by the user * @param parentComponent ignored - * @param consumer ignored + * @param consumer the callback to invoke afterwards * @return `true` */ override fun submit( - events: Array, + events: Array, additionalInfo: String?, parentComponent: Component, consumer: Consumer, ): Boolean { - val project = CommonDataKeys.PROJECT.getData(DataManager.getInstance().getDataContext(parentComponent)) - object : Backgroundable(project, Bundle("reporter.opening")) { - override fun run(indicator: ProgressIndicator) { - BrowserUtil.open(getIssueUrl(additionalInfo)) - consumer.consume( - SubmittedReportInfo( - "https://github.com/FWDekker/intellij-randomness/issues", - Bundle("reporter.issue"), - SubmittedReportInfo.SubmissionStatus.NEW_ISSUE + ProgressManager.getInstance() + .run(object : Backgroundable(null, Bundle("reporter.task.title"), false) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + + val report = github.report(IssueData(events.asList(), additionalInfo, pluginDescriptor)) + consumer.consume(report) + notifyUser(report) + } + }) + + return true + } + + /** + * Displays a notification to the user explaining the [report]. + */ + private fun notifyUser(report: SubmittedReportInfo) = + NotificationGroupManager.getInstance() + .getNotificationGroup("Error Report") + .run { + if (report.status == FAILED) + createNotification( + Bundle("reporter.notify.title.failure"), + report.linkText, + NotificationType.ERROR, + ) + else + createNotification( + Bundle("reporter.notify.title.success"), + if (report.status == DUPLICATE) Bundle("reporter.notify.description.duplicate") + else Bundle("reporter.notify.description.new"), + NotificationType.INFORMATION, ) - ) } - }.queue() - return true + .setIcon(Icons.RANDOMNESS) + .setImportant(false) + .applyIf(report.status != FAILED) { + addAction(object : NotificationAction(Bundle("reporter.notify.view_in_browser")) { + override fun actionPerformed(event: AnActionEvent, notification: Notification) = + BrowserUtil.browse(report.url) + }) + } + .notify(null) +} + + +/** + * Knows how to report [IssueData] to GitHub. + */ +private class GitHubReporter { + /** + * The repository to open issues in. + */ + private val repo = RepositoryId(GIT_REPO_USER, GIT_REPO) + + /** + * Service for interacting with the issues API on GitHub. + */ + private val issueService = IssueService(GitHubClient().also { it.setOAuth2Token(Scrambler.getToken()) }) + + + /** + * Attempts to report [issueData] to GitHub. + */ + fun report(issueData: IssueData): SubmittedReportInfo = + try { + val duplicate = issueService.pageIssues(repo).flatten().firstOrNull(issueData::isDuplicateOf) + + val context: Issue + if (duplicate == null) { + context = issueService.createIssue(repo, issueData.asGitHubIssue()) + } else { + issueService.createComment(repo, duplicate.number, issueData.body) + context = duplicate + } + + SubmittedReportInfo( + context.htmlUrl, + Bundle( + if (duplicate == null) "reporter.report.new" else "reporter.report.duplicate", + context.htmlUrl, + context.number, + ), + if (duplicate == null) NEW_ISSUE else DUPLICATE + ) + } catch (_: Exception) { + SubmittedReportInfo(null, Bundle("reporter.report.error"), FAILED) + } + + + /** + * A GitHub authentication token that is slightly scrambled. + * + * Though the scrambling uses encryption, it is not actually stored securely, and can be obtained relatively easily + * by other people. Even if [KEY] and [IV] were not stored in plaintext, you would eventually have to leak the + * plaintext token. There is no way around this. + * + * Assume the token is public knowledge: It may be stolen and abused, and it is your responsibility to ensure that + * the potential for harm is minimised: Use a fine-grained access token that is limited to read/write access for a + * single repo. Do not use your main repo, unless you're fine with the worst case of all your issues being deleted. + */ + private object Scrambler { + /** + * The resource path to the scrambled token. + */ + private const val PATH = "reporter/token.bin" + + /** + * The IV to use for (un)scrambling. + */ + private const val IV = "MgKsLCT9BDbPHqrp" + + /** + * The "private" key to use for (un)scrambling. + */ + private const val KEY = "WDWde5Hwm5bXgJN2" + + /** + * The IV specification to use for (un)scrambling. + */ + private val IV_SPEC = IvParameterSpec(IV.toByteArray(charset("UTF-8"))) + + /** + * The key specification to use for (un)scrambling. + */ + private val KEY_SPEC = SecretKeySpec(KEY.toByteArray(charset("UTF-8")), "AES") + + + /** + * Instantiates a [Cipher] for (un)scrambling a token. + */ + private fun createCipher() = Cipher.getInstance("AES/CBC/PKCS5PADDING") + + /** + * Reads the unscrambled token. + */ + fun getToken(): String = + unscramble(String(javaClass.classLoader.getResource(PATH)!!.readBytes())) + .also { require(it.startsWith("github")) { "Invalid token after unscrambling." } } + + /** + * Unscrambles the given [scrambledToken]. + */ + fun unscramble(scrambledToken: String): String = + createCipher() + .also { it.init(Cipher.DECRYPT_MODE, KEY_SPEC, IV_SPEC) } + .doFinal(Base64.getDecoder().decode(scrambledToken.toByteArray())) + .let { String(it).trim() } + + /** + * Scrambles the given [token]. + */ + fun scramble(token: String): String = + createCipher() + .also { it.init(Cipher.ENCRYPT_MODE, KEY_SPEC, IV_SPEC) } + .doFinal(token.trim().toByteArray()) + .let { String(Base64.getEncoder().encode(it)) } + + + /** + * Runs an interactive session to scramble a token into a file. + */ + @JvmStatic + fun main(args: Array) { + val target = File("token.bin") + + print("Enter token to scramble: ") + val token = readln() + + target.writeText(scramble(token)) + require(unscramble(target.readText()) == token) { "Stored token does not match input token." } + + println("Scrambled token has been stored in '${target.absolutePath}'.") + } } /** - * Returns the privacy notice text. + * Holds constants. */ - override fun getPrivacyNoticeText() = Bundle("reporter.privacy_notice") + companion object { + /** + * The name of the user that owns the repo to report errors in. + */ + private const val GIT_REPO_USER = "FWDekkerBot" + /** + * The repository to report errors in. + */ + private const val GIT_REPO = "intellij-randomness-issues" + } +} + +/** + * Contains a variety of metadata on an issue to report, and knows how to format the issue in a textual form. + * + * @property events The events to report. + * @property additionalInfo Additional information provided by the user. + * @property pluginDescriptor The descriptor of Randomness. + */ +private class IssueData( + val events: List, + val additionalInfo: String?, + val pluginDescriptor: PluginDescriptor, +) { + /** + * Event data for the [events]. + */ + private val eventData: List = + events.map { it.data }.filterIsInstance() /** - * Constructs a URL to create an issue that provides [additionalInfo] and is below the maximum URL limit. + * The hash that identifies this issue, used for duplication detection. */ - fun getIssueUrl(additionalInfo: String?): String { - val baseUrl = "https://github.com/FWDekker/intellij-randomness/issues/new?body=" + private val hash: String = + eventData.map { it.throwable.stackTrace.contentHashCode() }.hashCode().toUInt().toString(radix = 16) + + /** + * The list of included attachments. + */ + private val attachments: List = + eventData.flatMap { it.allAttachments }.filter { it.isIncluded } + - val additionalInfoSection = createMarkdownSection( - "Additional info", - if (additionalInfo.isNullOrBlank()) MORE_DETAIL_MESSAGE else additionalInfo + /** + * Returns the title of the issue. + */ + val title: String = + Bundle( + "reporter.issue.title", + hash, + eventData.firstNotNullOfOrNull { it.throwable.message } + ?: events.firstNotNullOfOrNull { it.message } + ?: Bundle("reporter.issue.title.unknown"), ) - val stacktraceSection = createMarkdownSection("Stacktraces", STACKTRACE_MESSAGE) - val versionSection = createMarkdownSection("Version information", getFormattedVersionInformation()) - return URLEncoder.encode(additionalInfoSection + stacktraceSection + versionSection, StandardCharsets.UTF_8) - .replace("%2B", "+") - .let { baseUrl + it } - } + /** + * The body of the issue. + */ + val body: String = + emptySequence>() + .plus("User-supplied comments" to additionalInfo.ifNullOrBlank { "_No comments supplied._" }.trim()) + .plus( + events + .map { it.throwableText } + .filterNot { it.isBlank() } + .mapIndexed { idx: Int, body: String -> "Stacktrace ${idx + 1}" to spoiler(code(body, "java")) } + ) + .plus(attachments.map { "Attachment: `${it.name}`" to spoiler(code(it.displayText)) }) + .plus( + "Version information" to + """ + - Randomness version: ${pluginDescriptor.version ?: "_Unknown_"} + - IDE version: ${ApplicationInfo.getInstance().apiVersion} + - Operating system: ${SystemInfo.OS_NAME} + - Java version: ${SystemInfo.JAVA_VERSION} + """.trimIndent() + ) + .joinToString(separator = "\n\n") { section(it.first, it.second) } + /** - * Creates a Markdown "section" containing the [title] in bold followed by the [contents] on the next line, - * finalized by two newlines. + * Returns the corresponding [Issue]. */ - private fun createMarkdownSection(title: String, contents: String) = - """ - **${title.trim()}** - ${contents.trim()} - """.trimIndent() + fun asGitHubIssue(): Issue = + Issue() + .also { + it.title = title + it.body = body + } /** - * Returns version information on the user's environment as a Markdown-style list. + * Returns `true` if and only if this [IssueData] is (likely) a duplicate of the existing [Issue]. */ - private fun getFormattedVersionInformation() = - """ - - Randomness version: ${pluginDescriptor?.version ?: "_Unknown_"} - - IDE version: ${ApplicationInfo.getInstance().apiVersion} - - Operating system: ${System.getProperty("os.name")} - - Java version: ${System.getProperty("java.version")} - """.trimIndent() + fun isDuplicateOf(issue: Issue): Boolean = + issue.title.takeWhile { it != ']' } == title.takeWhile { it != ']' } /** @@ -109,15 +359,27 @@ class ErrorReporter : ErrorReportSubmitter() { */ companion object { /** - * Message asking the user to provide more information about the exception. + * Returns [this] if [this] is neither `null` nor blank, and returns the output of [then] otherwise. + */ + fun String?.ifNullOrBlank(then: () -> String): String = + if (this.isNullOrBlank()) then() else this + + /** + * Creates a Markdown section with the given [title] and [body]. + */ + fun section(title: String, body: String): String = + "**${title.trim()}**\n${body.trim()}" + + /** + * Creates a Markdown spoiler tag with the given [heading] and [body]. */ - const val MORE_DETAIL_MESSAGE = - "Please describe your issue in more detail here. What were you doing when the exception occurred?" + fun spoiler(body: String, heading: String = "Click to show"): String = + "
\n ${heading.trim()}\n\n${body.prependIndent(" ")}\n\n
" /** - * Message asking the user to provide stacktrace information. + * Creates a Markdown code block containing [body] and using the given [language]. */ - const val STACKTRACE_MESSAGE = - "Please paste the full stacktrace from the IDE's error popup below.\n```java\n\n```" + fun code(body: String, language: String = ""): String = + "```$language\n$body\n```" } } diff --git a/src/main/resources/randomness.properties b/src/main/resources/randomness.properties index dfa6cfb1a..30305ee53 100644 --- a/src/main/resources/randomness.properties +++ b/src/main/resources/randomness.properties @@ -82,10 +82,19 @@ reference.ui.value.header=Value reference.ui.value.template_option=&Template: reference.ui.value.visit=Go to target reference.title=Reference -reporter.issue=Issue on GitHub -reporter.opening=Opening GitHub in browser -reporter.privacy_notice=Pressing the Report button will open a form on a web page with the details of this error filled in. Submitting the form requires a GitHub account and is subject to GitHub's privacy policy. -reporter.report=Report on GitHub +reporter.issue.title=[%1$s] %2$s +reporter.issue.title.unknown=Unspecified error +reporter.notify.description.new=An anonymous issue has been created on GitHub, where you can track the issue's progress and add additional (non-anonymous) comments. +reporter.notify.description.duplicate=An anonymous comment has been added to an existing issue on GitHub, where you can track the issue's progress and add additional (non-anonymous) comments. +reporter.notify.title.failure=Failed to report issue +reporter.notify.title.success=Successfully reported issue +reporter.notify.view_in_browser=View report in browser +reporter.privacy_notice=Pressing the Report button will submit an anonymous report to GitHub. The report will contain the error description you wrote, the stack trace of the error, version information about Randomness, your IDE, your operating system, and your Java installation, and the attachments shown above. +reporter.report=Report +reporter.report.duplicate=a comment to issue #%2$d. Thank you for your feedback! +reporter.report.error=This may happen if you are using an old version of Randomness. Otherwise, please try again later. +reporter.report.new=issue #%2$d. Thank you for your feedback! +reporter.task.title=Submitting issue report shared.action.add=Add shared.action.copy=Copy shared.action.down=Down diff --git a/src/main/resources/reporter/token.bin b/src/main/resources/reporter/token.bin new file mode 100644 index 000000000..8ce517c3a --- /dev/null +++ b/src/main/resources/reporter/token.bin @@ -0,0 +1 @@ +waqsjIyxurVgzb2WuANrfKLrZ/r99EZ6eZyUNv8wdoDKp5Tbhbm1sq5X4Ss2wOIU6PGsp9iWdUqPQNxkU1A/Fac8YXJN+MQtQ7V6tRZ7fAIpB73747z2se2XpDp2dJeB \ No newline at end of file