Repository: kubernetes/release
Pull request #3406 (2024-01-04 01:13:13)

release-notes check subcommand
#### What type of PR is this?

/kind feature

#### What this PR does / why we need it:

This PR adds a new `check` subcommand to `release-notes`. This command is designed tu run in CI to make it easy to enforce kubernetes-style release notes in PRs. The subcommand takes one or more PR numbers and will exit non-zero if the PRs are missing the release-notes block or a valid release note. It supports `NONE` or adding the `release-note-none` label to skip the note enforcement.

Here's the output from the subcommand help:

```
$] release-notes check  --help

release-notes check checks one or more PRs to ensure they contain
a valid release note. It is a subcommand designed to run in a postsubmit job to
block PRs missing a release note.

release-notes check will retrieve pull request data using the GitHub API and
look for a valid release notes block in the PR body. For example:

```release-note
Fixed a bug to make my software even more awesome
`` `

When enforcing release notes in PRs, we recommend adding the release-notes block
to your PR templates. See how Kubernetes does it here:
https://github.com/kubernetes/release/blob/d546da8a2ec580ea4c024637234cc976a6ba398a/.github/PULL_REQUEST_TEMPLATE.md?plain=1#L45-L57

If you want to skip the release notes check, you can create a new "release-notes-none"
label to your PR or type "NONE" in the notes block:

```release-note
NONE
`` `

Either of these will instruct the release note checked to allow a PR without a
valid note.

To generate release notes from these blocks, use release-notes generate.

Usage:
  release-notes check [flags]

Flags:
      --debug                    Enable debug logging
      --github-base-url string   Base URL of github
  -h, --help                     help for check
      --org string               Name of github organization (default "kubernetes")
      --pr ints                  pull request number(s) to check
      --repo string              Name of github repository (default "kubernetes")

```

When failing the subcommand will print a short help text to guide contributors looking at the logs:

```
release-notes check --pr=3403 --repo=release 

Error Checking Release Notes:

The pull request(s) specified do not have a valid release note.

Make sure the PRs have a ```release-note block (see help) in the PR body with markdown text
clearly specifying the change being introduced in your commits.

If you want to skip the release notes check, you can type NONE in the release notes block
or add a "release-notes-none" (without quotes) to your PR. This will
tell the release notes checker to allow PRs without a note.

For more information see:
https://github.com/kubernetes/release/tree/master/cmd/release-notes

