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>
472 lines
13 KiB
Go
472 lines
13 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"
|
|
libgit "github.com/hashicorp/vault/tools/pipeline/internal/pkg/git"
|
|
"github.com/jedib0t/go-pretty/v6/table"
|
|
slogctx "github.com/veqryn/slog-context"
|
|
)
|
|
|
|
// CopyPullRequestReq is a request to copy a pull request from the CE repo to
|
|
// the Ent repo.
|
|
type CopyPullRequestReq struct {
|
|
FromOwner string
|
|
FromRepo string
|
|
FromOrigin string
|
|
ToOwner string
|
|
ToRepo string
|
|
ToOrigin string
|
|
PullNumber uint
|
|
RepoDir string
|
|
EntBranchSuffix string // add +ent to release/* branches
|
|
}
|
|
|
|
// CopyPullRequestRes is a copy pull request response.
|
|
type CopyPullRequestRes struct {
|
|
Error error `json:"error,omitempty"`
|
|
Request *CopyPullRequestReq `json:"request,omitempty"`
|
|
OriginPullRequest *libgithub.PullRequest `json:"origin_pull_request,omitempty"`
|
|
PullRequest *libgithub.PullRequest `json:"pull_request,omitempty"`
|
|
Comment *libgithub.IssueComment `json:"comment,omitempty"`
|
|
}
|
|
|
|
// Run runs the request to copy a pull request from the CE repo to the Ent repo.
|
|
func (r *CopyPullRequestReq) Run(
|
|
ctx context.Context,
|
|
github *libgithub.Client,
|
|
git *libgit.Client,
|
|
) (*CopyPullRequestRes, error) {
|
|
var err error
|
|
res := &CopyPullRequestRes{Request: r}
|
|
|
|
slog.Default().DebugContext(slogctx.Append(ctx,
|
|
slog.String("from-owner", r.FromOwner),
|
|
slog.String("from-repo", r.FromRepo),
|
|
slog.String("from-origin", r.FromOrigin),
|
|
slog.String("to-owner", r.ToOwner),
|
|
slog.String("to-repo", r.ToRepo),
|
|
slog.String("to-origin", r.ToOrigin),
|
|
slog.String("repo-dir", r.RepoDir),
|
|
slog.Uint64("pull-number", uint64(r.PullNumber)),
|
|
slog.String("ent-branch-suffix", r.EntBranchSuffix),
|
|
), "copying pull request")
|
|
|
|
initialDir, err := os.Getwd()
|
|
if err != nil {
|
|
return res, fmt.Errorf("getting current working directory: %w", err)
|
|
}
|
|
|
|
// Whenever possible we try to update base pull request with a status update
|
|
// on how the copying has gone.
|
|
createComment := func() {
|
|
// Make sure we return a response even if we fail
|
|
if res == nil {
|
|
res = &CopyPullRequestRes{Request: r}
|
|
}
|
|
|
|
// Set any known errors on the response before we create a comment, as the
|
|
// error will be used in the comment body if present.
|
|
err = errors.Join(err, os.Chdir(initialDir))
|
|
var err1 error
|
|
res.Comment, err1 = createPullRequestComment(
|
|
ctx,
|
|
github,
|
|
r.FromOwner,
|
|
r.FromRepo,
|
|
int(r.PullNumber),
|
|
res.CommentBody(err),
|
|
)
|
|
|
|
// Set our finalized error on our response and also update our returned error
|
|
err = errors.Join(err, err1)
|
|
}
|
|
defer createComment()
|
|
|
|
// Make sure we have required and valid fields
|
|
err = r.Validate(ctx)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
// Make sure we've been given a valid location for a repo and/or create a
|
|
// temporary one
|
|
var tmpDir bool
|
|
r.RepoDir, err, tmpDir = ensureGitRepoDir(ctx, r.RepoDir)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
if tmpDir {
|
|
defer os.RemoveAll(r.RepoDir)
|
|
}
|
|
|
|
// Get our pull request details
|
|
res.OriginPullRequest, err = getPullRequest(
|
|
ctx, github, r.FromOwner, r.FromRepo, int(r.PullNumber),
|
|
)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
// Determine our pull request base ref. Handle the fact that enterprise
|
|
// release branches contain the +ent suffix.
|
|
baseRef := res.OriginPullRequest.GetBase().GetRef()
|
|
if strings.HasPrefix(baseRef, "release/") {
|
|
baseRef = baseRef + r.EntBranchSuffix
|
|
}
|
|
|
|
// 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
|
|
_, err = os.Stat(filepath.Join(r.RepoDir, ".git"))
|
|
if err == nil {
|
|
err = initializeExistingRepo(
|
|
ctx, git, r.RepoDir, r.ToOrigin, baseRef,
|
|
)
|
|
} else {
|
|
err = initializeNewRepo(
|
|
ctx, git, r.RepoDir, r.ToOwner, r.ToRepo, r.ToOrigin, baseRef,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
prBranch := res.OriginPullRequest.GetHead().GetRef()
|
|
prBranchRef := "remotes/" + r.FromOrigin + "/" + prBranch
|
|
|
|
// Add our from upstream as a remote and fetch our PR branch
|
|
slog.Default().DebugContext(ctx, "adding CE upstream and fetching PR branch")
|
|
remoteRes, err := git.Remote(ctx, &libgit.RemoteOpts{
|
|
Command: libgit.RemoteCommandAdd,
|
|
Track: []string{prBranch},
|
|
Fetch: true,
|
|
Name: r.FromOrigin,
|
|
URL: fmt.Sprintf("https://github.com/%s/%s.git", r.FromOwner, r.FromRepo),
|
|
})
|
|
if err != nil {
|
|
err = fmt.Errorf("fetching target branch base ref: %s, %w", remoteRes.String(), err)
|
|
return res, err
|
|
}
|
|
|
|
// Create a new branch for our copied changes.
|
|
branchName := r.copyBranchNameForRef(baseRef, prBranch)
|
|
// We don't have local references so create a new branch from our tracking branch
|
|
baseBranch := "remotes/" + r.ToOrigin + "/" + baseRef
|
|
slog.Default().DebugContext(ctx, "checking out new copy branch")
|
|
checkoutRes, err := git.Checkout(ctx, &libgit.CheckoutOpts{
|
|
NewBranchForceCheckout: branchName, // -B
|
|
Branch: baseBranch,
|
|
})
|
|
if err != nil {
|
|
return res, fmt.Errorf("checking out new copy branch: %s: %w", checkoutRes.String(), err)
|
|
}
|
|
|
|
// Generate a merge commit message. While git is able to generate a nice merge
|
|
// commit with a summary of all commit headers, we create our own that
|
|
// includes 'Co-Authored-By:' trailers in the commit message. As we always
|
|
// squash all commits into a single merge commit this helps to retain
|
|
// attribution for our source author.
|
|
commits, err := listPullRequestCommits(ctx, github, r.FromOwner, r.FromRepo, int(r.PullNumber))
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
commitMessageFile, err := renderEmbeddedTemplateToTmpFile("copy-pr-commit-message.tmpl", struct {
|
|
CoAuthoredByTrailers []string
|
|
OriginPullRequest *libgithub.PullRequest
|
|
TargetRef string
|
|
}{
|
|
r.getCoAuthoredByTrailers(commits),
|
|
res.OriginPullRequest,
|
|
baseRef,
|
|
})
|
|
if err != nil {
|
|
return res, fmt.Errorf("creating merge commit message: %w", err)
|
|
}
|
|
defer func() {
|
|
commitMessageFile.Close()
|
|
_ = os.Remove(commitMessageFile.Name())
|
|
}()
|
|
|
|
slog.Default().DebugContext(ctx, "merging CE PR branch into new copy branch")
|
|
mergeRes, mergeErr := git.Merge(ctx, &libgit.MergeOpts{
|
|
File: commitMessageFile.Name(),
|
|
NoVerify: true,
|
|
Strategy: libgit.MergeStrategyORT,
|
|
StrategyOptions: []libgit.MergeStrategyOption{
|
|
libgit.MergeStrategyOptionTheirs,
|
|
libgit.MergeStrategyOptionIgnoreSpaceChange,
|
|
},
|
|
IntoName: baseRef,
|
|
Commit: prBranchRef,
|
|
})
|
|
if mergeErr != nil {
|
|
mergeErr = fmt.Errorf("merging CE PR branch into new copy branch: %s: %w", mergeRes.String(), mergeErr)
|
|
}
|
|
|
|
// If our merge failed we still want to create a pull request for our
|
|
// failed copy so that a manual fix can be performed.
|
|
if mergeErr != nil {
|
|
err := resetAndCreateNOOPCommit(ctx, git, baseBranch)
|
|
if err != nil {
|
|
err = errors.Join(mergeErr, err)
|
|
|
|
// Something wen't wrong trying to create our no-op commit. There's
|
|
// nothing more we can do but return our error at this point.
|
|
return res, err
|
|
}
|
|
}
|
|
|
|
slog.Default().DebugContext(ctx, "pushing new branch to enterprise")
|
|
pushRes, err := git.Push(ctx, &libgit.PushOpts{
|
|
Repository: r.ToOrigin,
|
|
Refspec: []string{branchName},
|
|
})
|
|
if err != nil {
|
|
err = fmt.Errorf("pushing copied branch: %s: %w", pushRes.String(), err)
|
|
return res, errors.Join(mergeErr, err)
|
|
}
|
|
|
|
prTitle := fmt.Sprintf("Copy %s into %s", res.OriginPullRequest.GetTitle(), baseRef)
|
|
prBody, err := renderEmbeddedTemplate("copy-pr-message.tmpl", struct {
|
|
Error error
|
|
OriginPullRequest *libgithub.PullRequest
|
|
TargetRef string
|
|
}{
|
|
mergeErr,
|
|
res.OriginPullRequest,
|
|
baseRef,
|
|
})
|
|
if err != nil {
|
|
err = fmt.Errorf("creating copy pull request body %w", err)
|
|
return res, errors.Join(mergeErr, err)
|
|
}
|
|
|
|
res.PullRequest, _, err = github.PullRequests.Create(
|
|
ctx, r.ToOwner, r.ToRepo, &libgithub.NewPullRequest{
|
|
Title: &prTitle,
|
|
Head: &branchName,
|
|
HeadRepo: &r.ToRepo,
|
|
Base: &baseRef,
|
|
Body: &prBody,
|
|
},
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("creating copy pull request %w", err)
|
|
return res, errors.Join(mergeErr, err)
|
|
}
|
|
|
|
// Assign the pull request to the actor that was assigned the original
|
|
// pull request and anybody that approved it.
|
|
reviews, err := listPullRequestReviews(ctx, github, r.FromOwner, r.FromRepo, int(r.PullNumber))
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
err = addAssignees(
|
|
ctx,
|
|
github,
|
|
r.ToOwner,
|
|
r.ToRepo,
|
|
int(res.PullRequest.GetNumber()),
|
|
append(r.getApproverLogins(reviews), res.OriginPullRequest.GetAssignee().GetLogin()),
|
|
)
|
|
if err != nil {
|
|
err = fmt.Errorf("assigning ownership to copy pull request %w", err)
|
|
return res, errors.Join(mergeErr, err)
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// copyBranchNameForRef returns then branch name to use for our PR copy operation.
|
|
// e.g. copy/release/1.19.x+ent/my-feature-branch
|
|
func (r CopyPullRequestReq) copyBranchNameForRef(
|
|
ref string,
|
|
prBranch string,
|
|
) string {
|
|
name := fmt.Sprintf("copy/%s/%s", ref, prBranch)
|
|
if len(name) > 250 {
|
|
// Handle Githubs branch name max length
|
|
name = name[:250]
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// validate ensures that we've been given the minimum filter arguments necessary to complete a
|
|
// request. It is always recommended that additional fitlers be given to reduce the response size
|
|
// and not exhaust API limits.
|
|
func (r *CopyPullRequestReq) Validate(ctx context.Context) error {
|
|
if r == nil {
|
|
return errors.New("failed to initialize request")
|
|
}
|
|
|
|
if r.FromOrigin == "" {
|
|
return errors.New("no github from origin has been provided")
|
|
}
|
|
|
|
if r.FromOwner == "" {
|
|
return errors.New("no github from owner has been provided")
|
|
}
|
|
|
|
if r.FromRepo == "" {
|
|
return errors.New("no github from repository has been provided")
|
|
}
|
|
|
|
if r.ToOrigin == "" {
|
|
return errors.New("no github to origin has been provided")
|
|
}
|
|
|
|
if r.ToOwner == "" {
|
|
return errors.New("no github to owner has been provided")
|
|
}
|
|
|
|
if r.ToRepo == "" {
|
|
return errors.New("no github to repository has been provided")
|
|
}
|
|
|
|
if r.PullNumber == 0 {
|
|
return errors.New("no github pull request number has been provided")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CommentBody is the markdown comment body that we'll attempt to set on the
|
|
// pull request
|
|
func (r *CopyPullRequestRes) CommentBody(err error) string {
|
|
if r == nil {
|
|
return "no copy pull request response has been initialized"
|
|
}
|
|
|
|
t := r.ToTable(err)
|
|
if err == nil {
|
|
t.SetTitle("Copy workflow completed!")
|
|
return t.RenderMarkdown()
|
|
}
|
|
|
|
if t.Length() == 0 {
|
|
// If we don't have any rows in our table then there's no need to render a
|
|
// table so we'll just return an error
|
|
return "## Copying pull request failed!\n\nError: " + err.Error()
|
|
}
|
|
|
|
// Render out our table but put the error message in the caption
|
|
t.SetTitle("Copy pull request failed!")
|
|
// Set the caption to the top-level error only as any attempt errors are
|
|
// nested in the table.
|
|
t.SetCaption("Error: " + err.Error())
|
|
|
|
return t.RenderMarkdown()
|
|
}
|
|
|
|
// ToJSON marshals the response to JSON.
|
|
func (r *CopyPullRequestRes) ToJSON() ([]byte, error) {
|
|
b, err := json.Marshal(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling list changed files to JSON: %w", err)
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
// ToTable marshals the response to a text table.
|
|
func (r *CopyPullRequestRes) ToTable(err error) 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{
|
|
"From", "To", "Error",
|
|
})
|
|
|
|
row := table.Row{nil, nil}
|
|
if r.Request != nil {
|
|
from := r.Request.FromOwner + "/" + r.Request.FromRepo
|
|
if pr := r.OriginPullRequest; pr != nil {
|
|
from = fmt.Sprintf("[%s#%d](%s)", from, pr.GetID(), pr.GetHTMLURL())
|
|
}
|
|
to := r.Request.ToOwner + "/" + r.Request.ToRepo
|
|
if pr := r.PullRequest; pr != nil {
|
|
to = fmt.Sprintf("[%s#%d](%s)", to, pr.GetID(), pr.GetHTMLURL())
|
|
}
|
|
row = table.Row{from, to}
|
|
}
|
|
if err != nil {
|
|
row = append(row, err.Error())
|
|
} else {
|
|
row = append(row, nil)
|
|
}
|
|
t.AppendRow(row)
|
|
|
|
t.SuppressEmptyColumns()
|
|
t.SuppressTrailingSpaces()
|
|
|
|
return t
|
|
}
|
|
|
|
func (r *CopyPullRequestReq) getCoAuthoredByTrailers(commits []*libgithub.RepositoryCommit) []string {
|
|
if len(commits) < 1 {
|
|
return nil
|
|
}
|
|
|
|
seen := map[string]struct{}{}
|
|
trailers := []string{}
|
|
|
|
for _, repoCommit := range commits {
|
|
commit := repoCommit.GetCommit()
|
|
if commit == nil {
|
|
continue
|
|
}
|
|
author := commit.GetAuthor()
|
|
if author == nil {
|
|
continue
|
|
}
|
|
email := author.GetEmail()
|
|
if email == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[email]; ok {
|
|
continue
|
|
}
|
|
seen[email] = struct{}{}
|
|
trailers = append(trailers, fmt.Sprintf("Co-Authored-By: %s <%s>", author.GetName(), email))
|
|
}
|
|
|
|
return trailers
|
|
}
|
|
|
|
func (r *CopyPullRequestReq) getApproverLogins(reviews []*libgithub.PullRequestReview) []string {
|
|
if len(reviews) < 1 {
|
|
return nil
|
|
}
|
|
|
|
logins := map[string]struct{}{}
|
|
for _, review := range reviews {
|
|
if review == nil || review.State == nil || *review.State != "APPROVED" {
|
|
continue
|
|
}
|
|
if login := review.GetUser().GetLogin(); login != "" {
|
|
logins[login] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return slices.Sorted(maps.Keys(logins))
|
|
}
|