├── .circleci └── config.yml ├── .env-template ├── .github └── workflows │ ├── build.yml │ ├── release.yml │ └── validation.yml ├── .gitignore ├── .golangci.yml ├── README.md ├── build.goreleaser.yml ├── build └── package │ └── docker │ └── Dockerfile ├── cmd └── inclusify │ └── main.go ├── codecov.yml ├── go.mod ├── go.sum ├── pkg ├── branches │ ├── create.go │ ├── create_test.go │ ├── delete.go │ ├── delete_test.go │ └── updateDefault.go ├── config │ ├── config.go │ └── config_test.go ├── files │ ├── createScaffold.go │ └── updateRefs.go ├── gh │ ├── fake.go │ └── gh.go ├── message │ └── message.go ├── pulls │ ├── close.go │ ├── merge.go │ └── update.go ├── repos │ ├── create.go │ └── delete.go ├── tests │ ├── fakeRepo │ │ ├── .circleci │ │ │ ├── .gitattributes │ │ │ ├── .gitignore │ │ │ ├── Makefile │ │ │ ├── README.md │ │ │ ├── config.yml │ │ │ ├── config │ │ │ │ ├── config.yml │ │ │ │ ├── jobs │ │ │ │ │ └── test.yml │ │ │ │ └── workflows │ │ │ │ │ └── ci.yml │ │ │ └── pre-commit │ │ ├── .github │ │ │ └── workflows │ │ │ │ └── ci-tester.yml │ │ ├── .goreleaser.yml │ │ ├── .teamcity.yml │ │ ├── .travis.yaml │ │ ├── README.md │ │ └── scripts │ │ │ └── hello.py │ └── integration_test.go └── version │ └── version.go └── release.goreleaser.yml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | references: 4 | images: 5 | go: &GOLANG_IMAGE docker.mirror.hashicorp.services/cimg/go:1.15.2 6 | base: &CI_BASE docker.mirror.hashicorp.services/cimg/base:2020.09 7 | lint: &GOLANG_LINTER_IMAGE docker.mirror.hashicorp.services/golangci/golangci-lint:v1.28.1-alpine 8 | 9 | jobs: 10 | go-test: 11 | docker: 12 | - image: *GOLANG_IMAGE 13 | steps: 14 | - checkout 15 | - run: 16 | name: Run unit tests 17 | command: | 18 | mkdir -p /tmp/test-results 19 | gotestsum \ 20 | --junitfile /tmp/test-results/gotestsum-report.xml \ 21 | -- -cover -coverprofile=coverage-unit.txt ./... 22 | - run: 23 | name: Run integration tests 24 | command: | 25 | mkdir -p /tmp/test-results 26 | gotestsum \ 27 | --junitfile /tmp/test-results/gotestsum-report.xml \ 28 | -- -cover -coverprofile=coverage-integration.txt ./... \ 29 | --tags=integration 30 | - store_test_results: 31 | path: /tmp/test-results 32 | go-lint: 33 | docker: 34 | - image: *GOLANG_LINTER_IMAGE 35 | steps: 36 | - checkout 37 | - run: 38 | name: go linter 39 | command: | 40 | golangci-lint run 41 | docker-build: 42 | docker: 43 | - image: *CI_BASE 44 | resource_class: large 45 | steps: 46 | - checkout 47 | - setup_remote_docker: 48 | version: 19.03.12 49 | - run: 50 | name: Docker Build 51 | command: | 52 | docker build --progress=plain \ 53 | --tag "${CIRCLE_WORKFLOW_ID}" \ 54 | --file build/package/docker/Dockerfile \ 55 | . 56 | docker save "${CIRCLE_WORKFLOW_ID}" | gzip > inclusify.tar.gz 57 | - persist_to_workspace: 58 | root: "." 59 | paths: 60 | - inclusify.tar.gz 61 | docker-push: 62 | docker: 63 | - image: *CI_BASE 64 | steps: 65 | - attach_workspace: 66 | at: "." 67 | - setup_remote_docker: 68 | version: 19.03.12 69 | - run: 70 | name: Reload Docker Image 71 | command: | 72 | docker load < inclusify.tar.gz 73 | - run: 74 | name: Push Docker Image 75 | command: | 76 | version="$(docker run "${CIRCLE_WORKFLOW_ID}" inclusify --version)" 77 | docker tag "${CIRCLE_WORKFLOW_ID}" "hashicorpdev/inclusify:${version}" 78 | echo "$DOCKERHUB_PASSWORD" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin 79 | docker push "hashicorpdev/inclusify:${version}" 80 | 81 | workflows: 82 | version: 2 83 | validate: 84 | jobs: 85 | - go-test 86 | - go-lint 87 | - docker-build 88 | - docker-push: 89 | requires: 90 | - go-test 91 | - go-lint 92 | - docker-build 93 | filters: 94 | branches: 95 | only: 96 | - main 97 | -------------------------------------------------------------------------------- /.env-template: -------------------------------------------------------------------------------- 1 | export INCLUSIFY_OWNER="$owner" // REQUIRED: Name of the GitHub org or user account where the repo lives 2 | export INCLUSIFY_REPO="$repo" // REQUIRED: Name of the repo 3 | export INCLUSIFY_TOKEN="$github_personal_access_token" // REQUIRED: Your GitHub personal access token that has write access to the repo 4 | export INCLUSIFY_EXCLUSION=".git/,README.md" // OPTIONAL: Comma delimated directories/files that should not be updated. This defaults to nil. 5 | export INCLUSIFY_BASE="master" // OPTIONAL: Name of the current default base branch for the repo. This defaults to "master" 6 | export INCLUSIFY_TARGET="main" // OPTIONAL: Name of the new target base branch for the repo. This defaults to "main" -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | ### This builds and packages inclusify on every branch push in the repo. 2 | ### Artifacts are uploaded to GitHub to help with debugging. 3 | ### The GitHub release step performs the actions outlined in build.goreleaser.yml. 4 | 5 | name: Build 6 | 7 | on: 8 | push: 9 | branches-ignore: 10 | - main 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | - name: Setup go 19 | uses: actions/setup-go@v2 20 | with: 21 | go-version: '^1.15' 22 | - name: GitHub Release 23 | uses: goreleaser/goreleaser-action@v1 24 | with: 25 | version: latest 26 | args: release --skip-validate --skip-sign --skip-publish --timeout "60m" --config build.goreleaser.yml 27 | - name: Upload artifacts to actions workflow 28 | uses: actions/upload-artifact@v2 29 | with: 30 | name: ${{ github.event.repository.name }}-artifacts 31 | path: | 32 | ${{ github.workspace }}/dist/*.zip 33 | ${{ github.workspace }}/dist/*.sig 34 | ${{ github.workspace }}/dist/*SHA256 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | ### This builds, packages, signs, performs AV and malware scanning, and 2 | ### creates a new GitHub release for the newest version of inclusify. 3 | ### The GitHub release step performs the actions outlined in 4 | ### release.goreleaser.yml. A release is triggered when a new tag 5 | ### is pushed in the format vX.X.X 6 | 7 | name: Release 8 | 9 | on: 10 | push: 11 | tags: 12 | - 'v[0-9]+.[0-9]+.[0-9]+*' 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | - name: Setup go 21 | uses: actions/setup-go@v2 22 | with: 23 | go-version: '^1.15' 24 | - name: Install hc-codesign 25 | id: codesign 26 | run: | 27 | docker login docker.pkg.github.com -u docker -p $GITHUB_TOKEN && \ 28 | docker pull docker.pkg.github.com/hashicorp/hc-codesign/hc-codesign:$VERSION && \ 29 | echo "::set-output name=image::docker.pkg.github.com/hashicorp/hc-codesign/hc-codesign:$VERSION" 30 | env: 31 | VERSION: v0 32 | GITHUB_TOKEN: ${{ secrets.CODESIGN_GITHUB_TOKEN }} 33 | - name: Install wget & clamAV antivirus scanner 34 | run : | 35 | sudo apt-get -qq install -y ca-certificates wget clamav 36 | wget --version 37 | - name: Install maldet malware scanner 38 | run: | 39 | wget --no-verbose -O maldet-$VERSION.tar.gz https://github.com/rfxn/linux-malware-detect/archive/$VERSION.tar.gz 40 | sha256sum -c - <<< "$SHA256SUM maldet-$VERSION.tar.gz" 41 | sudo mkdir -p maldet-$VERSION 42 | sudo tar -xzf maldet-$VERSION.tar.gz --strip-components=1 -C maldet-$VERSION 43 | cd maldet-$VERSION 44 | sudo ./install.sh 45 | sudo maldet -u 46 | env: 47 | VERSION: 1.6.4 48 | SHA256SUM: 3ad66eebd443d32dd6c811dcf2d264b78678c75ed1d40c15434180d4453e60d2 49 | - name: Import PGP key for archive signing 50 | run: echo -e "$PGP_KEY" | gpg --import 51 | env: 52 | PGP_KEY: ${{ secrets.PGP_SIGNING_KEY }} 53 | - name: GitHub Release 54 | uses: goreleaser/goreleaser-action@v1 55 | with: 56 | version: latest 57 | args: release --skip-validate --timeout "60m" --config release.goreleaser.yml 58 | env: 59 | PGP_KEY_ID: ${{ secrets.PGP_KEY_ID }} 60 | CODESIGN_IMAGE: ${{ steps.codesign.outputs.image }} 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | ARTIFACTORY_TOKEN: ${{ secrets.ARTIFACTORY_TOKEN }} 63 | ARTIFACTORY_USER: ${{ secrets.ARTIFACTORY_USER }} 64 | CIRCLE_TOKEN: ${{ secrets.CIRCLE_TOKEN }} 65 | - name: Run clamAV antivirus scanner 66 | run: sudo clamscan ${{ github.workspace }}/dist/ 67 | - name: Run maldet malware scanner 68 | run: sudo maldet -a ${{ github.workspace }}/dist/ 69 | -------------------------------------------------------------------------------- /.github/workflows/validation.yml: -------------------------------------------------------------------------------- 1 | ### This runs GitHub's static analysis engine, CodeQL, against the repo's source code 2 | ### to find security vulnerabilities. It then automatically uploads the results 3 | ### to GitHub so they can be displayed in the repo's security tab. 4 | ### Source: https://github.com/github/codeql-action 5 | 6 | name: Code Scanning 7 | 8 | on: 9 | - push 10 | - pull_request 11 | 12 | jobs: 13 | scanning: 14 | name: CodeQL Scanning 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 2 22 | 23 | # If this run was triggered by a pull request event, then checkout 24 | # the head of the pull request instead of the merge commit 25 | - run: git checkout HEAD^2 26 | if: ${{ github.event_name == 'pull_request' }} 27 | 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v1 30 | with: 31 | languages: ${{ matrix.language }} 32 | 33 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java) 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v1 36 | 37 | - name: Perform CodeQL Analysis 38 | uses: github/codeql-action/analyze@v1 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | inclusify 3 | .DS_Store 4 | tmp-clone-* 5 | coverage.txt 6 | inclusify* 7 | dist/ 8 | bin/ -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | concurrency: 4 3 | timeout: 5m 4 | linters: 5 | # `enable-all` is deprecated and will be removed soon. 6 | disable-all: true 7 | enable: 8 | - typecheck 9 | - dupl 10 | - goprintffuncname 11 | - govet 12 | - nolintlint 13 | - rowserrcheck 14 | - gofmt 15 | - golint 16 | - goimports 17 | - misspell 18 | - bodyclose 19 | - unconvert 20 | - interfacer 21 | - ineffassign 22 | - scopelint 23 | - structcheck 24 | - deadcode 25 | - depguard 26 | - dogsled 27 | - errcheck 28 | - funlen 29 | - goconst 30 | - gocritic 31 | - gocyclo 32 | - gosimple 33 | - staticcheck 34 | - stylecheck 35 | - unused 36 | - varcheck 37 | - unparam 38 | - unconvert 39 | - whitespace 40 | 41 | linters-settings: 42 | funlen: 43 | lines: 80 44 | statements: 40 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Warning: This repo has been deprecated 2 | 3 | GitHub was a little late to the party, but they now have native support for nearly everything that Inclusify was bulit for. Check out [github/renaming](https://github.com/github/renaming) for more information. The one area not covered by GitHub is around updating code references from `master` to `main`- this part of Inclusify can be re-used, or other clone & find/replace/commit/create PR tools can be used. 4 | 5 | # inclusify 6 | 7 | Inclusify is a CLI that will rename the default branch of any GitHub repo and perform all other necessary tasks that go along with it, such as updating CI references, updating the base branch of open PR's, copying the branch protection, and deleting the old base. 8 | 9 | ``` 10 | Usage: inclusify [--version] [--help] [] 11 | 12 | Available commands are: 13 | createBranches Create new branches on GitHub. [subcommand] 14 | deleteBranches Delete repo's base branch and other auto-created branches. [subcommand] 15 | updateRefs Update code references from base to target in the given repo. [subcommand] 16 | updateDefault Update repo's default branch. [subcommand] 17 | updatePulls Update base branch of open PR's. [subcommand] 18 | ``` 19 | 20 | ## Usage 21 | 22 | 1. Download the latest release for your os/platform from https://github.com/hashicorp/inclusify/releases, and unzip to access the go binary. 23 | 24 | 2. Create an `.env` file with the following variables: 25 | 26 | | Environment Variable | Explanation | 27 | |----------------------------------------|--------------------------------------------------------------------------------------| 28 | | export INCLUSIFY_OWNER="$owner" | REQUIRED: Name of the GitHub org or user account where the repo lives | 29 | | export INCLUSIFY_REPO="$repo" | REQUIRED: Name of the repo to update | 30 | | export INCLUSIFY_TOKEN="$github_token" | REQUIRED: GitHub personal access token with -rw permissions | 31 | | export INCLUSIFY_BASE="master" | OPTIONAL: Name of the current default branch for the repo. This defaults to "master" | 32 | | export INCLUSIFY_TARGET="main" | OPTIONAL: Name of the new target base branch for the repo. This defaults to "main" | 33 | | export INCLUSIFY_EXCLUSION="vendor/,scripts/hello.py,README.md" | OPTIONAL: Comma delimited list of directories or files to exclude from the find/replace. Paths should be relative to the root of the repo. | 34 | 35 | **Note:** You can alternatively pass in the required flags to the subcommands or set environment variables locally without sourcing an env file. For ease of use, however, we recommend sourcing a local env file. 36 | 37 | 3. Source the file to set the environment variables locally: `source .env` 38 | 39 | 4. Run the below commands in the following order: 40 | 41 | Set up the new target branch and temporary branches which will be used in the next steps, and create a PR to update all code references from `base` to `target`. This happens via a simple find and replace within all files in the repo, with the exception of `.git/`, `go.mod`, and `go.sum`. To exclude other directories or files from the search, add them to `INCLUSIFY_EXCLUSION`. 42 | ``` 43 | ./inclusify createBranches 44 | ./inclusify updateRefs 45 | ``` 46 | 47 | On success, updateRefs will return a pull request URL. **Review the PR carefully, make any required changes, and merge it into the `target` branch before continuing.** 48 | 49 | Continue with the below commands to update the base branch of any open PR's from `base` to `target`. Finally, update the repo's default branch from `base` to `target`. If the `base` branch was protected, copy that protection over to `target`. 50 | ``` 51 | ./inclusify updatePulls 52 | ./inclusify updateDefault 53 | ``` 54 | 55 | After verifying everything is working properly, delete the old base branch. If the `base` branch was protected, the protection will be removed automatically, and then the branch will be deleted. This will also delete the `update-references` branch that was created in the first step. 56 | ``` 57 | ./inclusify deleteBranches 58 | ``` 59 | 60 | 5. Instruct all contributors to the repository to reset their local remote origins using one of the below methods: 61 | 1. Reset your local repo and branches to point to the new default 62 | 1. run `git fetch` 63 | 1. fetches all upstream tags/branches; this will pull the new default branch and update that the previous one at `origin/$INCLUSIFY_BASE` has been deleted 64 | 1. run `git remote set-head origin -a` 65 | 1. this sets your local `origin/HEAD` to reflect the source `origin/HEAD` of the new default branch 66 | 1. for each of your in-progress branches, run `git branch --set-upstream-to origin/$INCLUSIFY_TARGET`, e.g. `git branch --set-upstream-to origin/main` 67 | 1. this sets your local branch(es) to track diffs from `origin/$INCLUSIFY_TARGET` 68 | 1. the last step has two options: 69 | 1. if you only have `$INCLUSIFY_BASE` e.g. `master` in your local list of branches, then you need to rename it to `$INCLUSIFY_TARGET` e.g. `main` by running `git branch -m master main` 70 | 1. if you have both `master` and `main` branches in your list, then delete your local copy of `master` with `git branch -d master` 71 | 1. Delete your local repo and reclone 72 | 1. Of course you will want to push up any in-progress work first, ensure you don't have anything valuable stashed, etc. 73 | 74 | ## Local Development 75 | 76 | 1. Clone the repo 77 | ``` 78 | git clone git@github.com:hashicorp/inclusify.git ~/go/src/github.com/hashicorp/inclusify 79 | cd ~/go/src/github.com/hashicorp/inclusify 80 | ``` 81 | 82 | 2. Make any modifications you wish, and build the go binary 83 | ``` 84 | go build -o inclusify ./cmd/inclusify 85 | ``` 86 | 87 | 3. Pass in the required flags to the subcommands, set env vars, or source a local env file, as explained in step #2 above. Remember that the GitHub token chosen will need to be associated with a user with `write` access on the repo. 88 | 89 | 4. Set your `$GOPATH` 90 | ``` 91 | export GOPATH=$HOME/go 92 | ``` 93 | 94 | 5. Run the subcommands in the correct order, as explained in step #4 above. 95 | 96 | ## Testing 97 | 98 | To run the unit tests locally, clone the repo and run `go test ./...` or `gotestsum ./...` in the root of the directory. To run the integration tests, set the environment variables as described in step #2 above and add `--tags=integation` to the test command. These tests will create a new repo under the authenticated user, provision the repo, and create/delete real resources within it. Finally, the test repo will be deleted. 99 | 100 | All tests are run in CI on every push, and run against a repo in the format `$user/inclusify-tests-$random`. 101 | 102 | ## Docker 103 | 104 | The Dockerfile is located in `build/package/docker` and is available on the `hashicorpdev` dockerhub account. Build locally with the following command: 105 | 106 | ``` 107 | docker build -f build/package/docker/Dockerfile -t inclusify . 108 | docker run inclusify 109 | ``` 110 | -------------------------------------------------------------------------------- /build.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go test ./... 4 | 5 | builds: 6 | - mod_timestamp: '{{ .CommitTimestamp }}' 7 | targets: 8 | - linux_386 9 | - linux_amd64 10 | - darwin_amd64 11 | - windows_386 12 | - windows_amd64 13 | dir: ./cmd/inclusify/ 14 | flags: 15 | - -trimpath 16 | ldflags: 17 | - -X main.GitCommit={{ .Commit }} 18 | 19 | archives: 20 | - format: zip 21 | name_template: "{{ .ProjectName }}_{{ .ShortCommit }}_{{ .Os }}_{{ .Arch }}" 22 | files: 23 | - none* 24 | 25 | checksum: 26 | name_template: '{{ .ProjectName }}_{{ .ShortCommit }}_SHA256SUMS' 27 | algorithm: sha256 28 | 29 | changelog: 30 | skip: true 31 | -------------------------------------------------------------------------------- /build/package/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:experimental 2 | FROM docker.mirror.hashicorp.services/golang:1.15 as builder 3 | 4 | ENV GOBIN=/go/bin 5 | ENV CGO_ENABLED=0 6 | ENV GOOS=linux 7 | ENV GO111MODULE=on 8 | 9 | WORKDIR /go/src/github.com/hashicorp/inclusify 10 | 11 | COPY go.mod go.mod 12 | COPY go.sum go.sum 13 | 14 | # Download Go modules 15 | RUN go mod download 16 | 17 | COPY ./ . 18 | RUN go build -ldflags "" -o inclusify ./cmd/inclusify 19 | 20 | FROM docker.mirror.hashicorp.services/alpine:3.12 21 | RUN apk add --no-cache ca-certificates 22 | WORKDIR / 23 | COPY --from=builder \ 24 | # FROM 25 | /go/src/github.com/hashicorp/inclusify/inclusify \ 26 | # TO 27 | /usr/local/bin/inclusify 28 | 29 | CMD ["inclusify", "--version"] -------------------------------------------------------------------------------- /cmd/inclusify/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/mitchellh/cli" 8 | 9 | "github.com/hashicorp/inclusify/pkg/branches" 10 | "github.com/hashicorp/inclusify/pkg/config" 11 | "github.com/hashicorp/inclusify/pkg/files" 12 | "github.com/hashicorp/inclusify/pkg/gh" 13 | "github.com/hashicorp/inclusify/pkg/message" 14 | "github.com/hashicorp/inclusify/pkg/pulls" 15 | "github.com/hashicorp/inclusify/pkg/version" 16 | ) 17 | 18 | func main() { 19 | if err := inner(); err != nil { 20 | log.Printf("%s: %s\n", message.Error("inclusify error"), err) 21 | os.Exit(1) 22 | } 23 | } 24 | 25 | func inner() error { 26 | ui := &cli.BasicUi{ 27 | Reader: os.Stdin, 28 | Writer: os.Stdout, 29 | ErrorWriter: os.Stderr, 30 | } 31 | 32 | c := cli.NewCLI("inclusify", version.Version) 33 | c.HelpWriter = os.Stdout 34 | c.ErrorWriter = os.Stderr 35 | c.Args = os.Args[1:] 36 | client := &gh.BaseGithubInteractor{} 37 | 38 | // Parse and validate cmd line flags and env vars 39 | cf, err := config.ParseAndValidate(c.Args, ui) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | if cf != nil { 45 | client, err = gh.NewBaseGithubInteractor(cf.Token) 46 | if err != nil { 47 | return err 48 | } 49 | } 50 | 51 | tmpBranch := "update-references" 52 | c.Commands = map[string]cli.CommandFactory{ 53 | "createBranches": func() (cli.Command, error) { 54 | return &branches.CreateCommand{Config: cf, GithubClient: client, BranchesList: []string{tmpBranch}}, nil 55 | }, 56 | "updateRefs": func() (cli.Command, error) { 57 | return &files.UpdateRefsCommand{Config: cf, GithubClient: client, TempBranch: tmpBranch}, nil 58 | }, 59 | "updatePulls": func() (cli.Command, error) { 60 | return &pulls.UpdateCommand{Config: cf, GithubClient: client}, nil 61 | }, 62 | "updateDefault": func() (cli.Command, error) { 63 | return &branches.UpdateCommand{Config: cf, GithubClient: client}, nil 64 | }, 65 | "deleteBranches": func() (cli.Command, error) { 66 | return &branches.DeleteCommand{Config: cf, GithubClient: client, BranchesList: []string{tmpBranch}}, nil 67 | }, 68 | } 69 | 70 | _, err = c.Run() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | return nil 76 | } 77 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | patch: 7 | default: 8 | informational: true 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/inclusify 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 7 | github.com/fatih/color v1.9.0 8 | github.com/go-git/go-git/v5 v5.1.0 9 | github.com/google/go-github v17.0.0+incompatible 10 | github.com/google/go-github/v32 v32.1.0 11 | github.com/hashicorp/go-hclog v0.14.1 12 | github.com/hashicorp/go-version v1.2.1 13 | github.com/mitchellh/cli v1.1.1 14 | github.com/namsral/flag v1.7.4-pre 15 | github.com/otiai10/copy v1.2.0 16 | github.com/stretchr/testify v1.6.1 17 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= 3 | github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= 4 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= 5 | github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= 6 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26FmByPp2eC2qla6E1Tw+scpcg/to= 7 | github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 8 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 9 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 10 | github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= 11 | github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= 12 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= 17 | github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= 18 | github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= 19 | github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= 20 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 21 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 22 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= 23 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 24 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= 25 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 26 | github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= 27 | github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 28 | github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= 29 | github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= 30 | github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM= 31 | github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= 32 | github.com/go-git/go-git-fixtures/v4 v4.0.1 h1:q+IFMfLx200Q3scvt2hN79JsEzy4AmBTp/pqnefH+Bc= 33 | github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= 34 | github.com/go-git/go-git/v5 v5.1.0 h1:HxJn9g/E7eYvKW3Fm7Jt4ee8LXfPOm/H1cdDu8vEssk= 35 | github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM= 36 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 37 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 38 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 39 | github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 40 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 41 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 42 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 43 | github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II= 44 | github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= 45 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 46 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 47 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 48 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 49 | github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQLReU= 50 | github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= 51 | github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= 52 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 53 | github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= 54 | github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 55 | github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= 56 | github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= 57 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 58 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 59 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 60 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= 61 | github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 62 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 63 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 64 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 65 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 66 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 67 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 68 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 69 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 70 | github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 71 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 72 | github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= 73 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 74 | github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= 75 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 76 | github.com/mitchellh/cli v1.1.1 h1:J64v/xD7Clql+JVKSvkYojLOXu1ibnY9ZjGLwSt/89w= 77 | github.com/mitchellh/cli v1.1.1/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= 78 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 79 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 80 | github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs= 81 | github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo= 82 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 83 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 84 | github.com/otiai10/copy v1.2.0 h1:HvG945u96iNadPoG2/Ja2+AUJeW5YuFQMixq9yirC+k= 85 | github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= 86 | github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= 87 | github.com/otiai10/curr v1.0.0 h1:TJIWdbX0B+kpNagQrjgq8bCMrbhiuX73M2XwgtDMoOI= 88 | github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= 89 | github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= 90 | github.com/otiai10/mint v1.3.1 h1:BCmzIS3n71sGfHB5NMNDB3lHYPz8fWSkCAErHed//qc= 91 | github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= 92 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 93 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 94 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 95 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 96 | github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= 97 | github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= 98 | github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 99 | github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 100 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 101 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 102 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 103 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 104 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 105 | github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= 106 | github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= 107 | golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 108 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 109 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= 110 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 111 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 112 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 113 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 114 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 115 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= 116 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 117 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 118 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= 119 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 120 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 121 | golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 122 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 123 | golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 124 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 125 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 126 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 127 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 128 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= 129 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 130 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 131 | golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= 132 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 133 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 134 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 135 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= 136 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 137 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 138 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 139 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 140 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 141 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 142 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 143 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 144 | gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= 145 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 146 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 147 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 148 | -------------------------------------------------------------------------------- /pkg/branches/create.go: -------------------------------------------------------------------------------- 1 | package branches 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/google/go-github/v32/github" 9 | 10 | "github.com/hashicorp/inclusify/pkg/config" 11 | "github.com/hashicorp/inclusify/pkg/gh" 12 | "github.com/hashicorp/inclusify/pkg/message" 13 | ) 14 | 15 | // CreateCommand is a struct used to configure a Command for creating new 16 | // GitHub branches in the remote repo 17 | type CreateCommand struct { 18 | Config *config.Config 19 | GithubClient gh.GithubInteractor 20 | BranchesList []string 21 | } 22 | 23 | // Run creates the branch $target off of $base 24 | // It also creates a $tmpBranch that will be used for CI changes 25 | // Example: Create branches 'main' and 'update-ci-references' off of master 26 | func (c *CreateCommand) Run(args []string) int { 27 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 28 | defer cancel() 29 | 30 | c.BranchesList = append(c.BranchesList, c.Config.Target) 31 | for _, branch := range c.BranchesList { 32 | c.Config.Logger.Info(fmt.Sprintf( 33 | message.Info("Creating new branch %s off of %s"), branch, c.Config.Base, 34 | )) 35 | refName := fmt.Sprintf("refs/heads/%s", c.Config.Base) 36 | ref, _, err := c.GithubClient.GetGit().GetRef(ctx, c.Config.Owner, c.Config.Repo, refName) 37 | if err != nil { 38 | return c.exitError(fmt.Errorf("call to get master ref returned error: %w", err)) 39 | } 40 | 41 | sha := ref.Object.GetSHA() 42 | targetRef := fmt.Sprintf("refs/heads/%s", branch) 43 | targetRefObj := &github.Reference{ 44 | Ref: &targetRef, 45 | Object: &github.GitObject{ 46 | SHA: &sha, 47 | }, 48 | } 49 | 50 | _, _, err = c.GithubClient.GetGit().CreateRef(ctx, c.Config.Owner, c.Config.Repo, targetRefObj) 51 | if err != nil { 52 | return c.exitError(fmt.Errorf("call to create base ref returned error: %w", err)) 53 | } 54 | } 55 | 56 | c.Config.Logger.Info(message.Success("Success!")) 57 | 58 | return 0 59 | } 60 | 61 | // exitError prints the error to the configured UI Error channel (usually stderr) then 62 | // returns the exit code. 63 | func (c *CreateCommand) exitError(err error) int { 64 | c.Config.Logger.Error(message.Error(err.Error())) 65 | return 1 66 | } 67 | 68 | // Help returns the full help text. 69 | func (c *CreateCommand) Help() string { 70 | return `Usage: inclusify createBranches owner repo base target token 71 | Create a new branch called $target off $base, with all history included. Configuration is pulled from the local environment. 72 | Flags: 73 | --owner The GitHub org that owns the repo, e.g. 'hashicorp'. 74 | --repo The repository name, e.g. 'circle-codesign'. 75 | --base="master" The name of the current base branch, e.g. 'master'. 76 | --target="main" The name of the target branch, e.g. 'main'. 77 | --token Your Personal GitHub Access Token. 78 | ` 79 | } 80 | 81 | // Synopsis returns a sub 50 character summary of the command. 82 | func (c *CreateCommand) Synopsis() string { 83 | return "Create new branches on GitHub. [subcommand]" 84 | } 85 | -------------------------------------------------------------------------------- /pkg/branches/create_test.go: -------------------------------------------------------------------------------- 1 | // +build !integration 2 | 3 | package branches 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/google/go-github/github" 9 | hclog "github.com/hashicorp/go-hclog" 10 | "github.com/mitchellh/cli" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/hashicorp/inclusify/pkg/config" 15 | "github.com/hashicorp/inclusify/pkg/gh" 16 | ) 17 | 18 | func TestCreateBranchRun(t *testing.T) { 19 | ui := cli.NewMockUi() 20 | client := gh.NewMockGithubInteractor() 21 | branches := []string{"update-references"} 22 | 23 | config := &config.Config{ 24 | Owner: "hashicorp", 25 | Repo: "test", 26 | Base: "master", 27 | Target: "main", 28 | Token: "token", 29 | Logger: hclog.New(&hclog.LoggerOptions{ 30 | Output: ui.OutputWriter, 31 | }), 32 | } 33 | 34 | command := &CreateCommand{ 35 | Config: config, 36 | GithubClient: client, 37 | BranchesList: branches, 38 | } 39 | 40 | exit := command.Run([]string{}) 41 | 42 | // Did we exit with a zero exit code? 43 | if !assert.Equal(t, 0, exit) { 44 | require.Fail(t, ui.ErrorWriter.String()) 45 | } 46 | 47 | // Make some assertions about the UI output 48 | output := ui.OutputWriter.String() 49 | assert.Contains(t, output, "Creating new branch update-references off of master") 50 | assert.Contains(t, output, "Creating new branch main off of master") 51 | assert.Contains(t, output, "Success!") 52 | 53 | // Make some assertions about what we wrote to GitHub 54 | created := client.CreatedReferences 55 | assert.Len(t, created, 2) 56 | 57 | want := []*github.Reference{ 58 | { 59 | Ref: github.String("refs/heads/update-references"), 60 | Object: &github.GitObject{SHA: &client.MasterRef}, 61 | }, 62 | { 63 | Ref: github.String("refs/heads/main"), 64 | Object: &github.GitObject{SHA: &client.MasterRef}, 65 | }, 66 | } 67 | 68 | for i, c := range created { 69 | assert.Equal(t, want[i].GetRef(), c.GetRef()) 70 | assert.Equal(t, want[i].Object.GetSHA(), c.Object.GetSHA()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pkg/branches/delete.go: -------------------------------------------------------------------------------- 1 | package branches 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/hashicorp/inclusify/pkg/config" 9 | "github.com/hashicorp/inclusify/pkg/gh" 10 | "github.com/hashicorp/inclusify/pkg/message" 11 | ) 12 | 13 | // DeleteCommand is a struct used to configure a Command for deleting the 14 | // GitHub branch, $base, in the remote repo 15 | type DeleteCommand struct { 16 | Config *config.Config 17 | GithubClient gh.GithubInteractor 18 | BranchesList []string 19 | } 20 | 21 | // Run removes the branch protection from the $base branch 22 | // and deletes the $base branch from the remote repo 23 | // $base defaults to "master" if no $base flag or env var is provided 24 | // Example: Delete the 'master' branch 25 | func (c *DeleteCommand) Run(args []string) int { 26 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 27 | defer cancel() 28 | 29 | c.BranchesList = append(c.BranchesList, c.Config.Base) 30 | for _, branch := range c.BranchesList { 31 | c.Config.Logger.Info("Attempting to remove branch protection from branch", "branch", branch) 32 | _, err := c.GithubClient.GetRepo().RemoveBranchProtection(ctx, c.Config.Owner, c.Config.Repo, branch) 33 | if err != nil { 34 | // If there's no branch protection for the branch, that's OK! Log it and continue on 35 | c.Config.Logger.Info("Failed to remove branch protection from branch", "branch", branch, "error", err) 36 | } 37 | 38 | c.Config.Logger.Info("Attempting to delete branch", "branch", branch) 39 | refName := fmt.Sprintf("refs/heads/%s", branch) 40 | _, err = c.GithubClient.GetGit().DeleteRef(ctx, c.Config.Owner, c.Config.Repo, refName) 41 | if err != nil { 42 | // If there's no branch to delete, that's OK! Log it and continue on 43 | c.Config.Logger.Info("Failed to delete ref", "branch", branch, "error", err) 44 | } 45 | 46 | c.Config.Logger.Info(message.Success("Success! branch has been deleted"), "branch", branch, "ref", refName) 47 | } 48 | 49 | return 0 50 | } 51 | 52 | // Help returns the full help text. 53 | func (c *DeleteCommand) Help() string { 54 | return `Usage: inclusify deleteBranches owner repo base token 55 | Delete $base branch and other auto-created branches from the given GitHub repo. Configuration is pulled from the local environment. 56 | Flags: 57 | --owner The GitHub org that owns the repo, e.g. 'hashicorp'. 58 | --repo The repository name, e.g. 'circle-codesign'. 59 | --base="master" The name of the current base branch, e.g. 'master'. 60 | --token Your Personal GitHub Access Token. 61 | ` 62 | } 63 | 64 | // Synopsis returns a sub 50 character summary of the command. 65 | func (c *DeleteCommand) Synopsis() string { 66 | return "Delete repo's base branch and other auto-created branches. [subcommand]" 67 | } 68 | -------------------------------------------------------------------------------- /pkg/branches/delete_test.go: -------------------------------------------------------------------------------- 1 | // +build !integration 2 | 3 | package branches 4 | 5 | import ( 6 | "testing" 7 | 8 | hclog "github.com/hashicorp/go-hclog" 9 | "github.com/mitchellh/cli" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/hashicorp/inclusify/pkg/config" 14 | "github.com/hashicorp/inclusify/pkg/gh" 15 | ) 16 | 17 | func TestDeleteBranchesRun(t *testing.T) { 18 | ui := cli.NewMockUi() 19 | client := gh.NewMockGithubInteractor() 20 | branches := []string{"master"} 21 | 22 | config := &config.Config{ 23 | Owner: "hashicorp", 24 | Repo: "test", 25 | Base: "master", 26 | Target: "main", 27 | Token: "token", 28 | Logger: hclog.New(&hclog.LoggerOptions{ 29 | Output: ui.OutputWriter, 30 | }), 31 | } 32 | 33 | command := &DeleteCommand{ 34 | Config: config, 35 | GithubClient: client, 36 | BranchesList: branches, 37 | } 38 | 39 | exit := command.Run([]string{}) 40 | 41 | // Did we exit with a zero exit code? 42 | if !assert.Equal(t, 0, exit) { 43 | require.Fail(t, ui.ErrorWriter.String()) 44 | } 45 | 46 | // Make some assertions about the UI output 47 | output := ui.OutputWriter.String() 48 | assert.Contains(t, output, "Attempting to remove branch protection from branch: branch=master") 49 | assert.Contains(t, output, "Attempting to delete branch: branch=master") 50 | assert.Contains(t, output, "Success! branch has been deleted: branch=master ref=refs/heads/master") 51 | } 52 | -------------------------------------------------------------------------------- /pkg/branches/updateDefault.go: -------------------------------------------------------------------------------- 1 | package branches 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/google/go-github/v32/github" 9 | 10 | "github.com/hashicorp/inclusify/pkg/config" 11 | "github.com/hashicorp/inclusify/pkg/gh" 12 | "github.com/hashicorp/inclusify/pkg/message" 13 | ) 14 | 15 | // UpdateCommand is a struct used to configure a Command for updating 16 | // the GitHub default branch in the remote repo and copying the 17 | // branch protection from $base to $target 18 | type UpdateCommand struct { 19 | Config *config.Config 20 | GithubClient gh.GithubInteractor 21 | } 22 | 23 | // SetupBranchProtectionReq sets up the branch protection request 24 | func SetupBranchProtectionReq(c *UpdateCommand, base *github.Protection) *github.ProtectionRequest { 25 | req := &github.ProtectionRequest{ 26 | RequiredStatusChecks: base.RequiredStatusChecks, 27 | RequiredPullRequestReviews: createRequiredPullRequestReviewEnforcementRequest(base.RequiredPullRequestReviews), 28 | EnforceAdmins: base.EnforceAdmins.Enabled, 29 | Restrictions: createBranchRestrictionsRequest(base.Restrictions), 30 | RequireLinearHistory: &base.RequireLinearHistory.Enabled, 31 | AllowForcePushes: &base.AllowForcePushes.Enabled, 32 | AllowDeletions: &base.AllowDeletions.Enabled, 33 | } 34 | return req 35 | } 36 | 37 | func createBranchRestrictionsRequest(r *github.BranchRestrictions) *github.BranchRestrictionsRequest { 38 | if r == nil { 39 | return nil 40 | } 41 | return &github.BranchRestrictionsRequest{ 42 | Users: userStrings(r.Users), 43 | Teams: teamStrings(r.Teams), 44 | Apps: appStrings(r.Apps), 45 | } 46 | } 47 | 48 | func createRequiredPullRequestReviewEnforcementRequest(rp *github.PullRequestReviewsEnforcement) *github.PullRequestReviewsEnforcementRequest { 49 | var enforceUsers []string 50 | var enforceTeams []string 51 | var dismissalRestrictionsRequest *github.DismissalRestrictionsRequest 52 | reviewCount := 1 53 | 54 | if rp == nil { 55 | return nil 56 | } 57 | if rp.RequiredApprovingReviewCount > 0 { 58 | reviewCount = rp.RequiredApprovingReviewCount 59 | } 60 | if rp.DismissalRestrictions != nil { 61 | enforceUsers = userStrings(rp.DismissalRestrictions.Users) 62 | enforceTeams = teamStrings(rp.DismissalRestrictions.Teams) 63 | dismissalRestrictionsRequest = &github.DismissalRestrictionsRequest{ 64 | Users: &enforceUsers, 65 | Teams: &enforceTeams, 66 | } 67 | } else { 68 | dismissalRestrictionsRequest = nil 69 | } 70 | enforceReq := &github.PullRequestReviewsEnforcementRequest{ 71 | DismissalRestrictionsRequest: dismissalRestrictionsRequest, 72 | DismissStaleReviews: rp.DismissStaleReviews, 73 | RequireCodeOwnerReviews: rp.RequireCodeOwnerReviews, 74 | RequiredApprovingReviewCount: reviewCount, 75 | } 76 | 77 | return enforceReq 78 | } 79 | 80 | func userStrings(users []*github.User) []string { 81 | out := make([]string, len(users)) 82 | for i, u := range users { 83 | out[i] = u.GetLogin() 84 | } 85 | return out 86 | } 87 | 88 | func teamStrings(teams []*github.Team) []string { 89 | out := make([]string, len(teams)) 90 | for i, t := range teams { 91 | out[i] = t.GetSlug() 92 | } 93 | return out 94 | } 95 | 96 | func appStrings(apps []*github.App) []string { 97 | out := make([]string, len(apps)) 98 | for i, a := range apps { 99 | out[i] = a.GetSlug() 100 | } 101 | return out 102 | } 103 | 104 | // CopyBranchProtection will copy the branch protection from base and apply it to $target 105 | func CopyBranchProtection(c *UpdateCommand, base string, target string) (err error) { 106 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 107 | defer cancel() 108 | 109 | c.Config.Logger.Info("Getting branch protection for branch", "branch", base) 110 | baseProtection, res, err := c.GithubClient.GetRepo().GetBranchProtection(ctx, c.Config.Owner, c.Config.Repo, base) 111 | if err != nil { 112 | if res.StatusCode == 404 { 113 | c.Config.Logger.Info("Exiting -- The old base branch isn't protected, so there's nothing more to do") 114 | return nil 115 | } 116 | return fmt.Errorf("failed to get base branch protection: %w", err) 117 | } 118 | 119 | c.Config.Logger.Info("Creating the branch protection request for branch", "branch", target) 120 | targetProtectionReq := SetupBranchProtectionReq(c, baseProtection) 121 | if err != nil { 122 | return fmt.Errorf("failed to create the branch protection request: %w", err) 123 | } 124 | 125 | c.Config.Logger.Info("Updating the branch protection on branch", "branch", target) 126 | _, _, err = c.GithubClient.GetRepo().UpdateBranchProtection(ctx, c.Config.Owner, c.Config.Repo, target, targetProtectionReq) 127 | if err != nil { 128 | return fmt.Errorf("failed to update the target branches protection: %w", err) 129 | } 130 | 131 | return nil 132 | } 133 | 134 | // Run updates the default branch in the repo to the new $target branch 135 | // and copies the branch protection rules from $base to $target 136 | // Example: Update the repo's default branch from 'master' to 'main' 137 | func (c *UpdateCommand) Run(args []string) int { 138 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 139 | defer cancel() 140 | 141 | c.Config.Logger.Info("Updating the default branch to target", "repo", c.Config.Repo, "base", c.Config.Base, "target", c.Config.Target) 142 | editRepo := &github.Repository{DefaultBranch: &c.Config.Target} 143 | _, _, err := c.GithubClient.GetRepo().Edit(ctx, c.Config.Owner, c.Config.Repo, editRepo) 144 | if err != nil { 145 | return c.exitError(fmt.Errorf("failed to update default branch: %w", err)) 146 | } 147 | 148 | c.Config.Logger.Info("Attempting to apply the base branch protection to target", "base", c.Config.Base, "target", c.Config.Target) 149 | err = CopyBranchProtection(c, c.Config.Base, c.Config.Target) 150 | if err != nil { 151 | return c.exitError(err) 152 | } 153 | 154 | c.Config.Logger.Info(message.Success("Success!")) 155 | 156 | return 0 157 | } 158 | 159 | // exitError prints the error to the configured UI Error channel (usually stderr) then 160 | // returns the exit code. 161 | func (c *UpdateCommand) exitError(err error) int { 162 | c.Config.Logger.Error(message.Error(err.Error())) 163 | return 1 164 | } 165 | 166 | // Help returns the full help text. 167 | func (c *UpdateCommand) Help() string { 168 | return `Usage: inclusify updateDefault owner repo target token 169 | Update the default branch in the repo to $target, and copy branch protection from $base to $target. Configuration is pulled from the local environment. 170 | Flags: 171 | --owner The GitHub org that owns the repo, e.g. 'hashicorp'. 172 | --repo The repository name, e.g. 'circle-codesign'. 173 | --target="main" The name of the target branch, e.g. 'main'. 174 | --token Your Personal GitHub Access Token. 175 | ` 176 | } 177 | 178 | // Synopsis returns a sub 50 character summary of the command. 179 | func (c *UpdateCommand) Synopsis() string { 180 | return "Update repo's default branch. [subcommand]" 181 | } 182 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | hclog "github.com/hashicorp/go-hclog" 8 | "github.com/hashicorp/inclusify/pkg/message" 9 | "github.com/mitchellh/cli" 10 | nflag "github.com/namsral/flag" 11 | ) 12 | 13 | // Config is a struct that contains user inputs and our logger 14 | type Config struct { 15 | Owner string 16 | Repo string 17 | Base string 18 | Target string 19 | Token string 20 | Exclusion []string 21 | Logger hclog.Logger 22 | } 23 | 24 | // ParseAndValidate parses the cmd line flags / env vars, and verifies that all required 25 | // flags have been set. Users can pass in flags when calling a subcommand, or set env vars 26 | // with the prefix 'INCLUSIFY_'. If both values are set, the env var value will be used. 27 | func ParseAndValidate(args []string, ui cli.Ui) (c *Config, err error) { 28 | var ( 29 | owner, repo, token, base, target, exclusion string 30 | ) 31 | var exclusionArr []string 32 | 33 | // Values can be passed in to the subcommands as inputs flags, 34 | // or set as env vars with the prefix "INCLUSIFY_" 35 | flags := nflag.NewFlagSetWithEnvPrefix("inclusify", "INCLUSIFY", 0) 36 | flags.StringVar(&owner, "owner", "", "The GitHub org that owns the repo, e.g. 'hashicorp'") 37 | flags.StringVar(&repo, "repo", "", "The repository name, e.g. 'circle-codesign'") 38 | flags.StringVar(&base, "base", "master", "The name of the current base branch, e.g. 'master'") 39 | flags.StringVar(&target, "target", "main", "The name of the target branch, e.g. 'main'") 40 | flags.StringVar(&token, "token", "", "Your Personal GitHub Access Token") 41 | flags.StringVar(&exclusion, "exclusion", "", "Paths to exclude from reference updates, e.g. '.circleci/config.yml,.teamcity.yml'") 42 | 43 | // Special check for ./inclusify invocation without any args 44 | // Return the help message 45 | if len(args) == 0 { 46 | args = append(args, "--help") 47 | } 48 | 49 | // Pop the subcommand into 'cmd' 50 | // flags.Parse does not work when the subcommand is included 51 | cmd, inputFlags := args[0], args[1:] 52 | 53 | // Special check for help commands 54 | // command is ./inclusify --help or --version 55 | if len(inputFlags) == 0 && (cmd == "help" || cmd == "--help" || cmd == "-help" || cmd == "version" || cmd == "--version" || cmd == "-version") { 56 | return nil, nil 57 | } 58 | // command is ./inclusify $subcommand --help 59 | if len(inputFlags) == 1 && (inputFlags[0] == "help" || inputFlags[0] == "--help" || inputFlags[0] == "-help") { 60 | return nil, nil 61 | } 62 | 63 | if err := flags.Parse(inputFlags); err != nil { 64 | return c, fmt.Errorf("error parsing inputs: %w", err) 65 | } 66 | 67 | if owner == "" || repo == "" || token == "" { 68 | return c, fmt.Errorf( 69 | "%s\npass in all required flags or set environment variables with the 'INCLUSIFY_' prefix.\nRun [subcommand] --help to view required inputs", 70 | message.Error("required inputs are missing"), 71 | ) 72 | } 73 | 74 | if len(exclusion) > 0 { 75 | exclusionArr = strings.Split(exclusion, ",") 76 | } 77 | exclusionArr = append(exclusionArr, ".git/", "go.mod", "go.sum") 78 | 79 | logger := hclog.New(&hclog.LoggerOptions{ 80 | Name: "inclusify", 81 | Level: hclog.LevelFromString("INFO"), 82 | Output: &cli.UiWriter{Ui: ui}, 83 | }) 84 | 85 | c = &Config{ 86 | Owner: owner, 87 | Repo: repo, 88 | Base: base, 89 | Target: target, 90 | Token: token, 91 | Exclusion: exclusionArr, 92 | Logger: logger, 93 | } 94 | 95 | return c, nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | // +build !integration 2 | 3 | package config 4 | 5 | import ( 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/mitchellh/cli" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | // Test that the config is generated properly when only env vars are set 16 | func Test_ParseAndValidate_EnvVars(t *testing.T) { 17 | args := []string{"subcommand"} 18 | os.Setenv("INCLUSIFY_OWNER", "owner") 19 | os.Setenv("INCLUSIFY_REPO", "repo") 20 | os.Setenv("INCLUSIFY_BASE", "base") 21 | os.Setenv("INCLUSIFY_TARGET", "target") 22 | os.Setenv("INCLUSIFY_TOKEN", "token") 23 | os.Setenv("INCLUSIFY_EXCLUSION", ".circleci/,scripts/hello.py,.teamcity.yml") 24 | exclusionArr := strings.Split(os.Getenv("INCLUSIFY_EXCLUSION"), ",") 25 | 26 | ui := &cli.BasicUi{} 27 | config, err := ParseAndValidate(args, ui) 28 | require.NoError(t, err) 29 | exclusionArr = append(exclusionArr, ".git/", "go.mod", "go.sum") 30 | 31 | // Make some assertions about the UI output 32 | assert.Equal(t, os.Getenv("INCLUSIFY_OWNER"), config.Owner) 33 | assert.Equal(t, os.Getenv("INCLUSIFY_REPO"), config.Repo) 34 | assert.Equal(t, os.Getenv("INCLUSIFY_BASE"), config.Base) 35 | assert.Equal(t, os.Getenv("INCLUSIFY_TARGET"), config.Target) 36 | assert.Equal(t, os.Getenv("INCLUSIFY_TOKEN"), config.Token) 37 | assert.Equal(t, config.Exclusion, exclusionArr) 38 | } 39 | 40 | // Test that the config is generated properly when cmd line flags are passed in 41 | func Test_ParseAndValidate_Flags(t *testing.T) { 42 | os.Unsetenv("INCLUSIFY_OWNER") 43 | os.Unsetenv("INCLUSIFY_REPO") 44 | os.Unsetenv("INCLUSIFY_TOKEN") 45 | os.Unsetenv("INCLUSIFY_BASE") 46 | os.Unsetenv("INCLUSIFY_TARGET") 47 | os.Unsetenv("INCLUSIFY_EXCLUSION") 48 | 49 | owner := "hashicorp" 50 | repo := "inclusify" 51 | token := "github_token" 52 | exclusion := ".circleci/,scripts/hello.py,.teamcity.yml" 53 | exclusionArr := strings.Split(exclusion, ",") 54 | 55 | args := []string{"subcommand", "--owner", owner, "--repo", repo, "--token", token, "--exclusion", exclusion} 56 | exclusionArr = append(exclusionArr, ".git/", "go.mod", "go.sum") 57 | 58 | ui := &cli.BasicUi{} 59 | config, err := ParseAndValidate(args, ui) 60 | require.NoError(t, err) 61 | 62 | assert.Equal(t, owner, config.Owner) 63 | assert.Equal(t, repo, config.Repo) 64 | assert.Equal(t, "master", config.Base) 65 | assert.Equal(t, "main", config.Target) 66 | assert.Equal(t, token, config.Token) 67 | assert.Equal(t, exclusionArr, config.Exclusion) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/files/createScaffold.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | 11 | "github.com/dchest/uniuri" 12 | "github.com/google/go-github/v32/github" 13 | "github.com/hashicorp/inclusify/pkg/config" 14 | "github.com/hashicorp/inclusify/pkg/gh" 15 | "github.com/hashicorp/inclusify/pkg/message" 16 | "github.com/otiai10/copy" 17 | 18 | git "github.com/go-git/go-git/v5" 19 | gitConfig "github.com/go-git/go-git/v5/config" 20 | plumbing "github.com/go-git/go-git/v5/plumbing" 21 | object "github.com/go-git/go-git/v5/plumbing/object" 22 | http "github.com/go-git/go-git/v5/plumbing/transport/http" 23 | ) 24 | 25 | // CreateScaffoldCommand is a struct used to configure a Command for creating 26 | // an initial commit and pushing it to the base branch of a given repo 27 | type CreateScaffoldCommand struct { 28 | Config *config.Config 29 | GithubClient gh.GithubInteractor 30 | TempBranch string 31 | } 32 | 33 | // InitializeRepo creates a temp directory and initializes it as a new repo 34 | func InitializeRepo(c *CreateScaffoldCommand) (repoRef *git.Repository, dir string, err error) { 35 | pwd, err := os.Getwd() 36 | if err != nil { 37 | return nil, "", fmt.Errorf("failed to get current working directory: %w", err) 38 | } 39 | 40 | prefix := fmt.Sprintf("tmp-clone-%s", uniuri.NewLen(6)) 41 | c.Config.Logger.Info("Creating local temp dir", "dirPrefix", prefix) 42 | dir, err = ioutil.TempDir(pwd, prefix) 43 | if err != nil { 44 | return nil, "", fmt.Errorf("failed to create tmp directory: %w", err) 45 | } 46 | 47 | c.Config.Logger.Info("Initializing new repo at dir", "dir", dir) 48 | repo, err := git.PlainInit(dir, false) 49 | if err != nil { 50 | return nil, "", fmt.Errorf("failed to initialize new repo: %w", err) 51 | } 52 | 53 | c.Config.Logger.Info("Creating a new remote for base", "base", c.Config.Base) 54 | url := fmt.Sprintf("https://github.com/%s/%s.git", c.Config.Owner, c.Config.Repo) 55 | _, err = repo.CreateRemote((&gitConfig.RemoteConfig{ 56 | Name: "master", 57 | URLs: []string{url}, 58 | })) 59 | if err != nil { 60 | return nil, "", fmt.Errorf("failed to create remote: %w", err) 61 | } 62 | 63 | return repo, dir, nil 64 | } 65 | 66 | // CopyCIFiles copies the files from tests/fakeRepo/* into the temp-dir 67 | func CopyCIFiles(c *CreateScaffoldCommand, dir string) (err error) { 68 | c.Config.Logger.Info("Copying test CI files into temp directory") 69 | path, err := os.Getwd() 70 | if err != nil { 71 | return err 72 | } 73 | parent := filepath.Dir(path) 74 | fakeRepoPath := filepath.Join(parent, "tests", "fakeRepo") 75 | if _, err := os.Stat(fakeRepoPath); err != nil { 76 | if os.IsNotExist(err) { 77 | return err 78 | } 79 | } 80 | err = copy.Copy(fakeRepoPath, dir) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | 88 | // GitPushCommit adds, commits, and pushes the fakeRepo files to the base branch 89 | func GitPushCommit(c *CreateScaffoldCommand, repo *git.Repository) (err error) { 90 | worktree, err := repo.Worktree() 91 | if err != nil { 92 | return fmt.Errorf("failed to get worktree: %w", err) 93 | } 94 | 95 | c.Config.Logger.Info("Running `git add .`") 96 | _, err = worktree.Add(".") 97 | if err != nil { 98 | return fmt.Errorf("failed to 'git add .': %w", err) 99 | } 100 | 101 | c.Config.Logger.Info("Committing changes") 102 | commitMsg := "Creating initial commit" 103 | commitSha, err := worktree.Commit(commitMsg, &git.CommitOptions{ 104 | Author: &object.Signature{ 105 | When: time.Now(), 106 | }, 107 | }) 108 | if err != nil { 109 | return fmt.Errorf("failed to commit changes: %w", err) 110 | } 111 | 112 | // Create a new refspec in order to push to $base for the first time 113 | ref := fmt.Sprintf("refs/heads/%s", c.Config.Base) 114 | upstreamReference := plumbing.ReferenceName(ref) 115 | downstreamReference := plumbing.ReferenceName(ref) 116 | referenceList := append([]gitConfig.RefSpec{}, 117 | gitConfig.RefSpec(upstreamReference+":"+downstreamReference)) 118 | 119 | c.Config.Logger.Info("Pushing initial commit to remote", "branch", c.Config.Base, "sha", commitSha) 120 | err = repo.Push(&git.PushOptions{ 121 | RemoteName: "master", 122 | RefSpecs: referenceList, 123 | Auth: &http.BasicAuth{ 124 | Username: "irrelevant", // This cannot be an empty string 125 | Password: c.Config.Token, 126 | }, 127 | }) 128 | if err != nil { 129 | return fmt.Errorf("failed to push changes: %w", err) 130 | } 131 | 132 | return nil 133 | } 134 | 135 | // CreateBranchProtection creates a branch protection for the base branch 136 | func CreateBranchProtection(c *CreateScaffoldCommand) (err error) { 137 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 138 | defer cancel() 139 | 140 | c.Config.Logger.Info("Creating branch protection request", "branch", c.Config.Base) 141 | strict := true 142 | protectionRequest := &github.ProtectionRequest{ 143 | RequiredStatusChecks: &github.RequiredStatusChecks{ 144 | Strict: false, Contexts: []string{}, 145 | }, 146 | RequiredPullRequestReviews: &github.PullRequestReviewsEnforcementRequest{ 147 | DismissStaleReviews: strict, RequireCodeOwnerReviews: strict, RequiredApprovingReviewCount: 3, 148 | }, 149 | EnforceAdmins: strict, 150 | RequireLinearHistory: &strict, 151 | AllowForcePushes: &strict, 152 | AllowDeletions: &strict, 153 | } 154 | 155 | c.Config.Logger.Info("Applying branch protection", "branch", c.Config.Base) 156 | _, _, err = c.GithubClient.GetRepo().UpdateBranchProtection(ctx, c.Config.Owner, c.Config.Repo, c.Config.Base, protectionRequest) 157 | if err != nil { 158 | return fmt.Errorf("failed to create the base branch protection: %w", err) 159 | } 160 | 161 | return nil 162 | } 163 | 164 | // Run creates an initial commit in the $base of the repo, e.g. 'master' 165 | func (c *CreateScaffoldCommand) Run(args []string) int { 166 | repo, dir, err := InitializeRepo(c) 167 | if err != nil { 168 | return c.exitError(err) 169 | } 170 | 171 | err = CopyCIFiles(c, dir) 172 | if err != nil { 173 | return c.exitError(err) 174 | } 175 | 176 | err = GitPushCommit(c, repo) 177 | if err != nil { 178 | return c.exitError(err) 179 | } 180 | 181 | err = CreateBranchProtection(c) 182 | if err != nil { 183 | return c.exitError(err) 184 | } 185 | 186 | defer os.RemoveAll(dir) 187 | 188 | return 0 189 | } 190 | 191 | // exitError prints the error to the configured UI Error channel (usually stderr) then 192 | // returns the exit code. 193 | func (c *CreateScaffoldCommand) exitError(err error) int { 194 | c.Config.Logger.Error(message.Error(err.Error())) 195 | return 1 196 | } 197 | -------------------------------------------------------------------------------- /pkg/files/updateRefs.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/dchest/uniuri" 13 | git "github.com/go-git/go-git/v5" 14 | plumbing "github.com/go-git/go-git/v5/plumbing" 15 | object "github.com/go-git/go-git/v5/plumbing/object" 16 | http "github.com/go-git/go-git/v5/plumbing/transport/http" 17 | "github.com/google/go-github/v32/github" 18 | 19 | "github.com/hashicorp/inclusify/pkg/config" 20 | "github.com/hashicorp/inclusify/pkg/gh" 21 | "github.com/hashicorp/inclusify/pkg/message" 22 | ) 23 | 24 | // UpdateRefsCommand is a struct used to configure a Command for updating 25 | // CI references 26 | type UpdateRefsCommand struct { 27 | Config *config.Config 28 | GithubClient gh.GithubInteractor 29 | TempBranch string 30 | } 31 | 32 | // CloneRepo creates a temp directory and clones the repo at $tmpBranch into it 33 | func CloneRepo(c *UpdateRefsCommand) (repoRef *git.Repository, dir string, err error) { 34 | pwd, err := os.Getwd() 35 | if err != nil { 36 | return nil, "", fmt.Errorf("failed to get current working directory: %w", err) 37 | } 38 | 39 | prefix := fmt.Sprintf("tmp-clone-%s", uniuri.NewLen(6)) 40 | c.Config.Logger.Info("Creating local temp dir", "dirPrefix", prefix) 41 | dir, err = ioutil.TempDir(pwd, prefix) 42 | if err != nil { 43 | return nil, "", fmt.Errorf("failed to create tmp directory: %w", err) 44 | } 45 | 46 | url := fmt.Sprintf("https://github.com/%s/%s.git", c.Config.Owner, c.Config.Repo) 47 | refName := fmt.Sprintf("refs/heads/%s", c.TempBranch) 48 | repo, err := git.PlainClone(dir, false, &git.CloneOptions{ 49 | URL: url, 50 | Auth: &http.BasicAuth{ 51 | Username: "irrelevant", // This cannot be an empty string 52 | Password: c.Config.Token, 53 | }, 54 | ReferenceName: plumbing.ReferenceName(refName), 55 | }) 56 | if err != nil { 57 | return nil, "", fmt.Errorf("failed to clone repo %s %s %w", url, refName, err) 58 | } 59 | 60 | c.Config.Logger.Info(message.Success("Successfully cloned repo into local dir"), "repo", c.Config.Repo, "dir", dir) 61 | 62 | return repo, dir, nil 63 | } 64 | 65 | // UpdateReferences walks through the files in the cloned repo, and updates references from 66 | // $base to $target. It excludes any paths from `INCLUSIFY_PATH_EXCLUSION` 67 | func UpdateReferences(c *UpdateRefsCommand, dir string) (filesChanged bool, err error) { 68 | c.Config.Logger.Info("Finding and replacing all references from base to target in dir", "base", c.Config.Base, "target", c.Config.Target, "dir", dir) 69 | // Set a flag to false, and update it to true if any files are modified. 70 | filesChanged = false 71 | // Walk through the directories/files in the tmp directory, $dir, where the repo was cloned 72 | callback := func(path string, fi os.FileInfo, err error) error { 73 | // Skip directories and files that should be excluded 74 | if len(c.Config.Exclusion) > 0 { 75 | for _, fp := range c.Config.Exclusion { 76 | if strings.Contains(path, fp) { 77 | return nil 78 | } 79 | } 80 | } 81 | // Find and replace within the repo's files 82 | if !fi.IsDir() { 83 | read, err := ioutil.ReadFile(path) 84 | if err != nil { 85 | return err 86 | } 87 | // Find and replace all references from $base to $target within the files 88 | newContents := strings.ReplaceAll(string(read), c.Config.Base, c.Config.Target) 89 | // Set flag to true if the file was modified 90 | if newContents != string(read) { 91 | filesChanged = true 92 | } 93 | // Update the file with the new contents 94 | err = ioutil.WriteFile(path, []byte(newContents), 0) 95 | if err != nil { 96 | return err 97 | } 98 | c.Config.Logger.Info("Updated the file", "path", path) 99 | } 100 | return nil 101 | } 102 | 103 | err = filepath.Walk(dir, callback) 104 | if err != nil { 105 | return false, err 106 | } 107 | 108 | return filesChanged, nil 109 | } 110 | 111 | // GitPush adds, commits, and pushes all changes to $tmpBranch 112 | func GitPush(c *UpdateRefsCommand, tmpBranch string, repo *git.Repository, dir string) (err error) { 113 | worktree, err := repo.Worktree() 114 | if err != nil { 115 | return fmt.Errorf("failed to get worktree: %w", err) 116 | } 117 | 118 | c.Config.Logger.Info("Running `git add ", dir, "`") 119 | _, err = worktree.Add(".") 120 | if err != nil { 121 | return fmt.Errorf("failed to `git add %s`: %w", dir, err) 122 | } 123 | 124 | c.Config.Logger.Info("Committing changes") 125 | commitMsg := fmt.Sprintf("Update references from %s to %s", c.Config.Base, c.Config.Target) 126 | commitSha, err := worktree.Commit(commitMsg, &git.CommitOptions{ 127 | Author: &object.Signature{ 128 | When: time.Now(), 129 | }, 130 | }) 131 | if err != nil { 132 | return fmt.Errorf("failed to commit changes: %w", err) 133 | } 134 | 135 | c.Config.Logger.Info("Pushing commit to remote", "branch", tmpBranch, "sha", commitSha) 136 | err = repo.Push(&git.PushOptions{ 137 | Auth: &http.BasicAuth{ 138 | Username: "irrelevant", 139 | Password: c.Config.Token, 140 | }, 141 | }) 142 | if err != nil { 143 | return fmt.Errorf("failed to push changes: %w", err) 144 | } 145 | 146 | return nil 147 | } 148 | 149 | // OpenPull opens the pull request to merge the changes from $tmpBranch into $target. 150 | // $tmpBranch is 'update-references', and $target is typically 'main' 151 | func OpenPull(c *UpdateRefsCommand, tmpBranch string) (err error) { 152 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 153 | defer cancel() 154 | var body string 155 | 156 | c.Config.Logger.Info("Setting up PR request") 157 | title := fmt.Sprintf("Update References from %s to %s", c.Config.Base, c.Config.Target) 158 | if len(c.Config.Exclusion) == 0 { 159 | body = fmt.Sprintf("This PR was created to update all references from '%s' to '%s' in this repo.

