vault/tools/pipeline/internal/pkg/github/sync_branch_request.go
Vault Automation 6a71edd6dc
[VAULT-39996] pipeline(sync): add support for checking changed files (#12220) (#12296)
Signed-off-by: Ryan Cragun <me@ryan.ec>
Co-authored-by: Ryan Cragun <me@ryan.ec>
2026-02-11 15:15:26 -07:00

267 lines
7.5 KiB
Go

// Copyright IBM Corp. 2016, 2025
// SPDX-License-Identifier: BUSL-1.1
package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
libgithub "github.com/google/go-github/v81/github"
gitpkg "github.com/hashicorp/vault/tools/pipeline/internal/pkg/git"
gitclient "github.com/hashicorp/vault/tools/pipeline/internal/pkg/git/client"
"github.com/jedib0t/go-pretty/v6/table"
slogctx "github.com/veqryn/slog-context"
)
// SyncBranchReq is a request to synchronize two github hosted branches with
// a git merge from one into another.
//
// NOTE: We require that both branches exist for the operation to succeed.
type SyncBranchReq struct {
// Repository and branch information
FromOwner string
FromRepo string
FromOrigin string
FromBranch string
ToOwner string
ToRepo string
ToOrigin string
ToBranch string
RepoDir string
// Optional changed files checking
CheckGroups []string
}
// SyncBranchRes is a copy pull request response.
type SyncBranchRes struct {
Error error `json:"error,omitempty"`
Request *SyncBranchReq `json:"request,omitempty"`
}
// Run runs the request to synchronize a branches via a merge.
func (r *SyncBranchReq) Run(
ctx context.Context,
github *libgithub.Client,
git *gitclient.Client,
) (*SyncBranchRes, error) {
var err error
res := &SyncBranchRes{Request: r}
checkGroupsStr := strings.Join(r.CheckGroups, ", ")
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("from-branch", r.FromBranch),
slog.String("to-owner", r.ToOwner),
slog.String("to-repo", r.ToRepo),
slog.String("to-origin", r.ToOrigin),
slog.String("to-branch", r.ToBranch),
slog.String("repo-dir", r.RepoDir),
slog.String("disallowed-groups", checkGroupsStr),
), "synchronizing branches")
// 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)
}
// Clone the remote repository and fetch the branch we're going to merge into.
// 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, r.ToBranch,
)
} else {
err = initializeNewRepo(
ctx, git, r.RepoDir, r.ToOwner, r.ToRepo, r.ToOrigin, r.ToBranch,
)
}
if err != nil {
return res, err
}
// Check out our branch. Our intialization above will ensure we have a local
// reference.
slog.Default().DebugContext(ctx, "checking out to-branch")
checkoutRes, err := git.Checkout(ctx, &gitclient.CheckoutOpts{
Branch: r.ToBranch,
})
if err != nil {
return res, fmt.Errorf("checking out to-branch: %s: %w", checkoutRes.String(), err)
}
// Add our from upstream as a remote and fetch our from branch.
slog.Default().DebugContext(ctx, "adding from upstream and fetching from-branch")
remoteRes, err := git.Remote(ctx, &gitclient.RemoteOpts{
Command: gitclient.RemoteCommandAdd,
Track: []string{r.FromBranch},
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 from branch: %s, %w", remoteRes.String(), err)
return res, err
}
// Use our remote reference as our fromBranch as we haven't created a local
// reference.
fromBranch := "remotes/" + r.FromOrigin + "/" + r.FromBranch
// Verify that our local branch does not contain and files that are in
// disallowed changed files groups.
if len(r.CheckGroups) > 0 {
slog.Default().DebugContext(ctx, "checking branch history for changed files in disallowed groups")
checkChangedFiles := gitpkg.CheckChangedFilesReq{
// Using the branch option here will inspect the entirely history of
// added files to the branch to ensure that we don't accidentally have
// some disallowed files in the branch history.
Branch: fromBranch,
CheckGroups: r.CheckGroups,
WriteToGithubOutput: false,
}
checkChangedFilesRes, err := checkChangedFiles.Run(ctx, git)
if err != nil {
return res, fmt.Errorf("checking branch history for changed files: %s: %w", checkChangedFilesRes.String(), err)
}
if l := len(checkChangedFilesRes.MatchedFiles); l > 0 {
return res, fmt.Errorf(
"found %d files that matched disallowed-groups %s: %s",
l, checkGroupsStr, strings.Join(checkChangedFilesRes.MatchedFiles.Names(), ", "),
)
}
}
slog.Default().DebugContext(ctx, "merging from-branch into to-branch")
mergeRes, err := git.Merge(ctx, &gitclient.MergeOpts{
NoVerify: true,
Strategy: gitclient.MergeStrategyORT,
StrategyOptions: []gitclient.MergeStrategyOption{
gitclient.MergeStrategyOptionTheirs,
gitclient.MergeStrategyOptionIgnoreSpaceChange,
},
IntoName: r.ToBranch,
Commit: fromBranch,
})
if err != nil {
return res, fmt.Errorf("merging from-branch into to-branch: %s: %w", mergeRes.String(), err)
}
slog.Default().DebugContext(ctx, "pushing to-branch")
pushRes, err := git.Push(ctx, &gitclient.PushOpts{
Repository: r.ToOrigin,
Refspec: []string{r.ToBranch},
})
if err != nil {
return res, fmt.Errorf("pushing to-branch: %s: %w", pushRes.String(), err)
}
return res, nil
}
// 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 *SyncBranchReq) 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.FromBranch == "" {
return errors.New("no github from branch 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.ToBranch == "" {
return errors.New("no github to branch has been provided")
}
return nil
}
// ToJSON marshals the response to JSON.
func (r *SyncBranchRes) 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 *SyncBranchRes) 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",
})
if r.Request != nil {
from := r.Request.FromOwner + "/" + r.Request.FromRepo + "/" + r.Request.FromBranch
to := r.Request.ToOwner + "/" + r.Request.ToRepo + "/" + r.Request.ToBranch
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
}