From 4b61fd0196f4e1c02c9f8aae068d1bca83984c98 Mon Sep 17 00:00:00 2001 From: Sebastiano Poggi Date: Wed, 13 Aug 2025 17:02:18 +0200 Subject: [PATCH] [JEWEL-881] Write 0.30 release notes This adds the release notes for the 0.30 release. It also improves the release extractor plugin structure to make it more difficult to invoke it incorrectly and get partial notes. closes https://github.com/JetBrains/intellij-community/pull/3200 GitOrigin-RevId: 13004140eadbff6dcdb242cbf57afb7473383cdd --- platform/jewel/RELEASE NOTES.md | 107 +++ .../scripts/extract-release-notes.main.kts | 780 +++++++++--------- 2 files changed, 513 insertions(+), 374 deletions(-) diff --git a/platform/jewel/RELEASE NOTES.md b/platform/jewel/RELEASE NOTES.md index b450a5c4c16a..41d346dd7460 100644 --- a/platform/jewel/RELEASE NOTES.md +++ b/platform/jewel/RELEASE NOTES.md @@ -1,5 +1,112 @@ # Jewel Release Notes +## v0.30 (2025-08-28) + +| Supported IJP versions | Compose Multiplatform version | +|------------------------|-------------------------------| +| 2025.2.2+, 2025.1.5+ | 1.9.0-beta03 | + +### ⚠️ Important Changes + +* **JEWEL-892** All Jewel internal and experimental APIs are now also annotated with the corresponding `ApiStatus` annotation ([#3136](https://github.com/JetBrains/intellij-community/pull/3136)) + * This means they'll be correctly identified as such by all JetBrains tooling, including the Plugin DevKit and Marketplace. +* **JEWEL-896** Extracted Coil dependency as separate library out of the Markdown Images extension, so other plugins can use it too ([`6d9016a`](https://github.com/JetBrains/intellij-community/commit/6d9016a)) +* **JEWEL-897** Experimental API `renderImagesContent` renamed to `renderImageContent` (singular) ([#3145](https://github.com/JetBrains/intellij-community/pull/3145)) +* **JEWEL-915** Removed the experimental `JewelToolWindowNoThemeComposePanel` and `composeForToolWindowWithoutTheme` APIs — they were identical to the non-`ToolWindow` variants ([#3143](https://github.com/JetBrains/intellij-community/pull/3143)) +* **JEWEL-920** The default `Indication` has been set to a no-op implementation in both standalone and bridge, instead of the previous default implementation we inherited from Compose ([#3161](https://github.com/JetBrains/intellij-community/pull/3161)) + * We handle visual states separately from the `Indication` API, and as such this would only cause visual issues when using certain modifiers (e.g., `selectable`) +* **JEWEL-920** The experimental slot-based `ComboBox` overload has changed in a **breaking** way by reordering its parameters ([#3161](https://github.com/JetBrains/intellij-community/pull/3161)) +* **JEWEL-949** All the experimental `*.render` Markdown renderer APIs have been renamed and have lost the `onTextClick` parameter (non-breaking change) ([#3162](https://github.com/JetBrains/intellij-community/pull/3162)) + * They now all have a default implementation that delegates to the new counterparts, ignoring `onTextClick` +* **JEWEL-949** The experimental `Markdown` and `LazyMarkdown` composables have lost the `onTextClick` parameter (non-breaking change) ([#3162](https://github.com/JetBrains/intellij-community/pull/3162)) +* **JEWEL-949** The experimental API `ImageRendererExtension.renderImagesContent` was renamed to `renderImageContent` (singular "image") in a **breaking** manner ([#3162](https://github.com/JetBrains/intellij-community/pull/3162)) +* **JEWEL-949** The experimental `GitHubTableBlockRenderer` has been made private ([#3162](https://github.com/JetBrains/intellij-community/pull/3162)) +* **JEWEL-963** The 'int-ui-decorated-window' is now obsolete and will be removed in the future ([#3175](https://github.com/JetBrains/intellij-community/pull/3175)) + * Please update your dependencies to use the 'decorated-window' library directly +* **JEWEL-963** The 'decorated-window' module does not include the copies from Classes/Interfaces/Methods of JBR-Api ([#3175](https://github.com/JetBrains/intellij-community/pull/3175)) + * If you need access to any JBR-Api method, please use the official library instead - https://github.com/JetBrains/JetBrainsRuntimeApi +* **JEWEL-967** Updated CMP version to 1.9.0-beta03 ([#3188](https://github.com/JetBrains/intellij-community/pull/3188)) +* **JEWEL-972** Updated dividers so they are not accessible by screen readers ([#3182](https://github.com/JetBrains/intellij-community/pull/3182)) +* **JEWEL-980** `LocalMessageResourceResolverProvider` was not marked as experimental, but should have. This has been rectified ([#3191](https://github.com/JetBrains/intellij-community/pull/3191)) +* **JEWEL-985** `JewelTheme.instanceUuid` and `LocalThemeInstanceUuid` are now stable ([#3193](https://github.com/JetBrains/intellij-community/pull/3193)) +* **IJPL-200569** Internal classes generated by Compose Compiler Plugin are no longer considered part of the public API ([`161c8f7`](https://github.com/JetBrains/intellij-community/commit/161c8f7)) +* **IJPL-174837** Moved Jewel Showcase sample to the DevKit plugin so it's available in the IDE by default (253+) ([`62c5e21`](https://github.com/JetBrains/intellij-community/commit/161c8f7)) + +### New features + +* **JEWEL-286** Added support for `IconButton`s with a transparent background ([#3129](https://github.com/JetBrains/intellij-community/pull/3129)) + * Use a regular `IconButton` and set its style to the new `JewelTheme.transparentIconButtonStyle` +* **JEWEL-686** Added new `Default*Banner` and `Inline*Banner` components with support for automatically hiding the overflowing actions into a dropdown menu ([#3124](https://github.com/JetBrains/intellij-community/pull/3124)) +* **JEWEL-875** Added new slot-based API variants to the default banner that accept a Composable as content, getting feature parity with inline banners ([#3132](https://github.com/JetBrains/intellij-community/pull/3132)) +* **JEWEL-873** Added experimental support for Popups using a native window to standalone, too ([#3153](https://github.com/JetBrains/intellij-community/pull/3153)) + * Like for the bridge counterpart shipped in 0.29.0, this **experimental** feature is enabled via `JewelFlags` + * This feature allows your popups to draw outside your Compose Panel/Window + * The implementation is based on `JDialog` and requires one of the following conditions to work: + * Use the JetBrains Runtime; + * Enabled the `compose.interop.blending` system property; + * Set the LaF flag `Panel.background` to a transparent value; + * If none of the requirements are met, it falls back to the Compose implementation to avoid UI glitches + * Please report any bugs and issues you find in both the standalone and bridge implementations! +* **JEWEL-877** Added a `Brush.cssLinearGradient()` API that allows you to create CSS-like linear gradients ([#3121](https://github.com/JetBrains/intellij-community/pull/3121)) + * More info in [this article](https://blog.sebastiano.dev/say-hi-like-youre-ai-gradient-text-in-compose-for-desktop/) +* **JEWEL-897** You can now include local images in your Markdown content. Simply add your image files (like PNGs, JPGs, or SVGs) to your `src/main/resources` folder and reference them directly. For example, to display `my-logo.png` located in `src/main/resources/images/`, you would write: `![My Logo](images/my-logo.png)` ([#3145](https://github.com/JetBrains/intellij-community/pull/3145)) +* **JEWEL-911** Added `warning` and `disabledSelected` colors to `TextColors` ([#3144](https://github.com/JetBrains/intellij-community/pull/3144)) +* **JEWEL-913** Added a factory function for `InlineMarkdownRenderer` to help you create an inline Markdown renderer ([#3156](https://github.com/JetBrains/intellij-community/pull/3156)) +* **JEWEL-920** Added a simpler Boolean-based variant to `Modifier.outline()` ([#3161](https://github.com/JetBrains/intellij-community/pull/3161)) +* **JEWEL-943** Added new `InfoText` component to easily show info-styled text ([#3172](https://github.com/JetBrains/intellij-community/pull/3172)) +* **JEWEL-948** Added a new overload for scrollable containers that takes a more general `ScrollableState` parameter, that can be used with all lazy containers, as well as non-lazy containers that want to own their scroll modifier ([#3166](https://github.com/JetBrains/intellij-community/pull/3166)) +* **JEWEL-940** The current Jewel API version is available at runtime through the `JewelBuild.apiVersionString` property ([#3179](https://github.com/JetBrains/intellij-community/pull/3179)) +* **JEWEL-942** Created a new MarkdownText component to allow the use of markdown to easily format text ([#3185](https://github.com/JetBrains/intellij-community/pull/3185)) + * This method is similar to the "Text" component, but adds the parsing feature + * Note that providing a markdown text that renders another component (such as a heading) may cause crashes +* **JEWEL-947** Added a new `Image` composable that uses Jewel's `IconKey`-based icon loading pipeline to safely load non-icon images in both standalone and bridge modes ([#3180](https://github.com/JetBrains/intellij-community/pull/3180)) +* **JEWEL-950** Added a new overload for `ScrollState`-based scrollable containers that allows users to disable scrolling entirely ([#3168](https://github.com/JetBrains/intellij-community/pull/3168)) + +### Bug fixes + +* **JEWEL-842** Fixed the Markdown editor font ligatures settings to match user's IDE settings with all fonts ([#3163](https://github.com/JetBrains/intellij-community/pull/3163)) + * We were only enabling/disabling the `liga` feature, now we also toggle `calt`. + * Swing/the JBR uses the same two OpenType features, even though there are others too. +* **JEWEL-854** Scrollbars are now hidden from the accessibility context, preventing focus by screen readers ([#3154](https://github.com/JetBrains/intellij-community/pull/3154)) +* **JEWEL-879** Fixed `Link` not updating its state correctly (hovering, clicking, focusing, etc.) when it is disabled and re-enabled ([#3128](https://github.com/JetBrains/intellij-community/pull/3128)) +* **JEWEL-901** Fixed an issue in the experimental native `Popup` implementation where you needed two clicks to open the popups a second time ([#3131](https://github.com/JetBrains/intellij-community/pull/3131)) + * The popup was properly getting destroyed on dismissal, but the underlying node wasn't, causing this issue. +* **JEWEL-911** Fixed `TextColors.info` not being properly set in Darcula ([#3144](https://github.com/JetBrains/intellij-community/pull/3144)) + * This PR made reading global colours more resilient and accurate in general +* **JEWEL-916** Fixed a crash in the IntelliJ UI Inspector caused by unexpected null values in `AccessibleContext` when inspecting Jewel UI ([#3142](https://github.com/JetBrains/intellij-community/pull/3142)) +* **JEWEL-917** Fixed a crash with malformed IDE themes that do not declare the `Button.arc` LaF key ([#3147](https://github.com/JetBrains/intellij-community/pull/3147)) +* **JEWEL-918** Fixed the checkbox and radio button appearance in the IDE when using the Darcula theme ([#3148](https://github.com/JetBrains/intellij-community/pull/3148)) +* **JEWEL-920** Fixed a bug in `BasicLazyTree` where the item background state was not properly remembered ([#3161](https://github.com/JetBrains/intellij-community/pull/3161)) +* **JEWEL-920** Fixed a bug in `ListComboBox` where changing the `itemKeys` parameter value would not be picked up by the component until it exited and re-entered the composition ([#3161](https://github.com/JetBrains/intellij-community/pull/3161)) +* **JEWEL-920** Fixed a bug in `CircularProgressIndicator` where changing the `frameRetriever` parameter value would not be picked up by the component until it exited and re-entered the composition ([#3161](https://github.com/JetBrains/intellij-community/pull/3161)) +* **JEWEL-920** Fixed a bug where the `PopupMenu` was over-remembering some internal state ([#3161](https://github.com/JetBrains/intellij-community/pull/3161)) +* **JEWEL-936** Fixed scrollable containers with `AlwaysVisible` reserving space for the scrollbar even when the content is smaller than the viewport and the scrollbar is not visible ([#3158](https://github.com/JetBrains/intellij-community/pull/3158)) +* **JEWEL-936** Fixed a small bug in height calculation for horizontal scrollable containers in edge conditions ([#3158](https://github.com/JetBrains/intellij-community/pull/3158)) +* **JEWEL-936** Fixed scrollbar appearance and behaviour in standalone mode on Windows and Linux ([#3158](https://github.com/JetBrains/intellij-community/pull/3158)) +* **JEWEL-946** Fixed `SimpleListItem` colours in standalone and bridge ([#3160](https://github.com/JetBrains/intellij-community/pull/3160)) +* **JEWEL-946** Fixed incorrect application of the "active" state in `ListComboBox` items ([#3160](https://github.com/JetBrains/intellij-community/pull/3160)) +* **JEWEL-949** Fixed a bug that made it impossible to select text in a Markdown paragraph that contains one or more links ([#3162](https://github.com/JetBrains/intellij-community/pull/3162)) +* **JEWEL-949** Fixed a bug where disabled fenced code blocks in Markdown would look "lighter" than indented code blocks ([#3162](https://github.com/JetBrains/intellij-community/pull/3162)) +* **JEWEL-967** Fixed the Images Markdown extension artifact to correctly declare it depends on Coil3 ([#3188](https://github.com/JetBrains/intellij-community/pull/3188)) +* **JEWEL-968** Fixed the Markdown console font ligatures settings to match user's IDE settings with all fonts ([#3178](https://github.com/JetBrains/intellij-community/pull/3178)) +* **JEWEL-976** Fixed a `ArrayIndexOutOfBoundsException` in `ListComboBox` when passing an out-of-bounds selected index ([#3184](https://github.com/JetBrains/intellij-community/pull/3184)) +* **JEWEL-989** Fixed `plugin.xml` dependencies for the Markdown styling modules — both standalone and bridge ([#3197](https://github.com/JetBrains/intellij-community/pull/3197)) +* **JEWEL-985** Fixed a number of APIs that were missing the experimental annotations, or were improperly annotated as such: ([#3193](https://github.com/JetBrains/intellij-community/pull/3193)) + * `LocalCodeHighlighter` and `NoOpCodeHighlighter` + * `LocalPopupRenderer` +* **JEWEL-986** Added missing CMP Resources transitive dependency to the `ui` module's POM ([#3195](https://github.com/JetBrains/intellij-community/pull/3195)) + * Removed unnecessary dependency on the autolink, strikethrough, and images extensions from the bridge styling module + * Added missing dependency on the tables extension to the standalone styling module + +### Deprecated API + +* **JEWEL-686** Deprecated `Banner` APIs that had a composable slot for the actions. Migrate to the versions with `linkActions` and `iconActions` parameters ([#3124](https://github.com/JetBrains/intellij-community/pull/3124)) +* **JEWEL-920** Deprecated several APIs in `Menu.kt` that were left public by mistake so they can be made private as they should, in the future ([#3161](https://github.com/JetBrains/intellij-community/pull/3161)) +* **JEWEL-948** Deprecated `LazyListState`- and `LazyGridState`-based APIs for scrollable containers as they can be trivially migrated to the new `ScrollableState`-based APIs ([#3166](https://github.com/JetBrains/intellij-community/pull/3166)) +* **JEWEL-949** Deprecated all `MarkdownBlockRenderer.render` APIs in favour of the new APIs with better naming, and no `onTextClick` parameter ([#3162](https://github.com/JetBrains/intellij-community/pull/3162)) +* **JEWEL-949** Deprecated `Markdown` and `LazyMarkdown` overloads with the `onTextClick` parameter ([#3162](https://github.com/JetBrains/intellij-community/pull/3162)) +* **JEWEL-985** Deprecated `LocalMenuManager` as `MenuManager` is also deprecated ([#3193](https://github.com/JetBrains/intellij-community/pull/3193)) + ## v0.29 (2025-07-22) | Supported IJP versions | Compose Multiplatform version | diff --git a/platform/jewel/scripts/extract-release-notes.main.kts b/platform/jewel/scripts/extract-release-notes.main.kts index b03e51d7e958..bed425482341 100755 --- a/platform/jewel/scripts/extract-release-notes.main.kts +++ b/platform/jewel/scripts/extract-release-notes.main.kts @@ -1,11 +1,16 @@ #!/usr/bin/env kotlin +@file:Suppress("RAW_RUN_BLOCKING") @file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") @file:DependsOn("org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0") +@file:DependsOn("com.github.ajalt.clikt:clikt-jvm:5.0.3") @file:Import("utils.main.kts") -@file:Suppress("RAW_RUN_BLOCKING") -import java.io.File -import java.util.concurrent.ConcurrentHashMap +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.main +import com.github.ajalt.clikt.parameters.options.defaultLazy +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option import kotlin.system.exitProcess import kotlin.time.TimeSource.Monotonic.markNow import kotlinx.coroutines.Dispatchers @@ -19,6 +24,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.util.concurrent.ConcurrentHashMap // --- Configuration --- private object Config { @@ -29,415 +36,440 @@ private object Config { const val RELEASE_NOTES_FILE = "RELEASE NOTES.md" } -private val workingDir = File("").absoluteFile - -// --- Data Structures --- -private data class ReleaseNoteItem(val issueId: String?, val description: String, val prId: String, val prUrl: String) - -private enum class PrProcessingStatus { - Extracted, - BlankReleaseNotes, - NoReleaseNotes, - Error, -} - -private data class CommitInfo(val commitHash: String, val prId: String, val issueId: String?) - -private data class PrProcessingResult( - val prId: String, - val prTitle: String, - val status: PrProcessingStatus, - val notes: Map> = emptyMap(), - val logMessages: List = emptyList(), -) - -// --- Helper Functions --- -private fun getIndentation(line: String): Int = line.takeWhile { it.isWhitespace() }.length - -private fun formatReleaseNotesLine(note: ReleaseNoteItem): String { - val lines = note.description.lines() - val firstLine = lines.first() - val otherLines = lines.drop(1) - - return buildString { - append(" *") - if (note.issueId != null) { - append(" **") - append(note.issueId) - append("**") - } - append(" ") - append(firstLine.cleanupEntry(note.issueId)) - append(" ([#") - append(note.prId) - append("](") - append(note.prUrl) - append("))") - - if (otherLines.isNotEmpty()) { - val otherLinesText = otherLines.joinToString("\n") - if (otherLinesText.isNotBlank()) { - append("\n") - append(otherLinesText) - } - } - } -} - -private fun String.cleanupEntry(issueIdText: String?): String { - // 1. Remove trailing dot - val step1 = removeSuffix(".") - // 2. Remove issue ID if present - val step2 = - if (issueIdText != null) { - step1.removePrefix("$issueIdText ").removePrefix("**$issueIdText** ") - } else { - step1 - } - // 3. Trim - return step2.trim() -} - -private suspend fun processPr(commitInfo: CommitInfo, isVerbose: Boolean): PrProcessingResult { - val (_, prNumber, issueId) = commitInfo - val logs = mutableListOf() - - try { - val prInfo = - runCommand("gh pr view $prNumber --repo ${Config.UPSTREAM_REPO} --json url,body,title", workingDir) - .output - .let { Json.parseToJsonElement(it).jsonObject } - - val prUrl = prInfo["url"]?.jsonPrimitive?.content!! - val prBody = prInfo["body"]?.jsonPrimitive?.content!! - val prTitle = prInfo["title"]?.jsonPrimitive?.content!! - if (isVerbose) logs.add("ℹ️ PR body fetched:\n${prBody.prependIndent(" ")}\n") - - val lines = prBody.lines() - val headerIndex = - lines.indexOfFirst { it.trim().matches("""##+\s+release notes""".toRegex(RegexOption.IGNORE_CASE)) } - - if (headerIndex == -1) { - logs.add("⚠️ No 'Release Notes' section found.".asWarning()) - return PrProcessingResult(prNumber, prTitle, PrProcessingStatus.NoReleaseNotes, logMessages = logs) - } - - val subsequentLines = lines.drop(headerIndex + 1) - val nextHeaderIndex = subsequentLines.indexOfFirst { it.trim().matches("""^#{1,2}\s+.*""".toRegex()) } - val releaseNotesText = - (if (nextHeaderIndex != -1) subsequentLines.take(nextHeaderIndex) else subsequentLines) - .joinToString("\n") - .trim() - - if (releaseNotesText.isBlank()) { - logs.add("⚠️ 'Release Notes' section found but it was empty.".asWarning()) - return PrProcessingResult(prNumber, prTitle, PrProcessingStatus.BlankReleaseNotes, logMessages = logs) - } - if (isVerbose) logs.add("ℹ️ Extracted release notes text:\n$releaseNotesText\n") - - val notesInPr = mutableMapOf>() - var currentSection = "Other" - val releaseLines = releaseNotesText.lines() - - var i = 0 - while (i < releaseLines.size) { - val (nextIndex, nextSection) = - processLine(i, releaseLines, currentSection, issueId, prNumber, prUrl, notesInPr) - i = nextIndex - currentSection = nextSection - } - - logs.add("✅ Parsed notes successfully.") - return PrProcessingResult(prNumber, prTitle, PrProcessingStatus.Extracted, notesInPr, logs) - } catch (e: Exception) { - logs.add("❌ Error processing PR: ${e.message?.lines()?.firstOrNull()}".asError()) - return PrProcessingResult(prNumber, "[ERROR]", PrProcessingStatus.Error, logMessages = logs) - } -} - -private fun processLine( - index: Int, - releaseLines: List, - currentSectionIn: String, - issueId: String?, - prNumber: String, - prUrl: String, - notesInPr: MutableMap>, -): Pair { - var currentSection = currentSectionIn - val line = releaseLines[index] - - val headerMatch = """^#+\s+(.*)""".toRegex().find(line.trim()) - if (headerMatch != null) { - currentSection = headerMatch.groupValues[1].trim() - return index + 1 to currentSection - } - - if (line.isBlank()) { - return index + 1 to currentSection - } - - val trimmedLine = line.trim() - val isListItem = trimmedLine.startsWith("*") || trimmedLine.startsWith("-") - - if (isListItem) { - val baseIndentation = getIndentation(line) - val mainText = trimmedLine.removePrefix("*").removePrefix("-").trim() - val noteLines = mutableListOf(mainText) - - var j = index + 1 - while (j < releaseLines.size) { - val nextLine = releaseLines[j] - if (nextLine.isNotBlank()) { - if ("""^#+\s+(.*)""".toRegex().find(nextLine.trim()) != null) break // Stop at next header - if (getIndentation(nextLine) <= baseIndentation) break // Stop at new top-level item +class ExtractReleaseNotesCommand : CliktCommand() { + private val startDate: String by + option( + "--start-date", + "-s", + "--since", + help = + "The start date for the commit range (yyyy-mm-dd). " + + "If omitted, it will be inferred from the latest release in ${Config.RELEASE_NOTES_FILE}.", + ) + .defaultLazy { + val latestReleaseDate = getLatestReleaseDate() + if (latestReleaseDate.isNullOrBlank()) { + printlnErr( + "Error: --start-date is required if ${Config.RELEASE_NOTES_FILE} does not exist or contain a release date." + ) + exitProcess(1) + } + latestReleaseDate } - noteLines.add(nextLine) - j++ - } - - val fullDescription = noteLines.joinToString("\n") - val noteItem = ReleaseNoteItem(issueId, fullDescription, prNumber, prUrl) - notesInPr.getOrPut(currentSection) { mutableListOf() }.add(noteItem) - return j to currentSection - } else { - // This line is not a list item, so we skip it. - return index + 1 to currentSection - } -} - -// --- Main Entry Point --- -if (workingDir.name != "jewel" || workingDir.parentFile.name != "platform") { - printlnErr("This script must be run from the 'jewel' directory.") - exitProcess(1) -} - -private fun getLatestReleaseDate(): String? { - val releaseNotesFile = File(Config.RELEASE_NOTES_FILE) - if (!releaseNotesFile.exists()) { - printlnWarn( - "⚠️ Release notes file not found at '${releaseNotesFile.absolutePath}', can't determine start date." + private val endDate: String? by + option( + "--end-date", + "-e", + "--until", + help = "The end date for the commit range (yyyy-mm-dd). If omitted, it will default to today.", ) - return null - } - val releaseHeaderRegex = """## v\d+\.\d+ \((....-..-..)\)""".toRegex() - releaseNotesFile.useLines { lines -> - for (line in lines) { - val match = releaseHeaderRegex.find(line) - if (match != null) { - return match.groupValues[1] + private val isVerbose: Boolean by option("--verbose", "-v", help = "Enables verbose logging.").flag(default = false) + + override fun help(context: Context): String = + "Extracts release notes from PRs merged within a specified date range." + + override fun run() { + // --- Main Entry Point --- + if (workingDir.name != "jewel" || workingDir.parentFile.name != "platform") { + printlnErr("This script must be run from the 'jewel' directory.") + exitProcess(1) + } + + // --- Phase 1: Sequentially parse local git history --- + val normalizedJewelPath: String = File(Config.JEWEL_DIR).normalize().canonicalPath + val logMessage = buildString { + append("🔍 Enumerating commits in '$normalizedJewelPath' since $startDate") + if (endDate != null) { + append(" until $endDate") } } - } - printlnWarn("⚠️ Could not find any release date in ${Config.RELEASE_NOTES_FILE}.") - return null -} -private fun printUsageAndExit() { - println("Usage: ./extract-release-notes.main.kts --start-date [--end-date ] [--verbose]") - println("If --start-date is omitted, it will be inferred from the latest release in ${Config.RELEASE_NOTES_FILE}.") - println("Example: ./extract-release-notes.main.kts --start-date 2025-05-01 --end-date 2025-05-31") - exitProcess(1) -} + print("$logMessage...") -private val startDate: String = - getArg("start-date", "s") - ?: getLatestReleaseDate() - ?: run { - printUsageAndExit() - "" // Should be unreachable + val mark = markNow() + val gitLogCommand = buildString { + append("git log --since=") + append(startDate) + append(" --pretty=format:%H") + if (endDate != null) { + append(" --until=$endDate") + } + append(" -- ") + append(Config.JEWEL_DIR) } -private val endDate = getArg("end-date", "e") -// --- Phase 1: Sequentially parse local git history --- -private val normalizedJewelPath: String = File(Config.JEWEL_DIR).normalize().canonicalPath -private val logMessage = buildString { - append("🔍 Enumerating commits in '$normalizedJewelPath' since $startDate") - if (endDate != null) { - append(" until $endDate") - } -} - -print("$logMessage...") - -private val mark = markNow() -private val gitLogCommand = buildString { - append("git log --since=") - append(startDate) - append(" --pretty=format:%H") - if (endDate != null) { - append(" --until=$endDate") - } - append(" -- ") - append(Config.JEWEL_DIR) -} - -private val allCommitHashes = runBlocking { - runCommand(gitLogCommand, workingDir).output.lines().filter { it.isNotBlank() } -} - -private val elapsed = mark.elapsedNow() - -println(" DONE") - -println(" ℹ️ Found ${allCommitHashes.size} commits in $elapsed") - -print("🔍 Filtering relevant commits...") - -private val prCommits = mutableListOf() -private val jewelCommitsWithoutPr = mutableListOf>() -private val issueIdRegex = """\[(JEWEL-\d+.*)+]""".toRegex() -private val prRegex = """closes https://github.com/JetBrains/intellij-community/pull/(\d+)""".toRegex() - -for (commitHash in allCommitHashes) { - val commitBody = runBlocking { runCommand("git show -s --format=%B $commitHash", workingDir).output } - - val prNumber = prRegex.find(commitBody)?.groups?.get(1)?.value - if (prNumber != null) { - if (isVerbose) { - println(" Commit $commitHash -> PR #$prNumber") + val allCommitHashes = runBlocking { + runCommand(gitLogCommand, workingDir).output.lines().filter { it.isNotBlank() } } - val issueId = issueIdRegex.find(commitBody)?.groups?.get(1)?.value - prCommits.add(CommitInfo(commitHash, prNumber, issueId)) - } else { - if (commitBody.contains("JEWEL", ignoreCase = true)) { - jewelCommitsWithoutPr.add(commitHash to commitBody.lineSequence().first()) - } - if (isVerbose) { - println(" Commit $commitHash -> NO PR") - } - } -} -private val uniquePrCommits = prCommits.distinctBy { it.prId }.sortedBy { it.issueId } + val elapsed = mark.elapsedNow() -println(" DONE") + println(" DONE") -println( - " ℹ️ Found ${uniquePrCommits.size} unique PRs to process. " + - "(${allCommitHashes.size - uniquePrCommits.size} commits were skipped or were duplicates)" -) + println(" ℹ️ Found ${allCommitHashes.size} commits in $elapsed") -if (isVerbose) { - for (commitInfo in uniquePrCommits) { - val issueId = commitInfo.issueId ?: "unknown" - println(" Commit ${commitInfo.commitHash} -> PR #${commitInfo.prId}, issue $issueId") - } -} + print("🔍 Filtering relevant commits...") -// --- Phase 2: Process all PRs in parallel --- -println("🔎 Processing ${uniquePrCommits.size} PRs with up to ${Config.MAX_CONCURRENT_JOBS} parallel jobs...") + val prCommits = mutableListOf() + val jewelCommitsWithoutPr = mutableListOf>() + val issueIdRegex = """\[(JEWEL-\d+.*)+]""".toRegex() + val prRegex = """closes https://github.com/JetBrains/intellij-community/pull/(\d+)""".toRegex() -@OptIn(ExperimentalCoroutinesApi::class) -private val results = runBlocking { - val dispatcher = Dispatchers.IO.limitedParallelism(Config.MAX_CONCURRENT_JOBS) - val inProgressPrs = ConcurrentHashMap.newKeySet() + for (commitHash in allCommitHashes) { + val commitBody = runBlocking { runCommand("git show -s --format=%B $commitHash", workingDir).output } - // Launch a separate logger coroutine to print progress - val loggerJob = launch { - while (isActive) { - val currentPrs = inProgressPrs.sorted().joinToString(", ") { "#$it" } - val terminalWidth = getTerminalWidth() - val maxLen = terminalWidth - 20 // Hardcoded to include the "chrome" - print("\u001B[2K ⏳ Processing: [${currentPrs.take(maxLen).padEnd(maxLen)}]\r") - delay(100) - } - } - - val jobs = - uniquePrCommits.map { commitInfo -> - async(dispatcher) { - inProgressPrs.add(commitInfo.prId) - try { - processPr(commitInfo, isVerbose) - } finally { - inProgressPrs.remove(commitInfo.prId) + val prNumber = prRegex.find(commitBody)?.groups?.get(1)?.value + if (prNumber != null) { + if (isVerbose) { + println(" Commit $commitHash -> PR #$prNumber") + } + val issueId = issueIdRegex.find(commitBody)?.groups?.get(1)?.value + prCommits.add(CommitInfo(commitHash, prNumber, issueId)) + } else { + if (commitBody.contains("JEWEL", ignoreCase = true)) { + jewelCommitsWithoutPr.add(commitHash to commitBody.lineSequence().first()) + } + if (isVerbose) { + println(" Commit $commitHash -> NO PR") } } } - val completedResults = jobs.awaitAll() - loggerJob.cancel() - print("\r\u001B[2K") // Clear the progress line completely - println(" ✅ All PRs have been processed.") - completedResults -} + val uniquePrCommits = prCommits.distinctBy { it.prId }.sortedBy { it.issueId } -// 3. Aggregate final results -private val allReleaseNotes = mutableMapOf>() -private val processedPrs = mutableMapOf() + println(" DONE") -results.forEach { result -> - processedPrs[result.prId] = result - result.notes.forEach { (section, items) -> allReleaseNotes.getOrPut(section) { mutableListOf() }.addAll(items) } -} + println( + " ℹ️ Found ${uniquePrCommits.size} unique PRs to process. " + + "(${allCommitHashes.size - uniquePrCommits.size} commits were skipped or were duplicates)" + ) -// --- Print collated logs --- -if (isVerbose) { - println("\n--- PROCESSING LOGS ---") - - results - .sortedBy { it.prId.toInt() } - .forEach { result -> - println("\n[PR #${result.prId}]") - result.logMessages.forEach { msg -> println(" $msg") } + if (isVerbose) { + for (commitInfo in uniquePrCommits) { + val issueId = commitInfo.issueId ?: "unknown" + println(" Commit ${commitInfo.commitHash} -> PR #${commitInfo.prId}, issue $issueId") + } } - println() -} + // --- Phase 2: Process all PRs in parallel --- + println("🔎 Processing ${uniquePrCommits.size} PRs with up to ${Config.MAX_CONCURRENT_JOBS} parallel jobs...") -// 4. Write grouped release notes to the output file -println("✍️ Writing release notes to ${Config.OUTPUT_FILE}...") + @OptIn(ExperimentalCoroutinesApi::class) + val results = runBlocking { + val dispatcher = Dispatchers.IO.limitedParallelism(Config.MAX_CONCURRENT_JOBS) + val inProgressPrs = ConcurrentHashMap.newKeySet() -private val outputFile = File(Config.OUTPUT_FILE) + // Launch a separate logger coroutine to print progress + val loggerJob = launch { + while (isActive) { + val currentPrs = inProgressPrs.sorted().joinToString(", ") { "#$it" } + val terminalWidth = getTerminalWidth() + val maxLen = terminalWidth - 20 // Hardcoded to include the "chrome" + print("\u001B[2K ⏳ Processing: [${currentPrs.take(maxLen).padEnd(maxLen)}]\r") + delay(100) + } + } -outputFile.writeText("") + val jobs = + uniquePrCommits.map { commitInfo -> + async(dispatcher) { + inProgressPrs.add(commitInfo.prId) + try { + processPr(commitInfo, isVerbose) + } finally { + inProgressPrs.remove(commitInfo.prId) + } + } + } -private val sectionOrder = listOf("⚠️ Important Changes", "New features", "Bug fixes", "Deprecated API", "Other") -private val sortedSections = - allReleaseNotes.keys.sortedWith( - compareBy { sectionKey -> sectionOrder.indexOf(sectionKey).let { if (it == -1) Int.MAX_VALUE else it } } + val completedResults = jobs.awaitAll() + loggerJob.cancel() + print("\r\u001B[2K") // Clear the progress line completely + println(" ✅ All PRs have been processed.") + completedResults + } + + // 3. Aggregate final results + val allReleaseNotes = mutableMapOf>() + val processedPrs = mutableMapOf() + + results.forEach { result -> + processedPrs[result.prId] = result + result.notes.forEach { (section, items) -> + allReleaseNotes.getOrPut(section) { mutableListOf() }.addAll(items) + } + } + + // --- Print collated logs --- + if (isVerbose) { + println("\n--- PROCESSING LOGS ---") + + results + .sortedBy { it.prId.toInt() } + .forEach { result -> + println("\n[PR #${result.prId}]") + result.logMessages.forEach { msg -> println(" $msg") } + } + + println() + } + + // 4. Write grouped release notes to the output file + println("✍️ Writing release notes to ${Config.OUTPUT_FILE}...") + + val outputFile = File(Config.OUTPUT_FILE) + + outputFile.writeText("") + + val sectionOrder = listOf("⚠️ Important Changes", "New features", "Bug fixes", "Deprecated API", "Other") + val sortedSections = + allReleaseNotes.keys.sortedWith( + compareBy { sectionKey -> sectionOrder.indexOf(sectionKey).let { if (it == -1) Int.MAX_VALUE else it } } + ) + + sortedSections.forEach { sectionHeader -> + val notes = allReleaseNotes[sectionHeader]!! + outputFile.appendText("### $sectionHeader\n\n") + notes.forEach { note -> + val formattedLine = formatReleaseNotesLine(note) + outputFile.appendText("$formattedLine\n") + } + outputFile.appendText("\n") + } + + println(" ✅ Done.") + + // 5. Final Summary Table + println("\n--- SUMMARY ---") + + val summaryData: Map> = + processedPrs.entries.groupBy({ it.value.status }, { it.value }) + + PrProcessingStatus.entries.forEach { status -> + val processingResults = summaryData[status] ?: return@forEach + val prs = processingResults.sortedBy { it.prId } + println("\n[${status.name}] - ${prs.size} PRs") + for (pr in prs) { + print(" * ") + val id = pr.prId + println("#$id — ${pr.prTitle}".asLink("https://github.com/JetBrains/intellij-community/pull/$id")) + } + } + + println("\n\n✅ All tasks complete.") + + if (jewelCommitsWithoutPr.isNotEmpty()) { + println() + printlnWarn("⚠️ Found ${jewelCommitsWithoutPr.size} commits with 'JEWEL' in the message but no PR number:") + + for ((commitHash, headerLine) in jewelCommitsWithoutPr) { + println(" * ${commitHash.take(7)} ${headerLine}") + } + println() + } + } + + private val workingDir = File("").absoluteFile + + // --- Data Structures --- + private data class ReleaseNoteItem(val issueId: String?, val description: String, val prId: String, val prUrl: String) + + private enum class PrProcessingStatus { + Extracted, + BlankReleaseNotes, + NoReleaseNotes, + Error, + } + + private data class CommitInfo(val commitHash: String, val prId: String, val issueId: String?) + + private data class PrProcessingResult( + val prId: String, + val prTitle: String, + val status: PrProcessingStatus, + val notes: Map> = emptyMap(), + val logMessages: List = emptyList(), ) -sortedSections.forEach { sectionHeader -> - val notes = allReleaseNotes[sectionHeader]!! - outputFile.appendText("### $sectionHeader\n\n") - notes.forEach { note -> - val formattedLine = formatReleaseNotesLine(note) - outputFile.appendText("$formattedLine\n") + // --- Helper Functions --- + private fun getIndentation(line: String): Int = line.takeWhile { it.isWhitespace() }.length + + private fun formatReleaseNotesLine(note: ReleaseNoteItem): String { + val lines = note.description.lines() + val firstLine = lines.first() + val otherLines = lines.drop(1) + + return buildString { + append(" *") + if (note.issueId != null) { + append(" **") + append(note.issueId) + append("**") + } + append(" ") + append(firstLine.cleanupEntry(note.issueId)) + append(" ([#") + append(note.prId) + append("](") + append(note.prUrl) + append("))") + + if (otherLines.isNotEmpty()) { + val otherLinesText = otherLines.joinToString("\n") + if (otherLinesText.isNotBlank()) { + append("\n") + append(otherLinesText) + } + } + } } - outputFile.appendText("\n") -} -println(" ✅ Done.") + private fun String.cleanupEntry(issueIdText: String?): String { + // 1. Remove trailing dot + val step1 = removeSuffix(".") + // 2. Remove issue ID if present + val step2 = + if (issueIdText != null) { + step1.removePrefix("$issueIdText ").removePrefix("**$issueIdText** ") + } else { + step1 + } + // 3. Trim + return step2.trim() + } -// 5. Final Summary Table -println("\n--- SUMMARY ---") + private suspend fun processPr(commitInfo: CommitInfo, isVerbose: Boolean): PrProcessingResult { + val (_, prNumber, issueId) = commitInfo + val logs = mutableListOf() -private val summaryData: Map> = - processedPrs.entries.groupBy({ it.value.status }, { it.value }) + try { + val prInfo = + runCommand("gh pr view $prNumber --repo ${Config.UPSTREAM_REPO} --json url,body,title", workingDir) + .output + .let { Json.parseToJsonElement(it).jsonObject } -PrProcessingStatus.entries.forEach { status -> - val processingResults = summaryData[status] ?: return@forEach - val prs = processingResults.sortedBy { it.prId } - println("\n[${status.name}] - ${prs.size} PRs") - for (pr in prs) { - print(" * ") - val id = pr.prId - println("#$id — ${pr.prTitle}".asLink("https://github.com/JetBrains/intellij-community/pull/$id")) + val prUrl = prInfo["url"]?.jsonPrimitive?.content!! + val prBody = prInfo["body"]?.jsonPrimitive?.content!! + val prTitle = prInfo["title"]?.jsonPrimitive?.content!! + if (isVerbose) logs.add("ℹ️ PR body fetched:\n${prBody.prependIndent(" ")}\n") + + val lines = prBody.lines() + val headerIndex = + lines.indexOfFirst { it.trim().matches("""##+\s+release notes""".toRegex(RegexOption.IGNORE_CASE)) } + + if (headerIndex == -1) { + logs.add("⚠️ No 'Release Notes' section found.".asWarning()) + return PrProcessingResult(prNumber, prTitle, PrProcessingStatus.NoReleaseNotes, logMessages = logs) + } + + val subsequentLines = lines.drop(headerIndex + 1) + val nextHeaderIndex = subsequentLines.indexOfFirst { it.trim().matches("""^#{1,2}\s+.*""".toRegex()) } + val releaseNotesText = + (if (nextHeaderIndex != -1) subsequentLines.take(nextHeaderIndex) else subsequentLines) + .joinToString("\n") + .trim() + + if (releaseNotesText.isBlank()) { + logs.add("⚠️ 'Release Notes' section found but it was empty.".asWarning()) + return PrProcessingResult(prNumber, prTitle, PrProcessingStatus.BlankReleaseNotes, logMessages = logs) + } + if (isVerbose) logs.add("ℹ️ Extracted release notes text:\n$releaseNotesText\n") + + val notesInPr = mutableMapOf>() + var currentSection = "Other" + val releaseLines = releaseNotesText.lines() + + var i = 0 + while (i < releaseLines.size) { + val (nextIndex, nextSection) = + processLine(i, releaseLines, currentSection, issueId, prNumber, prUrl, notesInPr) + i = nextIndex + currentSection = nextSection + } + + logs.add("✅ Parsed notes successfully.") + return PrProcessingResult(prNumber, prTitle, PrProcessingStatus.Extracted, notesInPr, logs) + } catch (e: Exception) { + logs.add("❌ Error processing PR: ${e.message?.lines()?.firstOrNull()}".asError()) + return PrProcessingResult(prNumber, "[ERROR]", PrProcessingStatus.Error, logMessages = logs) + } + } + + private fun processLine( + index: Int, + releaseLines: List, + currentSectionIn: String, + issueId: String?, + prNumber: String, + prUrl: String, + notesInPr: MutableMap>, + ): Pair { + var currentSection = currentSectionIn + val line = releaseLines[index] + + val headerMatch = """^#+\s+(.*)""".toRegex().find(line.trim()) + if (headerMatch != null) { + currentSection = headerMatch.groupValues[1].trim() + return index + 1 to currentSection + } + + if (line.isBlank()) { + return index + 1 to currentSection + } + + val trimmedLine = line.trim() + val isListItem = trimmedLine.startsWith("*") || trimmedLine.startsWith("-") + + if (isListItem) { + val baseIndentation = getIndentation(line) + val mainText = trimmedLine.removePrefix("*").removePrefix("-").trim() + val noteLines = mutableListOf(mainText) + + var j = index + 1 + while (j < releaseLines.size) { + val nextLine = releaseLines[j] + if (nextLine.isNotBlank()) { + if ("""^#+\s+(.*)""".toRegex().find(nextLine.trim()) != null) break // Stop at next header + if (getIndentation(nextLine) <= baseIndentation) break // Stop at new top-level item + } + + noteLines.add(nextLine) + j++ + } + + val fullDescription = noteLines.joinToString("\n") + val noteItem = ReleaseNoteItem(issueId, fullDescription, prNumber, prUrl) + notesInPr.getOrPut(currentSection) { mutableListOf() }.add(noteItem) + return j to currentSection + } else { + // This line is not a list item, so we skip it. + return index + 1 to currentSection + } + } + + private fun getLatestReleaseDate(): String? { + val releaseNotesFile = File(Config.RELEASE_NOTES_FILE) + if (!releaseNotesFile.exists()) { + printlnWarn( + "⚠️ Release notes file not found at '${releaseNotesFile.absolutePath}', can't determine start date." + ) + return null + } + + val releaseHeaderRegex = """## v\d+\.\d+ \((....-..-..)\)""".toRegex() + releaseNotesFile.useLines { lines -> + for (line in lines) { + val match = releaseHeaderRegex.find(line) + if (match != null) { + return match.groupValues[1] + } + } + } + printlnWarn("⚠️ Could not find any release date in ${Config.RELEASE_NOTES_FILE}.") + return null } } -println("\n\n✅ All tasks complete.") - -if (jewelCommitsWithoutPr.isNotEmpty()) { - println() - printlnWarn("⚠️ Found ${jewelCommitsWithoutPr.size} commits with 'JEWEL' in the message but no PR number:") - - for ((commitHash, headerLine) in jewelCommitsWithoutPr) { - println(" * ${commitHash.take(7)} ${headerLine}") - } - println() -} +ExtractReleaseNotesCommand().main(args)