**NOTE**: This PR was generated automatically. Please take a close look before approving and merging!", c.Config.Base, c.Config.Target) 160 | } else { 161 | body = fmt.Sprintf("This PR was created to update all references from '%s' to '%s' in this repo.

The following paths have been excluded: '%v'

**NOTE**: This PR was generated automatically. Please take a close look before approving and merging!", c.Config.Base, c.Config.Target, c.Config.Exclusion) 162 | } 163 | 164 | modify := true 165 | pull := &github.NewPullRequest{ 166 | Title: &title, 167 | Head: &tmpBranch, 168 | Base: &c.Config.Target, 169 | Body: &body, 170 | MaintainerCanModify: &modify, 171 | } 172 | 173 | c.Config.Logger.Info(message.Info("Creating PR to merge changes from branch into target"), "branch", tmpBranch, "target", c.Config.Target) 174 | pr, _, err := c.GithubClient.GetPRs().Create(ctx, c.Config.Owner, c.Config.Repo, pull) 175 | if err != nil { 176 | return fmt.Errorf("failed to open PR: %w", err) 177 | } 178 | c.Config.Logger.Info(message.Success("Success! Review and merge the open PR"), "url", pr.GetHTMLURL()) 179 | 180 | return nil 181 | } 182 | 183 | // Run updates references from $base to $target in the cloned repo 184 | // Example: Update all occurrences of 'master' to 'main' in ./.github 185 | func (c *UpdateRefsCommand) Run(args []string) int { 186 | repo, dir, err := CloneRepo(c) 187 | if err != nil { 188 | return c.exitError(err) 189 | } 190 | 191 | ref, err := repo.Head() 192 | if err != nil { 193 | return c.exitError(fmt.Errorf("failed to retrieve HEAD commit: %w", err)) 194 | } 195 | c.Config.Logger.Info("Retrieved HEAD commit of branch", "branch", c.TempBranch, "sha", ref.Hash()) 196 | 197 | filesChanged, err := UpdateReferences(c, dir) 198 | if err != nil { 199 | return c.exitError(err) 200 | } 201 | 202 | // Exit if no files were modified during the find and replace 203 | if !filesChanged { 204 | c.Config.Logger.Info(message.Info("Exiting -- No CI files contained base, so there's nothing more to do"), "base", c.Config.Base) 205 | return 0 206 | } 207 | 208 | err = GitPush(c, c.TempBranch, repo, dir) 209 | if err != nil { 210 | return c.exitError(err) 211 | } 212 | 213 | err = OpenPull(c, c.TempBranch) 214 | if err != nil { 215 | return c.exitError(err) 216 | } 217 | 218 | defer os.RemoveAll(dir) 219 | 220 | return 0 221 | } 222 | 223 | // exitError prints the error to the configured UI Error channel (usually stderr) then 224 | // returns the exit code. 225 | func (c *UpdateRefsCommand) exitError(err error) int { 226 | c.Config.Logger.Error(message.Error(err.Error())) 227 | return 1 228 | } 229 | 230 | // Help returns the full help text. 231 | func (c *UpdateRefsCommand) Help() string { 232 | return `Usage: inclusify updateRefs owner repo base target token 233 | Update code references from base to target in the given repo. Any dirs/files provided in exclusion will be excluded. Configuration is pulled from the local environment. 234 | Flags: 235 | --owner The GitHub org that owns the repo, e.g. 'hashicorp'. 236 | --repo The repository name, e.g. 'circle-codesign'. 237 | --base="master" The name of the current base branch, e.g. 'master'. 238 | --target="main" The name of the target branch, e.g. 'main'. 239 | --token Your Personal GitHub Access Token. 240 | --exclusion Paths to exclude from reference updates. 241 | ` 242 | } 243 | 244 | // Synopsis returns a sub 50 character summary of the command. 245 | func (c *UpdateRefsCommand) Synopsis() string { 246 | return "Update code references from base to target in the given repo. [subcommand]" 247 | } 248 | -------------------------------------------------------------------------------- /pkg/gh/fake.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | github "github.com/google/go-github/v32/github" 9 | ) 10 | 11 | const ( 12 | masterRef = "e5615943864ba6af8a4cec905aceeeb94b2a2ad6" 13 | ) 14 | 15 | // MockGithubInteractor is a mock implementation of the GithubInteractor 16 | // interface. It makes basic checks about the validity of the inputs and records 17 | // all the References it creates. 18 | type MockGithubInteractor struct { 19 | Git GithubGitInteractor 20 | Repo GithubRepoInteractor 21 | PRs GithubPRInteractor 22 | 23 | MasterRef string 24 | 25 | CreatedReferences []*github.Reference 26 | } 27 | 28 | // NewMockGithubInteractor is a constructor for MockGithubInteractor. It sets 29 | // the struct up with everything it needs to mock different types of calls. 30 | func NewMockGithubInteractor() *MockGithubInteractor { 31 | m := &MockGithubInteractor{ 32 | MasterRef: masterRef, 33 | } 34 | 35 | m.Git = &MockGithubGitInteractor{parent: m} 36 | m.Repo = &MockGithubRepoInteractor{parent: m} 37 | m.PRs = &MockGithubPRsInteractor{parent: m} 38 | 39 | return m 40 | } 41 | 42 | // GetGit returns an internal mock that represents the GitService Client. 43 | func (m *MockGithubInteractor) GetGit() GithubGitInteractor { 44 | return m.Git 45 | } 46 | 47 | // GetRepo returns an internal mock that represents the Repository Service. 48 | func (m *MockGithubInteractor) GetRepo() GithubRepoInteractor { 49 | return m.Repo 50 | } 51 | 52 | // GetPRs returns an internal mock that represents the Pull Request Service. 53 | func (m *MockGithubInteractor) GetPRs() GithubPRInteractor { 54 | return m.PRs 55 | } 56 | 57 | // MockGithubGitInteractor is a mock implementation of the GithubGitInteractor 58 | // interface, which represents the GitService Client. 59 | type MockGithubGitInteractor struct { 60 | parent *MockGithubInteractor 61 | } 62 | 63 | // MockGithubRepoInteractor is a mock... 64 | type MockGithubRepoInteractor struct { 65 | parent *MockGithubInteractor 66 | } 67 | 68 | // MockGithubPRsInteractor is a mock... 69 | type MockGithubPRsInteractor struct { 70 | parent *MockGithubInteractor 71 | } 72 | 73 | // GetRef validates it is called for hashicorp/test@master, then returns a 74 | // hardcoded SHA. 75 | func (m *MockGithubGitInteractor) GetRef( 76 | ctx context.Context, owner string, repo string, ref string, 77 | ) (*github.Reference, *github.Response, error) { 78 | // Validate this request was for hashicorp/test 79 | if owner != "hashicorp" && repo != "test" { 80 | return nil, nil, errors.New("must be called for hashicorp/test") 81 | } 82 | 83 | // We can only return the ref for master 84 | if ref != "refs/heads/master" { 85 | return nil, nil, fmt.Errorf("must be called for refs/heads/master but got %s", ref) 86 | } 87 | 88 | // Let's start simple, always return a nicely formatted Ref, and no error: 89 | return &github.Reference{ 90 | Ref: github.String(ref), 91 | Object: &github.GitObject{ 92 | SHA: github.String(m.parent.MasterRef), 93 | }, 94 | }, nil, nil 95 | } 96 | 97 | // CreateRef checks it was called for hashicorp/test, then records the requested 98 | // Reference. 99 | func (m *MockGithubGitInteractor) CreateRef( 100 | ctx context.Context, owner string, repo string, ref *github.Reference, 101 | ) (*github.Reference, *github.Response, error) { 102 | // Validate this request was for hashicorp/test 103 | if owner != "hashicorp" && repo != "test" { 104 | return nil, nil, errors.New("must be called for hashicorp/test") 105 | } 106 | 107 | m.parent.CreatedReferences = append( 108 | m.parent.CreatedReferences, ref, 109 | ) 110 | 111 | return nil, nil, nil 112 | } 113 | 114 | // DeleteRef .................. 115 | func (m *MockGithubGitInteractor) DeleteRef( 116 | ctx context.Context, owner string, repo string, ref string) (*github.Response, error) { 117 | return nil, nil 118 | } 119 | 120 | // Create ............................. 121 | func (m *MockGithubRepoInteractor) Create( 122 | ctx context.Context, owner string, repository *github.Repository, 123 | ) (*github.Repository, *github.Response, error) { 124 | return nil, nil, nil 125 | } 126 | 127 | // Delete ............................. 128 | func (m *MockGithubRepoInteractor) Delete( 129 | ctx context.Context, owner string, repo string, 130 | ) (*github.Response, error) { 131 | return nil, nil 132 | } 133 | 134 | // Edit ............................. 135 | func (m *MockGithubRepoInteractor) Edit( 136 | ctx context.Context, owner string, repo string, repository *github.Repository, 137 | ) (*github.Repository, *github.Response, error) { 138 | return nil, nil, nil 139 | } 140 | 141 | // RemoveBranchProtection ............................. 142 | func (m *MockGithubRepoInteractor) RemoveBranchProtection( 143 | ctx context.Context, owner string, repo string, branch string, 144 | ) (*github.Response, error) { 145 | return nil, nil 146 | } 147 | 148 | // GetBranchProtection ............................. 149 | func (m *MockGithubRepoInteractor) GetBranchProtection( 150 | ctx context.Context, owner string, repo string, branch string, 151 | ) (*github.Protection, *github.Response, error) { 152 | return nil, nil, nil 153 | } 154 | 155 | // UpdateBranchProtection ............................. 156 | func (m *MockGithubRepoInteractor) UpdateBranchProtection( 157 | ctx context.Context, owner string, repo string, branch string, preq *github.ProtectionRequest, 158 | ) (*github.Protection, *github.Response, error) { 159 | return nil, nil, nil 160 | } 161 | 162 | // PR stuff 163 | 164 | // Edit ............................. 165 | func (m *MockGithubPRsInteractor) Edit( 166 | ctx context.Context, owner string, repo string, number int, pull *github.PullRequest, 167 | ) (*github.PullRequest, *github.Response, error) { 168 | return nil, nil, nil 169 | } 170 | 171 | // List ............................. 172 | func (m *MockGithubPRsInteractor) List( 173 | ctx context.Context, owner string, repo string, opts *github.PullRequestListOptions, 174 | ) ([]*github.PullRequest, *github.Response, error) { 175 | return nil, nil, nil 176 | } 177 | 178 | // Create ............................. 179 | func (m *MockGithubPRsInteractor) Create( 180 | ctx context.Context, owner string, repo string, pull *github.NewPullRequest, 181 | ) (*github.PullRequest, *github.Response, error) { 182 | return nil, nil, nil 183 | } 184 | 185 | // Merge ............................. 186 | func (m *MockGithubPRsInteractor) Merge( 187 | ctx context.Context, owner string, repo string, number int, commitMessage string, options *github.PullRequestOptions) (*github.PullRequestMergeResult, *github.Response, error) { 188 | return nil, nil, nil 189 | } 190 | -------------------------------------------------------------------------------- /pkg/gh/gh.go: -------------------------------------------------------------------------------- 1 | package gh 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/google/go-github/v32/github" 8 | "golang.org/x/oauth2" 9 | ) 10 | 11 | // GithubInteractor is an interface that represents interaction with the GitHub 12 | // API. This can be the real GitHub, or a mock. 13 | type GithubInteractor interface { 14 | GetGit() GithubGitInteractor 15 | GetRepo() GithubRepoInteractor 16 | GetPRs() GithubPRInteractor 17 | } 18 | 19 | // GithubGitInteractor is a more specific interface that represents a GitService 20 | // client in GitHub. This can also be real or fake. 21 | type GithubGitInteractor interface { 22 | GetRef(ctx context.Context, owner string, repo string, ref string) (*github.Reference, *github.Response, error) 23 | CreateRef(ctx context.Context, owner string, repo string, ref *github.Reference) (*github.Reference, *github.Response, error) 24 | DeleteRef(ctx context.Context, owner string, repo string, ref string) (*github.Response, error) 25 | } 26 | 27 | // GithubPRInteractor is a more specific interface that represents a PullsRequestService 28 | // in GitHub. This can also be real or fake. 29 | type GithubPRInteractor interface { 30 | Edit(ctx context.Context, owner string, repo string, number int, pull *github.PullRequest) (*github.PullRequest, *github.Response, error) 31 | List(ctx context.Context, owner string, repo string, opts *github.PullRequestListOptions) ([]*github.PullRequest, *github.Response, error) 32 | Create(ctx context.Context, owner string, repo string, pull *github.NewPullRequest) (*github.PullRequest, *github.Response, error) 33 | Merge(ctx context.Context, owner string, repo string, number int, commitMessage string, options *github.PullRequestOptions) (*github.PullRequestMergeResult, *github.Response, error) 34 | } 35 | 36 | // GithubRepoInteractor is a more specific interface that represents a RepositoriesService 37 | // in GitHub. This can also be real or fake. 38 | type GithubRepoInteractor interface { 39 | Create(ctx context.Context, owner string, repository *github.Repository) (*github.Repository, *github.Response, error) 40 | Edit(ctx context.Context, owner string, repo string, repository *github.Repository) (*github.Repository, *github.Response, error) 41 | RemoveBranchProtection(ctx context.Context, owner string, repo string, branch string) (*github.Response, error) 42 | GetBranchProtection(ctx context.Context, owner string, repo string, branch string) (*github.Protection, *github.Response, error) 43 | UpdateBranchProtection(ctx context.Context, owner string, repo string, branch string, preq *github.ProtectionRequest) (*github.Protection, *github.Response, error) 44 | Delete(ctx context.Context, owner string, repo string) (*github.Response, error) 45 | } 46 | 47 | // BaseGithubInteractor is a concrete implementation of the GithubInteractor 48 | // interface. In this case, it implements the methods of this interface by 49 | // calling the real GitHub client. 50 | type BaseGithubInteractor struct { 51 | github *github.Client 52 | repo *github.RepositoriesService 53 | pr *github.PullRequestsService 54 | } 55 | 56 | // GetGit returns the GitService Client. 57 | func (b *BaseGithubInteractor) GetGit() GithubGitInteractor { 58 | return b.github.Git 59 | } 60 | 61 | // GetRepo returns the RepositioriesService Client. 62 | func (b *BaseGithubInteractor) GetRepo() GithubRepoInteractor { 63 | return b.github.Repositories 64 | } 65 | 66 | // GetPRs returns the PullsRequestService Client. 67 | func (b *BaseGithubInteractor) GetPRs() GithubPRInteractor { 68 | return b.github.PullRequests 69 | } 70 | 71 | // NewBaseGithubInteractor is a constructor for baseGithubInteractor. 72 | func NewBaseGithubInteractor(token string) (*BaseGithubInteractor, error) { 73 | if token == "" { 74 | return nil, errors.New("cannot create GitHub Client with empty token") 75 | } 76 | 77 | ctx := context.Background() 78 | oauthToken := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 79 | oathClient := oauth2.NewClient(ctx, oauthToken) 80 | client := github.NewClient(oathClient) 81 | 82 | return &BaseGithubInteractor{ 83 | github: client, 84 | repo: client.Repositories, 85 | pr: client.PullRequests, 86 | }, nil 87 | } 88 | -------------------------------------------------------------------------------- /pkg/message/message.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "github.com/fatih/color" 5 | ) 6 | 7 | func Error(s string) string { 8 | return color.RedString(s) 9 | } 10 | 11 | func Success(s string) string { 12 | return color.GreenString(s) 13 | } 14 | 15 | func Warn(s string) string { 16 | return color.YellowString(s) 17 | } 18 | 19 | func Info(s string) string { 20 | return color.CyanString(s) 21 | } 22 | -------------------------------------------------------------------------------- /pkg/pulls/close.go: -------------------------------------------------------------------------------- 1 | package pulls 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/google/go-github/v32/github" 8 | 9 | "github.com/hashicorp/inclusify/pkg/config" 10 | "github.com/hashicorp/inclusify/pkg/gh" 11 | "github.com/hashicorp/inclusify/pkg/message" 12 | ) 13 | 14 | // CloseCommand is a struct used to configure a Command for closing an open PR 15 | type CloseCommand struct { 16 | Config *config.Config 17 | GithubClient gh.GithubInteractor 18 | PullNumber int 19 | } 20 | 21 | // Run closes an open PR on GitHub, given a PullNumber 22 | func (c *CloseCommand) Run(args []string) int { 23 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 24 | defer cancel() 25 | 26 | pr := &github.PullRequest{Number: &c.PullNumber, Base: nil} 27 | _, _, err := c.GithubClient.GetPRs().Edit(ctx, c.Config.Owner, c.Config.Repo, c.PullNumber, pr) 28 | if err != nil { 29 | return c.exitError(err) 30 | } 31 | 32 | c.Config.Logger.Info(message.Success("Successfully closed PR"), "number", c.PullNumber) 33 | 34 | return 0 35 | } 36 | 37 | // exitError prints the error to the configured UI Error channel (usually stderr) then 38 | // returns the exit code. 39 | func (c *CloseCommand) exitError(err error) int { 40 | c.Config.Logger.Error(message.Error(err.Error())) 41 | return 1 42 | } 43 | -------------------------------------------------------------------------------- /pkg/pulls/merge.go: -------------------------------------------------------------------------------- 1 | package pulls 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/google/go-github/v32/github" 9 | 10 | "github.com/hashicorp/inclusify/pkg/config" 11 | "github.com/hashicorp/inclusify/pkg/gh" 12 | "github.com/hashicorp/inclusify/pkg/message" 13 | ) 14 | 15 | // MergeCommand is a struct used to configure a Command for merging an open PR 16 | type MergeCommand struct { 17 | Config *config.Config 18 | GithubClient gh.GithubInteractor 19 | PullNumber int 20 | } 21 | 22 | // Run merges an open PR on GitHub, given a PullNumber 23 | func (c *MergeCommand) Run(args []string) int { 24 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 25 | defer cancel() 26 | 27 | options := &github.PullRequestOptions{MergeMethod: "squash"} 28 | result, _, err := c.GithubClient.GetPRs().Merge(ctx, c.Config.Owner, c.Config.Repo, c.PullNumber, "Merging Inclusify PR", options) 29 | if err != nil { 30 | return c.exitError(err) 31 | } 32 | if !*result.Merged { 33 | return c.exitError(errors.New("failed to merge PR")) 34 | } 35 | 36 | c.Config.Logger.Info(message.Success("Successfully merged PR"), "number", c.PullNumber) 37 | 38 | return 0 39 | } 40 | 41 | // exitError prints the error to the configured UI Error channel (usually stderr) then 42 | // returns the exit code. 43 | func (c *MergeCommand) exitError(err error) int { 44 | c.Config.Logger.Error(message.Error(err.Error())) 45 | return 1 46 | } 47 | -------------------------------------------------------------------------------- /pkg/pulls/update.go: -------------------------------------------------------------------------------- 1 | package pulls 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/google/go-github/v32/github" 9 | "github.com/hashicorp/inclusify/pkg/config" 10 | "github.com/hashicorp/inclusify/pkg/gh" 11 | "github.com/hashicorp/inclusify/pkg/message" 12 | ) 13 | 14 | // UpdateCommand is a struct used to configure a Command for updating open 15 | // PR's that target master to target the new $base 16 | type UpdateCommand struct { 17 | Config *config.Config 18 | GithubClient gh.GithubInteractor 19 | } 20 | 21 | // GetOpenPRs returns an array of all open PR's that target the $base branch 22 | func GetOpenPRs(c *UpdateCommand) (pulls []*github.PullRequest, err error) { 23 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 24 | defer cancel() 25 | 26 | c.Config.Logger.Info("Getting all open PR's targeting the branch", "base", c.Config.Base) 27 | var allPulls []*github.PullRequest 28 | opts := &github.PullRequestListOptions{ 29 | State: "open", 30 | Base: c.Config.Base, 31 | ListOptions: github.ListOptions{PerPage: 10}, 32 | } 33 | 34 | // Paginate to get all open PR's and store them in 'allPulls' array 35 | for { 36 | pulls, resp, err := c.GithubClient.GetPRs().List(ctx, c.Config.Owner, c.Config.Repo, opts) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to retrieve all open PR's: %w", err) 39 | } 40 | allPulls = append(allPulls, pulls...) 41 | if resp.NextPage == 0 { 42 | break 43 | } 44 | opts.Page = resp.NextPage 45 | } 46 | 47 | c.Config.Logger.Info("Retrieved all open PR's targeting the branch", "base", c.Config.Base, "prCount", len(allPulls)) 48 | 49 | return allPulls, nil 50 | } 51 | 52 | // UpdateOpenPRs will update all open PR's that pointed to $base to instead point to $target 53 | // Example: Update all open PR's that point to 'master' to point to 'main' 54 | func UpdateOpenPRs(c *UpdateCommand, pulls []*github.PullRequest, targetRef *github.Reference) (err error) { 55 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 56 | defer cancel() 57 | 58 | for _, pull := range pulls { 59 | pull.Base.Label = &c.Config.Target 60 | pull.Base.Ref = targetRef.Ref 61 | updatedPull, _, err := c.GithubClient.GetPRs().Edit(ctx, c.Config.Owner, c.Config.Repo, *pull.Number, pull) 62 | if err != nil { 63 | errString := fmt.Sprintf("failed to update base branch of PR %s", *pull.URL) 64 | return fmt.Errorf(errString+": %w", err) 65 | } 66 | c.Config.Logger.Info("Successfully updated base branch of PR to target", "base", c.Config.Base, "target", c.Config.Target, "pullNumber", updatedPull.GetNumber(), "pullURL", updatedPull.GetHTMLURL()) 67 | } 68 | return nil 69 | } 70 | 71 | // GetRef returns the ref of the $target branch 72 | func GetRef(c *UpdateCommand) (targetRef *github.Reference, err error) { 73 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 74 | defer cancel() 75 | 76 | ref := fmt.Sprintf("heads/%s", c.Config.Target) 77 | targetRef, _, err = c.GithubClient.GetGit().GetRef(ctx, c.Config.Owner, c.Config.Repo, ref) 78 | if err != nil { 79 | return nil, fmt.Errorf("failed to get $target ref: %w", err) 80 | } 81 | if targetRef == nil { 82 | return nil, fmt.Errorf("no $target ref, %s, was found", c.Config.Target) 83 | } 84 | return targetRef, nil 85 | } 86 | 87 | // Run updates all open PR's that point to $base to instead point to $target 88 | // Example: Update all open PR's that point to 'master' to point to 'main' 89 | func (c *UpdateCommand) Run(args []string) int { 90 | // Get a list of open PR's targeting the $base branch 91 | pulls, err := GetOpenPRs(c) 92 | if err != nil { 93 | return c.exitError(err) 94 | } 95 | if len(pulls) == 0 { 96 | c.Config.Logger.Info(message.Info("Exiting -- There are no open PR's to update")) 97 | return 0 98 | } 99 | 100 | // Get the ref of the $target branch 101 | ref, err := GetRef(c) 102 | if err != nil { 103 | return c.exitError(err) 104 | } 105 | 106 | // Update all open PR's that point to $base to point to $target 107 | err = UpdateOpenPRs(c, pulls, ref) 108 | if err != nil { 109 | return c.exitError(err) 110 | } 111 | 112 | c.Config.Logger.Info(message.Success("Success!")) 113 | 114 | return 0 115 | } 116 | 117 | // exitError prints the error to the configured UI Error channel (usually stderr) then 118 | // returns the exit code. 119 | func (c *UpdateCommand) exitError(err error) int { 120 | c.Config.Logger.Error(message.Error(err.Error())) 121 | return 1 122 | } 123 | 124 | // Help returns the full help text. 125 | func (c *UpdateCommand) Help() string { 126 | return `Usage: inclusify updatePulls owner repo base target token 127 | Update the base branch of all open PR's. Configuration is pulled from the local environment. 128 | Flags: 129 | --owner The GitHub org that owns the repo, e.g. 'hashicorp'. 130 | --repo The repository name, e.g. 'circle-codesign'. 131 | --base="master" The name of the current base branch, e.g. 'master'. 132 | --target="main" The name of the target branch, e.g. 'main'. 133 | --token Your Personal GitHub Access Token. 134 | ` 135 | } 136 | 137 | // Synopsis returns a sub 50 character summary of the command. 138 | func (c *UpdateCommand) Synopsis() string { 139 | return "Update base branch of open PR's. [subcommand]" 140 | } 141 | -------------------------------------------------------------------------------- /pkg/repos/create.go: -------------------------------------------------------------------------------- 1 | package branches 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/google/go-github/v32/github" 9 | "github.com/hashicorp/inclusify/pkg/config" 10 | "github.com/hashicorp/inclusify/pkg/gh" 11 | "github.com/hashicorp/inclusify/pkg/message" 12 | ) 13 | 14 | // CreateCommand is a struct used to configure a Command for creating a 15 | // new GitHub repo 16 | type CreateCommand struct { 17 | Config *config.Config 18 | GithubClient gh.GithubInteractor 19 | Repo string 20 | } 21 | 22 | // Run creates a new repo for the authenticated user 23 | func (c *CreateCommand) Run(args []string) int { 24 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 25 | defer cancel() 26 | 27 | c.Config.Logger.Info("Creating new repo for user", "repo", c.Repo, "user", c.Config.Owner) 28 | repositoryRequest := &github.Repository{ 29 | Name: &c.Repo, 30 | } 31 | repo, _, err := c.GithubClient.GetRepo().Create(ctx, "", repositoryRequest) 32 | if err != nil { 33 | return c.exitError(fmt.Errorf("call to create repo returned error: %w", err)) 34 | } 35 | 36 | c.Config.Logger.Info(message.Success("Successfully created new repo"), "repo", repo.GetName(), "url", repo.GetHTMLURL()) 37 | 38 | return 0 39 | } 40 | 41 | // exitError prints the error to the configured UI Error channel (usually stderr) then 42 | // returns the exit code. 43 | func (c *CreateCommand) exitError(err error) int { 44 | c.Config.Logger.Error(message.Error(err.Error())) 45 | return 1 46 | } 47 | -------------------------------------------------------------------------------- /pkg/repos/delete.go: -------------------------------------------------------------------------------- 1 | package branches 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/hashicorp/inclusify/pkg/config" 9 | "github.com/hashicorp/inclusify/pkg/gh" 10 | "github.com/hashicorp/inclusify/pkg/message" 11 | ) 12 | 13 | // DeleteCommand is a struct used to configure a Command for deleting a 14 | // GitHub repo 15 | type DeleteCommand struct { 16 | Config *config.Config 17 | GithubClient gh.GithubInteractor 18 | Repo string 19 | } 20 | 21 | // Run deletes the repo for the authenticated user 22 | func (c *DeleteCommand) Run(args []string) int { 23 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 24 | defer cancel() 25 | 26 | c.Config.Logger.Info("Deleting repo for user", "repo", c.Repo, "user", c.Config.Owner) 27 | 28 | _, err := c.GithubClient.GetRepo().Delete(ctx, c.Config.Owner, c.Repo) 29 | if err != nil { 30 | return c.exitError(fmt.Errorf("call to delete repo returned error: %w", err)) 31 | } 32 | 33 | c.Config.Logger.Info(message.Success("Successfully deleted repo"), "repo", c.Repo) 34 | 35 | return 0 36 | } 37 | 38 | // exitError prints the error to the configured UI Error channel (usually stderr) then 39 | // returns the exit code. 40 | func (c *DeleteCommand) exitError(err error) int { 41 | c.Config.Logger.Error(message.Error(err.Error())) 42 | return 1 43 | } 44 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/.gitattributes: -------------------------------------------------------------------------------- 1 | config.yml linguist-generated 2 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/.gitignore: -------------------------------------------------------------------------------- 1 | .tmp/ 2 | .DS_Store -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/Makefile: -------------------------------------------------------------------------------- 1 | # Set SHELL to 'strict mode' without using .SHELLFLAGS for max compatibility. 2 | # See https://fieldnotes.tech/how-to-shell-for-compatible-makefiles/ 3 | SHELL := /usr/bin/env bash -euo pipefail -c 4 | 5 | # CONFIG is the name of the make target someone 6 | # would invoke to update the main config file (config.yml). 7 | CONFIG ?= ci-config 8 | # VERIFY is the name of the make target someone 9 | # would invoke to verify the config file. 10 | VERIFY ?= ci-verify 11 | 12 | CIRCLECI := circleci --skip-update-check 13 | ifeq ($(DEBUG_CIRCLECI_CLI),YES) 14 | CIRCLECI += --debug 15 | endif 16 | 17 | # For config processing, always refer to circleci.com not self-hosted circleci, 18 | # because self-hosted does not currently support the necessary API. 19 | CIRCLECI_CLI_HOST := https://circleci.com 20 | export CIRCLECI_CLI_HOST 21 | 22 | # Set up some documentation/help message variables. 23 | # We do not attempt to install the CircleCI CLI from this Makefile. 24 | CCI_INSTALL_LINK := https://circleci.com/docs/2.0/local-cli/\#installation 25 | CCI_INSTALL_MSG := Please install CircleCI CLI. See $(CCI_INSTALL_LINK) 26 | CCI_VERSION := $(shell $(CIRCLECI) version 2> /dev/null) 27 | ifeq ($(CCI_VERSION),) 28 | # Attempting to use the CLI fails with installation instructions. 29 | CIRCLECI := echo '$(CCI_INSTALL_MSG)'; exit 1; \# 30 | endif 31 | 32 | SOURCE_DIR := config 33 | SOURCE_YML := $(shell [ ! -d $(SOURCE_DIR) ] || find $(SOURCE_DIR) -name '*.yml') 34 | CONFIG_SOURCE := Makefile $(SOURCE_YML) | $(SOURCE_DIR) 35 | OUT := config.yml 36 | TMP := .tmp/config-processed 37 | CONFIG_PACKED := .tmp/config-packed 38 | 39 | default: help 40 | 41 | help: 42 | @echo "Usage for master branch:" 43 | @echo " make $(CONFIG): recompile config.yml from $(SOURCE_DIR)/" 44 | @echo " make $(VERIFY): verify that config.yml is a true mapping from $(SOURCE_DIR)/" 45 | @echo 46 | @echo "Diagnostics:" 47 | @[ -z "$(CCI_VERSION)" ] || echo " circleci-cli version $(CCI_VERSION)" 48 | @[ -n "$(CCI_VERSION)" ] || echo " $(CCI_INSTALL_MSG)" 49 | 50 | $(SOURCE_DIR): 51 | @echo No source directory $(SOURCE_DIR) found.; exit 1 52 | 53 | # Make sure our .tmp dir exists. 54 | $(shell [ -d .tmp ] || mkdir .tmp) 55 | 56 | .PHONY: $(CONFIG) 57 | $(CONFIG): $(OUT) 58 | 59 | .PHONY: $(VERIFY) 60 | $(VERIFY): config-up-to-date 61 | @$(CIRCLECI) config validate $(OUT) 62 | 63 | define GENERATED_FILE_HEADER 64 | ### *** 65 | ### WARNING: DO NOT manually EDIT or MERGE this file, it is generated by 'make $(CONFIG)'. 66 | ### INSTEAD: Edit or merge the source in $(SOURCE_DIR)/ then run 'make $(CONFIG)'. 67 | ### *** 68 | endef 69 | export GENERATED_FILE_HEADER 70 | 71 | # GEN_CONFIG writes the config to a temporary file. If the whole process succeeds, 72 | # it them moves that file to $@. This makes is an atomic operation, so if it fails 73 | # make doesn't consider a half-baked file up to date. 74 | define GEN_CONFIG 75 | @$(CIRCLECI) config pack $(SOURCE_DIR) > $(CONFIG_PACKED) 76 | @echo "$$GENERATED_FILE_HEADER" > $@.tmp || { rm -f $@; exit 1; } 77 | @$(CIRCLECI) config process $(CONFIG_PACKED) >> $@.tmp || { rm -f $@.tmp; exit 1; } 78 | @mv -f $@.tmp $@ 79 | endef 80 | 81 | $(OUT): $(CONFIG_SOURCE) 82 | $(GEN_CONFIG) 83 | @echo "$@ updated" 84 | 85 | $(TMP): $(CONFIG_SOURCE) 86 | $(GEN_CONFIG) 87 | 88 | .PHONY: config-up-to-date 89 | config-up-to-date: $(TMP) # Note this must not depend on $(OUT)! 90 | @if diff -w $(OUT) $<; then \ 91 | echo "master generated $(OUT) is up to date!"; \ 92 | else \ 93 | echo "master generated $(OUT) is out of date, run make $(CONFIG) to update."; \ 94 | exit 1; \ 95 | fi 96 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/README.md: -------------------------------------------------------------------------------- 1 | # How to use CircleCI multi-file config 2 | 3 | This README and the Makefile should be in your `.circleci` directory, 4 | in the root of your repository. 5 | All path references in this README assume we are in this `.circleci` directory. 6 | 7 | The `Makefile` in this directory generates `./config.yml` in CircleCI 2.0 syntax, 8 | from the tree rooted at `./config/`, which contains files in CircleCI 2.0 or 2.1 syntax. 9 | 10 | 11 | ## Quickstart 12 | 13 | The basic workflow is: 14 | 15 | - Edit source files in `./config/` 16 | - When you are done, run `make ci-config` to update `./config.yml` 17 | - Commit this entire `.circleci` directory, including that generated file together. 18 | - Run `make ci-verify` to ensure the current `./config.yml` is up to date with the source. 19 | 20 | When merging this `.circleci` directory: 21 | 22 | - Do not merge the generated `./config.yml` file, instead: 23 | - Merge the source files under `./config/`, and then 24 | - Run `make ci-config` to re-generate the merged `./config.yml` 25 | 26 | And that's it, for more detail, read on! 27 | 28 | 29 | ## How does it work, roughly? 30 | 31 | CircleCI supports [generating a single config file from many], 32 | using the `$ circleci config pack` command. 33 | It also supports [expanding 2.1 syntax to 2.0 syntax] 34 | using the `$ circleci config process` command. 35 | We use these two commands, stitched together using the `Makefile` 36 | to implement the workflow. 37 | 38 | [generating a single config file from many]: https://circleci.com/docs/2.0/local-cli/#packing-a-config 39 | [expanding 2.1 syntax to 2.0 syntax]: https://circleci.com/docs/2.0/local-cli/#processing-a-config 40 | 41 | 42 | ## Prerequisites 43 | 44 | You will need the [CircleCI CLI tool] installed and working, 45 | at least version `0.1.5607`. 46 | You can [download this tool directly from GitHub Releases]. 47 | 48 | ``` 49 | $ circleci version 50 | 0.1.5607+f705856 51 | ``` 52 | 53 | [CircleCI CLI tool]: https://circleci.com/docs/2.0/local-cli/ 54 | [download this tool directly from GitHub Releases]: https://github.com/CircleCI-Public/circleci-cli/releases 55 | 56 | 57 | ## Updating the config source 58 | 59 | Before making changes, be sure to understand the layout 60 | of the `./config/` file tree, as well as circleci 2.1 syntax. 61 | See the [Syntax and layout] section below. 62 | 63 | To update the config, you should edit, add or remove files 64 | in the `./config/` directory, 65 | and then run `make ci-config`. 66 | If that's successful, 67 | you should then commit every `*.yml` file in the tree rooted in this directory. 68 | That is: you should commit both the source under `./config/` 69 | and the generated file `./config.yml` at the same time, in the same commit. 70 | The included git pre-commit hook will help with this. 71 | Do not edit the `./config.yml` file directly, as you will lose your changes 72 | next time `make ci-config` is run. 73 | 74 | [Syntax and layout]: #syntax-and-layout 75 | 76 | 77 | ### Verifying `./config.yml` 78 | 79 | To check whether or not the current `./config.yml` is up to date with the source 80 | and valid, run `$ make ci-verify`. 81 | Note that `$ make ci-verify` should be run in CI, 82 | in case not everyone has the git pre-commit hook set up correctly. 83 | 84 | 85 | #### Example shell session 86 | 87 | ```sh 88 | $ make ci-config 89 | config.yml updated 90 | $ git add -A . # The -A makes sure to include deletions/renames etc. 91 | $ git commit -m "ci: blah blah blah" 92 | Changes detected in .circleci/, running 'make -C .circleci ci-verify' 93 | --> Generated config.yml is up to date! 94 | --> Config file at config.yml is valid. 95 | ``` 96 | 97 | 98 | ### Syntax and layout 99 | 100 | It is important to understand the layout of the config directory. 101 | Read the documentation on [packing a config] for a full understanding 102 | of how multiple YAML files are merged by the circleci CLI tool. 103 | 104 | [packing a config]: https://circleci.com/docs/2.0/local-cli/#packing-a-config 105 | 106 | Here is an example file tree (with comments added afterwards): 107 | 108 | ```sh 109 | $ tree . 110 | . 111 | ├── Makefile 112 | ├── README.md # This file. 113 | ├── config # The source code for config.yml is rooted here. 114 | │   ├── @config.yml # Files beginning with @ are treated specially by `circleci config pack` 115 | │   ├── commands # Subdirectories of config become top-level keys. 116 | │   │   └── go_test.yml # Filenames (minus .yml) become top-level keys under 117 | │   │   └── go_build.yml # their parent (in this case "commands"). 118 | │ │ # The contents of go_test.yml therefore are placed at: .commands.go_test: 119 | │   └── jobs # jobs also becomes a top-level key under config... 120 | │   ├── build.yml # ...and likewise filenames become keys under their parent. 121 | │   └── test.yml 122 | └── config.yml # The generated file in 2.0 syntax. 123 | ``` 124 | 125 | About those `@` files... Preceding a filename with `@` 126 | indicates to `$ circleci config pack` that the contents of this YAML file 127 | should be at the top-level, rather than underneath a key named after their filename. 128 | This naming convention is unfortunate as it breaks autocompletion in bash, 129 | but there we go. 130 | 131 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/config.yml: -------------------------------------------------------------------------------- 1 | ### *** 2 | ### WARNING: DO NOT manually EDIT or MERGE this file, it is generated by 'make ci-config'. 3 | ### INSTEAD: Edit or merge the source in config/ then run 'make ci-config'. 4 | ### *** 5 | version: 2 6 | jobs: 7 | test: 8 | docker: 9 | - image: cimg/base:2020.01 10 | environment: 11 | - DEFAULT_BRANCH: master 12 | shell: /usr/bin/env bash -euo pipefail 13 | steps: 14 | - checkout 15 | - attach_workspace: 16 | at: . 17 | - run: 18 | command: mkdir master 19 | - run: 20 | command: echo $DEFAULT_BRANCH 21 | - run: 22 | command: echo $BRANCH 23 | - persist_to_workspace: 24 | paths: 25 | - master 26 | root: . 27 | workflows: 28 | ci: 29 | jobs: 30 | - test: 31 | filters: 32 | branches: 33 | ignore: master 34 | version: 2 35 | 36 | # Original config.yml file: 37 | # executors: 38 | # go: 39 | # docker: 40 | # - image: golang:1.14.6 41 | # environment: 42 | # BRANCH: master 43 | # GOPATH: /go 44 | # working_directory: /go/src/github.com/mdeggies/circleci-tester 45 | # jobs: 46 | # test: 47 | # docker: 48 | # - image: cimg/base:2020.01 49 | # environment: 50 | # DEFAULT_BRANCH: master 51 | # shell: /usr/bin/env bash -euo pipefail 52 | # steps: 53 | # - checkout 54 | # - attach_workspace: 55 | # at: . 56 | # - run: mkdir master 57 | # - run: echo $DEFAULT_BRANCH 58 | # - run: echo $BRANCH 59 | # - persist_to_workspace: 60 | # paths: 61 | # - master 62 | # root: . 63 | # references: 64 | # common_envs: 65 | # BRANCH: master 66 | # go-machine-image: circleci/classic:201808-01 67 | # version: 2.1 68 | # workflows: 69 | # ci: 70 | # jobs: 71 | # - test: 72 | # filters: 73 | # branches: 74 | # ignore: master -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/config/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | references: 4 | go-machine-image: &go_machine_image 5 | circleci/classic:201808-01 6 | 7 | # common references 8 | common_envs: &common_envs 9 | BRANCH: "master" 10 | 11 | executors: 12 | go: 13 | working_directory: /blah/master 14 | docker: 15 | - image: golang:1.14.6 16 | environment: 17 | <<: *common_envs 18 | GOPATH: /go 19 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/config/jobs/test.yml: -------------------------------------------------------------------------------- 1 | docker: 2 | - image: cimg/base:2020.01 3 | shell: /usr/bin/env bash -euo pipefail 4 | environment: 5 | DEFAULT_BRANCH: master 6 | steps: 7 | - checkout 8 | - attach_workspace: 9 | at: . 10 | - run: mkdir master 11 | - run: echo $DEFAULT_BRANCH 12 | - run: echo $BRANCH 13 | - persist_to_workspace: 14 | root: . 15 | paths: 16 | - master -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/config/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | - test: 3 | filters: 4 | branches: 5 | ignore: master -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.circleci/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | # Check .circleci/config.yml is up to date and valid, and that all changes are 6 | # included together in this commit. 7 | 8 | # Fail early if we accidentally used '.yaml' instead of '.yml' 9 | if ! git diff --name-only --cached --exit-code -- '.circleci/***.yaml'; then 10 | echo "ERROR: File(s) with .yaml extension detected. Please rename them .yml instead." 11 | exit 1 12 | fi 13 | 14 | # Succeed early if no changes to yml files in .circleci/ are currently staged. 15 | # make ci-verify is slow so we really don't want to run it unnecessarily. 16 | if git diff --name-only --cached --exit-code -- '.circleci/***.yml'; then 17 | exit 0 18 | fi 19 | 20 | # Make sure to add no explicit output before this line, as it would just be noise 21 | # for those making non-circleci changes. 22 | echo "==> Verifying config changes in .circleci/" 23 | echo "--> OK: All files are .yml not .yaml" 24 | 25 | # Ensure commit includes _all_ files in .circleci/ 26 | # So not only are the files up to date, but we are also committing them in one go. 27 | if ! git diff --name-only --exit-code -- '.circleci/***.yml'; then 28 | echo "ERROR: Some .yml diffs in .circleci/ are staged, others not." 29 | echo "Please commit the entire .circleci/ directory together, or omit it altogether." 30 | exit 1 31 | fi 32 | 33 | # Even untracked yml or yaml files will get baked into the output. 34 | # This is a loose check on _any_ untracked files in .circleci/ for simplicity. 35 | if [ -n "$(git ls-files --others --exclude-standard '.circleci/')" ]; then 36 | echo "ERROR: You have untracked files in .circleci/ please add or delete them." 37 | exit 1 38 | fi 39 | 40 | echo "--> OK: All .yml files in .circleci are staged." 41 | if ! make -C .circleci ci-verify; then 42 | echo "ERROR: make ci-verify failed" 43 | exit 1 44 | fi 45 | echo "--> OK: make ci-verify succeeded." 46 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.github/workflows/ci-tester.yml: -------------------------------------------------------------------------------- 1 | name: Greet Everyone 2 | # This workflow is triggered on pushes to the repository. 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | # Job name is Greeting 8 | name: Greetings master 9 | # This job runs on Linux 10 | runs-on: ubuntu-latest 11 | steps: 12 | # This step uses GitHub's hello-world-javascript-action: https://github.com/actions/hello-world-javascript-action 13 | - name: Hello world 14 | uses: actions/hello-world-javascript-action@v1 15 | with: 16 | who-to-greet: 'Mona the master Octocat' 17 | id: hello 18 | # This step prints an output (time) from the previous step's action. 19 | - name: Echo the greeting's time 20 | run: echo 'The time was ${{ steps.hello.outputs.time }}.' 21 | - name: Echo master 22 | run: echo 'master master master master' 23 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - mod_timestamp: '{{ .CommitTimestamp }}' 3 | targets: 4 | - darwin_amd64 5 | hooks: 6 | post: echo "master build" 7 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.teamcity.yml: -------------------------------------------------------------------------------- 1 | example master -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/.travis.yaml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | 5 | - "2.7" 6 | 7 | script: 8 | - python hello.py 9 | - echo "master" 10 | -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/README.md: -------------------------------------------------------------------------------- 1 | ## Readme for inclusify integration tests 2 | 3 | master master master -------------------------------------------------------------------------------- /pkg/tests/fakeRepo/scripts/hello.py: -------------------------------------------------------------------------------- 1 | print("hello master") -------------------------------------------------------------------------------- /pkg/tests/integration_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package tests 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "regexp" 9 | "strconv" 10 | "sync" 11 | "testing" 12 | 13 | "github.com/dchest/uniuri" 14 | "github.com/mitchellh/cli" 15 | 16 | branches "github.com/hashicorp/inclusify/pkg/branches" 17 | "github.com/hashicorp/inclusify/pkg/config" 18 | "github.com/hashicorp/inclusify/pkg/files" 19 | "github.com/hashicorp/inclusify/pkg/gh" 20 | "github.com/hashicorp/inclusify/pkg/pulls" 21 | repos "github.com/hashicorp/inclusify/pkg/repos" 22 | 23 | "github.com/stretchr/testify/assert" 24 | "github.com/stretchr/testify/require" 25 | ) 26 | 27 | var seqMutex sync.Mutex 28 | var i Inputs 29 | 30 | // Inputs is a struct that contains all of our test config values 31 | type Inputs struct { 32 | owner string 33 | repo string 34 | token string 35 | base string 36 | target string 37 | exclusion string 38 | temp string 39 | random string 40 | branchesList []string 41 | pullRequestURL string 42 | } 43 | 44 | // SetVals sets test config values that will be used in all integration tests 45 | func (i *Inputs) SetVals(t *testing.T) { 46 | owner, exists := os.LookupEnv("INCLUSIFY_OWNER") 47 | if exists != true { 48 | t.Errorf("Cannot find the required env var INCLUSIFY_OWNER") 49 | } 50 | token, exists := os.LookupEnv("INCLUSIFY_TOKEN") 51 | if exists != true { 52 | t.Errorf("Cannot find the required env var INCLUSIFY_TOKEN") 53 | } 54 | i.owner = owner 55 | i.token = token 56 | i.repo = fmt.Sprintf("inclusify-tests-%s", uniuri.NewLenChars(8, []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"))) 57 | i.base = "master-clone" 58 | i.target = "main" 59 | i.exclusion = "scripts/,.teamcity.yml" 60 | i.temp = "update-references" 61 | i.random = "my-fancy-branch" 62 | } 63 | 64 | // SetURL receives a pointer to Inputs so it can modify the pullRequestURL field 65 | func (i *Inputs) SetURL(url string) { 66 | i.pullRequestURL = url 67 | } 68 | 69 | // GetVals receives a copy of Inputs and returns the structs values. 70 | func (i Inputs) GetVals() (string, string, string, string, string, string, string) { 71 | return i.owner, i.repo, i.token, i.base, i.target, i.temp, i.random 72 | } 73 | 74 | // GetURL recieces a copy of the pullRequestURL field and returns its value 75 | func (i Inputs) GetURL() string { 76 | return i.pullRequestURL 77 | } 78 | 79 | // Seq is used to ensure all tests run in sequence 80 | func seq() func() { 81 | seqMutex.Lock() 82 | return func() { 83 | seqMutex.Unlock() 84 | } 85 | } 86 | 87 | // TestSetTestValues sets config values to use in our integration tests 88 | func Test_SetTestConfigValues(t *testing.T) { 89 | defer seq()() 90 | i.SetVals(t) 91 | } 92 | 93 | // Test_CreateRepository creates a new repository for the currently authenticated user 94 | func Test_CreateRepository(t *testing.T) { 95 | defer seq()() 96 | mockUI := cli.NewMockUi() 97 | owner, repo, token, base, target, _, _ := i.GetVals() 98 | args := []string{"", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 99 | 100 | // Parse and validate cmd line flags and env vars 101 | config, err := config.ParseAndValidate(args, mockUI) 102 | require.NoError(t, err) 103 | 104 | client, err := gh.NewBaseGithubInteractor(token) 105 | require.NoError(t, err) 106 | 107 | command := &repos.CreateCommand{ 108 | Config: config, 109 | GithubClient: client, 110 | Repo: repo, 111 | } 112 | 113 | exit := command.Run([]string{}) 114 | 115 | // Did we exit with a zero exit code? 116 | if !assert.Equal(t, 0, exit) { 117 | require.Fail(t, mockUI.ErrorWriter.String()) 118 | } 119 | 120 | // Make some assertions about the UI output 121 | output := mockUI.OutputWriter.String() 122 | assert.Contains(t, output, fmt.Sprintf("Creating new repo for user: repo=%s user=%s", repo, owner)) 123 | assert.Contains(t, output, fmt.Sprintf("Successfully created new repo: repo=%s url=", repo)) 124 | } 125 | 126 | // Test_CreateScaffold creates an initial commit in the newly created repository 127 | func Test_CreateScaffold(t *testing.T) { 128 | defer seq()() 129 | mockUI := cli.NewMockUi() 130 | owner, repo, token, base, target, _, _ := i.GetVals() 131 | base = "master" 132 | args := []string{"", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 133 | 134 | // Parse and validate cmd line flags and env vars 135 | config, err := config.ParseAndValidate(args, mockUI) 136 | require.NoError(t, err) 137 | 138 | client, err := gh.NewBaseGithubInteractor(token) 139 | require.NoError(t, err) 140 | 141 | command := &files.CreateScaffoldCommand{ 142 | Config: config, 143 | GithubClient: client, 144 | } 145 | 146 | exit := command.Run([]string{}) 147 | 148 | // Did we exit with a zero exit code? 149 | if !assert.Equal(t, 0, exit) { 150 | require.Fail(t, mockUI.ErrorWriter.String()) 151 | } 152 | 153 | // Make some assertions about the UI output 154 | output := mockUI.OutputWriter.String() 155 | assert.Contains(t, output, "Creating local temp dir: dirPrefix=tmp-clone-") 156 | assert.Contains(t, output, "Initializing new repo at dir: dir=") 157 | assert.Contains(t, output, fmt.Sprintf("Creating a new remote for base: base=%s", base)) 158 | assert.Contains(t, output, "Copying test CI files into temp directory") 159 | assert.Contains(t, output, "Running `git add") 160 | assert.Contains(t, output, "Committing changes") 161 | assert.Contains(t, output, fmt.Sprintf("Pushing initial commit to remote: branch=%s sha=", base)) 162 | assert.Contains(t, output, fmt.Sprintf("Creating branch protection request: branch=%s", base)) 163 | assert.Contains(t, output, fmt.Sprintf("Applying branch protection: branch=%s", base)) 164 | 165 | assert.NotContains(t, output, "failed to commit changes") 166 | assert.NotContains(t, output, "failed to push changes") 167 | assert.NotContains(t, output, "failed to create the base branch protection") 168 | } 169 | 170 | // Test_CreateBranches creates the master-clone branch, 171 | // update-references branch, and the main branch, off of the head of master 172 | func Test_CreateBranches(t *testing.T) { 173 | defer seq()() 174 | subcommand := "createBranches" 175 | mockUI := cli.NewMockUi() 176 | owner, repo, token, base, target, temp, random := i.GetVals() 177 | list := []string{base, temp, random} 178 | base = "master" 179 | args := []string{subcommand, "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 180 | 181 | // Parse and validate cmd line flags and env vars 182 | config, err := config.ParseAndValidate(args, mockUI) 183 | require.NoError(t, err) 184 | 185 | client, err := gh.NewBaseGithubInteractor(token) 186 | require.NoError(t, err) 187 | 188 | command := &branches.CreateCommand{ 189 | Config: config, 190 | GithubClient: client, 191 | BranchesList: list, 192 | } 193 | 194 | exit := command.Run([]string{}) 195 | 196 | // Did we exit with a zero exit code? 197 | if !assert.Equal(t, 0, exit) { 198 | require.Fail(t, mockUI.ErrorWriter.String()) 199 | } 200 | 201 | // Make some assertions about the UI output 202 | output := mockUI.OutputWriter.String() 203 | assert.Contains(t, output, fmt.Sprintf("Creating new branch %s off of %s", list[0], base)) 204 | assert.Contains(t, output, fmt.Sprintf("Creating new branch %s off of %s", list[1], base)) 205 | assert.Contains(t, output, fmt.Sprintf("Creating new branch %s off of %s", list[2], base)) 206 | assert.Contains(t, output, fmt.Sprintf("Creating new branch %s off of %s", target, base)) 207 | assert.Contains(t, output, "Success!") 208 | } 209 | 210 | // Test_UpdateOpenPullRequestsNoOp updates any open pull requests that have 'main' as a base 211 | // Since there are no open PR's targeting that base, this will effectively do nothing 212 | func Test_UpdateOpenPullRequestsNoOp(t *testing.T) { 213 | defer seq()() 214 | mockUI := cli.NewMockUi() 215 | owner, repo, token, base, target, _, _ := i.GetVals() 216 | args := []string{"updatePulls", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 217 | 218 | // Parse and validate cmd line flags and env vars 219 | config, err := config.ParseAndValidate(args, mockUI) 220 | require.NoError(t, err) 221 | 222 | client, err := gh.NewBaseGithubInteractor(token) 223 | require.NoError(t, err) 224 | 225 | command := &pulls.UpdateCommand{ 226 | Config: config, 227 | GithubClient: client, 228 | } 229 | 230 | exit := command.Run([]string{}) 231 | 232 | // Did we exit with a zero exit code? 233 | if !assert.Equal(t, 0, exit) { 234 | require.Fail(t, mockUI.ErrorWriter.String()) 235 | } 236 | 237 | // Make some assertions about the UI output 238 | output := mockUI.OutputWriter.String() 239 | assert.Contains(t, output, "Exiting -- There are no open PR's to update") 240 | } 241 | 242 | // Test_UpdateRefs finds and replaces all references of 'master' to 'main' in the given CI files 243 | // in the 'update-references' branch, and opens a PR to merge changes from 'update-references' to 'main' 244 | // No files/dirs in `exclusions` is considered. 245 | func Test_UpdateRefs(t *testing.T) { 246 | defer seq()() 247 | mockUI := cli.NewMockUi() 248 | base := "master" 249 | owner, repo, token, _, target, temp, _ := i.GetVals() 250 | args := []string{"updateRefs", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 251 | 252 | // Parse and validate cmd line flags and env vars 253 | config, err := config.ParseAndValidate(args, mockUI) 254 | require.NoError(t, err) 255 | 256 | client, err := gh.NewBaseGithubInteractor(token) 257 | require.NoError(t, err) 258 | 259 | command := &files.UpdateRefsCommand{ 260 | Config: config, 261 | GithubClient: client, 262 | TempBranch: temp, 263 | } 264 | 265 | exit := command.Run([]string{}) 266 | 267 | // Did we exit with a zero exit code? 268 | if !assert.Equal(t, 0, exit) { 269 | require.Fail(t, mockUI.ErrorWriter.String()) 270 | } 271 | 272 | // Make some assertions about the UI output 273 | output := mockUI.OutputWriter.String() 274 | assert.Contains(t, output, fmt.Sprintf("Successfully cloned repo into local dir: repo=%s dir=", repo)) 275 | assert.Contains(t, output, fmt.Sprintf("Retrieved HEAD commit of branch: branch=%s", temp)) 276 | assert.Contains(t, output, "Finding and replacing all references from base to target in dir") 277 | assert.Contains(t, output, "Finding and replacing all references from base to target in dir") 278 | assert.Contains(t, output, "Running `git add") 279 | assert.Contains(t, output, "Committing changes") 280 | assert.Contains(t, output, fmt.Sprintf("Pushing commit to remote: branch=%s sha=", temp)) 281 | assert.Contains(t, output, fmt.Sprintf("Creating PR to merge changes from branch into target: branch=%s target=%s", temp, target)) 282 | assert.Contains(t, output, fmt.Sprintf("Success! Review and merge the open PR: url=https://github.com/%s/%s/pull/", owner, repo)) 283 | 284 | // Extract pull request URL from output 285 | scheme := fmt.Sprintf(`(https:\/\/github.com\/%s\/%s\/pull\/)\d*`, owner, repo) 286 | r, err := regexp.Compile(scheme) 287 | if err != nil { 288 | t.Errorf("REGEX pattern did not compile: %v", err) 289 | } 290 | url := r.FindString(output) 291 | i.SetURL(url) 292 | } 293 | 294 | // Test_MergePullRequest merges the pull request created in TestUpdateRefs() 295 | func Test_MergePullRequest(t *testing.T) { 296 | defer seq()() 297 | pullRequestURL := i.GetURL() 298 | mockUI := cli.NewMockUi() 299 | owner, repo, token, base, target, _, _ := i.GetVals() 300 | args := []string{"", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 301 | 302 | // Parse and validate cmd line flags and env vars 303 | config, err := config.ParseAndValidate(args, mockUI) 304 | require.NoError(t, err) 305 | 306 | // Extract pull request number from URL 307 | r, err := regexp.Compile(`[-]?\d[\d,]*[\.]?[\d{2}]*`) 308 | require.NoError(t, err) 309 | 310 | result := r.FindString(pullRequestURL) 311 | prNumber, err := strconv.Atoi(result) 312 | require.NoError(t, err) 313 | 314 | client, err := gh.NewBaseGithubInteractor(token) 315 | require.NoError(t, err) 316 | 317 | command := &pulls.MergeCommand{ 318 | Config: config, 319 | GithubClient: client, 320 | PullNumber: prNumber, 321 | } 322 | 323 | exit := command.Run([]string{}) 324 | 325 | // Did we exit with a zero exit code? 326 | if !assert.Equal(t, 0, exit) { 327 | require.Fail(t, mockUI.ErrorWriter.String()) 328 | } 329 | 330 | // Make some assertions about the UI output 331 | output := mockUI.OutputWriter.String() 332 | assert.Contains(t, output, fmt.Sprintf("Successfully merged PR: number=%d", prNumber)) 333 | } 334 | 335 | // Test_CreateOpenPullRequest finds and replaces all references of 'master' to 'master-clone' 336 | // in the given CI files, and pushes the changes to 'my-fancy-branch' branch + opens a PR. 337 | // This will let us test that we can successfully update the base branch of an open PR 338 | func Test_CreateOpenPullRequest(t *testing.T) { 339 | defer seq()() 340 | mockUI := cli.NewMockUi() 341 | owner, repo, token, base, _, _, random := i.GetVals() 342 | args := []string{"updateRefs", "--owner", owner, "--repo", repo, "--base", "master", "--target", base, "--token", token} 343 | 344 | // Parse and validate cmd line flags and env vars 345 | config, err := config.ParseAndValidate(args, mockUI) 346 | require.NoError(t, err) 347 | 348 | client, err := gh.NewBaseGithubInteractor(token) 349 | require.NoError(t, err) 350 | 351 | command := &files.UpdateRefsCommand{ 352 | Config: config, 353 | GithubClient: client, 354 | TempBranch: random, 355 | } 356 | 357 | exit := command.Run([]string{}) 358 | 359 | // Did we exit with a zero exit code? 360 | if !assert.Equal(t, 0, exit) { 361 | require.Fail(t, mockUI.ErrorWriter.String()) 362 | } 363 | 364 | // Make some assertions about the UI output 365 | output := mockUI.OutputWriter.String() 366 | assert.Contains(t, output, fmt.Sprintf("Successfully cloned repo into local dir: repo=%s dir=", repo)) 367 | assert.Contains(t, output, fmt.Sprintf("Retrieved HEAD commit of branch: branch=%s", random)) 368 | assert.Contains(t, output, "Finding and replacing all references from base to target in dir") 369 | assert.Contains(t, output, "Running `git add") 370 | assert.Contains(t, output, "Committing changes") 371 | assert.Contains(t, output, fmt.Sprintf("Pushing commit to remote: branch=%s sha=", random)) 372 | assert.Contains(t, output, fmt.Sprintf("Creating PR to merge changes from branch into target: branch=%s target=%s", random, "master")) 373 | assert.Contains(t, output, fmt.Sprintf("Success! Review and merge the open PR: url=https://github.com/%s/%s/pull/", owner, repo)) 374 | 375 | // Extract pull request URL from output 376 | scheme := fmt.Sprintf(`(https:\/\/github.com\/%s\/%s\/pull\/)\d*`, owner, repo) 377 | r, err := regexp.Compile(scheme) 378 | if err != nil { 379 | t.Errorf("REGEX pattern did not compile: %v", err) 380 | } 381 | url := r.FindString(output) 382 | i.SetURL(url) 383 | } 384 | 385 | // Test_UpdateOpenPullRequests updates the base of the open pull request 386 | // created by TestCreateOpenPullRequest() 387 | func Test_UpdateOpenPullRequests(t *testing.T) { 388 | defer seq()() 389 | mockUI := cli.NewMockUi() 390 | owner, repo, token, base, target, _, _ := i.GetVals() 391 | args := []string{"updatePulls", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 392 | 393 | // Parse and validate cmd line flags and env vars 394 | config, err := config.ParseAndValidate(args, mockUI) 395 | require.NoError(t, err) 396 | 397 | client, err := gh.NewBaseGithubInteractor(token) 398 | require.NoError(t, err) 399 | 400 | command := &pulls.UpdateCommand{ 401 | Config: config, 402 | GithubClient: client, 403 | } 404 | 405 | exit := command.Run([]string{}) 406 | 407 | // Did we exit with a zero exit code? 408 | if !assert.Equal(t, 0, exit) { 409 | require.Fail(t, mockUI.ErrorWriter.String()) 410 | } 411 | 412 | // Make some assertions about the UI output 413 | output := mockUI.OutputWriter.String() 414 | assert.Contains(t, output, fmt.Sprintf("Getting all open PR's targeting the branch: base=%s", base)) 415 | assert.Contains(t, output, fmt.Sprintf("Retrieved all open PR's targeting the branch: base=%s", base)) 416 | assert.Contains(t, output, fmt.Sprintf("Successfully updated base branch of PR to target: base=%s target=%s", base, target)) 417 | assert.Contains(t, output, "Success!") 418 | } 419 | 420 | // Test_CloseOpenPullRequest closes the pull request created in TestUpdateOpenPullRequests() to cleanup 421 | func Test_CloseOpenPullRequest(t *testing.T) { 422 | defer seq()() 423 | pullRequestURL := i.GetURL() 424 | mockUI := cli.NewMockUi() 425 | owner, repo, token, base, target, _, _ := i.GetVals() 426 | args := []string{"", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 427 | 428 | // Parse and validate cmd line flags and env vars 429 | config, err := config.ParseAndValidate(args, mockUI) 430 | require.NoError(t, err) 431 | 432 | // Extract pull request number from URL 433 | r, err := regexp.Compile(`[-]?\d[\d,]*[\.]?[\d{2}]*`) 434 | require.NoError(t, err) 435 | 436 | result := r.FindString(pullRequestURL) 437 | prNumber, err := strconv.Atoi(result) 438 | require.NoError(t, err) 439 | 440 | client, err := gh.NewBaseGithubInteractor(token) 441 | if err != nil { 442 | t.Errorf("Failed to create client due to error: %v", err) 443 | } 444 | 445 | command := &pulls.CloseCommand{ 446 | Config: config, 447 | GithubClient: client, 448 | PullNumber: prNumber, 449 | } 450 | 451 | exit := command.Run([]string{}) 452 | 453 | // Did we exit with a zero exit code? 454 | if !assert.Equal(t, 0, exit) { 455 | require.Fail(t, mockUI.ErrorWriter.String()) 456 | } 457 | 458 | // Make some assertions about the UI output 459 | output := mockUI.OutputWriter.String() 460 | assert.Contains(t, output, "Successfully closed PR: number=") 461 | } 462 | 463 | // Test_CreateBaseBranchProtection copies the branch protection from 'master' to 'master-clone' 464 | func Test_CreateBaseBranchProtection(t *testing.T) { 465 | defer seq()() 466 | mockUI := cli.NewMockUi() 467 | owner, repo, token, base, _, _, _ := i.GetVals() 468 | args := []string{"", "--owner", owner, "--repo", repo, "--base", base, "--token", token} 469 | 470 | // Parse and validate cmd line flags and env vars 471 | config, err := config.ParseAndValidate(args, mockUI) 472 | require.NoError(t, err) 473 | 474 | client, err := gh.NewBaseGithubInteractor(token) 475 | require.NoError(t, err) 476 | 477 | c := &branches.UpdateCommand{ 478 | Config: config, 479 | GithubClient: client, 480 | } 481 | 482 | err = branches.CopyBranchProtection(c, "master", base) 483 | require.NoError(t, err) 484 | 485 | output := mockUI.OutputWriter.String() 486 | assert.Contains(t, output, "Getting branch protection for branch: branch=master") 487 | assert.Contains(t, output, fmt.Sprintf("Creating the branch protection request for branch: branch=%s", base)) 488 | assert.Contains(t, output, fmt.Sprintf("Updating the branch protection on branch: branch=%s", base)) 489 | } 490 | 491 | // Test_UpdateDefaultBranch updates the default branch in the repo from 'master' to 'main' 492 | func Test_UpdateDefaultBranch(t *testing.T) { 493 | defer seq()() 494 | mockUI := cli.NewMockUi() 495 | owner, repo, token, base, target, _, _ := i.GetVals() 496 | args := []string{"updateDefault", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 497 | 498 | // Parse and validate cmd line flags and env vars 499 | config, err := config.ParseAndValidate(args, mockUI) 500 | require.NoError(t, err) 501 | 502 | client, err := gh.NewBaseGithubInteractor(token) 503 | require.NoError(t, err) 504 | 505 | command := &branches.UpdateCommand{ 506 | Config: config, 507 | GithubClient: client, 508 | } 509 | 510 | exit := command.Run([]string{}) 511 | 512 | // Did we exit with a zero exit code? 513 | if !assert.Equal(t, 0, exit) { 514 | require.Fail(t, mockUI.ErrorWriter.String()) 515 | } 516 | 517 | // Make some assertions about the UI output 518 | output := mockUI.OutputWriter.String() 519 | assert.Contains(t, output, fmt.Sprintf("Updating the default branch to target: repo=%s base=%s target=%s", repo, base, target)) 520 | assert.Contains(t, output, fmt.Sprintf("Attempting to apply the base branch protection to target: base=%s target=%s", base, target)) 521 | assert.Contains(t, output, fmt.Sprintf("Getting branch protection for branch: branch=%s", base)) 522 | assert.Contains(t, output, fmt.Sprintf("Creating the branch protection request for branch: branch=%s", target)) 523 | assert.Contains(t, output, fmt.Sprintf("Updating the branch protection on branch: branch=%s", target)) 524 | assert.Contains(t, output, "Success!") 525 | } 526 | 527 | // Test_DeleteTestBranches deletes the test branches we created in this test suite (including their branch protections) 528 | func Test_DeleteTestBranches(t *testing.T) { 529 | defer seq()() 530 | mockUI := cli.NewMockUi() 531 | owner, repo, token, base, _, temp, random := i.GetVals() 532 | args := []string{"deleteBranches", "--owner", owner, "--repo", repo, "--base", base, "--token", token} 533 | list := []string{temp, random, "master"} 534 | 535 | // Parse and validate cmd line flags and env vars 536 | config, err := config.ParseAndValidate(args, mockUI) 537 | require.NoError(t, err) 538 | 539 | client, err := gh.NewBaseGithubInteractor(token) 540 | require.NoError(t, err) 541 | 542 | command := &branches.DeleteCommand{ 543 | Config: config, 544 | GithubClient: client, 545 | BranchesList: list, 546 | } 547 | 548 | exit := command.Run([]string{}) 549 | 550 | // Did we exit with a zero exit code? 551 | if !assert.Equal(t, 0, exit) { 552 | require.Fail(t, mockUI.ErrorWriter.String()) 553 | } 554 | 555 | // Make some assertions about the UI output 556 | output := mockUI.OutputWriter.String() 557 | for _, branch := range list { 558 | assert.Contains(t, output, fmt.Sprintf("Attempting to remove branch protection from branch: branch=%s", branch)) 559 | assert.Contains(t, output, fmt.Sprintf("Attempting to delete branch: branch=%s", branch)) 560 | assert.Contains(t, output, fmt.Sprintf("Success! branch has been deleted: branch=%s", branch)) 561 | } 562 | } 563 | 564 | // Test_DeleteRepo deletes the repo that was created in this test suite 565 | func Test_DeleteRepo(t *testing.T) { 566 | defer seq()() 567 | mockUI := cli.NewMockUi() 568 | owner, repo, token, base, target, _, _ := i.GetVals() 569 | args := []string{"", "--owner", owner, "--repo", repo, "--base", base, "--target", target, "--token", token} 570 | 571 | // Parse and validate cmd line flags and env vars 572 | config, err := config.ParseAndValidate(args, mockUI) 573 | require.NoError(t, err) 574 | 575 | client, err := gh.NewBaseGithubInteractor(token) 576 | require.NoError(t, err) 577 | 578 | command := &repos.DeleteCommand{ 579 | Config: config, 580 | GithubClient: client, 581 | Repo: repo, 582 | } 583 | 584 | exit := command.Run([]string{}) 585 | 586 | // Did we exit with a zero exit code? 587 | if !assert.Equal(t, 0, exit) { 588 | require.Fail(t, mockUI.ErrorWriter.String()) 589 | } 590 | 591 | // Make some assertions about the UI output 592 | output := mockUI.OutputWriter.String() 593 | assert.Contains(t, output, fmt.Sprintf("Deleting repo for user: repo=%s user=%s", repo, owner)) 594 | assert.Contains(t, output, fmt.Sprintf("Successfully deleted repo: repo=%s", repo)) 595 | } 596 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | version "github.com/hashicorp/go-version" 5 | ) 6 | 7 | // Version is the current version of inclusify. 8 | var Version = "0.2.1" 9 | 10 | // SemVer is an instance of version.Version. This has the secondary 11 | // benefit of verifying during tests and init time that our version is a 12 | // proper semantic version, which should always be the case. 13 | var SemVer *version.Version 14 | 15 | func init() { 16 | SemVer = version.Must(version.NewVersion(Version)) 17 | } 18 | 19 | // String returns the complete version string 20 | func String() string { 21 | return SemVer.String() 22 | } 23 | -------------------------------------------------------------------------------- /release.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go test ./... 4 | 5 | builds: 6 | # Build, sign, and notarize the darwin and windows packages 7 | - id: signable 8 | mod_timestamp: '{{ .CommitTimestamp }}' 9 | targets: 10 | - darwin_amd64 11 | - windows_386 12 | - windows_amd64 13 | hooks: 14 | post: | 15 | docker run 16 | -e ARTIFACTORY_TOKEN={{ .Env.ARTIFACTORY_TOKEN }} 17 | -e ARTIFACTORY_USER={{ .Env.ARTIFACTORY_USER }} 18 | -e CIRCLE_TOKEN={{ .Env.CIRCLE_TOKEN }} 19 | -v {{ dir .Path }}:/workdir 20 | {{ .Env.CODESIGN_IMAGE }} 21 | sign -product-name={{ .ProjectName }} {{ .Name }} 22 | dir: ./cmd/inclusify/ 23 | flags: 24 | - -trimpath 25 | ldflags: 26 | - -X main.GitCommit={{ .Commit }} 27 | # Build the linux packages 28 | - mod_timestamp: '{{ .CommitTimestamp }}' 29 | targets: 30 | - linux_386 31 | - linux_amd64 32 | dir: ./cmd/inclusify/ 33 | flags: 34 | - -trimpath 35 | ldflags: 36 | - -X main.GitCommit={{ .Commit }} 37 | 38 | archives: 39 | - format: zip 40 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 41 | files: 42 | - none* 43 | 44 | checksum: 45 | name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' 46 | algorithm: sha256 47 | 48 | signs: 49 | - args: ["-u", "{{ .Env.PGP_KEY_ID }}", "--output", "${signature}", "--detach-sign", "${artifact}"] 50 | artifacts: checksum 51 | 52 | changelog: 53 | skip: true 54 | --------------------------------------------------------------------------------