vault/tools/pipeline/internal/pkg/github/create_backport.go
Ryan Cragun 3611b8b709
[VAULT-32028] pipeline(github): add sync branches sub-command (#31252)
Add a new `pipeline github sync branches` command that can synchronize
two branches. We'll use this to synchronize the
`hashicorp/vault-enterprise/ce/*` branches with `hashicorp/vault/*`.

As the community repository is effectively a mirror of what is hosted in
Enterprise, a scheduled sync cadence is probably fine. Eventually we'll
hook the workflow and sync into the release pipeline to ensure that
`hashicorp/vault` branches are up-to-date when cutting community
releases.

As part of this I also fixed a few static analysis issues that popped up
when running `golangci-lint` and fixed a few smaller bugs.

Signed-off-by: Ryan Cragun <me@ryan.ec>
2025-07-11 14:08:14 -06:00

1056 lines
34 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
"os"
"path/filepath"
"slices"
"strings"
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/<Owner>/<Repo>.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{}
}
// 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))
var err1 error
res.Comment, err1 = createPullRequestComment(
ctx, github, r.Owner, r.Repo, int(r.PullNumber), res.CommentBody(),
)
// 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
r.RepoDir, res.Error, tmpDir = ensureGitRepoDir(ctx, r.RepoDir)
if res.Error != nil {
return res
}
if tmpDir {
defer os.RemoveAll(r.RepoDir)
}
// Get our pull request details
res.OriginPullRequest, res.Error = getPullRequest(
ctx, github, r.Owner, r.Repo, int(r.PullNumber),
)
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 = initializeExistingRepo(
ctx, git, r.RepoDir, r.BaseOrigin, baseRef,
)
} else {
res.Error = initializeNewRepo(
ctx, git, r.RepoDir, r.Owner, r.Repo, r.BaseOrigin, 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
}
// 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 *libgithub.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 {
err = resetAndCreateNOOPCommit(ctx, git, ref)
if err != nil {
res.Error = errors.Join(res.Error, 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 := renderEmbeddedTemplate("backport-pr-message.tmpl", struct {
OriginPullRequest *libgithub.PullRequest
Attempt *CreateBackportAttempt
}{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.
err = addAssignees(
ctx,
github,
r.Owner,
r.Repo,
int(res.PullRequest.GetNumber()),
[]string{pr.GetAssignee().GetLogin(), pr.GetMergedBy().GetLogin()},
)
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 *libgithub.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
}
// Names returns the label names as slice of strings
func (l Labels) Names() []string {
if 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+"/")
}
// isEnt takes a branch reference and determines whether or not it refers to
// an enterprise branch.
func (r *CreateBackportReq) isEnt(ref string) bool {
return !r.hasCEPrefix(ref)
}
// 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
}