diff --git a/README.md b/README.md index 13643b0..39ab861 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ It currently automates the following tasks: - The suffix following the labels `Backport to: ` or `Forwardport to:` must match the [git branch name](https://github.com/vitessio/vitess/branches/all?query=release-) - If there is conflict, the backport PR will be created as a draft and a comment will be added to ping the author of the original PR. - Automatic query serving error code documentation +- Automatic cobra documentation generation for programs: + - If a PR is merged to `main`, a website PR is created automatically. + - If a PR is merged to another branch, nothing is done (yet!). + - When a release is published, a website PR to update the `COBRADOC_VERSION_PAIRS` and regenerate the docs is opened. If an existing sync PR is in-flight, the second PR will be based on that one, and they may be merged in either order. ## Installing the Bot You can install and configure the bot with the following commands: @@ -36,7 +40,7 @@ In order to test the bot locally you will need to create a new GitHub App in htt - In the `Webhook` section you will need to fill in the `Webhook URL`. You can get this value by running `lt --port 8080` locally, this will print the URL linked to your local environment. Use that URL in the field. You must add `/api/github/hook` after the URL printed by `lt`, to redirect the webhooks to the correct API path (i.e. `https://lazy-frogs-hear.loca.lt/api/github/hook`). - You also need to set a `Webhook secret` and save its value for later. - In the section `Permissions`, we need for repository permissions: `Contents` (Read & Write), `Issues` (Read & Write), `Metadata` (Read Only), `Pull requests` (Read & Write) -- In the section `Subscribe to events` select: `Create`, `Issue comment`, `Issues`, `Pull request`, `Push`. Or any other permission depending on what you need for your local dev. +- In the section `Subscribe to events` select: `Create`, `Issue comment`, `Issues`, `Pull request`, `Push`, and `Release`. Or any other permission depending on what you need for your local dev. - In the section `Where can this GitHub App be installed?`, select `Any account`. - Click on `Create GitHub App`. @@ -49,6 +53,7 @@ Now, create an `.env` file at the root. The file is formatted as follows: ```dotenv SERVER_ADDRESS=127.0.0.1 REVIEW_CHECKLIST_PATH=./config/review_checklist.txt +BOT_USER_LOGIN=vitess-bot[bot] PRIVATE_KEY_PATH=.data/ GITHUB_APP_INTEGRATION_ID= GITHUB_APP_WEBHOOK_SECRET= @@ -57,4 +62,6 @@ GITHUB_V3_API_URL=https://api.github.com/ Replace the placeholders with the proper values. You will be able to find `GITHUB_APP_INTEGRATION_ID` in the `General` page of your GitHub App under `App ID`. +Note that the `BOT_USER_LOGIN` is the name you gave the App you created above, _plus_ the literal `[bot]` on the end. + Once that is done, you should be able to run the program! \ No newline at end of file diff --git a/go/cobradocs_sync.go b/go/cobradocs_sync.go index a430e52..5ae360d 100644 --- a/go/cobradocs_sync.go +++ b/go/cobradocs_sync.go @@ -49,7 +49,7 @@ func synchronizeCobraDocs( return nil, errors.Wrapf(err, "Failed to create git ref %s ref for repository %s/%s to %s on Pull Request %d", newBranch, website.Owner, website.Name, op, prInfo.num) } - if err := setupRepo(ctx, vitess, prInfo, op); err != nil { + if err := setupRepo(ctx, vitess, fmt.Sprintf("%s on Pull Request %d", op, prInfo.num)); err != nil { return nil, err } @@ -57,7 +57,7 @@ func synchronizeCobraDocs( return nil, errors.Wrapf(err, "Failed to fetch tags in repository %s/%s to %s on Pull Request %d", vitess.Owner, vitess.Name, op, prInfo.num) } - if err := setupRepo(ctx, website, prInfo, op); err != nil { + if err := setupRepo(ctx, website, fmt.Sprintf("%s on Pull Request %d", op, prInfo.num)); err != nil { return nil, err } @@ -75,7 +75,14 @@ func synchronizeCobraDocs( return nil, errors.Wrapf(err, "Failed to run cobradoc sync script in repository %s/%s to %s on Pull Request %d", website.Owner, website.Name, newBranch, prInfo.num) } - // TODO: do we need to amend the commit to change the author to the bot? + // Amend the commit to change the author to the bot. + if err := website.Commit(ctx, "", git.CommitOpts{ + Author: botCommitAuthor, + Amend: true, + NoEdit: true, + }); err != nil { + return nil, errors.Wrapf(err, "Failed to amend commit author to %s on Pull Request %d", op, prInfo.num) + } // Push the branch if err := website.Push(ctx, git.PushOpts{ @@ -103,21 +110,21 @@ func synchronizeCobraDocs( } -func setupRepo(ctx context.Context, repo *git.Repo, prInfo prInformation, op string) error { +func setupRepo(ctx context.Context, repo *git.Repo, op string) error { if err := repo.Clone(ctx); err != nil { - return errors.Wrapf(err, "Failed to clone repository %s/%s to %s on Pull Request %d", repo.Owner, repo.Name, op, prInfo.num) + return errors.Wrapf(err, "Failed to clone repository %s/%s to %s", repo.Owner, repo.Name, op) } if err := repo.Clean(ctx); err != nil { - return errors.Wrapf(err, "Failed to clean the repository %s/%s to %s on Pull Request %d", repo.Owner, repo.Name, op, prInfo.num) + return errors.Wrapf(err, "Failed to clean the repository %s/%s to %s", repo.Owner, repo.Name, op) } if err := repo.Fetch(ctx, "origin"); err != nil { - return errors.Wrapf(err, "Failed to fetch origin on repository %s/%s to %s on Pull Request %d", repo.Owner, repo.Name, op, prInfo.num) + return errors.Wrapf(err, "Failed to fetch origin on repository %s/%s to %s", repo.Owner, repo.Name, op) } if err := repo.ResetHard(ctx, "HEAD"); err != nil { - return errors.Wrapf(err, "Failed to reset the repository %s/%s to %s on Pull Request %d", repo.Owner, repo.Name, op, prInfo.num) + return errors.Wrapf(err, "Failed to reset the repository %s/%s to %s", repo.Owner, repo.Name, op) } return nil diff --git a/go/config.go b/go/config.go index bb5927d..0632b1f 100644 --- a/go/config.go +++ b/go/config.go @@ -27,6 +27,7 @@ import ( type config struct { Github githubapp.Config + botLogin string reviewChecklist string address string logFile string @@ -63,6 +64,8 @@ func readConfig() (*config, error) { } c.reviewChecklist = string(bytes) + c.botLogin = os.Getenv("BOT_USER_LOGIN") + // Get server address serverAddress := os.Getenv("SERVER_ADDRESS") if serverAddress == "" { diff --git a/go/git/pull_request.go b/go/git/pull_request.go index 01014bf..8814216 100644 --- a/go/git/pull_request.go +++ b/go/git/pull_request.go @@ -25,6 +25,28 @@ import ( const rowsPerPage = 100 +func (r *Repo) ListPRs(ctx context.Context, client *github.Client, opts github.PullRequestListOptions) (pulls []*github.PullRequest, err error) { + cont := true + for page := 1; cont; page++ { + opts.ListOptions = github.ListOptions{ + PerPage: rowsPerPage, + Page: page, + } + prs, _, err := client.PullRequests.List(ctx, r.Owner, r.Name, &opts) + if err != nil { + return nil, errors.Wrapf(err, "Failed to list pull requests in %s/%s - at page %d", r.Owner, r.Name, page) + } + + pulls = append(pulls, prs...) + if len(prs) < rowsPerPage { + cont = false + break + } + } + + return pulls, nil +} + // ListPRFiles returns a list of all files included in a given PR in the repo. func (r *Repo) ListPRFiles(ctx context.Context, client *github.Client, pr int) (allFiles []*github.CommitFile, err error) { cont := true diff --git a/go/git/repo.go b/go/git/repo.go index 6fc69ab..9bde2bf 100644 --- a/go/git/repo.go +++ b/go/git/repo.go @@ -30,6 +30,18 @@ type Repo struct { LocalDir string } +func NewRepo(owner, name string) *Repo { + return &Repo{ + Owner: owner, + Name: name, + } +} + +func (r *Repo) WithLocalDir(dir string) *Repo { + r.LocalDir = dir + return r +} + func (r *Repo) Add(ctx context.Context, arg ...string) error { _, err := shell.NewContext(ctx, "git", append([]string{"add"}, arg...)...).InDir(r.LocalDir).Output() return err diff --git a/go/main.go b/go/main.go index c41d891..21c5180 100644 --- a/go/main.go +++ b/go/main.go @@ -67,8 +67,13 @@ func main() { panic(err) } + releaseHandler, err := NewReleaseHandler(cc, cfg.botLogin) + if err != nil { + panic(err) + } + webhookHandler := githubapp.NewEventDispatcher( - []githubapp.EventHandler{prCommentHandler}, + []githubapp.EventHandler{prCommentHandler, releaseHandler}, cfg.Github.App.WebhookSecret, githubapp.WithScheduler( githubapp.AsyncScheduler(), diff --git a/go/pull_request.go b/go/pull_request.go index 1e74a36..894c1bb 100644 --- a/go/pull_request.go +++ b/go/pull_request.go @@ -254,11 +254,10 @@ func (h *PullRequestHandler) createErrorDocumentation(ctx context.Context, event return nil } - vitess := &git.Repo{ - Owner: prInfo.repoOwner, - Name: prInfo.repoName, - LocalDir: filepath.Join(h.Workdir(), "vitess"), - } + vitess := git.NewRepo( + prInfo.repoOwner, + prInfo.repoName, + ).WithLocalDir(filepath.Join(h.Workdir(), "vitess")) logger.Debug().Msgf("Listing changed files in Pull Request %s/%s#%d", prInfo.repoOwner, prInfo.repoName, prInfo.num) changeDetected, err := detectErrorCodeChanges(ctx, vitess, prInfo, client) @@ -280,11 +279,10 @@ func (h *PullRequestHandler) createErrorDocumentation(ctx context.Context, event return nil } - website := &git.Repo{ - Owner: prInfo.repoOwner, - Name: "website", - LocalDir: filepath.Join(h.Workdir(), "website"), - } + website := git.NewRepo( + prInfo.repoOwner, + "website", + ).WithLocalDir(filepath.Join(h.Workdir(), "website")) h.websiteRepoLock.Lock() currentVersionDocs, err := cloneWebsiteAndGetCurrentVersionOfDocs(ctx, website, prInfo) @@ -359,11 +357,10 @@ func (h *PullRequestHandler) backportPR(ctx context.Context, event github.PullRe logger.Debug().Msgf("Will forwardport Pull Request %s/%s#%d to branches %v", prInfo.repoOwner, prInfo.repoName, prInfo.num, forwardportBranches) } - vitessRepo := &git.Repo{ - Owner: prInfo.repoOwner, - Name: prInfo.repoName, - LocalDir: filepath.Join(h.Workdir(), "vitess"), - } + vitessRepo := git.NewRepo( + prInfo.repoOwner, + prInfo.repoName, + ).WithLocalDir(filepath.Join(h.Workdir(), "vitess")) mergedCommitSHA := pr.GetMergeCommitSHA() for _, branch := range backportBranches { @@ -421,11 +418,10 @@ func (h *PullRequestHandler) updateDocs(ctx context.Context, event github.PullRe return nil } - vitess := &git.Repo{ - Owner: prInfo.repoOwner, - Name: prInfo.repoName, - LocalDir: filepath.Join(h.Workdir(), "vitess"), - } + vitess := git.NewRepo( + prInfo.repoOwner, + prInfo.repoName, + ).WithLocalDir(filepath.Join(h.Workdir(), "vitess")) docChanges, err := detectCobraDocChanges(ctx, vitess, client, prInfo) if err != nil { @@ -437,11 +433,10 @@ func (h *PullRequestHandler) updateDocs(ctx context.Context, event github.PullRe return nil } - website := &git.Repo{ - Owner: prInfo.repoOwner, - Name: "website", - LocalDir: filepath.Join(h.Workdir(), "website"), - } + website := git.NewRepo( + prInfo.repoOwner, + "website", + ).WithLocalDir(filepath.Join(h.Workdir(), "website")) _, err = synchronizeCobraDocs(ctx, client, vitess, website, event.GetPullRequest(), prInfo) return err diff --git a/go/release_handler.go b/go/release_handler.go new file mode 100644 index 0000000..93939c5 --- /dev/null +++ b/go/release_handler.go @@ -0,0 +1,384 @@ +/* +Copyright 2023 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + + "github.com/google/go-github/v53/github" + "github.com/palantir/go-githubapp/githubapp" + "github.com/pkg/errors" + "github.com/rs/zerolog" + "github.com/vitess.io/vitess-bot/go/git" + "github.com/vitess.io/vitess-bot/go/semver" + "github.com/vitess.io/vitess-bot/go/shell" +) + +type releaseMetadata struct { + repoName string + repoOwner string + + tag string + draft bool + prerelease bool + + url string +} + +func getReleaseMetadata(event *github.ReleaseEvent) *releaseMetadata { + return &releaseMetadata{ + repoOwner: event.GetRepo().GetOwner().GetLogin(), + repoName: event.GetRepo().GetName(), + tag: event.GetRelease().GetTagName(), + draft: event.GetRelease().GetDraft(), + prerelease: event.GetRelease().GetPrerelease(), + url: event.GetRelease().GetHTMLURL(), + } +} + +type ReleaseHandler struct { + githubapp.ClientCreator + botLogin string + + m sync.Mutex +} + +func NewReleaseHandler(cc githubapp.ClientCreator, botLogin string) (h *ReleaseHandler, err error) { + h = &ReleaseHandler{ + ClientCreator: cc, + botLogin: botLogin, + } + err = os.MkdirAll(h.Workdir(), 0777|os.ModeDir) + + return h, err +} + +func (h *ReleaseHandler) Workdir() string { + return filepath.Join("/", "tmp", "release_handler") +} + +func (h *ReleaseHandler) Handles() []string { + return []string{"release"} +} + +func (h *ReleaseHandler) Handle(ctx context.Context, _, _ string, payload []byte) error { + var event github.ReleaseEvent + if err := json.Unmarshal(payload, &event); err != nil { + return errors.Wrap(err, "Failed to parse release event payload") + } + + switch event.GetAction() { + case "published": + releaseMeta := getReleaseMetadata(&event) + if releaseMeta.repoName != "vitess" { + return nil + } + + if releaseMeta.draft { + return nil + } + + version, err := semver.Parse(releaseMeta.tag) + if err != nil { // release tag is not semver-compliant (which includes release candidates) + return nil + } + + client, err := h.NewInstallationClient(githubapp.GetInstallationIDFromEvent(&event)) + if err != nil { + return err + } + + h.m.Lock() + defer h.m.Unlock() + + _, err = h.updateReleasedCobraDocs(ctx, client, releaseMeta, version) + if err != nil { + return err + } + + return nil + } + + return nil +} + +// TODO: refactor out shared code between here and synchronizeCobraDocs() +func (h *ReleaseHandler) updateReleasedCobraDocs( + ctx context.Context, + client *github.Client, + releaseMeta *releaseMetadata, + version semver.Version, +) (*github.PullRequest, error) { + vitess := git.NewRepo( + releaseMeta.repoOwner, + "vitess", + ).WithLocalDir(filepath.Join(h.Workdir(), "vitess")) + website := git.NewRepo( + releaseMeta.repoOwner, + "website", + ).WithLocalDir(filepath.Join(h.Workdir(), "website")) + + prs, err := website.ListPRs(ctx, client, github.PullRequestListOptions{ + State: "open", + Head: "update-release-cobradocs-for-", + Base: "prod", + Sort: "created", + Direction: "desc", + }) + if err != nil { + return nil, err + } + + branch := "prod" + baseRepo := website + op := "update release cobradocs" + + for _, pr := range prs { + if pr.GetUser().GetLogin() != h.botLogin { + continue + } + + // Most recent PR created by the bot. Base a new PR off of it. + head := pr.GetHead() + + branch = head.GetRef() + repo := head.GetRepo() + baseRepo = git.NewRepo(repo.GetOwner().GetLogin(), repo.GetName()) + + zerolog.DefaultContextLogger.Debug().Msgf("using existing PR #%d (%s/%s:%s)", pr.GetNumber(), baseRepo.Owner, baseRepo.Name, branch) + break + } + + baseRef, _, err := client.Git.GetRef(ctx, baseRepo.Owner, baseRepo.Name, "heads/"+branch) + if err != nil { + return nil, errors.Wrapf(err, "Failed to fetch %s ref for repository %s/%s to %s for %s", branch, baseRepo.Owner, baseRepo.Name, op, version.String()) + } + + newBranch := fmt.Sprintf("update-release-cobradocs-for-%s", version.String()) + _, err = website.CreateBranch(ctx, client, baseRef, newBranch) + if err != nil { + return nil, errors.Wrapf(err, "Failed to create git ref %s for repository %s/%s to %s for %s", newBranch, website.Owner, website.Name, op, version.String()) + } + + if err := setupRepo(ctx, vitess, fmt.Sprintf("%s for %s", op, version.String())); err != nil { + return nil, err + } + + if err := vitess.FetchRef(ctx, "origin", "--tags"); err != nil { + return nil, errors.Wrapf(err, "Failed to fetch tags in repository %s/%s to %s for %s", vitess.Owner, vitess.Name, op, version.String()) + } + + if err := setupRepo(ctx, website, fmt.Sprintf("%s for %s", op, version.String())); err != nil { + return nil, err + } + + // Checkout the new branch we created. + if err := website.Checkout(ctx, newBranch); err != nil { + return nil, errors.Wrapf(err, "Failed to checkout repository %s/%s to branch %s to %s for %s", website.Owner, website.Name, newBranch, op, version.String()) + } + + awk, err := shell.NewContext(ctx, + "awk", + "-F\"", + "-e", + `$0 ~ /COBRADOC_VERSION_PAIRS="?([^"])"?/ { printf $2 }`, + "Makefile", + ).InDir(website.LocalDir).Output() + if err != nil { + return nil, errors.Wrap(err, "Failed to extract COBRADOC_VERSION_PAIRS from website Makefile") + } + + versionPairs, err := extractVersionPairsFromWebsite(string(awk)) + if err != nil { + return nil, errors.Wrap(err, "Failed to extract COBRADOC_VERSION_PAIRS from website Makefile") + } + + versionPairs = updateVersionPairs(versionPairs, version) + + // Update the Makefile and author a commit. + if err := replaceVersionPairs(ctx, website, versionPairs); err != nil { + return nil, errors.Wrapf(err, "Failed to update COBRADOC_VERSION_PAIRS in repository %s/%s to %s for %s", website.Owner, website.Name, op, version.String()) + } + + if err := website.Add(ctx, "Makefile"); err != nil { + return nil, errors.Wrapf(err, "Failed to stage changes in repository %s/%s to %s for %s", website.Owner, website.Name, op, version.String()) + } + + if err := website.Commit(ctx, fmt.Sprintf("Update COBRADOC_VERSION_PAIRS for new release %s", version.String()), git.CommitOpts{ + Author: botCommitAuthor, + }); err != nil { + return nil, errors.Wrapf(err, "Failed to commit COBRADOC_VERSION_PAIRS in repository %s/%s to %s for %s", website.Owner, website.Name, op, version.String()) + } + + // Run the sync script (which authors the commit already). + _, err = shell.NewContext(ctx, "./tools/sync_cobradocs.sh").InDir(website.LocalDir).WithExtraEnv( + fmt.Sprintf("VITESS_DIR=%s", vitess.LocalDir), + "COBRADOCS_SYNC_PERSIST=yes", + ).Output() + if err != nil { + return nil, errors.Wrapf(err, "Failed to run cobradoc sync script in repository %s/%s to %s for %s", website.Owner, website.Name, op, version.String()) + } + + // Amend the commit to change the author to the bot, and change the message + // to something more appropriate. + if err := website.Commit(ctx, fmt.Sprintf("Update released cobradocs with %s", releaseMeta.url), git.CommitOpts{ + Author: botCommitAuthor, + Amend: true, + }); err != nil { + return nil, errors.Wrapf(err, "Failed to amend commit author to %s for %s", op, version.String()) + } + + // Push the branch + if err := website.Push(ctx, git.PushOpts{ + Remote: "origin", + Refs: []string{newBranch}, + Force: true, + }); err != nil { + return nil, errors.Wrapf(err, "Failed to push %s to %s for %s", newBranch, op, version.String()) + } + + // Create a Pull Request for the new branch. + newPR := &github.NewPullRequest{ + Title: github.String(fmt.Sprintf("[cobradocs] update released cobradocs with %s", version.String())), + Head: github.String(newBranch), + Base: github.String("prod"), // hard-coded since sometimes `branch` is a different base. + Body: github.String(fmt.Sprintf("## Description\nThis is an automated PR to update the released cobradocs with [%s](%s)", version.String(), releaseMeta.url)), + MaintainerCanModify: github.Bool(true), + } + newPRCreated, _, err := client.PullRequests.Create(ctx, website.Owner, website.Name, newPR) + if err != nil { + return nil, errors.Wrapf(err, "Failed to create Pull Request using branch %s on %s/%s", newBranch, website.Owner, website.Name) + } + + return newPRCreated, nil +} + +type versionPair struct { + release semver.Version + tag string + docs string +} + +// For example: +// export COBRADOC_VERSION_PAIRS="main:19.0,v18.0.0-rc1:18.0,v17.0.3:17.0,v16.0.5:16.0,v15.0.5:15.0" +func extractVersionPairsFromWebsite(awk string) (versions []*versionPair, err error) { + if len(awk) == 0 { + return nil, errors.New("no version pair data from website") + } + + for _, pair := range strings.Split(awk, ",") { + parts := strings.Split(pair, ":") + if len(parts) != 2 { + return nil, fmt.Errorf("bad version pair %s", pair) + } + + var vp versionPair + switch parts[0] { + case "main": // special handling for the main branch + vp.tag = parts[0] + default: + vp.release, err = semver.Parse(parts[0]) + if err != nil { + return nil, err + } + } + + vp.docs = parts[1] + versions = append(versions, &vp) + } + + return versions, nil +} + +func updateVersionPairs(originalPairs []*versionPair, version semver.Version) (newPairs []*versionPair) { + var isRCBump bool + for _, pair := range originalPairs { + if version.RCVersion == 0 { + break + } + + if pair.release.Major == version.Major { + isRCBump = true + break + } + } + + newPairs = make([]*versionPair, 0, len(originalPairs)) + // Find the pair we need to update in the Makefile. + for _, pair := range originalPairs { + switch { + case pair.release.Major == version.Major: + newPairs = append(newPairs, &versionPair{ + release: version, + docs: pair.docs, + }) + case pair.tag == "main" && version.RCVersion > 0 && !isRCBump: + // Insert new version for "main:" + newPairs = append([]*versionPair{{ + tag: "main", + docs: fmt.Sprintf("%d.0", version.Major+1), + }}, newPairs...) + newPairs = append(newPairs, &versionPair{ + release: version, + docs: pair.docs, + }) + default: + newPairs = append(newPairs, pair) + } + } + + return newPairs +} + +func replaceVersionPairs(ctx context.Context, website *git.Repo, versionPairs []*versionPair) error { + slices.SortFunc(versionPairs, func(a, b *versionPair) int { + return -strings.Compare(a.docs, b.docs) + }) + + var ( + buf strings.Builder + pairs []string + ) + for _, pair := range versionPairs { + if pair.tag != "" { + buf.WriteString(pair.tag) + } else { + fmt.Fprintf(&buf, "v%s", pair.release.String()) + } + + buf.WriteString(":") + buf.WriteString(pair.docs) + + pairs = append(pairs, buf.String()) + buf.Reset() + } + + _, err := shell.NewContext(ctx, + "sed", + "-i", "", + "-e", fmt.Sprintf(`s/\(export COBRADOC_VERSION_PAIRS=\).*/\1%q/`, strings.Join(pairs, ",")), + "Makefile", + ).InDir(website.LocalDir).Output() + return err +} diff --git a/go/semver/semver.go b/go/semver/semver.go new file mode 100644 index 0000000..e73a430 --- /dev/null +++ b/go/semver/semver.go @@ -0,0 +1,80 @@ +/* +Copyright 2023 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package semver + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +var versionRegexp = regexp.MustCompile(`(v)?(\d+)\.(\d+)\.(\d+)(-rc\d+)?`) + +type Version struct { + Major, Minor, Patch uint + RCVersion uint +} + +func Parse(s string) (v Version, err error) { + m := versionRegexp.FindStringSubmatch(s) + if m == nil { + return Version{}, fmt.Errorf("%s is not a valid semver (does not match %s)", s, versionRegexp.String()) + } + + major, err := strconv.ParseUint(m[2], 10, 64) + if err != nil { + return v, err + } + + minor, err := strconv.ParseUint(m[3], 10, 64) + if err != nil { + return v, err + } + + patch, err := strconv.ParseUint(m[4], 10, 64) + if err != nil { + return v, err + } + + if len(m[5]) > 0 { + // remove "-rc" + rc, err := strconv.ParseUint(m[5][3:], 10, 64) + if err != nil { + return v, err + } + + v.RCVersion = uint(rc) + } + + v.Major = uint(major) + v.Minor = uint(minor) + v.Patch = uint(patch) + + return v, nil +} + +func (v Version) String() string { + var buf strings.Builder + fmt.Fprintf(&buf, "%d.%d.%d", v.Major, v.Minor, v.Patch) + + if v.RCVersion > 0 { + fmt.Fprintf(&buf, "-rc%d", v.RCVersion) + } + + return buf.String() +} diff --git a/go/semver/semver_test.go b/go/semver/semver_test.go new file mode 100644 index 0000000..cd27b2b --- /dev/null +++ b/go/semver/semver_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2023 The Vitess Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package semver + +import "testing" + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + tag string + expect string + }{ + { + tag: "1.2.3", + expect: "1.2.3", + }, + { + tag: "v1.2.3", + expect: "1.2.3", + }, + { + tag: "v10.0.18", + expect: "10.0.18", + }, + { + tag: "v18.0.0-rc1", + expect: "18.0.0-rc1", + }, + } + + for _, test := range tests { + test := test + t.Run(test.tag, func(t *testing.T) { + t.Parallel() + + v, err := Parse(test.tag) + if test.expect == "" { + if err == nil { + t.Fatalf("Parse(%s) should error; got %s", test.tag, v.String()) + } + } + + if err != nil { + t.Fatalf("Parse(%s) should not error; got %s", test.tag, err.Error()) + } + + if v.String() != test.expect { + t.Fatalf("Parse(%s): want %s; got %s", test.tag, test.expect, v.String()) + } + }) + } +}