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