mirror of https://github.com/prometheus/prometheus
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
parent
98dcd28b1a
commit
dd6d5ec74a
@ -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
|
@ -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…
Reference in new issue