code.gitea.io/gitea@v1.21.7/contrib/backport/backport.go (about) 1 // Copyright 2023 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 //nolint:forbidigo 5 package main 6 7 import ( 8 "context" 9 "fmt" 10 "log" 11 "net/http" 12 "os" 13 "os/exec" 14 "os/signal" 15 "path" 16 "strconv" 17 "strings" 18 "syscall" 19 20 "github.com/google/go-github/v53/github" 21 "github.com/urfave/cli/v2" 22 "gopkg.in/yaml.v3" 23 ) 24 25 const defaultVersion = "v1.18" // to backport to 26 27 func main() { 28 app := cli.NewApp() 29 app.Name = "backport" 30 app.Usage = "Backport provided PR-number on to the current or previous released version" 31 app.Description = `Backport will look-up the PR in Gitea's git log and attempt to cherry-pick it on the current version` 32 app.ArgsUsage = "<PR-to-backport>" 33 34 app.Flags = []cli.Flag{ 35 &cli.StringFlag{ 36 Name: "version", 37 Usage: "Version branch to backport on to", 38 }, 39 &cli.StringFlag{ 40 Name: "upstream", 41 Value: "origin", 42 Usage: "Upstream remote for the Gitea upstream", 43 }, 44 &cli.StringFlag{ 45 Name: "release-branch", 46 Value: "", 47 Usage: "Release branch to backport on. Will default to release/<version>", 48 }, 49 &cli.StringFlag{ 50 Name: "cherry-pick", 51 Usage: "SHA to cherry-pick as backport", 52 }, 53 &cli.StringFlag{ 54 Name: "backport-branch", 55 Usage: "Backport branch to backport on to (default: backport-<pr>-<version>", 56 }, 57 &cli.StringFlag{ 58 Name: "remote", 59 Value: "", 60 Usage: "Remote for your fork of the Gitea upstream", 61 }, 62 &cli.StringFlag{ 63 Name: "fork-user", 64 Value: "", 65 Usage: "Forked user name on Github", 66 }, 67 &cli.BoolFlag{ 68 Name: "no-fetch", 69 Usage: "Set this flag to prevent fetch of remote branches", 70 }, 71 &cli.BoolFlag{ 72 Name: "no-amend-message", 73 Usage: "Set this flag to prevent automatic amendment of the commit message", 74 }, 75 &cli.BoolFlag{ 76 Name: "no-push", 77 Usage: "Set this flag to prevent pushing the backport up to your fork", 78 }, 79 &cli.BoolFlag{ 80 Name: "no-xdg-open", 81 Usage: "Set this flag to not use xdg-open to open the PR URL", 82 }, 83 &cli.BoolFlag{ 84 Name: "continue", 85 Usage: "Set this flag to continue from a git cherry-pick that has broken", 86 }, 87 } 88 cli.AppHelpTemplate = `NAME: 89 {{.Name}} - {{.Usage}} 90 USAGE: 91 {{.HelpName}} {{if .VisibleFlags}}[options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}} 92 {{if len .Authors}} 93 AUTHOR: 94 {{range .Authors}}{{ . }}{{end}} 95 {{end}}{{if .Commands}} 96 OPTIONS: 97 {{range .VisibleFlags}}{{.}} 98 {{end}}{{end}} 99 ` 100 101 app.Action = runBackport 102 103 if err := app.Run(os.Args); err != nil { 104 fmt.Fprintf(os.Stderr, "Unable to backport: %v\n", err) 105 } 106 } 107 108 func runBackport(c *cli.Context) error { 109 ctx, cancel := installSignals() 110 defer cancel() 111 112 continuing := c.Bool("continue") 113 114 var pr string 115 116 version := c.String("version") 117 if version == "" && continuing { 118 // determine version from current branch name 119 var err error 120 pr, version, err = readCurrentBranch(ctx) 121 if err != nil { 122 return err 123 } 124 } 125 if version == "" { 126 version = readVersion() 127 } 128 if version == "" { 129 version = defaultVersion 130 } 131 132 upstream := c.String("upstream") 133 if upstream == "" { 134 upstream = "origin" 135 } 136 137 forkUser := c.String("fork-user") 138 remote := c.String("remote") 139 if remote == "" && !c.Bool("--no-push") { 140 var err error 141 remote, forkUser, err = determineRemote(ctx, forkUser) 142 if err != nil { 143 return err 144 } 145 } 146 147 upstreamReleaseBranch := c.String("release-branch") 148 if upstreamReleaseBranch == "" { 149 upstreamReleaseBranch = path.Join("release", version) 150 } 151 152 localReleaseBranch := path.Join(upstream, upstreamReleaseBranch) 153 154 args := c.Args().Slice() 155 if len(args) == 0 && pr == "" { 156 return fmt.Errorf("no PR number provided\nProvide a PR number to backport") 157 } else if len(args) != 1 && pr == "" { 158 return fmt.Errorf("multiple PRs provided %v\nOnly a single PR can be backported at a time", args) 159 } 160 if pr == "" { 161 pr = args[0] 162 } 163 164 backportBranch := c.String("backport-branch") 165 if backportBranch == "" { 166 backportBranch = "backport-" + pr + "-" + version 167 } 168 169 fmt.Printf("* Backporting %s to %s as %s\n", pr, localReleaseBranch, backportBranch) 170 171 sha := c.String("cherry-pick") 172 if sha == "" { 173 var err error 174 sha, err = determineSHAforPR(ctx, pr) 175 if err != nil { 176 return err 177 } 178 } 179 if sha == "" { 180 return fmt.Errorf("unable to determine sha for cherry-pick of %s", pr) 181 } 182 183 if !c.Bool("no-fetch") { 184 if err := fetchRemoteAndMain(ctx, upstream, upstreamReleaseBranch); err != nil { 185 return err 186 } 187 } 188 189 if !continuing { 190 if err := checkoutBackportBranch(ctx, backportBranch, localReleaseBranch); err != nil { 191 return err 192 } 193 } 194 195 if err := cherrypick(ctx, sha); err != nil { 196 return err 197 } 198 199 if !c.Bool("no-amend-message") { 200 if err := amendCommit(ctx, pr); err != nil { 201 return err 202 } 203 } 204 205 if !c.Bool("no-push") { 206 url := "https://github.com/go-gitea/gitea/compare/" + upstreamReleaseBranch + "..." + forkUser + ":" + backportBranch 207 208 if err := gitPushUp(ctx, remote, backportBranch); err != nil { 209 return err 210 } 211 212 if !c.Bool("no-xdg-open") { 213 if err := xdgOpen(ctx, url); err != nil { 214 return err 215 } 216 } else { 217 fmt.Printf("* Navigate to %s to open PR\n", url) 218 } 219 } 220 return nil 221 } 222 223 func xdgOpen(ctx context.Context, url string) error { 224 fmt.Printf("* `xdg-open %s`\n", url) 225 out, err := exec.CommandContext(ctx, "xdg-open", url).Output() 226 if err != nil { 227 fmt.Fprintf(os.Stderr, "%s", string(out)) 228 return fmt.Errorf("unable to xdg-open to %s: %w", url, err) 229 } 230 return nil 231 } 232 233 func gitPushUp(ctx context.Context, remote, backportBranch string) error { 234 fmt.Printf("* `git push -u %s %s`\n", remote, backportBranch) 235 out, err := exec.CommandContext(ctx, "git", "push", "-u", remote, backportBranch).Output() 236 if err != nil { 237 fmt.Fprintf(os.Stderr, "%s", string(out)) 238 return fmt.Errorf("unable to push up to %s: %w", remote, err) 239 } 240 return nil 241 } 242 243 func amendCommit(ctx context.Context, pr string) error { 244 fmt.Printf("* Amending commit to prepend `Backport #%s` to body\n", pr) 245 out, err := exec.CommandContext(ctx, "git", "log", "-1", "--pretty=format:%B").Output() 246 if err != nil { 247 fmt.Fprintf(os.Stderr, "%s", string(out)) 248 return fmt.Errorf("unable to get last log message: %w", err) 249 } 250 251 parts := strings.SplitN(string(out), "\n", 2) 252 253 if len(parts) != 2 { 254 return fmt.Errorf("unable to interpret log message:\n%s", string(out)) 255 } 256 subject, body := parts[0], parts[1] 257 if !strings.HasSuffix(subject, " (#"+pr+")") { 258 subject = subject + " (#" + pr + ")" 259 } 260 261 out, err = exec.CommandContext(ctx, "git", "commit", "--amend", "-m", subject+"\n\nBackport #"+pr+"\n"+body).Output() 262 if err != nil { 263 fmt.Fprintf(os.Stderr, "%s", string(out)) 264 return fmt.Errorf("unable to amend last log message: %w", err) 265 } 266 return nil 267 } 268 269 func cherrypick(ctx context.Context, sha string) error { 270 // Check if a CHERRY_PICK_HEAD exists 271 if _, err := os.Stat(".git/CHERRY_PICK_HEAD"); err == nil { 272 // Assume that we are in the middle of cherry-pick - continue it 273 fmt.Println("* Attempting git cherry-pick --continue") 274 out, err := exec.CommandContext(ctx, "git", "cherry-pick", "--continue").Output() 275 if err != nil { 276 fmt.Fprintf(os.Stderr, "git cherry-pick --continue failed:\n%s\n", string(out)) 277 return fmt.Errorf("unable to continue cherry-pick: %w", err) 278 } 279 return nil 280 } 281 282 fmt.Printf("* Attempting git cherry-pick %s\n", sha) 283 out, err := exec.CommandContext(ctx, "git", "cherry-pick", sha).Output() 284 if err != nil { 285 fmt.Fprintf(os.Stderr, "git cherry-pick %s failed:\n%s\n", sha, string(out)) 286 return fmt.Errorf("git cherry-pick %s failed: %w", sha, err) 287 } 288 return nil 289 } 290 291 func checkoutBackportBranch(ctx context.Context, backportBranch, releaseBranch string) error { 292 out, err := exec.CommandContext(ctx, "git", "branch", "--show-current").Output() 293 if err != nil { 294 return fmt.Errorf("unable to check current branch %w", err) 295 } 296 297 currentBranch := strings.TrimSpace(string(out)) 298 fmt.Printf("* Current branch is %s\n", currentBranch) 299 if currentBranch == backportBranch { 300 fmt.Printf("* Current branch is %s - not checking out\n", currentBranch) 301 return nil 302 } 303 304 if _, err := exec.CommandContext(ctx, "git", "rev-list", "-1", backportBranch).Output(); err == nil { 305 fmt.Printf("* Branch %s already exists. Checking it out...\n", backportBranch) 306 return exec.CommandContext(ctx, "git", "checkout", "-f", backportBranch).Run() 307 } 308 309 fmt.Printf("* `git checkout -b %s %s`\n", backportBranch, releaseBranch) 310 return exec.CommandContext(ctx, "git", "checkout", "-b", backportBranch, releaseBranch).Run() 311 } 312 313 func fetchRemoteAndMain(ctx context.Context, remote, releaseBranch string) error { 314 fmt.Printf("* `git fetch %s main`\n", remote) 315 out, err := exec.CommandContext(ctx, "git", "fetch", remote, "main").Output() 316 if err != nil { 317 fmt.Println(string(out)) 318 return fmt.Errorf("unable to fetch %s from %s: %w", "main", remote, err) 319 } 320 fmt.Println(string(out)) 321 322 fmt.Printf("* `git fetch %s %s`\n", remote, releaseBranch) 323 out, err = exec.CommandContext(ctx, "git", "fetch", remote, releaseBranch).Output() 324 if err != nil { 325 fmt.Println(string(out)) 326 return fmt.Errorf("unable to fetch %s from %s: %w", releaseBranch, remote, err) 327 } 328 fmt.Println(string(out)) 329 330 return nil 331 } 332 333 func determineRemote(ctx context.Context, forkUser string) (string, string, error) { 334 out, err := exec.CommandContext(ctx, "git", "remote", "-v").Output() 335 if err != nil { 336 fmt.Fprintf(os.Stderr, "Unable to list git remotes:\n%s\n", string(out)) 337 return "", "", fmt.Errorf("unable to determine forked remote: %w", err) 338 } 339 lines := strings.Split(string(out), "\n") 340 for _, line := range lines { 341 fields := strings.Split(line, "\t") 342 name, remote := fields[0], fields[1] 343 // only look at pushers 344 if !strings.HasSuffix(remote, " (push)") { 345 continue 346 } 347 // only look at github.com pushes 348 if !strings.Contains(remote, "github.com") { 349 continue 350 } 351 // ignore go-gitea/gitea 352 if strings.Contains(remote, "go-gitea/gitea") { 353 continue 354 } 355 if !strings.Contains(remote, forkUser) { 356 continue 357 } 358 if strings.HasPrefix(remote, "git@github.com:") { 359 forkUser = strings.TrimPrefix(remote, "git@github.com:") 360 } else if strings.HasPrefix(remote, "https://github.com/") { 361 forkUser = strings.TrimPrefix(remote, "https://github.com/") 362 } else if strings.HasPrefix(remote, "https://www.github.com/") { 363 forkUser = strings.TrimPrefix(remote, "https://www.github.com/") 364 } else if forkUser == "" { 365 return "", "", fmt.Errorf("unable to extract forkUser from remote %s: %s", name, remote) 366 } 367 idx := strings.Index(forkUser, "/") 368 if idx >= 0 { 369 forkUser = forkUser[:idx] 370 } 371 return name, forkUser, nil 372 } 373 return "", "", fmt.Errorf("unable to find appropriate remote in:\n%s", string(out)) 374 } 375 376 func readCurrentBranch(ctx context.Context) (pr, version string, err error) { 377 out, err := exec.CommandContext(ctx, "git", "branch", "--show-current").Output() 378 if err != nil { 379 fmt.Fprintf(os.Stderr, "Unable to read current git branch:\n%s\n", string(out)) 380 return "", "", fmt.Errorf("unable to read current git branch: %w", err) 381 } 382 parts := strings.Split(strings.TrimSpace(string(out)), "-") 383 384 if len(parts) != 3 || parts[0] != "backport" { 385 fmt.Fprintf(os.Stderr, "Unable to continue from git branch:\n%s\n", string(out)) 386 return "", "", fmt.Errorf("unable to continue from git branch:\n%s", string(out)) 387 } 388 389 return parts[1], parts[2], nil 390 } 391 392 func readVersion() string { 393 bs, err := os.ReadFile("docs/config.yaml") 394 if err != nil { 395 if err == os.ErrNotExist { 396 log.Println("`docs/config.yaml` not present") 397 return "" 398 } 399 fmt.Fprintf(os.Stderr, "Unable to read `docs/config.yaml`: %v\n", err) 400 return "" 401 } 402 403 type params struct { 404 Version string 405 } 406 type docConfig struct { 407 Params params 408 } 409 dc := &docConfig{} 410 if err := yaml.Unmarshal(bs, dc); err != nil { 411 fmt.Fprintf(os.Stderr, "Unable to read `docs/config.yaml`: %v\n", err) 412 return "" 413 } 414 415 if dc.Params.Version == "" { 416 fmt.Fprintf(os.Stderr, "No version in `docs/config.yaml`") 417 return "" 418 } 419 420 version := dc.Params.Version 421 if version[0] != 'v' { 422 version = "v" + version 423 } 424 425 split := strings.SplitN(version, ".", 3) 426 427 return strings.Join(split[:2], ".") 428 } 429 430 func determineSHAforPR(ctx context.Context, prStr string) (string, error) { 431 prNum, err := strconv.Atoi(prStr) 432 if err != nil { 433 return "", err 434 } 435 436 client := github.NewClient(http.DefaultClient) 437 438 pr, _, err := client.PullRequests.Get(ctx, "go-gitea", "gitea", prNum) 439 if err != nil { 440 return "", err 441 } 442 443 if pr.Merged == nil || !*pr.Merged { 444 return "", fmt.Errorf("PR #%d is not yet merged - cannot determine sha to backport", prNum) 445 } 446 447 if pr.MergeCommitSHA != nil { 448 return *pr.MergeCommitSHA, nil 449 } 450 451 return "", nil 452 } 453 454 func installSignals() (context.Context, context.CancelFunc) { 455 ctx, cancel := context.WithCancel(context.Background()) 456 go func() { 457 // install notify 458 signalChannel := make(chan os.Signal, 1) 459 460 signal.Notify( 461 signalChannel, 462 syscall.SIGINT, 463 syscall.SIGTERM, 464 ) 465 select { 466 case <-signalChannel: 467 case <-ctx.Done(): 468 } 469 cancel() 470 signal.Reset() 471 }() 472 473 return ctx, cancel 474 }