github.com/jingweno/gh@v2.1.1-0.20221007190738-04a7985fa9a1+incompatible/commands/pull_request.go (about) 1 package commands 2 3 import ( 4 "fmt" 5 "reflect" 6 "regexp" 7 "strings" 8 9 "github.com/jingweno/gh/git" 10 "github.com/jingweno/gh/github" 11 "github.com/jingweno/gh/utils" 12 ) 13 14 var cmdPullRequest = &Command{ 15 Run: pullRequest, 16 Usage: "pull-request [-f] [-m <MESSAGE>|-F <FILE>|-i <ISSUE>|<ISSUE-URL>] [-b <BASE>] [-h <HEAD>] ", 17 Short: "Open a pull request on GitHub", 18 Long: `Opens a pull request on GitHub for the project that the "origin" remote 19 points to. The default head of the pull request is the current branch. 20 Both base and head of the pull request can be explicitly given in one of 21 the following formats: "branch", "owner:branch", "owner/repo:branch". 22 This command will abort operation if it detects that the current topic 23 branch has local commits that are not yet pushed to its upstream branch 24 on the remote. To skip this check, use "-f". 25 26 Without <MESSAGE> or <FILE>, a text editor will open in which title and body 27 of the pull request can be entered in the same manner as git commit message. 28 Pull request message can also be passed via stdin with "-F -". 29 30 If instead of normal <TITLE> an issue number is given with "-i", the pull 31 request will be attached to an existing GitHub issue. Alternatively, instead 32 of title you can paste a full URL to an issue on GitHub. 33 `, 34 } 35 36 var ( 37 flagPullRequestBase, 38 flagPullRequestHead, 39 flagPullRequestIssue, 40 flagPullRequestMessage, 41 flagPullRequestFile string 42 flagPullRequestForce bool 43 ) 44 45 func init() { 46 cmdPullRequest.Flag.StringVarP(&flagPullRequestBase, "base", "b", "", "BASE") 47 cmdPullRequest.Flag.StringVarP(&flagPullRequestHead, "head", "h", "", "HEAD") 48 cmdPullRequest.Flag.StringVarP(&flagPullRequestIssue, "issue", "i", "", "ISSUE") 49 cmdPullRequest.Flag.StringVarP(&flagPullRequestMessage, "message", "m", "", "MESSAGE") 50 cmdPullRequest.Flag.BoolVarP(&flagPullRequestForce, "force", "f", false, "FORCE") 51 cmdPullRequest.Flag.StringVarP(&flagPullRequestFile, "file", "F", "", "FILE") 52 53 CmdRunner.Use(cmdPullRequest) 54 } 55 56 /* 57 # while on a topic branch called "feature": 58 $ gh pull-request 59 [ opens text editor to edit title & body for the request ] 60 [ opened pull request on GitHub for "YOUR_USER:feature" ] 61 62 # explicit pull base & head: 63 $ gh pull-request -b jingweno:master -h jingweno:feature 64 65 $ gh pull-request -m "title\n\nbody" 66 [ create pull request with title & body ] 67 68 $ gh pull-request -i 123 69 [ attached pull request to issue #123 ] 70 71 $ gh pull-request https://github.com/jingweno/gh/pull/123 72 [ attached pull request to issue #123 ] 73 74 $ gh pull-request -F FILE 75 [ create pull request with title & body from FILE ] 76 */ 77 func pullRequest(cmd *Command, args *Args) { 78 localRepo := github.LocalRepo() 79 80 currentBranch, err := localRepo.CurrentBranch() 81 utils.Check(err) 82 83 baseProject, err := localRepo.MainProject() 84 utils.Check(err) 85 86 client := github.NewClient(baseProject.Host) 87 88 trackedBranch, headProject, err := localRepo.RemoteBranchAndProject(client.Credentials.User) 89 utils.Check(err) 90 91 var ( 92 base, head string 93 force bool 94 ) 95 96 force = flagPullRequestForce 97 98 if flagPullRequestBase != "" { 99 baseProject, base = parsePullRequestProject(baseProject, flagPullRequestBase) 100 } 101 102 if flagPullRequestHead != "" { 103 headProject, head = parsePullRequestProject(headProject, flagPullRequestHead) 104 } 105 106 if args.ParamsSize() == 1 { 107 arg := args.RemoveParam(0) 108 flagPullRequestIssue = parsePullRequestIssueNumber(arg) 109 } 110 111 if base == "" { 112 masterBranch := localRepo.MasterBranch() 113 base = masterBranch.ShortName() 114 } 115 116 if head == "" { 117 if !trackedBranch.IsRemote() { 118 // the current branch tracking another branch 119 // pretend there's no upstream at all 120 trackedBranch = nil 121 } else { 122 if reflect.DeepEqual(baseProject, headProject) && base == trackedBranch.ShortName() { 123 e := fmt.Errorf(`Aborted: head branch is the same as base ("%s")`, base) 124 e = fmt.Errorf("%s\n(use `-h <branch>` to specify an explicit pull request head)", e) 125 utils.Check(e) 126 } 127 } 128 129 if trackedBranch == nil { 130 head = currentBranch.ShortName() 131 } else { 132 head = trackedBranch.ShortName() 133 } 134 } 135 136 title, body, err := getTitleAndBodyFromFlags(flagPullRequestMessage, flagPullRequestFile) 137 utils.Check(err) 138 139 fullBase := fmt.Sprintf("%s:%s", baseProject.Owner, base) 140 fullHead := fmt.Sprintf("%s:%s", headProject.Owner, head) 141 142 if !force && trackedBranch != nil { 143 remoteCommits, _ := git.RefList(trackedBranch.LongName(), "") 144 if len(remoteCommits) > 0 { 145 err = fmt.Errorf("Aborted: %d commits are not yet pushed to %s", len(remoteCommits), trackedBranch.LongName()) 146 err = fmt.Errorf("%s\n(use `-f` to force submit a pull request anyway)", err) 147 utils.Check(err) 148 } 149 } 150 151 if title == "" && flagPullRequestIssue == "" { 152 commits, _ := git.RefList(base, head) 153 title, body, err = writePullRequestTitleAndBody(base, head, fullBase, fullHead, commits) 154 utils.Check(err) 155 } 156 157 if title == "" && flagPullRequestIssue == "" { 158 utils.Check(fmt.Errorf("Aborting due to empty pull request title")) 159 } 160 161 var pullRequestURL string 162 if args.Noop { 163 args.Before(fmt.Sprintf("Would request a pull request to %s from %s", fullBase, fullHead), "") 164 pullRequestURL = "PULL_REQUEST_URL" 165 } else { 166 if title != "" { 167 pr, err := client.CreatePullRequest(baseProject, base, fullHead, title, body) 168 utils.Check(err) 169 pullRequestURL = pr.HTMLURL 170 } 171 172 if flagPullRequestIssue != "" { 173 pr, err := client.CreatePullRequestForIssue(baseProject, base, fullHead, flagPullRequestIssue) 174 utils.Check(err) 175 pullRequestURL = pr.HTMLURL 176 } 177 } 178 179 args.Replace("echo", "", pullRequestURL) 180 if flagPullRequestIssue != "" { 181 args.After("echo", "Warning: Issue to pull request conversion is deprecated and might not work in the future.") 182 } 183 } 184 185 func writePullRequestTitleAndBody(base, head, fullBase, fullHead string, commits []string) (title, body string, err error) { 186 message, err := pullRequestChangesMessage(base, head, fullBase, fullHead, commits) 187 if err != nil { 188 return 189 } 190 191 editor, err := github.NewEditor("PULLREQ", message) 192 if err != nil { 193 return 194 } 195 196 return editor.EditTitleAndBody() 197 } 198 199 func pullRequestChangesMessage(base, head, fullBase, fullHead string, commits []string) (string, error) { 200 var defaultMsg, commitSummary string 201 if len(commits) == 1 { 202 msg, err := git.Show(commits[0]) 203 if err != nil { 204 return "", err 205 } 206 defaultMsg = fmt.Sprintf("%s\n", msg) 207 } else if len(commits) > 1 { 208 commitLogs, err := git.Log(base, head) 209 if err != nil { 210 return "", err 211 } 212 213 if len(commitLogs) > 0 { 214 startRegexp := regexp.MustCompilePOSIX("^") 215 endRegexp := regexp.MustCompilePOSIX(" +$") 216 217 commitLogs = strings.TrimSpace(commitLogs) 218 commitLogs = startRegexp.ReplaceAllString(commitLogs, "# ") 219 commitLogs = endRegexp.ReplaceAllString(commitLogs, "") 220 commitSummary = ` 221 # 222 # Changes: 223 # 224 %s` 225 commitSummary = fmt.Sprintf(commitSummary, commitLogs) 226 } 227 } 228 229 message := `%s 230 # Requesting a pull to %s from %s 231 # 232 # Write a message for this pull request. The first block 233 # of the text is the title and the rest is description.%s 234 ` 235 message = fmt.Sprintf(message, defaultMsg, fullBase, fullHead, commitSummary) 236 237 return message, nil 238 } 239 240 func parsePullRequestProject(context *github.Project, s string) (p *github.Project, ref string) { 241 p = context 242 ref = s 243 244 if strings.Contains(s, ":") { 245 split := strings.SplitN(s, ":", 2) 246 ref = split[1] 247 var name string 248 if !strings.Contains(split[0], "/") { 249 name = context.Name 250 } 251 p = github.NewProject(split[0], name, context.Host) 252 } 253 254 return 255 } 256 257 func parsePullRequestIssueNumber(url string) string { 258 u, e := github.ParseURL(url) 259 if e != nil { 260 return "" 261 } 262 263 r := regexp.MustCompile(`^issues\/(\d+)`) 264 p := u.ProjectPath() 265 if r.MatchString(p) { 266 return r.FindStringSubmatch(p)[1] 267 } 268 269 return "" 270 }