feat: add utils to generate release notes draft.

add a CI check to make sure PRs provide a release note entry.

run "make release-note{check,generate} --help" to learn more

make the release note block part of .github/PULL_REQUEST_TEMPLATE.md (inspired from k8s')

Signed-off-by: machine424 <ayoubmrini424@gmail.com>
pull/15191/head
machine424 1 month ago
parent 98dcd28b1a
commit dd6d5ec74a
No known key found for this signature in database
GPG Key ID: A4B001A4FDEE017D

@ -1,9 +1,5 @@
<!--
Please give your PR a title in the form "area: short description". For example "tsdb: reduce disk usage by 95%"
If your PR is to fix an issue, put "Fixes #issue-number" in the description.
Don't forget!
- Please give your PR a title in the form "area: short description". For example "tsdb: reduce disk usage by 95%"
- Please sign CNCF's Developer Certificate of Origin and sign-off your commits by adding the -s / --signoff flag to `git commit`. See https://github.com/apps/dco for more information.
@ -17,3 +13,18 @@
- All comments should start with a capital letter and end with a full stop.
-->
#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.*
-->
#### Does this PR introduce a user-facing change?
<!--
If no, just write "NONE" in the release-note block below.
If yes, please provide a one-line release note in the `release-note` block below
-->
```release-note
```

@ -0,0 +1,23 @@
name: 'Release note'
on:
pull_request:
types:
- opened
- reopened
- edited
permissions:
contents: read
jobs:
check_release_note:
name: check
runs-on: ubuntu-latest
if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks.
container:
image: quay.io/prometheus/golang-builder:1.23-base
steps:
- uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
- env:
PR_DESCRIPTION: ${{ github.event.pull_request.body }}
run: |
echo "$PR_DESCRIPTION" | make release-note-check

@ -192,3 +192,11 @@ update-all-go-deps:
$(GO) get -d $$m; \
done
@cd ./documentation/examples/remote_storage/ && $(GO) mod tidy
.PHONY: release-note-check
release-note-check:
@cd ./scripts/release-note && $(GO) mod tidy && $(GO) run main.go check
.PHONY: release-note-generate
release-note-generate:
@cd ./scripts/release-note && $(GO) mod tidy && $(GO) run main.go generate

@ -0,0 +1,7 @@
module github.com/prometheus/prometheus/scripts/release-note
go 1.22.0
require github.com/google/go-github/v66 v66.0.0
require github.com/google/go-querystring v1.1.0 // indirect

@ -0,0 +1,8 @@
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M=
github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

@ -0,0 +1,261 @@
// Copyright 2024 The Prometheus Authors
// 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 (
"bufio"
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"regexp"
"strings"
gogithub "github.com/google/go-github/v66/github"
)
const (
ghOrg = "prometheus"
ghRepo = "prometheus"
ghTokenEnvVar = "PROM_GRN_GITHUB_TOKEN"
generateSubCmd = "generate"
checkSubCmd = "check"
releaseBlockName = "```release-note ```"
notApplicable = "NONE"
perPageLimit = 10000
)
var (
releaseNoteRegex = regexp.MustCompile("(?s)```release-note\\s+(?U)(.*)\\s*```")
errPRAlreadySeen = fmt.Errorf("pull request was already processed")
errReleaseNoteNotFound = fmt.Errorf("release note couldn't be retrieved. If the release note "+
"is not applicable, you can put %s in the %s block", notApplicable, releaseBlockName)
)
type releaseNotesBuilder struct {
ghClient *gogithub.Client
baseRef string
headRef string
seenPrs map[int64]struct{}
}
func NewReleaseNotesBuilder(baseRef, headRef string) *releaseNotesBuilder {
ghClient := gogithub.NewClient(nil)
token, set := os.LookupEnv(ghTokenEnvVar)
if set {
ghClient = ghClient.WithAuthToken(token)
}
return &releaseNotesBuilder{
ghClient: ghClient,
baseRef: baseRef,
headRef: headRef,
seenPrs: make(map[int64]struct{}),
}
}
func (builder releaseNotesBuilder) fetchReleaseNote(pr *gogithub.PullRequest) (string, error) {
defer func() {
// pr.ID isn't supposed to be nil.
builder.seenPrs[*pr.ID] = struct{}{}
}()
if _, seen := builder.seenPrs[*pr.ID]; seen {
return "", errPRAlreadySeen
}
if !isEmptyString(pr.Body) {
note, err := extractReleaseNote(*pr.Body)
if err == nil {
return note, nil
}
if !errors.Is(err, errReleaseNoteNotFound) {
return "", err
}
}
// Use the PR's title as a fallback.
if isEmptyString(pr.Title) {
return "", fmt.Errorf("pull request body and title are empty")
}
return fmt.Sprintf("WARNING: PR TITLE AS RELEASE NOTE: %s", *pr.Title), nil
}
// TODO: maybe this will need to be grouped.
func (builder releaseNotesBuilder) printReleaseNotesDraft(compare *gogithub.CommitsComparison) {
for _, commit := range compare.Commits {
prs, _, err := builder.ghClient.PullRequests.ListPullRequestsWithCommit(
context.Background(),
ghOrg,
ghRepo,
*commit.SHA,
nil,
)
if err != nil {
log.Fatalf("error getting the commits between %s and %s: %s", builder.baseRef, builder.headRef, err.Error())
}
for _, pr := range prs {
releaseNote, err := builder.fetchReleaseNote(pr)
if err != nil {
if errors.Is(err, errPRAlreadySeen) {
continue
}
fmt.Printf("- ERROR: COULDN'T GET RELEASE NOTE: %s (%s).\n", err.Error(), *pr.HTMLURL)
continue
}
if releaseNote == notApplicable {
continue
}
fmt.Printf("- %s (%s).\n", releaseNote, *pr.HTMLURL)
}
}
fmt.Printf("\nFull Changelog: %s\n", *compare.HTMLURL)
}
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Available subcommands are: %s, %s.\n", generateSubCmd, checkSubCmd)
}
flag.Parse()
args := flag.Args()
if len(args) == 0 {
flag.Usage()
log.Fatal("Please specify a subcommand.")
}
cmd, args := args[0], args[1:]
switch cmd {
case checkSubCmd:
check(args)
case generateSubCmd:
generate(args)
default:
log.Fatalf("Unrecognized subcommand: %q.", cmd)
}
}
func check(args []string) {
flag := flag.NewFlagSet("check", flag.ExitOnError)
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "The command reads input from stdin (typically a GitHub pull request body) and "+
"extracts the note from the %s block. Fails if the release note could not be extracted.\n", releaseBlockName)
}
flag.Parse(args)
args = flag.Args()
if len(args) != 0 {
flag.Usage()
log.Fatalf("Unknown args: %q", args)
}
var sb strings.Builder
buf := bufio.NewScanner(os.Stdin)
for buf.Scan() {
sb.WriteString(buf.Text())
sb.WriteString("\n")
}
if err := buf.Err(); err != nil {
log.Fatalf("Error reading input: %s", err)
}
note, err := extractReleaseNote(sb.String())
if err != nil {
log.Fatal(err.Error())
}
if note == notApplicable {
fmt.Fprint(os.Stderr, "Release note is not applicable.\n")
return
}
fmt.Fprintf(os.Stderr, "Release note was extracted:\n%q\n", note)
}
func generate(args []string) {
flag := flag.NewFlagSet("generate", flag.ExitOnError)
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "The command fetches release note entries from pull requests "+
"associated with all commits between two references and assembles them into a draft "+
"release notes printed to stdout.\nUses %s env var for GitHub API requests if set.\n", ghTokenEnvVar)
flag.PrintDefaults()
}
baseRef := flag.String("base", "", "base reference")
headRef := flag.String("head", "", "head reference")
flag.Parse(args)
args = flag.Args()
if len(args) != 0 {
flag.Usage()
log.Fatalf("Unknown args: %q", args)
}
if isEmptyString(baseRef) {
flag.Usage()
log.Fatalf("base reference not set")
}
if isEmptyString(headRef) {
flag.Usage()
log.Fatalf("head reference not set")
}
builder := NewReleaseNotesBuilder(*baseRef, *headRef)
compare, _, err := builder.ghClient.Repositories.CompareCommits(
context.Background(),
ghOrg,
ghRepo,
builder.baseRef,
builder.headRef,
&gogithub.ListOptions{
PerPage: perPageLimit,
},
)
if err != nil {
log.Fatalf("error getting the commits between %s and %s: %s", builder.baseRef, builder.headRef, err.Error())
}
if len(compare.Commits) == perPageLimit {
log.Fatal("commits could be missed, pagination should be set up.")
}
builder.printReleaseNotesDraft(compare)
}
func isEmptyString(s *string) bool {
return s == nil || *s == ""
}
func extractReleaseNote(s string) (string, error) {
match := releaseNoteRegex.FindStringSubmatch(s)
if len(match) != 2 {
return "", fmt.Errorf("%s block not found: %w", releaseBlockName, errReleaseNoteNotFound)
}
note := match[1]
switch {
case note == "":
return "", fmt.Errorf("release note is empty: %w", errReleaseNoteNotFound)
case note == notApplicable:
return notApplicable, nil
case strings.Contains(note, "\n"):
return "", fmt.Errorf("%s block should contain one line: %q", releaseBlockName, note)
}
return note, nil
}
Loading…
Cancel
Save