chore: backport release action (#26143) · coder/coder@7ef8201
1+package main
2+3+import (
4+"regexp"
5+"sort"
6+"strconv"
7+"strings"
8+)
9+10+// commitEntry represents a single non-merge commit.
11+type commitEntry struct {
12+SHA string
13+FullSHA string
14+Title string
15+Timestamp int64
16+}
17+18+// cherryPickPRRe matches cherry-pick bot titles like
19+// "chore: foo bar (cherry-pick #42) (#43)".
20+var cherryPickPRRe = regexp.MustCompile(`\(cherry-pick #(\d+)\)\s*\(#\d+\)$`)
21+22+// humanizedAreas maps conventional commit scopes to human-readable area
23+// names. Order matters: more specific prefixes must come first so that
24+// the first partial match wins.
25+var humanizedAreas = []struct {
26+Prefix string
27+Area string
28+}{
29+ {"agent/agentssh", "Agent SSH"},
30+ {"coderd/database", "Database"},
31+ {"enterprise/audit", "Auditing"},
32+ {"enterprise/cli", "CLI"},
33+ {"enterprise/coderd", "Server"},
34+ {"enterprise/dbcrypt", "Database"},
35+ {"enterprise/derpmesh", "Networking"},
36+ {"enterprise/provisionerd", "Provisioner"},
37+ {"enterprise/tailnet", "Networking"},
38+ {"enterprise/wsproxy", "Workspace Proxy"},
39+ {"agent", "Agent"},
40+ {"cli", "CLI"},
41+ {"coderd", "Server"},
42+ {"codersdk", "SDK"},
43+ {"docs", "Documentation"},
44+ {"enterprise", "Enterprise"},
45+ {"examples", "Examples"},
46+ {"helm", "Helm"},
47+ {"install.sh", "Installer"},
48+ {"provisionersdk", "SDK"},
49+ {"provisionerd", "Provisioner"},
50+ {"provisioner", "Provisioner"},
51+ {"pty", "CLI"},
52+ {"scaletest", "Scale Testing"},
53+ {"site", "Dashboard"},
54+ {"support", "Support"},
55+ {"tailnet", "Networking"},
56+}
57+58+// commitLog returns non-merge commits in the given range, filtering
59+// out left-side commits (already in the base) and deduplicating
60+// cherry-picks using git's --cherry-mark.
61+func commitLog(commitRange string) ([]commitEntry, error) {
62+// Use --left-right --cherry-mark to identify equivalent
63+// (cherry-picked) commits and left-side-only commits.
64+out, err := gitOutput("log", "--no-merges", "--left-right", "--cherry-mark",
65+"--pretty=format:%m %ct %h %H %s", commitRange)
66+if err != nil {
67+return nil, err
68+ }
69+if out == "" {
70+return nil, nil
71+ }
72+73+// Collect cherry-pick equivalent commits (marked with '=') so
74+// we can skip duplicates. We keep only the right-side version.
75+seen := make(map[string]bool)
76+77+var entries []commitEntry
78+for _, line := range strings.Split(out, "\n") {
79+line = strings.TrimSpace(line)
80+if line == "" {
81+continue
82+ }
83+// Format: %m %ct %h %H %s
84+// mark timestamp shortSHA fullSHA title...
85+parts := strings.SplitN(line, " ", 5)
86+if len(parts) < 5 {
87+continue
88+ }
89+mark := parts[0]
90+ts, _ := strconv.ParseInt(parts[1], 10, 64)
91+shortSHA := parts[2]
92+fullSHA := parts[3]
93+title := parts[4]
94+95+// Skip left-side commits (already in the old version).
96+if mark == "<" {
97+continue
98+ }
99+// Skip cherry-pick equivalents that we've already seen
100+// (marked '=' by --cherry-mark).
101+if mark == "=" {
102+if seen[title] {
103+continue
104+ }
105+seen[title] = true
106+ }
107+108+// Normalize cherry-pick bot titles:
109+// "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)"
110+if m := cherryPickPRRe.FindStringSubmatch(title); m != nil {
111+title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")"
112+ }
113+114+entries = append(entries, commitEntry{
115+SHA: shortSHA,
116+FullSHA: fullSHA,
117+Title: title,
118+Timestamp: ts,
119+ })
120+ }
121+122+// Sort by conventional commit prefix, then by timestamp
123+// (matching the bash script's sort -k3,3 -k1,1n).
124+sort.SliceStable(entries, func(i, j int) bool {
125+pi := commitSortPrefix(entries[i].Title)
126+pj := commitSortPrefix(entries[j].Title)
127+if pi != pj {
128+return pi < pj
129+ }
130+return entries[i].Timestamp < entries[j].Timestamp
131+ })
132+133+return entries, nil
134+}
135+136+// commitSortPrefix extracts the first word of a title for sorting.
137+func commitSortPrefix(title string) string {
138+idx := strings.IndexAny(title, " (:")
139+if idx < 0 {
140+return title
141+ }
142+return title[:idx]
143+}
144+145+// conventionalPrefixRe extracts prefix, scope, and rest from a
146+// conventional commit title. Does NOT match breaking "!" suffix;
147+// those titles are left as-is (matching bash behavior).
148+var conventionalPrefixRe = regexp.MustCompile(`^([a-z]+)(\((.+)\))?:\s*(.*)$`)
149+150+// humanizeTitle converts a conventional commit title to a
151+// human-readable form, e.g. "feat(site): add bar" -> "Dashboard: Add bar".
152+func humanizeTitle(title string) string {
153+m := conventionalPrefixRe.FindStringSubmatch(title)
154+if m == nil {
155+return title
156+ }
157+scope := m[3] // may be empty
158+rest := m[4]
159+if rest == "" {
160+return title
161+ }
162+// Capitalize the first letter of the rest.
163+rest = strings.ToUpper(rest[:1]) + rest[1:]
164+165+if scope == "" {
166+return rest
167+ }
168+169+// Look up scope in humanizedAreas (first partial match wins).
170+for _, ha := range humanizedAreas {
171+if strings.HasPrefix(scope, ha.Prefix) {
172+return ha.Area + ": " + rest
173+ }
174+ }
175+// Scope not found in map; return as-is.
176+return title
177+}
178+179+// breakingCommitRe matches conventional commit "!:" breaking changes.
180+var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`)
181+182+// categorizeCommit determines the release note section for a commit.
183+// The priority order matches the bash script: breaking title first,
184+// then labels (breaking, security, experimental), then prefix.
185+func categorizeCommit(title string, labels []string) string {
186+// Check breaking title first (matches bash behavior).
187+if breakingCommitRe.MatchString(title) {
188+return "breaking"
189+ }
190+191+// Label-based categorization.
192+for _, l := range labels {
193+if l == "release/breaking" {
194+return "breaking"
195+ }
196+if l == "security" {
197+return "security"
198+ }
199+if l == "release/experimental" {
200+return "experimental"
201+ }
202+ }
203+204+// Extract the conventional commit prefix (e.g. "feat", "fix(scope)").
205+prefixRe := regexp.MustCompile(`^([a-z]+)(\(.+\))?[!]?:`)
206+m := prefixRe.FindStringSubmatch(title)
207+if m == nil {
208+return "other"
209+ }
210+211+validPrefixes := []string{
212+"feat", "fix", "docs", "refactor", "perf",
213+"test", "build", "ci", "chore", "revert",
214+ }
215+for _, p := range validPrefixes {
216+if m[1] == p {
217+return p
218+ }
219+ }
220+return "other"
221+}