Skip to content

feat: implement gh pr revert #8826

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: implement pr revert
  • Loading branch information
lucasmelin committed Mar 17, 2024
commit 1f7464a05d5c20738b433710cadaf576ffb969c4
22 changes: 19 additions & 3 deletions api/queries_pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,8 @@ func parseCheckStatusFromCheckConclusionState(state CheckConclusionState) checkS
func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
published := []PullRequestReview{}
for _, prr := range pr.Reviews.Nodes {
//Dont display pending reviews
//Dont display commenting reviews without top level comment body
// Dont display pending reviews
// Dont display commenting reviews without top level comment body
if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") {
published = append(published, prr)
}
Expand Down Expand Up @@ -561,7 +561,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
reviewParams["teamIds"] = ids
}

//TODO: How much work to extract this into own method and use for create and edit?
// TODO: How much work to extract this into own method and use for create and edit?
if len(reviewParams) > 0 {
reviewQuery := `
mutation PullRequestCreateRequestReviews($input: RequestReviewsInput!) {
Expand Down Expand Up @@ -674,6 +674,22 @@ func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) er
return client.Mutate(repo.RepoHost(), "PullRequestReadyForReview", &mutation, variables)
}

func PullRequestRevert(client *Client, repo ghrepo.Interface, params githubv4.RevertPullRequestInput) error {
var mutation struct {
RevertPullRequest struct {
PullRequest struct {
ID githubv4.ID
}
} `graphql:"revertPullRequest(input: $input)"`
}

variables := map[string]interface{}{
"input": params,
}

return client.Mutate(repo.RepoHost(), "PullRequestRevert", &mutation, variables)
}

func ConvertPullRequestToDraft(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
var mutation struct {
ConvertPullRequestToDraft struct {
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/pr/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
cmdMerge "github.com/cli/cli/v2/pkg/cmd/pr/merge"
cmdReady "github.com/cli/cli/v2/pkg/cmd/pr/ready"
cmdReopen "github.com/cli/cli/v2/pkg/cmd/pr/reopen"
cmdRevert "github.com/cli/cli/v2/pkg/cmd/pr/revert"
cmdReview "github.com/cli/cli/v2/pkg/cmd/pr/review"
cmdStatus "github.com/cli/cli/v2/pkg/cmd/pr/status"
cmdView "github.com/cli/cli/v2/pkg/cmd/pr/view"
Expand Down Expand Up @@ -61,6 +62,7 @@ func NewCmdPR(f *cmdutil.Factory) *cobra.Command {
cmdComment.NewCmdComment(f, nil),
cmdClose.NewCmdClose(f, nil),
cmdReopen.NewCmdReopen(f, nil),
cmdRevert.NewCmdRevert(f, nil),
cmdEdit.NewCmdEdit(f, nil),
cmdLock.NewCmdLock(f, cmd.Name(), nil),
cmdLock.NewCmdUnlock(f, cmd.Name(), nil),
Expand Down
122 changes: 122 additions & 0 deletions pkg/cmd/pr/revert/revert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package revert

import (
"fmt"
"net/http"

"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/shurcooL/githubv4"
"github.com/spf13/cobra"
)

type RevertOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams

Finder shared.PRFinder

SelectorArg string

Body string
BodySet bool
Title string
IsDraft bool
}

func NewCmdRevert(f *cmdutil.Factory, runF func(*RevertOptions) error) *cobra.Command {
opts := &RevertOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}

var bodyFile string

cmd := &cobra.Command{
Use: "revert {<number> | <url> | <branch>}",
Short: "Revert a pull request",
Args: cmdutil.ExactArgs(1, "cannot revert pull request: number, url, or branch required"),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Finder = shared.NewFinder(f)

if len(args) > 0 {
opts.SelectorArg = args[0]
}

bodyProvided := cmd.Flags().Changed("body")
bodyFileProvided := bodyFile != ""

if err := cmdutil.MutuallyExclusive(
"specify only one of `--body` or `--body-file`",
bodyProvided,
bodyFileProvided,
); err != nil {
return err
}

if bodyProvided || bodyFileProvided {
opts.BodySet = true
if bodyFileProvided {
b, err := cmdutil.ReadFile(bodyFile, opts.IO.In)
if err != nil {
return err
}
opts.Body = string(b)
}
}

if runF != nil {
return runF(opts)
}
return revertRun(opts)
},
}

cmd.Flags().BoolVarP(&opts.IsDraft, "draft", "d", false, "Mark revert pull request as a draft")
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the revert pull request")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Body for the revert pull request")
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
return cmd
}

func revertRun(opts *RevertOptions) error {
cs := opts.IO.ColorScheme()

findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"id", "number", "state", "title"},
}
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
if pr.State != "MERGED" {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request %s#%d (%s) can't be reverted because it has not been merged\n", cs.FailureIcon(), ghrepo.FullName(baseRepo), pr.Number, pr.Title)
return cmdutil.SilentError
}

httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)

params := githubv4.RevertPullRequestInput{
PullRequestID: pr.ID,
Title: githubv4.NewString(githubv4.String(opts.Title)),
Body: githubv4.NewString(githubv4.String(opts.Body)),
Draft: githubv4.NewBoolean(githubv4.Boolean(opts.IsDraft)),
}
Copy link
Preview

Copilot AI Jul 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Body field should only be set when opts.BodySet is true. Currently, an empty string will be passed to the API when no body is provided, which may override GitHub's default revert body generation.

Suggested change
}
Draft: githubv4.NewBoolean(githubv4.Boolean(opts.IsDraft)),
}
if opts.BodySet {
params.Body = githubv4.NewString(githubv4.String(opts.Body))
}

Copilot uses AI. Check for mistakes.


Copy link
Preview

Copilot AI Jul 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Title field should only be set when opts.Title is not empty. Currently, an empty string will be passed to the API when no title is provided, which may override GitHub's default revert title generation.

Suggested change
Body: githubv4.NewString(githubv4.String(opts.Body)),
Draft: githubv4.NewBoolean(githubv4.Boolean(opts.IsDraft)),
}
if opts.Title != "" {
params.Title = githubv4.NewString(githubv4.String(opts.Title))
}

Copilot uses AI. Check for mistakes.

err = api.PullRequestRevert(apiClient, baseRepo, params)
if err != nil {
return fmt.Errorf("API call failed: %w", err)
}

fmt.Fprintf(opts.IO.ErrOut, "%s Created revert PR for pull request %s#%d (%s)\n", cs.SuccessIconWithColor(cs.Green), ghrepo.FullName(baseRepo), pr.Number, pr.Title)

return nil
}
120 changes: 120 additions & 0 deletions pkg/cmd/pr/revert/revert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package revert

import (
"bytes"
"io"
"net/http"
"testing"

"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)

func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(isTTY)
ios.SetStdinTTY(isTTY)
ios.SetStderrTTY(isTTY)

factory := &cmdutil.Factory{
IOStreams: ios,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
}

cmd := NewCmdRevert(factory, nil)

argv, err := shlex.Split(cli)
if err != nil {
return nil, err
}
cmd.SetArgs(argv)

cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)

_, err = cmd.ExecuteC()
return &test.CmdOut{
OutBuf: stdout,
ErrBuf: stderr,
}, err
}

func TestPRRevert(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)

shared.RunCommandFinder("123", &api.PullRequest{
ID: "SOME-ID",
Number: 123,
State: "MERGED",
Title: "The title of the PR",
}, ghrepo.New("OWNER", "REPO"))

http.Register(
httpmock.GraphQL(`mutation PullRequestRevert\b`),
httpmock.GraphQLMutation(`{"id": "SOME-ID"}`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["pullRequestId"], "SOME-ID")
}),
)

output, err := runCommand(http, true, "123")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Created revert PR for pull request OWNER/REPO#123 (The title of the PR)\n", output.Stderr())
}

func TestPRRevert_notRevertable(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)

shared.RunCommandFinder("123", &api.PullRequest{
ID: "SOME-ID",
Number: 123,
State: "OPEN",
Title: "The title of the PR",
}, ghrepo.New("OWNER", "REPO"))

output, err := runCommand(http, true, "123")
assert.Error(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "X Pull request OWNER/REPO#123 (The title of the PR) can't be reverted because it has not been merged\n", output.Stderr())
}

func TestPRRevert_withAllOpts(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)

shared.RunCommandFinder("123", &api.PullRequest{
ID: "THE-ID",
Number: 123,
State: "MERGED",
Title: "The title of the PR",
}, ghrepo.New("OWNER", "REPO"))

http.Register(
httpmock.GraphQL(`mutation PullRequestRevert\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID" }`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["pullRequestId"], "THE-ID")
assert.Equal(t, inputs["title"], "Revert PR title")
assert.Equal(t, inputs["body"], "Revert PR body")
assert.Equal(t, inputs["draft"], true)
}),
)

output, err := runCommand(http, true, "123 --title 'Revert PR title' --body 'Revert PR body' --draft")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Created revert PR for pull request OWNER/REPO#123 (The title of the PR)\n", output.Stderr())
}