From 9f38d1bb884257c001d5bbd0d950cd94667738a3 Mon Sep 17 00:00:00 2001 From: Bob Glickstein Date: Sat, 1 Apr 2023 08:29:57 -0700 Subject: [PATCH] Make the bobg/modver repo suitable for use in GitHub Actions (#15) * Checkpoint. * Checkpoint. Refactor some stuff to internal and create cmd/modver-action. * go mod tidy; move a test * More test coverage. * INPUT_GITHUB_TOKEN * Add GOROOT/bin to PATH in modver-action. * Not PATH but GOROOT needs to be set. * Show failure from go env * More test coverage. * Is this a thing? * Will this work? * Iterate. * Iterate. * Iterate. * Iterate. * Iterate. * Iterate. * Iterate. * Iterate. * Update Readme with info about using Modver in GitHub Actions. --- Dockerfile | 9 ++ Readme.md | 72 ++++++++++- action.yml | 13 ++ cmd/modver-action/main.go | 34 +++++ cmd/modver/compare.go | 25 +++- cmd/modver/compare_test.go | 152 +++++++++++++++++++++++ cmd/modver/main.go | 2 +- cmd/modver/options.go | 24 ---- cmd/modver/options_test.go | 46 ------- cmd/modver/tags.go | 6 +- go.mod | 2 +- {cmd/modver => internal}/comment.md.tmpl | 0 internal/github.go | 48 +++++++ internal/github_test.go | 56 +++++++++ {cmd/modver => internal}/pr.go | 5 +- {cmd/modver => internal}/pr_test.go | 2 +- 16 files changed, 413 insertions(+), 83 deletions(-) create mode 100644 Dockerfile create mode 100644 action.yml create mode 100644 cmd/modver-action/main.go create mode 100644 cmd/modver/compare_test.go rename {cmd/modver => internal}/comment.md.tmpl (100%) create mode 100644 internal/github.go create mode 100644 internal/github_test.go rename {cmd/modver => internal}/pr.go (95%) rename {cmd/modver => internal}/pr_test.go (99%) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c1b215c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:latest + +ADD . /app + +WORKDIR /app + +RUN go build ./cmd/modver-action + +ENTRYPOINT ["/app/modver-action"] diff --git a/Readme.md b/Readme.md index c482cbd..8c9f633 100644 --- a/Readme.md +++ b/Readme.md @@ -6,17 +6,24 @@ [![Coverage Status](https://coveralls.io/repos/github/bobg/modver/badge.svg?branch=master)](https://coveralls.io/github/bobg/modver?branch=master) This is modver, -a Go package and command that helps you obey [semantic versioning rules](https://semver.org/) in your Go module. +a tool that helps you obey [semantic versioning rules](https://semver.org/) in your Go module. It can read and compare two different versions of the same module, from two different directories, -or two different Git commits. +or two different Git commits, +or the base and head of a Git pull request. It then reports whether the changes require an increase in the major-version number, the minor-version number, or the patchlevel. ## Installation and usage +Modver can be used from the command line, +or in your Go program, +or with [GitHub Actions](https://github.com/features/actions). + +### Command-line interface + Install the `modver` command like this: ```sh @@ -37,7 +44,68 @@ The arguments `HEAD~1` and `HEAD` specify two Git revisions to compare; in this case, the latest two commits on the current branch. These could also be tags or commit hashes. +### GitHub Action + +You can arrange for Modver to inspect the changes on your pull-request branch +as part of a GitHub Actions-based continuous-integration step. +It will add a comment to the pull request with its findings, +and will update the comment as new commits are pushed to the branch. + +To do this, you’ll need a directory in your GitHub repository named `.github/workflows`, +and a Yaml file containing (at least) the following: + +```yaml +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.19 + + - name: Modver + if: ${{ github.event_name == 'pull_request' }} + uses: bobg/modver@v2.5.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + pull_request_url: https://github.com/${{ github.repository }}/pull/${{ github.event.number }} +``` + +This can be combined with other steps that run unit tests, etc. +You can change `Tests` to whatever name you like, +and should change `main` to the name of your repository’s default branch. +If your pull request is on a GitHub server other than `github.com`, +change the hostname in the `pull_request_url` parameter to match. + +Note the `fetch-depth: 0` parameter for the `Checkout` step. +This causes GitHub Actions to create a clone of your repo with its full history, +as opposed to the default, +which is a shallow clone. +Modver requires enough history to be present in the clone +for it to access the “base” and “head” revisions of your pull-request branch. + +For more information about configuring GitHub Actions, +see [the GitHub Actions documentation](https://docs.github.com/actions). + +### Go library + Modver also has a simple API for use from within Go programs. +Add it to your project with `go get github.com/bobg/modver/v2@latest`. +See [the Go doc page](https://pkg.go.dev/github.com/bobg/modver/v2) for information about how to use it. ## Semantic versioning diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..907d8d1 --- /dev/null +++ b/action.yml @@ -0,0 +1,13 @@ +name: Modver +description: Analyze pull requests for changes in Go code that require updating a module's version number. +author: Bob Glickstein +inputs: + github_token: + description: 'The GitHub token to use for authentication.' + required: true + pull_request_url: + description: 'The full github.com URL of the pull request.' + required: true +runs: + using: 'docker' + image: 'Dockerfile' diff --git a/cmd/modver-action/main.go b/cmd/modver-action/main.go new file mode 100644 index 0000000..7a1d2c1 --- /dev/null +++ b/cmd/modver-action/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/bobg/modver/v2" + "github.com/bobg/modver/v2/internal" +) + +func main() { + os.Setenv("GOROOT", "/usr/local/go") // Work around some Docker weirdness. + + prURL := os.Getenv("INPUT_PULL_REQUEST_URL") + host, owner, reponame, prnum, err := internal.ParsePR(prURL) + if err != nil { + log.Fatal(err) + } + token := os.Getenv("INPUT_GITHUB_TOKEN") + if token == "" { + log.Fatal("No GitHub token in the environment variable INPUT_GITHUB_TOKEN") + } + ctx := context.Background() + gh, err := internal.NewClient(ctx, host, token) + if err != nil { + log.Fatalf("Creating GitHub client: %s", err) + } + result, err := internal.PR(ctx, gh, owner, reponame, prnum) + if err != nil { + log.Fatalf("Running comparison: %s", err) + } + modver.Pretty(os.Stdout, result) +} diff --git a/cmd/modver/compare.go b/cmd/modver/compare.go index e86c525..d78d719 100644 --- a/cmd/modver/compare.go +++ b/cmd/modver/compare.go @@ -9,19 +9,34 @@ import ( "github.com/pkg/errors" "github.com/bobg/modver/v2" + "github.com/bobg/modver/v2/internal" ) func doCompare(ctx context.Context, opts options) (modver.Result, error) { + return doCompareHelper(ctx, opts, internal.NewClient, internal.PR, modver.CompareGitWith, modver.CompareDirs) +} + +type ( + newClientType = func(ctx context.Context, host, token string) (*github.Client, error) + prType = func(ctx context.Context, gh *github.Client, owner, reponame string, prnum int) (modver.Result, error) + compareGitWithType = func(ctx context.Context, repoURL, olderRev, newerRev string, f func(older, newer string) (modver.Result, error)) (modver.Result, error) + compareDirsType = func(older, newer string) (modver.Result, error) +) + +func doCompareHelper(ctx context.Context, opts options, newClient newClientType, pr prType, compareGitWith compareGitWithType, compareDirs compareDirsType) (modver.Result, error) { if opts.pr != "" { - owner, reponame, prnum, err := parsePR(opts.pr) + host, owner, reponame, prnum, err := internal.ParsePR(opts.pr) if err != nil { return modver.None, errors.Wrap(err, "parsing pull-request URL") } if opts.ghtoken == "" { return modver.None, fmt.Errorf("usage: %s -pr URL [-token TOKEN]", os.Args[0]) } - gh := github.NewTokenClient(ctx, opts.ghtoken) - return doPR(ctx, gh, owner, reponame, prnum) + gh, err := newClient(ctx, host, opts.ghtoken) + if err != nil { + return modver.None, errors.Wrap(err, "creating GitHub client") + } + return pr(ctx, gh, owner, reponame, prnum) } if opts.gitRepo != "" { @@ -34,10 +49,10 @@ func doCompare(ctx context.Context, opts options) (modver.Result, error) { callback = getTags(&opts.v1, &opts.v2, opts.args[0], opts.args[1]) } - return modver.CompareGitWith(ctx, opts.gitRepo, opts.args[0], opts.args[1], callback) + return compareGitWith(ctx, opts.gitRepo, opts.args[0], opts.args[1], callback) } if len(opts.args) != 2 { return nil, fmt.Errorf("usage: %s [-q | -pretty] [-v1 OLDERVERSION -v2 NEWERVERSION] OLDERDIR NEWERDIR", os.Args[0]) } - return modver.CompareDirs(opts.args[0], opts.args[1]) + return compareDirs(opts.args[0], opts.args[1]) } diff --git a/cmd/modver/compare_test.go b/cmd/modver/compare_test.go new file mode 100644 index 0000000..a423910 --- /dev/null +++ b/cmd/modver/compare_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-github/v50/github" + + "github.com/bobg/modver/v2" +) + +func TestDoCompare(t *testing.T) { + cases := []struct { + opts options + wantErr bool + pr func(*testing.T, *int) prType + compareGitWith func(*testing.T, *int) compareGitWithType + compareDirs func(*testing.T, *int) compareDirsType + }{{ + opts: options{ + pr: "https://github.com/foo/bar/pull/17", + ghtoken: "token", + }, + pr: mockPR("foo", "bar", 17), + }, { + opts: options{ + pr: "https://github.com/foo/bar/baz/pull/17", + ghtoken: "token", + }, + wantErr: true, + }, { + opts: options{ + pr: "https://github.com/foo/bar/pull/17", + }, + wantErr: true, + }, { + opts: options{ + gitRepo: ".git", + args: []string{"older", "newer"}, + }, + compareGitWith: mockCompareGitWith(".git", "older", "newer"), + }, { + opts: options{ + gitRepo: ".git", + args: []string{"older", "newer", "evenmorenewer"}, + }, + wantErr: true, + }, { + opts: options{ + args: []string{"older", "newer"}, + }, + compareDirs: mockCompareDirs("older", "newer"), + }, { + opts: options{ + args: []string{"older"}, + }, + wantErr: true, + }} + + ctx := context.Background() + + for i, tc := range cases { + t.Run(fmt.Sprintf("case_%02d", i+1), func(t *testing.T) { + var ( + pr prType + compareGitWith compareGitWithType + compareDirs compareDirsType + calls int + ) + if tc.pr != nil { + pr = tc.pr(t, &calls) + } + if tc.compareGitWith != nil { + compareGitWith = tc.compareGitWith(t, &calls) + } + if tc.compareDirs != nil { + compareDirs = tc.compareDirs(t, &calls) + } + + _, err := doCompareHelper(ctx, tc.opts, mockNewClient, pr, compareGitWith, compareDirs) + if err != nil { + if !tc.wantErr { + t.Errorf("got error %s, wanted none", err) + } + return + } + if tc.wantErr { + t.Error("got no error, wanted one") + return + } + if calls != 1 { + t.Errorf("got %d calls, want 1", calls) + } + }) + } +} + +func mockNewClient(ctx context.Context, host, token string) (*github.Client, error) { + return nil, nil +} + +func mockPR(wantOwner, wantRepo string, wantPRNum int) func(*testing.T, *int) prType { + return func(t *testing.T, calls *int) prType { + return func(ctx context.Context, gh *github.Client, owner, reponame string, prnum int) (modver.Result, error) { + *calls++ + if owner != wantOwner { + t.Errorf("got owner %s, want %s", owner, wantOwner) + } + if reponame != wantRepo { + t.Errorf("got repo %s, want %s", reponame, wantRepo) + } + if wantPRNum != prnum { + t.Errorf("got PR number %d, want %d", prnum, wantPRNum) + } + return modver.None, nil + } + } +} + +func mockCompareGitWith(wantGitRepo, wantOlder, wantNewer string) func(*testing.T, *int) compareGitWithType { + return func(t *testing.T, calls *int) compareGitWithType { + return func(ctx context.Context, repoURL, olderRev, newerRev string, f func(older, newer string) (modver.Result, error)) (modver.Result, error) { + *calls++ + if repoURL != wantGitRepo { + t.Errorf("got repo URL %s, want %s", repoURL, wantGitRepo) + } + if olderRev != wantOlder { + t.Errorf("got older rev %s, want %s", olderRev, wantOlder) + } + if newerRev != wantNewer { + t.Errorf("got newer rev %s, want %s", newerRev, wantNewer) + } + return modver.None, nil + } + } +} + +func mockCompareDirs(wantOlder, wantNewer string) func(*testing.T, *int) compareDirsType { + return func(t *testing.T, calls *int) compareDirsType { + return func(older, newer string) (modver.Result, error) { + *calls++ + if older != wantOlder { + t.Errorf("got older dir %s, want %s", older, wantOlder) + } + if newer != wantNewer { + t.Errorf("got newer dir %s, want %s", newer, wantNewer) + } + return modver.None, nil + } + } +} diff --git a/cmd/modver/main.go b/cmd/modver/main.go index e18c357..c1604c9 100644 --- a/cmd/modver/main.go +++ b/cmd/modver/main.go @@ -11,7 +11,7 @@ // // With `-pr URL`, // the URL must be that of a github.com pull request -// (having the form https://github.com/OWNER/REPO/pull/NUMBER). +// (having the form https://HOST/OWNER/REPO/pull/NUMBER). // The environment variable GITHUB_TOKEN must contain a valid GitHub access token, // or else one must be supplied on the command line with -token. // In this mode, diff --git a/cmd/modver/options.go b/cmd/modver/options.go index 30457a3..c69402d 100644 --- a/cmd/modver/options.go +++ b/cmd/modver/options.go @@ -3,9 +3,7 @@ package main import ( "flag" "fmt" - "net/url" "os" - "strconv" "strings" "github.com/pkg/errors" @@ -65,25 +63,3 @@ func parseArgsHelper(args []string) (opts options, err error) { return opts, nil } - -func parsePR(pr string) (owner, reponame string, prnum int, err error) { - u, err := url.Parse(pr) - if err != nil { - err = errors.Wrap(err, "parsing GitHub pull-request URL") - return - } - path := strings.TrimLeft(u.Path, "/") - parts := strings.Split(path, "/") - if len(parts) < 4 { - err = fmt.Errorf("too few path elements in pull-request URL (got %d, want 4)", len(parts)) - return - } - if parts[2] != "pull" { - err = fmt.Errorf("pull-request URL not in expected format") - return - } - owner, reponame = parts[0], parts[1] - prnum, err = strconv.Atoi(parts[3]) - err = errors.Wrap(err, "parsing number from GitHub pull-request URL") - return -} diff --git a/cmd/modver/options_test.go b/cmd/modver/options_test.go index 5c25a50..b222cf9 100644 --- a/cmd/modver/options_test.go +++ b/cmd/modver/options_test.go @@ -75,49 +75,3 @@ func TestParseArgs(t *testing.T) { }) } } - -func TestParsePR(t *testing.T) { - cases := []struct { - inp string - wantErr bool - owner, reponame string - prnum int - }{{ - wantErr: true, - }, { - inp: "https://x/y", - wantErr: true, - }, { - inp: "https://github.com/bobg/modver/bleah/17", - wantErr: true, - }, { - inp: "https://github.com/bobg/modver/pull/17", - owner: "bobg", - reponame: "modver", - prnum: 17, - }} - - for i, tc := range cases { - t.Run(fmt.Sprintf("case_%02d", i+1), func(t *testing.T) { - owner, reponame, prnum, err := parsePR(tc.inp) - if err != nil { - if !tc.wantErr { - t.Errorf("got error %v, wanted no error", err) - } - return - } - if tc.wantErr { - t.Fatal("got no error but wanted one") - } - if owner != tc.owner { - t.Errorf("got owner %s, want %s", owner, tc.owner) - } - if reponame != tc.reponame { - t.Errorf("got repo %s, want %s", reponame, tc.reponame) - } - if prnum != tc.prnum { - t.Errorf("got PR number %d, want %d", prnum, tc.prnum) - } - }) - } -} diff --git a/cmd/modver/tags.go b/cmd/modver/tags.go index 2ce613f..7190b6b 100644 --- a/cmd/modver/tags.go +++ b/cmd/modver/tags.go @@ -17,6 +17,10 @@ import ( ) func getTags(v1, v2 *string, olderRev, newerRev string) func(older, newer string) (modver.Result, error) { + return getTagsHelper(v1, v2, olderRev, newerRev, modver.CompareDirs) +} + +func getTagsHelper(v1, v2 *string, olderRev, newerRev string, compareDirs compareDirsType) func(older, newer string) (modver.Result, error) { return func(older, newer string) (modver.Result, error) { tag, err := getTag(older, olderRev) if err != nil { @@ -30,7 +34,7 @@ func getTags(v1, v2 *string, olderRev, newerRev string) func(older, newer string } *v2 = tag - return modver.CompareDirs(older, newer) + return compareDirs(older, newer) } } diff --git a/go.mod b/go.mod index 5b91a3e..8660963 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/go-github/v50 v50.2.0 github.com/pkg/errors v0.9.1 golang.org/x/mod v0.9.0 + golang.org/x/oauth2 v0.6.0 golang.org/x/tools v0.7.0 ) @@ -29,7 +30,6 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.7.0 // indirect golang.org/x/net v0.8.0 // indirect - golang.org/x/oauth2 v0.6.0 // indirect golang.org/x/sys v0.6.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.28.0 // indirect diff --git a/cmd/modver/comment.md.tmpl b/internal/comment.md.tmpl similarity index 100% rename from cmd/modver/comment.md.tmpl rename to internal/comment.md.tmpl diff --git a/internal/github.go b/internal/github.go new file mode 100644 index 0000000..f50625c --- /dev/null +++ b/internal/github.go @@ -0,0 +1,48 @@ +package internal + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/google/go-github/v50/github" + "github.com/pkg/errors" + "golang.org/x/oauth2" +) + +// ParsePR parses a GitHub pull-request URL, +// which should have the form http(s)://HOST/OWNER/REPO/pull/NUMBER. +func ParsePR(pr string) (host, owner, reponame string, prnum int, err error) { + u, err := url.Parse(pr) + if err != nil { + err = errors.Wrap(err, "parsing GitHub pull-request URL") + return + } + path := strings.TrimLeft(u.Path, "/") + parts := strings.Split(path, "/") + if len(parts) < 4 { + err = fmt.Errorf("too few path elements in pull-request URL (got %d, want 4)", len(parts)) + return + } + if parts[2] != "pull" { + err = fmt.Errorf("pull-request URL not in expected format") + return + } + host = u.Host + owner, reponame = parts[0], parts[1] + prnum, err = strconv.Atoi(parts[3]) + err = errors.Wrap(err, "parsing number from GitHub pull-request URL") + return +} + +// NewClient creates a new GitHub client talking to the given host and authenticated with the given token. +func NewClient(ctx context.Context, host, token string) (*github.Client, error) { + if strings.ToLower(host) == "github.com" { + return github.NewTokenClient(ctx, token), nil + } + oClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})) + u := "https://" + host + return github.NewEnterpriseClient(u, u, oClient) +} diff --git a/internal/github_test.go b/internal/github_test.go new file mode 100644 index 0000000..d320602 --- /dev/null +++ b/internal/github_test.go @@ -0,0 +1,56 @@ +package internal + +import ( + "fmt" + "testing" +) + +func TestParsePR(t *testing.T) { + cases := []struct { + inp string + wantErr bool + host, owner, reponame string + prnum int + }{{ + wantErr: true, + }, { + inp: "https://x/y", + wantErr: true, + }, { + inp: "https://github.com/bobg/modver/bleah/17", + wantErr: true, + }, { + inp: "https://github.com/bobg/modver/pull/17", + host: "github.com", + owner: "bobg", + reponame: "modver", + prnum: 17, + }} + + for i, tc := range cases { + t.Run(fmt.Sprintf("case_%02d", i+1), func(t *testing.T) { + host, owner, reponame, prnum, err := ParsePR(tc.inp) + if err != nil { + if !tc.wantErr { + t.Errorf("got error %v, wanted no error", err) + } + return + } + if tc.wantErr { + t.Fatal("got no error but wanted one") + } + if host != tc.host { + t.Errorf("got host %s, want %s", host, tc.host) + } + if owner != tc.owner { + t.Errorf("got owner %s, want %s", owner, tc.owner) + } + if reponame != tc.reponame { + t.Errorf("got repo %s, want %s", reponame, tc.reponame) + } + if prnum != tc.prnum { + t.Errorf("got PR number %d, want %d", prnum, tc.prnum) + } + }) + } +} diff --git a/cmd/modver/pr.go b/internal/pr.go similarity index 95% rename from cmd/modver/pr.go rename to internal/pr.go index 0788ceb..6de3569 100644 --- a/cmd/modver/pr.go +++ b/internal/pr.go @@ -1,4 +1,4 @@ -package main +package internal import ( "bufio" @@ -16,7 +16,8 @@ import ( "github.com/bobg/modver/v2" ) -func doPR(ctx context.Context, gh *github.Client, owner, reponame string, prnum int) (modver.Result, error) { +// PR performs modver analysis on a GitHub pull request. +func PR(ctx context.Context, gh *github.Client, owner, reponame string, prnum int) (modver.Result, error) { return prHelper(ctx, gh.Repositories, gh.PullRequests, gh.Issues, modver.CompareGit, owner, reponame, prnum) } diff --git a/cmd/modver/pr_test.go b/internal/pr_test.go similarity index 99% rename from cmd/modver/pr_test.go rename to internal/pr_test.go index ffa44b4..b1b34da 100644 --- a/cmd/modver/pr_test.go +++ b/internal/pr_test.go @@ -1,4 +1,4 @@ -package main +package internal import ( "context"