263 lines
7.7 KiB
Go
263 lines
7.7 KiB
Go
/*
|
|
Copyright 2025 The Kubernetes Authors All rights reserved.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"maps"
|
|
"net/http"
|
|
"os"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/google/go-github/v74/github"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// Config holds knobs for filtering and grouping pull requests when generating
|
|
// release notes.
|
|
// All string comparisons are case-insensitive.
|
|
type Config struct {
|
|
// all PRs with these prefixes will be skipped in the Changelog
|
|
SkipPrefixes []string
|
|
|
|
// Group PRs with these prefixes into their own group heading based on their Prefix
|
|
PrefixGroups map[string]string
|
|
|
|
//Group PRs with these prefixes into their own group heading based the string they contain
|
|
ContainGroups map[string]string
|
|
|
|
// AllowedLabels enumerates the labels that can be used for grouping.
|
|
// Labels outside this list are ignored.
|
|
AllowedLabels []string
|
|
}
|
|
|
|
var cfg = Config{
|
|
// if PR title "starts with" these words, skip them from change log
|
|
SkipPrefixes: []string{
|
|
"ci:",
|
|
"test:",
|
|
"build:",
|
|
"Build(deps):",
|
|
"site:",
|
|
"docs:",
|
|
"chore:",
|
|
"Post-release:"},
|
|
// if PR title "starts with" these words, put them in these group
|
|
PrefixGroups: map[string]string{
|
|
"addon:": "Addons",
|
|
"addon ": "Addons",
|
|
"ISO:": "Base image versions",
|
|
"ISO/Kicabse:": "Base image versions",
|
|
"Kicbase": "Base image versions",
|
|
"cni": "CNI",
|
|
},
|
|
// if PR title "contains" these words, put them in these group
|
|
ContainGroups: map[string]string{
|
|
"bug": "Bug fixes",
|
|
"fix": "Bug fixes",
|
|
"vfkit:": "Drivers",
|
|
"qemu:": "Drivers",
|
|
"driver": "Drivers",
|
|
"krunkit:": "Drivers",
|
|
"kvm:": "Drivers",
|
|
"hyperv:": "Drivers",
|
|
"hyperkit:": "Drivers",
|
|
"vbox:": "Drivers",
|
|
"improve": "Improvements",
|
|
"translation": "UI",
|
|
},
|
|
AllowedLabels: []string{"kind/feature", "kind/bug", "kind/enhancement"},
|
|
}
|
|
|
|
func main() {
|
|
|
|
var (
|
|
owner = flag.String("owner", "kubernetes", "repository owner")
|
|
repo = flag.String("repo", "minikube", "repository name")
|
|
startRef = flag.String("start", "", "start git ref (exclusive) (defaults to last release tag)")
|
|
endRef = flag.String("end", "HEAD", "end git ref (inclusive)")
|
|
)
|
|
flag.Parse()
|
|
fmt.Println("Generating changelog, this might take a few minutes ...")
|
|
ctx := context.Background()
|
|
gh := newGitHubClient(ctx)
|
|
|
|
// Determine the starting reference; use the latest release if none is provided.
|
|
start := resolveStartRef(ctx, gh, *owner, *repo, *startRef)
|
|
|
|
// Collect and group pull requests between the refs, then print the result.
|
|
prs := pullRequestsBetween(ctx, gh, *owner, *repo, start, *endRef)
|
|
groups := groupPullRequests(prs, cfg)
|
|
printGroups(groups)
|
|
}
|
|
|
|
// newGitHubClient constructs a GitHub client. If GITHUB_TOKEN is set the client
|
|
// uses it for authentication to avoid strict rate limits.
|
|
func newGitHubClient(ctx context.Context) *github.Client {
|
|
token := os.Getenv("GITHUB_TOKEN")
|
|
httpClient := http.DefaultClient
|
|
if token != "" {
|
|
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
|
|
httpClient = oauth2.NewClient(ctx, ts)
|
|
}
|
|
return github.NewClient(httpClient)
|
|
}
|
|
|
|
// resolveStartRef returns the starting git reference. When start is empty the
|
|
// most recent GitHub release tag is used.
|
|
func resolveStartRef(ctx context.Context, gh *github.Client, owner, repo, start string) string {
|
|
if start != "" {
|
|
return start
|
|
}
|
|
rel, _, err := gh.Repositories.GetLatestRelease(ctx, owner, repo)
|
|
if err != nil {
|
|
log.Fatalf("get latest release: %v", err)
|
|
}
|
|
return rel.GetTagName()
|
|
}
|
|
|
|
// pullRequestsBetween finds all pull requests merged between start and end by
|
|
// walking the commit comparison and querying PRs for each commit.
|
|
func pullRequestsBetween(ctx context.Context, gh *github.Client, owner, repo, start, end string) map[int]*github.PullRequest {
|
|
var allCommits []*github.RepositoryCommit
|
|
opts := &github.ListOptions{PerPage: 100}
|
|
for {
|
|
cmp, resp, err := gh.Repositories.CompareCommits(ctx, owner, repo, start, end, opts)
|
|
if err != nil {
|
|
log.Fatalf("compare commits: %v", err)
|
|
}
|
|
allCommits = append(allCommits, cmp.Commits...)
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
opts.Page = resp.NextPage
|
|
}
|
|
|
|
prsMap := make(map[int]*github.PullRequest)
|
|
for _, c := range allCommits {
|
|
prs, _, err := gh.PullRequests.ListPullRequestsWithCommit(ctx, owner, repo, c.GetSHA(), nil)
|
|
if err != nil {
|
|
log.Printf("list PRs for commit %s: %v", c.GetSHA(), err)
|
|
continue
|
|
}
|
|
for _, pr := range prs {
|
|
prsMap[pr.GetNumber()] = pr
|
|
}
|
|
}
|
|
return prsMap
|
|
}
|
|
|
|
// groupPullRequests assigns each pull request to a group based on the provided
|
|
// configuration and allowed label list.
|
|
func groupPullRequests(prs map[int]*github.PullRequest, cfg Config) map[string][]*github.PullRequest {
|
|
groups := make(map[string][]*github.PullRequest)
|
|
|
|
// Build a quick lookup for allowed labels.
|
|
allowed := make(map[string]struct{})
|
|
for _, l := range cfg.AllowedLabels {
|
|
allowed[strings.ToLower(l)] = struct{}{}
|
|
}
|
|
|
|
for _, pr := range prs {
|
|
group, skip := classify(pr, cfg, allowed)
|
|
if skip {
|
|
continue
|
|
}
|
|
groups[group] = append(groups[group], pr)
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// classify returns the group name for a pull request and whether it should be
|
|
// skipped entirely.
|
|
func classify(pr *github.PullRequest, cfg Config, allowed map[string]struct{}) (string, bool) {
|
|
title := pr.GetTitle()
|
|
lower := strings.ToLower(title)
|
|
|
|
// Skip PRs with any configured prefix.
|
|
for _, p := range cfg.SkipPrefixes {
|
|
if strings.HasPrefix(lower, strings.ToLower(p)) {
|
|
return "", true
|
|
}
|
|
}
|
|
|
|
// Group by specific prefixes first.
|
|
for pref, g := range cfg.PrefixGroups {
|
|
if strings.HasPrefix(lower, strings.ToLower(pref)) {
|
|
return g, false
|
|
}
|
|
}
|
|
|
|
// Then look for substring matches. Iterate in a deterministic order so
|
|
// overlapping keywords behave predictably. Longer substrings are checked
|
|
// first to favor more specific matches.
|
|
subs := slices.Collect(maps.Keys(cfg.ContainGroups))
|
|
sort.Slice(subs, func(i, j int) bool {
|
|
if len(subs[i]) == len(subs[j]) {
|
|
return subs[i] < subs[j]
|
|
}
|
|
return len(subs[i]) > len(subs[j])
|
|
})
|
|
for _, sub := range subs {
|
|
if strings.Contains(lower, strings.ToLower(sub)) {
|
|
return cfg.ContainGroups[sub], false
|
|
}
|
|
}
|
|
|
|
// Finally, use an allowed label if present.
|
|
for _, l := range pr.Labels {
|
|
name := l.GetName()
|
|
if _, ok := allowed[strings.ToLower(name)]; ok {
|
|
return name, false
|
|
}
|
|
}
|
|
return "other", false
|
|
}
|
|
|
|
// printGroups writes the grouped pull requests to stdout. Groups and the
|
|
// entries within each group are sorted for stable output.
|
|
func printGroups(groups map[string][]*github.PullRequest) {
|
|
groupNames := make([]string, 0, len(groups))
|
|
for g := range groups {
|
|
groupNames = append(groupNames, g)
|
|
}
|
|
sort.Strings(groupNames)
|
|
for _, g := range groupNames {
|
|
fmt.Printf("## %s\n", g)
|
|
prs := groups[g]
|
|
|
|
// Sort entries alphabetically by title with PR number as a tiebreaker.
|
|
sort.Slice(prs, func(i, j int) bool {
|
|
ti := strings.ToLower(prs[i].GetTitle())
|
|
tj := strings.ToLower(prs[j].GetTitle())
|
|
if ti == tj {
|
|
return prs[i].GetNumber() < prs[j].GetNumber()
|
|
}
|
|
return ti < tj
|
|
})
|
|
for _, pr := range prs {
|
|
fmt.Printf("* %s (#%d)\n", pr.GetTitle(), pr.GetNumber())
|
|
}
|
|
fmt.Println()
|
|
}
|
|
}
|