mirror of
https://github.com/hashicorp/vault.git
synced 2025-08-07 07:07:05 +02:00
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>
1056 lines
34 KiB
Go
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
|
|
}
|