You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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