mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-03-22 06:50:54 +07:00
[JEWEL] Add release notes extractor script
This script reads release notes from PR descriptions starting from a certain date and collects them in a `new_release_notes.md` file. It will try to match the monorepo merge commits with their PR, assuming the commit format includes a "closes [...]" line pointing to the PR they originate from. From there, they can be manually cleaned up and moved to `RELEASE NOTES.md` when preparing a release. The `new_release_notes.md` file is ignored as it is a transient file. closes https://github.com/JetBrains/intellij-community/pull/3098 (cherry picked from commit e4fb35d3ed15441663518ba662a6f104e8eef748) (cherry picked from commit 47948b837b2d224ff94032bd7114ea13afe5a72f) IJ-MR-168786 GitOrigin-RevId: 7f301b5dce3ad5585e9a53e711db35e37924a7ba
This commit is contained in:
committed by
intellij-monorepo-bot
parent
d925c1b66f
commit
8406dd2d5c
1
platform/jewel/.gitignore
vendored
1
platform/jewel/.gitignore
vendored
@@ -108,3 +108,4 @@ $RECYCLE.BIN/
|
||||
/buildSrc/.kotlin
|
||||
/foundation/bin/*
|
||||
/samples/showcase/bin/*
|
||||
/new_release_notes.md
|
||||
|
||||
283
platform/jewel/extract-release-notes.main.kts
Executable file
283
platform/jewel/extract-release-notes.main.kts
Executable file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env kotlin
|
||||
// Coroutine dependency for KTS scripts
|
||||
@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import kotlin.system.exitProcess
|
||||
import kotlin.time.TimeSource.Monotonic.markNow
|
||||
|
||||
// --- Configuration ---
|
||||
object Config {
|
||||
const val UPSTREAM_REPO = "JetBrains/intellij-community"
|
||||
const val JEWEL_DIR = "."
|
||||
const val OUTPUT_FILE = "new_release_notes.md"
|
||||
const val MAX_CONCURRENT_JOBS = 5
|
||||
}
|
||||
|
||||
// --- Data Structures ---
|
||||
data class ReleaseNoteItem(val issueId: String?, val description: String, val prId: String, val prUrl: String)
|
||||
|
||||
enum class ProcessedPrStatus {
|
||||
Extracted,
|
||||
BlankReleaseNotes,
|
||||
NoReleaseNotes,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class CommitInfo(val commitHash: String, val prId: String, val issueId: String?)
|
||||
|
||||
data class CommitResult(
|
||||
val prId: String,
|
||||
val status: ProcessedPrStatus,
|
||||
val notes: Map<String, List<ReleaseNoteItem>> = emptyMap(),
|
||||
val logMessages: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
// --- Helper Functions ---
|
||||
fun runCommand(vararg command: String, workDir: File = File(".")): String {
|
||||
val process =
|
||||
ProcessBuilder(*command)
|
||||
.directory(workDir)
|
||||
.redirectOutput(ProcessBuilder.Redirect.PIPE)
|
||||
.redirectError(ProcessBuilder.Redirect.PIPE)
|
||||
.start()
|
||||
|
||||
if (!process.waitFor(60, TimeUnit.SECONDS)) {
|
||||
process.destroy()
|
||||
throw TimeoutException("Command timed out: ${command.joinToString(" ")}")
|
||||
}
|
||||
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
if (process.exitValue() != 0) {
|
||||
val error = process.errorStream.bufferedReader().readText()
|
||||
error("Command failed with exit code ${process.exitValue()}: ${command.joinToString(" ")}\n$error")
|
||||
}
|
||||
return output.trim()
|
||||
}
|
||||
|
||||
fun formatReleaseNotesLine(note: ReleaseNoteItem): String = buildString {
|
||||
append(" *")
|
||||
if (note.issueId != null) {
|
||||
append(" **")
|
||||
append(note.issueId)
|
||||
append("**")
|
||||
}
|
||||
append(" ")
|
||||
append(note.description)
|
||||
append(" ([#")
|
||||
append(note.prId)
|
||||
append("](")
|
||||
append(note.prUrl)
|
||||
append("))")
|
||||
}
|
||||
|
||||
// --- Core Logic (now collects logs instead of printing them) ---
|
||||
fun processPr(commitInfo: CommitInfo, isVerbose: Boolean): CommitResult {
|
||||
val (_, prNumber, issueId) = commitInfo
|
||||
val logs = mutableListOf<String>()
|
||||
|
||||
try {
|
||||
val prUrl =
|
||||
runCommand("gh", "pr", "view", prNumber, "--repo", Config.UPSTREAM_REPO, "--json", "url", "-q", ".url")
|
||||
val prBody =
|
||||
runCommand("gh", "pr", "view", prNumber, "--repo", Config.UPSTREAM_REPO, "--json", "body", "-q", ".body")
|
||||
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.")
|
||||
return CommitResult(prNumber, ProcessedPrStatus.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.")
|
||||
return CommitResult(prNumber, ProcessedPrStatus.BlankReleaseNotes, logMessages = logs)
|
||||
}
|
||||
if (isVerbose) logs.add("ℹ️ Extracted release notes text:\n$releaseNotesText\n")
|
||||
|
||||
val notesInPr = mutableMapOf<String, MutableList<ReleaseNoteItem>>()
|
||||
var currentSection = "Uncategorized"
|
||||
releaseNotesText.lines().forEach { line ->
|
||||
val headerMatch = """^#+\s+(.*)""".toRegex().find(line.trim())
|
||||
if (headerMatch != null) {
|
||||
currentSection =
|
||||
headerMatch.groupValues[1].trim().lowercase().replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase() else it.toString()
|
||||
}
|
||||
} else if (line.isNotBlank()) {
|
||||
val mainText = line.trim().removePrefix("*").removePrefix("-").trim()
|
||||
val noteItem = ReleaseNoteItem(issueId, mainText, prNumber, prUrl)
|
||||
notesInPr.getOrPut(currentSection) { mutableListOf() }.add(noteItem)
|
||||
}
|
||||
}
|
||||
logs.add("✅ Parsed notes successfully.")
|
||||
return CommitResult(prNumber, ProcessedPrStatus.Extracted, notesInPr, logs)
|
||||
} catch (e: Exception) {
|
||||
logs.add("❌ Error processing PR: ${e.message?.lines()?.firstOrNull()}")
|
||||
return CommitResult(prNumber, ProcessedPrStatus.Error, logMessages = logs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main Entry Point ---
|
||||
val isVerbose = args.contains("--verbose") || args.contains("-v")
|
||||
val startDate =
|
||||
args.lastOrNull()
|
||||
?: run {
|
||||
println("Usage: ./generate-release-notes.main.kts [--verbose|-v] <start-date>")
|
||||
println("Example: ./generate-release-notes.main.kts --verbose 2025-05-01")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
// --- Phase 1: Sequentially parse local git history ---
|
||||
val normalizedJewelPath: String = File(Config.JEWEL_DIR).normalize().absolutePath
|
||||
println("🔍 Enumerating commits in '$normalizedJewelPath' since $startDate...")
|
||||
|
||||
val mark = markNow()
|
||||
val allCommitHashes =
|
||||
runCommand("git", "log", "--since=$startDate", "--pretty=format:%H", "--", Config.JEWEL_DIR)
|
||||
.lines() // Process line by line
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
val elapsed = mark.elapsedNow()
|
||||
|
||||
println(" Found ${allCommitHashes.size} commits in $elapsed")
|
||||
|
||||
val prCommits = mutableListOf<CommitInfo>()
|
||||
val issueIdRegex = """\[(JEWEL-\d+)]""".toRegex()
|
||||
val prRegex = """closes https://github.com/JetBrains/intellij-community/pull/(\d+)""".toRegex()
|
||||
|
||||
for (commitHash in allCommitHashes) {
|
||||
val commitBody = runCommand("git", "show", "-s", "--format=%B", commitHash)
|
||||
prRegex.find(commitBody)?.groups?.get(1)?.value?.let { prNumber ->
|
||||
val issueId = issueIdRegex.find(commitBody)?.groups?.get(1)?.value
|
||||
prCommits.add(CommitInfo(commitHash, prNumber, issueId))
|
||||
}
|
||||
}
|
||||
|
||||
val uniquePrCommits = prCommits.distinctBy { it.prId }
|
||||
|
||||
println(
|
||||
" Found ${uniquePrCommits.size} unique PRs to process. " +
|
||||
"(${allCommitHashes.size - uniquePrCommits.size} commits were skipped or were duplicates)"
|
||||
)
|
||||
|
||||
if (isVerbose) {
|
||||
for (commitInfo in uniquePrCommits) {
|
||||
val issueId = commitInfo.issueId ?: "unknown"
|
||||
println(" Commit ${commitInfo.commitHash} -> PR #${commitInfo.prId}, issue $issueId")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 2: Process all PRs in parallel ---
|
||||
println("\n🔎 Processing ${uniquePrCommits.size} PRs with up to ${Config.MAX_CONCURRENT_JOBS} parallel jobs...")
|
||||
|
||||
@Suppress("RAW_RUN_BLOCKING") // This is not IJP code
|
||||
val results = runBlocking {
|
||||
val dispatcher = Dispatchers.IO.limitedParallelism(Config.MAX_CONCURRENT_JOBS)
|
||||
val inProgressPrs = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
// Launch a separate logger coroutine to print progress
|
||||
val loggerJob = launch {
|
||||
while (isActive) {
|
||||
val currentPrs = inProgressPrs.map { "#$it" }.sorted().joinToString(", ")
|
||||
print("\r Currently processing: [${currentPrs.padEnd(50)}]")
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
val jobs =
|
||||
uniquePrCommits.map { commitInfo ->
|
||||
async(dispatcher) {
|
||||
inProgressPrs.add(commitInfo.prId)
|
||||
try {
|
||||
processPr(commitInfo, isVerbose)
|
||||
} finally {
|
||||
inProgressPrs.remove(commitInfo.prId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val completedResults = jobs.awaitAll()
|
||||
loggerJob.cancel()
|
||||
print("\r".padEnd(80) + "\r") // Clear the progress line completely
|
||||
println("\n✅ All PRs have been processed.")
|
||||
completedResults
|
||||
}
|
||||
|
||||
// 3. Aggregate final results
|
||||
val allReleaseNotes = mutableMapOf<String, MutableList<ReleaseNoteItem>>()
|
||||
val processedPrs = mutableMapOf<String, ProcessedPrStatus>()
|
||||
|
||||
results.forEach { result ->
|
||||
processedPrs[result.prId] = result.status
|
||||
result.notes.forEach { (section, items) -> allReleaseNotes.getOrPut(section) { mutableListOf() }.addAll(items) }
|
||||
}
|
||||
|
||||
// --- NEW: Print collated logs ---
|
||||
println("\n--- PROCESSING LOGS ---")
|
||||
|
||||
results
|
||||
.sortedBy { it.prId.toInt() }
|
||||
.forEach { result ->
|
||||
println("\n[PR #${result.prId}]")
|
||||
result.logMessages.forEach { msg -> println(" $msg") }
|
||||
}
|
||||
|
||||
// 4. Write grouped release notes to file
|
||||
println("\n\n✍️ Writing release notes to ${Config.OUTPUT_FILE}...")
|
||||
|
||||
val outputFile = File(Config.OUTPUT_FILE)
|
||||
|
||||
outputFile.writeText("")
|
||||
|
||||
val sectionOrder = listOf("New Features", "Enhancements", "Bug Fixes", "Deprecations", "Uncategorized")
|
||||
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 = processedPrs.entries.groupBy({ it.value }, { it.key })
|
||||
|
||||
ProcessedPrStatus.entries.forEach { status ->
|
||||
val prs = summaryData[status]?.map { it.toInt() }?.sorted() ?: emptyList()
|
||||
if (prs.isEmpty()) return@forEach
|
||||
println("\n[${status.name}] - ${prs.size} PRs")
|
||||
println(prs.joinToString(", ") { "#$it" })
|
||||
}
|
||||
|
||||
println("\n\n✅ All tasks complete.")
|
||||
Reference in New Issue
Block a user