```

#### Which issue(s) this PR fixes:

None

#### Special notes for your reviewer:


This subcommand will be part of a new release-notes action in k-sigs/release-actions :rocket:
([first part is here](https://github.com/kubernetes-sigs/release-actions/pull/56))

/assign @cpanato 

#### Does this PR introduce a user-facing change?

```release-note
The `release-notes` utility will now have subcommands to support more functionality
- The original functionality of `release-notes` has now been moved to the `release-notes generate` subcommand. This is the default subcommand to avoid breaking compatibility.
- We now have a new `release-notes check` subcommand to verify if PRs have a valid release note
```

Signed-off-by: Adolfo García Veytia (Puerco) <adolfo.garcia@uservers.net>



Patch:
diff --git a/cmd/release-notes/check.go b/cmd/release-notes/check.go
new file mode 100644
index 00000000..b9f6f118
--- /dev/null
+++ b/cmd/release-notes/check.go
@@ -0,0 +1,189 @@
+/*
+Copyright 2023 The Kubernetes 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 (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+	"k8s.io/release/pkg/notes"
+	"k8s.io/release/pkg/notes/options"
+	"sigs.k8s.io/release-utils/env"
+)
+
+const (
+	pullRequestGuidance = "The pull request(s) specified do not have a valid release note.\n\n" +
+		"Make sure the PRs have a ```release-note block (see help) in the PR body with markdown text\n" +
+		"clearly specifying the change being introduced in your commits.\n\n" +
+		"If you want to skip the release notes check, you can type NONE in the release notes block\n" +
+		"or add a \"release-notes-none\" (without quotes) to your PR. This will\n" +
+		"tell the release notes checker to allow PRs without a note.\n\n" +
+		"For more information see:\n" +
+		"https://github.com/kubernetes/release/tree/master/cmd/release-notes\n\n\n"
+	bkTicks = "```"
+)
+
+type checkPROptions struct {
+	options.Options
+	PullRequests []int
+}
+
+func (o *checkPROptions) ValidateAndFinish() error {
+	var lenErr, prNrErr, orgErr, repoErr error
+	if len(o.PullRequests) == 0 {
+		lenErr = fmt.Errorf("no pull requests numbers specified")
+	}
+
+	for _, n := range o.PullRequests {
+		if n == 0 {
+			prNrErr = fmt.Errorf("invalid pull request number (must be an integer larger than 0)")
+			break
+		}
+	}
+
+	if o.GithubOrg == "" {
+		orgErr = fmt.Errorf("no GitHub organization specified")
+	}
+
+	if o.GithubRepo == "" {
+		orgErr = fmt.Errorf("no GitHub repository specified")
+	}
+
+	return errors.Join(
+		lenErr, prNrErr, orgErr, repoErr,
+	)
+}
+
+var checkPROpts *checkPROptions
+
+func addCheckPRFlags(subcommand *cobra.Command) {
+	// githubBaseURL contains the github base URL.
+	subcommand.PersistentFlags().StringVar(
+		&checkPROpts.GithubBaseURL,
+		"github-base-url",
+		env.Default("GITHUB_BASE_URL", ""),
+		"Base URL of github",
+	)
+
+	// githubOrg contains name of github organization that holds the repo to scrape.
+	subcommand.PersistentFlags().StringVar(
+		&checkPROpts.GithubOrg,
+		"org",
+		env.Default("ORG", notes.DefaultOrg),
+		"Name of github organization",
+	)
+
+	// githubRepo contains name of github repository to scrape.
+	subcommand.PersistentFlags().StringVar(
+		&checkPROpts.GithubRepo,
+		"repo",
+		env.Default("REPO", notes.DefaultRepo),
+		"Name of github repository",
+	)
+
+	// Debug output
+	subcommand.PersistentFlags().BoolVar(
+		&checkPROpts.Debug,
+		"debug",
+		env.IsSet("DEBUG"),
+		"Enable debug logging",
+	)
+
+	subcommand.PersistentFlags().IntSliceVar(
+		&checkPROpts.PullRequests,
+		"pr",
+		[]int{},
+		"pull request number(s) to check",
+	)
+}
+
+func addCheckPR(parent *cobra.Command) {
+	checkPROpts = &checkPROptions{
+		Options: options.Options{},
+	}
+
+	checkprCmd := &cobra.Command{
+		Short: "Checks a pull request on GitHub to ensure it has a release note",
+		Long: `release-notes check checks one or more PRs to ensure they contain
+a valid release note. It is a subcommand designed to run in a postsubmit job to
+block PRs missing a release note.
+
+release-notes check will retrieve pull request data using the GitHub API and
+look for a valid release notes block in the PR body. For example:
+
+` + bkTicks + `release-note
+Fixed a bug to make my software even more awesome
+` + bkTicks + `
+
+When enforcing release notes in PRs, we recommend adding the release-notes block
+to your PR templates. See how Kubernetes does it here:
+https://github.com/kubernetes/release/blob/d546da8a2ec580ea4c024637234cc976a6ba398a/.github/PULL_REQUEST_TEMPLATE.md?plain=1#L45-L57
+
+If you want to skip the release notes check, you can create a new "release-notes-none"
+label to your PR or type "NONE" in the notes block:
+
+` + bkTicks + `release-note
+NONE
+` + bkTicks + `
+
+Either of these will instruct the release note checked to allow a PR without a
+valid note.
+
+To generate release notes from these blocks, use release-notes generate.
+
+
+		`,
+		Use:           "check",
+		SilenceUsage:  false,
+		SilenceErrors: false,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			g, err := notes.NewGatherer(context.Background(), &options.Options{
+				GithubBaseURL: checkPROpts.GithubBaseURL,
+				GithubOrg:     checkPROpts.GithubOrg,
+				GithubRepo:    checkPROpts.GithubRepo,
+			})
+			if err != nil {
+				return fmt.Errorf("creating notes gatherer: %w", err)
+			}
+
+			errs := []error{}
+
+			for _, prNr := range checkPROpts.PullRequests {
+				_, err := g.ReleaseNoteForPullRequest(prNr)
+				if err != nil {
+					errs = append(errs, fmt.Errorf("checking notes for PR #%d: %w", prNr, err))
+				}
+			}
+
+			if len(errs) > 0 {
+				fmt.Fprintf(os.Stderr, "\nError Checking Release Notes:\n\n"+pullRequestGuidance)
+				return errors.Join(errs...)
+			}
+
+			return nil
+		},
+		PreRunE: func(*cobra.Command, []string) error {
+			return checkPROpts.ValidateAndFinish()
+		},
+	}
+
+	addCheckPRFlags(checkprCmd)
+	parent.AddCommand(checkprCmd)
+}
diff --git a/cmd/release-notes/generate.go b/cmd/release-notes/generate.go
new file mode 100644
index 00000000..3fb2152b
--- /dev/null
+++ b/cmd/release-notes/generate.go
@@ -0,0 +1,266 @@
+/*
+Copyright 2023 The Kubernetes 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 (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/spf13/cobra"
+	"k8s.io/release/pkg/notes"
+	"k8s.io/release/pkg/notes/options"
+	"k8s.io/release/pkg/release"
+	"sigs.k8s.io/release-sdk/git"
+	"sigs.k8s.io/release-utils/env"
+)
+
+func addGenerateFlags(subcommand *cobra.Command) {
+	// githubBaseURL contains the github base URL.
+	subcommand.PersistentFlags().StringVar(
+		&opts.GithubBaseURL,
+		"github-base-url",
+		env.Default("GITHUB_BASE_URL", ""),
+		"Base URL of github",
+	)
+
+	// githubUploadURL contains the github upload URL.
+	subcommand.PersistentFlags().StringVar(
+		&opts.GithubUploadURL,
+		"github-upload-url",
+		env.Default("GITHUB_UPLOAD_URL", ""),
+		"Upload URL of github",
+	)
+
+	// githubOrg contains name of github organization that holds the repo to scrape.
+	subcommand.PersistentFlags().StringVar(
+		&opts.GithubOrg,
+		"org",
+		env.Default("ORG", notes.DefaultOrg),
+		"Name of github organization",
+	)
+
+	// githubRepo contains name of github repository to scrape.
+	subcommand.PersistentFlags().StringVar(
+		&opts.GithubRepo,
+		"repo",
+		env.Default("REPO", notes.DefaultRepo),
+		"Name of github repository",
+	)
+
+	// output contains the path on the filesystem to where the resultant
+	// release notes should be printed.
+	subcommand.PersistentFlags().StringVar(
+		&releaseNotesOpts.outputFile,
+		"output",
+		env.Default("OUTPUT", ""),
+		"The path to the where the release notes will be printed",
+	)
+
+	// branch is which branch to scrape.
+	subcommand.PersistentFlags().StringVar(
+		&opts.Branch,
+		"branch",
+		env.Default("BRANCH", git.DefaultBranch),
+		fmt.Sprintf("Select which branch to scrape. Defaults to `%s`", git.DefaultBranch),
+	)
+
+	// startSHA contains the commit SHA where the release note generation
+	// begins.
+	subcommand.PersistentFlags().StringVar(
+		&opts.StartSHA,
+		"start-sha",
+		env.Default("START_SHA", ""),
+		"The commit hash to start at",
+	)
+
+	// endSHA contains the commit SHA where the release note generation ends.
+	subcommand.PersistentFlags().StringVar(
+		&opts.EndSHA,
+		"end-sha",
+		env.Default("END_SHA", ""),
+		"The commit hash to end at",
+	)
+
+	// startRev contains any valid git object where the release note generation
+	// begins. Can be used as alternative to start-sha.
+	subcommand.PersistentFlags().StringVar(
+		&opts.StartRev,
+		"start-rev",
+		env.Default("START_REV", ""),
+		"The git revision to start at. Can be used as alternative to start-sha.",
+	)
+
+	// endRev contains any valid git object where the release note generation
+	// ends. Can be used as alternative to start-sha.
+	subcommand.PersistentFlags().StringVar(
+		&opts.EndRev,
+		"end-rev",
+		env.Default("END_REV", ""),
+		"The git revision to end at. Can be used as alternative to end-sha.",
+	)
+
+	// repoPath contains the path to a local Kubernetes repository to avoid the
+	// delay during git clone
+	subcommand.PersistentFlags().StringVar(
+		&opts.RepoPath,
+		"repo-path",
+		env.Default("REPO_PATH", filepath.Join(os.TempDir(), "k8s-repo")),
+		"Path to a local Kubernetes repository, used only for tag discovery.",
+	)
+
+	// format is the output format to produce the notes in.
+	subcommand.PersistentFlags().StringVar(
+		&opts.Format,
+		"format",
+		env.Default("FORMAT", options.FormatMarkdown),
+		fmt.Sprintf("The format for notes output (options: %s)",
+			options.FormatJSON+", "+options.FormatMarkdown,
+		),
+	)
+
+	// go-template is the go template to be used when the format is markdown
+	subcommand.PersistentFlags().StringVar(
+		&opts.GoTemplate,
+		"go-template",
+		env.Default("GO_TEMPLATE", options.GoTemplateDefault),
+		fmt.Sprintf("The go template to be used if --format=markdown (options: %s)",
+			strings.Join([]string{
+				options.GoTemplateDefault,
+				options.GoTemplateInline + "<template>",
+				options.GoTemplatePrefix + "<file.template>",
+			}, ", "),
+		),
+	)
+
+	subcommand.PersistentFlags().BoolVar(
+		&opts.AddMarkdownLinks,
+		"markdown-links",
+		env.IsSet("MARKDOWN_LINKS"),
+		"Add links for PRs and authors are added in the markdown format",
+	)
+
+	subcommand.PersistentFlags().StringVar(
+		&opts.RequiredAuthor,
+		"required-author",
+		env.Default("REQUIRED_AUTHOR", "k8s-ci-robot"),
+		"Only commits from this GitHub user are considered. Set to empty string to include all users",
+	)
+
+	subcommand.PersistentFlags().BoolVar(
+		&opts.Debug,
+		"debug",
+		env.IsSet("DEBUG"),
+		"Enable debug logging",
+	)
+
+	subcommand.PersistentFlags().StringVar(
+		&opts.DiscoverMode,
+		"discover",
+		env.Default("DISCOVER", options.RevisionDiscoveryModeNONE),
+		fmt.Sprintf("The revision discovery mode for automatic revision retrieval (options: %s)",
+			strings.Join([]string{
+				options.RevisionDiscoveryModeNONE,
+				options.RevisionDiscoveryModeMergeBaseToLatest,
+				options.RevisionDiscoveryModePatchToPatch,
+				options.RevisionDiscoveryModeMinorToMinor,
+			}, ", "),
+		),
+	)
+
+	subcommand.PersistentFlags().StringVar(
+		&opts.ReleaseBucket,
+		"release-bucket",
+		env.Default("RELEASE_BUCKET", release.ProductionBucket),
+		"Specify gs bucket to point to in generated notes",
+	)
+
+	subcommand.PersistentFlags().StringVar(
+		&opts.ReleaseTars,
+		"release-tars",
+		env.Default("RELEASE_TARS", ""),
+		"Directory of tars to sha512 sum for display",
+	)
+
+	subcommand.PersistentFlags().BoolVar(
+		&releaseNotesOpts.tableOfContents,
+		"toc",
+		env.IsSet("TOC"),
+		"Enable the rendering of the table of contents",
+	)
+
+	subcommand.PersistentFlags().StringVar(
+		&opts.RecordDir,
+		"record",
+		env.Default("RECORD", ""),
+		"Record the API into a directory",
+	)
+
+	subcommand.PersistentFlags().StringVar(
+		&opts.ReplayDir,
+		"replay",
+		env.Default("REPLAY", ""),
+		"Replay a previously recorded API from a directory",
+	)
+
+	subcommand.PersistentFlags().BoolVar(
+		&releaseNotesOpts.dependencies,
+		"dependencies",
+		true,
+		"Add dependency report",
+	)
+
+	subcommand.PersistentFlags().StringSliceVarP(
+		&opts.MapProviderStrings,
+		"maps-from",
+		"m",
+		[]string{},
+		"specify a location to recursively look for release notes *.y[a]ml file mappings",
+	)
+	subcommand.PersistentFlags().BoolVar(
+		&opts.ListReleaseNotesV2,
+		"list-v2",
+		false,
+		"enable experimental implementation to list commits (ListReleaseNotesV2)",
+	)
+}
+
+// addGenerate adds the generate subcomand to the main release notes cobra cmd.
+func addGenerate(parent *cobra.Command) {
+	// Create the cobra command
+	generateCmd := &cobra.Command{
+		Short:         "Generate release notes from GitHub pull request data (default)",
+		Use:           "generate",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			releaseNotes, err := notes.GatherReleaseNotes(opts)
+			if err != nil {
+				return fmt.Errorf("gathering release notes: %w", err)
+			}
+
+			return WriteReleaseNotes(releaseNotes)
+		},
+		PreRunE: func(*cobra.Command, []string) error {
+			return opts.ValidateAndFinish()
+		},
+	}
+
+	addGenerateFlags(generateCmd)
+	parent.AddCommand(generateCmd)
+}
diff --git a/cmd/release-notes/main.go b/cmd/release-notes/main.go
index 51f7470f..3d49f7ae 100644
--- a/cmd/release-notes/main.go
+++ b/cmd/release-notes/main.go
@@ -21,7 +21,6 @@ import (
 	"fmt"
 	"io"
 	"os"
-	"path/filepath"
 	"strings"
 
 	"github.com/sirupsen/logrus"
@@ -30,10 +29,8 @@ import (
 	"k8s.io/release/pkg/notes"
 	"k8s.io/release/pkg/notes/document"
 	"k8s.io/release/pkg/notes/options"
-	"k8s.io/release/pkg/release"
 	"sigs.k8s.io/mdtoc/pkg/mdtoc"
 	"sigs.k8s.io/release-sdk/git"
-	"sigs.k8s.io/release-utils/env"
 	"sigs.k8s.io/release-utils/log"
 )
 
@@ -46,228 +43,8 @@ type releaseNotesOptions struct {
 var (
 	releaseNotesOpts = &releaseNotesOptions{}
 	opts             = options.New()
-	cmd              = &cobra.Command{
-		Short:         "release-notes - The Kubernetes Release Notes Generator",
-		Use:           "release-notes",
-		SilenceUsage:  true,
-		SilenceErrors: true,
-		RunE:          run,
-		PreRunE: func(*cobra.Command, []string) error {
-			return opts.ValidateAndFinish()
-		},
-	}
 )
 
-func init() {
-	// githubBaseURL contains the github base URL.
-	cmd.PersistentFlags().StringVar(
-		&opts.GithubBaseURL,
-		"github-base-url",
-		env.Default("GITHUB_BASE_URL", ""),
-		"Base URL of github",
-	)
-
-	// githubUploadURL contains the github upload URL.
-	cmd.PersistentFlags().StringVar(
-		&opts.GithubUploadURL,
-		"github-upload-url",
-		env.Default("GITHUB_UPLOAD_URL", ""),
-		"Upload URL of github",
-	)
-
-	// githubOrg contains name of github organization that holds the repo to scrape.
-	cmd.PersistentFlags().StringVar(
-		&opts.GithubOrg,
-		"org",
-		env.Default("ORG", notes.DefaultOrg),
-		"Name of github organization",
-	)
-
-	// githubRepo contains name of github repository to scrape.
-	cmd.PersistentFlags().StringVar(
-		&opts.GithubRepo,
-		"repo",
-		env.Default("REPO", notes.DefaultRepo),
-		"Name of github repository",
-	)
-
-	// output contains the path on the filesystem to where the resultant
-	// release notes should be printed.
-	cmd.PersistentFlags().StringVar(
-		&releaseNotesOpts.outputFile,
-		"output",
-		env.Default("OUTPUT", ""),
-		"The path to the where the release notes will be printed",
-	)
-
-	// branch is which branch to scrape.
-	cmd.PersistentFlags().StringVar(
-		&opts.Branch,
-		"branch",
-		env.Default("BRANCH", git.DefaultBranch),
-		fmt.Sprintf("Select which branch to scrape. Defaults to `%s`", git.DefaultBranch),
-	)
-
-	// startSHA contains the commit SHA where the release note generation
-	// begins.
-	cmd.PersistentFlags().StringVar(
-		&opts.StartSHA,
-		"start-sha",
-		env.Default("START_SHA", ""),
-		"The commit hash to start at",
-	)
-
-	// endSHA contains the commit SHA where the release note generation ends.
-	cmd.PersistentFlags().StringVar(
-		&opts.EndSHA,
-		"end-sha",
-		env.Default("END_SHA", ""),
-		"The commit hash to end at",
-	)
-
-	// startRev contains any valid git object where the release note generation
-	// begins. Can be used as alternative to start-sha.
-	cmd.PersistentFlags().StringVar(
-		&opts.StartRev,
-		"start-rev",
-		env.Default("START_REV", ""),
-		"The git revision to start at. Can be used as alternative to start-sha.",
-	)
-
-	// endRev contains any valid git object where the release note generation
-	// ends. Can be used as alternative to start-sha.
-	cmd.PersistentFlags().StringVar(
-		&opts.EndRev,
-		"end-rev",
-		env.Default("END_REV", ""),
-		"The git revision to end at. Can be used as alternative to end-sha.",
-	)
-
-	// repoPath contains the path to a local Kubernetes repository to avoid the
-	// delay during git clone
-	cmd.PersistentFlags().StringVar(
-		&opts.RepoPath,
-		"repo-path",
-		env.Default("REPO_PATH", filepath.Join(os.TempDir(), "k8s-repo")),
-		"Path to a local Kubernetes repository, used only for tag discovery.",
-	)
-
-	// format is the output format to produce the notes in.
-	cmd.PersistentFlags().StringVar(
-		&opts.Format,
-		"format",
-		env.Default("FORMAT", options.FormatMarkdown),
-		fmt.Sprintf("The format for notes output (options: %s)",
-			options.FormatJSON+", "+options.FormatMarkdown,
-		),
-	)
-
-	// go-template is the go template to be used when the format is markdown
-	cmd.PersistentFlags().StringVar(
-		&opts.GoTemplate,
-		"go-template",
-		env.Default("GO_TEMPLATE", options.GoTemplateDefault),
-		fmt.Sprintf("The go template to be used if --format=markdown (options: %s)",
-			strings.Join([]string{
-				options.GoTemplateDefault,
-				options.GoTemplateInline + "<template>",
-				options.GoTemplatePrefix + "<file.template>",
-			}, ", "),
-		),
-	)
-
-	cmd.PersistentFlags().BoolVar(
-		&opts.AddMarkdownLinks,
-		"markdown-links",
-		env.IsSet("MARKDOWN_LINKS"),
-		"Add links for PRs and authors are added in the markdown format",
-	)
-
-	cmd.PersistentFlags().StringVar(
-		&opts.RequiredAuthor,
-		"required-author",
-		env.Default("REQUIRED_AUTHOR", "k8s-ci-robot"),
-		"Only commits from this GitHub user are considered. Set to empty string to include all users",
-	)
-
-	cmd.PersistentFlags().BoolVar(
-		&opts.Debug,
-		"debug",
-		env.IsSet("DEBUG"),
-		"Enable debug logging",
-	)
-
-	cmd.PersistentFlags().StringVar(
-		&opts.DiscoverMode,
-		"discover",
-		env.Default("DISCOVER", options.RevisionDiscoveryModeNONE),
-		fmt.Sprintf("The revision discovery mode for automatic revision retrieval (options: %s)",
-			strings.Join([]string{
-				options.RevisionDiscoveryModeNONE,
-				options.RevisionDiscoveryModeMergeBaseToLatest,
-				options.RevisionDiscoveryModePatchToPatch,
-				options.RevisionDiscoveryModeMinorToMinor,
-			}, ", "),
-		),
-	)
-
-	cmd.PersistentFlags().StringVar(
-		&opts.ReleaseBucket,
-		"release-bucket",
-		env.Default("RELEASE_BUCKET", release.ProductionBucket),
-		"Specify gs bucket to point to in generated notes",
-	)
-
-	cmd.PersistentFlags().StringVar(
-		&opts.ReleaseTars,
-		"release-tars",
-		env.Default("RELEASE_TARS", ""),
-		"Directory of tars to sha512 sum for display",
-	)
-
-	cmd.PersistentFlags().BoolVar(
-		&releaseNotesOpts.tableOfContents,
-		"toc",
-		env.IsSet("TOC"),
-		"Enable the rendering of the table of contents",
-	)
-
-	cmd.PersistentFlags().StringVar(
-		&opts.RecordDir,
-		"record",
-		env.Default("RECORD", ""),
-		"Record the API into a directory",
-	)
-
-	cmd.PersistentFlags().StringVar(
-		&opts.ReplayDir,
-		"replay",
-		env.Default("REPLAY", ""),
-		"Replay a previously recorded API from a directory",
-	)
-
-	cmd.PersistentFlags().BoolVar(
-		&releaseNotesOpts.dependencies,
-		"dependencies",
-		true,
-		"Add dependency report",
-	)
-
-	cmd.PersistentFlags().StringSliceVarP(
-		&opts.MapProviderStrings,
-		"maps-from",
-		"m",
-		[]string{},
-		"specify a location to recursively look for release notes *.y[a]ml file mappings",
-	)
-	cmd.PersistentFlags().BoolVar(
-		&opts.ListReleaseNotesV2,
-		"list-v2",
-		false,
-		"enable experimental implementation to list commits (ListReleaseNotesV2)",
-	)
-}
-
 func WriteReleaseNotes(releaseNotes *notes.ReleaseNotes) (err error) {
 	logrus.Infof(
 		"Got %d release notes, performing rendering",
@@ -374,18 +151,44 @@ func WriteReleaseNotes(releaseNotes *notes.ReleaseNotes) (err error) {
 	return nil
 }
 
-func run(*cobra.Command, []string) error {
-	releaseNotes, err := notes.GatherReleaseNotes(opts)
-	if err != nil {
-		return fmt.Errorf("gathering release notes: %w", err)
+// hackDefaultSubcommand is a utility function that hacks the "generate"
+// subcommand as default to avoid breaking compatibility with previoud
+// versions of release-notes.
+func hackDefaultSubcommand(cmd *cobra.Command) {
+	if len(os.Args) > 1 {
+		// We accept --version and "completion"
+		if os.Args[1] == "completion" || os.Args[1] == "--version" || os.Args[1] == "--help" {
+			return
+		}
+
+		// Check if the first arg corresponds to a registered subcommand
+		for _, command := range cmd.Commands() {
+			if command.Use == os.Args[1] {
+				return
+			}
+		}
 	}
 
-	return WriteReleaseNotes(releaseNotes)
+	logrus.Warn("No subcommand specified, running \"generate\" ")
+	os.Args = append([]string{os.Args[0], "generate"}, os.Args[1:]...)
 }
 
 func main() {
 	logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true})
 	logrus.AddHook(log.NewFilenameHook())
+
+	cmd := &cobra.Command{
+		Short:         "release-notes - The Kubernetes Release Notes Generator",
+		Use:           "release-notes",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+	}
+
+	addGenerate(cmd)
+	addCheckPR(cmd)
+
+	hackDefaultSubcommand(cmd)
+
 	if err := cmd.Execute(); err != nil {
 		logrus.Fatal(err)
 	}
diff --git a/pkg/notes/notes.go b/pkg/notes/notes.go
index e388113b..98ca69de 100644
--- a/pkg/notes/notes.go
+++ b/pkg/notes/notes.go
@@ -701,6 +701,68 @@ func (g *Gatherer) gatherNotes(commits []*gogithub.RepositoryCommit) (filtered [
 	return allResults.List(), nil
 }
 
+// ReleaseNoteForPullRequest returns a release note from a pull request number.
+// If the release note is blank or
+func (g *Gatherer) ReleaseNoteForPullRequest(prNr int) (*ReleaseNote, error) {
+	pr, _, err := g.client.GetPullRequest(g.context, g.options.GithubOrg, g.options.GithubRepo, prNr)
+	if err != nil {
+		return nil, fmt.Errorf("reading PR from GitHub: %w", err)
+	}
+	prBody := pr.GetBody()
+
+	// This will be true when the release note is NONE or the flag is set
+	var doNotPublish bool
+
+	// If we match exclusion filter (release-note-none), we don't look further,
+	// instead we return that PR. We return that PR because it might have a map,
+	// which is checked after this function returns
+	if MatchesExcludeFilter(prBody) || len(labelsWithPrefix(pr, "release-note-none")) > 0 {
+		doNotPublish = true
+	}
+
+	// If we didn't match the exclusion filter, try to extract the release note from the PR.
+	// If we can't extract the release note, consider that the PR is invalid and take the next one
+	s, err := noteTextFromString(prBody)
+	if err != nil && !doNotPublish {
+		return nil, fmt.Errorf("PR #%d does not seem to contain a valid release note: %w", pr.GetNumber(), err)
+	}
+
+	// If we found a valid release note, return the PR, otherwise, take the next one
+	if s == "" && !doNotPublish {
+		return nil, fmt.Errorf("PR #%d does not seem to contain a valid release note", pr.GetNumber())
+	}
+
+	if doNotPublish {
+		s = ""
+	}
+
+	// Create the release notes object
+	note := &ReleaseNote{
+		Text:           s,
+		Markdown:       s,
+		Documentation:  []*Documentation{},
+		Author:         *pr.GetUser().Login,
+		AuthorURL:      fmt.Sprintf("%s%s", g.options.GithubBaseURL, *pr.GetUser().Login),
+		PrURL:          fmt.Sprintf("%s%s/%s/pull/%d", g.options.GithubBaseURL, g.options.GithubOrg, g.options.GithubRepo, prNr),
+		PrNumber:       prNr,
+		SIGs:           labelsWithPrefix(pr, "sig"),
+		Kinds:          labelsWithPrefix(pr, "kind"),
+		Areas:          labelsWithPrefix(pr, "area"),
+		Feature:        false,
+		Duplicate:      false,
+		DuplicateKind:  false,
+		ActionRequired: false,
+		DoNotPublish:   doNotPublish,
+		DataFields:     map[string]ReleaseNotesDataField{},
+	}
+
+	if s != "" {
+		logrus.Infof("PR #%d seems to contain a release note", pr.GetNumber())
+	}
+
+	return note, nil
+}
+
 func (g *Gatherer) notesForCommit(commit *gogithub.RepositoryCommit) (*Result, error) {
 	prs, err := g.prsFromCommit(commit)
 	if err != nil {


Test patch:
diff --git a/cmd/release-notes/check_test.go b/cmd/release-notes/check_test.go
new file mode 100644
index 00000000..f55de3de
--- /dev/null
+++ b/cmd/release-notes/check_test.go
@@ -0,0 +1,96 @@
+/*
+Copyright 2023 The Kubernetes 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 (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"k8s.io/release/pkg/notes/options"
+)
+
+func TestPROptsValidateAndFinish(t *testing.T) {
+	testOrg := "testOrg"
+	testRepo := "testRepo"
+	for _, tc := range []struct {
+		name    string
+		sut     checkPROptions
+		mustErr bool
+	}{
+		{
+			name: "good options",
+			sut: checkPROptions{
+				Options: options.Options{
+					GithubOrg:  testOrg,
+					GithubRepo: testRepo,
+				},
+				PullRequests: []int{1, 2},
+			},
+			mustErr: false,
+		},
+		{
+			name: "missing repo",
+			sut: checkPROptions{
+				Options: options.Options{
+					GithubOrg: testOrg,
+				},
+				PullRequests: []int{1, 2},
+			},
+			mustErr: true,
+		},
+		{
+			name: "missing org",
+			sut: checkPROptions{
+				Options: options.Options{
+					GithubRepo: testRepo,
+				},
+				PullRequests: []int{1, 2},
+			},
+			mustErr: true,
+		},
+		{
+			name: "missing PRs",
+			sut: checkPROptions{
+				Options: options.Options{
+					GithubOrg:  testOrg,
+					GithubRepo: testRepo,
+				},
+				PullRequests: []int{},
+			},
+			mustErr: true,
+		},
+		{
+			name: "invalid PR",
+			sut: checkPROptions{
+				Options: options.Options{
+					GithubOrg:  testOrg,
+					GithubRepo: testRepo,
+				},
+				PullRequests: []int{0},
+			},
+			mustErr: true,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.mustErr {
+				require.Error(t, tc.sut.ValidateAndFinish())
+			} else {
+				require.NoError(t, tc.sut.ValidateAndFinish())
+			}
+		})
+	}
+}
diff --git a/pkg/notes/notes_test.go b/pkg/notes/notes_test.go
index 5d84d2db..58784bb1 100644
--- a/pkg/notes/notes_test.go
+++ b/pkg/notes/notes_test.go
@@ -25,6 +25,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/require"
+	"k8s.io/release/pkg/notes/options"
 
 	kgithub "sigs.k8s.io/release-sdk/github"
 )
@@ -502,3 +503,59 @@ func TestCapitalizeString(t *testing.T) {
 		require.Equal(t, tc.expected, result)
 	}
 }
+
+func TestReleaseNoteForPullRequest(t *testing.T) {
+	g, err := NewGatherer(context.Background(), &options.Options{
+		GithubBaseURL: kgithub.GitHubURL,
+		GithubOrg:     DefaultOrg,
+		GithubRepo:    "release",
+	})
+	require.NoError(t, err)
+	for _, tc := range []struct {
+		name         string
+		prNr         int
+		expectedNote string
+		notPublish   bool
+		shouldErr    bool
+	}{
+		{
+			name:         "Normal Release Note",
+			prNr:         3378,
+			expectedNote: "Fixed wrong amount of logger steps for `krel obs`.",
+			notPublish:   false,
+			shouldErr:    false,
+		},
+		{
+			name:         "tagged release-note-none",
+			prNr:         3398,
+			expectedNote: "",
+			notPublish:   true,
+			shouldErr:    false,
+		},
+		{
+			name:       "NONE release note",
+			prNr:       3401,
+			notPublish: true,
+			shouldErr:  false,
+		},
+		{
+			name:      "Not a PR",
+			prNr:      3403,
+			shouldErr: true,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			note, err := g.ReleaseNoteForPullRequest(tc.prNr)
+			if tc.shouldErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.NotNil(t, note)
+			if tc.notPublish {
+				require.True(t, note.DoNotPublish)
+			}
+			require.Equal(t, tc.expectedNote, note.Markdown)
+		})
+	}
+}