├── .devcontainer └── devcontainer.json ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── pull_request_template.md ├── renovate.json └── workflows │ ├── build.yaml │ ├── codeql-analysis.yml │ ├── coverage.yaml │ ├── docs.yaml │ ├── fuzz.yaml │ ├── goreleaser-check.yaml │ ├── linting.yaml │ ├── readme.yaml │ ├── release.yaml │ ├── scorecards.yml │ ├── test-install.yaml │ └── test.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── cmd ├── cmd-close.go ├── cmd-merge.go ├── cmd-print.go ├── cmd-root.go ├── cmd-run.go ├── cmd-status.go ├── cmd-version.go ├── command.go ├── config.go ├── flags.go ├── git.go ├── logging.go ├── other.go └── platform.go ├── docs ├── README.template.md └── img │ ├── demo.gif │ ├── fa │ ├── code-merge.svg │ ├── print.svg │ ├── rabbit-fast.svg │ ├── tasks.svg │ └── times-hexagon.svg │ ├── logo-dark-mode.svg │ └── logo.svg ├── examples ├── README.md ├── general │ ├── clone.sh │ ├── replace-file-content.sh │ └── replace.sh ├── go │ ├── empty-interface-to-any.sh │ ├── ioutil.sh │ ├── linting.sh │ ├── module-update.sh │ └── upgrade-go-version.sh └── node │ ├── package-update.sh │ └── replace.js ├── go.mod ├── go.sum ├── install.sh ├── internal ├── git │ ├── changes.go │ ├── cmdgit │ │ └── git.go │ ├── commit.go │ ├── git.go │ └── gogit │ │ └── git.go ├── http │ └── logging.go ├── log │ ├── censor-formatter.go │ ├── censor-formatter_test.go │ └── default-censors.go ├── multigitter │ ├── close.go │ ├── cmd.go │ ├── logger │ │ └── logger.go │ ├── merge.go │ ├── print.go │ ├── repocounter │ │ ├── counter.go │ │ └── counter_test.go │ ├── run.go │ ├── shared.go │ ├── status.go │ └── terminal │ │ └── terminal.go └── scm │ ├── README.md │ ├── bitbucketcloud │ ├── bitbucket_cloud.go │ └── custom_structs.go │ ├── bitbucketserver │ ├── bitbucket_server.go │ ├── pullrequest.go │ └── repository.go │ ├── changes.go │ ├── gitea │ ├── gitea.go │ ├── pullrequest.go │ ├── repository.go │ └── util.go │ ├── github │ ├── commit.go │ ├── github.go │ ├── github_test.go │ ├── graphql.go │ ├── graphql_test.go │ ├── pullrequest.go │ ├── pullrequest_test.go │ ├── repository.go │ ├── retry.go │ ├── retry_test.go │ ├── util.go │ └── util_test.go │ ├── gitlab │ ├── gitlab.go │ ├── gitlab_test.go │ ├── pullrequest.go │ └── repository.go │ ├── pullrequest.go │ ├── repository.go │ ├── util.go │ └── util_test.go ├── main.go ├── tests ├── fuzzing_test.go ├── print_test.go ├── repo_helper_test.go ├── scripts │ ├── adder │ │ └── main.go │ ├── changer │ │ └── main.go │ ├── printer │ │ └── main.go │ ├── pwd │ │ └── main.go │ └── remover │ │ └── main.go ├── setup_test.go ├── story_test.go ├── table_test.go ├── test-config.yaml └── vcmock │ └── vcmock.go ├── tools ├── completions.sh ├── docs │ └── main.go └── readme-docs │ └── main.go └── version.txt /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-gitter development", 3 | "customizations": { 4 | "codespaces": { 5 | "openFiles": [ 6 | "CONTRIBUTING.md" 7 | ] 8 | }, 9 | "vscode": { 10 | "extensions": [ 11 | "golang.go" 12 | ], 13 | "settings": { 14 | "go.lintTool": "golangci-lint" 15 | } 16 | } 17 | }, 18 | "image": "mcr.microsoft.com/vscode/devcontainers/go:1", 19 | "updateContentCommand": "go get" 20 | } 21 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Bitbucket server 2 | /internal/scm/bitbucketserver @ryancurrah 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Have an organization that '....' 16 | 2. Run `multi-gitter run -xxx` 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | It is for example often useful to include detailed logs from a run with `--log-level=trace`. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: A new feature or proposed changed behavior 4 | title: 'Feature request: ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **The feature request** 11 | 12 | 13 | **My use case** 14 | 15 | 16 | **Implementation** 17 | 18 | - [ ] I would like to contribute this feature if it's a suitable addition to multi-gitter 19 | - [ ] I have no intention of adding this feature myself. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question / Support 3 | about: Please ask questions under Discussions instead 4 | title: "Please don't create an issue to ask a question, open a discussion instead." 5 | labels: 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please don't create an Issue to ask a question. Open a Discussion instead: https://github.com/lindell/multi-gitter/discussions 11 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # What does this change 2 | _Give a summary of the change, and how it affects end-users. It's okay to copy/paste your commit messages._ 3 | 4 | _For example if it introduces a new flag or modifies a commands output, give an example of you running the command and showing real output here._ 5 | 6 | # What issue does it fix 7 | Closes # _(issue)_ 8 | 9 | _If there is not an existing issue, please make sure we have context on why this change is needed. ._ 10 | 11 | # Notes for the reviewer 12 | _Put any questions or notes for the reviewer here._ 13 | 14 | # Checklist 15 | - [ ] Made sure the PR follows the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines 16 | - [ ] Tests if something new is added 17 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "dependencyDashboard": true, 4 | "enabledManagers": [ 5 | "github-actions", 6 | "gomod" 7 | ], 8 | "semanticCommitType": "dep", 9 | "semanticCommitScope": "", 10 | "vulnerabilityAlerts": { 11 | "labels": [ 12 | "security" 13 | ] 14 | }, 15 | "minimumReleaseAge": "3 days", 16 | "packageRules": [ 17 | { 18 | "description": "Update non-major Github Actions releases monthly in group", 19 | "groupName": "all non-major Github Actions", 20 | "matchManagers": [ 21 | "github-actions" 22 | ], 23 | "matchUpdateTypes": [ 24 | "minor", 25 | "patch", 26 | "pin", 27 | "pinDigest", 28 | "digest" 29 | ], 30 | "schedule": [ 31 | "on the first day of the month also on the 2nd day of the month before 5pm" 32 | ] 33 | }, 34 | { 35 | "description": "Require an approval for major Github Actions releases", 36 | "matchManagers": [ 37 | "github-actions" 38 | ], 39 | "matchUpdateTypes": [ 40 | "major" 41 | ], 42 | "dependencyDashboardApproval": true 43 | }, 44 | { 45 | "description": "Update non-major Go modules releases monthly and merge automatically", 46 | "matchManagers": [ 47 | "gomod" 48 | ], 49 | "matchUpdateTypes": [ 50 | "minor", 51 | "patch", 52 | "digest" 53 | ], 54 | "automerge": true, 55 | "automergeType": "branch", 56 | "schedule": [ 57 | "on the first day of the month also on the 2nd day of the month before 5pm" 58 | ] 59 | }, 60 | { 61 | "description": "Update major Go modules releases monthly", 62 | "matchManagers": [ 63 | "gomod" 64 | ], 65 | "matchUpdateTypes": [ 66 | "major" 67 | ], 68 | "schedule": [ 69 | "on the first day of the month also on the 2nd day of the month before 5pm" 70 | ] 71 | } 72 | ], 73 | "prConcurrentLimit": 5, 74 | "prHourlyLimit": 3, 75 | "postUpdateOptions": [ 76 | "gomodUpdateImportPaths", 77 | "gomodTidy" 78 | ], 79 | "github-actions": { 80 | "enabled": true, 81 | "pinDigests": true, 82 | "semanticCommitType": "ci" 83 | }, 84 | "gomod": { 85 | "enabled": true 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Building 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | build: 8 | name: Build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out code into the Go module directory 12 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 16 | with: 17 | go-version-file: "go.mod" 18 | 19 | - name: Build 20 | run: go build main.go 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ master ] 9 | schedule: 10 | - cron: '10 6 * * 2' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | analyze: 17 | permissions: 18 | actions: read # for github/codeql-action/init to get workflow details 19 | contents: read # for actions/checkout to fetch code 20 | security-events: write # for github/codeql-action/autobuild to send a status report 21 | name: Analyze 22 | runs-on: ubuntu-latest 23 | 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | language: [ 'go' ] 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | 33 | - name: Set up Go 34 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 35 | with: 36 | go-version-file: 'go.mod' 37 | 38 | # Initializes the CodeQL tools for scanning. 39 | - name: Initialize CodeQL 40 | uses: github/codeql-action/init@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2.28.1 41 | with: 42 | languages: ${{ matrix.language }} 43 | 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2.28.1 46 | 47 | - name: Perform CodeQL Analysis 48 | uses: github/codeql-action/analyze@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2.28.1 49 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Testing Coverage 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | jobs: 6 | build: 7 | name: Test and Coverage 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out code into the Go module directory 11 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 17 | with: 18 | go-version-file: "go.mod" 19 | 20 | - name: Prepare coverage 21 | run: mkdir coverage 22 | 23 | - name: Test 24 | run: SKIP_TYPES=time-dependent go test ./... -coverpkg=$( go list ./... | grep -v /tests | grep -v /tools | paste -sd "," -) -coverprofile coverage/coverage.out 25 | 26 | - name: Coverage convert 27 | uses: jandelgado/gcov2lcov-action@4e1989767862652e6ca8d3e2e61aabe6d43be28b # v1.1.1 28 | with: 29 | infile: coverage/coverage.out 30 | outfile: coverage/lcov.info 31 | 32 | - name: Coveralls report 33 | uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | name: Generate docs 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | permissions: 14 | contents: write # for Git to git push 15 | name: Release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Check out code into the Go module directory 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 23 | with: 24 | go-version-file: "go.mod" 25 | id: go 26 | 27 | - name: Generate docs 28 | run: go run ./tools/docs/main.go 29 | 30 | - name: Commit changes 31 | continue-on-error: true 32 | run: | 33 | git config user.email "johan@lindell.me" 34 | git config user.name "Automated docs generator" 35 | git checkout . 36 | git checkout docs -- 37 | mv tmp-docs/* ./ 38 | git add * 39 | git commit -m "Updated docs" 40 | 41 | - name: Push changes 42 | if: ${{ success() }} 43 | run: | 44 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY 45 | git push 46 | -------------------------------------------------------------------------------- /.github/workflows/fuzz.yaml: -------------------------------------------------------------------------------- 1 | name: Fuzzing 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | build: 8 | name: Fuzzing 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out code into the Go module directory 12 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 13 | 14 | - name: Set up Go 15 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 16 | with: 17 | go-version-file: "go.mod" 18 | 19 | - name: Fuzz 20 | run: go test ./tests -fuzz . -fuzztime=2m 21 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser-check.yaml: -------------------------------------------------------------------------------- 1 | name: Check GoReleaser config 2 | on: 3 | push: 4 | branches: 5 | - "release-please--**" 6 | - "renovate/all-non-major-github-actions" 7 | - master 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | verify-goreleaser: 14 | name: Check GoReleaser config 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 24 | with: 25 | go-version-file: "go.mod" 26 | 27 | - name: Check GoReleaser config 28 | uses: goreleaser/goreleaser-action@5742e2a039330cbb23ebf35f046f814d4c6ff811 # v5.1.0 29 | with: 30 | version: "~> v2" 31 | args: check 32 | 33 | - name: Build with GoReleaser 34 | uses: goreleaser/goreleaser-action@5742e2a039330cbb23ebf35f046f814d4c6ff811 # v5.1.0 35 | with: 36 | version: "~> v2" 37 | args: build --snapshot --clean 38 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: [pull_request] 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | golangci-lint: 8 | permissions: 9 | checks: write # for reviewdog/action-golangci-lint to report issues using checks 10 | contents: read # for actions/checkout to fetch code 11 | name: golangci-lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out code into the Go module directory 15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | - name: golangci-lint 17 | uses: reviewdog/action-golangci-lint@f9bba13753278f6a73b27a56a3ffb1bfda90ed71 # v2.8.0 18 | with: 19 | go_version_file: "go.mod" 20 | level: warning 21 | -------------------------------------------------------------------------------- /.github/workflows/readme.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | name: Generate readme 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | build: 13 | permissions: 14 | contents: write # for Git to git push 15 | name: Release 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Check out code into the Go module directory 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 23 | with: 24 | go-version-file: "go.mod" 25 | id: go 26 | 27 | - name: Generate readme 28 | run: go run ./tools/readme-docs/main.go 29 | 30 | - name: Commit changes 31 | continue-on-error: true 32 | run: | 33 | git checkout master 34 | git config user.email "github-actions[bot]@users.noreply.github.com" 35 | git config user.name "github-actions[bot]" 36 | git add README.md 37 | COAUTHOR=`git log -1 --pretty=format:'Co-authored-by: %an <%ae>'` 38 | git commit -m "docs: updated readme" -m "$COAUTHOR" 39 | 40 | - name: Push changes 41 | if: ${{ success() }} 42 | run: | 43 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY 44 | git push 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | 6 | permissions: 7 | contents: read 8 | 9 | name: release 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write # needed for release-please 15 | issues: write # needed for github-release-commenter 16 | pull-requests: write # needed for release-please and github-release-commenter 17 | steps: 18 | - uses: GoogleCloudPlatform/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3.7.13 19 | id: release 20 | with: 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | release-type: simple 23 | package-name: multi-gitter 24 | changelog-types: '[{"type":"feat","section":"Features","hidden":false},{"type":"fix","section":"Bug Fixes","hidden":false},{"type":"chore","section":"Miscellaneous","hidden":false},{"type":"dep","section":"Dependencies","hidden":false}]' 25 | 26 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | with: 28 | fetch-depth: 0 29 | if: ${{ steps.release.outputs.release_created }} 30 | 31 | - name: Set up Go 32 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 33 | with: 34 | go-version-file: "go.mod" 35 | if: ${{ steps.release.outputs.release_created }} 36 | 37 | - name: Import GPG key for signing 38 | id: gpg 39 | run: | 40 | echo "${GPG_PRIVATE_KEY}" | gpg --import 41 | fingerprint=$(echo "${GPG_PRIVATE_KEY}" | gpg --show-keys --with-colons | awk -F ":" '$1=="fpr" {print $10}') 42 | echo "fingerprint=$fingerprint" >> $GITHUB_OUTPUT 43 | env: 44 | GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} 45 | - name: Run GoReleaser 46 | uses: goreleaser/goreleaser-action@5742e2a039330cbb23ebf35f046f814d4c6ff811 # v5.1.0 47 | with: 48 | version: "~> v2" 49 | args: release --clean 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GENERAL_GITHUB_SECRET }} # The tokens needs access to another repo, so the secret.GITHUB_SECRET won't suffice 52 | FURY_TOKEN: ${{ secrets.FURY_TOKEN }} # Gemfury token 53 | GPG_FINGERPRINT: ${{ steps.gpg.outputs.fingerprint }} # Fingerprint of GPG signing key 54 | if: ${{ steps.release.outputs.release_created }} 55 | 56 | - name: Comment on prs and issues 57 | uses: apexskier/github-release-commenter@3bd413ad5e1d603bfe2282f9f06f2bdcec079327 # v1.3.6 58 | with: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | comment-template: Included in release {release_link} 🎉 61 | skip-label: "autorelease: tagged,autorelease: pending" 62 | if: ${{ steps.release.outputs.release_created }} 63 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | name: OpenSSF Scorecard 2 | 3 | on: 4 | schedule: 5 | - cron: "20 7 * * 2" 6 | push: 7 | branches: ["master"] 8 | 9 | permissions: read-all 10 | 11 | jobs: 12 | analysis: 13 | name: Scorecard analysis 14 | runs-on: ubuntu-latest 15 | permissions: 16 | # Needed to upload the results to code-scanning dashboard. 17 | security-events: write 18 | # Needed to publish results and get a badge (see publish_results below). 19 | id-token: write 20 | contents: read 21 | actions: read 22 | 23 | steps: 24 | - name: "Checkout code" 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | with: 27 | persist-credentials: false 28 | 29 | - name: "Run analysis" 30 | uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 31 | with: 32 | results_file: results.sarif 33 | results_format: sarif 34 | publish_results: true 35 | 36 | # Upload the results as artifacts. 37 | - name: "Upload artifact" 38 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 39 | with: 40 | name: SARIF file 41 | path: results.sarif 42 | retention-days: 5 43 | 44 | # Upload the results to GitHub's code scanning dashboard. 45 | - name: "Upload to code-scanning" 46 | uses: github/codeql-action/upload-sarif@b8d3b6e8af63cde30bdc382c0bc28114f4346c88 # v2.28.1 47 | with: 48 | sarif_file: results.sarif 49 | -------------------------------------------------------------------------------- /.github/workflows/test-install.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test install.sh 3 | 4 | on: 5 | pull_request: 6 | paths: 7 | - .github/workflows/test-install.yaml 8 | - install.sh 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test-install-sh: 15 | name: Test install.sh 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | - name: Install multi-gitter 24 | env: 25 | BINDIR: ${{ github.workspace }}/bin 26 | FULL_NAME: ${{ github.event.pull_request.head.repo.full_name }} 27 | REF: ${{ github.head_ref }} 28 | run: | 29 | curl -s https://raw.githubusercontent.com/$FULL_NAME/$REF/install.sh | sh -s -- -d 30 | echo "$BINDIR" >> $GITHUB_PATH 31 | shell: sh 32 | - name: Print version 33 | run: multi-gitter version 34 | shell: sh 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | on: [push, pull_request] 3 | permissions: 4 | contents: read 5 | 6 | jobs: 7 | build: 8 | name: Test 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: 13 | - macos-latest 14 | - ubuntu-latest 15 | - windows-latest 16 | steps: 17 | - name: Check out code into the Go module directory 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 22 | with: 23 | go-version-file: "go.mod" 24 | 25 | # Because of a bug in go-git that that makes cloning of a folder in another Windows drive letter, 26 | # the test has to be moved and run in another folder on Windows (until the bug is fixed) 27 | # https://github.com/go-git/go-git/issues/247 28 | - name: Make sure the Windows test is run on the correct drive 29 | if: matrix.os == 'windows-latest' 30 | run: copy-item -Path "." -Destination "$env:temp\multi-gitter" -Recurse 31 | - name: Test (Windows) 32 | if: matrix.os == 'windows-latest' 33 | run: $env:SKIP_TYPES='time-dependent'; cd $env:temp\multi-gitter; go test ./... -v 34 | 35 | - name: Test (Not Windows) 36 | if: matrix.os != 'windows-latest' 37 | run: SKIP_TYPES=time-dependent go test ./... -v 38 | 39 | - name: Build 40 | run: go build main.go 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | 17 | main 18 | 19 | tmp-docs/ 20 | 21 | dist/ 22 | 23 | coverage/ 24 | 25 | completions/ 26 | 27 | config.yml 28 | 29 | # Editor specific files 30 | .vscode/ 31 | .idea/ 32 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | default: none 5 | enable: 6 | - bodyclose 7 | - dupl 8 | - errcheck 9 | - goconst 10 | - gocritic 11 | - gocyclo 12 | - gosec 13 | - govet 14 | - ineffassign 15 | - lll 16 | - misspell 17 | - revive 18 | - rowserrcheck 19 | - staticcheck 20 | - unconvert 21 | - unparam 22 | - unused 23 | - whitespace 24 | settings: 25 | dupl: 26 | threshold: 200 27 | goconst: 28 | min-len: 2 29 | min-occurrences: 3 30 | gocritic: 31 | disabled-tags: 32 | - opinionated 33 | disabled-checks: 34 | - ifElseChain 35 | gocyclo: 36 | min-complexity: 25 37 | govet: 38 | disable: 39 | - composites 40 | lll: 41 | line-length: 200 42 | misspell: 43 | ignore-rules: 44 | - statuser 45 | exclusions: 46 | generated: lax 47 | presets: 48 | - comments 49 | - common-false-positives 50 | - legacy 51 | - std-error-handling 52 | rules: 53 | - linters: 54 | - dupl 55 | - errcheck 56 | - exportloopref 57 | - funlen 58 | - gocyclo 59 | - gosec 60 | - lll 61 | - unparam 62 | path: _test\.go 63 | # The cmd package contains a lot of descriptions that must be on a single long line 64 | - linters: 65 | - funlen 66 | - lll 67 | path: cmd/ 68 | - linters: 69 | - gosec 70 | path: tools/ 71 | # at least one file in a package should have a package comment (stylecheck) 72 | - path: (.+)\.go$ 73 | text: 'ST1000:' 74 | # rule that command runs must be hard coded 75 | - path: (.+)\.go$ 76 | text: 'G204:' 77 | paths: 78 | - third_party$ 79 | - builtin$ 80 | - examples$ 81 | formatters: 82 | enable: 83 | - gofmt 84 | exclusions: 85 | generated: lax 86 | paths: 87 | - third_party$ 88 | - builtin$ 89 | - examples$ 90 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | before: 3 | hooks: 4 | - go mod download 5 | - ./tools/completions.sh 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | ldflags: "-s -w -X main.version={{.Version}} -X main.commit={{.FullCommit}} -X main.date={{.CommitDate}}" 10 | goos: 11 | - darwin 12 | - linux 13 | - windows 14 | goarch: 15 | - 386 16 | - amd64 17 | - arm 18 | - arm64 19 | ignore: 20 | - goos: darwin 21 | goarch: arm 22 | archives: 23 | - name_template: >- 24 | {{ .ProjectName }}_ 25 | {{- .Version }}_ 26 | {{- title .Os }}_ 27 | {{- if eq .Arch "amd64" }}x86_64 28 | {{- else if eq .Arch "386" }}i386 29 | {{- else if eq .Arch "arm" }}ARM 30 | {{- else if eq .Arch "arm64" }}ARM64 31 | {{- else }}{{ .Arch }}{{ end }} 32 | files: 33 | - README.md 34 | - LICENSE 35 | - completions/* 36 | nfpms: 37 | - file_name_template: "{{ .ConventionalFileName }}" 38 | id: packages 39 | description: Update multiple repositories in bulk 40 | maintainer: Johan Lindell 41 | license: Apache-2.0 42 | contents: 43 | - src: ./completions/multi-gitter.bash 44 | dst: /etc/bash_completion.d/multi-gitter 45 | - src: ./completions/multi-gitter.fish 46 | dst: /usr/share/fish/completions/multi-gitter.fish 47 | - src: ./completions/multi-gitter.zsh 48 | dst: /usr/local/share/zsh/site-functions/_multi-gitter 49 | formats: 50 | - deb 51 | - rpm 52 | recommends: 53 | - git 54 | checksum: 55 | name_template: "checksums.txt" 56 | snapshot: 57 | version_template: "{{ .Tag }}-next" 58 | changelog: 59 | sort: asc 60 | filters: 61 | exclude: 62 | - "^docs:" 63 | - "^test:" 64 | brews: 65 | - name: multi-gitter 66 | repository: 67 | owner: lindell 68 | name: homebrew-multi-gitter 69 | goarm: 6 70 | commit_author: 71 | name: Johan Lindell 72 | email: johan@lindell.me 73 | description: "Update multiple repositories in bulk" 74 | homepage: https://github.com/lindell/multi-gitter 75 | license: "Apache-2.0" 76 | directory: Formula 77 | install: |- 78 | bin.install "multi-gitter" 79 | bash_completion.install "completions/multi-gitter.bash" => "multi-gitter" 80 | zsh_completion.install "completions/multi-gitter.zsh" => "_multi-gitter" 81 | fish_completion.install "completions/multi-gitter.fish" 82 | test: | 83 | system "#{bin}/multi-gitter", "version" 84 | publishers: 85 | - name: fury.io 86 | ids: 87 | - packages 88 | dir: "{{ dir .ArtifactPath }}" 89 | cmd: curl -F package=@{{ .ArtifactName }} https://{{ .Env.FURY_TOKEN }}@push.fury.io/multi-gitter/ 90 | signs: 91 | - artifacts: checksum 92 | args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"] 93 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | johan@lindell.me. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing Guide 2 | ---- 3 | 4 | We welcome contributions in multiple forms. This file describes some of the ways how you can help out in various ways. 5 | 6 | ## Bug fixes or new feature 7 | 8 | You would like to help out with code? Great! Before you get started, please read the following guidelines: 9 | 10 | ### General guidelines for Pull Requests 11 | 12 | 💬 Create a ticket discussing the change and get a confirmation that this change will be merged if built before starting your pull request. This is true for most pull requests, except for small changes such as typos. 13 | 14 | 🚧 Clearly state the state of you pull requests. If the PR is not finished but you want feedback. Mark it as a draft PR. 15 | 16 | 1️⃣ Limit the pull requests to solve one specific task. 17 | 18 | ⬇️ Create a new commit for any changes after the pull request was created. Changes will be squashed once merged. But to make the review process easier, never rewrite the history. 19 | 20 | ### Get started coding 21 | 22 | The only dependency needed for multi-gitter development is `go`. By forking and cloning the repo you are ready to go. You can run the code by replacing `multi-gitter` with `go run main.go`, for example: 23 | 24 | ```sh 25 | go run main.go run ./examples/general/replace.sh -R my-org/test-repo -m "Test message" 26 | ``` 27 | 28 | If you don't have a Go setup already, you can use a [GitHub codespace already configured to work with multi-gitter development.](https://github.com/codespaces/new/lindell/multi-gitter?resume=1). 29 | 30 | ### Test your code 31 | 32 | All tests can be run with `go test ./...`. These tests will also run on multiple platforms once you push it. 33 | 34 | ### Linting 35 | 36 | This project is linted with golangci-lint, and the [configuration file exist in the root of this project](/.golangci.yml). Linting is never perfect, and if something is not linting correctly, please discuss it in the PR. 37 | 38 | Some rules might seem superfluous, like comments on everything that is exported, but to encourage the addition of as many useful comments as possible, this repository imposes the rule that everything that is exported should have comments. 39 | 40 | ### Docs 41 | 42 | *Don't change the README.md file*. If you want to make changes to the README.md file. Please take a look in `./docs/README.template.md` since the README.md file is generated based on it. If you want to change something like the description of a flag, that change can be made directly in the code and docs will be updated automatically. 43 | 44 | ### Structure 45 | 46 | * **cmd**: This is where the CLI is created. Everything that has to do with parsing flags or similar is done here, but no actual execution logic. 47 | * **docs**: Documentation. 48 | * **examples**: Example scripts that can be used together with multi-gitter. 49 | * **internal** 50 | * **git**: All implementations of git. 51 | * **multigitter**: The main logic of multi-gitter. This is the code that glues everything together. 52 | * **scm**: Source control system implementations such as GitHub/GitLab/etc. 53 | * **test**: Integration tests. 54 | * **tools**: Tools for CI/CD or for development. 55 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the last stable version at any given point. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Vulnerabilities can be disclosed in private using 10 | [GitHub advisories](https://github.com/lindell/multi-gitter/security/advisories/new). 11 | -------------------------------------------------------------------------------- /cmd/cmd-close.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lindell/multi-gitter/internal/multigitter" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // CloseCmd closes pull requests 11 | func CloseCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "close", 14 | Short: "Close pull requests.", 15 | Long: "Close pull requests with a specified branch name in an organization and with specified conditions.", 16 | Args: cobra.NoArgs, 17 | PreRunE: logFlagInit, 18 | RunE: closeCMD, 19 | } 20 | 21 | cmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.") 22 | configurePlatform(cmd) 23 | configureRunPlatform(cmd, false) 24 | configureLogging(cmd, "-") 25 | configureConfig(cmd) 26 | 27 | return cmd 28 | } 29 | 30 | func closeCMD(cmd *cobra.Command, _ []string) error { 31 | flag := cmd.Flags() 32 | 33 | branchName, _ := flag.GetString("branch") 34 | 35 | vc, err := getVersionController(flag, true, false) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | statuser := multigitter.Closer{ 41 | VersionController: vc, 42 | 43 | FeatureBranch: branchName, 44 | } 45 | 46 | err = statuser.Close(context.Background()) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /cmd/cmd-merge.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lindell/multi-gitter/internal/multigitter" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // MergeCmd merges pull requests 11 | func MergeCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "merge", 14 | Short: "Merge pull requests.", 15 | Long: "Merge pull requests with a specified branch name in an organization and with specified conditions.", 16 | Args: cobra.NoArgs, 17 | PreRunE: logFlagInit, 18 | RunE: merge, 19 | } 20 | 21 | cmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.") 22 | cmd.Flags().StringSliceP("merge-type", "", []string{"merge", "squash", "rebase"}, 23 | "The type of merge that should be done (GitHub). Multiple types can be used as backup strategies if the first one is not allowed.") 24 | configurePlatform(cmd) 25 | configureRunPlatform(cmd, false) 26 | configureLogging(cmd, "-") 27 | configureConfig(cmd) 28 | 29 | return cmd 30 | } 31 | 32 | func merge(cmd *cobra.Command, _ []string) error { 33 | flag := cmd.Flags() 34 | 35 | branchName, _ := flag.GetString("branch") 36 | 37 | vc, err := getVersionController(flag, true, false) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | statuser := multigitter.Merger{ 43 | VersionController: vc, 44 | 45 | FeatureBranch: branchName, 46 | } 47 | 48 | err = statuser.Merge(context.Background()) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /cmd/cmd-print.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/pkg/errors" 11 | 12 | "github.com/lindell/multi-gitter/internal/multigitter" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | //nolint:lll 17 | const printHelp = ` 18 | This command will clone down multiple repositories. For each of those repositories, the script will be run in the context of that repository. The output of each script run in each repo will be printed, by default to stdout and stderr, but it can be configured to write to files as well. 19 | 20 | When the script is invoked, these environment variables are set: 21 | - REPOSITORY will be set to the name of the repository currently being executed 22 | ` 23 | 24 | // PrintCmd is the main command that runs a script for multiple repositories and print the output of each run 25 | func PrintCmd() *cobra.Command { 26 | cmd := &cobra.Command{ 27 | Use: "print [script path]", 28 | Short: "Clones multiple repositories, run a script in that directory, and prints the output of each run.", 29 | Long: printHelp, 30 | Args: cobra.ExactArgs(1), 31 | PreRunE: logFlagInit, 32 | RunE: printCMD, 33 | } 34 | 35 | cmd.Flags().IntP("concurrent", "C", 1, "The maximum number of concurrent runs.") 36 | cmd.Flags().StringP("error-output", "E", "-", `The file that the output of the script should be outputted to. "-" means stderr.`) 37 | cmd.Flags().StringP("clone-dir", "", "", "The temporary directory where the repositories will be cloned. If not set, the default os temporary directory will be used.") 38 | configureGit(cmd) 39 | configurePlatform(cmd) 40 | configureLogging(cmd, "") 41 | configureConfig(cmd) 42 | cmd.Flags().AddFlagSet(outputFlag()) 43 | 44 | return cmd 45 | } 46 | 47 | func printCMD(cmd *cobra.Command, _ []string) error { 48 | flag := cmd.Flags() 49 | 50 | concurrent, _ := flag.GetInt("concurrent") 51 | strOutput, _ := flag.GetString("output") 52 | strErrOutput, _ := flag.GetString("error-output") 53 | cloneDir, _ := flag.GetString("clone-dir") 54 | 55 | if concurrent < 1 { 56 | return errors.New("concurrent runs can't be less than one") 57 | } 58 | 59 | output, err := fileOutput(strOutput, os.Stdout) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | errOutput, err := fileOutput(strErrOutput, os.Stderr) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | vc, err := getVersionController(flag, true, true) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | gitCreator, err := getGitCreator(flag) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | executablePath, arguments, err := parseCommand(flag.Arg(0)) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | // Set up signal listening to cancel the context and let started runs finish gracefully 85 | ctx, cancel := context.WithCancel(context.Background()) 86 | c := make(chan os.Signal, 1) 87 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 88 | go func() { 89 | <-c 90 | fmt.Fprintln(os.Stderr, "Finishing up ongoing runs. Press CTRL+C again to abort now.") 91 | cancel() 92 | <-c 93 | os.Exit(1) 94 | }() 95 | 96 | printer := multigitter.Printer{ 97 | ScriptPath: executablePath, 98 | Arguments: arguments, 99 | 100 | VersionController: vc, 101 | 102 | Stdout: output, 103 | Stderr: errOutput, 104 | 105 | Concurrent: concurrent, 106 | CloneDir: cloneDir, 107 | 108 | CreateGit: gitCreator, 109 | } 110 | 111 | err = printer.Print(ctx) 112 | if err != nil { 113 | fmt.Fprintln(os.Stderr, err.Error()) 114 | os.Exit(1) 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /cmd/cmd-root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // RootCmd is the root command containing all subcommands 11 | func RootCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "multi-gitter", 14 | Short: "Multi gitter is a tool for making changes into multiple git repositories.", 15 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 16 | return initializeConfig(cmd) // Bind configs that are not flags 17 | }, 18 | } 19 | 20 | cmd.AddCommand(RunCmd()) 21 | cmd.AddCommand(StatusCmd()) 22 | cmd.AddCommand(MergeCmd()) 23 | cmd.AddCommand(CloseCmd()) 24 | cmd.AddCommand(PrintCmd()) 25 | cmd.AddCommand(VersionCmd()) 26 | 27 | return cmd 28 | } 29 | 30 | func init() { 31 | rand.Seed(time.Now().UTC().UnixNano()) 32 | } 33 | -------------------------------------------------------------------------------- /cmd/cmd-status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/lindell/multi-gitter/internal/multigitter" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // StatusCmd gets statuses of pull requests 12 | func StatusCmd() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "status", 15 | Short: "Get the status of pull requests.", 16 | Long: "Get the status of all pull requests with a specified branch name in an organization.", 17 | Args: cobra.NoArgs, 18 | PreRunE: logFlagInit, 19 | RunE: status, 20 | } 21 | 22 | cmd.Flags().StringP("branch", "B", "multi-gitter-branch", "The name of the branch where changes are committed.") 23 | configurePlatform(cmd) 24 | configureRunPlatform(cmd, false) 25 | configureLogging(cmd, "-") 26 | configureConfig(cmd) 27 | cmd.Flags().AddFlagSet(outputFlag()) 28 | 29 | return cmd 30 | } 31 | 32 | func status(cmd *cobra.Command, _ []string) error { 33 | flag := cmd.Flags() 34 | 35 | branchName, _ := flag.GetString("branch") 36 | strOutput, _ := flag.GetString("output") 37 | 38 | vc, err := getVersionController(flag, true, false) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | output, err := fileOutput(strOutput, os.Stdout) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | statuser := multigitter.Statuser{ 49 | VersionController: vc, 50 | 51 | Output: output, 52 | 53 | FeatureBranch: branchName, 54 | } 55 | 56 | err = statuser.Statuses(context.Background()) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /cmd/cmd-version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "time" 7 | 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // VersionCmd prints the version of multi-gitter 12 | func VersionCmd() *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "version", 15 | Short: "Get the version of multi-gitter.", 16 | Long: "Get the version of multi-gitter.", 17 | Args: cobra.NoArgs, 18 | Run: version, 19 | } 20 | 21 | return cmd 22 | } 23 | 24 | // Version is the current version of multigitter (set by main.go) 25 | var Version string 26 | 27 | // BuildDate is the time the build was made (set by main.go) 28 | var BuildDate time.Time 29 | 30 | // Commit is the commit the build was made on (set by main.go) 31 | var Commit string 32 | 33 | func version(_ *cobra.Command, _ []string) { 34 | fmt.Printf("multi-gitter version: %s\n", Version) 35 | fmt.Printf("Release-Date: %s\n", BuildDate.Format("2006-01-02")) 36 | fmt.Printf("Go version: %s\n", runtime.Version()) 37 | fmt.Printf("OS: %s\n", runtime.GOOS) 38 | fmt.Printf("Arch: %s\n", runtime.GOARCH) 39 | fmt.Printf("Commit: %s\n", Commit) 40 | } 41 | -------------------------------------------------------------------------------- /cmd/command.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | func parseCommand(command string) (executablePath string, arguments []string, err error) { 13 | workingDir, err := os.Getwd() 14 | if err != nil { 15 | return "", nil, errors.New("could not get the working directory") 16 | } 17 | 18 | parsedCommand, err := parseCommandLine(command) 19 | if err != nil { 20 | return "", nil, errors.Errorf("could not parse command: %s", err) 21 | } 22 | executablePath, err = exec.LookPath(parsedCommand[0]) 23 | if err != nil { 24 | if _, err := os.Stat(parsedCommand[0]); os.IsNotExist(err) { 25 | return "", nil, errors.Errorf("could not find executable %s", parsedCommand[0]) 26 | } 27 | return "", nil, errors.Errorf("could not find executable %s, does it have executable privileges?", parsedCommand[0]) 28 | } 29 | // Executable needs to be defined with an absolute path since it will be run within the context of repositories 30 | if !filepath.IsAbs(executablePath) { 31 | executablePath = filepath.Join(workingDir, executablePath) 32 | } 33 | 34 | return executablePath, parsedCommand[1:], nil 35 | } 36 | 37 | // https://stackoverflow.com/a/46973603 38 | func parseCommandLine(command string) ([]string, error) { 39 | type state int 40 | 41 | const ( 42 | stateStart state = iota 43 | stateQuotes 44 | stateArg 45 | ) 46 | 47 | var args []string 48 | currentState := stateStart 49 | current := "" 50 | quote := "\"" 51 | escapeNext := true 52 | for i := 0; i < len(command); i++ { 53 | c := command[i] 54 | 55 | if currentState == stateQuotes { 56 | if string(c) != quote { 57 | current += string(c) 58 | } else { 59 | args = append(args, current) 60 | current = "" 61 | currentState = stateStart 62 | } 63 | continue 64 | } 65 | 66 | if escapeNext { 67 | current += string(c) 68 | escapeNext = false 69 | continue 70 | } 71 | 72 | if c == '\\' { 73 | escapeNext = true 74 | continue 75 | } 76 | 77 | if c == '"' || c == '\'' { 78 | currentState = stateQuotes 79 | quote = string(c) 80 | continue 81 | } 82 | 83 | if currentState == stateArg { 84 | if c == ' ' || c == '\t' { 85 | args = append(args, current) 86 | current = "" 87 | currentState = stateStart 88 | } else { 89 | current += string(c) 90 | } 91 | continue 92 | } 93 | 94 | if c != ' ' && c != '\t' { 95 | currentState = stateArg 96 | current += string(c) 97 | } 98 | } 99 | 100 | if currentState == stateQuotes { 101 | return []string{}, fmt.Errorf("unclosed quote in command line: %s", command) 102 | } 103 | 104 | if current != "" { 105 | args = append(args, current) 106 | } 107 | 108 | return args, nil 109 | } 110 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/pflag" 8 | "github.com/spf13/viper" 9 | ) 10 | 11 | func configureConfig(cmd *cobra.Command) { 12 | cmd.Flags().StringP("config", "", "", "Path of the config file.") 13 | } 14 | 15 | func initializeConfig(cmd *cobra.Command) error { 16 | // Prioritize reading config files defined with --config 17 | if err := initializeDynamicConfig(cmd); err != nil { 18 | return err 19 | } 20 | 21 | // Read any config defined in static config files 22 | return initializeStaticConfig(cmd) 23 | } 24 | 25 | func initializeDynamicConfig(cmd *cobra.Command) error { 26 | configFile, _ := cmd.Flags().GetString("config") 27 | if configFile == "" { 28 | return nil 29 | } 30 | 31 | v := viper.New() 32 | 33 | v.SetConfigFile(configFile) 34 | v.SetConfigType("yaml") 35 | 36 | if err := v.ReadInConfig(); err != nil { 37 | return err 38 | } 39 | 40 | bindFlags(cmd, v) 41 | 42 | return nil 43 | } 44 | 45 | func initializeStaticConfig(cmd *cobra.Command) error { 46 | v := viper.New() 47 | 48 | v.SetConfigType("yaml") 49 | v.SetConfigName("config") 50 | v.AddConfigPath("$HOME/.multi-gitter") 51 | 52 | // Attempt to read the config file, gracefully ignoring errors 53 | // caused by a config file not being found. Return an error 54 | // if we cannot parse the config file. 55 | if err := v.ReadInConfig(); err != nil { 56 | // It's okay if there isn't a config file 57 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 58 | return err 59 | } 60 | } 61 | 62 | bindFlags(cmd, v) 63 | 64 | return nil 65 | } 66 | 67 | func bindFlags(cmd *cobra.Command, v *viper.Viper) { 68 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 69 | // Apply the viper config value to the flag when the flag is not set and viper has a value 70 | if !f.Changed && v.IsSet(f.Name) { 71 | val := v.Get(f.Name) 72 | 73 | switch val := val.(type) { 74 | case []interface{}: 75 | for _, v := range val { 76 | _ = cmd.Flags().Set(f.Name, fmt.Sprintf("%v", v)) 77 | } 78 | default: 79 | _ = cmd.Flags().Set(f.Name, fmt.Sprintf("%v", val)) 80 | } 81 | } 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /cmd/flags.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/spf13/pflag" 4 | 5 | // stringSlice is a wrapped around *pflag.FlagSet.GetStringSlice to allow nil when the flag is not set 6 | func stringSlice(set *pflag.FlagSet, name string) ([]string, error) { 7 | if !set.Changed(name) { 8 | return nil, nil 9 | } 10 | return set.GetStringSlice(name) 11 | } 12 | -------------------------------------------------------------------------------- /cmd/git.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/lindell/multi-gitter/internal/git/cmdgit" 5 | "github.com/lindell/multi-gitter/internal/git/gogit" 6 | "github.com/lindell/multi-gitter/internal/multigitter" 7 | "github.com/pkg/errors" 8 | "github.com/spf13/cobra" 9 | flag "github.com/spf13/pflag" 10 | ) 11 | 12 | func configureGit(cmd *cobra.Command) { 13 | cmd.Flags().IntP("fetch-depth", "f", 1, "Limit fetching to the specified number of commits. Set to 0 for no limit.") 14 | cmd.Flags().StringP("git-type", "", "go", `The type of git implementation to use. 15 | Available values: 16 | go: Uses go-git, a Go native implementation of git. This is compiled with the multi-gitter binary, and no extra dependencies are needed. 17 | cmd: Calls out to the git command. This requires git to be installed and available with by calling "git". 18 | `) 19 | _ = cmd.RegisterFlagCompletionFunc("git-type", func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 20 | return []string{"go", "cmd"}, cobra.ShellCompDirectiveDefault 21 | }) 22 | } 23 | 24 | func getGitCreator(flag *flag.FlagSet) (func(string) multigitter.Git, error) { 25 | fetchDepth, _ := flag.GetInt("fetch-depth") 26 | gitType, _ := flag.GetString("git-type") 27 | 28 | switch gitType { 29 | case "go": 30 | return func(path string) multigitter.Git { 31 | return &gogit.Git{ 32 | Directory: path, 33 | FetchDepth: fetchDepth, 34 | } 35 | }, nil 36 | case "cmd": 37 | return func(path string) multigitter.Git { 38 | return &cmdgit.Git{ 39 | Directory: path, 40 | FetchDepth: fetchDepth, 41 | } 42 | }, nil 43 | } 44 | 45 | return nil, errors.Errorf(`could not parse git type "%s"`, gitType) 46 | } 47 | -------------------------------------------------------------------------------- /cmd/logging.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pkg/errors" 8 | log "github.com/sirupsen/logrus" 9 | "github.com/spf13/cobra" 10 | 11 | internallog "github.com/lindell/multi-gitter/internal/log" 12 | "github.com/lindell/multi-gitter/internal/multigitter/terminal" 13 | ) 14 | 15 | func configureLogging(cmd *cobra.Command, logFile string) { 16 | flags := cmd.Flags() 17 | 18 | flags.StringP("log-level", "L", "info", "The level of logging that should be made. Available values: trace, debug, info, error.") 19 | _ = cmd.RegisterFlagCompletionFunc("log-level", func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 20 | return []string{"trace", "debug", "info", "error"}, cobra.ShellCompDirectiveDefault 21 | }) 22 | 23 | flags.StringP("log-format", "", "text", `The formatting of the logs. Available values: text, json, json-pretty.`) 24 | _ = cmd.RegisterFlagCompletionFunc("log-format", func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 25 | return []string{"text", "json", "json-pretty"}, cobra.ShellCompDirectiveDefault 26 | }) 27 | 28 | flags.StringP("log-file", "", logFile, `The file where all logs should be printed to. "-" means stdout.`) 29 | 30 | flags.BoolP("plain-output", "", false, `Don't use any terminal formatting when printing the output.`) 31 | } 32 | 33 | func logFlagInit(cmd *cobra.Command, _ []string) error { 34 | // Parse and set log level 35 | strLevel, _ := cmd.Flags().GetString("log-level") 36 | logLevel, err := log.ParseLevel(strLevel) 37 | if err != nil { 38 | return fmt.Errorf("invalid log-level: %s", strLevel) 39 | } 40 | log.SetLevel(logLevel) 41 | 42 | // Set how custom terminal formatting is handled 43 | plainOutput, _ := cmd.Flags().GetBool("plain-output") 44 | terminal.DefaultPrinter.Plain = plainOutput 45 | 46 | // Parse and set the log format 47 | strFormat, _ := cmd.Flags().GetString("log-format") 48 | 49 | var formatter log.Formatter 50 | switch strFormat { 51 | case "text": 52 | formatter = &log.TextFormatter{ 53 | DisableColors: plainOutput, 54 | } 55 | case "json": 56 | formatter = &log.JSONFormatter{} 57 | case "json-pretty": 58 | if plainOutput { 59 | return errors.New("can't use json-pretty logs with with plain-output") 60 | } 61 | formatter = &log.JSONFormatter{ 62 | PrettyPrint: true, 63 | } 64 | default: 65 | return fmt.Errorf(`unknown log-format "%s"`, strFormat) 66 | } 67 | 68 | // Make sure sensitive data is censored before logging them 69 | var censorItems []internallog.CensorItem 70 | if token, err := getToken(cmd.Flags()); err == nil && token != "" { 71 | censorItems = append(censorItems, internallog.CensorItem{ 72 | Sensitive: token, 73 | Replacement: "", 74 | }) 75 | } 76 | 77 | log.SetFormatter(internallog.NewCensorFormatter(formatter, censorItems...)) 78 | 79 | // Set the output (file) 80 | strFile, _ := cmd.Flags().GetString("log-file") 81 | if strFile == "" { 82 | log.SetOutput(nopWriter{}) 83 | } else if strFile != "-" { 84 | file, err := os.Create(strFile) 85 | if err != nil { 86 | return errors.Wrapf(err, "could not open log-file %s", strFile) 87 | } 88 | log.SetOutput(file) 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /cmd/other.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/lindell/multi-gitter/internal/scm" 8 | "github.com/pkg/errors" 9 | flag "github.com/spf13/pflag" 10 | ) 11 | 12 | func outputFlag() *flag.FlagSet { 13 | flags := flag.NewFlagSet("output", flag.ExitOnError) 14 | 15 | flags.StringP("output", "o", "-", `The file that the output of the script should be outputted to. "-" means stdout.`) 16 | 17 | return flags 18 | } 19 | 20 | func getToken(flag *flag.FlagSet) (string, error) { 21 | if OverrideVersionController != nil { 22 | return "", nil 23 | } 24 | 25 | token, _ := flag.GetString("token") 26 | 27 | if token == "" { 28 | if ght := os.Getenv("GITHUB_TOKEN"); ght != "" { 29 | token = ght 30 | } else if ght := os.Getenv("GITLAB_TOKEN"); ght != "" { 31 | token = ght 32 | } else if ght := os.Getenv("GITEA_TOKEN"); ght != "" { 33 | token = ght 34 | } else if ght := os.Getenv("BITBUCKET_SERVER_TOKEN"); ght != "" { 35 | token = ght 36 | } else if ght := os.Getenv("BITBUCKET_CLOUD_APP_PASSWORD"); ght != "" { 37 | token = ght 38 | } else if ght := os.Getenv("BITBUCKET_CLOUD_WORKSPACE_TOKEN"); ght != "" { 39 | token = ght 40 | } 41 | } 42 | 43 | if token == "" { 44 | return "", errors.New("either the --token flag or the GITHUB_TOKEN/GITLAB_TOKEN/GITEA_TOKEN/BITBUCKET_SERVER_TOKEN/BITBUCKET_CLOUD_APP_PASSWORD/BITBUCKET_CLOUD_WORKSPACE_TOKEN environment variable has to be set") 45 | } 46 | 47 | return token, nil 48 | } 49 | 50 | func getMergeTypes(flag *flag.FlagSet) ([]scm.MergeType, error) { 51 | mergeTypeStrs, _ := flag.GetStringSlice("merge-type") // Only used for the merge command 52 | 53 | // Convert all defined merge types (if any) 54 | var err error 55 | mergeTypes := make([]scm.MergeType, len(mergeTypeStrs)) 56 | for i, mt := range mergeTypeStrs { 57 | mergeTypes[i], err = scm.ParseMergeType(mt) 58 | if err != nil { 59 | return nil, err 60 | } 61 | } 62 | 63 | return mergeTypes, nil 64 | } 65 | 66 | // nopWriter is a writer that does nothing 67 | type nopWriter struct{} 68 | 69 | func (nw nopWriter) Write(bb []byte) (int, error) { 70 | return len(bb), nil 71 | } 72 | 73 | type nopCloser struct { 74 | io.Writer 75 | } 76 | 77 | func (nopCloser) Close() error { return nil } 78 | 79 | func fileOutput(value string, std io.Writer) (io.WriteCloser, error) { 80 | if value != "-" { 81 | file, err := os.Create(value) 82 | if err != nil { 83 | return nil, errors.Wrapf(err, "could not open file %s", value) 84 | } 85 | return file, nil 86 | } 87 | return nopCloser{std}, nil 88 | } 89 | -------------------------------------------------------------------------------- /docs/README.template.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | Multi-gitter logo 5 | 6 |

