diff --git a/tools/pipeline/go.mod b/tools/pipeline/go.mod index 8db1d688c2..3cfcc6567f 100644 --- a/tools/pipeline/go.mod +++ b/tools/pipeline/go.mod @@ -1,6 +1,6 @@ module github.com/hashicorp/vault/tools/pipeline -go 1.23.2 +go 1.24.0 require ( github.com/Masterminds/semver v1.5.0 @@ -9,10 +9,12 @@ require ( github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/releases-api v0.2.3 github.com/jedib0t/go-pretty/v6 v6.6.8 + github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/veqryn/slog-context v0.8.0 github.com/zclconf/go-cty v1.16.4 + golang.org/x/oauth2 v0.31.0 ) require ( @@ -57,6 +59,7 @@ require ( github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/spf13/pflag v1.0.7 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/tools/pipeline/go.sum b/tools/pipeline/go.sum index 759ad46f75..82b130e298 100644 --- a/tools/pipeline/go.sum +++ b/tools/pipeline/go.sum @@ -171,6 +171,10 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/secure-systems-lab/go-securesystemslib v0.7.0 h1:OwvJ5jQf9LnIAS83waAjPbcMsODrTQUpJ02eNLUoxBg= github.com/secure-systems-lab/go-securesystemslib v0.7.0/go.mod h1:/2gYnlnHVQ6xeGtfIqFy7Do03K4cdCY0A/GlJLDKLHI= +github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= +github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -209,6 +213,8 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/tools/pipeline/internal/cmd/github.go b/tools/pipeline/internal/cmd/github.go index 9beba8db11..6ada37c38e 100644 --- a/tools/pipeline/internal/cmd/github.go +++ b/tools/pipeline/internal/cmd/github.go @@ -4,23 +4,28 @@ package cmd import ( + "context" "fmt" "os" "path/filepath" "github.com/google/go-github/v74/github" "github.com/hashicorp/vault/tools/pipeline/internal/pkg/git" + "github.com/shurcooL/githubv4" "github.com/spf13/cobra" + "golang.org/x/oauth2" ) type githubCommandState struct { - Github *github.Client - Git *git.Client + Git *git.Client + GithubV3 *github.Client + GithubV4 *githubv4.Client } var githubCmdState = &githubCommandState{ - Github: github.NewClient(nil), - Git: git.NewClient(git.WithLoadTokenFromEnv()), + GithubV3: github.NewClient(nil), + GithubV4: githubv4.NewClient(nil), + Git: git.NewClient(git.WithLoadTokenFromEnv()), } func newGithubCmd() *cobra.Command { @@ -28,16 +33,24 @@ func newGithubCmd() *cobra.Command { Use: "github", Short: "Github commands", Long: "Github commands", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if token, set := os.LookupEnv("GITHUB_TOKEN"); set { + githubCmdState.GithubV3 = githubCmdState.GithubV3.WithAuthToken(token) + githubCmdState.GithubV4 = githubv4.NewClient( + oauth2.NewClient(context.Background(), + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), + ), + ) + } else { + fmt.Println("\x1b[1;33;49mWARNING\x1b[0m: GITHUB_TOKEN has not been set. While not always required for read actions on public repositories you're likely to get throttled without it") + } + + return nil + }, } - githubCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - if token, set := os.LookupEnv("GITHUB_TOKEN"); set { - githubCmdState.Github = githubCmdState.Github.WithAuthToken(token) - } else { - fmt.Println("\x1b[1;33;49mWARNING\x1b[0m: GITHUB_TOKEN has not been set. While not always required for read actions on public repositories you're likely to get throttled without it") - } - return nil - } + githubCmd.AddCommand(newGithubCheckCmd()) + githubCmd.AddCommand(newGithubCloseCmd()) githubCmd.AddCommand(newGithubCopyCmd()) githubCmd.AddCommand(newGithubCreateCmd()) githubCmd.AddCommand(newGithubFindCmd()) diff --git a/tools/pipeline/internal/cmd/github_check_commit_status.go b/tools/pipeline/internal/cmd/github_check_commit_status.go index 6f44d24514..93d4c740f3 100644 --- a/tools/pipeline/internal/cmd/github_check_commit_status.go +++ b/tools/pipeline/internal/cmd/github_check_commit_status.go @@ -41,7 +41,7 @@ func newCheckGithubCommitStatusCmd() *cobra.Command { func runCheckGithubCommitStatusCmd(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Don't spam the usage on failure - res, err := checkGithubCommitStatusReq.Run(context.TODO(), githubCmdState.Github) + res, err := checkGithubCommitStatusReq.Run(context.TODO(), githubCmdState.GithubV3) if err != nil { return err } diff --git a/tools/pipeline/internal/cmd/github_close.go b/tools/pipeline/internal/cmd/github_close.go new file mode 100644 index 0000000000..529217cd6e --- /dev/null +++ b/tools/pipeline/internal/cmd/github_close.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func newGithubCloseCmd() *cobra.Command { + closeCmd := &cobra.Command{ + Use: "close", + Short: "Github close commands", + Long: "Github close commands", + } + closeCmd.AddCommand(newCloseGithubCopiedPullRequestCmd()) + + return closeCmd +} diff --git a/tools/pipeline/internal/cmd/github_close_origin_pull_request.go b/tools/pipeline/internal/cmd/github_close_origin_pull_request.go new file mode 100644 index 0000000000..2b4f756c53 --- /dev/null +++ b/tools/pipeline/internal/cmd/github_close_origin_pull_request.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/vault/tools/pipeline/internal/pkg/github" + "github.com/spf13/cobra" +) + +var closeCopiedOriginPullRequestReq = github.CloseCopiedOriginPullRequestReq{} + +func newCloseGithubCopiedPullRequestCmd() *cobra.Command { + closeCopiedOriginPRCmd := &cobra.Command{ + Use: "origin-pull-request [number]", + Short: "Close the origin pull request of a copied pull request", + RunE: runCloseGithubCopiedPullRequestCmd, + Args: argsOnlyPRNumber(&closeCopiedOriginPullRequestReq.PullNumber), + } + + closeCopiedOriginPRCmd.PersistentFlags().StringVarP(&closeCopiedOriginPullRequestReq.Owner, "owner", "o", "hashicorp", "The Github organization") + closeCopiedOriginPRCmd.PersistentFlags().StringVarP(&closeCopiedOriginPullRequestReq.Repo, "repo", "r", "vault-enterprise", "The Github repository. Private repositories require auth via a GITHUB_TOKEN env var") + + return closeCopiedOriginPRCmd +} + +func runCloseGithubCopiedPullRequestCmd(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true // Don't spam the usage on failure + + res, err := closeCopiedOriginPullRequestReq.Run(context.TODO(), githubCmdState.GithubV3, githubCmdState.GithubV4) + switch rootCfg.format { + case "json": + b, err1 := res.ToJSON() + if err != nil { + return errors.Join(err, err1) + } + fmt.Println(string(b)) + case "markdown": + tbl := res.ToTable(err) + tbl.SetTitle("Close Origin Pull Request") + fmt.Println(tbl.RenderMarkdown()) + default: + fmt.Println(res.ToTable(err).Render()) + } + + return err +} diff --git a/tools/pipeline/internal/cmd/github_copy_pull_request.go b/tools/pipeline/internal/cmd/github_copy_pull_request.go index cc8631bbd1..7b700a8c86 100644 --- a/tools/pipeline/internal/cmd/github_copy_pull_request.go +++ b/tools/pipeline/internal/cmd/github_copy_pull_request.go @@ -22,25 +22,7 @@ func newCopyGithubPullRequestCmd() *cobra.Command { Short: "Copy a pull request", Long: "Copy a pull request from the Community repository to the Enterprise repository", RunE: runCopyGithubPullRequestCmd, - Args: func(cmd *cobra.Command, args []string) error { - switch len(args) { - case 1: - pr, err := strconv.ParseUint(args[0], 10, 0) - if err != nil { - return fmt.Errorf("invalid pull number: %s: %w", args[0], err) - } - if pr <= math.MaxUint32 { - copyGithubPullRequestReq.PullNumber = uint(pr) - } else { - return fmt.Errorf("invalid pull number: %s: number is too large", args[0]) - } - return nil - case 0: - return errors.New("no pull request number has been provided") - default: - return fmt.Errorf("invalid arguments: only pull request number is expected, received %d arguments: %v", len(args), args) - } - }, + Args: argsOnlyPRNumber(©GithubPullRequestReq.PullNumber), } copyPRCmd.PersistentFlags().StringVar(©GithubPullRequestReq.FromOrigin, "from-origin", "ce", "The name to use for the base remote origin") @@ -58,7 +40,7 @@ func newCopyGithubPullRequestCmd() *cobra.Command { func runCopyGithubPullRequestCmd(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Don't spam the usage on failure - res, err := copyGithubPullRequestReq.Run(context.TODO(), githubCmdState.Github, githubCmdState.Git) + res, err := copyGithubPullRequestReq.Run(context.TODO(), githubCmdState.GithubV3, githubCmdState.Git) switch rootCfg.format { case "json": @@ -77,3 +59,25 @@ func runCopyGithubPullRequestCmd(cmd *cobra.Command, args []string) error { return err } + +func argsOnlyPRNumber(dest *uint) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + switch len(args) { + case 1: + pr, err := strconv.ParseUint(args[0], 10, 0) + if err != nil { + return fmt.Errorf("invalid pull number: %s: %w", args[0], err) + } + if pr <= math.MaxUint32 { + *dest = uint(pr) + } else { + return fmt.Errorf("invalid pull number: %s: number is too large", args[0]) + } + return nil + case 0: + return errors.New("no pull request number has been provided") + default: + return fmt.Errorf("invalid arguments: only pull request number is expected, received %d arguments: %v", len(args), args) + } + } +} diff --git a/tools/pipeline/internal/cmd/github_create_backport.go b/tools/pipeline/internal/cmd/github_create_backport.go index 67b377f216..90a9941267 100644 --- a/tools/pipeline/internal/cmd/github_create_backport.go +++ b/tools/pipeline/internal/cmd/github_create_backport.go @@ -93,7 +93,7 @@ func runCreateGithubBackportCmd(cmd *cobra.Command, args []string) error { createGithubBackportState.req.CEExclude = createGithubBackportState.req.CEExclude.Add(changed.FileGroup(eg)) } - res := createGithubBackportState.req.Run(context.TODO(), githubCmdState.Github, githubCmdState.Git) + res := createGithubBackportState.req.Run(context.TODO(), githubCmdState.GithubV3, githubCmdState.Git) if res == nil { res = &github.CreateBackportRes{} } diff --git a/tools/pipeline/internal/cmd/github_find_workflow_artifact.go b/tools/pipeline/internal/cmd/github_find_workflow_artifact.go index 250b24bf78..6643c0c466 100644 --- a/tools/pipeline/internal/cmd/github_find_workflow_artifact.go +++ b/tools/pipeline/internal/cmd/github_find_workflow_artifact.go @@ -35,7 +35,7 @@ func newGithubFindWorkflowArtifactCmd() *cobra.Command { func runFindGithubWorkflowArtifactCmd(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Don't spam the usage on failure - res, err := findWorkflowArtifact.Run(context.TODO(), githubCmdState.Github) + res, err := findWorkflowArtifact.Run(context.TODO(), githubCmdState.GithubV3) if err != nil { return fmt.Errorf("finding workflow artifact: %w", err) } diff --git a/tools/pipeline/internal/cmd/github_list_changed_files.go b/tools/pipeline/internal/cmd/github_list_changed_files.go index 3d19b72a6b..19e49949e6 100644 --- a/tools/pipeline/internal/cmd/github_list_changed_files.go +++ b/tools/pipeline/internal/cmd/github_list_changed_files.go @@ -34,7 +34,7 @@ func newGithubListChangedFilesCmd() *cobra.Command { func runListGithubChangedFilesCmd(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Don't spam the usage on failure - res, err := listGithubChangedFiles.Run(context.TODO(), githubCmdState.Github) + res, err := listGithubChangedFiles.Run(context.TODO(), githubCmdState.GithubV3) if err != nil { return fmt.Errorf("listing changed files: %w", err) } diff --git a/tools/pipeline/internal/cmd/github_list_commit_statuses.go b/tools/pipeline/internal/cmd/github_list_commit_statuses.go index 64ad88032e..22dbd368a6 100644 --- a/tools/pipeline/internal/cmd/github_list_commit_statuses.go +++ b/tools/pipeline/internal/cmd/github_list_commit_statuses.go @@ -33,7 +33,7 @@ func newGithubListCommitStatusesCmd() *cobra.Command { func runListGithubCommitStatuses(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Don't spam the usage on failure - res, err := listGithubCommitStatuses.Run(context.TODO(), githubCmdState.Github) + res, err := listGithubCommitStatuses.Run(context.TODO(), githubCmdState.GithubV3) if err != nil { return fmt.Errorf("listing github commit statuses: %w", err) } diff --git a/tools/pipeline/internal/cmd/github_list_runs.go b/tools/pipeline/internal/cmd/github_list_runs.go index c8dc3c451d..7b88dd2ff3 100644 --- a/tools/pipeline/internal/cmd/github_list_runs.go +++ b/tools/pipeline/internal/cmd/github_list_runs.go @@ -53,7 +53,7 @@ func newGithubListWorkflowRunsCmd() *cobra.Command { func runListGithubWorkflowsCmd(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Don't spam the usage on failure - res, err := listGithubWorkflowRuns.Run(context.TODO(), githubCmdState.Github) + res, err := listGithubWorkflowRuns.Run(context.TODO(), githubCmdState.GithubV3) if err != nil { return fmt.Errorf("listing github workflow runs: %w", err) } diff --git a/tools/pipeline/internal/cmd/github_sync_branch.go b/tools/pipeline/internal/cmd/github_sync_branch.go index d603516663..64af01949d 100644 --- a/tools/pipeline/internal/cmd/github_sync_branch.go +++ b/tools/pipeline/internal/cmd/github_sync_branch.go @@ -47,7 +47,7 @@ func newSyncGithubBranchCmd() *cobra.Command { func runSyncGithubBranchCmd(cmd *cobra.Command, args []string) error { cmd.SilenceUsage = true // Don't spam the usage on failure - res, err := syncGithubBranchReq.Run(context.TODO(), githubCmdState.Github, githubCmdState.Git) + res, err := syncGithubBranchReq.Run(context.TODO(), githubCmdState.GithubV3, githubCmdState.Git) switch rootCfg.format { case "json": diff --git a/tools/pipeline/internal/pkg/github/close_copied_origin_pull_request.go b/tools/pipeline/internal/pkg/github/close_copied_origin_pull_request.go new file mode 100644 index 0000000000..744a008e1e --- /dev/null +++ b/tools/pipeline/internal/pkg/github/close_copied_origin_pull_request.go @@ -0,0 +1,366 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package github + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + + libgithub "github.com/google/go-github/v74/github" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/shurcooL/githubv4" + slogctx "github.com/veqryn/slog-context" +) + +// CloseCopiedOriginPullRequestReq is a request to copy a pull request from the CE repo to +// the Ent repo. +type CloseCopiedOriginPullRequestReq struct { + Owner string + Repo string + PullNumber uint +} + +// CloseCopiedOriginPullRequestRes is a copy pull request response. +type CloseCopiedOriginPullRequestRes struct { + CopiedClosingIssues []*ClosingIssueRef `json:"copied_closing_issues,omitempty"` + CopiedComment *libgithub.IssueComment `json:"copy_comment,omitempty"` + CopiedPullRequest *libgithub.PullRequest `json:"copy_pull_request,omitempty"` + OriginClosingIssues []*ClosingIssueRef `json:"origin_associated_issues,omitempty"` + OriginComment *libgithub.IssueComment `json:"origin_comment,omitempty"` + OriginPullRequest *libgithub.PullRequest `json:"origin_pull_request,omitempty"` +} + +// ClosingIssueRefs represents our Github GraphQL query for finding issues +// associated with our Pull Request that ought to be automatically closed. +// +// The raw query looks something like: +// +// { +// repository(owner: $owner, name: $repo) { +// pullRequest(number: $number) { +// repository { +// nameWithOwner +// } +// number +// closingIssuesReferences(first: 100) { +// edges { +// node { +// url +// number +// title +// closed +// repository { +// nameWithOwner +// } +// } +// } +// } +// } +// } +// } +type ClosingIssueRefs struct { + Repository struct { + PullRequest struct { + Repository struct { + NameWithOwner string `json:"name_with_owner,omitempty"` + } `json:"repository"` + Number int `json:"number,omitempty"` + ClosingIssuesReferences struct { + Edges []struct { + Node *ClosingIssueRef `json:"node"` + } `json:"edges"` + } `json:"closing_issues_references" graphql:"closingIssuesReferences(first: 100)"` + } `json:"pull_request" graphql:"pullRequest(number: $number)"` + } `json:"repository" graphql:"repository(owner: $owner, name: $repo)"` +} + +// ClosingIssueRef is an issue that is associated with a pull request. +type ClosingIssueRef struct { + URL string `json:"url,omitempty" graphql:"url"` + Number int `json:"number,omitempty"` + Title string `json:"title,omitempty"` + Closed bool `json:"closed,omitempty"` + Repository struct { + NameWithOwner string `json:"name_with_owner,omitempty"` + } `json:"repository"` +} + +// Run runs the request to copy a pull request from the CE repo to the Ent repo. +func (r *CloseCopiedOriginPullRequestReq) Run( + ctx context.Context, + githubV3 *libgithub.Client, + githubV4 *githubv4.Client, +) (*CloseCopiedOriginPullRequestRes, error) { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("owner", r.Owner), + slog.String("repo", r.Repo), + slog.Uint64("pull-number", uint64(r.PullNumber)), + ), "closing copied pull request") + + res := &CloseCopiedOriginPullRequestRes{ + OriginClosingIssues: []*ClosingIssueRef{}, + CopiedClosingIssues: []*ClosingIssueRef{}, + } + var err error + originOwner, originRepo := "", "" + var originNumber uint = 0 + + // Whenever possible we try to update base pull request with a status update + // on how the copying has gone. + createComments := func() { + // Make sure we return a response even if we fail + if res == nil { + res = &CloseCopiedOriginPullRequestRes{ + OriginClosingIssues: []*ClosingIssueRef{}, + CopiedClosingIssues: []*ClosingIssueRef{}, + } + } + + var err1, err2 error + res.CopiedComment, err1 = createPullRequestComment( + ctx, + githubV3, + r.Owner, + r.Repo, + int(r.PullNumber), + res.copiedCommentBody(err), + ) + + res.OriginComment, err2 = createPullRequestComment( + ctx, + githubV3, + originOwner, + originRepo, + int(originNumber), + res.originCommentBody(err), + ) + + // Set our finalized error on our response and also update our returned error + err = errors.Join(err, err1, err2) + } + defer createComments() + + err = r.validate() + if err != nil { + return res, err + } + + // Get the pull details of the copied PR + res.CopiedPullRequest, err = getPullRequest(ctx, githubV3, r.Owner, r.Repo, int(r.PullNumber)) + if err != nil { + return res, err + } + + // Determine the origin PR from the copied PR branch name + originOwner, originRepo, originNumber, _, err = decodeCopyPullRequestBranch(res.CopiedPullRequest.GetHead().GetRef()) + if err != nil { + return res, err + } + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("origin-owner", originOwner), + slog.String("origin-repo", originRepo), + slog.Uint64("origin-pull-number", uint64(originNumber)), + ), "decoded origin pull request information from copied pull request") + + // Get the pull details of the origin PR + res.OriginPullRequest, err = getPullRequest(ctx, githubV3, originOwner, originRepo, int(originNumber)) + if err != nil { + return res, err + } + + // Close the origin PR if it's not closed already + if res.OriginPullRequest.GetState() != "closed" { + err = closePullRequest(ctx, githubV3, originOwner, originRepo, int(originNumber)) + if err != nil { + return res, fmt.Errorf("unable to close origin pull request: %w", err) + } + } else { + slog.Default().DebugContext(ctx, "origin pull request has already been closed") + } + + // Close any issues associated with either the origin or copied PRs + res.OriginClosingIssues, err = listPullRequestClosingIssues( + ctx, githubV4, originOwner, originRepo, int(originNumber), + ) + if err != nil { + return res, err + } + + res.CopiedClosingIssues, err = listPullRequestClosingIssues( + ctx, githubV4, r.Owner, r.Repo, int(r.PullNumber), + ) + if err != nil { + return res, err + } + + slog.Default().DebugContext(ctx, "closing any open associated issues") + for _, issue := range slices.CompactFunc( + append(res.OriginClosingIssues, res.CopiedClosingIssues...), + closingIssueRefEqual, + ) { + if issue.Closed { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("repository", issue.Repository.NameWithOwner), + slog.Int("number", issue.Number), + ), "associated issue is already closed") + continue + } + + nwo := issue.Repository.NameWithOwner + parts := strings.SplitN(nwo, "/", 2) + if len(parts) != 2 { + return res, fmt.Errorf("could not determine repo and owner from associated issue %d, got: %s", + issue.Number, + nwo, + ) + } + + err = closeIssue(ctx, githubV3, parts[0], parts[1], issue.Number) + if err != nil { + return res, fmt.Errorf("unable to close associated issue: %w", err) + } + } + + return res, nil +} + +// validate ensures that we've been given all required fields necessary to +// perform the request. +func (r *CloseCopiedOriginPullRequestReq) validate() error { + if r == nil { + return errors.New("failed to initialize request") + } + + if r.Owner == "" { + return errors.New("no github owner has been provided") + } + + if r.Repo == "" { + return errors.New("no github repository has been provided") + } + + if r.PullNumber == 0 { + return errors.New("no github pull request number has been provided") + } + + return nil +} + +// copiedCommentBody is the markdown comment body that we'll attempt to set on +// the copied pull request. +func (r *CloseCopiedOriginPullRequestRes) copiedCommentBody(err error) string { + if r == nil { + return "no close copied origin pull request response has been initialized" + } + + t := r.ToTable(err) + if err == nil { + t.SetTitle("Origin pull request and all associated issues have been closed!") + 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 "## Closing origin pull request failed!\n\nError: " + err.Error() + } + + // Render out our table but put the error message in the caption + t.SetTitle("Closing origin 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() +} + +// originCommentBody is the markdown comment body that we'll attempt to set on +// the origin pull request. +func (r *CloseCopiedOriginPullRequestRes) originCommentBody(err error) string { + if r == nil { + return "no close copied origin pull request response has been initialized" + } + + t := r.ToTable(err) + if err == nil { + t.SetTitle("Copied pull request has been merged!") + 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 "## Copied pull request has been merged!\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 *CloseCopiedOriginPullRequestRes) 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 *CloseCopiedOriginPullRequestRes) 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{ + "Origin Pull Request", "Copied Pull Request", "Commit SHA", "Error", + }) + row := table.Row{ + r.OriginPullRequest.GetHTMLURL(), + r.CopiedPullRequest.GetHTMLURL(), + fmt.Sprintf( + "https://github.com/%s/commit/%s", + r.CopiedPullRequest.GetHead().GetRepo().GetFullName(), + r.CopiedPullRequest.GetMergeCommitSHA(), + ), + } + if err != nil { + row = append(row, err.Error()) + } + + t.AppendRow(row) + + closedIssues := slices.CompactFunc( + append(r.OriginClosingIssues, r.CopiedClosingIssues...), + closingIssueRefEqual, + ) + if len(closedIssues) > 0 { + urls := []string{} + for _, ci := range closedIssues { + urls = append(urls, ci.URL) + } + t.AppendFooter(table.Row{ + "", "Closed Issues", strings.Join(urls, "\n"), + }) + } + + t.SuppressEmptyColumns() + t.SuppressTrailingSpaces() + + return t +} diff --git a/tools/pipeline/internal/pkg/github/close_copied_origin_pull_request_test.go b/tools/pipeline/internal/pkg/github/close_copied_origin_pull_request_test.go new file mode 100644 index 0000000000..718227259e --- /dev/null +++ b/tools/pipeline/internal/pkg/github/close_copied_origin_pull_request_test.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package github + +import ( + "context" + "os" + "testing" + + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// TestAcc_AssociatedIssues does an actual request against a known PR to check +// whether or not or query works as expected. +func TestAcc_AssociatedIssues(t *testing.T) { + t.Parallel() + + token, setToken := os.LookupEnv("GITHUB_TOKEN") + _, setACC := os.LookupEnv("PIPELINE_ACC") + if !setACC && setToken { + t.Skip("GITHUB_TOKEN and PIPELINE_ACC are not set") + } + + github := githubv4.NewClient( + oauth2.NewClient(context.Background(), + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), + ), + ) + + ai := &ClosingIssueRefs{} + require.NoError(t, github.Query(t.Context(), ai, map[string]any{ + "owner": githubv4.String("hashicorp"), + "repo": githubv4.String("vault-enterprise"), + "number": githubv4.Int(9484), + })) + + require.Equal(t, "hashicorp/vault-enterprise", ai.Repository.PullRequest.Repository.NameWithOwner) + require.Equal(t, 9484, ai.Repository.PullRequest.Number) + require.Len(t, ai.Repository.PullRequest.ClosingIssuesReferences.Edges, 1) + require.True(t, ai.Repository.PullRequest.ClosingIssuesReferences.Edges[0].Node.Closed) + require.Equal(t, 31545, ai.Repository.PullRequest.ClosingIssuesReferences.Edges[0].Node.Number) + require.Equal(t, + "https://github.com/hashicorp/vault/issues/31545", + ai.Repository.PullRequest.ClosingIssuesReferences.Edges[0].Node.URL, + ) +} diff --git a/tools/pipeline/internal/pkg/github/copy_pull_request.go b/tools/pipeline/internal/pkg/github/copy_pull_request.go index 13f5182ff9..cb3cdcda2f 100644 --- a/tools/pipeline/internal/pkg/github/copy_pull_request.go +++ b/tools/pipeline/internal/pkg/github/copy_pull_request.go @@ -165,8 +165,10 @@ func (r *CopyPullRequestReq) Run( return res, err } - // Create a new branch for our copied changes. - branchName := r.copyBranchNameForRef(baseRef, prBranch) + // Create a new branch for our copied changes. Encode the details of our origin + // pull request into the branch name so that future post-merge operations can + // determine the origin PR using only the branch name. + branchName := encodeCopyPullRequestBranch(r.FromOwner, r.FromRepo, r.PullNumber, 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") @@ -338,21 +340,6 @@ func (r *CopyPullRequestReq) Run( 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. diff --git a/tools/pipeline/internal/pkg/github/copy_pull_request_branch.go b/tools/pipeline/internal/pkg/github/copy_pull_request_branch.go new file mode 100644 index 0000000000..1d7bfd60e5 --- /dev/null +++ b/tools/pipeline/internal/pkg/github/copy_pull_request_branch.go @@ -0,0 +1,126 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package github + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +// copyBranchPrefix is the prefix for copied pull request branches. It is used +// both for copying pull requests and for closing copied pull requests. +const copyBranchPrefix = "copy" + +// encodeCopyPullRequestBranch returns the branch name to use for our PR copy operation. +// The branch name is important because we trigger post-merge automation based +// on the branch prefix. We also encode origin PR information for use in +// post-merge operations. +// +// The format is: //// +// eg: copy/hashicorp/vault/99999/my-feature-branch +// +// This informs the post-merge close operations that the Pull Request was originally +// copied from hashicorp/vault's Pull Request number 99999 and that the branch +// name of the pull request is my-feature-branch. +// +// It's imporant that both the encodeCopyPullRequestBranch and decodeCopyPullRequestBranch +// stay in sync. +func encodeCopyPullRequestBranch( + owner string, + repo string, + number uint, + prBranch string, +) string { + prNumber := strconv.Itoa(int(number)) + name := strings.Join([]string{ + copyBranchPrefix, + owner, + repo, + prNumber, + prBranch, + }, "/") + if len(name) > 250 { + // Handle Githubs branch name max length + name = name[:250] + } + + return name +} + +// decodeCopyPullRequestBranch returns the encoded origin PR details from the +// copied Pull Request branch name. +// +// The format must be the same as what is described in encodeCopyPullRequestBranch() +func decodeCopyPullRequestBranch(ref string) ( + owner string, + repo string, + number uint, + branch string, + err error, +) { + if ref == "" { + err = errors.New("no copy branch provided") + return owner, repo, number, branch, err + } + + if !strings.HasPrefix(ref, copyBranchPrefix) { + err = fmt.Errorf("invalid copy branch: branch does not start with %s", copyBranchPrefix) + return owner, repo, number, branch, err + } + + // eg: copy/hashicorp/vault/99999/my-feature-branch + parts := strings.SplitN(ref, "/", 5) + if len(parts) < 5 { + err = fmt.Errorf("invalid copy branch: expected 5 parts, got %d", len(parts)) + return owner, repo, number, branch, err + } + owner = parts[1] + repo = parts[2] + + var signedNumber int + signedNumber, err = strconv.Atoi(parts[3]) + if err != nil { + err = fmt.Errorf("invalid copy branch: pull request number is not a number: %w", err) + return owner, repo, number, branch, err + } + if signedNumber < 0 { + err = fmt.Errorf("invalid copy branch: number must be positive, got %d", signedNumber) + return owner, repo, number, branch, err + } + number = uint(signedNumber) + + branch = parts[4] + + return owner, repo, number, branch, err +} + +func closingIssueRefEqual(a, b *ClosingIssueRef) bool { + if a == nil && b == nil { + return true + } + + if (a == nil && b != nil) || (a != nil && b == nil) { + return false + } + + if a.URL != b.URL { + return false + } + + if a.Number != b.Number { + return false + } + + if a.Title != b.Title { + return false + } + + if a.Repository.NameWithOwner != b.Repository.NameWithOwner { + return false + } + + return true +} diff --git a/tools/pipeline/internal/pkg/github/create_backport.go b/tools/pipeline/internal/pkg/github/create_backport.go index cca7a96628..5c3d30f306 100644 --- a/tools/pipeline/internal/pkg/github/create_backport.go +++ b/tools/pipeline/internal/pkg/github/create_backport.go @@ -91,7 +91,7 @@ type CreateBackportReq struct { // NewCreateBackportPRReq() type NewCreateBackportReqOpt func(*CreateBackportReq) -// CreateBackportPRReq is a respose of creating a backport pull request +// CreateBackportRes 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"` diff --git a/tools/pipeline/internal/pkg/github/issue.go b/tools/pipeline/internal/pkg/github/issue.go new file mode 100644 index 0000000000..58c7e60978 --- /dev/null +++ b/tools/pipeline/internal/pkg/github/issue.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package github + +import ( + "context" + "log/slog" + + libgithub "github.com/google/go-github/v74/github" + slogctx "github.com/veqryn/slog-context" +) + +// closeIssue closes an issue. +func closeIssue( + ctx context.Context, + github *libgithub.Client, + owner string, + repo string, + issueNumber int, +) error { + ctx = slogctx.Append(ctx, + slog.String("owner", owner), + slog.String("repo", repo), + slog.Int("issue-number", issueNumber), + ) + slog.Default().DebugContext(ctx, "closing issue") + + _, _, err := github.Issues.Edit(ctx, owner, repo, issueNumber, &libgithub.IssueRequest{ + State: libgithub.Ptr("closed"), + }) + + return err +} diff --git a/tools/pipeline/internal/pkg/github/pull_request.go b/tools/pipeline/internal/pkg/github/pull_request.go index d5d4c302b8..d0274b7802 100644 --- a/tools/pipeline/internal/pkg/github/pull_request.go +++ b/tools/pipeline/internal/pkg/github/pull_request.go @@ -9,6 +9,7 @@ import ( "log/slog" libgithub "github.com/google/go-github/v74/github" + "github.com/shurcooL/githubv4" slogctx "github.com/veqryn/slog-context" ) @@ -64,6 +65,28 @@ func getPullRequest( return pr, nil } +// closePullRequest closes a pull request. +func closePullRequest( + ctx context.Context, + github *libgithub.Client, + owner string, + repo string, + pullNumber int, +) error { + ctx = slogctx.Append(ctx, + slog.String("owner", owner), + slog.String("repo", repo), + slog.Int("pull-number", pullNumber), + ) + slog.Default().DebugContext(ctx, "closing pull request") + + _, _, err := github.PullRequests.Edit(ctx, owner, repo, pullNumber, &libgithub.PullRequest{ + State: libgithub.Ptr("closed"), + }) + + return err +} + // listPullRequestCommits lists all of the commits associated with a pull request. func listPullRequestCommits( ctx context.Context, @@ -127,3 +150,37 @@ func listPullRequestReviews( opts.Page = res.NextPage } } + +// listPullRequestClosingIssues lists all "closing issues" associated with +// a pull request. These can be set using keywords associations or manually +// using the "development" sidebar on either an issue or pull request. +func listPullRequestClosingIssues( + ctx context.Context, + github *githubv4.Client, + owner string, + repo string, + pullNumber int, +) ([]*ClosingIssueRef, error) { + slog.Default().DebugContext(slogctx.Append(ctx, + slog.String("owner", owner), + slog.String("repo", repo), + slog.Int("pull-number", pullNumber), + ), "getting pull request associated issues") + + oai := ClosingIssueRefs{} + err := github.Query(ctx, &oai, map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "number": githubv4.Int(pullNumber), + }) + if err != nil { + return nil, err + } + + ais := []*ClosingIssueRef{} + for _, ai := range oai.Repository.PullRequest.ClosingIssuesReferences.Edges { + ais = append(ais, ai.Node) + } + + return ais, nil +} diff --git a/tools/pipeline/internal/pkg/github/pull_request_test.go b/tools/pipeline/internal/pkg/github/pull_request_test.go new file mode 100644 index 0000000000..c1304344e4 --- /dev/null +++ b/tools/pipeline/internal/pkg/github/pull_request_test.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package github + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_encodeCopyPullRequest_Roundtrip(t *testing.T) { + t.Parallel() + + for desc, test := range map[string]struct { + owner string + repo string + number uint + prBranch string + expectedPRBranch string + }{ + "standard": { + owner: "hashicorp", + repo: "vault", + number: 49689, + prBranch: "my-feature-branch", + expectedPRBranch: "my-feature-branch", + }, + "super long": { + owner: "hashicorp", + repo: "vault", + number: 49689, + prBranch: "Lorem-ipsum-dolor-sit-amet--consectetur-adipiscing-elit--Praesent-accumsan-metus-sed-vehicula-accumsan--Nunc-semper-vehicula-tempor--Vestibulum-fringilla-enim-nec-ipsum-tincidunt-viverra--Etiam-iaculis-metus-ultricies-risus-rutrum--et-lobortis-orci-al", + // truncated the branch name as we'll hit our max char limit + expectedPRBranch: "Lorem-ipsum-dolor-sit-amet--consectetur-adipiscing-elit--Praesent-accumsan-metus-sed-vehicula-accumsan--Nunc-semper-vehicula-tempor--Vestibulum-fringilla-enim-nec-ipsum-tincidunt-viverra--Etiam-iaculis-metus-ultricies-risus", + }, + } { + t.Run(desc, func(t *testing.T) { + t.Parallel() + + encodedBranch := encodeCopyPullRequestBranch(test.owner, test.repo, test.number, test.prBranch) + owner, repo, number, branch, err := decodeCopyPullRequestBranch(encodedBranch) + require.NoError(t, err) + require.Equal(t, test.owner, owner) + require.Equal(t, test.repo, repo) + require.Equal(t, test.number, number) + require.Equal(t, test.expectedPRBranch, branch) + }) + } +}