diff --git a/tools/pipeline/go.mod b/tools/pipeline/go.mod index 229ed2ad7a..ee54a3c7a4 100644 --- a/tools/pipeline/go.mod +++ b/tools/pipeline/go.mod @@ -11,6 +11,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/veqryn/slog-context v0.7.0 + github.com/zclconf/go-cty v1.16.2 ) require ( @@ -56,7 +57,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/zclconf/go-cty v1.16.2 // indirect go.mongodb.org/mongo-driver v1.17.3 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect diff --git a/tools/pipeline/internal/cmd/github.go b/tools/pipeline/internal/cmd/github.go index c970f08b4c..86f05958e3 100644 --- a/tools/pipeline/internal/cmd/github.go +++ b/tools/pipeline/internal/cmd/github.go @@ -31,14 +31,6 @@ func newGithubCmd() *cobra.Command { Long: "Github commands", } github.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - if parent := cmd.Parent(); parent != nil { - if parent.PersistentPreRunE != nil { - err := parent.PersistentPreRunE(parent, args) - if err != nil { - return err - } - } - } if token, set := os.LookupEnv("GITHUB_TOKEN"); set { githubCmdState.Github = githubCmdState.Github.WithAuthToken(token) } else { @@ -46,6 +38,7 @@ func newGithubCmd() *cobra.Command { } return nil } + github.AddCommand(newGithubCreateCmd()) github.AddCommand(newGithubListCmd()) return github diff --git a/tools/pipeline/internal/cmd/github_create.go b/tools/pipeline/internal/cmd/github_create.go new file mode 100644 index 0000000000..cced53dac5 --- /dev/null +++ b/tools/pipeline/internal/cmd/github_create.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newGithubCreateCmd() *cobra.Command { + create := &cobra.Command{ + Use: "create", + Short: "Github create commands", + Long: "Github create commands", + } + create.AddCommand(newGithubCreateBackportCmd()) + + return create +} diff --git a/tools/pipeline/internal/cmd/github_create_backport.go b/tools/pipeline/internal/cmd/github_create_backport.go new file mode 100644 index 0000000000..f07c3c9945 --- /dev/null +++ b/tools/pipeline/internal/cmd/github_create_backport.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + + "github.com/hashicorp/vault/tools/pipeline/internal/pkg/changed" + "github.com/hashicorp/vault/tools/pipeline/internal/pkg/github" + "github.com/spf13/cobra" +) + +var createGithubBackportState struct { + req github.CreateBackportReq + ceExclude []string + ceAllowInactive []string +} + +func newGithubCreateBackportCmd() *cobra.Command { + listRuns := &cobra.Command{ + Use: "backport 1234", + Short: "Create a backport pull request from another pull request", + Long: "Create a backport pull request from another pull request", + RunE: runCreateGithubBackportCmd, + Args: func(cmd *cobra.Command, args []string) error { + switch len(args) { + case 1: + pr, err := strconv.ParseUint(args[0], 10, 0) + if err != nil { + return fmt.Errorf("invalid pull number: %s: %w", args[0], err) + } + if pr <= math.MaxUint32 { + createGithubBackportState.req.PullNumber = uint(pr) + } else { + return fmt.Errorf("invalid pull number: %s: number is too large", args[0]) + } + return nil + case 0: + return errors.New("no pull request number has been provided") + default: + return fmt.Errorf("invalid arguments: only pull request number is expected, received %d arguments: %v", len(args), args) + } + }, + } + + listRuns.PersistentFlags().StringSliceVarP(&createGithubBackportState.ceAllowInactive, "ce-allow-inactive-groups", "a", []string{"docs", "changelog", "pipeline"}, "Change file groups that should be allowed to backport to inactive CE branches") + listRuns.PersistentFlags().StringVar(&createGithubBackportState.req.CEBranchPrefix, "ce-branch-prefix", "ce", "The branch name prefix") + listRuns.PersistentFlags().StringSliceVarP(&createGithubBackportState.ceExclude, "ce-exclude-groups", "e", []string{"enterprise"}, "Change file groups that should be excluded from the backporting to CE branches") + listRuns.PersistentFlags().StringVar(&createGithubBackportState.req.BaseOrigin, "base-origin", "origin", "The name to use for the base remote origin") + listRuns.PersistentFlags().StringVarP(&createGithubBackportState.req.Owner, "owner", "o", "hashicorp", "The Github organization") + listRuns.PersistentFlags().StringVarP(&createGithubBackportState.req.Repo, "repo", "r", "vault-enterprise", "The Github repository. Private repositories require auth via a GITHUB_TOKEN env var") + listRuns.PersistentFlags().StringVarP(&createGithubBackportState.req.RepoDir, "repo-dir", "d", "", "The path to the vault repository dir. If not set a temporary directory will be used") + listRuns.PersistentFlags().StringVarP(&createGithubBackportState.req.ReleaseVersionConfigPath, "releases-version-path", "m", "", "The path to .release/versions.hcl") + listRuns.PersistentFlags().UintVar(&createGithubBackportState.req.ReleaseRecurseDepth, "recurse", 3, "If no path to a config file is given, recursively search backwards for it and stop at root or until we've his the configured depth.") + + // NOTE: The following are technically flags but they only for testing testing + // the command before we cut over to new utility. + listRuns.PersistentFlags().StringVar(&createGithubBackportState.req.EntBranchPrefix, "ent-branch-prefix", "", "The ent branch name prefix. Only used for testing before migration to the new workflow") + listRuns.PersistentFlags().StringVar(&createGithubBackportState.req.BackportLabelPrefix, "backport-label-prefix", "backport", "The name to use for the base remote origin") + + err := listRuns.PersistentFlags().MarkHidden("ent-branch-prefix") + if err != nil { + panic(err) + } + + err = listRuns.PersistentFlags().MarkHidden("backport-label-prefix") + if err != nil { + panic(err) + } + + return listRuns +} + +func runCreateGithubBackportCmd(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true // Don't spam the usage on failure + + for i, ig := range createGithubBackportState.ceAllowInactive { + if i == 0 && createGithubBackportState.req.CEAllowInactiveGroups == nil { + createGithubBackportState.req.CEAllowInactiveGroups = changed.FileGroups{} + } + createGithubBackportState.req.CEAllowInactiveGroups = createGithubBackportState.req.CEAllowInactiveGroups.Add(changed.FileGroup(ig)) + } + + for i, eg := range createGithubBackportState.ceExclude { + if i == 0 && createGithubBackportState.req.CEExclude == nil { + createGithubBackportState.req.CEExclude = changed.FileGroups{} + } + createGithubBackportState.req.CEExclude = createGithubBackportState.req.CEExclude.Add(changed.FileGroup(eg)) + } + + res := createGithubBackportState.req.Run(context.TODO(), githubCmdState.Github, githubCmdState.Git) + if res == nil { + res = &github.CreateBackportRes{} + } + if err := res.Err(); err != nil { + res.ErrorMessage = err.Error() + } + + switch rootCfg.format { + case "json": + b, err := res.ToJSON() + if err != nil { + return errors.Join(res.Err(), err) + } + fmt.Println(string(b)) + default: + fmt.Println(res.ToTable().Render()) + } + + return res.Err() +} diff --git a/tools/pipeline/internal/cmd/root.go b/tools/pipeline/internal/cmd/root.go index 34ab042fa1..0e34123be0 100644 --- a/tools/pipeline/internal/cmd/root.go +++ b/tools/pipeline/internal/cmd/root.go @@ -64,6 +64,7 @@ func newRootCmd() *cobra.Command { // Execute executes the root pipeline command. func Execute() { + cobra.EnableTraverseRunHooks = true // Automatically chain run hooks rootCmd := newRootCmd() rootCmd.SilenceErrors = true // We handle this below diff --git a/tools/pipeline/internal/pkg/git/am.go b/tools/pipeline/internal/pkg/git/am.go new file mode 100644 index 0000000000..dfcf2bd9e0 --- /dev/null +++ b/tools/pipeline/internal/pkg/git/am.go @@ -0,0 +1,135 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package git + +import ( + "context" + "fmt" + "strings" +) + +// AmOpts are the git am flags and arguments +// See: https://git-scm.com/docs/git-am +type AmOpts struct { + // Options + CommitterDateIsAuthorDate bool // --committer-date-is-author-date + Empty EmptyCommit // --empty= + Keep bool // --keep + KeepNonPatch bool // --keep-non-patch + MessageID bool // --message-id + NoMessageID bool // --no-message-id + NoReReReAutoupdate bool // --no-rerere-autoupdate + NoVerify bool // --no-verify + Quiet bool // --quiet + ReReReAutoupdate bool // --rerere-autoupdate + Signoff bool // --signoff + ThreeWayMerge bool // --3way + Whitespace ApplyWhitespaceAction // --whitespace= + + // Targets, depending on which combination of options you're setting + Mbox []string // + + // Sequences + Abort bool // --abort + Continue bool // --continue + Quit bool // --quit + Resolved bool // --resolved + Retry bool // --retry + + // Options that are allowed on sequences + AllowEmpty bool // --allow-empty +} + +// Am runs the git am command +func (c *Client) Am(ctx context.Context, opts *AmOpts) (*ExecResponse, error) { + return c.Exec(ctx, "am", opts) +} + +// String returns the options as a string +func (o *AmOpts) String() string { + return strings.Join(o.Strings(), " ") +} + +// Strings returns the options as a string slice +func (o *AmOpts) Strings() []string { + if o == nil { + return nil + } + + opts := []string{} + + switch { + case o.Abort: + return append(opts, "--abort") + case o.Continue: + return append(opts, "--continue") + case o.Quit: + return append(opts, "--quit") + case o.Resolved: + if o.AllowEmpty { + opts = append(opts, "--allow-empty") + } + return append(opts, "--resolved") + case o.Retry: + return append(opts, "--retry") + } + + if o.CommitterDateIsAuthorDate { + opts = append(opts, "--committer-date-is-author-date") + } + + if o.Empty != "" { + opts = append(opts, fmt.Sprintf("--empty=%s", string(o.Empty))) + } + + if o.Keep { + opts = append(opts, "--keep") + } + + if o.KeepNonPatch { + opts = append(opts, "--keep-non-patch") + } + + if o.MessageID { + opts = append(opts, "--message-id") + } + + if o.NoMessageID { + opts = append(opts, "--no-message-id") + } + + if o.NoReReReAutoupdate { + opts = append(opts, "--no-rerere-autoupdate") + } + + if o.NoVerify { + opts = append(opts, "--no-verify") + } + + if o.Quiet { + opts = append(opts, "--quiet") + } + + if o.ReReReAutoupdate { + opts = append(opts, "--rerere-autoupdate") + } + + if o.Signoff { + opts = append(opts, "--signoff") + } + + if o.ThreeWayMerge { + opts = append(opts, "--3way") + } + + if o.Whitespace != "" { + opts = append(opts, fmt.Sprintf("--whitespace=%s", string(o.Whitespace))) + } + + if len(o.Mbox) > 0 { + opts = append(opts, o.Mbox...) + } + + return opts +} diff --git a/tools/pipeline/internal/pkg/git/opts_test.go b/tools/pipeline/internal/pkg/git/opts_test.go index 1a383cb2c7..d45f3e4381 100644 --- a/tools/pipeline/internal/pkg/git/opts_test.go +++ b/tools/pipeline/internal/pkg/git/opts_test.go @@ -22,6 +22,77 @@ func TestOptsStringers(t *testing.T) { opts OptStringer expected string }{ + "am": { + &AmOpts{ + AllowEmpty: true, // Only supported for --resolved + CommitterDateIsAuthorDate: true, + Empty: EmptyCommitKeep, + Keep: true, + KeepNonPatch: true, + MessageID: true, + NoMessageID: true, + NoReReReAutoupdate: true, + NoVerify: true, + Quiet: true, + ReReReAutoupdate: true, + Signoff: true, + ThreeWayMerge: true, + Whitespace: ApplyWhitespaceActionFix, + Mbox: []string{"/path/to/my.patch"}, + }, + "--committer-date-is-author-date --empty=keep --keep --keep-non-patch --message-id --no-message-id --no-rerere-autoupdate --no-verify --quiet --rerere-autoupdate --signoff --3way --whitespace=fix /path/to/my.patch", + }, + "am --continue": { + &AmOpts{ + // Unallowed options are ignored + Empty: EmptyCommitKeep, + AllowEmpty: true, + // Sequence + Continue: true, + }, + "--continue", + }, + "am --abort": { + &AmOpts{ + // Unallowed options are ignored + Empty: EmptyCommitKeep, + AllowEmpty: true, + // Sequence + Abort: true, + }, + "--abort", + }, + "am --quit": { + &AmOpts{ + // Unallowed options are ignored + Empty: EmptyCommitKeep, + AllowEmpty: true, + // Sequence + Quit: true, + }, + "--quit", + }, + "am --allow-empty --resolved": { + &AmOpts{ + // Unallowed options are ignored + Empty: EmptyCommitKeep, + // Allowed options are kept + AllowEmpty: true, + // Sequence + Resolved: true, + }, + "--allow-empty --resolved", + }, + "am --retry": { + &AmOpts{ + // Unallowed options are ignored + Empty: EmptyCommitKeep, + AllowEmpty: true, + // Sequence + Retry: true, + }, + "--retry", + }, "apply": { &ApplyOpts{ AllowEmpty: true, diff --git a/tools/pipeline/internal/pkg/github/create_backport.go b/tools/pipeline/internal/pkg/github/create_backport.go new file mode 100644 index 0000000000..1430eddfe8 --- /dev/null +++ b/tools/pipeline/internal/pkg/github/create_backport.go @@ -0,0 +1,1256 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package github + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "text/template" + + "github.com/google/go-github/v68/github" + libgithub "github.com/google/go-github/v68/github" + "github.com/hashicorp/vault/tools/pipeline/internal/pkg/changed" + libgit "github.com/hashicorp/vault/tools/pipeline/internal/pkg/git" + "github.com/hashicorp/vault/tools/pipeline/internal/pkg/releases" + "github.com/jedib0t/go-pretty/v6/table" + slogctx "github.com/veqryn/slog-context" +) + +// CreateBackportReq is a request to create a backport pull request from another +// pull request. The request has been designed to work when triggered in a +// Github Actions workflow where the only required values are present in the +// github event context. That assumes a pull request event: +// +// pull_request_target: +// types: closed +// +// The request ought to be guarded so as to nominally trigger only on merges: +// +// if: github.even.pull_request.merged" +// +// See Run() for more details around how the request determines which branches +// to backport to, whether or not the backport commits need to be amended for +// excluded CE files, or whether or not the backport can be skipped entirely. +// +// NOTE: At this time the request only supports a single squashed merge commit. +type CreateBackportReq struct { + // The Github Owner. E.g. "hashicorp" + Owner string + // The Github Repo. E.g. "vault-enterprise" + Repo string + // The Pull Request ID Number of the PR that we wish to backport. + PullNumber uint + // BaseOrigin is the name of the remote for the base ref of the pull request. + // E.g. "origin". + BaseOrigin string + + // The local directory where to clone the repository: + // https://github.com//.git. + // If the directory is configured it either must exist. When unset, a + // temporary directory will be created and used automatically. + RepoDir string + + // ReleaseVersionConfigPath is the path to .release/versions.hcl. We use this + // file to determine which branches are active so that we can automatically + // determine which origins to backport depending on the given tags. + ReleaseVersionConfigPath string + // ReleaseRecurseDepth defined how many directories back we're allowed to + // scan to search for .release/versions.hcl. This is incompatible with + // ReleaseVersionConfigPath. + ReleaseRecurseDepth uint + + // CEExclude are changed files groups for files that ought to be excluded + // when creating CE backports. E.g. ["enterprise"] + CEExclude changed.FileGroups + // CEBranchPrefix is the prefix used for CE branches. E.g. "ce" + CEBranchPrefix string + // CEAllowInactiveGroups are changed file groups for files that ought to be + // allowed to be backported to inactive CE branches. Eg. ["docs", "pipeline"] + CEAllowInactiveGroups changed.FileGroups + + // NOTE: The following fields are for testing purposes only and might be + // removed after the cutover to the new workflow. + + // EntBranchPrefix is an ent branch prefix. This is only used for testing + // before we migrate to the tool full time. + EntBranchPrefix string + + // BackportLabelPrefix is the backport label prefix. E.g. "backport". This + // should only be used for testing before the new workflow is active. + BackportLabelPrefix string +} + +// NewCreateBackportReqOpt is a functional option to set fields when calling +// NewCreateBackportPRReq() +type NewCreateBackportReqOpt func(*CreateBackportReq) + +// CreateBackportPRReq is a respose of creating a backport pull request +type CreateBackportRes struct { + OriginPullRequest *libgithub.PullRequest `json:"origin_pull_request,omitempty"` + Branch string `json:"branch,omitempty"` + Attempts map[string]*CreateBackportAttempt `json:"attempts,omitempty"` + Comment *libgithub.IssueComment `json:"comment,omitempty"` + Error error `json:"-"` + // Use a separate field so we marshal the error message to a string value + ErrorMessage string `json:"error,omitempty"` +} + +// Labels are just a collection of github labels that we have created various +// helper functions for. +type Labels []*libgithub.Label + +// CreateBackportAttempt is an attempt at creating a backport for target +// branch reference. +type CreateBackportAttempt struct { + BaseRef string `json:"base_ref,omitempty"` + TargetRef string `json:"target_ref,omitempty"` + Error error `json:"error,omitempty"` + Skipped bool `json:"skipped,omitempty"` + SkippedReason string `json:"skipped_reason,omitempty"` + PullRequest *libgithub.PullRequest `json:"pull_request,omitempty"` +} + +// NewCreateBackportReq takes variable options and returns a new +// CreateBackportPRReq. +func NewCreateBackportReq(opts ...NewCreateBackportReqOpt) *CreateBackportReq { + req := &CreateBackportReq{ + Owner: "hashicorp", + Repo: "vault-enterprise", + ReleaseRecurseDepth: 3, + CEExclude: changed.FileGroups{changed.FileGroupEnterprise}, + CEBranchPrefix: "ce", + CEAllowInactiveGroups: changed.FileGroups{ + changed.FileGroupChangelog, + changed.FileGroupDocs, + changed.FileGroupPipeline, + }, + BaseOrigin: "origin", + BackportLabelPrefix: "backport", + } + + for _, opt := range opts { + opt(req) + } + + return req +} + +// WithCreateBackportReqOwner sets the Owner +func WithCreateBackportReqOwner(owner string) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.Owner = owner + } +} + +// WithCreateBrackportReqRepo sets the Repo +func WithCreateBrackportReqRepo(repo string) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.Repo = repo + } +} + +// WithCreateBrackportReqRepoDir sets the RepoDir +func WithCreateBrackportReqRepoDir(dir string) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.RepoDir = dir + } +} + +// WithCreateBrackportReqPullNumber sets the PullNumber +func WithCreateBrackportReqPullNumber(number uint) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.PullNumber = number + } +} + +// WithCreateBrackportReqBaseOrigin sets the BaseOrigin +func WithCreateBrackportReqBaseOrigin(origin string) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.BaseOrigin = origin + } +} + +// WithCreateBrackportReqReleaseRecurseDepth sets the ReleaseRecurseDepth +func WithCreateBrackportReqReleaseRecurseDepth(depth uint) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.ReleaseRecurseDepth = depth + } +} + +// WithCreateBrackportReqCEExclude sets the CEExclude +func WithCreateBrackportReqCEExclude(exclude changed.FileGroups) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.CEExclude = exclude + } +} + +// WithCreateBrackportReqCEBranchPrefix sets the CEBranchPrefix +func WithCreateBrackportReqCEBranchPrefix(prefix string) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.CEBranchPrefix = prefix + } +} + +// WithCreateBrackportReqAllowInactiveGroups sets the CEAllowInactiveGroups +func WithCreateBrackportReqAllowInactiveGroups(groups changed.FileGroups) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.CEAllowInactiveGroups = groups + } +} + +// WithCreateBrackportReqEntBranchPrefix sets the EntBranchPrefix +func WithCreateBrackportReqEntBranchPrefix(prefix string) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.EntBranchPrefix = prefix + } +} + +// WithCreateBrackportReqBackportLabelPrefix sets the BackportLabelPrefix +func WithCreateBrackportReqBackportLabelPrefix(prefix string) NewCreateBackportReqOpt { + return func(req *CreateBackportReq) { + req.BackportLabelPrefix = prefix + } +} + +// Run runs the backport request to create backports for every target branch +// as needed. +// +// If the base references is to an enteprise branch, that is, the base reference +// branch does not contain the CEBranchPrefix, then a backport to the +// corresponding CE branch is assumed and will be created. +// +// If the base reference is to a CE branch then backports are only created if +// there are backport labels present. +// +// Backport labels should be listed in the same schema as .release/versions.hcl: +// E.g. "release/1.19.x". The correct backport branches will be used depending +// on whether or not base branch of the PR is enteprise or CE. +// +// Enterprise branches will only ever backport to the corresponding ce branch +// and to other enterprise branches. When those enterprise branches are merged +// we'll create the CE backports. +// +// There are many factors to conside when backporting to a CE branch. The +// request will automatically inspect the changed files of a PR to determine +// if the PR contains non-enterprise files that need to be backported. In the +// event we've only changed enterprise files we'll skip the CE backport. +// If we've changed both enterprise and non-enterprise files the backport will +// automatically remove the enterprise files. +// +// We also factor in whether or not a CE branch is "active". If the branch is +// inactive we'll skip backporting unless the change includes docs, pipeline +// changes, or README changes. This allows docs authors to write docs against +// enteprise branches and have them backported without having to do it manually. +// +// We also do our best to update the source pull request with a comment that +// outlines each backport and its status. +// +// This request designed to always return a response, even if things go wrong. +// We will always attempt to run all backport references even if some fail. +// As such we don't return an error here but do embed them in the response for +// more control and precise handling. Callers should use Err() on the response +// to get a singular error, or they can inspect the Error field for each +// backport attempt. +func (r *CreateBackportReq) Run( + ctx context.Context, + github *libgithub.Client, + git *libgit.Client, +) (res *CreateBackportRes) { + res = &CreateBackportRes{Attempts: map[string]*CreateBackportAttempt{}} + + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("owner", r.Owner), + slog.String("repo", r.Repo), + slog.String("repo-dir", r.RepoDir), + slog.Uint64("pull-number", uint64(r.PullNumber)), + slog.String("base-origin", r.BaseOrigin), + slog.String("config-path", r.ReleaseVersionConfigPath), + slog.Uint64("config-path-recurse-depth", uint64(r.ReleaseRecurseDepth)), + slog.String("ce-branch-prefix", r.CEBranchPrefix), + slog.String("ce-allow-inactive", strings.Join(r.CEAllowInactiveGroups.Groups(), ",")), + slog.String("ce-exclude", strings.Join(r.CEExclude.Groups(), ",")), + slog.String("ent-branch-prefix", r.EntBranchPrefix), + slog.String("backport-label-prefix", r.BackportLabelPrefix), + ), "running create backport pr request") + + initialDir, err := os.Getwd() + if err != nil { + res.Error = fmt.Errorf("getting current working directory: %w", err) + return res + } + + // Whenever possible we try to update base pull request with a status update + // on how the backporting has gone. + defer func() { + // Make sure we return a response even if we fail + if res == nil { + res = &CreateBackportRes{} + } + + // Figure out the comment body. Worst case it ought to be whatever error + // we've returned. + var body string + if res.Error != nil { + body = res.Error.Error() + } + + // Set any known errors on the response before we create a comment, as the + // error will be used in the comment body if present. + res.Error = errors.Join(res.Error, os.Chdir(initialDir)) + body = res.CommentBody() + var err1 error + res.Comment, err1 = r.createPullRequestComment(ctx, github, body) + + // Set our finalized error on our response and also update our returned error + res.Error = errors.Join(res.Error, err1) + }() + + // Make sure we have required and valid fields + res.Error = r.Validate(ctx) + if res.Error != nil { + return res + } + + // Make sure we've been given a valid location for a repo and/or create a + // temporary one + var tmpDir bool + res.Error, tmpDir = r.ensureRepoDir(ctx) + if res.Error != nil { + return res + } + if tmpDir { + defer os.RemoveAll(r.RepoDir) + } + + // Get our pull request details + res.OriginPullRequest, res.Error = r.getPR(ctx, github) + if res.Error != nil { + return res + } + + // Make sure our PR is merged and has a merge SHA + if !res.OriginPullRequest.GetMerged() { + res.Error = errors.New("cannot backport unmerged PR") + return res + } + if res.OriginPullRequest.GetMergeCommitSHA() == "" { + res.Error = errors.New("no merge commit SHA is associated with the PR") + return res + } + + // Determine which CE branches are active. Do this before we change our + // working directory since the path given could be relative to the original + // path. + var activeVersions map[string]*releases.Version + activeVersions, res.Error = r.getActiveVersions(ctx) + if res.Error != nil { + return res + } + + // Clone the remote repository and fetch the base ref, which is the branch our + // pull request was created against. These will change our working directory + // into RepoDir + baseRef := res.OriginPullRequest.GetBase().GetRef() + _, err = os.Stat(filepath.Join(r.RepoDir, ".git")) + if err == nil { + res.Error = r.initializeExistingRepo(ctx, git, baseRef) + } else { + res.Error = r.initializeNewRepo(ctx, git, baseRef) + } + if res.Error != nil { + return res + } + + // Get the list of changed files and determine if our PR modified any files + // in CEExclude. + var changedFiles *ListChangedFilesRes + changedFiles, res.Error = r.getChangedFiles(ctx, github) + if res.Error != nil { + return res + } + + // Determine base references we want to backport and create backports for each + // reference. In cases where the reference starts with the CEBranchPrefix then + // we'll remove any files that are in exclude groups. + for _, ref := range r.determineBackportRefs(ctx, baseRef, res.OriginPullRequest.Labels) { + res.Attempts[ref] = r.backportRef( + ctx, git, github, res.OriginPullRequest, activeVersions, changedFiles, ref, + ) + + if attempt := res.Attempts[ref]; attempt != nil && attempt.Error != nil { + // Something went wrong attempting to backport the reference. Reset our + // repository to ensure that our next attempt does not start in a nasty + // state. + resetRes, err := git.Reset(ctx, &libgit.ResetOpts{ + Mode: libgit.ResetModeHard, + Treeish: fmt.Sprintf("%s/%s", r.BaseOrigin, baseRef), + }) + if err != nil { + res.Error = errors.Join(res.Error, fmt.Errorf( + "resetting repository after failed attempt: %s: %w", resetRes.String(), err), + ) + // If we can't reset the repository there's no point in trying further + // attempts as we must assume something has gone horribly wrong. + break + } + } + } + + return res +} + +// Validate validates the request to ensure that all required fields are present +func (r *CreateBackportReq) Validate(ctx context.Context) error { + if r == nil { + return fmt.Errorf("unitialized") + } + + var err error + defer func() { + if err != nil { + err = fmt.Errorf("validating create backport pr requests: %w", err) + } + }() + + slog.Default().DebugContext(ctx, "validating create backport pr request") + + if r.Owner == "" { + return errors.New("no github organization has been provided") + } + + if r.Repo == "" { + return errors.New("no github repository has been provided") + } + + if r.BaseOrigin == "" { + return errors.New("no base origin has been configued") + } + + if r.PullNumber == 0 { + return errors.New("no pull request number or commit SHA has been provided") + } + + if r.CEBranchPrefix == "" { + return errors.New("no ce branch prefix has been configured") + } + + if r.CEExclude == nil { + return errors.New("ce-exclude has not been initialized") + } + + if r.CEAllowInactiveGroups == nil { + return errors.New("ce inactive-allowed has not been initialized") + } + + if r.BackportLabelPrefix == "" { + return errors.New("no backport label prefix has been configured") + } + + return nil +} + +// AttemptErrors are any potential errors encountered during our backport attempts +func (r *CreateBackportRes) AttemptErrors() []error { + if r == nil || len(r.Attempts) < 1 { + return nil + } + + errs := []error{} + for _, k := range slices.Sorted(maps.Keys(r.Attempts)) { + a := r.Attempts[k] + if a.Error == nil { + continue + } + errs = append(errs, a.Error) + } + + return errs +} + +// CommentBody is the markdown comment body that we'll attempt to set on the +// pull request +func (r *CreateBackportRes) CommentBody() string { + if r == nil { + return "no backport response has been initialized" + } + + t := r.ToTable() + err := r.Err() + if err == nil { + t.SetTitle("Backport workflow completed!") + return t.RenderMarkdown() + } + + if t.Length() == 0 { + // If we don't have any rows in our table then we never made it far enough + // to have attempts. As such, there's no need to render a table so we'll + // just return an error + return "## Backport workflow failed!\n\nError: " + err.Error() + } + + // Render out our table but put the error message in the caption + t.SetTitle("Backport workflow failed!") + if r.Error != nil { + // Set the caption to the top-level error only as any attempt errors are + // nested in the table. + t.SetCaption("Error: " + r.Error.Error()) + } + + return t.RenderMarkdown() +} + +// Err returns a single combined error comprised of any issues that might have +// arisen during Run() but also that of any individual backport attempt. +func (r *CreateBackportRes) Err() error { + if r == nil { + return fmt.Errorf("uninitialized") + } + + return errors.Join(r.Error, errors.Join(r.AttemptErrors()...)) +} + +// ToJSON marshals the response to JSON. +func (r *CreateBackportRes) ToJSON() ([]byte, error) { + b, err := json.Marshal(r) + if err != nil { + return nil, fmt.Errorf("marshaling create backport pr response to JSON: %w", err) + } + + return b, nil +} + +// ToTable marshals the response to a text table. +func (r *CreateBackportRes) ToTable() table.Writer { + t := table.NewWriter() + t.Style().Options.DrawBorder = false + t.Style().Options.SeparateColumns = false + t.Style().Options.SeparateFooter = false + t.Style().Options.SeparateHeader = false + t.Style().Options.SeparateRows = false + t.AppendHeader(table.Row{ + "Base Branch", "Target Branch", "URL", "Skipped Reason", "Error", + }) + + for _, version := range slices.Sorted(maps.Keys(r.Attempts)) { + values := r.Attempts[version] + row := table.Row{values.BaseRef, values.TargetRef} + if values.PullRequest != nil { + row = append(row, values.PullRequest.GetHTMLURL()) + } else { + row = append(row, nil) + } + valErr := "" + if values.Error != nil { + valErr = values.Error.Error() + } + row = append(row, values.SkippedReason, valErr) + + t.AppendRow(row) + } + + t.SuppressEmptyColumns() + t.SuppressTrailingSpaces() + + return t +} + +// createPullRequestComment creates a status comment on the pull request. +func (r *CreateBackportReq) createPullRequestComment( + ctx context.Context, + github *libgithub.Client, + body string, +) (*libgithub.IssueComment, error) { + // Always try and write a comment on the pull request + comment, _, err := github.Issues.CreateComment( + ctx, r.Owner, r.Repo, int(r.PullNumber), &libgithub.IssueComment{ + Body: &body, + }, + ) + if err != nil { + err = fmt.Errorf("creating backport pull request comment: %w", err) + } + + return comment, err +} + +// ensureRepoDir repoDir verifies that the RepoDir exists and is a directory. +// If the RepoDir is unset a temporary directory will be created. A boolean +// is returned which can be used to determine whether or not the RepoDir is +// a temporary directory. +func (r *CreateBackportReq) ensureRepoDir(ctx context.Context) (error, bool) { + slog.Default().DebugContext(ctx, "verifying or creating repository directory") + + if r.RepoDir == "" { + var err error + r.RepoDir, err = os.MkdirTemp("", "pipeline-create-pr") + return err, true + } + + info, err := os.Stat(r.RepoDir) + if err != nil { + return fmt.Errorf("checking repository directory: %w", err), false + } + + if !info.IsDir() { + return errors.New("repo dir must be a directory"), false + } + + return nil, false +} + +// backportBranchNameForRef returns then branch name to use for our backport, +// e.g. ce/backport/1.19.x/my-feature-branch +func (r CreateBackportReq) backportBranchNameForRef( + ref string, + prBranch string, +) string { + name := fmt.Sprintf("backport/%s/%s", ref, prBranch) + if len(name) > 250 { + // Handle Githubs branch name max length + name = name[:250] + } + + return name +} + +func (r *CreateBackportReq) backportRef( + ctx context.Context, + git *libgit.Client, + github *libgithub.Client, + pr *github.PullRequest, + activeVersions map[string]*releases.Version, + changedFiles *ListChangedFilesRes, + ref string, // the full base ref of the branch we're backporting to +) *CreateBackportAttempt { + res := &CreateBackportAttempt{BaseRef: ref} + + baseRefVersion := r.baseRefVersion(ref) + // Get the name of our PR branch. We'll use this in our backport branch names + // to make it easier to find the source. + prBranch := pr.GetHead().GetRef() + // The branch name for our backport, e.g. ce/backport/1.19.x/my-feature-branch + branchName := r.backportBranchNameForRef(ref, prBranch) + res.TargetRef = branchName + commitSHA := pr.GetMergeCommitSHA() + bigCtx := slogctx.Append(ctx, + slog.String("target-base-ref", ref), + slog.String("target-ref-version", baseRefVersion), + slog.String("target-branch", branchName), + slog.String("pr-branch", prBranch), + slog.String("commit-sha", commitSHA), + ) + + if reason, shouldSkip := r.shouldSkipRef( + ctx, baseRefVersion, ref, activeVersions, changedFiles, + ); shouldSkip { + slog.Default().InfoContext(slogctx.Append(bigCtx, + slog.String("base-ref-version", baseRefVersion), + slog.String("target-ref", ref), + slog.String("reason", reason), + ), "skipping backport") + + res.Skipped = true + res.SkippedReason = reason + + return res + } + + slog.Default().DebugContext(bigCtx, "creating backport pull request") + slog.Default().DebugContext(ctx, "fetching backport target branch base ref") + fetchRes, err := git.Fetch(ctx, &libgit.FetchOpts{ + // Fetch the ref but also provide a local tracking branch of the same name + // e.g. "git fetch origin main:main" + Refspec: []string{r.BaseOrigin, fmt.Sprintf("%s:%s", ref, ref)}, + SetUpstream: true, + Porcelain: true, + }) + if err != nil { + res.Error = fmt.Errorf("fetching target branch base ref: %s, %w", fetchRes.String(), err) + return res + } + + slog.Default().DebugContext(ctx, "checking out new backport branch") + checkoutRes, err := git.Checkout(ctx, &libgit.CheckoutOpts{ + NewBranchForceCheckout: branchName, // -B + Branch: ref, + }) + if err != nil { + res.Error = fmt.Errorf("checking out new backport branch: %s: %w", checkoutRes.String(), err) + return res + } + + // Try and backport the commit + if r.hasCEPrefix(ref) && changedFiles.Groups.Any(r.CEExclude) { + // We're backporting enterprise to CE but the commit has files we don't + // want to include. If we try and cherry-pick the commit it will almost + // certainly fail unless the enterprise only file is new. + res.Error = r.backportCECommitWithPatch(ctx, git, pr, changedFiles, commitSHA) + } else { + // We're backporting everything else. Simply cherry-pick the commit. + slog.Default().DebugContext(ctx, "cherry-picking") + cherryPickRes, err := git.CherryPick(ctx, &libgit.CherryPickOpts{ + FF: true, + Empty: libgit.EmptyCommitKeep, + Commit: commitSHA, + Strategy: libgit.MergeStrategyORT, + StrategyOptions: []libgit.MergeStrategyOption{ + libgit.MergeStrategyOptionOurs, + libgit.MergeStrategyOptionIgnoreSpaceChange, + }, + }) + if err != nil { + res.Error = fmt.Errorf("cherry-picking backport merge commit: %s: %w", cherryPickRes.String(), err) + } + } + + // If our backport failed we still want to create a pull request for our + // failed backport. There's still some debate and the validity of this approach + // but our current process for ensuring backports have been merged is auditing + // the open pull requests for a branch. Until that changes we'll need to do + // this. + if res.Error != nil { + resetRes, err := git.Reset(ctx, &libgit.ResetOpts{ + Mode: libgit.ResetModeHard, + Treeish: ref, + }) + if err != nil { + res.Error = errors.Join(res.Error, fmt.Errorf("resetting back to base reference: %s: %w", resetRes.String(), err)) + } + commitRes, err := git.Commit(ctx, &libgit.CommitOpts{ + AllowEmpty: true, + Message: "no-op commit due to failed backport", + NoVerify: true, + NoEdit: true, + }) + if err != nil { + res.Error = errors.Join(res.Error, fmt.Errorf("committing no-op commit: %s: %w", commitRes.String(), err)) + } + } + + pushRes, err := git.Push(ctx, &libgit.PushOpts{ + Repository: r.BaseOrigin, + Refspec: []string{branchName}, + }) + if err != nil { + res.Error = errors.Join(res.Error, fmt.Errorf("pushing backport branch: %s: %w", pushRes.String(), err)) + + // If we didn't successfully push the branch we can't open a PR so it's time + // to return. + return res + } + + prTitle := fmt.Sprintf("Backport %s into %s", pr.GetTitle(), ref) + prBody, err := r.pullRequestBody(pr, res) + if err != nil { + res.Error = fmt.Errorf("creating backport pull request body %w", err) + return res + } + res.PullRequest, _, err = github.PullRequests.Create( + ctx, r.Owner, r.Repo, &libgithub.NewPullRequest{ + Title: &prTitle, + Head: &branchName, + HeadRepo: &r.Repo, + Base: &ref, + Body: &prBody, + }, + ) + if err != nil { + res.Error = fmt.Errorf("creating backport pull request %w", err) + return res + } + + // Assign the pull request to the actor that merged the pull request and/or the + // person(s) that it was assigned to. + assignees := []string{pr.GetAssignee().GetLogin(), pr.GetMergedBy().GetLogin()} + _, _, err = github.Issues.AddAssignees( + ctx, r.Owner, r.Repo, int(res.PullRequest.GetNumber()), slices.Compact(slices.DeleteFunc(assignees, func(a string) bool { + return a == "" + })), + ) + if err != nil { + res.Error = fmt.Errorf("assigning ownership to backport pull request %w", err) + return res + } + + return res +} + +// backportCECommitWithPatch backports a commit to the currently checked out +// branch and will omit and excluded files for CE backports. This commit +// backport strategy involves creating a new diff patch and applying it rather +// than a cherry-pick. We do this so as to not require fixing bad cherry-picks +// when modifying enterprise only files that don't exist on the CE branch. +func (r *CreateBackportReq) backportCECommitWithPatch( + ctx context.Context, + git *libgit.Client, + pr *github.PullRequest, + changedFiles *ListChangedFilesRes, + commitSHA string, +) error { + var err error + // Get a list of files that do not include excluded groups. + files := changed.Files{} + for _, file := range changedFiles.Files { + if file.Groups.Any(r.CEExclude) { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("file", file.Name()), + ), "skipping file as it is in one-or-more excluded groups") + } else { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("file", file.Name()), + ), "including changed file") + files = append(files, file) + } + } + + // Create a unified patch of just the files we want to backport. + tmpDir, err := os.MkdirTemp("", "ce-backport-patch") + if err != nil { + return fmt.Errorf("creating temporary directory for CE patches: %w", err) + } + patchFile := filepath.Join(tmpDir, pr.GetBase().GetSHA()+".patch") + + patchRes, err := git.Show(ctx, &libgit.ShowOpts{ + DiffAlgorithm: libgit.DiffAlgorithmMyers, + // Use mboxrd so that we can we use 'git am' to apply and commit the patch + // and inherit all metadata from the source commit. + Format: "mboxrd", + NoColor: true, + Output: patchFile, + Object: commitSHA, + Patch: true, + PathSpec: files.Names(), + }) + if err != nil { + return fmt.Errorf("creating CE backport patch %s: %w", patchRes.String(), err) + } + + // Apply the patch and commit it with the original details + amRes, err := git.Am(ctx, &libgit.AmOpts{ + CommitterDateIsAuthorDate: true, + Empty: libgit.EmptyCommitKeep, + KeepNonPatch: true, + ThreeWayMerge: true, + Whitespace: libgit.WhitespaceActionFix, + Mbox: []string{patchFile}, + }) + if err != nil { + return fmt.Errorf("apply CE backport patch: %s: %w", amRes.String(), err) + } + + return nil +} + +// baseRefVersion represents the baseRef as an active branch version. Active +// branch versions are defined in .release/versions.hcl and ought to be +// considered the source of truth for which CE branches are active. The output +// also maps 1:1 to with backport labels. e.g. +// +// ce/main => main +// ent/main => main +// main => main +// ce/release/1.19.x => release/1.19.x +// release/1.19.x+ent => release/1.19.x +// ent/release/1.19.x+ent => release/1.19.x +func (r *CreateBackportReq) baseRefVersion(ref string) string { + switch { + case r.hasCEPrefix(ref): + return strings.TrimSuffix(strings.TrimPrefix(ref, r.CEBranchPrefix+"/"), "+ent") + case r.hasEntPrefix(ref): + return strings.TrimSuffix(strings.TrimPrefix(ref, r.EntBranchPrefix+"/"), "+ent") + default: + return strings.TrimSuffix(ref, "+ent") + } +} + +// determineBackportRefs determines which backport target branches are candidates +// to backport to depending on a combination of our source pull requests base +// reference and the labels that are present on the pull request. +// +// If the base reference of the original PR is main, we assume we ought to +// backport to ce/main. +// +// Any non-main backport references are derived from the original pull requests +// labels. The valid labels are translated to the corresponding references +// that match the source pull requests base reference type: enterprise or +// community +func (r *CreateBackportReq) determineBackportRefs( + ctx context.Context, + baseRef string, + labels Labels, +) (res []string) { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("labels", strings.Join(labels.Names(), " ")), + ), "determining backport base references from pull request labels") + + defer func() { + if len(res) < 1 { + res = nil + } + }() + + baseRefVersion := r.baseRefVersion(baseRef) + if r.isEnt(baseRef) { + // We're dealing an enterprise PR. Always backport to the corresponding + // CE branch if it's active. + if baseRefVersion == "main" { + res = append(res, fmt.Sprintf("%s/main", r.CEBranchPrefix)) + } else { + res = append(res, fmt.Sprintf("%s/%s", r.CEBranchPrefix, baseRefVersion)) + } + + // Backport to all enterprise release branches that match our backport labels + for _, label := range labels.Names() { + parts := strings.SplitN(label, "/", 2) + if len(parts) != 2 || parts[0] != r.BackportLabelPrefix { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("label", label), + slog.String("backport-label-prefix", r.BackportLabelPrefix), + ), "skipping label because it does not match the backport label prefix") + continue + } + + if parts[1] == baseRefVersion { + slog.Default().WarnContext(slogctx.Append(ctx, + slog.String("label", label), + slog.String("base-ref-version", baseRefVersion), + ), "skipping label because we cannot backport to the same reference") + continue + } + + if r.EntBranchPrefix == "" { + res = append(res, fmt.Sprintf("release/%s+ent", parts[1])) + } else { + res = append(res, fmt.Sprintf("%s/release/%s+ent", r.EntBranchPrefix, parts[1])) + } + } + } else { + // We're dealing with a CE PR. Backport to all CE release branches that match + // our backport labels + for _, label := range labels.Names() { + parts := strings.SplitN(label, "/", 2) + if len(parts) != 2 || parts[0] != r.BackportLabelPrefix { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("label", label), + slog.String("backport-label-prefix", r.BackportLabelPrefix), + ), "skipping label because it does not match the backport label prefix") + continue + } + + if parts[1] == baseRefVersion { + slog.Default().WarnContext(slogctx.Append(ctx, + slog.String("label", label), + slog.String("base-ref-version", baseRefVersion), + ), "skipping label because we cannot backport to the same reference") + + continue + } + + res = append(res, fmt.Sprintf("%s/release/%s", r.CEBranchPrefix, parts[1])) + } + } + + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("refs", strings.Join(res, ",")), + ), "determined target backport references") + + return res +} + +// getActiveVersions gets the active versions from .release/versions.hcl +func (r *CreateBackportReq) getActiveVersions( + ctx context.Context, +) (map[string]*releases.Version, error) { + req := &releases.ListActiveVersionsReq{ + Recurse: r.ReleaseRecurseDepth, + ReleaseVersionConfigPath: r.ReleaseVersionConfigPath, + } + res, err := req.Run(ctx) + if err != nil { + return nil, err + } + + return res.VersionsConfig.ActiveVersion.Versions, nil +} + +// getChangedFiles gets a list of files that changed in the PR and determines +// whether or not we need to worry about excluding some or all of them for CE +// backports. +func (r *CreateBackportReq) getChangedFiles( + ctx context.Context, + github *libgithub.Client, +) (*ListChangedFilesRes, error) { + req := ListChangedFilesReq{ + Owner: r.Owner, + Repo: r.Repo, + PullNumber: int(r.PullNumber), + GroupFiles: true, + } + res, err := req.Run(ctx, github) + if err != nil { + return nil, err + } + + return res, nil +} + +// getPR does GET for the pull request details +func (r *CreateBackportReq) getPR( + ctx context.Context, + github *libgithub.Client, +) (*libgithub.PullRequest, error) { + slog.Default().DebugContext(ctx, "getting PR details") + pr, _, err := github.PullRequests.Get(ctx, r.Owner, r.Repo, int(r.PullNumber)) + if err != nil { + return nil, err + } + + return pr, nil +} + +// Names returns the label names as slice of strings +func (l Labels) Names() []string { + if l == nil || len(l) < 1 { + return nil + } + + res := []string{} + for label := range slices.Values(l) { + if label != nil { + res = append(res, label.GetName()) + } + } + + return res +} + +// hasCEPrefix takes a branch reference and determines whether or not it starts +// with the CEBranchPrefix. +func (r *CreateBackportReq) hasCEPrefix(ref string) bool { + return strings.HasPrefix(ref, r.CEBranchPrefix+"/") +} + +// hasEntPrefix takes a branch reference and determines whether or not it starts +// with the EntBranchPrefix. +func (r *CreateBackportReq) hasEntPrefix(ref string) bool { + if r.EntBranchPrefix == "" { + return false + } + + return strings.HasPrefix(ref, r.EntBranchPrefix+"/") +} + +// initializeExistingRepo initializes an existing repository. It assumes that +// at least one remote origin exists and that some branch is checked out. If +// the current branch is our baseRef we'll pull in the latest changes, otherwise +// we'll fetch baseRef. +func (r *CreateBackportReq) initializeExistingRepo( + ctx context.Context, + git *libgit.Client, + baseRef string, +) error { + // We've been given an already initialized git directory. We'll have to + // assume it's the correct repo that has been cloned. + slog.Default().WarnContext(ctx, "using an already initialized git repository") + + slog.Default().DebugContext(ctx, "changing working directory to repo-dir") + err := os.Chdir(r.RepoDir) + if err != nil { + return fmt.Errorf("changing directory to the repository dir: %w", err) + } + + // Determine if we're on the correct branch. If we are, pull it, otherwise + // fetch it. + slog.Default().DebugContext(ctx, "getting existing repo current branch") + res, err := git.Branch(ctx, &libgit.BranchOpts{ + NoColor: true, + ShowCurrent: true, + }) + if err != nil { + return fmt.Errorf("getting existing repo current branch: %w", err) + } + + if strings.TrimSpace(string(res.Stdout)) == baseRef { + // Our existing repo is already checked out to correct branch. + // Fetch the base ref to make sure our existing repository has the necessary + // objects and references we'll need to cherry-pick the commits to our + // branch. + slog.Default().DebugContext(ctx, "pulling in latest changes") + res, err := git.Pull(ctx, &libgit.PullOpts{ + Refspec: []string{r.BaseOrigin, baseRef}, + Autostash: true, + SetUpstream: true, + Rebase: libgit.RebaseStrategyTrue, + }) + if err != nil { + return fmt.Errorf("pulling repo current branch: %s: %w", res.String(), err) + } + + return nil + } + + // Fetch the base ref to make sure our existing repository has the necessary + // objects and references we'll need to cherry-pick the commits to our + // branch. + slog.Default().DebugContext(ctx, "fetching repository base ref") + res, err = git.Fetch(ctx, &libgit.FetchOpts{ + // Fetch the ref but also provide a local tracking branch of the same name + // e.g. "git fetch origin main:main" + Refspec: []string{r.BaseOrigin, fmt.Sprintf("%s:%s", baseRef, baseRef)}, + SetUpstream: true, + Porcelain: true, + }) + if err != nil { + return fmt.Errorf("fetching base ref: %s: %w", res.String(), err) + } + + return nil +} + +// initializeNewRepo initializes a new repository by cloning the repo fetching +// the base branch we'll cherry pick from. +func (r *CreateBackportReq) initializeNewRepo( + ctx context.Context, + git *libgit.Client, + baseRef string, +) error { + cloneURL := fmt.Sprintf("https://github.com/%s/%s.git", r.Owner, r.Repo) + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("repo-dir", r.RepoDir), + slog.String("repo-url", cloneURL), + ), "initializing new clone of repository") + + res, err := git.Clone(ctx, &libgit.CloneOpts{ + Repository: cloneURL, + Directory: r.RepoDir, + Origin: r.BaseOrigin, + Branch: baseRef, + SingleBranch: true, + NoCheckout: true, + }) + if err != nil { + return fmt.Errorf("cloning repository: %s: %w", res.String(), err) + } + + slog.Default().DebugContext(ctx, "changing working directory to repo-dir") + err = os.Chdir(r.RepoDir) + if err != nil { + return fmt.Errorf("changing directory to the repository dir: %w", err) + } + + return nil +} + +// isEnt takes a branch reference and determines whether or not it refers to +// an enterprise branch. +func (r *CreateBackportReq) isEnt(ref string) bool { + if r.hasCEPrefix(ref) { + return false + } + + return true +} + +// pullRequestBody uses the PullRequest and backport reference to render the +// embedded backport-pr.tmpl template that we can use for the pull request body. +func (r *CreateBackportReq) pullRequestBody( + origin *libgithub.PullRequest, + attempt *CreateBackportAttempt, +) (string, error) { + tmpl, err := embeds.ReadFile("embeds/backport-pr.tmpl") + if err != nil { + return "", err + } + + t, err := template.New("backport-pr.tmpl").Parse(string(tmpl)) + if err != nil { + return "", err + } + + buf := bytes.Buffer{} + err = t.Execute(&buf, struct { + OriginPullRequest *libgithub.PullRequest + Attempt *CreateBackportAttempt + }{origin, attempt}) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +// shouldSkipRef determines whether or we ought to backport to a given branch +// reference. It considers whether or not the base ref is for enterprise or +// CE, which files have changed and which CE branches are active. +func (r *CreateBackportReq) shouldSkipRef( + ctx context.Context, + baseRefVersion string, + ref string, + activeVersions map[string]*releases.Version, + changedFiles *ListChangedFilesRes, +) (string, bool) { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("base-ref-version", baseRefVersion), + slog.String("target-ref", ref), + ), "determining whether to skip backport") + + if changedFiles == nil || len(changedFiles.Files) < 1 { + return "no files were changed", true + } + + if baseRefVersion == "" { + return "missing base ref", true + } + + if ref == "" { + return "missing fef", true + } + + if !r.hasCEPrefix(ref) { + // It's an enterprise backport so we'll always do it. + return "references to enterprise branches always backported", false + } + + // Check if all of our files belong to excluded groups, i.e. they're all + // files in the "enterprise" group. + if changedFiles.Files.EachHasAnyGroup(r.CEExclude) { + return fmt.Sprintf( + "all changed files are in excluded groups: %s", r.CEExclude.String(), + ), true + } + + if ref == r.CEBranchPrefix+"/main" { + return "ce/main is always active and there are CE allowed files", false + } + + // Check if there are inactive-allowed changed files, i.e. docs or pipeline + // files are included so we'll always backport to the CE branch. + if r.CEAllowInactiveGroups.Any(changedFiles.Groups) { + return fmt.Sprintf( + "one or more changed file groups [%s] are included in allowed inactive changed file groups [%s]", + changedFiles.Groups.String(), r.CEAllowInactiveGroups.String(), + ), false + } + + // Check if ce branch is active or not + if ver, ok := activeVersions[baseRefVersion]; ok { + if ver.CEActive { + return "CE branch is active", false + } + return "CE branch is inactive", true + } + + return fmt.Sprintf( + "could not find branch in active branches configuration: %s", baseRefVersion, + ), true +} diff --git a/tools/pipeline/internal/pkg/github/create_backport_test.go b/tools/pipeline/internal/pkg/github/create_backport_test.go new file mode 100644 index 0000000000..f1ff6abefb --- /dev/null +++ b/tools/pipeline/internal/pkg/github/create_backport_test.go @@ -0,0 +1,741 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package github + +import ( + "context" + "errors" + "testing" + + libgithub "github.com/google/go-github/v68/github" + "github.com/hashicorp/vault/tools/pipeline/internal/pkg/changed" + "github.com/hashicorp/vault/tools/pipeline/internal/pkg/releases" + "github.com/stretchr/testify/require" +) + +// TestCreateBackportReq_Validate tests validation of the request +func TestCreateBackportReq_Validate(t *testing.T) { + t.Parallel() + + for name, test := range map[string]struct { + req *CreateBackportReq + valid bool + }{ + "empty": {nil, false}, + "valid": {NewCreateBackportReq(WithCreateBrackportReqPullNumber(1234)), true}, + "no owner": { + NewCreateBackportReq( + WithCreateBrackportReqPullNumber(1234), + WithCreateBackportReqOwner(""), + ), false, + }, + "no repo": { + NewCreateBackportReq( + WithCreateBrackportReqPullNumber(1234), + WithCreateBrackportReqRepo(""), + ), false, + }, + "no pull number": {NewCreateBackportReq(), false}, + "no ce branch prefix": { + NewCreateBackportReq( + WithCreateBrackportReqPullNumber(1234), + WithCreateBrackportReqCEBranchPrefix(""), + ), false, + }, + "no base origin": { + NewCreateBackportReq( + WithCreateBrackportReqPullNumber(1234), + WithCreateBrackportReqBaseOrigin(""), + ), false, + }, + "uninitialized exclude groups": { + NewCreateBackportReq( + WithCreateBrackportReqPullNumber(1234), + WithCreateBrackportReqCEExclude(nil), + ), false, + }, + "uninitialized inactive groups": { + NewCreateBackportReq( + WithCreateBrackportReqPullNumber(1234), + WithCreateBrackportReqAllowInactiveGroups(nil), + ), false, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + if test.valid { + require.NoError(t, test.req.Validate(context.Background())) + } else { + require.Error(t, test.req.Validate(context.Background())) + } + }) + } +} + +// TestCreateBackportReq_backportNameForRef tests generating the backport +// branch name from branch name ref and the original PR branch name. +func TestCreateBackportReq_backportNameForRef(t *testing.T) { + t.Parallel() + + for name, test := range map[string]struct { + ref string // These should be full branch names + prBranch string + expected string + }{ + // backporting to ent main should never really happen but we'll test the + // logic anyway + "ent main": { + "main", + "my-pr", + "backport/main/my-pr", + }, + "ent release branch": { + "release/1.19.x+ent", + "my-pr", + "backport/release/1.19.x+ent/my-pr", + }, + "ce main": { + "ce/main", + "my-pr", + "backport/ce/main/my-pr", + }, + "ce release branch": { + "ce/release/1.19.x", + "my-pr", + "backport/ce/release/1.19.x/my-pr", + }, + "truncates super long branch name": { + "main", + "my-really-really-long-pr-name-that-must-exceed-two-hundred-and-fifty-characters-when-it-is-appended-to-the-backport-and-base-ref-prefixes-ought-to-be-truncated-so-as-to-not-exceed-the-github-pr-branch-requirements-otherwise-bad-things-happen", + "backport/main/my-really-really-long-pr-name-that-must-exceed-two-hundred-and-fifty-characters-when-it-is-appended-to-the-backport-and-base-ref-prefixes-ought-to-be-truncated-so-as-to-not-exceed-the-github-pr-branch-requirements-otherwise-bad-things-h", + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + req := NewCreateBackportReq() + require.Equal(t, test.expected, req.backportBranchNameForRef(test.ref, test.prBranch)) + }) + } +} + +// TestCreateBackportReq_baseRefVersion tests generating the base ref version +// from the backport branch reference. The base ref version matches the schema +// used in .release/versions.hcl. +func TestCreateBackportReq_baseRefVersion(t *testing.T) { + t.Parallel() + + for ref, test := range map[string]struct { + req *CreateBackportReq + expectedRef string + }{ + // backporting to ent main should never really happen but we'll test the + // logic anyway + "main": {req: NewCreateBackportReq(), expectedRef: "main"}, + "ce/main": {req: NewCreateBackportReq(), expectedRef: "main"}, + "ent/main": {req: NewCreateBackportReq(WithCreateBrackportReqEntBranchPrefix("ent")), expectedRef: "main"}, + "release/1.19.x+ent": {req: NewCreateBackportReq(), expectedRef: "release/1.19.x"}, + "ce/release/1.19.x": {req: NewCreateBackportReq(), expectedRef: "release/1.19.x"}, + "ent/release/1.19.x": {req: NewCreateBackportReq(WithCreateBrackportReqEntBranchPrefix("ent")), expectedRef: "release/1.19.x"}, + } { + t.Run(ref, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.expectedRef, test.req.baseRefVersion(ref)) + }) + } +} + +// TestCreateBackportReq_determineBackportRefs tests generating a list +// of backport refs when considering the base ref of the PR and any labels +// that have been applied to it. +func TestCreateBackportReq_determineBackportRefs(t *testing.T) { + t.Parallel() + + for name, test := range map[string]struct { + req *CreateBackportReq + baseRef string + labels Labels + expected []string + }{ + "ent main no labels": { + NewCreateBackportReq(), + "main", + nil, + []string{"ce/main"}, + }, + "ent main no labels with ent prefix": { + NewCreateBackportReq(WithCreateBrackportReqEntBranchPrefix("ent")), + "ent/main", + nil, + []string{"ce/main"}, + }, + "ent main with labels": { + NewCreateBackportReq(), + "main", + Labels{ + &libgithub.Label{Name: libgithub.Ptr("backport/1.19.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.18.x")}, + }, + []string{"ce/main", "release/1.19.x+ent", "release/1.18.x+ent"}, + }, + "ent main with labels with ent prefix": { + NewCreateBackportReq(WithCreateBrackportReqEntBranchPrefix("ent")), + "ent/main", + Labels{ + &libgithub.Label{Name: libgithub.Ptr("backport/1.19.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.18.x")}, + }, + []string{"ce/main", "ent/release/1.19.x+ent", "ent/release/1.18.x+ent"}, + }, + "ent release no labels": { + NewCreateBackportReq(), + "release/1.19.x+ent", + nil, + []string{"ce/release/1.19.x"}, + }, + "ent release no labels with ent prefix": { + NewCreateBackportReq(WithCreateBrackportReqEntBranchPrefix("ent")), + "ent/release/1.19.x+ent", + nil, + []string{"ce/release/1.19.x"}, + }, + "ent release with labels": { + NewCreateBackportReq(), + "release/1.19.x+ent", + Labels{ + &libgithub.Label{Name: libgithub.Ptr("backport/1.18.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.17.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.16.x")}, + }, + []string{ + "ce/release/1.19.x", + "release/1.18.x+ent", + "release/1.17.x+ent", + "release/1.16.x+ent", + }, + }, + "ent release with labels with ent prefix": { + NewCreateBackportReq(WithCreateBrackportReqEntBranchPrefix("ent")), + "ent/release/1.19.x+ent", + Labels{ + &libgithub.Label{Name: libgithub.Ptr("backport/1.18.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.17.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.16.x")}, + }, + []string{ + "ce/release/1.19.x", + "ent/release/1.18.x+ent", + "ent/release/1.17.x+ent", + "ent/release/1.16.x+ent", + }, + }, + "ce main no labels": { + NewCreateBackportReq(), + "ce/main", + nil, + nil, + }, + "ce main with labels": { + NewCreateBackportReq(), + "ce/main", + Labels{ + &libgithub.Label{Name: libgithub.Ptr("backport/1.19.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.18.x")}, + }, + []string{"ce/release/1.19.x", "ce/release/1.18.x"}, + }, + "ce release no labels": { + NewCreateBackportReq(), + "ce/release/1.19.x", + nil, + nil, + }, + "ce release with labels": { + NewCreateBackportReq(), + "ce/release/1.19.x", + Labels{ + &libgithub.Label{Name: libgithub.Ptr("backport/1.18.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.17.x")}, + &libgithub.Label{Name: libgithub.Ptr("backport/1.16.x")}, + }, + []string{"ce/release/1.18.x", "ce/release/1.17.x", "ce/release/1.16.x"}, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + require.EqualValues(t, test.expected, test.req.determineBackportRefs(context.Background(), test.baseRef, test.labels)) + }) + } +} + +// TestCreateBackportReq_shouldSkipRef tests whether various combinations of +// base refs, backport refs, changed files, and active CE versions are +// backportable references or should be skipped. +func TestCreateBackportReq_shouldSkipRef(t *testing.T) { + t.Parallel() + + defaultActiveVersions := map[string]*releases.Version{ + // main is never going to be in here as it's assumed it's always active + "1.19.x": {CEActive: true, LTS: true}, + "1.18.x": {CEActive: false}, + "1.17.x": {CEActive: false}, + "1.16.x": {CEActive: true, LTS: true}, + } + + defaultChangedFiles := &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr(".github/workflows/build.yml"), + }, + Groups: changed.FileGroups{"pipeline"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("go.mod"), + }, + Groups: changed.FileGroups{"app", "gotoolchain"}, + }, + }, + Groups: changed.FileGroups{ + "app", "gotoolchain", "pipeline", + }, + } + + for name, test := range map[string]struct { + baseRefVersion string + ref string + activeVersions map[string]*releases.Version + changedFiles *ListChangedFilesRes + skip bool + }{ + "main to ce/main": { + baseRefVersion: "main", + ref: "ce/main", + activeVersions: defaultActiveVersions, + changedFiles: defaultChangedFiles, + skip: false, + }, + "main to ce/main with ent and ce only files": { + baseRefVersion: "main", + ref: "ce/main", + activeVersions: defaultActiveVersions, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("e1c10eae02e13f5a090b9c29b0b1a3003e8ca7f6"), + Filename: libgithub.Ptr("go.mod"), + }, + Groups: changed.FileGroups{"app", "gotoolchain"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("a6397662ea1d5fdde744ff3e4246377cf369197a"), + Filename: libgithub.Ptr("vault_ent/go.mod"), + }, + Groups: changed.FileGroups{"app", "enterprise", "gotoolchain"}, + }, + }, + Groups: changed.FileGroups{ + "app", "enterprise", "gotoolchain", + }, + }, + skip: false, + }, + "main to active release/1.19.x+ent": { + baseRefVersion: "1.19.x", + ref: "release/1.19.x+ent", + activeVersions: defaultActiveVersions, + changedFiles: defaultChangedFiles, + skip: false, + }, + "main to release/1.18.x+ent (inactive CE)": { + baseRefVersion: "1.18.x", + ref: "release/1.18.x+ent", + activeVersions: defaultActiveVersions, + changedFiles: defaultChangedFiles, + skip: false, + }, + "active release branch with app changes": { + baseRefVersion: "1.19.x", + ref: "ce/release/1.19.x", + activeVersions: defaultActiveVersions, + changedFiles: defaultChangedFiles, + skip: false, + }, + "active release branch to CE with only ent changes": { + baseRefVersion: "1.19.x", + ref: "ce/release/1.19.x", + activeVersions: defaultActiveVersions, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr(".github/workflows/build-artifacts-ent.yml"), + }, + Groups: changed.FileGroups{"enterprise", "pipeline"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/vault_ent/go.mod"), + }, + Groups: changed.FileGroups{"app", "enterprise", "gotoolchain"}, + }, + }, + Groups: changed.FileGroups{ + "app", "enterprise", "gotoolchain", "pipeline", + }, + }, + skip: true, + }, + "inactive ce branch with no allowed group changes": { + baseRefVersion: "1.18.x", + ref: "ce/release/1.18.x", + activeVersions: defaultActiveVersions, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/go.mod"), + }, + Groups: changed.FileGroups{"app", "gotoolchain"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/vault_ent/go.mod"), + }, + Groups: changed.FileGroups{"app", "enterprise", "gotoolchain"}, + }, + }, + Groups: changed.FileGroups{ + "app", "enterprise", "gotoolchain", + }, + }, + + skip: true, + }, + "inactive ce with with pipeline changes": { + baseRefVersion: "1.18.x", + ref: "ce/release/1.18.x", + activeVersions: defaultActiveVersions, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr(".github/workflows/build.yml"), + }, + Groups: changed.FileGroups{"pipeline"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/go.mod"), + }, + Groups: changed.FileGroups{"app", "gotoolchain"}, + }, + }, + Groups: changed.FileGroups{ + "app", "gotoolchain", "pipeline", + }, + }, + + skip: false, + }, + "inactive ce with with docs changes": { + baseRefVersion: "1.17.x", + ref: "ce/release/1.17.x", + activeVersions: defaultActiveVersions, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("website/content/docs/index.mdx"), + }, + Groups: changed.FileGroups{"docs"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/go.mod"), + }, + Groups: changed.FileGroups{"app", "gotoolchain"}, + }, + }, + Groups: changed.FileGroups{ + "app", "gotoolchain", "pipeline", + }, + }, + + skip: false, + }, + "inactive ce with with changelog changes": { + baseRefVersion: "1.17.x", + ref: "ce/release/1.17.x", + activeVersions: defaultActiveVersions, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("changelog/1234.txt"), + }, + Groups: changed.FileGroups{"changelog"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/go.mod"), + }, + Groups: changed.FileGroups{"app", "gotoolchain"}, + }, + }, + Groups: changed.FileGroups{ + "app", "gotoolchain", "pipeline", + }, + }, + + skip: false, + }, + "empty changed files list is skipped": { + baseRefVersion: "1.19.x", + ref: "ce/release/1.19.x", + activeVersions: defaultActiveVersions, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{}, + Groups: changed.FileGroups{}, + }, + skip: true, + }, + "nil changed files list is skipped": { + baseRefVersion: "1.19.x", + ref: "ce/release/1.19.x", + activeVersions: defaultActiveVersions, + changedFiles: nil, + skip: true, + }, + "release branch with no active versions": { + baseRefVersion: "1.19.x", + ref: "ce/release/1.19.x", + activeVersions: map[string]*releases.Version{}, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/go.mod"), + }, + Groups: changed.FileGroups{"app", "gotoolchain"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/vault_ent/go.mod"), + }, + Groups: changed.FileGroups{"app", "enterprise", "gotoolchain"}, + }, + }, + Groups: changed.FileGroups{ + "app", "enterprise", "gotoolchain", + }, + }, + skip: true, + }, + "release branch with nil active versions": { + baseRefVersion: "1.19.x", + ref: "ce/release/1.19.x", + activeVersions: nil, + changedFiles: &ListChangedFilesRes{ + Files: changed.Files{ + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/go.mod"), + }, + Groups: changed.FileGroups{"app", "gotoolchain"}, + }, + { + File: &libgithub.CommitFile{ + SHA: libgithub.Ptr("84e0b544965861a7c6373e639cb13755512f84f4"), + Filename: libgithub.Ptr("vault/vault_ent/go.mod"), + }, + Groups: changed.FileGroups{"app", "enterprise", "gotoolchain"}, + }, + }, + }, + skip: true, + }, + "missing base ref version": { + baseRefVersion: "", + ref: "ce/main", + activeVersions: defaultActiveVersions, + changedFiles: defaultChangedFiles, + skip: true, + }, + "missing ref version": { + baseRefVersion: "main", + ref: "", + activeVersions: defaultActiveVersions, + changedFiles: defaultChangedFiles, + skip: true, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + req := NewCreateBackportReq() + msg, skip := req.shouldSkipRef( + context.Background(), + test.baseRefVersion, + test.ref, + test.activeVersions, + test.changedFiles, + ) + require.Equalf( + t, test.skip, skip, "should have %t but got %t with %s", test.skip, skip, msg) + }) + } +} + +func TestCreateBackportReq_pullRequestBody(t *testing.T) { + t.Parallel() + + for name, test := range map[string]struct { + expectContains []string + expectNotContains []string + origin *libgithub.PullRequest + attempt *CreateBackportAttempt + }{ + "no error": { + expectContains: []string{"original body"}, + expectNotContains: []string{"error body"}, + origin: &libgithub.PullRequest{ + Body: libgithub.Ptr("original body"), + Number: libgithub.Ptr(1234), + HTMLURL: libgithub.Ptr("https://github.com/hashicorp/vault-enterprise/pull/1234"), + MergedBy: &libgithub.User{Login: libgithub.Ptr("my-login")}, + }, + attempt: &CreateBackportAttempt{ + TargetRef: "release/1.19.x", + }, + }, + "error": { + expectContains: []string{"original body", "error body"}, + origin: &libgithub.PullRequest{ + Body: libgithub.Ptr("original body"), + Number: libgithub.Ptr(1234), + HTMLURL: libgithub.Ptr("https://github.com/hashicorp/vault-enterprise/pull/1234"), + MergedBy: &libgithub.User{Login: libgithub.Ptr("my-login")}, + }, + attempt: &CreateBackportAttempt{ + TargetRef: "release/1.19.x", + Error: errors.New("error body"), + }, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + req := NewCreateBackportReq() + got, err := req.pullRequestBody(test.origin, test.attempt) + require.NoError(t, err) + for _, c := range test.expectContains { + require.Containsf(t, got, c, got) + } + for _, nc := range test.expectNotContains { + require.NotContainsf(t, got, nc, got) + } + }) + } +} + +func TestCreateBackportRes_Err(t *testing.T) { + t.Parallel() + + for name, test := range map[string]struct { + in *CreateBackportRes + failed error + }{ + "nil": { + nil, + errors.New("uninitialized"), + }, + "no errors": { + &CreateBackportRes{ + Attempts: map[string]*CreateBackportAttempt{ + "ce/main": {}, + "release/1.18.x": {}, + "release/1.19.x": {}, + }, + }, + nil, + }, + "top level error no attempt errors": { + &CreateBackportRes{ + Error: errors.New("top-failed"), + Attempts: map[string]*CreateBackportAttempt{ + "ce/main": {}, + "release/1.18.x": {}, + "release/1.19.x": {}, + }, + }, + errors.New("top-failed"), + }, + "no top level error attempt errors": { + &CreateBackportRes{ + Attempts: map[string]*CreateBackportAttempt{ + "ce/main": { + Error: errors.New("child-failed"), + }, + "release/1.18.x": {}, + "release/1.19.x": {}, + }, + }, + errors.New("child-failed"), + }, + "top level and attempt errors": { + &CreateBackportRes{ + Error: errors.New("top-failed"), + Attempts: map[string]*CreateBackportAttempt{ + "ce/main": {}, + "release/1.18.x": {}, + "release/1.19.x": { + Error: errors.New("child-failed"), + }, + }, + }, + errors.New("top-failed\nchild-failed"), + }, + "multiple attempt errors": { + &CreateBackportRes{ + Error: errors.New("top-failed"), + Attempts: map[string]*CreateBackportAttempt{ + "ce/main": {}, + "release/1.18.x": { + Error: errors.New("child-2-failed"), + }, + "release/1.19.x": { + Error: errors.New("child-3-failed"), + }, + }, + }, + // When multiple attempts fail the errros should be stable + errors.New("top-failed\nchild-2-failed\nchild-3-failed"), + }, + } { + t.Run(name, func(t *testing.T) { + if test.failed == nil { + require.Nil(t, test.in.Err()) + } else { + require.Equal(t, test.failed.Error(), test.in.Err().Error()) + } + }) + } +} diff --git a/tools/pipeline/internal/pkg/github/embeds.go b/tools/pipeline/internal/pkg/github/embeds.go new file mode 100644 index 0000000000..293fed0dab --- /dev/null +++ b/tools/pipeline/internal/pkg/github/embeds.go @@ -0,0 +1,9 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package github + +import "embed" + +//go:embed embeds/* +var embeds embed.FS diff --git a/tools/pipeline/internal/pkg/github/embeds/backport-pr.tmpl b/tools/pipeline/internal/pkg/github/embeds/backport-pr.tmpl new file mode 100644 index 0000000000..666848087f --- /dev/null +++ b/tools/pipeline/internal/pkg/github/embeds/backport-pr.tmpl @@ -0,0 +1,34 @@ +## Backport [#{{ .OriginPullRequest.Number }}]({{ .OriginPullRequest.HTMLURL }}) {{ if .Attempt.TargetRef }} into {{ .Attempt.TargetRef }} {{ end }} + +{{ if .Attempt.Error }} +:rotating_light: +>**Warning** automatic backport of commits failed. If the first commit failed, +you will see a blank no-op commit below. If at least one commit succeeded, you +will see the backported commits up to, _but not including_, the commit where +the merge conflict occurred. + +The person who merged in the original PR is: @{{ .OriginPullRequest.GetMergedBy.GetLogin }} +This person should resolve the merge-conflict(s) by either: +* Manually completing the backports into this branch +* Creating a new branch and manually backporting all commits + +Error(s) encountered while attempting the backport: +> {{ .Attempt.Error }} + +To continue the backport process, please follow the instructions below: + +1. Checkout the branch in this PR locally (or optionally create a new branch off {{ .OriginPullRequest.GetBase.GetRef }}) +1. Manually cherry-pick the missing commits from the original PR into this branch: + 1. Cherry-pick the commits from the original PR into this branch `git cherry-pick ` (see Overview of unprocessed commits below for the list of commits to cherry-pick) + 1. Resolve any conflicts that arise + 1. Remove any CE only files if the backport target branch is to ce + 1. Push the changes to this branch +1. Update the PR description to reflect the new commit(s) + +{{ end }} + +The following text was copied from the body of the original pull request + +--- + +{{ .OriginPullRequest.Body }}