mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: add new interactive release package (#22624)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// commitEntry represents a single non-merge commit.
|
||||
type commitEntry struct {
|
||||
SHA string
|
||||
FullSHA string
|
||||
Title string
|
||||
PRCount int // 0 if no PR number found
|
||||
Timestamp int64
|
||||
}
|
||||
|
||||
var prNumRe = regexp.MustCompile(`\(#(\d+)\)`)
|
||||
|
||||
// cherryPickPRRe matches cherry-pick bot titles like
|
||||
// "chore: foo bar (cherry-pick #42) (#43)".
|
||||
var cherryPickPRRe = regexp.MustCompile(`\(cherry-pick #(\d+)\)\s*\(#\d+\)$`)
|
||||
|
||||
// commitLog returns non-merge commits in the given range, filtering
|
||||
// out left-side commits (already in the base) and deduplicating
|
||||
// cherry-picks using git's --cherry-mark.
|
||||
func commitLog(commitRange string) ([]commitEntry, error) {
|
||||
// Use --left-right --cherry-mark to identify equivalent
|
||||
// (cherry-picked) commits and left-side-only commits.
|
||||
out, err := gitOutput("log", "--no-merges", "--left-right", "--cherry-mark",
|
||||
"--pretty=format:%m %ct %h %H %s", commitRange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Collect cherry-pick equivalent commits (marked with '=') so
|
||||
// we can skip duplicates. We keep only the right-side version.
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var entries []commitEntry
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Format: %m %ct %h %H %s
|
||||
// mark timestamp shortSHA fullSHA title...
|
||||
parts := strings.SplitN(line, " ", 5)
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
}
|
||||
mark := parts[0]
|
||||
ts, _ := strconv.ParseInt(parts[1], 10, 64)
|
||||
shortSHA := parts[2]
|
||||
fullSHA := parts[3]
|
||||
title := parts[4]
|
||||
|
||||
// Skip left-side commits (already in the old version).
|
||||
if mark == "<" {
|
||||
continue
|
||||
}
|
||||
// Skip cherry-pick equivalents that we've already seen
|
||||
// (marked '=' by --cherry-mark).
|
||||
if mark == "=" {
|
||||
if seen[title] {
|
||||
continue
|
||||
}
|
||||
seen[title] = true
|
||||
}
|
||||
|
||||
// Normalize cherry-pick bot titles:
|
||||
// "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)"
|
||||
if m := cherryPickPRRe.FindStringSubmatch(title); m != nil {
|
||||
title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")"
|
||||
}
|
||||
|
||||
e := commitEntry{
|
||||
SHA: shortSHA,
|
||||
FullSHA: fullSHA,
|
||||
Title: title,
|
||||
Timestamp: ts,
|
||||
}
|
||||
if m := prNumRe.FindStringSubmatch(e.Title); m != nil {
|
||||
e.PRCount, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
// Sort by conventional commit prefix, then by timestamp
|
||||
// (matching the bash script's sort -k3,3 -k1,1n).
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
pi := commitSortPrefix(entries[i].Title)
|
||||
pj := commitSortPrefix(entries[j].Title)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
return entries[i].Timestamp < entries[j].Timestamp
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// commitSortPrefix extracts the first word of a title for sorting.
|
||||
func commitSortPrefix(title string) string {
|
||||
idx := strings.IndexAny(title, " (:")
|
||||
if idx < 0 {
|
||||
return title
|
||||
}
|
||||
return title[:idx]
|
||||
}
|
||||
|
||||
// humanizedAreas maps conventional commit scopes to human-readable area
|
||||
// names. Order matters: more specific prefixes must come first so that
|
||||
// the first partial match wins.
|
||||
var humanizedAreas = []struct {
|
||||
Prefix string
|
||||
Area string
|
||||
}{
|
||||
{"agent/agentssh", "Agent SSH"},
|
||||
{"coderd/database", "Database"},
|
||||
{"enterprise/audit", "Auditing"},
|
||||
{"enterprise/cli", "CLI"},
|
||||
{"enterprise/coderd", "Server"},
|
||||
{"enterprise/dbcrypt", "Database"},
|
||||
{"enterprise/derpmesh", "Networking"},
|
||||
{"enterprise/provisionerd", "Provisioner"},
|
||||
{"enterprise/tailnet", "Networking"},
|
||||
{"enterprise/wsproxy", "Workspace Proxy"},
|
||||
{"agent", "Agent"},
|
||||
{"cli", "CLI"},
|
||||
{"coderd", "Server"},
|
||||
{"codersdk", "SDK"},
|
||||
{"docs", "Documentation"},
|
||||
{"enterprise", "Enterprise"},
|
||||
{"examples", "Examples"},
|
||||
{"helm", "Helm"},
|
||||
{"install.sh", "Installer"},
|
||||
{"provisionersdk", "SDK"},
|
||||
{"provisionerd", "Provisioner"},
|
||||
{"provisioner", "Provisioner"},
|
||||
{"pty", "CLI"},
|
||||
{"scaletest", "Scale Testing"},
|
||||
{"site", "Dashboard"},
|
||||
{"support", "Support"},
|
||||
{"tailnet", "Networking"},
|
||||
}
|
||||
|
||||
// conventionalPrefixRe extracts prefix, scope, and rest from a
|
||||
// conventional commit title. Does NOT match breaking "!" suffix —
|
||||
// those titles are left as-is (matching bash behavior).
|
||||
var conventionalPrefixRe = regexp.MustCompile(`^([a-z]+)(\((.+)\))?:\s*(.*)$`)
|
||||
|
||||
// humanizeTitle converts a conventional commit title to a
|
||||
// human-readable form, e.g. "feat(site): add bar" → "Dashboard: Add bar".
|
||||
func humanizeTitle(title string) string {
|
||||
m := conventionalPrefixRe.FindStringSubmatch(title)
|
||||
if m == nil {
|
||||
return title
|
||||
}
|
||||
scope := m[3] // may be empty
|
||||
rest := m[4]
|
||||
if rest == "" {
|
||||
return title
|
||||
}
|
||||
// Capitalize the first letter of the rest.
|
||||
rest = strings.ToUpper(rest[:1]) + rest[1:]
|
||||
|
||||
if scope == "" {
|
||||
return rest
|
||||
}
|
||||
|
||||
// Look up scope in humanizedAreas (first partial match wins).
|
||||
for _, ha := range humanizedAreas {
|
||||
if strings.HasPrefix(scope, ha.Prefix) {
|
||||
return ha.Area + ": " + rest
|
||||
}
|
||||
}
|
||||
// Scope not found in map — return as-is.
|
||||
return title
|
||||
}
|
||||
|
||||
// breakingCommitRe matches conventional commit "!:" breaking changes.
|
||||
var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`)
|
||||
|
||||
// categorizeCommit determines the release note section for a commit.
|
||||
// The priority order matches the bash script: breaking title first,
|
||||
// then labels (breaking, security, experimental), then prefix.
|
||||
func categorizeCommit(title string, labels []string) string {
|
||||
// Check breaking title first (matches bash behavior).
|
||||
if breakingCommitRe.MatchString(title) {
|
||||
return "breaking"
|
||||
}
|
||||
|
||||
// Label-based categorization.
|
||||
for _, l := range labels {
|
||||
if l == "release/breaking" {
|
||||
return "breaking"
|
||||
}
|
||||
if l == "security" {
|
||||
return "security"
|
||||
}
|
||||
if l == "release/experimental" {
|
||||
return "experimental"
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the conventional commit prefix (e.g. "feat", "fix(scope)").
|
||||
prefixRe := regexp.MustCompile(`^([a-z]+)(\(.+\))?[!]?:`)
|
||||
m := prefixRe.FindStringSubmatch(title)
|
||||
if m == nil {
|
||||
return "other"
|
||||
}
|
||||
|
||||
validPrefixes := []string{
|
||||
"feat", "fix", "docs", "refactor", "perf",
|
||||
"test", "build", "ci", "chore", "revert",
|
||||
}
|
||||
for _, p := range validPrefixes {
|
||||
if m[1] == p {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
Reference in New Issue
Block a user