7 | 8 |
9 | Go build status 10 | Go test status 11 | Go Report Card 12 | OpenSSF Scorecard 13 |
14 |
15 | 16 | *multi-gitter* allows you to make changes in multiple repositories simultaneously. This is achieved by running a script or program in the context of multiple repositories. If any changes are made, a pull request is created that can be merged manually by the set reviewers, or automatically by multi-gitter when CI pipelines have completed successfully. 17 | 18 | Are you a bash-guru or simply prefer your scripting in Node.js? It doesn't matter, since multi-gitter support any type of script or program. **If you can script it to run in one place, you can run it in all your repositories with one command!** 19 | 20 | ### Some examples: 21 | * Syncing a file (like a PR-template) 22 | * Programmatic refactoring 23 | * Updating a dependency 24 | * Automatically fixing linting issues 25 | * Search and replace 26 | * Anything else you are able to script! 27 | 28 | ## Demo 29 | 30 | ![Gif](docs/img/demo.gif) 31 | 32 | ## Example 33 | 34 | ### Run with file 35 | ```bash 36 | $ multi-gitter run ./my-script.sh -O my-org -m "Commit message" -B branch-name 37 | ``` 38 | 39 | Make sure the script has execution permissions before running it (`chmod +x ./my-script.sh`) 40 | 41 | ### Run code through interpreter 42 | If you are running an interpreted language or similar, it's important to specify the path as an absolute value (since the script will be run in the context of each repository). Using the `$PWD` variable helps with this. 43 | ```bash 44 | $ multi-gitter run "python $PWD/run.py" -O my-org -m "Commit message" -B branch-name 45 | $ multi-gitter run "node $PWD/script.js" -R repo1 -R repo2 -m "Commit message" -B branch-name 46 | $ multi-gitter run "go run $PWD/main.go" -U my-user -m "Commit message" -B branch-name 47 | ``` 48 | 49 | ### Test before live run 50 | You might want to test your changes before creating commits. The `--dry-run` flag provides an easy way to test without actually making any modifications. It works well when setting the log level to `debug`, with `--log-level=debug`, to also print the changes that would have been made. 51 | ``` 52 | $ multi-gitter run ./script.sh --dry-run --log-level=debug -O my-org -m "Commit message" -B branch-name 53 | ``` 54 | 55 | ## Install 56 | 57 | ### Homebrew 58 | If you are using Mac or Linux, [Homebrew](https://brew.sh/) is an easy way of installing multi-gitter. 59 | ```bash 60 | brew install lindell/multi-gitter/multi-gitter 61 | ``` 62 | 63 | ### Manual binary install 64 | Find the binary for your operating system from the [release page](https://github.com/lindell/multi-gitter/releases) and download it. 65 | 66 | ### Automatic binary install 67 | To automatically install the latest version 68 | ```bash 69 | curl -s https://raw.githubusercontent.com/lindell/multi-gitter/master/install.sh | sh 70 | ``` 71 | 72 | ### From source 73 | You can also install from source with `go install`, this is not recommended for most cases. 74 | ```bash 75 | go install github.com/lindell/multi-gitter@latest 76 | ``` 77 | 78 | ## Token 79 | 80 | To use multi-gitter, a token that is allowed to list repositories and create pull requests is needed. This token can either be set in the `GITHUB_TOKEN`, `GITLAB_TOKEN`, `GITEA_TOKEN` environment variable, or by using the `--token` flag. 81 | 82 | ### GitHub 83 | 84 | [How to generate a GitHub personal access token (classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic). Make sure to give it `repo` permissions. 85 | 86 | ### GitLab 87 | 88 | [How to generate a GitLab personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html). Make sure to give to it the `api` permission. 89 | 90 | ### Gitea 91 | 92 | In Gitea, access tokens can be generated under Settings -> Applications -> Manage Access Tokens 93 | 94 | ## Config file 95 | 96 | All configuration in multi-gitter can be done through command line flags, configuration files or a combination of both. If you want to use a configuration file, simply use the `--config=./path/to/config.yaml` option. Multi-gitter will also read from the file `~/.multi-gitter/config` and take and configuration from there. The priority of configs are first flags, then defined config file and lastly the static config file. 97 | 98 | {{range .Commands}} 99 | {{if .YAMLExample}} 100 |
101 | All available {{.Name}} options 102 | 103 | ```yaml 104 | {{ .YAMLExample }} 105 | ``` 106 |
107 | {{end}}{{end}} 108 | 109 | ## Usage 110 | {{range .Commands}} 111 | * [{{ .Name }}](#-usage-of-{{ .Name }}) {{ .Short }}{{end}} 112 | 113 | {{range .Commands}} 114 | ### {{.Name}} Usage of `{{.Name}}` 115 | {{.Long}} 116 | ``` 117 | {{.Usage}} 118 | ``` 119 | 120 | {{end}} 121 | 122 | ## Example scripts 123 | {{range .ExampleCategories}} 124 | ### {{.Name}} 125 | {{range .Examples}} 126 |
127 | {{.Title}} 128 | 129 | ```{{.Type}} 130 | {{.Body}} 131 | ``` 132 |
133 | {{end}}{{end}} 134 | 135 | Do you have a nice script that might be useful to others? Please create a PR that adds it to the [examples folder](/examples). 136 | 137 | 138 |
139 | 140 | Bitbucket Cloud 141 | 142 | _note: bitbucket cloud support is currently in Beta_ 143 | 144 | In order to use bitbucket cloud you will need to create and use an [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) or [Workspace Token](https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/). The app password or workspace token you create needs sufficient permissions so ensure you grant it Read and Write access to projects, repositories and pull requests and at least Read access to your account and workspace membership. 145 | 146 | You will need to configure the bitbucket workspace using the `org` option for multi-gitter for the repositories you want to make changes to. You will also need to configure the authentication method using the `auth-type` flag e.g. `multi-gitter run examples/go/upgrade-go-version.sh -u your_username --org "your_workspace" --auth-type app-password` 147 | 148 | ### Example 149 | Here is an example of using the command line options to run a script from the `examples/` directory and make pull-requests for a few repositories in a specified workspace, using app password authentication. 150 | ```shell 151 | export BITBUCKET_CLOUD_APP_PASSWORD="your_app_password" 152 | multi-gitter run examples/go/upgrade-go-version.sh -u your_username --org "your_workspace" --repo "your_first_repository,your_second_repository" --platform bitbucket_cloud -m "your_commit_message" -B your_branch_name --auth-type app-password 153 | ``` 154 | 155 | Here is an example of running the script using workspace token authentication. 156 | ```shell 157 | export BITBUCKET_CLOUD_WORKSPACE_TOKEN="your_workspace_token" 158 | multi-gitter run examples/go/upgrade-go-version.sh -u your_username --org "your_workspace" --repo "your_first_repository,your_second_repository" --platform bitbucket_cloud -m "your_commit_message" -B your_branch_name --auth-type workspace-token 159 | ``` 160 | 161 | ### Bitbucket Cloud Limitations 162 | Currently, we add the repositories default reviewers as a reviewer for any pull-request you create. If you want to specify specific reviewers, you will need to add them using their `UUID` instead of their username since bitbucket does not allow us to look up a `UUID` using their username. [This article has more information about where you can get a users UUID.](https://community.atlassian.com/t5/Bitbucket-articles/Retrieve-the-Atlassian-Account-ID-AAID-in-bitbucket-org/ba-p/2471787) 163 | 164 | We don't support specifying specific projects for bitbucket cloud yet, you should still be able to make changes to the repositories you want but certain functionality, like forking, does not work as well until we implement that feature. 165 | Using `fork: true` is currently experimental within multi-gitter for Bitbucket Cloud, and will be addressed in future updates. 166 | Here are the known limitations: 167 | - The forked repository will appear in the user-provided workspace/orgName, with the given repo name, but will appear in a random project within that workspace. 168 | - Using `git-type: cmd` is required for Bitbucket Cloud forking for now until better support is added, as `git-type: go` causes inconsistent behavior(intermittent unauthorized errors). 169 | 170 | We also only support modifying a single workspace, any additional workspaces passed into the multi-gitter `org` option will be ignored after the first value. 171 | 172 | We also have noticed the performance is slower with larger workspaces and we expect to resolve this when we add support for projects to make filtering repositories by project faster. 173 | 174 |
175 | -------------------------------------------------------------------------------- /docs/img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lindell/multi-gitter/992dce88bff7c2465a312fdb251e931ecdf5517f/docs/img/demo.gif -------------------------------------------------------------------------------- /docs/img/fa/code-merge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/img/fa/print.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/img/fa/rabbit-fast.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/img/fa/tasks.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/img/fa/times-hexagon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/img/logo-dark-mode.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 19 | 20 | multi-gitter 21 | 22 | -------------------------------------------------------------------------------- /docs/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 18 | 19 | multi-gitter 20 | 21 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | multi-gitter example scripts 2 | ---- 3 | 4 | This folder contains example scripts that can be used with multi-gitter and might be useful to use as is or to get inspiration from. 5 | 6 | ### Add your own example 7 | 8 | Other developers might find how you solved your use-case useful as well! Please contribute your example by adding it to corresponding language folder (or general). 9 | 10 | Always add a `Title: xxx` comment to your script with a line comment so that it can be parsed by automated tools. 11 | 12 | Bash example: 13 | ```bash 14 | # Title: My bash script that does X 15 | ``` 16 | 17 | JavaScript example: 18 | ```js 19 | // Title: My js script that does X 20 | ``` 21 | -------------------------------------------------------------------------------- /examples/general/clone.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Clone all repositories locally while maintaining their group folder structure 4 | 5 | # This script should be used with the print command. 6 | mkdir -p ~/multi-gitter/$REPOSITORY 7 | cp -r . ~/multi-gitter/$REPOSITORY 8 | -------------------------------------------------------------------------------- /examples/general/replace-file-content.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Replace a file if it exist 4 | 5 | REPLACE_FILE=~/test/pull_request_template.md # The file that should replace the file in the repo, must be an absolute path 6 | FILE=.github/pull_request_template.md # Relative from any repos root 7 | 8 | # Don't replace this file if it does not already exist in the repo 9 | if [ ! -f "$FILE" ]; then 10 | exit 1 11 | fi 12 | 13 | cp $REPLACE_FILE $FILE 14 | -------------------------------------------------------------------------------- /examples/general/replace.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Replace text in all files 4 | 5 | # Assuming you are using gnu sed, if you are running this on a mac, please see https://stackoverflow.com/questions/4247068/sed-command-with-i-option-failing-on-mac-but-works-on-linux 6 | 7 | find ./ -type f -exec sed -i -e 's/apple/orange/g' {} \; 8 | -------------------------------------------------------------------------------- /examples/go/empty-interface-to-any.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Replace all instances of empty interface with any 4 | 5 | gofmt -r 'interface{} -> any' -w **/*.go 6 | -------------------------------------------------------------------------------- /examples/go/ioutil.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Fix the ioutil deprecation 4 | 5 | gofmt -w -r 'ioutil.Discard -> io.Discard' . 6 | gofmt -w -r 'ioutil.NopCloser -> io.NopCloser' . 7 | gofmt -w -r 'ioutil.ReadAll -> io.ReadAll' . 8 | gofmt -w -r 'ioutil.ReadFile -> os.ReadFile' . 9 | gofmt -w -r 'ioutil.TempDir -> os.MkdirTemp' . 10 | gofmt -w -r 'ioutil.TempFile -> os.CreateTemp' . 11 | gofmt -w -r 'ioutil.WriteFile -> os.WriteFile' . 12 | gofmt -w -r 'ioutil.ReadDir -> os.ReadDir ' . # (note: returns a slice of os.DirEntry rather than a slice of fs.FileInfo) 13 | 14 | goimports -w . 15 | -------------------------------------------------------------------------------- /examples/go/linting.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Fix linting problems in all your go repositories 4 | 5 | golangci-lint run ./... --fix 6 | -------------------------------------------------------------------------------- /examples/go/module-update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Updates a go module to a new (patch/minor) version 4 | 5 | ### Change these values ### 6 | MODULE=github.com/go-git/go-git/v5 7 | VERSION=v5.1.0 8 | 9 | # Check if the module already exist, abort if it does not 10 | go list -m $MODULE &> /dev/null 11 | status_code=$? 12 | if [ $status_code -ne 0 ]; then 13 | echo "Module \"$MODULE\" does not exist" 14 | exit 1 15 | fi 16 | 17 | go get $MODULE@$VERSION 18 | -------------------------------------------------------------------------------- /examples/go/upgrade-go-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Upgrade Go version in go modules 4 | 5 | go mod edit -go 1.18 6 | go mod tidy 7 | -------------------------------------------------------------------------------- /examples/node/package-update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Title: Updates a npm dependency if it does exist 4 | 5 | ### Change these values ### 6 | PACKAGE=webpack 7 | VERSION=4.43.0 8 | 9 | if [ ! -f "package.json" ]; then 10 | echo "package.json does not exist" 11 | exit 1 12 | fi 13 | 14 | # Check if the package already exist (without having to install all packages first), abort if it does not 15 | current_version=`jq ".dependencies[\"$PACKAGE\"]" package.json` 16 | if [ "$current_version" == "null" ]; 17 | then 18 | echo "Package \"$PACKAGE\" does not exist" 19 | exit 2 20 | fi 21 | 22 | npm install --save $PACKAGE@$VERSION 23 | -------------------------------------------------------------------------------- /examples/node/replace.js: -------------------------------------------------------------------------------- 1 | // Title: Simple replace using node 2 | 3 | const { readFile, writeFile } = require("fs").promises; 4 | 5 | async function replace() { 6 | let data = await readFile("./README.md", "utf8"); 7 | data = data.replace("apple", "orange"); 8 | await writeFile("./README.md", data, "utf8"); 9 | } 10 | 11 | replace(); 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lindell/multi-gitter 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | code.gitea.io/sdk/gitea v0.21.0 7 | github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 8 | github.com/gfleury/go-bitbucket-v1 v0.0.0-20240917142304-df385efaac68 9 | github.com/go-git/go-git/v5 v5.16.0 10 | github.com/google/go-github/v70 v70.0.0 11 | github.com/ktrysmt/go-bitbucket v0.9.85 12 | github.com/mitchellh/mapstructure v1.5.0 13 | github.com/pkg/errors v0.9.1 14 | github.com/sirupsen/logrus v1.9.3 15 | github.com/spf13/cobra v1.9.1 16 | github.com/spf13/pflag v1.0.6 17 | github.com/spf13/viper v1.20.1 18 | github.com/stretchr/testify v1.10.0 19 | github.com/xanzy/go-gitlab v0.115.0 20 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 21 | golang.org/x/oauth2 v0.30.0 22 | ) 23 | 24 | require ( 25 | dario.cat/mergo v1.0.0 // indirect 26 | github.com/42wim/httpsig v1.2.2 // indirect 27 | github.com/Microsoft/go-winio v0.6.2 // indirect 28 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 29 | github.com/cloudflare/circl v1.6.1 // indirect 30 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 31 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 32 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 33 | github.com/davidmz/go-pageant v1.0.2 // indirect 34 | github.com/emirpasic/gods v1.18.1 // indirect 35 | github.com/fsnotify/fsnotify v1.8.0 // indirect 36 | github.com/go-fed/httpsig v1.1.0 // indirect 37 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 38 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 39 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 40 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 41 | github.com/google/go-querystring v1.1.0 // indirect 42 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 43 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 44 | github.com/hashicorp/go-version v1.7.0 // indirect 45 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 46 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 47 | github.com/kevinburke/ssh_config v1.2.0 // indirect 48 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 49 | github.com/pjbgf/sha1cd v0.3.2 // indirect 50 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 51 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 52 | github.com/sagikazarmark/locafero v0.7.0 // indirect 53 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 54 | github.com/skeema/knownhosts v1.3.1 // indirect 55 | github.com/sourcegraph/conc v0.3.0 // indirect 56 | github.com/spf13/afero v1.12.0 // indirect 57 | github.com/spf13/cast v1.7.1 // indirect 58 | github.com/subosito/gotenv v1.6.0 // indirect 59 | github.com/xanzy/ssh-agent v0.3.3 // indirect 60 | go.uber.org/atomic v1.9.0 // indirect 61 | go.uber.org/multierr v1.9.0 // indirect 62 | golang.org/x/crypto v0.37.0 // indirect 63 | golang.org/x/net v0.39.0 // indirect 64 | golang.org/x/sys v0.32.0 // indirect 65 | golang.org/x/text v0.24.0 // indirect 66 | golang.org/x/time v0.8.0 // indirect 67 | gopkg.in/warnings.v0 v0.1.2 // indirect 68 | gopkg.in/yaml.v3 v3.0.1 // indirect 69 | ) 70 | -------------------------------------------------------------------------------- /internal/git/changes.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | // Changes represents the changes made to a repository 4 | type Changes struct { 5 | // Map of file paths to the changes made to the file 6 | // The key is the file path and the value is the change 7 | Additions map[string][]byte 8 | 9 | // List of file paths that were deleted 10 | Deletions []string 11 | 12 | // OldHash is the hash of the previous commit 13 | OldHash string 14 | } 15 | 16 | type LastCommitChecker interface { 17 | LastCommitChanges() (Changes, error) 18 | } 19 | -------------------------------------------------------------------------------- /internal/git/cmdgit/git.go: -------------------------------------------------------------------------------- 1 | package cmdgit 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os/exec" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/lindell/multi-gitter/internal/git" 12 | "github.com/pkg/errors" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // Git is an implementation of git that executes git as commands 17 | type Git struct { 18 | Directory string // The (temporary) directory that should be worked within 19 | FetchDepth int // Limit fetching to the specified number of commits 20 | } 21 | 22 | var errRe = regexp.MustCompile(`(^|\n)(error|fatal): (.+)`) 23 | 24 | func (g *Git) run(cmd *exec.Cmd) (string, error) { 25 | stderr := &bytes.Buffer{} 26 | stdout := &bytes.Buffer{} 27 | 28 | cmd.Dir = g.Directory 29 | cmd.Stderr = stderr 30 | cmd.Stdout = stdout 31 | 32 | err := cmd.Run() 33 | logGitExecution(cmd, stdout, stderr) 34 | if err != nil { 35 | matches := errRe.FindStringSubmatch(stderr.String()) 36 | if matches != nil { 37 | return "", errors.New(matches[3]) 38 | } 39 | 40 | msg := fmt.Sprintf(`git command exited with %d (%s)`, 41 | cmd.ProcessState.ExitCode(), 42 | stderr.String(), 43 | ) 44 | 45 | return "", errors.New(msg) 46 | } 47 | return stdout.String(), nil 48 | } 49 | 50 | func logGitExecution(cmd *exec.Cmd, stdout *bytes.Buffer, stderr *bytes.Buffer) { 51 | log.WithFields(log.Fields{ 52 | "cmd": cmd.String(), 53 | "stdout": stdout.String(), 54 | "stderr": stderr.String(), 55 | }).Trace("cmdgit") 56 | } 57 | 58 | // Clone a repository 59 | func (g *Git) Clone(ctx context.Context, url string, baseName string) error { 60 | args := []string{"clone", url, "--branch", baseName, "--single-branch"} 61 | if g.FetchDepth > 0 { 62 | args = append(args, "--depth", fmt.Sprint(g.FetchDepth)) 63 | } 64 | args = append(args, g.Directory) 65 | 66 | cmd := exec.CommandContext(ctx, "git", args...) 67 | _, err := g.run(cmd) 68 | return err 69 | } 70 | 71 | // ChangeBranch changes the branch 72 | func (g *Git) ChangeBranch(branchName string) error { 73 | cmd := exec.Command("git", "checkout", "-b", branchName) 74 | _, err := g.run(cmd) 75 | return err 76 | } 77 | 78 | // Changes detect if any changes has been made in the directory 79 | func (g *Git) Changes() (bool, error) { 80 | cmd := exec.Command("git", "status", "-s") 81 | stdOut, err := g.run(cmd) 82 | return len(stdOut) > 0, err 83 | } 84 | 85 | // Commit and push all changes 86 | func (g *Git) Commit(commitAuthor *git.CommitAuthor, commitMessage string) error { 87 | cmd := exec.Command("git", "add", ".") 88 | _, err := g.run(cmd) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | cmd = exec.Command("git", "commit", "--no-verify", "-m", commitMessage) 94 | 95 | if commitAuthor != nil { 96 | cmd.Env = append(cmd.Env, 97 | "GIT_AUTHOR_NAME="+commitAuthor.Name, 98 | "GIT_AUTHOR_EMAIL="+commitAuthor.Email, 99 | "GIT_COMMITTER_NAME="+commitAuthor.Name, 100 | "GIT_COMMITTER_EMAIL="+commitAuthor.Email, 101 | ) 102 | } 103 | 104 | _, err = g.run(cmd) 105 | if err != nil { 106 | return err 107 | } 108 | 109 | if err := g.logDiff(); err != nil { 110 | return err 111 | } 112 | 113 | return err 114 | } 115 | 116 | func (g *Git) logDiff() error { 117 | if !log.IsLevelEnabled(log.DebugLevel) { 118 | return nil 119 | } 120 | 121 | cmd := exec.Command("git", "diff", "HEAD~1") 122 | stdout, err := g.run(cmd) 123 | if err != nil { 124 | return err 125 | } 126 | 127 | log.Debug(stdout) 128 | 129 | return nil 130 | } 131 | 132 | // BranchExist checks if the new branch exists 133 | func (g *Git) BranchExist(remoteName, branchName string) (bool, error) { 134 | cmd := exec.Command("git", "ls-remote", "-q", "-h", remoteName) 135 | stdOut, err := g.run(cmd) 136 | if err != nil { 137 | return false, err 138 | } 139 | return strings.Contains(stdOut, fmt.Sprintf("\trefs/heads/%s\n", branchName)), nil 140 | } 141 | 142 | // Push the committed changes to the remote 143 | func (g *Git) Push(ctx context.Context, remoteName string, force bool) error { 144 | args := []string{"push", "--no-verify", remoteName} 145 | if force { 146 | args = append(args, "--force") 147 | } 148 | args = append(args, "HEAD") 149 | 150 | cmd := exec.CommandContext(ctx, "git", args...) 151 | _, err := g.run(cmd) 152 | return err 153 | } 154 | 155 | // AddRemote adds a new remote 156 | func (g *Git) AddRemote(name, url string) error { 157 | cmd := exec.Command("git", "remote", "add", name, url) 158 | _, err := g.run(cmd) 159 | return err 160 | } 161 | -------------------------------------------------------------------------------- /internal/git/commit.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | // CommitAuthor is the data (name and email) used when a commit is made 4 | type CommitAuthor struct { 5 | Name string 6 | Email string 7 | } 8 | -------------------------------------------------------------------------------- /internal/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | // Config is configuration for any git implementation 4 | type Config struct { 5 | // Absolute path to the directory 6 | Directory string 7 | // The fetch depth used when cloning, if set to 0, the entire history will be used 8 | FetchDepth int 9 | } 10 | -------------------------------------------------------------------------------- /internal/git/gogit/git.go: -------------------------------------------------------------------------------- 1 | package gogit 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "time" 8 | 9 | "github.com/go-git/go-git/v5/config" 10 | "github.com/go-git/go-git/v5/plumbing/format/gitignore" 11 | "github.com/go-git/go-git/v5/plumbing/object" 12 | "github.com/go-git/go-git/v5/utils/merkletrie" 13 | internalgit "github.com/lindell/multi-gitter/internal/git" 14 | "github.com/pkg/errors" 15 | 16 | git "github.com/go-git/go-git/v5" 17 | "github.com/go-git/go-git/v5/plumbing" 18 | log "github.com/sirupsen/logrus" 19 | ) 20 | 21 | // Git is an implementation of git that used go-git 22 | type Git struct { 23 | Directory string // The (temporary) directory that should be worked within 24 | FetchDepth int // Limit fetching to the specified number of commits 25 | 26 | repo *git.Repository // The repository after the clone has been made 27 | } 28 | 29 | // Clone a repository 30 | func (g *Git) Clone(ctx context.Context, url string, baseName string) error { 31 | r, err := git.PlainCloneContext(ctx, g.Directory, false, &git.CloneOptions{ 32 | URL: url, 33 | RemoteName: "origin", 34 | Depth: g.FetchDepth, 35 | ReferenceName: plumbing.NewBranchReferenceName(baseName), 36 | SingleBranch: true, 37 | }) 38 | if err != nil { 39 | return errors.Wrap(err, "could not clone from the remote") 40 | } 41 | 42 | g.repo = r 43 | 44 | return nil 45 | } 46 | 47 | // ChangeBranch changes the branch 48 | func (g *Git) ChangeBranch(branchName string) error { 49 | w, err := g.repo.Worktree() 50 | if err != nil { 51 | return err 52 | } 53 | 54 | err = w.Checkout(&git.CheckoutOptions{ 55 | Branch: plumbing.NewBranchReferenceName(branchName), 56 | Create: true, 57 | }) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // Changes detect if any changes has been made in the directory 66 | func (g *Git) Changes() (bool, error) { 67 | w, err := g.repo.Worktree() 68 | if err != nil { 69 | return false, err 70 | } 71 | 72 | status, err := w.Status() 73 | if err != nil { 74 | return false, err 75 | } 76 | 77 | return !status.IsClean(), nil 78 | } 79 | 80 | // Commit and push all changes 81 | func (g *Git) Commit(commitAuthor *internalgit.CommitAuthor, commitMessage string) error { 82 | w, err := g.repo.Worktree() 83 | if err != nil { 84 | return err 85 | } 86 | 87 | // Make sure gitignore is used 88 | patterns, err := gitignore.ReadPatterns(w.Filesystem, nil) 89 | if err != nil { 90 | return err 91 | } 92 | w.Excludes = patterns 93 | 94 | err = w.AddWithOptions(&git.AddOptions{ 95 | All: true, 96 | }) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | status, err := w.Status() 102 | if err != nil { 103 | return err 104 | } 105 | 106 | // This is a workaround for a bug in go-git where "add all" does not add deleted files 107 | // If https://github.com/go-git/go-git/issues/223 is fixed, this can be removed 108 | for file, s := range status { 109 | if s.Worktree == git.Deleted { 110 | _, err = w.Add(file) 111 | if err != nil { 112 | return err 113 | } 114 | } 115 | } 116 | 117 | // Get the current hash to be able to diff it with the committed changes later 118 | oldHead, err := g.repo.Head() 119 | if err != nil { 120 | return err 121 | } 122 | oldHash := oldHead.Hash() 123 | 124 | var author *object.Signature 125 | if commitAuthor != nil { 126 | author = &object.Signature{ 127 | Name: commitAuthor.Name, 128 | Email: commitAuthor.Email, 129 | When: time.Now(), 130 | } 131 | } 132 | 133 | hash, err := w.Commit(commitMessage, &git.CommitOptions{ 134 | Author: author, 135 | }) 136 | if err != nil { 137 | return err 138 | } 139 | 140 | commit, err := g.repo.CommitObject(hash) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | _ = g.logDiff(oldHash, commit.Hash) 146 | 147 | return nil 148 | } 149 | 150 | func (g *Git) logDiff(aHash, bHash plumbing.Hash) error { 151 | if !log.IsLevelEnabled(log.DebugLevel) { 152 | return nil 153 | } 154 | 155 | aCommit, err := g.repo.CommitObject(aHash) 156 | if err != nil { 157 | return err 158 | } 159 | aTree, err := aCommit.Tree() 160 | if err != nil { 161 | return err 162 | } 163 | 164 | bCommit, err := g.repo.CommitObject(bHash) 165 | if err != nil { 166 | return err 167 | } 168 | bTree, err := bCommit.Tree() 169 | if err != nil { 170 | return err 171 | } 172 | 173 | patch, err := aTree.Patch(bTree) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | buf := &bytes.Buffer{} 179 | err = patch.Encode(buf) 180 | if err != nil { 181 | return err 182 | } 183 | log.Debug(buf.String()) 184 | 185 | return nil 186 | } 187 | 188 | // BranchExist checks if the new branch exists 189 | func (g *Git) BranchExist(remoteName, branchName string) (bool, error) { 190 | remote, err := g.repo.Remote(remoteName) 191 | if err != nil { 192 | return false, err 193 | } 194 | 195 | refs, err := remote.List(&git.ListOptions{}) 196 | if err != nil { 197 | return false, err 198 | } 199 | for _, r := range refs { 200 | if r.Name().Short() == branchName { 201 | return true, nil 202 | } 203 | } 204 | return false, nil 205 | } 206 | 207 | // Push the committed changes to the remote 208 | func (g *Git) Push(ctx context.Context, remoteName string, force bool) error { 209 | return g.repo.PushContext(ctx, &git.PushOptions{ 210 | RemoteName: remoteName, 211 | Force: force, 212 | }) 213 | } 214 | 215 | // AddRemote adds a new remote 216 | func (g *Git) AddRemote(name, url string) error { 217 | _, err := g.repo.CreateRemote(&config.RemoteConfig{ 218 | Name: name, 219 | URLs: []string{url}, 220 | }) 221 | return err 222 | } 223 | 224 | func (g *Git) LastCommitChanges() (internalgit.Changes, error) { 225 | iter, err := g.repo.Log(&git.LogOptions{}) 226 | if err != nil { 227 | return internalgit.Changes{}, err 228 | } 229 | 230 | current, err := iter.Next() 231 | if err != nil { 232 | return internalgit.Changes{}, errors.WithMessage(err, "could not get current commit") 233 | } 234 | last, err := iter.Next() 235 | if err != nil { 236 | return internalgit.Changes{}, errors.WithMessage(err, "could not get last commit") 237 | } 238 | 239 | currentTree, err := current.Tree() 240 | if err != nil { 241 | return internalgit.Changes{}, errors.WithMessage(err, "could not get current tree") 242 | } 243 | lastTree, err := last.Tree() 244 | if err != nil { 245 | return internalgit.Changes{}, errors.WithMessage(err, "could not get current tree") 246 | } 247 | 248 | changes, err := lastTree.Diff(currentTree) 249 | if err != nil { 250 | return internalgit.Changes{}, errors.WithMessage(err, "could not get diff") 251 | } 252 | 253 | additions := map[string][]byte{} 254 | deletions := []string{} 255 | for _, change := range changes { 256 | action, err := change.Action() 257 | if err != nil { 258 | return internalgit.Changes{}, errors.WithMessage(err, "could not get action") 259 | } 260 | 261 | if action == merkletrie.Insert || action == merkletrie.Modify { 262 | _, to, err := change.Files() 263 | if err != nil { 264 | return internalgit.Changes{}, errors.WithMessage(err, "could not get files") 265 | } 266 | 267 | reader, err := to.Reader() 268 | if err != nil { 269 | return internalgit.Changes{}, errors.WithMessage(err, "could not get reader") 270 | } 271 | bytes, err := io.ReadAll(reader) 272 | reader.Close() 273 | if err != nil { 274 | return internalgit.Changes{}, errors.WithMessage(err, "could not read file") 275 | } 276 | 277 | additions[change.To.Name] = bytes 278 | } else if action == merkletrie.Delete { 279 | deletions = append(deletions, change.From.Name) 280 | } 281 | } 282 | 283 | return internalgit.Changes{ 284 | Additions: additions, 285 | Deletions: deletions, 286 | OldHash: last.Hash.String(), 287 | }, nil 288 | } 289 | -------------------------------------------------------------------------------- /internal/http/logging.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "time" 7 | 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // NewLoggingRoundTripper creates a new logging roundtripper 12 | func NewLoggingRoundTripper(rt http.RoundTripper) http.RoundTripper { 13 | return LoggingRoundTripper{ 14 | Next: rt, 15 | } 16 | } 17 | 18 | // LoggingRoundTripper logs a request-response 19 | type LoggingRoundTripper struct { 20 | Next http.RoundTripper 21 | } 22 | 23 | // RoundTrip logs a request-response 24 | func (l LoggingRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { 25 | req, _ := httputil.DumpRequestOut(r, true) 26 | 27 | var roundTripper http.RoundTripper 28 | if l.Next != nil { 29 | roundTripper = l.Next 30 | } else { 31 | roundTripper = http.DefaultTransport 32 | } 33 | 34 | start := time.Now() 35 | resp, err := roundTripper.RoundTrip(r) 36 | took := time.Since(start) 37 | 38 | var res []byte 39 | if resp != nil { 40 | res, _ = httputil.DumpResponse(resp, true) 41 | } 42 | 43 | logger := log.WithFields(log.Fields{ 44 | "host": r.Host, 45 | "took": took, 46 | "request": string(req), 47 | "response": string(res), 48 | }) 49 | logger.Trace("http request") 50 | 51 | return resp, err 52 | } 53 | -------------------------------------------------------------------------------- /internal/log/censor-formatter.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | "strings" 7 | 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // NewCensorFormatter creates a new formatter that censors sensitive logs. 12 | // It contains some default censoring rules, but additional items may be used 13 | func NewCensorFormatter(underlyingFormatter log.Formatter, additionalCensoring ...CensorItem) *CensorFormatter { 14 | return &CensorFormatter{ 15 | CensorItems: append(defaultCensorItems, additionalCensoring...), 16 | UnderlyingFormatter: underlyingFormatter, 17 | } 18 | } 19 | 20 | // CensorFormatter makes sure sensitive data is not logged. 21 | // It works as a middleware and sensors the data before sending it to an underlying formatter 22 | type CensorFormatter struct { 23 | CensorItems []CensorItem 24 | UnderlyingFormatter log.Formatter 25 | } 26 | 27 | // CensorItem is something that should be censored, Sensitive will be replaced with Replacement 28 | type CensorItem struct { 29 | Sensitive string 30 | SensitiveRegexp *regexp.Regexp 31 | Replacement string 32 | } 33 | 34 | func (c CensorItem) stringReplace(str string) string { 35 | if c.Sensitive != "" { 36 | return strings.ReplaceAll(str, c.Sensitive, c.Replacement) 37 | } 38 | if c.SensitiveRegexp != nil { 39 | return c.SensitiveRegexp.ReplaceAllString(str, c.Replacement) 40 | } 41 | return str 42 | } 43 | 44 | func (c CensorItem) byteReplace(bb []byte) []byte { 45 | if c.Sensitive != "" { 46 | return bytes.ReplaceAll(bb, []byte(c.Sensitive), []byte(c.Replacement)) 47 | } 48 | if c.SensitiveRegexp != nil { 49 | return c.SensitiveRegexp.ReplaceAll(bb, []byte(c.Replacement)) 50 | } 51 | return bb 52 | } 53 | 54 | // Format censors some data and sends the entry to the underlying formatter 55 | func (f *CensorFormatter) Format(entry *log.Entry) ([]byte, error) { 56 | for _, s := range f.CensorItems { 57 | entry.Message = s.stringReplace(entry.Message) 58 | 59 | for key := range entry.Data { 60 | if str, ok := entry.Data[key].(string); ok { 61 | entry.Data[key] = s.stringReplace(str) 62 | } 63 | if bb, ok := entry.Data[key].([]byte); ok { 64 | entry.Data[key] = s.byteReplace(bb) 65 | } 66 | } 67 | } 68 | return f.UnderlyingFormatter.Format(entry) 69 | } 70 | -------------------------------------------------------------------------------- /internal/log/censor-formatter_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type dummyFormatter struct { 12 | entry logrus.Entry 13 | } 14 | 15 | func (f *dummyFormatter) Format(entry *logrus.Entry) ([]byte, error) { 16 | f.entry = *entry 17 | return []byte{}, nil 18 | } 19 | 20 | func TestFormat(t *testing.T) { 21 | tests := []struct { 22 | name string 23 | items []CensorItem 24 | entry logrus.Entry 25 | 26 | expectedMessage string 27 | expectedData logrus.Fields 28 | }{ 29 | { 30 | name: "simple", 31 | items: []CensorItem{ 32 | { 33 | Sensitive: "password", 34 | Replacement: "", 35 | }, 36 | }, 37 | entry: logrus.Entry{ 38 | Message: "this is the password", 39 | Data: logrus.Fields{ 40 | "string-field": "something password something", 41 | "bytes-field": []byte("something password something"), 42 | }, 43 | }, 44 | expectedMessage: "this is the ", 45 | expectedData: logrus.Fields{ 46 | "string-field": "something something", 47 | "bytes-field": []byte("something something"), 48 | }, 49 | }, 50 | { 51 | name: "multiple items", 52 | items: []CensorItem{ 53 | { 54 | Sensitive: "password", 55 | Replacement: "", 56 | }, 57 | { 58 | Sensitive: "token", 59 | Replacement: "", 60 | }, 61 | }, 62 | entry: logrus.Entry{ 63 | Message: "a password and a token", 64 | Data: logrus.Fields{ 65 | "string-field": "a password and a token", 66 | "bytes-field": []byte("a password and a token"), 67 | }, 68 | }, 69 | expectedMessage: "a and a ", 70 | expectedData: logrus.Fields{ 71 | "string-field": "a and a ", 72 | "bytes-field": []byte("a and a "), 73 | }, 74 | }, 75 | { 76 | name: "http authorization", 77 | items: []CensorItem{ 78 | { 79 | SensitiveRegexp: regexp.MustCompile(`(?i)\n(Authorization: [a-z]+) ([^ ]+)\n`), 80 | Replacement: "\n$1 \n", 81 | }, 82 | }, 83 | entry: logrus.Entry{ 84 | Data: logrus.Fields{ 85 | "request": `GET /api/v4/ HTTP/1.1 86 | Host: gitlab.com 87 | User-Agent: Go-http-client/1.1 88 | Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== 89 | Accept-Encoding: gzip 90 | 91 | Some Data`, 92 | "request-as-byte-slice": []byte(`GET /api/v4/ HTTP/1.1 93 | Host: gitlab.com 94 | User-Agent: Go-http-client/1.1 95 | Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== 96 | Accept-Encoding: gzip 97 | 98 | Some Data`), 99 | }, 100 | }, 101 | expectedData: logrus.Fields{ 102 | "request": `GET /api/v4/ HTTP/1.1 103 | Host: gitlab.com 104 | User-Agent: Go-http-client/1.1 105 | Authorization: Basic 106 | Accept-Encoding: gzip 107 | 108 | Some Data`, 109 | "request-as-byte-slice": []byte(`GET /api/v4/ HTTP/1.1 110 | Host: gitlab.com 111 | User-Agent: Go-http-client/1.1 112 | Authorization: Basic 113 | Accept-Encoding: gzip 114 | 115 | Some Data`), 116 | }, 117 | }, 118 | } 119 | 120 | for _, test := range tests { 121 | t.Run(test.name, func(t *testing.T) { 122 | dummyFormatter := &dummyFormatter{} 123 | formatter := CensorFormatter{ 124 | CensorItems: test.items, 125 | UnderlyingFormatter: dummyFormatter, 126 | } 127 | _, err := formatter.Format(&test.entry) 128 | assert.NoError(t, err) 129 | 130 | assert.Equal(t, test.expectedData, dummyFormatter.entry.Data) 131 | assert.Equal(t, test.expectedMessage, dummyFormatter.entry.Message) 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/log/default-censors.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import "regexp" 4 | 5 | var defaultCensorItems = []CensorItem{ 6 | { 7 | SensitiveRegexp: regexp.MustCompile(`(?i)\n(Authorization: [a-z]+) ([^ ]+)\n`), 8 | Replacement: "\n$1 \n", 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /internal/multigitter/close.go: -------------------------------------------------------------------------------- 1 | package multigitter 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lindell/multi-gitter/internal/scm" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Closer closes pull requests 11 | type Closer struct { 12 | VersionController VersionController 13 | 14 | FeatureBranch string 15 | } 16 | 17 | // Close closes pull requests 18 | func (s Closer) Close(ctx context.Context) error { 19 | prs, err := s.VersionController.GetPullRequests(ctx, s.FeatureBranch) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | openPRs := make([]scm.PullRequest, 0, len(prs)) 25 | for _, pr := range prs { 26 | if pr.Status() != scm.PullRequestStatusClosed && pr.Status() != scm.PullRequestStatusMerged { 27 | openPRs = append(openPRs, pr) 28 | } 29 | } 30 | 31 | log.Infof("Closing %d pull requests", len(openPRs)) 32 | 33 | for _, pr := range openPRs { 34 | log.WithField("pr", pr.String()).Infof("Closing") 35 | err := s.VersionController.ClosePullRequest(ctx, pr) 36 | if err != nil { 37 | return err 38 | } 39 | } 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/multigitter/cmd.go: -------------------------------------------------------------------------------- 1 | package multigitter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/lindell/multi-gitter/internal/scm" 7 | "os" 8 | "os/exec" 9 | ) 10 | 11 | func prepareScriptCommand( 12 | ctx context.Context, 13 | repo scm.Repository, 14 | workDir string, 15 | scriptPath string, 16 | arguments []string, 17 | ) (cmd *exec.Cmd) { 18 | // Run the command that might or might not change the content of the repo 19 | // If the command return a non-zero exit code, abort. 20 | cmd = exec.CommandContext(ctx, scriptPath, arguments...) 21 | cmd.Dir = workDir 22 | cmd.Env = append(os.Environ(), 23 | fmt.Sprintf("REPOSITORY=%s", repo.FullName()), 24 | ) 25 | return cmd 26 | } 27 | -------------------------------------------------------------------------------- /internal/multigitter/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | ) 7 | 8 | type logger interface { 9 | Infof(format string, args ...interface{}) 10 | } 11 | 12 | // NewLogger creates a new logger that logs things that is written to the returned writer 13 | func NewLogger(logger logger) io.WriteCloser { 14 | reader, writer := io.Pipe() 15 | 16 | // Print each line that is outputted by the script 17 | go func() { 18 | buf := bufio.NewReader(reader) 19 | for { 20 | line, err := buf.ReadString('\n') 21 | if line != "" { 22 | logger.Infof("Script output: %s", line) 23 | } 24 | if err != nil { 25 | return 26 | } 27 | } 28 | }() 29 | 30 | return writer 31 | } 32 | -------------------------------------------------------------------------------- /internal/multigitter/merge.go: -------------------------------------------------------------------------------- 1 | package multigitter 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lindell/multi-gitter/internal/scm" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Merger merges pull requests in an organization 11 | type Merger struct { 12 | VersionController VersionController 13 | 14 | FeatureBranch string 15 | } 16 | 17 | // Merge merges pull requests in an organization 18 | func (s Merger) Merge(ctx context.Context) error { 19 | prs, err := s.VersionController.GetPullRequests(ctx, s.FeatureBranch) 20 | if err != nil { 21 | return err 22 | } 23 | 24 | successPrs := make([]scm.PullRequest, 0, len(prs)) 25 | for _, pr := range prs { 26 | if pr.Status() == scm.PullRequestStatusSuccess { 27 | successPrs = append(successPrs, pr) 28 | } 29 | } 30 | 31 | log.Infof("Merging %d pull requests", len(successPrs)) 32 | 33 | for _, pr := range successPrs { 34 | log := log.WithField("pr", pr.String()) 35 | 36 | log.Infof("Merging") 37 | err := s.VersionController.MergePullRequest(ctx, pr) 38 | if err != nil { 39 | log.Errorf("Error occurred while merging: %s", err.Error()) 40 | } 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/multigitter/print.go: -------------------------------------------------------------------------------- 1 | package multigitter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/lindell/multi-gitter/internal/multigitter/repocounter" 10 | "github.com/lindell/multi-gitter/internal/scm" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // Printer contains fields to be able to do the print command 15 | type Printer struct { 16 | VersionController VersionController 17 | 18 | ScriptPath string // Must be absolute path 19 | Arguments []string 20 | 21 | Stdout io.Writer 22 | Stderr io.Writer 23 | 24 | Concurrent int 25 | CloneDir string 26 | 27 | CreateGit func(dir string) Git 28 | } 29 | 30 | // Print runs a script for multiple repositories and print the output of each run 31 | func (r Printer) Print(ctx context.Context) error { 32 | repos, err := r.VersionController.GetRepositories(ctx) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | rc := repocounter.NewCounter() 38 | defer func() { 39 | if info := rc.Info(); info != "" { 40 | fmt.Fprint(log.StandardLogger().Out, info) 41 | } 42 | }() 43 | 44 | log.Infof("Running on %d repositories", len(repos)) 45 | 46 | runInParallel(func(i int) { 47 | logger := log.WithField("repo", repos[i].FullName()) 48 | err := r.runSingleRepo(ctx, repos[i]) 49 | if err != nil { 50 | if err != errAborted { 51 | logger.Info(err) 52 | } 53 | rc.AddError(err, repos[i], nil) 54 | return 55 | } 56 | 57 | rc.AddSuccessRepositories(repos[i]) 58 | }, len(repos), r.Concurrent) 59 | 60 | return nil 61 | } 62 | 63 | func (r Printer) runSingleRepo(ctx context.Context, repo scm.Repository) error { 64 | if ctx.Err() != nil { 65 | return errAborted 66 | } 67 | 68 | log := log.WithField("repo", repo.FullName()) 69 | log.Info("Cloning and running script") 70 | tmpDir, err := createTempDir(r.CloneDir) 71 | 72 | defer os.RemoveAll(tmpDir) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | sourceController := r.CreateGit(tmpDir) 78 | 79 | err = sourceController.Clone(ctx, repo.CloneURL(), repo.DefaultBranch()) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | cmd := prepareScriptCommand(ctx, repo, tmpDir, r.ScriptPath, r.Arguments) 85 | 86 | cmd.Stdout = r.Stdout 87 | cmd.Stderr = r.Stderr 88 | 89 | err = cmd.Run() 90 | if err != nil { 91 | return transformExecError(err) 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /internal/multigitter/repocounter/counter.go: -------------------------------------------------------------------------------- 1 | package repocounter 2 | 3 | import ( 4 | "fmt" 5 | "maps" 6 | "slices" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/lindell/multi-gitter/internal/multigitter/terminal" 11 | "github.com/lindell/multi-gitter/internal/scm" 12 | ) 13 | 14 | // Counter keeps track of succeeded and failed repositories 15 | type Counter struct { 16 | successRepositories []repoInfo 17 | errors map[string][]repoInfo 18 | lock sync.RWMutex 19 | } 20 | 21 | type repoInfo struct { 22 | repository scm.Repository 23 | pullRequest scm.PullRequest 24 | } 25 | 26 | func (ri repoInfo) String() string { 27 | if ri.pullRequest == nil { 28 | return ri.repository.FullName() 29 | } else { 30 | if urler, hasURL := ri.pullRequest.(urler); hasURL && urler.URL() != "" { 31 | return terminal.Link(ri.pullRequest.String(), urler.URL()) 32 | } else { 33 | return ri.pullRequest.String() 34 | } 35 | } 36 | } 37 | 38 | // NewCounter create a new repo counter 39 | func NewCounter() *Counter { 40 | return &Counter{ 41 | errors: map[string][]repoInfo{}, 42 | } 43 | } 44 | 45 | // AddError add a failing repository together with the error that caused it 46 | func (r *Counter) AddError(err error, repo scm.Repository, pr scm.PullRequest) { 47 | defer r.lock.Unlock() 48 | r.lock.Lock() 49 | 50 | msg := err.Error() 51 | r.errors[msg] = append(r.errors[msg], repoInfo{ 52 | repository: repo, 53 | pullRequest: pr, 54 | }) 55 | } 56 | 57 | // AddSuccessRepositories adds a repository that succeeded 58 | func (r *Counter) AddSuccessRepositories(repo scm.Repository) { 59 | defer r.lock.Unlock() 60 | r.lock.Lock() 61 | 62 | r.successRepositories = append(r.successRepositories, repoInfo{ 63 | repository: repo, 64 | }) 65 | } 66 | 67 | // AddSuccessPullRequest adds a pullrequest that succeeded 68 | func (r *Counter) AddSuccessPullRequest(repo scm.Repository, pr scm.PullRequest) { 69 | defer r.lock.Unlock() 70 | r.lock.Lock() 71 | 72 | r.successRepositories = append(r.successRepositories, repoInfo{ 73 | repository: repo, 74 | pullRequest: pr, 75 | }) 76 | } 77 | 78 | // Info returns a formatted string about all repositories 79 | func (r *Counter) Info() string { 80 | defer r.lock.RUnlock() 81 | r.lock.RLock() 82 | 83 | var exitInfo string 84 | 85 | errors := slices.Collect(maps.Keys(r.errors)) 86 | slices.Sort(errors) 87 | 88 | for _, errMsg := range errors { 89 | exitInfo += fmt.Sprintf("%s:\n", strings.ToUpper(errMsg[0:1])+errMsg[1:]) 90 | for _, errInfo := range r.errors[errMsg] { 91 | exitInfo += fmt.Sprintf(" %s\n", errInfo.String()) 92 | } 93 | } 94 | 95 | if len(r.successRepositories) > 0 { 96 | exitInfo += "Repositories with a successful run:\n" 97 | for _, repoInfo := range r.successRepositories { 98 | exitInfo += fmt.Sprintf(" %s\n", repoInfo.String()) 99 | } 100 | } 101 | 102 | return exitInfo 103 | } 104 | 105 | type urler interface { 106 | URL() string 107 | } 108 | -------------------------------------------------------------------------------- /internal/multigitter/repocounter/counter_test.go: -------------------------------------------------------------------------------- 1 | package repocounter_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/lindell/multi-gitter/internal/multigitter/repocounter" 10 | "github.com/lindell/multi-gitter/internal/multigitter/terminal" 11 | "github.com/lindell/multi-gitter/internal/scm" 12 | "github.com/lindell/multi-gitter/tests/vcmock" 13 | ) 14 | 15 | func fakeRepo(i int) vcmock.Repository { 16 | return vcmock.Repository{ 17 | OwnerName: fmt.Sprintf("owner-%d", i), 18 | RepoName: fmt.Sprintf("repo-%d", i), 19 | } 20 | } 21 | 22 | func fakePR(i int) vcmock.PullRequest { 23 | return vcmock.PullRequest{ 24 | PRStatus: scm.PullRequestStatusPending, 25 | PRNumber: 42, 26 | Merged: false, 27 | Repository: fakeRepo(i), 28 | } 29 | } 30 | 31 | func TestCounter_Info(t *testing.T) { 32 | tests := []struct { 33 | name string 34 | changeFn func(r *repocounter.Counter) 35 | want string 36 | }{ 37 | { 38 | name: "empty", 39 | want: "", 40 | changeFn: func(r *repocounter.Counter) {}, 41 | }, 42 | { 43 | name: "one success", 44 | changeFn: func(r *repocounter.Counter) { 45 | r.AddSuccessRepositories(fakeRepo(1)) 46 | }, 47 | want: ` 48 | Repositories with a successful run: 49 | owner-1/repo-1 50 | `, 51 | }, 52 | { 53 | name: "one success with pr", 54 | changeFn: func(r *repocounter.Counter) { 55 | r.AddSuccessPullRequest(fakeRepo(1), fakePR(1)) 56 | }, 57 | want: ` 58 | Repositories with a successful run: 59 | owner-1/repo-1 #42 60 | `, 61 | }, 62 | { 63 | name: "one success with pr and link", 64 | changeFn: func(r *repocounter.Counter) { 65 | repo := vcmock.Repository{ 66 | OwnerName: "owner-1", 67 | RepoName: "has-url", 68 | } 69 | pr := vcmock.PullRequest{ 70 | PRStatus: scm.PullRequestStatusPending, 71 | Repository: repo, 72 | PRNumber: 42, 73 | } 74 | r.AddSuccessPullRequest(repo, pr) 75 | }, 76 | want: ` 77 | Repositories with a successful run: 78 | ` + terminal.Link("owner-1/has-url #42", "https://github.com/owner/has-url/pull/1") + ` 79 | `, 80 | }, 81 | { 82 | name: "one error no pr", 83 | changeFn: func(r *repocounter.Counter) { 84 | r.AddError(errors.New("test error"), fakeRepo(1), nil) 85 | }, 86 | want: ` 87 | Test error: 88 | owner-1/repo-1 89 | `, 90 | }, 91 | { 92 | name: "one error with pr", 93 | changeFn: func(r *repocounter.Counter) { 94 | r.AddError(errors.New("test error"), fakeRepo(1), fakePR(1)) 95 | }, 96 | want: ` 97 | Test error: 98 | owner-1/repo-1 #42 99 | `, 100 | }, 101 | { 102 | name: "one error with pr and link", 103 | changeFn: func(r *repocounter.Counter) { 104 | repo := vcmock.Repository{ 105 | OwnerName: "owner-1", 106 | RepoName: "has-url", 107 | } 108 | pr := vcmock.PullRequest{ 109 | PRStatus: scm.PullRequestStatusPending, 110 | Repository: repo, 111 | PRNumber: 42, 112 | } 113 | r.AddError(errors.New("test error"), repo, pr) 114 | }, 115 | want: ` 116 | Test error: 117 | ` + terminal.Link("owner-1/has-url #42", "https://github.com/owner/has-url/pull/1") + ` 118 | `, 119 | }, 120 | { 121 | name: "one error with pr", 122 | changeFn: func(r *repocounter.Counter) { 123 | r.AddError(errors.New("test error"), fakeRepo(1), fakePR(1)) 124 | }, 125 | want: ` 126 | Test error: 127 | owner-1/repo-1 #42 128 | `, 129 | }, 130 | { 131 | name: "multiple", 132 | changeFn: func(r *repocounter.Counter) { 133 | r.AddError(errors.New("test error"), fakeRepo(1), fakePR(1)) 134 | r.AddSuccessPullRequest(fakeRepo(2), fakePR(2)) 135 | r.AddSuccessPullRequest(fakeRepo(3), fakePR(3)) 136 | r.AddError(errors.New("test error 2"), fakeRepo(5), fakePR(5)) 137 | r.AddError(errors.New("test error"), fakeRepo(4), fakePR(4)) 138 | }, 139 | want: ` 140 | Test error: 141 | owner-1/repo-1 #42 142 | owner-4/repo-4 #42 143 | Test error 2: 144 | owner-5/repo-5 #42 145 | Repositories with a successful run: 146 | owner-2/repo-2 #42 147 | owner-3/repo-3 #42 148 | `, 149 | }, 150 | } 151 | for _, tt := range tests { 152 | t.Run(tt.name, func(t *testing.T) { 153 | r := repocounter.NewCounter() 154 | tt.changeFn(r) 155 | 156 | if got := r.Info(); got != strings.TrimLeft(tt.want, "\n") { 157 | t.Errorf("Counter.Info() = %v, want %v", got, tt.want) 158 | } 159 | }) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /internal/multigitter/shared.go: -------------------------------------------------------------------------------- 1 | package multigitter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "syscall" 9 | 10 | "github.com/lindell/multi-gitter/internal/git" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type urler interface { 15 | URL() string 16 | } 17 | 18 | func transformExecError(err error) error { 19 | var sysErr syscall.Errno 20 | if ok := errors.As(err, &sysErr); ok { 21 | if sysErr.Error() == "exec format error" { 22 | return errors.New("the script or program is in the wrong format") 23 | } 24 | } 25 | return err 26 | } 27 | 28 | // Git is a git implementation 29 | type Git interface { 30 | Clone(ctx context.Context, url string, baseName string) error 31 | ChangeBranch(branchName string) error 32 | Changes() (bool, error) 33 | Commit(commitAuthor *git.CommitAuthor, commitMessage string) error 34 | BranchExist(remoteName, branchName string) (bool, error) 35 | Push(ctx context.Context, remoteName string, force bool) error 36 | AddRemote(name, url string) error 37 | } 38 | 39 | type stackTracer interface { 40 | StackTrace() errors.StackTrace 41 | } 42 | 43 | func getStackTrace(err error) string { 44 | if err, ok := err.(stackTracer); ok { 45 | trace := "" 46 | for _, f := range err.StackTrace() { 47 | trace += fmt.Sprintf("%+s:%d\n", f, f) 48 | } 49 | return trace 50 | } 51 | return "" 52 | } 53 | 54 | // ConflictStrategy define how a conflict of an already existing branch should be handled 55 | type ConflictStrategy int 56 | 57 | const ( 58 | // ConflictStrategySkip will skip the run for if the branch does already exist 59 | ConflictStrategySkip ConflictStrategy = iota + 1 60 | // ConflictStrategyReplace will ignore any existing branch and replace it with new changes 61 | ConflictStrategyReplace 62 | ) 63 | 64 | // ParseConflictStrategy parses a conflict strategy from a string 65 | func ParseConflictStrategy(str string) (ConflictStrategy, error) { 66 | switch str { 67 | default: 68 | return ConflictStrategy(0), fmt.Errorf("could not parse \"%s\" as conflict strategy", str) 69 | case "skip": 70 | return ConflictStrategySkip, nil 71 | case "replace": 72 | return ConflictStrategyReplace, nil 73 | } 74 | } 75 | 76 | // createTempDir creates a temporary directory in the given directory. 77 | // If the given directory is an empty string, it will use the os.TempDir() 78 | func createTempDir(cloneDir string) (string, error) { 79 | if cloneDir == "" { 80 | cloneDir = os.TempDir() 81 | } 82 | 83 | absDir, err := makeAbsolutePath(cloneDir) 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | err = createDirectoryIfDoesntExist(absDir) 89 | if err != nil { 90 | return "", err 91 | } 92 | 93 | tmpDir, err := os.MkdirTemp(absDir, "multi-git-changer-") 94 | if err != nil { 95 | return "", err 96 | } 97 | 98 | return tmpDir, nil 99 | } 100 | 101 | func createDirectoryIfDoesntExist(directoryPath string) error { 102 | // Check if the directory exists 103 | if _, err := os.Stat(directoryPath); !os.IsNotExist(err) { 104 | return nil 105 | } 106 | 107 | // Create the directory 108 | err := os.MkdirAll(directoryPath, 0700) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return nil 114 | } 115 | 116 | // makeAbsolutePath creates an absolute path from a relative path 117 | func makeAbsolutePath(path string) (string, error) { 118 | workingDir, err := os.Getwd() 119 | if err != nil { 120 | return "", errors.Wrap(err, "could not get the working directory") 121 | } 122 | 123 | if !filepath.IsAbs(path) { 124 | return filepath.Join(workingDir, path), nil 125 | } 126 | 127 | return path, nil 128 | } 129 | -------------------------------------------------------------------------------- /internal/multigitter/status.go: -------------------------------------------------------------------------------- 1 | package multigitter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/lindell/multi-gitter/internal/multigitter/terminal" 9 | ) 10 | 11 | // Statuser checks the statuses of pull requests 12 | type Statuser struct { 13 | VersionController VersionController 14 | 15 | Output io.Writer 16 | 17 | FeatureBranch string 18 | } 19 | 20 | // Statuses checks the statuses of pull requests 21 | func (s Statuser) Statuses(ctx context.Context) error { 22 | prs, err := s.VersionController.GetPullRequests(ctx, s.FeatureBranch) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | for _, pr := range prs { 28 | if urler, hasURL := pr.(urler); hasURL && urler.URL() != "" { 29 | fmt.Fprintf(s.Output, "%s: %s\n", terminal.Link(pr.String(), urler.URL()), pr.Status()) 30 | } else { 31 | fmt.Fprintf(s.Output, "%s: %s\n", pr.String(), pr.Status()) 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/multigitter/terminal/terminal.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import "fmt" 4 | 5 | // Printer formats things to the terminal 6 | type Printer struct { 7 | Plain bool // Don't use terminal formatting 8 | } 9 | 10 | var DefaultPrinter = &Printer{} 11 | 12 | // Link generates a link in that can be displayed in the terminal 13 | // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda 14 | func (t *Printer) Link(text, url string) string { 15 | if t.Plain { 16 | return text 17 | } 18 | 19 | return fmt.Sprintf("\x1B]8;;%s\a%s\x1B]8;;\a", url, text) 20 | } 21 | 22 | // Bold generates a bold text for the terminal 23 | func (t *Printer) Bold(text string) string { 24 | if t.Plain { 25 | return text 26 | } 27 | 28 | return fmt.Sprintf("\033[1m%s\033[0m", text) 29 | } 30 | 31 | // Link generates a link in that can be displayed in the terminal using the default terminal printer 32 | func Link(text, url string) string { 33 | return DefaultPrinter.Link(text, url) 34 | } 35 | 36 | // Bold generates a bold text for the terminal using the default terminal printer 37 | func Bold(text string) string { 38 | return DefaultPrinter.Bold(text) 39 | } 40 | -------------------------------------------------------------------------------- /internal/scm/README.md: -------------------------------------------------------------------------------- 1 | # Source Control Managers 2 | 3 | This folder contains all Source Control Managers. They do all implement the `VersionController` interface described below. 4 | 5 | ```go 6 | type VersionController interface { 7 | // Should get repositories based on the scm configuration 8 | GetRepositories(ctx context.Context) ([]scm.Repository, error) 9 | // Creates a pull request. The repo parameter will always originate from the same package 10 | CreatePullRequest(ctx context.Context, repo scm.Repository, prRepo scm.Repository, newPR scm.NewPullRequest) (scm.PullRequest, error) 11 | // Gets the latest pull requests from repositories based on the scm configuration 12 | GetPullRequests(ctx context.Context, branchName string) ([]scm.PullRequest, error) 13 | // Merges a pull request, the pr parameter will always originate from the same package 14 | MergePullRequest(ctx context.Context, pr scm.PullRequest) error 15 | // Close a pull request, the pr parameter will always originate from the same package 16 | ClosePullRequest(ctx context.Context, pr scm.PullRequest) error 17 | // ForkRepository forks a repository. If newOwner is set, use it, otherwise fork to the current user 18 | ForkRepository(ctx context.Context, repo scm.Repository, newOwner string) (scm.Repository, error) 19 | } 20 | ``` 21 | 22 | 23 | ## Autocompletion 24 | 25 | The version controller can also implement additional functions to support features such as shell-autocompletion. The following functions can be implemented independently and will automatically be used for tab completions when the user has activated it. 26 | 27 | ```go 28 | func GetAutocompleteOrganizations(ctx context.Context, search string) ([]string, error) 29 | ``` 30 | ```go 31 | func GetAutocompleteUsers(ctx context.Context, search string) ([]string, error) 32 | ``` 33 | ```go 34 | func GetAutocompleteRepositories(ctx context.Context, search string) ([]string, error) 35 | ``` 36 | -------------------------------------------------------------------------------- /internal/scm/bitbucketcloud/custom_structs.go: -------------------------------------------------------------------------------- 1 | package bitbucketcloud 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ktrysmt/go-bitbucket" 7 | "github.com/lindell/multi-gitter/internal/scm" 8 | ) 9 | 10 | const ( 11 | cloneHTTPType = "https" 12 | cloneSSHType = "ssh" 13 | stateMerged = "MERGED" 14 | stateDeclined = "DECLINED" 15 | ) 16 | 17 | type newPrResponse struct { 18 | ID int `json:"id"` 19 | Links links `json:"links"` 20 | } 21 | 22 | type bitbucketPullRequests struct { 23 | Next string `json:"next"` 24 | Page int `json:"page"` 25 | PageLen int `json:"pagelen"` 26 | Previous string `json:"previous"` 27 | Size int `json:"size"` 28 | Values []bbPullRequest `json:"values"` 29 | } 30 | 31 | type bbPullRequest struct { 32 | State string `json:"state"` 33 | Source pullRequestRef `json:"source"` 34 | Destination pullRequestRef `json:"destination"` 35 | Links links `json:"links"` 36 | Title string `json:"title"` 37 | Type string `json:"type"` 38 | ID int `json:"id"` 39 | } 40 | 41 | type pullRequestRef struct { 42 | Branch branch `json:"branch"` 43 | Commit commit `json:"Commit"` 44 | Repository bitbucket.Repository `json:"repository"` 45 | } 46 | 47 | type branch struct { 48 | Name string `json:"name"` 49 | } 50 | 51 | type commit struct { 52 | Hash string `json:"hash"` 53 | Type string `json:"type"` 54 | Links links `json:"links"` 55 | } 56 | 57 | type links struct { 58 | Self hrefLink `json:"self,omitempty"` 59 | HTML hrefLink `json:"html,omitempty"` 60 | } 61 | 62 | type repoLinks struct { 63 | Clone []hrefLink `json:"clone,omitempty"` 64 | Self []hrefLink `json:"self,omitempty"` 65 | HTML []hrefLink `json:"html,omitempty"` 66 | } 67 | 68 | type hrefLink struct { 69 | Href string `json:"href"` 70 | Name string `json:"name"` 71 | } 72 | 73 | type pullRequest struct { 74 | project string 75 | repoName string 76 | branchName string 77 | prProject string 78 | prRepoName string 79 | number int 80 | guiURL string 81 | status scm.PullRequestStatus 82 | } 83 | 84 | func (pr pullRequest) String() string { 85 | return fmt.Sprintf("%s/%s #%d", pr.project, pr.repoName, pr.number) 86 | } 87 | 88 | func (pr pullRequest) Status() scm.PullRequestStatus { 89 | return pr.status 90 | } 91 | 92 | func (pr pullRequest) URL() string { 93 | return pr.guiURL 94 | } 95 | 96 | // repository contains information about a bitbucket repository 97 | type repository struct { 98 | name string 99 | project string 100 | defaultBranch string 101 | cloneURL string 102 | } 103 | 104 | func (r repository) CloneURL() string { 105 | return r.cloneURL 106 | } 107 | 108 | func (r repository) DefaultBranch() string { 109 | return r.defaultBranch 110 | } 111 | 112 | func (r repository) FullName() string { 113 | return r.project + "/" + r.name 114 | } 115 | -------------------------------------------------------------------------------- /internal/scm/bitbucketserver/pullrequest.go: -------------------------------------------------------------------------------- 1 | package bitbucketserver 2 | 3 | import ( 4 | "fmt" 5 | 6 | bitbucketv1 "github.com/gfleury/go-bitbucket-v1" 7 | 8 | "github.com/lindell/multi-gitter/internal/scm" 9 | ) 10 | 11 | func newPullRequest(pr bitbucketv1.PullRequest) pullRequest { 12 | return pullRequest{ 13 | project: pr.ToRef.Repository.Project.Key, 14 | repoName: pr.ToRef.Repository.Slug, 15 | branchName: pr.FromRef.DisplayID, 16 | prProject: pr.FromRef.Repository.Project.Key, 17 | prRepoName: pr.FromRef.Repository.Slug, 18 | number: pr.ID, 19 | guiURL: pr.Links.Self[0].Href, 20 | } 21 | } 22 | 23 | type pullRequest struct { 24 | project string 25 | repoName string 26 | branchName string 27 | prProject string 28 | prRepoName string 29 | number int 30 | version int32 31 | guiURL string 32 | status scm.PullRequestStatus 33 | } 34 | 35 | func (pr pullRequest) String() string { 36 | return fmt.Sprintf("%s/%s #%d", pr.project, pr.repoName, pr.number) 37 | } 38 | 39 | func (pr pullRequest) Status() scm.PullRequestStatus { 40 | return pr.status 41 | } 42 | 43 | func (pr pullRequest) URL() string { 44 | return pr.guiURL 45 | } 46 | -------------------------------------------------------------------------------- /internal/scm/bitbucketserver/repository.go: -------------------------------------------------------------------------------- 1 | package bitbucketserver 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | 7 | bitbucketv1 "github.com/gfleury/go-bitbucket-v1" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | func (b *BitbucketServer) convertRepository(bitbucketRepository *bitbucketv1.Repository, defaultBranch bitbucketv1.Branch) (*repository, error) { 12 | var cloneURL string 13 | 14 | if b.sshAuth { 15 | cloneURL = findLinkType(bitbucketRepository.Links.Clone, cloneSSHType) 16 | if cloneURL == "" { 17 | return nil, errors.Errorf("unable to find clone url for repository %s using clone type %s", bitbucketRepository.Name, cloneSSHType) 18 | } 19 | } else { 20 | httpURL := findLinkType(bitbucketRepository.Links.Clone, cloneHTTPType) 21 | if httpURL == "" { 22 | return nil, errors.Errorf("unable to find clone url for repository %s using clone type %s", bitbucketRepository.Name, cloneHTTPType) 23 | } 24 | parsedURL, err := url.Parse(httpURL) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | parsedURL.User = url.UserPassword(b.username, b.token) 30 | cloneURL = parsedURL.String() 31 | } 32 | 33 | repo := repository{ 34 | name: bitbucketRepository.Slug, 35 | project: bitbucketRepository.Project.Key, 36 | defaultBranch: defaultBranch.DisplayID, 37 | cloneURL: cloneURL, 38 | } 39 | 40 | return &repo, nil 41 | } 42 | 43 | func findLinkType(links []bitbucketv1.CloneLink, cloneType string) string { 44 | for _, clone := range links { 45 | if strings.EqualFold(clone.Name, cloneType) { 46 | return clone.Href 47 | } 48 | } 49 | 50 | return "" 51 | } 52 | 53 | // repository contains information about a bitbucket repository 54 | type repository struct { 55 | name string 56 | project string 57 | defaultBranch string 58 | cloneURL string 59 | } 60 | 61 | func (r repository) CloneURL() string { 62 | return r.cloneURL 63 | } 64 | 65 | func (r repository) DefaultBranch() string { 66 | return r.defaultBranch 67 | } 68 | 69 | func (r repository) FullName() string { 70 | return r.project + "/" + r.name 71 | } 72 | -------------------------------------------------------------------------------- /internal/scm/changes.go: -------------------------------------------------------------------------------- 1 | package scm 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lindell/multi-gitter/internal/git" 7 | ) 8 | 9 | // ChangePusher makes a commit through the API 10 | type ChangePusher interface { 11 | Push( 12 | ctx context.Context, 13 | repo Repository, 14 | commitMessage string, 15 | change git.Changes, 16 | featureBranch string, 17 | branchExist bool, 18 | forcePush bool, 19 | ) error 20 | } 21 | -------------------------------------------------------------------------------- /internal/scm/gitea/pullrequest.go: -------------------------------------------------------------------------------- 1 | package gitea 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lindell/multi-gitter/internal/scm" 7 | ) 8 | 9 | type pullRequest struct { 10 | ownerName string 11 | repoName string 12 | branchName string 13 | prOwnerName string 14 | prRepoName string 15 | index int64 // The id of the PR 16 | webURL string 17 | status scm.PullRequestStatus 18 | } 19 | 20 | func (pr pullRequest) String() string { 21 | return fmt.Sprintf("%s/%s #%d", pr.ownerName, pr.repoName, pr.index) 22 | } 23 | 24 | func (pr pullRequest) Status() scm.PullRequestStatus { 25 | return pr.status 26 | } 27 | 28 | func (pr pullRequest) URL() string { 29 | return pr.webURL 30 | } 31 | -------------------------------------------------------------------------------- /internal/scm/gitea/repository.go: -------------------------------------------------------------------------------- 1 | package gitea 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "code.gitea.io/sdk/gitea" 8 | ) 9 | 10 | func (g *Gitea) convertRepository(repo *gitea.Repository) (repository, error) { 11 | var repoURL string 12 | if g.SSHAuth { 13 | repoURL = repo.SSHURL 14 | } else { 15 | u, err := url.Parse(repo.CloneURL) 16 | if err != nil { 17 | return repository{}, err 18 | } 19 | // Set the token as https://oauth2:TOKEN@url 20 | u.User = url.UserPassword("oauth2", g.token) 21 | repoURL = u.String() 22 | } 23 | 24 | return repository{ 25 | url: repoURL, 26 | name: repo.Name, 27 | ownerName: repo.Owner.UserName, 28 | defaultBranch: repo.DefaultBranch, 29 | }, nil 30 | } 31 | 32 | type repository struct { 33 | url string 34 | name string 35 | ownerName string 36 | defaultBranch string 37 | } 38 | 39 | func (r repository) CloneURL() string { 40 | return r.url 41 | } 42 | 43 | func (r repository) DefaultBranch() string { 44 | return r.defaultBranch 45 | } 46 | 47 | func (r repository) FullName() string { 48 | return fmt.Sprintf("%s/%s", r.ownerName, r.name) 49 | } 50 | -------------------------------------------------------------------------------- /internal/scm/gitea/util.go: -------------------------------------------------------------------------------- 1 | package gitea 2 | 3 | import ( 4 | "code.gitea.io/sdk/gitea" 5 | "github.com/lindell/multi-gitter/internal/scm" 6 | ) 7 | 8 | // maps merge types to what they are called in the gitea api 9 | var mergeTypeGiteaName = map[scm.MergeType]gitea.MergeStyle{ 10 | scm.MergeTypeMerge: gitea.MergeStyleMerge, 11 | scm.MergeTypeRebase: gitea.MergeStyleRebase, 12 | scm.MergeTypeSquash: gitea.MergeStyleSquash, 13 | } 14 | 15 | // repoMergeTypes returns a list of all allowed merge types 16 | func repoMergeTypes(repo *gitea.Repository) []scm.MergeType { 17 | ret := []scm.MergeType{} 18 | if repo.AllowMerge { 19 | ret = append(ret, scm.MergeTypeMerge) 20 | } 21 | if repo.AllowMerge { 22 | ret = append(ret, scm.MergeTypeRebase) 23 | } 24 | if repo.AllowSquash { 25 | ret = append(ret, scm.MergeTypeSquash) 26 | } 27 | return ret 28 | } 29 | -------------------------------------------------------------------------------- /internal/scm/github/commit.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/lindell/multi-gitter/internal/git" 10 | "github.com/lindell/multi-gitter/internal/scm" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // Github should implement the ChangePusher interface 15 | var _ scm.ChangePusher = &Github{} 16 | 17 | func (g *Github) Push( 18 | ctx context.Context, 19 | r scm.Repository, 20 | commitMessage string, 21 | changes git.Changes, 22 | featureBranch string, 23 | branchExist bool, 24 | forcePush bool, 25 | ) error { 26 | repo := r.(repository) 27 | 28 | // There is no way to force push with the API, so we need to delete the branch 29 | // and create it again. 30 | if forcePush { 31 | err := g.deleteRef(ctx, repo, featureBranch) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | branchExist = false 37 | } 38 | 39 | if !branchExist { 40 | err := g.CreateBranch(ctx, repo, featureBranch, changes.OldHash) 41 | if err != nil { 42 | return err 43 | } 44 | } 45 | 46 | err := g.CommitThroughAPI(ctx, repo, featureBranch, commitMessage, changes) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (g *Github) CommitThroughAPI(ctx context.Context, 55 | repo repository, 56 | branch string, 57 | commitMessage string, 58 | changes git.Changes, 59 | ) error { 60 | query := ` 61 | mutation ($input: CreateCommitOnBranchInput!) { 62 | createCommitOnBranch(input: $input) { 63 | commit { 64 | url 65 | } 66 | } 67 | }` 68 | 69 | var v createCommitOnBranchInput 70 | 71 | v.Input.Branch.RepositoryNameWithOwner = repo.ownerName + "/" + repo.name 72 | 73 | v.Input.Branch.BranchName = branch 74 | v.Input.ExpectedHeadOid = changes.OldHash 75 | v.Input.Message.Headline = commitMessage 76 | 77 | for path, contents := range changes.Additions { 78 | v.Input.FileChanges.Additions = append(v.Input.FileChanges.Additions, commitAddition{ 79 | Path: path, 80 | Contents: base64.StdEncoding.EncodeToString(contents), 81 | }) 82 | } 83 | 84 | for _, path := range changes.Deletions { 85 | v.Input.FileChanges.Deletions = append(v.Input.FileChanges.Deletions, commitDeletion{ 86 | Path: path, 87 | }) 88 | } 89 | 90 | var result map[string]interface{} 91 | 92 | err := g.makeGraphQLRequest(ctx, query, v, &result) 93 | if err != nil { 94 | return errors.WithMessage(err, "could not commit changes though API") 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func (g *Github) CreateBranch(ctx context.Context, repo repository, branchName string, oid string) error { 101 | query := `mutation($input: CreateRefInput!){ 102 | createRef(input: $input) { 103 | ref { 104 | name 105 | } 106 | } 107 | }` 108 | 109 | if !strings.HasPrefix(repo.name, "refs/heads/") { 110 | branchName = "refs/heads/" + branchName 111 | } 112 | 113 | var cri CreateRefInput 114 | cri.Input.Name = branchName 115 | 116 | cri.Input.Oid = oid 117 | cri.Input.RepositoryID = repo.id 118 | 119 | err := g.makeGraphQLRequest(ctx, query, cri, nil) 120 | if err != nil { 121 | return errors.WithMessage(err, "could not create branch") 122 | } 123 | 124 | return nil 125 | } 126 | 127 | func (g *Github) deleteRef(ctx context.Context, repo repository, branchName string) error { 128 | branchRef, err := g.getBranchID(ctx, repo, branchName) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | query := `mutation($input: DeleteRefInput!){ 134 | deleteRef(input: $input){ 135 | clientMutationId 136 | } 137 | }` 138 | 139 | var deleteRefInput DeleteRefInput 140 | deleteRefInput.Input.RefID = branchRef 141 | 142 | err = g.makeGraphQLRequest(ctx, query, deleteRefInput, nil) 143 | if err != nil { 144 | return errors.WithMessage(err, "could not delete branch") 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func (g *Github) getBranchID(ctx context.Context, repo repository, branchName string) (string, error) { 151 | query := `query($owner: String!, $name: String!, $qualifiedName: String!) { 152 | repository(owner: $owner, name: $name) { 153 | ref(qualifiedName: $qualifiedName) { 154 | id 155 | } 156 | } 157 | }` 158 | 159 | var result getRefOutput 160 | err := g.makeGraphQLRequest(ctx, query, &RepositoryInput{ 161 | Name: repo.name, 162 | Owner: repo.ownerName, 163 | QualifiedName: fmt.Sprintf("refs/heads/%s", branchName), 164 | }, &result) 165 | if err != nil { 166 | return "", errors.WithMessage(err, "could not get branch ID") 167 | } 168 | 169 | return result.Repository.Ref.ID, nil 170 | } 171 | 172 | type createCommitOnBranchInput struct { 173 | Input struct { 174 | ExpectedHeadOid string `json:"expectedHeadOid"` 175 | Branch struct { 176 | RepositoryNameWithOwner string `json:"repositoryNameWithOwner"` 177 | BranchName string `json:"branchName"` 178 | } `json:"branch"` 179 | Message struct { 180 | Headline string `json:"headline"` 181 | } `json:"message"` 182 | FileChanges struct { 183 | Additions []commitAddition `json:"additions,omitempty"` 184 | Deletions []commitDeletion `json:"deletions,omitempty"` 185 | } `json:"fileChanges"` 186 | } `json:"input"` 187 | } 188 | 189 | type commitAddition struct { 190 | Path string `json:"path,omitempty"` 191 | Contents string `json:"contents,omitempty"` 192 | } 193 | 194 | type commitDeletion struct { 195 | Path string `json:"path,omitempty"` 196 | } 197 | 198 | type CreateRefInput struct { 199 | Input struct { 200 | Name string `json:"name"` 201 | Oid string `json:"oid"` 202 | RepositoryID string `json:"repositoryId"` 203 | } `json:"input"` 204 | } 205 | 206 | type RepositoryInput struct { 207 | Name string `json:"name"` 208 | Owner string `json:"owner"` 209 | QualifiedName string `json:"qualifiedName"` 210 | } 211 | 212 | type DeleteRefInput struct { 213 | Input struct { 214 | RefID string `json:"refId"` 215 | } `json:"input"` 216 | } 217 | 218 | type getRepositoryOutput struct { 219 | Repository struct { 220 | ID string `json:"id"` 221 | } `json:"repository"` 222 | } 223 | 224 | type getRefOutput struct { 225 | Repository struct { 226 | Ref struct { 227 | ID string `json:"id"` 228 | } `json:"ref"` 229 | } `json:"repository"` 230 | } 231 | -------------------------------------------------------------------------------- /internal/scm/github/graphql.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | func (g *Github) makeGraphQLRequestWithRetry(ctx context.Context, query string, data interface{}, res interface{}) error { 16 | return retryAPIRequest(ctx, func() error { 17 | return g.makeGraphQLRequest(ctx, query, data, res) 18 | }) 19 | } 20 | 21 | func (g *Github) makeGraphQLRequest(ctx context.Context, query string, data interface{}, res interface{}) error { 22 | type reqData struct { 23 | Query string `json:"query"` 24 | Data interface{} `json:"variables"` 25 | } 26 | rawReqData, err := json.Marshal(reqData{ 27 | Query: query, 28 | Data: data, 29 | }) 30 | 31 | if err != nil { 32 | return errors.WithMessage(err, "could not marshal graphql request") 33 | } 34 | 35 | graphQLURL := "https://api.github.com/graphql" 36 | if g.baseURL != "" { 37 | graphQLURL, err = graphQLEndpoint(g.baseURL) 38 | if err != nil { 39 | return errors.WithMessage(err, "could not get graphql endpoint") 40 | } 41 | } 42 | 43 | req, err := http.NewRequestWithContext(ctx, "POST", graphQLURL, bytes.NewBuffer(rawReqData)) 44 | if err != nil { 45 | return errors.WithMessage(err, "could not create graphql request") 46 | } 47 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", g.token)) 48 | 49 | resp, err := g.httpClient.Do(req) 50 | if err != nil { 51 | return err 52 | } 53 | defer resp.Body.Close() 54 | 55 | retryAfterErr := retryAfterFromHTTPResponse(resp) 56 | if retryAfterErr != nil { 57 | return retryAfterErr 58 | } 59 | 60 | resultData := struct { 61 | Data json.RawMessage `json:"data"` 62 | Errors []struct { 63 | Message string `json:"message"` 64 | } `json:"errors"` 65 | Message string `json:"message"` 66 | }{} 67 | 68 | if err := json.NewDecoder(resp.Body).Decode(&resultData); err != nil { 69 | return errors.WithMessage(err, "could not read graphql response body") 70 | } 71 | 72 | if len(resultData.Errors) > 0 { 73 | errorsMsgs := make([]string, len(resultData.Errors)) 74 | for i := range resultData.Errors { 75 | errorsMsgs[i] = resultData.Errors[i].Message 76 | } 77 | return errors.WithMessage( 78 | errors.New(strings.Join(errorsMsgs, "\n")), 79 | "encountered error during GraphQL query", 80 | ) 81 | } 82 | 83 | if resp.StatusCode >= 400 { 84 | return errors.Errorf("could not make GitHub GraphQL request: %s", resultData.Message) 85 | } 86 | 87 | if res == nil { 88 | return nil 89 | } 90 | 91 | if err := json.Unmarshal(resultData.Data, res); err != nil { 92 | return errors.WithMessage(err, "could not unmarshal graphQL result") 93 | } 94 | 95 | return nil 96 | } 97 | 98 | // graphQLEndpoint takes a url to a github enterprise instance (or the v3 api) and returns the url to the graphql endpoint 99 | func graphQLEndpoint(u string) (string, error) { 100 | baseEndpoint, err := url.Parse(u) 101 | if err != nil { 102 | return "", err 103 | } 104 | if !strings.HasSuffix(baseEndpoint.Path, "/") { 105 | baseEndpoint.Path += "/" 106 | } 107 | 108 | if strings.HasPrefix(baseEndpoint.Host, "api.") || 109 | strings.Contains(baseEndpoint.Host, ".api.") { 110 | baseEndpoint.Path += "graphql" 111 | } else { 112 | baseEndpoint.Path = stripSuffixIfExist(baseEndpoint.Path, "v3/") 113 | baseEndpoint.Path = stripSuffixIfExist(baseEndpoint.Path, "api/") 114 | baseEndpoint.Path += "api/graphql" 115 | } 116 | 117 | return baseEndpoint.String(), nil 118 | } 119 | 120 | type graphqlPullRequestState string 121 | 122 | const ( 123 | graphqlPullRequestStateError graphqlPullRequestState = "ERROR" 124 | graphqlPullRequestStateFailure graphqlPullRequestState = "FAILURE" 125 | graphqlPullRequestStatePending graphqlPullRequestState = "PENDING" 126 | graphqlPullRequestStateSuccess graphqlPullRequestState = "SUCCESS" 127 | ) 128 | 129 | type graphqlRepo struct { 130 | PullRequests struct { 131 | Nodes []graphqlPR `json:"nodes"` 132 | } `json:"pullRequests"` 133 | } 134 | 135 | type graphqlPR struct { 136 | Number int `json:"number"` 137 | HeadRefName string `json:"headRefName"` 138 | Closed bool `json:"closed"` 139 | URL string `json:"url"` 140 | Merged bool `json:"merged"` 141 | BaseRepository struct { 142 | Name string `json:"name"` 143 | Owner struct { 144 | Login string `json:"login"` 145 | } `json:"owner"` 146 | } `json:"baseRepository"` 147 | HeadRepository struct { 148 | Name string `json:"name"` 149 | Owner struct { 150 | Login string `json:"login"` 151 | } `json:"owner"` 152 | } `json:"headRepository"` 153 | Commits struct { 154 | Nodes []struct { 155 | Commit struct { 156 | StatusCheckRollup struct { 157 | State *graphqlPullRequestState `json:"state"` 158 | } `json:"statusCheckRollup"` 159 | } `json:"commit"` 160 | } `json:"nodes"` 161 | } `json:"commits"` 162 | } 163 | -------------------------------------------------------------------------------- /internal/scm/github/graphql_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func Test_graphQLEndpoint(t *testing.T) { 9 | tests := []struct { 10 | url string 11 | want string 12 | }{ 13 | {url: "https://github.detsbihcs.io/api/v3", want: "https://github.detsbihcs.io/api/graphql"}, 14 | {url: "https://github.detsbihcs.io/api/v3/", want: "https://github.detsbihcs.io/api/graphql"}, 15 | {url: "https://github.detsbihcs.io/api/", want: "https://github.detsbihcs.io/api/graphql"}, 16 | {url: "https://github.detsbihcs.io/", want: "https://github.detsbihcs.io/api/graphql"}, 17 | {url: "https://api.github.detsbihcs.io/", want: "https://api.github.detsbihcs.io/graphql"}, 18 | {url: "https://more.api.github.detsbihcs.io/", want: "https://more.api.github.detsbihcs.io/graphql"}, 19 | } 20 | for _, tt := range tests { 21 | t.Run(fmt.Sprintf("url: %s", tt.url), func(t *testing.T) { 22 | if got, _ := graphQLEndpoint(tt.url); got != tt.want { 23 | t.Errorf("graphQLEndpoint() = %v, want %v", got, tt.want) 24 | } 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/scm/github/pullrequest.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/go-github/v70/github" 7 | 8 | "github.com/lindell/multi-gitter/internal/scm" 9 | ) 10 | 11 | func convertPullRequest(pr *github.PullRequest) pullRequest { 12 | return pullRequest{ 13 | ownerName: pr.GetBase().GetUser().GetLogin(), 14 | repoName: pr.GetBase().GetRepo().GetName(), 15 | branchName: pr.GetHead().GetRef(), 16 | prOwnerName: pr.GetHead().GetUser().GetLogin(), 17 | prRepoName: pr.GetHead().GetRepo().GetName(), 18 | number: pr.GetNumber(), 19 | guiURL: pr.GetHTMLURL(), 20 | } 21 | } 22 | 23 | func convertGraphQLPullRequest(pr graphqlPR) pullRequest { 24 | var combinedStatus *graphqlPullRequestState 25 | nodes := pr.Commits.Nodes 26 | if len(nodes) > 0 { 27 | combinedStatus = nodes[0].Commit.StatusCheckRollup.State 28 | } 29 | 30 | status := scm.PullRequestStatusUnknown 31 | 32 | if pr.Merged { 33 | status = scm.PullRequestStatusMerged 34 | } else if pr.Closed { 35 | status = scm.PullRequestStatusClosed 36 | } else if combinedStatus == nil { 37 | status = scm.PullRequestStatusSuccess 38 | } else { 39 | switch *combinedStatus { 40 | case graphqlPullRequestStatePending: 41 | status = scm.PullRequestStatusPending 42 | case graphqlPullRequestStateSuccess: 43 | status = scm.PullRequestStatusSuccess 44 | case graphqlPullRequestStateFailure, graphqlPullRequestStateError: 45 | status = scm.PullRequestStatusError 46 | } 47 | } 48 | 49 | return pullRequest{ 50 | ownerName: pr.BaseRepository.Owner.Login, 51 | repoName: pr.BaseRepository.Name, 52 | branchName: pr.HeadRefName, 53 | prOwnerName: pr.HeadRepository.Owner.Login, 54 | prRepoName: pr.HeadRepository.Name, 55 | number: pr.Number, 56 | guiURL: pr.URL, 57 | status: status, 58 | } 59 | } 60 | 61 | type pullRequest struct { 62 | ownerName string 63 | repoName string 64 | branchName string 65 | prOwnerName string 66 | prRepoName string 67 | number int 68 | guiURL string 69 | status scm.PullRequestStatus 70 | } 71 | 72 | func (pr pullRequest) String() string { 73 | return fmt.Sprintf("%s/%s #%d", pr.ownerName, pr.repoName, pr.number) 74 | } 75 | 76 | func (pr pullRequest) Status() scm.PullRequestStatus { 77 | return pr.status 78 | } 79 | 80 | func (pr pullRequest) URL() string { 81 | return pr.guiURL 82 | } 83 | -------------------------------------------------------------------------------- /internal/scm/github/pullrequest_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lindell/multi-gitter/internal/scm" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_convertGraphQLPullRequest(t *testing.T) { 11 | scenarios := []struct { 12 | name string 13 | pr graphqlPR 14 | expected pullRequest 15 | }{{ 16 | name: "should return status 'closed' when the PR branch is deleted", 17 | pr: graphqlPR{ 18 | Number: 1, 19 | HeadRefName: "dummy_branch", 20 | Closed: true, 21 | URL: "http://dummy.url", 22 | Merged: false, 23 | BaseRepository: struct { 24 | Name string "json:\"name\"" 25 | Owner struct { 26 | Login string "json:\"login\"" 27 | } "json:\"owner\"" 28 | }{ 29 | Name: "base_repo", 30 | Owner: struct { 31 | Login string "json:\"login\"" 32 | }{Login: "dummy_user"}, 33 | }, 34 | HeadRepository: struct { 35 | Name string "json:\"name\"" 36 | Owner struct { 37 | Login string "json:\"login\"" 38 | } "json:\"owner\"" 39 | }{ 40 | Name: "pr_owner", 41 | Owner: struct { 42 | Login string "json:\"login\"" 43 | }{Login: "dummy_owner"}, 44 | }, 45 | }, 46 | expected: pullRequest{ 47 | status: scm.PullRequestStatusClosed, 48 | ownerName: "dummy_user", 49 | repoName: "base_repo", 50 | branchName: "dummy_branch", 51 | prOwnerName: "dummy_owner", 52 | prRepoName: "pr_owner", 53 | number: 1, 54 | guiURL: "http://dummy.url", 55 | }, 56 | }} 57 | 58 | for _, scenario := range scenarios { 59 | t.Run(scenario.name, func(t *testing.T) { 60 | got := convertGraphQLPullRequest(scenario.pr) 61 | if got != scenario.expected { 62 | assert.Equal(t, scenario.expected, got) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /internal/scm/github/repository.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/google/go-github/v70/github" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | func (g *Github) convertRepo(r *github.Repository) (repository, error) { 12 | var repoURL string 13 | if g.SSHAuth { 14 | repoURL = r.GetSSHURL() 15 | } else { 16 | u, err := url.Parse(r.GetCloneURL()) 17 | if err != nil { 18 | return repository{}, errors.Wrap(err, "could not parse github clone error") 19 | } 20 | // Set the token as https://oauth2@TOKEN@url 21 | u.User = url.UserPassword("oauth2", g.token) 22 | repoURL = u.String() 23 | } 24 | 25 | return repository{ 26 | url: repoURL, 27 | id: r.GetNodeID(), 28 | name: r.GetName(), 29 | ownerName: r.GetOwner().GetLogin(), 30 | defaultBranch: r.GetDefaultBranch(), 31 | }, nil 32 | } 33 | 34 | type repository struct { 35 | url string 36 | id string 37 | name string 38 | ownerName string 39 | defaultBranch string 40 | } 41 | 42 | func (r repository) CloneURL() string { 43 | return r.url 44 | } 45 | 46 | func (r repository) DefaultBranch() string { 47 | return r.defaultBranch 48 | } 49 | 50 | func (r repository) FullName() string { 51 | return fmt.Sprintf("%s/%s", r.ownerName, r.name) 52 | } 53 | -------------------------------------------------------------------------------- /internal/scm/github/retry.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/google/go-github/v70/github" 13 | "github.com/pkg/errors" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | const retryHeader = "Retry-After" 18 | 19 | var sleep = func(ctx context.Context, d time.Duration) error { 20 | log.Infof("Hit rate limit, sleeping for %s", d) 21 | select { 22 | case <-ctx.Done(): 23 | return errors.New("aborted while waiting for rate-limit") 24 | case <-time.After(d): 25 | return nil 26 | } 27 | } 28 | 29 | type retryAfterError time.Duration 30 | 31 | func (r retryAfterError) Error() string { 32 | return fmt.Sprintf("rate limit exceeded, waiting for %s)", time.Duration(r)) 33 | } 34 | 35 | // retry runs a GitHub API request and retries it if a temporary error occurred 36 | func retry[K any](ctx context.Context, fn func() (K, *github.Response, error)) (K, *github.Response, error) { 37 | var val K 38 | resp, err := retryWithoutReturn(ctx, func() (*github.Response, error) { 39 | var resp *github.Response 40 | var err error 41 | val, resp, err = fn() 42 | return resp, err 43 | }) 44 | return val, resp, err 45 | } 46 | 47 | // retryWithoutReturn runs a GitHub API request with no return value and retries it if a temporary error occurred 48 | func retryWithoutReturn(ctx context.Context, fn func() (*github.Response, error)) (*github.Response, error) { 49 | var response *github.Response 50 | err := retryAPIRequest(ctx, func() error { 51 | var err error 52 | response, err = fn() 53 | 54 | if response != nil { 55 | httpResponse := response.Response 56 | retryAfterErr := retryAfterFromHTTPResponse(httpResponse) 57 | if retryAfterErr != nil { 58 | return retryAfterErr 59 | } 60 | } 61 | 62 | return err 63 | }) 64 | return response, err 65 | } 66 | 67 | func retryAPIRequest(ctx context.Context, fn func() error) error { 68 | tries := 0 69 | 70 | for { 71 | tries++ 72 | 73 | err := fn() 74 | if err == nil { // NB! 75 | return nil 76 | } 77 | 78 | var retryAfter retryAfterError 79 | switch { 80 | // If GitHub has specified how long we should wait, use that information 81 | case errors.As(err, &retryAfter): 82 | err := sleep(ctx, time.Duration(retryAfter)) 83 | if err != nil { 84 | return err 85 | } 86 | // If secondary rate limit error, use an exponential back-off to determine the wait 87 | case strings.Contains(err.Error(), "secondary rate limit"): 88 | err := sleep(ctx, exponentialBackoff(tries)) 89 | if err != nil { 90 | return err 91 | } 92 | // If any other error, return the error 93 | default: 94 | return err 95 | } 96 | } 97 | } 98 | 99 | func retryAfterFromHTTPResponse(response *http.Response) error { 100 | if response == nil { 101 | return nil 102 | } 103 | 104 | retryAfterStr := response.Header.Get(retryHeader) 105 | if retryAfterStr == "" { 106 | return nil 107 | } 108 | 109 | retryAfterSeconds, err := strconv.Atoi(retryAfterStr) 110 | if err != nil { 111 | return nil 112 | } 113 | 114 | if retryAfterSeconds <= 0 { 115 | return nil 116 | } 117 | 118 | return retryAfterError(time.Duration(retryAfterSeconds) * time.Second) 119 | } 120 | 121 | func exponentialBackoff(tries int) time.Duration { 122 | // 10, 80, 270... seconds 123 | return time.Duration(math.Pow(float64(tries), 3)) * 10 * time.Second 124 | } 125 | -------------------------------------------------------------------------------- /internal/scm/github/retry_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/google/go-github/v70/github" 12 | "github.com/pkg/errors" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func Test_retryWithoutReturn(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | responses []response 20 | wantErr bool 21 | sleep time.Duration 22 | }{ 23 | { 24 | name: "one fail", 25 | responses: []response{ 26 | secondaryRateLimitError, 27 | okResponse, 28 | }, 29 | sleep: 10 * time.Second, 30 | }, 31 | { 32 | name: "two fails", 33 | responses: []response{ 34 | secondaryRateLimitError, 35 | secondaryRateLimitError, 36 | okResponse, 37 | }, 38 | sleep: 1*time.Minute + 30*time.Second, 39 | }, 40 | { 41 | name: "three fails", 42 | responses: []response{ 43 | secondaryRateLimitError, 44 | secondaryRateLimitError, 45 | secondaryRateLimitError, 46 | okResponse, 47 | }, 48 | sleep: 6*time.Minute + 0*time.Second, 49 | }, 50 | { 51 | name: "four fails", 52 | responses: []response{ 53 | secondaryRateLimitError, 54 | secondaryRateLimitError, 55 | secondaryRateLimitError, 56 | secondaryRateLimitError, 57 | okResponse, 58 | }, 59 | sleep: 16*time.Minute + 40*time.Second, 60 | }, 61 | { 62 | name: "a real fail", 63 | responses: []response{ 64 | realError, 65 | }, 66 | sleep: 0, 67 | wantErr: true, 68 | }, 69 | { 70 | name: "two temporary fails and a real one", 71 | responses: []response{ 72 | secondaryRateLimitError, 73 | secondaryRateLimitError, 74 | realError, 75 | }, 76 | sleep: 1*time.Minute + 30*time.Second, 77 | wantErr: true, 78 | }, 79 | { 80 | name: "primary rate limit", 81 | responses: []response{ 82 | primaryRateLimitError, 83 | okResponse, 84 | }, 85 | sleep: 1*time.Minute + 37*time.Second, 86 | wantErr: false, 87 | }, 88 | { 89 | name: "two primary rate limit errors in a row", 90 | responses: []response{ 91 | primaryRateLimitError, 92 | primaryRateLimitError, 93 | okResponse, 94 | }, 95 | sleep: 3*time.Minute + 14*time.Second, 96 | wantErr: false, 97 | }, 98 | { 99 | name: "secondary rate limit followed by primary rate limit", 100 | responses: []response{ 101 | secondaryRateLimitError, 102 | primaryRateLimitError, 103 | okResponse, 104 | }, 105 | sleep: 1*time.Minute + 47*time.Second, 106 | wantErr: false, 107 | }, 108 | } 109 | for _, tt := range tests { 110 | t.Run(tt.name, func(t *testing.T) { 111 | var slept time.Duration 112 | sleep = func(ctx context.Context, d time.Duration) error { 113 | slept += d 114 | return nil 115 | } 116 | 117 | call := 0 118 | fn := func() (*github.Response, error) { 119 | resp := tt.responses[call] 120 | call++ 121 | return resp.response, resp.err 122 | } 123 | 124 | if _, err := retryWithoutReturn(context.Background(), fn); (err != nil) != tt.wantErr { 125 | t.Errorf("retry() error = %v, wantErr %v", err, tt.wantErr) 126 | } 127 | 128 | assert.Equal(t, tt.sleep, slept) 129 | }) 130 | } 131 | } 132 | 133 | func Test_retry(t *testing.T) { 134 | var slept time.Duration 135 | sleep = func(ctx context.Context, d time.Duration) error { 136 | slept += d 137 | return nil 138 | } 139 | 140 | call := 0 141 | fn := func() (*github.PullRequest, *github.Response, error) { 142 | call++ 143 | if call == 4 { 144 | return &github.PullRequest{ 145 | ID: &[]int64{100}[0], 146 | }, okResponse.response, nil 147 | } 148 | return nil, secondaryRateLimitError.response, secondaryRateLimitError.err 149 | } 150 | 151 | pr, resp, err := retry(context.Background(), fn) 152 | assert.Equal(t, int64(100), *pr.ID) 153 | assert.Equal(t, 200, resp.StatusCode) 154 | assert.NoError(t, err) 155 | } 156 | 157 | type response struct { 158 | response *github.Response 159 | err error 160 | } 161 | 162 | var okResponse = response{ 163 | err: nil, 164 | response: &github.Response{ 165 | Response: &http.Response{ 166 | StatusCode: 200, 167 | }, 168 | }, 169 | } 170 | var realError = createResponse(http.StatusNotFound, errors.New("something went wrong")) 171 | var secondaryErrorMsg = "You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later." 172 | var secondaryRateLimitError = createResponse(http.StatusForbidden, errors.New(secondaryErrorMsg)) 173 | var primaryRateLimitError = response{ 174 | response: &github.Response{ 175 | Response: &http.Response{ 176 | StatusCode: http.StatusForbidden, 177 | Header: http.Header{ 178 | "Retry-After": []string{"97"}, 179 | }, 180 | }, 181 | }, 182 | err: errors.New("rate limited"), 183 | } 184 | 185 | func createResponse(statusCode int, err error) response { 186 | return response{ 187 | response: createGithubResponse(statusCode, err.Error()), 188 | err: err, 189 | } 190 | } 191 | 192 | func createGithubResponse(statusCode int, errorMsg string) *github.Response { 193 | return &github.Response{ 194 | Response: &http.Response{ 195 | StatusCode: statusCode, 196 | Body: io.NopCloser(strings.NewReader(errorMsg)), 197 | }, 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /internal/scm/github/util.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/google/go-github/v70/github" 7 | "github.com/lindell/multi-gitter/internal/scm" 8 | ) 9 | 10 | // maps merge types to what they are called in the github api 11 | var mergeTypeGhName = map[scm.MergeType]string{ 12 | scm.MergeTypeMerge: "merge", 13 | scm.MergeTypeRebase: "rebase", 14 | scm.MergeTypeSquash: "squash", 15 | } 16 | 17 | // repoMergeTypes returns a list of all allowed merge types 18 | func repoMergeTypes(repo *github.Repository) []scm.MergeType { 19 | ret := []scm.MergeType{} 20 | if repo.GetAllowMergeCommit() { 21 | ret = append(ret, scm.MergeTypeMerge) 22 | } 23 | if repo.GetAllowRebaseMerge() { 24 | ret = append(ret, scm.MergeTypeRebase) 25 | } 26 | if repo.GetAllowSquashMerge() { 27 | ret = append(ret, scm.MergeTypeSquash) 28 | } 29 | return ret 30 | } 31 | 32 | func stripSuffixIfExist(str string, suffix string) string { 33 | if strings.HasSuffix(str, suffix) { 34 | return str[:len(str)-len(suffix)] 35 | } 36 | return str 37 | } 38 | 39 | func chunkSlice[T any](stack []T, chunkSize int) [][]T { 40 | var chunks = make([][]T, 0, (len(stack)/chunkSize)+1) 41 | for chunkSize < len(stack) { 42 | stack, chunks = stack[chunkSize:], append(chunks, stack[0:chunkSize:chunkSize]) 43 | } 44 | 45 | return append(chunks, stack) 46 | } 47 | -------------------------------------------------------------------------------- /internal/scm/github/util_test.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func Test_stripSuffixIfExist(t *testing.T) { 10 | assert.Equal(t, "string", stripSuffixIfExist("stringSuffix", "Suffix")) 11 | assert.Equal(t, "stringSuffix", stripSuffixIfExist("stringSuffix", "NoMatch")) 12 | } 13 | 14 | func Test_chunkSlice(t *testing.T) { 15 | assert.Equal(t, [][]int{{0, 1}, {2}}, chunkSlice([]int{0, 1, 2}, 2)) 16 | assert.Equal(t, [][]int{{0, 1, 2}}, chunkSlice([]int{0, 1, 2}, 4)) 17 | } 18 | -------------------------------------------------------------------------------- /internal/scm/gitlab/gitlab_test.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestParseProjectReference(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | val string 12 | want ProjectReference 13 | wantErr bool 14 | }{ 15 | { 16 | name: "single", 17 | val: "my-group/my-project", 18 | want: ProjectReference{ 19 | OwnerName: "my-group", 20 | Name: "my-project", 21 | }, 22 | }, 23 | { 24 | name: "subgroup", 25 | val: "my-group/sub-group/my-project", 26 | want: ProjectReference{ 27 | OwnerName: "my-group/sub-group", 28 | Name: "my-project", 29 | }, 30 | }, 31 | { 32 | name: "two subgroups", 33 | val: "my-group/sub-group1/sub-group2/my-project", 34 | want: ProjectReference{ 35 | OwnerName: "my-group/sub-group1/sub-group2", 36 | Name: "my-project", 37 | }, 38 | }, 39 | { 40 | name: "no-group", 41 | val: "my-project", 42 | wantErr: true, 43 | }, 44 | } 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | got, err := ParseProjectReference(tt.val) 48 | if (err != nil) != tt.wantErr { 49 | t.Errorf("ParseProjectReference() error = %v, wantErr %v", err, tt.wantErr) 50 | return 51 | } 52 | if !reflect.DeepEqual(got, tt.want) { 53 | t.Errorf("ParseProjectReference() = %v, want %v", got, tt.want) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/scm/gitlab/pullrequest.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/lindell/multi-gitter/internal/scm" 7 | ) 8 | 9 | type pullRequest struct { 10 | ownerName string 11 | repoName string 12 | targetPID int 13 | sourcePID int 14 | branchName string 15 | iid int 16 | webURL string 17 | status scm.PullRequestStatus 18 | } 19 | 20 | func (pr pullRequest) String() string { 21 | return fmt.Sprintf("%s/%s #%d", pr.ownerName, pr.repoName, pr.iid) 22 | } 23 | 24 | func (pr pullRequest) Status() scm.PullRequestStatus { 25 | return pr.status 26 | } 27 | 28 | func (pr pullRequest) URL() string { 29 | return pr.webURL 30 | } 31 | -------------------------------------------------------------------------------- /internal/scm/gitlab/repository.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/xanzy/go-gitlab" 8 | ) 9 | 10 | func (g *Gitlab) convertProject(project *gitlab.Project) (repository, error) { 11 | var cloneURL string 12 | if g.Config.SSHAuth { 13 | cloneURL = project.SSHURLToRepo 14 | } else { 15 | u, err := url.Parse(project.HTTPURLToRepo) 16 | if err != nil { 17 | return repository{}, err 18 | } 19 | u.User = url.UserPassword("oauth2", g.token) 20 | cloneURL = u.String() 21 | } 22 | 23 | return repository{ 24 | url: cloneURL, 25 | pid: project.ID, 26 | name: project.Path, 27 | ownerName: project.Namespace.FullPath, 28 | defaultBranch: project.DefaultBranch, 29 | shouldSquash: shouldSquash(project), 30 | }, nil 31 | } 32 | 33 | func shouldSquash(project *gitlab.Project) bool { 34 | switch project.SquashOption { 35 | case gitlab.SquashOptionAlways, gitlab.SquashOptionDefaultOn: 36 | return true 37 | case gitlab.SquashOptionNever, gitlab.SquashOptionDefaultOff: 38 | return false 39 | default: 40 | return false 41 | } 42 | } 43 | 44 | type repository struct { 45 | url string 46 | pid int 47 | name string 48 | ownerName string 49 | defaultBranch string 50 | shouldSquash bool 51 | } 52 | 53 | func (r repository) CloneURL() string { 54 | return r.url 55 | } 56 | 57 | func (r repository) DefaultBranch() string { 58 | return r.defaultBranch 59 | } 60 | 61 | func (r repository) FullName() string { 62 | return fmt.Sprintf("%s/%s", r.ownerName, r.name) 63 | } 64 | -------------------------------------------------------------------------------- /internal/scm/pullrequest.go: -------------------------------------------------------------------------------- 1 | package scm 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // NewPullRequest is the data needed to create a new pull request 9 | type NewPullRequest struct { 10 | Title string 11 | Body string 12 | Head string 13 | Base string 14 | 15 | Reviewers []string // The username of all reviewers 16 | TeamReviewers []string // Teams to assign as reviewers 17 | Assignees []string 18 | Draft bool 19 | Labels []string 20 | } 21 | 22 | // PullRequestStatus is the status of a pull request, including statuses of the last commit 23 | type PullRequestStatus int 24 | 25 | // All PullRequestStatuses 26 | const ( 27 | PullRequestStatusUnknown PullRequestStatus = iota 28 | PullRequestStatusSuccess 29 | PullRequestStatusPending 30 | PullRequestStatusError 31 | PullRequestStatusMerged 32 | PullRequestStatusClosed 33 | ) 34 | 35 | func (s PullRequestStatus) String() string { 36 | switch s { 37 | case PullRequestStatusUnknown: 38 | return "Unknown" 39 | case PullRequestStatusSuccess: 40 | return "Success" 41 | case PullRequestStatusPending: 42 | return "Pending" 43 | case PullRequestStatusError: 44 | return "Error" 45 | case PullRequestStatusMerged: 46 | return "Merged" 47 | case PullRequestStatusClosed: 48 | return "Closed" 49 | } 50 | return "Unknown" 51 | } 52 | 53 | // PullRequest represents a pull request 54 | type PullRequest interface { 55 | Status() PullRequestStatus 56 | String() string 57 | } 58 | 59 | // MergeType is the way a pull request is "merged" into the base branch 60 | type MergeType int 61 | 62 | // All MergeTypes 63 | const ( 64 | MergeTypeUnknown MergeType = iota 65 | MergeTypeMerge 66 | MergeTypeRebase 67 | MergeTypeSquash 68 | ) 69 | 70 | // ParseMergeType parses a merge type 71 | func ParseMergeType(typ string) (MergeType, error) { 72 | switch strings.ToLower(typ) { 73 | case "merge": 74 | return MergeTypeMerge, nil 75 | case "rebase": 76 | return MergeTypeRebase, nil 77 | case "squash": 78 | return MergeTypeSquash, nil 79 | } 80 | return MergeTypeUnknown, fmt.Errorf(`not a valid merge type: "%s"`, typ) 81 | } 82 | 83 | // MergeTypeIntersection calculates the intersection of two merge type slices, 84 | // The order of the first slice will be preserved 85 | func MergeTypeIntersection(mergeTypes1, mergeTypes2 []MergeType) []MergeType { 86 | res := []MergeType{} 87 | for _, mt := range mergeTypes1 { 88 | for _, mt2 := range mergeTypes2 { 89 | if mt == mt2 { 90 | res = append(res, mt) 91 | } 92 | } 93 | } 94 | return res 95 | } 96 | -------------------------------------------------------------------------------- /internal/scm/repository.go: -------------------------------------------------------------------------------- 1 | package scm 2 | 3 | // Repository provides all the information needed about a git repository 4 | type Repository interface { 5 | // CloneURL returns the clone address of the repository 6 | CloneURL() string 7 | // DefaultBranch returns the name of the default branch of the repository 8 | DefaultBranch() string 9 | // FullName returns the full id of the repository, usually ownerName/repoName 10 | FullName() string 11 | } 12 | 13 | func RepoContainsTopic(repoTopics []string, filterTopics []string) bool { 14 | repoTopicsMap := map[string]struct{}{} 15 | for _, v := range repoTopics { 16 | repoTopicsMap[v] = struct{}{} 17 | } 18 | 19 | for _, v := range filterTopics { 20 | if _, ok := repoTopicsMap[v]; ok { 21 | return true 22 | } 23 | } 24 | 25 | return false 26 | } 27 | -------------------------------------------------------------------------------- /internal/scm/util.go: -------------------------------------------------------------------------------- 1 | package scm 2 | 3 | // Diff two slices and get the added and removed items compared to s1 4 | func Diff[T comparable](s1, s2 []T) (added, removed []T) { 5 | s1Lookup := map[T]struct{}{} 6 | for _, v := range s1 { 7 | s1Lookup[v] = struct{}{} 8 | } 9 | s2Lookup := map[T]struct{}{} 10 | for _, v := range s2 { 11 | s2Lookup[v] = struct{}{} 12 | } 13 | 14 | for _, v := range s2 { 15 | if _, ok := s1Lookup[v]; !ok { 16 | added = append(added, v) 17 | } 18 | } 19 | for _, v := range s1 { 20 | if _, ok := s2Lookup[v]; !ok { 21 | removed = append(removed, v) 22 | } 23 | } 24 | 25 | return added, removed 26 | } 27 | 28 | // Map runs a function for each value in a slice and returns a slice of all function returns 29 | func Map[T any, K any](vals []T, mapping func(T) K) []K { 30 | newVals := make([]K, len(vals)) 31 | for i, v := range vals { 32 | newVals[i] = mapping(v) 33 | } 34 | return newVals 35 | } 36 | -------------------------------------------------------------------------------- /internal/scm/util_test.go: -------------------------------------------------------------------------------- 1 | package scm 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestDiff(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | s1 []int 12 | s2 []int 13 | wantAdded []int 14 | wantRemoved []int 15 | }{ 16 | { 17 | name: "same", 18 | s1: []int{1, 2, 3}, 19 | s2: []int{1, 2, 3}, 20 | wantAdded: nil, 21 | wantRemoved: nil, 22 | }, 23 | { 24 | name: "empty s2", 25 | s1: []int{1, 2, 3}, 26 | s2: []int{}, 27 | wantAdded: nil, 28 | wantRemoved: []int{1, 2, 3}, 29 | }, 30 | { 31 | name: "empty s1", 32 | s1: []int{}, 33 | s2: []int{1, 2, 3}, 34 | wantAdded: []int{1, 2, 3}, 35 | wantRemoved: nil, 36 | }, 37 | { 38 | name: "some overlap", 39 | s1: []int{1, 2, 3}, 40 | s2: []int{3, 4, 5}, 41 | wantAdded: []int{4, 5}, 42 | wantRemoved: []int{1, 2}, 43 | }, 44 | } 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | gotAdded, gotRemoved := Diff(tt.s1, tt.s2) 48 | if !reflect.DeepEqual(gotAdded, tt.wantAdded) { 49 | t.Errorf("Diff() gotAdded = %v, want %v", gotAdded, tt.wantAdded) 50 | } 51 | if !reflect.DeepEqual(gotRemoved, tt.wantRemoved) { 52 | t.Errorf("Diff() gotRemoved = %v, want %v", gotRemoved, tt.wantRemoved) 53 | } 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/lindell/multi-gitter/cmd" 9 | ) 10 | 11 | var version = "development" 12 | var date = "now" 13 | var commit = "unknown" 14 | 15 | func main() { 16 | cmd.Version = version 17 | cmd.BuildDate, _ = time.ParseInLocation(time.RFC3339, date, time.UTC) 18 | cmd.Commit = commit 19 | if err := cmd.RootCmd().Execute(); err != nil { 20 | fmt.Fprintln(os.Stderr, err) 21 | os.Exit(1) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/fuzzing_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/lindell/multi-gitter/cmd" 10 | "github.com/lindell/multi-gitter/tests/vcmock" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func FuzzRun(f *testing.F) { 15 | f.Add( 16 | "assignee1,assignee2", // assignees 17 | "commit message", // commit-message 18 | 1, // concurrent 19 | "skip", // conflict-strategy 20 | false, // draft 21 | false, // dry-run 22 | 1, // fetch-depth 23 | false, // fork 24 | "fork-owner", // fork-owner 25 | "go", // git-type 26 | "label1,label2", // labels 27 | "text", // log-format 28 | "info", // log-level 29 | 1, // max-reviewers 30 | 1, // max-team-reviewers 31 | "pr-body", // pr-body 32 | "pr-title", // pr-title 33 | "reviewer1,reviewer2", // reviewers 34 | false, // skip-forks 35 | false, // skip-pr 36 | "should-not-change", // skip-repo 37 | "team-reviewer1,team-reviewer1", // team-reviewers 38 | "topic1,topic2", // topic 39 | ) 40 | f.Fuzz(func( 41 | t *testing.T, 42 | 43 | assignees string, 44 | commitMessage string, 45 | concurrent int, 46 | conflictStrategy string, 47 | draft bool, 48 | dryRun bool, 49 | fetchDepth int, 50 | fork bool, 51 | forkOwner string, 52 | gitType string, 53 | labels string, 54 | logFormat string, 55 | logLevel string, 56 | maxReviewers int, 57 | maxTeamReviewers int, 58 | prBody string, 59 | prTitle string, 60 | reviewers string, 61 | skipForks bool, 62 | skipPr bool, 63 | skipRepo string, 64 | teamReviewers string, 65 | topic string, 66 | ) { 67 | vcMock := &vcmock.VersionController{} 68 | defer vcMock.Clean() 69 | cmd.OverrideVersionController = vcMock 70 | 71 | tmpDir, err := os.MkdirTemp(os.TempDir(), "multi-git-test-run-") 72 | defer os.RemoveAll(tmpDir) 73 | assert.NoError(t, err) 74 | 75 | workingDir, err := os.Getwd() 76 | assert.NoError(t, err) 77 | 78 | changerBinaryPath := normalizePath(filepath.Join(workingDir, changerBinaryPath)) 79 | 80 | changeRepo := createRepo(t, "owner", "should-change", "i like apples") 81 | changeRepo2 := createRepo(t, "owner", "should-change-2", "i like my apple") 82 | noChangeRepo := createRepo(t, "owner", "should-not-change", "i like oranges") 83 | vcMock.AddRepository(changeRepo) 84 | vcMock.AddRepository(changeRepo2) 85 | vcMock.AddRepository(noChangeRepo) 86 | 87 | runOutFile := filepath.Join(tmpDir, "run-out.txt") 88 | runLogFile := filepath.Join(tmpDir, "run-log.txt") 89 | 90 | command := cmd.RootCmd() 91 | command.SetArgs([]string{"run", 92 | "--output", runOutFile, 93 | "--log-file", runLogFile, 94 | "--author-name", "Test Author", 95 | "--author-email", "test@example.com", 96 | "--assignees", assignees, 97 | "--commit-message", commitMessage, 98 | "--concurrent", fmt.Sprint(concurrent), 99 | "--conflict-strategy", conflictStrategy, 100 | fmt.Sprintf("--draft=%t", draft), 101 | fmt.Sprintf("--dry-run=%t", dryRun), 102 | "--fetch-depth", fmt.Sprint(fetchDepth), 103 | fmt.Sprintf("--fork=%t", fork), 104 | "--fork-owner", forkOwner, 105 | "--git-type", gitType, 106 | "--labels", labels, 107 | "--log-format", logFormat, 108 | "--log-level", logLevel, 109 | "--max-reviewers", fmt.Sprint(maxReviewers), 110 | "--max-team-reviewers", fmt.Sprint(maxTeamReviewers), 111 | "--pr-body", prBody, 112 | "--pr-title", prTitle, 113 | "--reviewers", reviewers, 114 | fmt.Sprintf("--skip-forks=%t", skipForks), 115 | fmt.Sprintf("--skip-pr=%t", skipPr), 116 | "--skip-repo", skipRepo, 117 | "--team-reviewers", teamReviewers, 118 | "--topic", topic, 119 | changerBinaryPath, 120 | }) 121 | err = command.Execute() 122 | if err != nil { 123 | assert.NotContains(t, err.Error(), "panic") 124 | } 125 | 126 | // Verify that the output was correct 127 | runOutData, _ := os.ReadFile(runOutFile) 128 | assert.NotContains(t, string(runOutData), "panic") 129 | runLogData, _ := os.ReadFile(runLogFile) 130 | assert.NotContains(t, string(runLogData), "panic") 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /tests/print_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/lindell/multi-gitter/cmd" 10 | "github.com/lindell/multi-gitter/tests/vcmock" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestPrint(t *testing.T) { 16 | vcMock := &vcmock.VersionController{} 17 | defer vcMock.Clean() 18 | cmd.OverrideVersionController = vcMock 19 | 20 | tmpDir, err := os.MkdirTemp(os.TempDir(), "multi-git-test-run-") 21 | assert.NoError(t, err) 22 | 23 | workingDir, err := os.Getwd() 24 | assert.NoError(t, err) 25 | 26 | changeRepo := createRepo(t, "owner", "test-1", "i like apples") 27 | changeRepo2 := createRepo(t, "owner", "test-2", "i like my apple") 28 | noChangeRepo := createRepo(t, "owner", "test-3", "i like oranges") 29 | vcMock.AddRepository(changeRepo) 30 | vcMock.AddRepository(changeRepo2) 31 | vcMock.AddRepository(noChangeRepo) 32 | 33 | runLogFile := filepath.Join(tmpDir, "print-log.txt") 34 | outFile := filepath.Join(tmpDir, "out.txt") 35 | errOutFile := filepath.Join(tmpDir, "err-out.txt") 36 | 37 | command := cmd.RootCmd() 38 | command.SetArgs([]string{ 39 | "print", 40 | "--log-file", filepath.ToSlash(runLogFile), 41 | "--output", filepath.ToSlash(outFile), 42 | "--error-output", filepath.ToSlash(errOutFile), 43 | fmt.Sprintf(`go run %s`, normalizePath(filepath.Join(workingDir, "scripts/printer/main.go"))), 44 | }) 45 | err = command.Execute() 46 | assert.NoError(t, err) 47 | 48 | // Verify that the output was correct 49 | outData, err := os.ReadFile(outFile) 50 | require.NoError(t, err) 51 | assert.Equal(t, "i like apples\ni like my apple\ni like oranges\n", string(outData)) 52 | 53 | // Verify that the error output was correct 54 | errOutData, err := os.ReadFile(errOutFile) 55 | require.NoError(t, err) 56 | assert.Equal(t, "I LIKE APPLES\nI LIKE MY APPLE\nI LIKE ORANGES\n", string(errOutData)) 57 | } 58 | -------------------------------------------------------------------------------- /tests/repo_helper_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | git "github.com/go-git/go-git/v5" 12 | "github.com/go-git/go-git/v5/plumbing" 13 | "github.com/go-git/go-git/v5/plumbing/object" 14 | "github.com/lindell/multi-gitter/tests/vcmock" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | const fileName = "test.txt" 20 | 21 | func createRepo(t *testing.T, ownerName string, repoName string, dataInFile string) vcmock.Repository { 22 | tmpDir, err := createDummyRepo(dataInFile, os.TempDir()) 23 | require.NoError(t, err) 24 | 25 | return vcmock.Repository{ 26 | OwnerName: ownerName, 27 | RepoName: repoName, 28 | Path: tmpDir, 29 | } 30 | } 31 | 32 | func createDummyRepo(dataInFile string, dir string) (string, error) { 33 | tmpDir, err := os.MkdirTemp(dir, "multi-git-test-*.git") 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | repo, err := git.PlainInit(tmpDir, false) 39 | if err != nil { 40 | return "", err 41 | } 42 | 43 | testFilePath := filepath.Join(tmpDir, fileName) 44 | 45 | err = os.WriteFile(testFilePath, []byte(dataInFile), 0600) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | wt, err := repo.Worktree() 51 | if err != nil { 52 | return "", err 53 | } 54 | 55 | if _, err = wt.Add("."); err != nil { 56 | return "", err 57 | } 58 | 59 | _, err = wt.Commit("First commit", &git.CommitOptions{ 60 | Author: &object.Signature{ 61 | Name: "test", 62 | Email: "test@example.com", 63 | When: time.Now(), 64 | }, 65 | }) 66 | if err != nil { 67 | return "", err 68 | } 69 | 70 | return tmpDir, nil 71 | } 72 | 73 | func changeBranch(t *testing.T, path string, branchName string, create bool) { 74 | repo, err := git.PlainOpen(path) 75 | assert.NoError(t, err) 76 | 77 | wt, err := repo.Worktree() 78 | assert.NoError(t, err) 79 | 80 | err = wt.Checkout(&git.CheckoutOptions{ 81 | Branch: plumbing.NewBranchReferenceName(branchName), 82 | Create: create, 83 | }) 84 | assert.NoError(t, err) 85 | } 86 | 87 | func branchExist(t *testing.T, path string, branchName string) bool { 88 | repo, err := git.PlainOpen(path) 89 | assert.NoError(t, err) 90 | 91 | _, err = repo.Reference(plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branchName)), false) 92 | if err == plumbing.ErrReferenceNotFound { 93 | return false 94 | } 95 | assert.NoError(t, err) 96 | 97 | return true 98 | } 99 | 100 | func changeTestFile(t *testing.T, basePath string, content string, commitMessage string) { 101 | repo, err := git.PlainOpen(basePath) 102 | require.NoError(t, err) 103 | 104 | testFilePath := filepath.Join(basePath, fileName) 105 | 106 | err = os.WriteFile(testFilePath, []byte(content), 0600) 107 | require.NoError(t, err) 108 | 109 | wt, err := repo.Worktree() 110 | require.NoError(t, err) 111 | 112 | _, err = wt.Add(".") 113 | require.NoError(t, err) 114 | 115 | _, err = wt.Commit(commitMessage, &git.CommitOptions{ 116 | Author: &object.Signature{ 117 | Name: "test", 118 | Email: "test@example.com", 119 | When: time.Now(), 120 | }, 121 | }) 122 | require.NoError(t, err) 123 | } 124 | 125 | func addFile(t *testing.T, basePath string, fn string, content string, commitMessage string) { 126 | repo, err := git.PlainOpen(basePath) 127 | require.NoError(t, err) 128 | 129 | testFilePath := filepath.Join(basePath, fn) 130 | 131 | err = os.WriteFile(testFilePath, []byte(content), 0600) 132 | require.NoError(t, err) 133 | 134 | wt, err := repo.Worktree() 135 | require.NoError(t, err) 136 | 137 | _, err = wt.Add(".") 138 | require.NoError(t, err) 139 | 140 | _, err = wt.Commit(commitMessage, &git.CommitOptions{ 141 | Author: &object.Signature{ 142 | Name: "test", 143 | Email: "test@example.com", 144 | When: time.Now(), 145 | }, 146 | }) 147 | require.NoError(t, err) 148 | } 149 | 150 | func readTestFile(t *testing.T, basePath string) string { 151 | testFilePath := filepath.Join(basePath, fileName) 152 | 153 | b, err := os.ReadFile(testFilePath) 154 | require.NoError(t, err) 155 | 156 | return string(b) 157 | } 158 | 159 | func readFile(t *testing.T, basePath string, fn string) string { 160 | testFilePath := filepath.Join(basePath, fn) 161 | 162 | b, err := os.ReadFile(testFilePath) 163 | require.NoError(t, err) 164 | 165 | return string(b) 166 | } 167 | 168 | func fileExist(t *testing.T, basePath string, fn string) bool { 169 | _, err := os.Stat(filepath.Join(basePath, fn)) 170 | if os.IsNotExist(err) { 171 | return false 172 | } 173 | 174 | require.NoError(t, err) 175 | return true 176 | } 177 | 178 | func normalizePath(path string) string { 179 | return strings.ReplaceAll(filepath.ToSlash(path), " ", "\\ ") 180 | } 181 | -------------------------------------------------------------------------------- /tests/scripts/adder/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func main() { 11 | filenames := flag.String("filenames", "", "") 12 | data := flag.String("data", "", "") 13 | flag.Parse() 14 | 15 | if *filenames == "" { 16 | panic("empty filename") 17 | } 18 | if *data == "" { 19 | panic("empty data") 20 | } 21 | 22 | for _, fn := range strings.Split(*filenames, ",") { 23 | dir := filepath.Dir(fn) 24 | if dir != "." { 25 | totalFilepath := "." 26 | for _, fp := range strings.Split(dir, string(filepath.Separator)) { 27 | totalFilepath = filepath.Join(totalFilepath, fp) 28 | err := os.Mkdir(totalFilepath, 0755) 29 | if err != nil { 30 | panic(err) 31 | } 32 | } 33 | } 34 | 35 | err := os.WriteFile(fn, []byte(*data), 0600) 36 | if err != nil { 37 | panic(err) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/scripts/changer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "os" 7 | "time" 8 | ) 9 | 10 | const fileName = "test.txt" 11 | 12 | func main() { 13 | duration := flag.String("sleep", "", "Time to sleep before running the script") 14 | flag.Parse() 15 | 16 | if *duration != "" { 17 | d, _ := time.ParseDuration(*duration) 18 | time.Sleep(d) 19 | } 20 | 21 | data, err := os.ReadFile(fileName) 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | replaced := bytes.ReplaceAll(data, []byte("apple"), []byte("banana")) 27 | 28 | err = os.WriteFile(fileName, replaced, 0600) 29 | if err != nil { 30 | panic(err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/scripts/printer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | const fileName = "test.txt" 10 | 11 | func main() { 12 | data, err := os.ReadFile(fileName) 13 | if err != nil { 14 | panic(err) 15 | } 16 | 17 | fmt.Println(string(data)) 18 | fmt.Fprintln(os.Stderr, strings.ToUpper(string(data))) 19 | } 20 | -------------------------------------------------------------------------------- /tests/scripts/pwd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | path, _ := os.Getwd() 10 | fmt.Println("Current path:", path) 11 | err := os.WriteFile("pwd.txt", []byte(path), 0600) 12 | if err != nil { 13 | fmt.Println("Could not write to pwd.txt:", err) 14 | return 15 | } 16 | fmt.Println("Wrote to pwd.txt") 17 | } 18 | -------------------------------------------------------------------------------- /tests/scripts/remover/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func main() { 8 | os.Remove("test_file") 9 | } 10 | -------------------------------------------------------------------------------- /tests/setup_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "runtime" 7 | "testing" 8 | ) 9 | 10 | var changerBinaryPath string 11 | var printerBinaryPath string 12 | 13 | func TestMain(m *testing.M) { 14 | switch runtime.GOOS { 15 | case "windows": 16 | changerBinaryPath = "scripts/changer/main.exe" 17 | printerBinaryPath = "scripts/printer/main.exe" 18 | default: 19 | changerBinaryPath = "scripts/changer/main" 20 | printerBinaryPath = "scripts/printer/main" 21 | } 22 | 23 | command := exec.Command("go", "build", "-o", changerBinaryPath, "scripts/changer/main.go") 24 | if err := command.Run(); err != nil { 25 | panic(err) 26 | } 27 | 28 | command = exec.Command("go", "build", "-o", printerBinaryPath, "scripts/printer/main.go") 29 | if err := command.Run(); err != nil { 30 | panic(err) 31 | } 32 | 33 | os.Exit(m.Run()) 34 | } 35 | -------------------------------------------------------------------------------- /tests/story_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/lindell/multi-gitter/cmd" 9 | "github.com/lindell/multi-gitter/internal/scm" 10 | "github.com/lindell/multi-gitter/tests/vcmock" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | // TestStory tests the common use case: run, status, merge, status 16 | func TestStory(t *testing.T) { 17 | vcMock := &vcmock.VersionController{} 18 | defer vcMock.Clean() 19 | cmd.OverrideVersionController = vcMock 20 | 21 | tmpDir, err := os.MkdirTemp(os.TempDir(), "multi-git-test-run-") 22 | defer os.RemoveAll(tmpDir) 23 | assert.NoError(t, err) 24 | 25 | workingDir, err := os.Getwd() 26 | assert.NoError(t, err) 27 | 28 | changerBinaryPath := normalizePath(filepath.Join(workingDir, changerBinaryPath)) 29 | 30 | changeRepo := createRepo(t, "owner", "should-change", "i like apples") 31 | changeRepo2 := createRepo(t, "owner", "should-change-2", "i like my apple") 32 | noChangeRepo := createRepo(t, "owner", "should-not-change", "i like oranges") 33 | vcMock.AddRepository(changeRepo) 34 | vcMock.AddRepository(changeRepo2) 35 | vcMock.AddRepository(noChangeRepo) 36 | 37 | runOutFile := filepath.Join(tmpDir, "run-log.txt") 38 | 39 | command := cmd.RootCmd() 40 | command.SetArgs([]string{ 41 | "run", 42 | "--output", runOutFile, 43 | "--author-name", "Test Author", 44 | "--author-email", "test@example.com", 45 | "-B", "custom-branch-name", 46 | "-m", "test", 47 | changerBinaryPath, 48 | }) 49 | err = command.Execute() 50 | assert.NoError(t, err) 51 | 52 | // Verify that the data of the original branch is intact 53 | data, err := os.ReadFile(filepath.Join(changeRepo.Path, fileName)) 54 | assert.NoError(t, err) 55 | assert.Equal(t, []byte("i like apples"), data) 56 | 57 | // Verify that the new branch is changed 58 | changeBranch(t, changeRepo.Path, "custom-branch-name", false) 59 | data, err = os.ReadFile(filepath.Join(changeRepo.Path, fileName)) 60 | assert.NoError(t, err) 61 | assert.Equal(t, []byte("i like bananas"), data) 62 | 63 | // Verify that the output was correct 64 | runOutData, err := os.ReadFile(runOutFile) 65 | require.NoError(t, err) 66 | assert.Equal(t, `No data was changed: 67 | owner/should-not-change 68 | Repositories with a successful run: 69 | owner/should-change #1 70 | owner/should-change-2 #2 71 | `, string(runOutData)) 72 | 73 | // 74 | // PullRequestStatus 75 | // 76 | statusOutFile := filepath.Join(tmpDir, "status-log.txt") 77 | 78 | command = cmd.RootCmd() 79 | command.SetArgs([]string{ 80 | "status", 81 | "--output", statusOutFile, 82 | "-B", "custom-branch-name", 83 | }) 84 | err = command.Execute() 85 | assert.NoError(t, err) 86 | 87 | // Verify that the output was correct 88 | statusOutData, err := os.ReadFile(statusOutFile) 89 | require.NoError(t, err) 90 | assert.Equal(t, "owner/should-change #1: Pending\nowner/should-change-2 #2: Pending\n", string(statusOutData)) 91 | 92 | // One of the created PRs is set to succeeded 93 | vcMock.SetPRStatus("should-change", "custom-branch-name", scm.PullRequestStatusSuccess) 94 | 95 | // 96 | // Merge 97 | // 98 | mergeLogFile := filepath.Join(tmpDir, "merge-log.txt") 99 | 100 | command = cmd.RootCmd() 101 | command.SetArgs([]string{ 102 | "merge", 103 | "--log-file", mergeLogFile, 104 | "-B", "custom-branch-name", 105 | }) 106 | err = command.Execute() 107 | assert.NoError(t, err) 108 | 109 | // Verify that the output was correct 110 | mergeLogData, err := os.ReadFile(mergeLogFile) 111 | require.NoError(t, err) 112 | assert.Contains(t, string(mergeLogData), "Merging 1 pull requests") 113 | assert.Contains(t, string(mergeLogData), "Merging pr=\"owner/should-change #1\"") 114 | 115 | // 116 | // After Merge PullRequestStatus 117 | // 118 | afterMergeStatusOutFile := filepath.Join(tmpDir, "after-merge-status-log.txt") 119 | 120 | command = cmd.RootCmd() 121 | command.SetArgs([]string{ 122 | "status", 123 | "--output", afterMergeStatusOutFile, 124 | "-B", "custom-branch-name", 125 | }) 126 | err = command.Execute() 127 | assert.NoError(t, err) 128 | 129 | // Verify that the output was correct 130 | afterMergeStatusOutData, err := os.ReadFile(afterMergeStatusOutFile) 131 | require.NoError(t, err) 132 | assert.Equal(t, "owner/should-change #1: Merged\nowner/should-change-2 #2: Pending\n", string(afterMergeStatusOutData)) 133 | 134 | // 135 | // Close 136 | // 137 | closeLogFile := filepath.Join(tmpDir, "close-log.txt") 138 | 139 | command = cmd.RootCmd() 140 | command.SetArgs([]string{ 141 | "close", 142 | "--log-file", closeLogFile, 143 | "-B", "custom-branch-name", 144 | }) 145 | err = command.Execute() 146 | assert.NoError(t, err) 147 | 148 | // Verify that the output was correct 149 | closeLogData, err := os.ReadFile(closeLogFile) 150 | require.NoError(t, err) 151 | assert.Contains(t, string(closeLogData), "Closing 1 pull request") 152 | assert.Contains(t, string(closeLogData), "Closing pr=\"owner/should-change-2 #2\"") 153 | 154 | // 155 | // After Close PullRequestStatus 156 | // 157 | afterCloseStatusOutFile := filepath.Join(tmpDir, "after-close-status-log.txt") 158 | 159 | command = cmd.RootCmd() 160 | command.SetArgs([]string{ 161 | "status", 162 | "--output", afterCloseStatusOutFile, 163 | "-B", "custom-branch-name", 164 | }) 165 | err = command.Execute() 166 | assert.NoError(t, err) 167 | 168 | // Verify that the output was correct 169 | afterCloseStatusOutData, err := os.ReadFile(afterCloseStatusOutFile) 170 | require.NoError(t, err) 171 | assert.Equal(t, "owner/should-change #1: Merged\nowner/should-change-2 #2: Closed\n", string(afterCloseStatusOutData)) 172 | } 173 | -------------------------------------------------------------------------------- /tests/test-config.yaml: -------------------------------------------------------------------------------- 1 | branch: should-not-be-used 2 | commit-message: config-message 3 | -------------------------------------------------------------------------------- /tests/vcmock/vcmock.go: -------------------------------------------------------------------------------- 1 | // This package contains a a mock version controller (github/gitlab etc.) 2 | 3 | package vcmock 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "sync" 11 | 12 | git "github.com/go-git/go-git/v5" 13 | internalgit "github.com/lindell/multi-gitter/internal/git" 14 | "github.com/lindell/multi-gitter/internal/scm" 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | // VersionController is a mock of an version controller (Github/Gitlab/etc.) 19 | type VersionController struct { 20 | PRNumber int 21 | Repositories []Repository 22 | PullRequests []PullRequest 23 | Changes []internalgit.Changes 24 | 25 | prLock sync.RWMutex 26 | } 27 | 28 | // GetRepositories returns mock repositories 29 | func (vc *VersionController) GetRepositories(_ context.Context) ([]scm.Repository, error) { 30 | ret := make([]scm.Repository, len(vc.Repositories)) 31 | for i := range vc.Repositories { 32 | ret[i] = vc.Repositories[i] 33 | } 34 | return ret, nil 35 | } 36 | 37 | // CreatePullRequest stores a mock pull request 38 | func (vc *VersionController) CreatePullRequest(_ context.Context, repo scm.Repository, _ scm.Repository, newPR scm.NewPullRequest) (scm.PullRequest, error) { 39 | repository := repo.(Repository) 40 | 41 | vc.prLock.Lock() 42 | defer vc.prLock.Unlock() 43 | 44 | vc.PRNumber++ 45 | pr := PullRequest{ 46 | PRStatus: scm.PullRequestStatusPending, 47 | PRNumber: vc.PRNumber, 48 | Repository: repository, 49 | NewPullRequest: newPR, 50 | } 51 | vc.PullRequests = append(vc.PullRequests, pr) 52 | 53 | return pr, nil 54 | } 55 | 56 | // UpdatePullRequest updates an existing mock pull request 57 | func (vc *VersionController) UpdatePullRequest(_ context.Context, _ scm.Repository, pullReq scm.PullRequest, updatedPR scm.NewPullRequest) (scm.PullRequest, error) { 58 | pullRequest := pullReq.(PullRequest) 59 | 60 | vc.prLock.Lock() 61 | defer vc.prLock.Unlock() 62 | 63 | for i := range vc.PullRequests { 64 | if vc.PullRequests[i].PRNumber == pullRequest.PRNumber && vc.PullRequests[i].Repository.FullName() == pullRequest.Repository.FullName() { 65 | vc.PullRequests[i].Title = updatedPR.Title 66 | vc.PullRequests[i].Body = updatedPR.Body 67 | vc.PullRequests[i].Reviewers = updatedPR.Reviewers 68 | vc.PullRequests[i].TeamReviewers = updatedPR.TeamReviewers 69 | vc.PullRequests[i].Assignees = updatedPR.Assignees 70 | vc.PullRequests[i].Labels = updatedPR.Labels 71 | return vc.PullRequests[i], nil 72 | } 73 | } 74 | return nil, errors.New("could not find pull request") 75 | } 76 | 77 | // GetPullRequests gets mock pull request statuses 78 | func (vc *VersionController) GetPullRequests(_ context.Context, branchName string) ([]scm.PullRequest, error) { 79 | vc.prLock.RLock() 80 | defer vc.prLock.RUnlock() 81 | 82 | ret := make([]scm.PullRequest, 0, len(vc.PullRequests)) 83 | for _, pr := range vc.PullRequests { 84 | if pr.NewPullRequest.Head == branchName { 85 | ret = append(ret, pr) 86 | } 87 | } 88 | return ret, nil 89 | } 90 | 91 | // GetOpenPullRequest gets mock open pull request 92 | func (vc *VersionController) GetOpenPullRequest(_ context.Context, repo scm.Repository, branchName string) (scm.PullRequest, error) { 93 | vc.prLock.RLock() 94 | defer vc.prLock.RUnlock() 95 | 96 | r := repo.(Repository) 97 | 98 | for _, pr := range vc.PullRequests { 99 | if r.OwnerName == pr.OwnerName && r.RepoName == pr.RepoName && pr.NewPullRequest.Head == branchName && openPullRequest(pr) { 100 | return pr, nil 101 | } 102 | } 103 | return nil, nil 104 | } 105 | 106 | func openPullRequest(pr PullRequest) bool { 107 | return pr.PRStatus == scm.PullRequestStatusSuccess || pr.PRStatus == scm.PullRequestStatusPending 108 | } 109 | 110 | // MergePullRequest sets the status of a mock pull requests to merged 111 | func (vc *VersionController) MergePullRequest(_ context.Context, pr scm.PullRequest) error { 112 | vc.prLock.Lock() 113 | defer vc.prLock.Unlock() 114 | 115 | pullRequest := pr.(PullRequest) 116 | for i := range vc.PullRequests { 117 | if vc.PullRequests[i].Repository.FullName() == pullRequest.Repository.FullName() { 118 | vc.PullRequests[i].PRStatus = scm.PullRequestStatusMerged 119 | return nil 120 | } 121 | } 122 | return errors.New("could not find pull request") 123 | } 124 | 125 | // ClosePullRequest sets the status of a mock pull requests to closed 126 | func (vc *VersionController) ClosePullRequest(_ context.Context, pr scm.PullRequest) error { 127 | vc.prLock.Lock() 128 | defer vc.prLock.Unlock() 129 | 130 | pullRequest := pr.(PullRequest) 131 | for i := range vc.PullRequests { 132 | if vc.PullRequests[i].Repository.FullName() == pullRequest.Repository.FullName() { 133 | vc.PullRequests[i].PRStatus = scm.PullRequestStatusClosed 134 | return nil 135 | } 136 | } 137 | return errors.New("could not find pull request") 138 | } 139 | 140 | // AddRepository adds a repository to the mock 141 | func (vc *VersionController) AddRepository(repo ...Repository) { 142 | vc.Repositories = append(vc.Repositories, repo...) 143 | } 144 | 145 | // SetPRStatus sets the status of a pull request 146 | func (vc *VersionController) SetPRStatus(repoName string, branchName string, newStatus scm.PullRequestStatus) { 147 | vc.prLock.Lock() 148 | defer vc.prLock.Unlock() 149 | 150 | for i := range vc.PullRequests { 151 | if vc.PullRequests[i].Repository.RepoName == repoName && vc.PullRequests[i].Head == branchName { 152 | vc.PullRequests[i].PRStatus = newStatus 153 | } 154 | } 155 | } 156 | 157 | func (vc *VersionController) Push( 158 | ctx context.Context, 159 | r scm.Repository, 160 | commitMessage string, 161 | changes internalgit.Changes, 162 | featureBranch string, 163 | branchExist bool, 164 | forcePush bool, 165 | ) error { 166 | vc.Changes = append(vc.Changes, changes) 167 | 168 | return nil 169 | } 170 | 171 | // GetAutocompleteOrganizations gets organizations for autocompletion 172 | func (vc *VersionController) GetAutocompleteOrganizations(_ context.Context, str string) ([]string, error) { 173 | return []string{"static-org", str}, nil 174 | } 175 | 176 | // GetAutocompleteUsers gets users for autocompletion 177 | func (vc *VersionController) GetAutocompleteUsers(_ context.Context, str string) ([]string, error) { 178 | return []string{"static-user", str}, nil 179 | } 180 | 181 | // GetAutocompleteRepositories gets repositories for autocompletion 182 | func (vc *VersionController) GetAutocompleteRepositories(_ context.Context, str string) ([]string, error) { 183 | return []string{"static-repo", str}, nil 184 | } 185 | 186 | // ForkRepository forks a repository 187 | func (vc *VersionController) ForkRepository(ctx context.Context, repo scm.Repository, newOwner string) (scm.Repository, error) { 188 | r := repo.(Repository) 189 | 190 | if newOwner == "" { 191 | newOwner = "default-owner" 192 | } 193 | 194 | newPath := fmt.Sprintf("%s-forked-%s", r.Path, newOwner) 195 | 196 | _, err := git.PlainCloneContext(ctx, newPath, false, &git.CloneOptions{ 197 | URL: fmt.Sprintf(`file://%s`, filepath.ToSlash(r.Path)), 198 | }) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | return Repository{ 204 | OwnerName: newOwner, 205 | RepoName: r.RepoName, 206 | Path: newPath, 207 | }, nil 208 | } 209 | 210 | // Clean cleans up the data on disk that exist within the version controller mock 211 | func (vc *VersionController) Clean() { 212 | for _, repo := range vc.Repositories { 213 | repo.Delete() 214 | } 215 | } 216 | 217 | // PullRequest is a mock pr 218 | type PullRequest struct { 219 | PRStatus scm.PullRequestStatus 220 | PRNumber int 221 | Merged bool 222 | 223 | Repository 224 | scm.NewPullRequest 225 | } 226 | 227 | // Status returns the pr status 228 | func (pr PullRequest) Status() scm.PullRequestStatus { 229 | return pr.PRStatus 230 | } 231 | 232 | // String return a description of the pr 233 | func (pr PullRequest) String() string { 234 | return fmt.Sprintf("%s #%d", pr.Repository.FullName(), pr.PRNumber) 235 | } 236 | 237 | func (pr PullRequest) URL() string { 238 | if pr.Repository.RepoName == "has-url" { 239 | return "https://github.com/owner/has-url/pull/1" 240 | } 241 | 242 | return "" 243 | } 244 | 245 | // Repository is a mock repository 246 | type Repository struct { 247 | OwnerName string 248 | RepoName string 249 | Path string 250 | } 251 | 252 | // CloneURL return the URL (filepath) of the repository on disk 253 | func (r Repository) CloneURL() string { 254 | return fmt.Sprintf(`file://%s`, filepath.ToSlash(r.Path)) 255 | } 256 | 257 | // DefaultBranch returns "master" 258 | func (r Repository) DefaultBranch() string { 259 | return "master" 260 | } 261 | 262 | // FullName returns the name of the mock repo 263 | func (r Repository) FullName() string { 264 | return fmt.Sprintf("%s/%s", r.OwnerName, r.RepoName) 265 | } 266 | 267 | // Owner returns the owner of a repo 268 | func (r Repository) Owner() string { 269 | return r.OwnerName 270 | } 271 | 272 | // Delete deletes data on disk 273 | func (r Repository) Delete() { 274 | os.RemoveAll(r.Path) 275 | } 276 | -------------------------------------------------------------------------------- /tools/completions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | rm -rf completions 4 | mkdir completions 5 | for sh in bash zsh fish; do 6 | go run main.go completion "$sh" >"completions/multi-gitter.$sh" 7 | done 8 | -------------------------------------------------------------------------------- /tools/docs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/lindell/multi-gitter/cmd" 8 | "github.com/spf13/cobra/doc" 9 | ) 10 | 11 | const genDir = "./tmp-docs" 12 | 13 | func main() { 14 | os.RemoveAll(genDir) 15 | err := os.MkdirAll(genDir, os.ModeDir|0700) 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | 20 | rootCmd := cmd.RootCmd() 21 | rootCmd.DisableAutoGenTag = true 22 | err = doc.GenMarkdownTree(rootCmd, genDir) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tools/readme-docs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | "text/template" 13 | 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/pflag" 16 | 17 | "github.com/lindell/multi-gitter/cmd" 18 | ) 19 | 20 | const templatePath = "./docs/README.template.md" 21 | const resultingPath = "./README.md" 22 | 23 | type templateData struct { 24 | MainUsage string 25 | Commands []command 26 | ExampleCategories []exampleCategory 27 | } 28 | 29 | type command struct { 30 | ImageIcon string 31 | Name string 32 | Long string 33 | Short string 34 | Usage string 35 | YAMLExample string 36 | } 37 | 38 | type exampleCategory struct { 39 | Name string 40 | Examples []example 41 | } 42 | 43 | type example struct { 44 | Title string 45 | Body string 46 | Type string 47 | } 48 | 49 | func main() { 50 | data := templateData{} 51 | 52 | // Main usage 53 | data.MainUsage = strings.TrimSpace(cmd.RootCmd().UsageString()) 54 | 55 | subCommands := cmd.RootCmd().Commands() 56 | 57 | // All commands 58 | cmds := []struct { 59 | imgIcon string 60 | cmd *cobra.Command 61 | }{ 62 | { 63 | imgIcon: "docs/img/fa/rabbit-fast.svg", 64 | cmd: commandByName(subCommands, "run"), 65 | }, 66 | { 67 | imgIcon: "docs/img/fa/code-merge.svg", 68 | cmd: commandByName(subCommands, "merge"), 69 | }, 70 | { 71 | imgIcon: "docs/img/fa/tasks.svg", 72 | cmd: commandByName(subCommands, "status"), 73 | }, 74 | { 75 | imgIcon: "docs/img/fa/times-hexagon.svg", 76 | cmd: commandByName(subCommands, "close"), 77 | }, 78 | { 79 | imgIcon: "docs/img/fa/print.svg", 80 | cmd: commandByName(subCommands, "print"), 81 | }, 82 | } 83 | for _, c := range cmds { 84 | data.Commands = append(data.Commands, command{ 85 | Name: c.cmd.Name(), 86 | ImageIcon: c.imgIcon, 87 | Long: c.cmd.Long, 88 | Short: c.cmd.Short, 89 | Usage: strings.TrimSpace(c.cmd.UsageString()), 90 | YAMLExample: getYAMLExample(c.cmd), 91 | }) 92 | } 93 | 94 | var err error 95 | data.ExampleCategories, err = readExamples() 96 | if err != nil { 97 | log.Fatal(err) 98 | } 99 | 100 | tmpl, err := template.ParseFiles(templatePath) 101 | if err != nil { 102 | log.Fatal(err) 103 | } 104 | 105 | tmplBuf := &bytes.Buffer{} 106 | err = tmpl.Execute(tmplBuf, data) 107 | if err != nil { 108 | log.Fatal(err) 109 | } 110 | 111 | err = os.WriteFile(resultingPath, tmplBuf.Bytes(), 0644) 112 | if err != nil { 113 | log.Fatal(err) 114 | } 115 | } 116 | 117 | // Replace some of the default values in the yaml example with these values 118 | var yamlExamples = map[string]string{ 119 | "repo": "\n - my-org/js-repo\n - other-org/python-repo", 120 | "project": "\n - group/project", 121 | } 122 | 123 | var listDefaultRegex = regexp.MustCompile(`^\[(.+)\]$`) 124 | 125 | func getYAMLExample(cmd *cobra.Command) string { 126 | if cmd.Flag("config") == nil { 127 | return "" 128 | } 129 | 130 | b := strings.Builder{} 131 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 132 | if f.Name == "config" { 133 | return 134 | } 135 | 136 | // Determine how to format the example values 137 | val := f.DefValue 138 | if val == "-" { 139 | val = ` "-"` 140 | } else if val == "[]" { 141 | val = "\n - example" 142 | } else if matches := listDefaultRegex.FindStringSubmatch(val); matches != nil { 143 | val = "\n - " + strings.Join(strings.Split(matches[1], ","), "\n - ") 144 | } else if val != "" { 145 | val = " " + val 146 | } 147 | 148 | if replacement, ok := yamlExamples[f.Name]; ok { 149 | val = replacement 150 | } 151 | 152 | usage := strings.Split(strings.TrimSpace(f.Usage), "\n") 153 | for i := range usage { 154 | usage[i] = "# " + usage[i] 155 | } 156 | 157 | b.WriteString(fmt.Sprintf("%s\n%s:%s\n\n", strings.Join(usage, "\n"), f.Name, val)) 158 | }) 159 | return strings.TrimSpace(b.String()) 160 | } 161 | 162 | func commandByName(cmds []*cobra.Command, name string) *cobra.Command { 163 | for _, command := range cmds { 164 | if command.Name() == name { 165 | return command 166 | } 167 | } 168 | panic(fmt.Sprintf(`could not find command "%s"`, name)) 169 | } 170 | 171 | var titleRegex = regexp.MustCompile("(#|//) ?Title: ([^\n]+)[\n\r]+") 172 | 173 | func readExamples() ([]exampleCategory, error) { 174 | categories := []exampleCategory{} 175 | 176 | examplesDir := "./examples" 177 | files, err := os.ReadDir(examplesDir) 178 | if err != nil { 179 | return nil, err 180 | } 181 | for _, f := range files { 182 | if !f.IsDir() { 183 | continue 184 | } 185 | 186 | var examples []example 187 | categoryDir := filepath.Join(examplesDir, f.Name()) 188 | exampleFiles, err := os.ReadDir(categoryDir) 189 | if err != nil { 190 | return nil, err 191 | } 192 | for _, e := range exampleFiles { 193 | b, err := os.ReadFile(filepath.Join(categoryDir, e.Name())) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | matches := titleRegex.FindSubmatch(b) 199 | if matches == nil { 200 | return nil, errors.New("could not find title") 201 | } 202 | 203 | examples = append(examples, example{ 204 | Title: string(matches[2]), 205 | Body: strings.TrimSpace(string(titleRegex.ReplaceAll(b, nil))), 206 | Type: filepath.Ext(e.Name())[1:], 207 | }) 208 | } 209 | 210 | category := &exampleCategory{ 211 | Name: f.Name(), 212 | Examples: examples, 213 | } 214 | categories = append(categories, *category) 215 | } 216 | 217 | return categories, nil 218 | } 219 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 0.57.1 2 | --------------------------------------------------------------------------------