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)