diff --git a/.github/ISSUE_TEMPLATE/azure_devops.md b/.github/ISSUE_TEMPLATE/azure_devops.md new file mode 100644 index 0000000..0aa371d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/azure_devops.md @@ -0,0 +1,27 @@ +--- +name: Azure DevOps +about: Migrate Azure DevOps pipelines to GitHub Actions with Valet +title: "[Azure DevOps]:" +labels: azure-devops +assignees: "" +--- + +## Inputs + +Provide the following required inputs: + +Organization: +_The Azure DevOps organization to migrate pipelines from._ + +Project: +_The Azure DevOps project to migrate pipelines from._ + +## Available commands + +The following commands can be executed by adding a comment to this issue: + +- `/audit` +- `/dry-run --pipeline-type pipeline|release --pipeline-id :pipeline-id` +- `/migrate --pipeline-type pipeline|release --pipeline-id :pipeline-id --target-url :github-repository-url` + +**Note:** The `pipeline-type` option will default to `pipeline` if omitted. If any remaining options are missing, the command will not be successful. diff --git a/.github/ISSUE_TEMPLATE/circle_ci.md b/.github/ISSUE_TEMPLATE/circle_ci.md new file mode 100644 index 0000000..e762812 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/circle_ci.md @@ -0,0 +1,24 @@ +--- +name: Circle CI +about: Migrate Circle CI pipelines to GitHub Actions with Valet +title: "[Circle CI]:" +labels: circle-ci +assignees: "" +--- + +## Inputs + +Provide the following required inputs: + +Organization: +_The Circle CI organization to migrate pipelines from._ + +## Available commands + +The following commands can be executed by adding a comment to this issue: + +- `/audit` +- `/dry-run --repository :repository-name` +- `/migrate --repository :repository-name --target-url :github-repository-url` + +**Note**: If any options are missing, the command will not be successful. diff --git a/.github/ISSUE_TEMPLATE/gitlab_ci.md b/.github/ISSUE_TEMPLATE/gitlab_ci.md new file mode 100644 index 0000000..3f5cdd1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/gitlab_ci.md @@ -0,0 +1,24 @@ +--- +name: GitLab CI +about: Migrate GitLab CI pipelines to GitHub Actions with Valet +title: "[GitLab CI]:" +labels: gitlab +assignees: "" +--- + +## Inputs + +Provide the following required inputs: + +Namespace: +_The GitLab CI namespace (or group) to migrate pipelines from._ + +## Available commands + +The following commands can be executed by adding a comment to this issue: + +- `/audit` +- `/dry-run --project :project-name` +- `/migrate --project :project-name --target-url :github-repository-url` + +**Note**: If any options are missing, the command will not be successful. diff --git a/.github/ISSUE_TEMPLATE/jenkins.md b/.github/ISSUE_TEMPLATE/jenkins.md new file mode 100644 index 0000000..f509eb8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/jenkins.md @@ -0,0 +1,24 @@ +--- +name: Jenkins +about: Migrate Jenkins jobs to GitHub Actions with Valet +title: "[Jenkins]:" +labels: jenkins +assignees: "" +--- + +## Inputs + +Provide the following optional inputs: + +Folders: +_Include specific folders in an audit_ + +## Available commands + +The following commands can be executed by adding a comment to this issue: + +- `/audit` +- `/dry-run --source-url :jenkins-job-url` +- `/migrate --source-url :jenkins-job-url --target-url :github-repository-url` + +**Note**: If any options are missing, the command will not be successful. diff --git a/.github/ISSUE_TEMPLATE/travis_ci.md b/.github/ISSUE_TEMPLATE/travis_ci.md new file mode 100644 index 0000000..4bcf5b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/travis_ci.md @@ -0,0 +1,24 @@ +--- +name: Travis CI +about: Migrate Travis CI pipelines to GitHub Actions with Valet +title: "[Travis CI]:" +labels: travis-ci +assignees: "" +--- + +## Inputs + +Provide the following required inputs: + +Organization: +_The Travis CI organization to migrate pipelines from._ + +## Available commands + +The following commands can be executed by adding a comment to this issue: + +- `/audit` +- `/dry-run --repository :repository-name` +- `/migrate --repository :repository-name --target-url :github-repository-url` + +**Note**: If any options are missing, the command will not be successful. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a45a02c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +env: + ruby_version: 2.7.1 + +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ env.ruby_version }} + - name: Install dependencies + run: bundle install + - name: Run specs + run: bin/rspec + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ruby/setup-ruby@v1.47.0 + with: + ruby-version: ${{ env.ruby_version }} + - name: Install dependencies + run: bundle install + - name: Lint + run: bin/rubocop diff --git a/.github/workflows/issue_ops.yml b/.github/workflows/issue_ops.yml new file mode 100644 index 0000000..7485d11 --- /dev/null +++ b/.github/workflows/issue_ops.yml @@ -0,0 +1,227 @@ +name: valet-issue-ops + +on: + issue_comment: + types: [created] + +env: + GITHUB_INSTANCE_URL: https://github.com + GITHUB_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }} + JENKINS_INSTANCE_URL: https://jenkout.westus2.cloudapp.azure.com + JENKINS_USERNAME: ${{ secrets.jenkins_username }} + JENKINS_ACCESS_TOKEN: ${{ secrets.jenkins_access_token }} + JENKINSFILE_ACCESS_TOKEN: ${{ secrets.jenkinsfile_access_token }} + AZURE_DEVOPS_ACCESS_TOKEN: ${{ secrets.azure_devops_access_token }} + TRAVIS_CI_ACCESS_TOKEN: ${{ secrets.travis_ci_access_token }} + TRAVIS_CI_SOURCE_GITHUB_ACCESS_TOKEN: ${{ secrets.travis_ci_source_github_access_token }} + GITLAB_ACCESS_TOKEN: ${{ secrets.gitlab_access_token }} + CIRCLE_CI_ACCESS_TOKEN: ${{ secrets.circle_ci_access_token }} + CIRCLE_CI_SOURCE_GITHUB_ACCESS_TOKEN: ${{ secrets.circle_ci_source_github_access_token }} + +jobs: + execute-valet: + runs-on: ubuntu-latest + outputs: + command: ${{ steps.prepare.outputs.command }} + log-filename: ${{ steps.logs.outputs.filename }} + container: + image: ghcr.io/valet-customers/valet-cli:latest + credentials: + username: ${{ secrets.valet_ghcr_username }} + password: ${{ secrets.valet_ghcr_password }} + steps: + - uses: actions-ecosystem/action-add-labels@v1 + if: always() + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + labels: valet-running + - uses: actions/checkout@v2 + - name: Install dependencies + run: bundle install --without development + - name: Prepare arguments + id: prepare + run: | + echo "${{ toJSON(github.event.issue.labels.*.name) }}" + ./bin/parse_issue "${{ github.event.issue.body }}" "${{ github.event.comment.body }}" "${{ toJSON(github.event.issue.labels.*.name) }}" + - name: Validate arguments + run: | + if [ -z "${{ steps.prepare.outputs.provider }}" ]; then + echo "Unable to determine provider" + exit 1 + elif [ -z "${{ steps.prepare.outputs.command }}" ]; then + echo "Unable to determine command" + exit 1 + fi + - name: execute valet + run: | + valet ${{ steps.prepare.outputs.command }} ${{ steps.prepare.outputs.provider }} \ + ${{ steps.prepare.outputs.args }} \ + --output-dir /data/output \ + --no-telemetry + - uses: actions/upload-artifact@v2 + if: always() + with: + path: /data/output/ + name: output + - if: always() + id: logs + run: | + path=$(ls /data/output/log/*.log | head -1) + filename=$(basename "$path") + echo "LOG_FILE_PATH=$path" >> $GITHUB_ENV + echo "::set-output name=filename::$filename" + - uses: actions/upload-artifact@v2 + if: always() + with: + path: ${{ env.LOG_FILE_PATH }} + name: logs + + audit: + runs-on: ubuntu-latest + if: needs.execute-valet.outputs.command == 'audit' + needs: execute-valet + steps: + - uses: actions/download-artifact@v2 + if: always() + with: + name: output + - uses: actions/github-script@v5 + with: + script: | + const fs = require('fs') + const summaryText = fs.readFileSync("./audit_summary.md", "utf8") + const artifactUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}` + const body = `Audit successfully completed :rocket: + +
+ Audit summary :point_down: + + \`\`\` + ${summaryText} + \`\`\` + +
+ + Download full results [here](${artifactUrl}) + ` + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }) + dry-run: + runs-on: ubuntu-latest + if: needs.execute-valet.outputs.command == 'dry-run' + needs: execute-valet + steps: + - uses: actions/download-artifact@v2 + if: always() + with: + name: output + - uses: actions/github-script@v5 + with: + script: | + const fs = require('fs') + const directory = "${{ github.workspace }}/" + const globber = await glob.create(`${directory}**/*.yml`) + + const workflows = [] + for await (const file of globber.globGenerator()) { + const content = fs.readFileSync(file, 'utf8') + workflows.push( + "
", + ` ${file.substring(directory.length)}`, + "", + "```yaml", + content, + "```", + "
", + "" + ); + } + + const body = `Dry run was successful :boom: + + Transformed workflows: + + ${workflows.join("\n")} + ` + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }) + + migrate: + runs-on: ubuntu-latest + if: needs.execute-valet.outputs.command == 'migrate' + needs: execute-valet + steps: + - uses: actions/download-artifact@v2 + if: always() + with: + name: logs + - id: pull-request-url + run: | + pullRequest=$(grep "${{ env.pullRequestPattern }}" ${{ needs.execute-valet.outputs.log-filename }} | sed -rn "s/^.*${{ env.pullRequestPattern }}'(.+)'.*$/\1/p") + echo $pullRequest + echo ::set-output name=output::$pullRequest + env: + pullRequestPattern: "Pull request: " + - uses: actions/github-script@v5 + env: + PULL_REQUEST_URL: "${{ steps.pull-request-url.outputs.output }}" + with: + script: | + const body = `Migration was successful :sparkles: + + Continue to the [pull request](${process.env.PULL_REQUEST_URL}) to complete the migration. + ` + try { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }) + } catch(e) { + console.log(e) + } + + cleanup: + runs-on: ubuntu-latest + needs: [execute-valet, audit, migrate, dry-run] + if: always() + steps: + - uses: actions/download-artifact@v2 + if: always() && needs.execute-valet.result == 'failure' + with: + name: logs + - uses: actions/github-script@v5 + if: always() && needs.execute-valet.result == 'failure' + with: + script: | + const fs = require('fs') + const body = `Something went wrong. Please check the logs for more information. + +
+ Logs :point_down: + + \`\`\` + ${fs.readFileSync("${{ needs.execute-valet.outputs.log-filename }}", "utf8")} + \`\`\` +
+ ` + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body + }) + - uses: actions-ecosystem/action-remove-labels@v1 + if: always() + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + labels: valet-running diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20efa2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ +/log/ +/vendor/ + +.DS_Store + +# rspec failure tracking +.rspec_status + +.env*local + +*.gem +.vscode/settings.json diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..9dec795 --- /dev/null +++ b/.rspec @@ -0,0 +1,5 @@ +--format progress +--color +--require spec_helper +--format RSpec::Github::Formatter +--pattern "**/*_spec.rb" diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..a6b7ac4 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,37 @@ +inherit_gem: + rubocop-github: + - config/default_edge.yml + +Lint/AmbiguousBlockAssociation: + Exclude: + - "spec/**/*" +Style/HashEachMethods: + Enabled: true +Style/HashTransformKeys: + Enabled: true +Style/HashTransformValues: + Enabled: true +Style/Documentation: + Enabled: false +Naming/MethodParameterName: + Enabled: false +Style/MultilineIfModifier: + Enabled: false +Performance/Detect: + Enabled: false +Layout/FirstArrayElementLineBreak: + Enabled: true +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent +Layout/FirstHashElementLineBreak: + Enabled: true +Layout/FirstMethodArgumentLineBreak: + Enabled: true +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table + EnforcedLastArgumentHashStyle: always_inspect +Layout/MultilineHashKeyLineBreaks: + Enabled: true +Layout/MultilineMethodArgumentLineBreaks: + Enabled: true diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..860487c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.7.1 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..8a902e5 --- /dev/null +++ b/Gemfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } + +gem "require_all", "~> 3.0.0" + +group :development do + gem "dotenv", "~> 2.7.6" + gem "factory_bot", "~> 6.1" + gem "faker", "~> 2.17" + gem "pry-byebug", "~> 3.9" + gem "rspec", "~> 3.10" + gem "rspec-github", "~> 2.3", ">= 2.3.1" + gem "rubocop", "~> 0.80", "< 0.81" + gem "rubocop-github", "~> 0.14.0" + gem "rubocop-performance", "~> 1.6.1" + gem "ruby-debug-ide", "~> 0.7.2" + gem "shoulda-matchers", "~> 4.5.1" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..48e1546 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,94 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (6.1.4.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + ast (2.4.2) + byebug (11.1.3) + coderay (1.1.3) + concurrent-ruby (1.1.9) + diff-lcs (1.4.4) + dotenv (2.7.6) + factory_bot (6.2.0) + activesupport (>= 5.0.0) + faker (2.19.0) + i18n (>= 1.6, < 2) + i18n (1.8.11) + concurrent-ruby (~> 1.0) + jaro_winkler (1.5.4) + method_source (1.0.0) + minitest (5.14.4) + parallel (1.21.0) + parser (3.0.3.1) + ast (~> 2.4.1) + pry (0.13.1) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.9.0) + byebug (~> 11.0) + pry (~> 0.13.0) + rainbow (3.0.0) + rake (13.0.6) + require_all (3.0.0) + rexml (3.2.5) + rspec (3.10.0) + rspec-core (~> 3.10.0) + rspec-expectations (~> 3.10.0) + rspec-mocks (~> 3.10.0) + rspec-core (3.10.1) + rspec-support (~> 3.10.0) + rspec-expectations (3.10.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-github (2.3.1) + rspec-core (~> 3.0) + rspec-mocks (3.10.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.10.0) + rspec-support (3.10.3) + rubocop (0.80.1) + jaro_winkler (~> 1.5.1) + parallel (~> 1.10) + parser (>= 2.7.0.1) + rainbow (>= 2.2.2, < 4.0) + rexml + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-github (0.14.0) + rubocop (~> 0.59) + rubocop-performance (1.6.1) + rubocop (>= 0.71.0) + ruby-debug-ide (0.7.3) + rake (>= 0.8.1) + ruby-progressbar (1.11.0) + shoulda-matchers (4.5.1) + activesupport (>= 4.2.0) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) + unicode-display_width (1.6.1) + zeitwerk (2.5.1) + +PLATFORMS + ruby + x86_64-darwin-19 + +DEPENDENCIES + dotenv (~> 2.7.6) + factory_bot (~> 6.1) + faker (~> 2.17) + pry-byebug (~> 3.9) + require_all (~> 3.0.0) + rspec (~> 3.10) + rspec-github (~> 2.3, >= 2.3.1) + rubocop (~> 0.80, < 0.81) + rubocop-github (~> 0.14.0) + rubocop-performance (~> 1.6.1) + ruby-debug-ide (~> 0.7.2) + shoulda-matchers (~> 4.5.1) + +BUNDLED WITH + 2.2.33 diff --git a/Readme.md b/Readme.md index 72832be..4556f67 100644 --- a/Readme.md +++ b/Readme.md @@ -1 +1,82 @@ -# issue-ops +# Valet Issue Ops + +Valet can be orchestrated using GitHub Actions and Issues. This template repository can be used to enable this workflow to migrate pipelines from an existing CI/CD instance to GitHub Actions. + +## Getting started + +To get started create a new repository using this repository as the template by clicking [here](https://github.com/github/valet-issue-ops/generate). Next, add the repository secrets relevant to the CI/CD providers being migrated: + +### All CI/CD providers + +The following secrets are required: + +- `VALET_GHCR_USERNAME`: The username to access the `valet` container. +- `VALET_GHCR_PASSWORD`: The password to access the `valet` container (requires `read:packages` scope). +- `GH_ACCESS_TOKEN`: GitHub personal access token to create pull requests (requires `repo` and `workflow` scopes). + +Optionally, the following environment variables can be set: + +- `GITHUB_INSTANCE_URL`: The base URL of your GitHub instance (only required if it is **not** ). + +### Azure DevOps + +The following secrets are required: + +- `AZURE_DEVOPS_ACCESS_TOKEN`: The personal access token to access the Azure DevOps instance. This token requires the following scopes: + - Build (Read & Execute) + - Code (Read) + - Release (Read) + - Service Connections (Read) + - Variable Groups (Read) + +Optionally, the following environment variables can be set: + +- `AZURE_DEVOPS_INSTANCE_URL`: The base URL of your Azure DevOps instance (only required if it is **not** ). + +### Circle CI + +The following secrets are required: + +- `CIRCLE_CI_ACCESS_TOKEN`: The personal access token to access the Circle CI instance. +- `CIRCLE_CI_SOURCE_GITHUB_ACCESS_TOKEN`: The personal access token to access pipeline files stored in GitHub. + +Optionally, the following environment variables can be set: + +- `CIRCLE_CI_INSTANCE_URL`: The base URL of your Circle CI instance (only required if it is **not** ). + +### GitLab CI + +The following secrets are required: + +- `GITLAB_ACCESS_TOKEN`: The personal access token to access the GitLab CI instance (requires `read_api` scope). + +Optionally, the following environment variables can be set: + +- `GITLAB_INSTANCE_URL`: The base URL of your GitLab CI instance (only required if it is **not** ). + +### Jenkins + +The following secrets are required: + +- `JENKINSFILE_ACCESS_TOKEN`: The personal access token used to retrieve the contents of a `Jenkinsfile` stored in the build repository (requires `repo` scope). +- `JENKINS_ACCESS_TOKEN`: The access token used to view Jenkins resources. +- `JENKINS_USERNAME`: The username of the user's access token. + +The following environment variables are required: + +- `JENKINS_INSTANCE_URL`: The base URL of your Jenkins instance. + +### Travis CI + +The following secrets are required: + +- `TRAVIS_CI_ACCESS_TOKEN`: The personal access token to access the Travis CI instance. +- `TRAVIS_CI_SOURCE_GITHUB_ACCESS_TOKEN`: The personal access token to access pipeline files stored in GitHub. + +Optionally, the following environment variables can be set: + +- `TRAVIS_CI_INSTANCE_URL`: The base URL of your Travis CI instance (only required if it is **not** ). + +## Pipeline migration + +Once configured, pipelines can be migrated to GitHub Actions by opening an issue with the relevant issue template and following the instructions. diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..d27f0c3 --- /dev/null +++ b/bin/console @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "./cli" + +Pry.start diff --git a/bin/parse_issue b/bin/parse_issue new file mode 100755 index 0000000..bad38d0 --- /dev/null +++ b/bin/parse_issue @@ -0,0 +1,26 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "yaml" +require "open3" + +require_relative "./../cli" + +issue_content, comment_body, labels = ARGV + +puts labels + +raise "No issue content provided" if issue_content.nil? +raise "No comment provided" if comment_body.nil? +raise "No labels provided" if labels.nil? + +provider = Provider.new(labels) +command = Command.new(comment_body) + +arguments = Arguments.new(provider, command, issue_content) + +return unless command.valid? + +provider.to_output +command.to_output +arguments.to_output diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 0000000..80957cf --- /dev/null +++ b/bin/rspec @@ -0,0 +1,31 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "pathname" +ENV["BUNDLE_GEMFILE"] ||= File.expand_path( + "../../Gemfile", + Pathname.new(__FILE__).realpath +) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if /This file was generated by Bundler/.match?(File.read(bundle_binstub, 300)) + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rspec-core", "rspec") diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 0000000..8d97c87 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,31 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rubocop' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "pathname" +ENV["BUNDLE_GEMFILE"] ||= File.expand_path( + "../../Gemfile", + Pathname.new(__FILE__).realpath +) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if /This file was generated by Bundler/.match?(File.read(bundle_binstub, 300)) + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rubocop", "rubocop") diff --git a/cli.rb b/cli.rb new file mode 100644 index 0000000..9fb390a --- /dev/null +++ b/cli.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "require_all" +require "pry" if ENV["VALET_CONTAINER"].nil? +require "json" + +require_all "lib" diff --git a/lib/concerns/issue_parser.rb b/lib/concerns/issue_parser.rb new file mode 100644 index 0000000..c5ad0b5 --- /dev/null +++ b/lib/concerns/issue_parser.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module IssueParser + def parameter_from_issue(name, text) + match = text.match(/#{name}: ([^\n]+)/) + return if match.nil? + + match[1].strip + end +end diff --git a/lib/concerns/output_writer.rb b/lib/concerns/output_writer.rb new file mode 100644 index 0000000..44a2fb1 --- /dev/null +++ b/lib/concerns/output_writer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module OutputWriter + def set_output(name, value) + return if value.nil? + + puts "::set-output name=#{name}::#{value}" + end +end diff --git a/lib/models/arguments.rb b/lib/models/arguments.rb new file mode 100644 index 0000000..12aa66d --- /dev/null +++ b/lib/models/arguments.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../concerns/output_writer" + +class Arguments + include OutputWriter + + def initialize(provider, command, issue_content) + @args = argument_class(provider, command, issue_content) + end + + def argument_class(provider, command, issue_content) + provider.module.const_get(command.classify).new(issue_content, command) + end + + def to_output + arguments = @args.to_a + return if arguments.blank? + + set_output( + "args", + arguments.map do |a| + next a unless a.include?(" ") + + a.inspect + end.join(" ") + ) + end +end diff --git a/lib/models/azure_devops/audit.rb b/lib/models/azure_devops/audit.rb new file mode 100644 index 0000000..94dcf8f --- /dev/null +++ b/lib/models/azure_devops/audit.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module AzureDevops + class Audit + include IssueParser + + def initialize(issue_content, _) + @organization = parameter_from_issue("Organization", issue_content) + @project = parameter_from_issue("Project", issue_content) + end + + def to_a + args = [] + args.concat(["--azure-devops-organization", @organization]) unless @organization.nil? + args.concat(["--azure-devops-project", @project]) unless @project.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/azure_devops/dry_run.rb b/lib/models/azure_devops/dry_run.rb new file mode 100644 index 0000000..bb8d758 --- /dev/null +++ b/lib/models/azure_devops/dry_run.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module AzureDevops + class DryRun + include IssueParser + + def initialize(issue_content, command) + @organization = parameter_from_issue("Organization", issue_content) + @project = parameter_from_issue("Project", issue_content) + + @pipeline_type = command.options.fetch("pipeline-type", "pipeline") + @pipeline_id = command.options["pipeline-id"] + end + + def to_a + args = [@pipeline_type] + args.concat(["--azure-devops-organization", @organization]) unless @organization.nil? + args.concat(["--azure-devops-project", @project]) unless @project.nil? + args.concat(["--pipeline-id", @pipeline_id]) unless @pipeline_id.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/azure_devops/migrate.rb b/lib/models/azure_devops/migrate.rb new file mode 100644 index 0000000..053d2aa --- /dev/null +++ b/lib/models/azure_devops/migrate.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module AzureDevops + class Migrate + include IssueParser + + def initialize(issue_content, command) + @organization = parameter_from_issue("Organization", issue_content) + @project = parameter_from_issue("Project", issue_content) + + @pipeline_type = command.options.fetch("pipeline-type", "pipeline") + @pipeline_id = command.options["pipeline-id"] + @target_url = command.options["target-url"] + end + + def to_a + args = [@pipeline_type] + args.concat(["--azure-devops-organization", @organization]) unless @organization.nil? + args.concat(["--azure-devops-project", @project]) unless @project.nil? + args.concat(["--pipeline-id", @pipeline_id]) unless @pipeline_id.nil? + args.concat(["--target-url", @target_url]) unless @target_url.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/circle_ci/audit.rb b/lib/models/circle_ci/audit.rb new file mode 100644 index 0000000..2ff5f17 --- /dev/null +++ b/lib/models/circle_ci/audit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module CircleCI + class Audit + include IssueParser + + def initialize(issue_content, _) + @organization = parameter_from_issue("Organization", issue_content) + end + + def to_a + return if @organization.nil? + + ["--circle-ci-organization", @organization] + end + end +end diff --git a/lib/models/circle_ci/dry_run.rb b/lib/models/circle_ci/dry_run.rb new file mode 100644 index 0000000..9307368 --- /dev/null +++ b/lib/models/circle_ci/dry_run.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module CircleCI + class DryRun + include IssueParser + + def initialize(issue_content, command) + # TODO: manually test this + @organization = parameter_from_issue("Organization", issue_content) + @project = command.options["project"] + end + + def to_a + args = [] + args.concat(["--circle-ci-organization", @organization]) unless @organization.nil? + args.concat(["--circle-ci-project", @project]) unless @project.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/circle_ci/migrate.rb b/lib/models/circle_ci/migrate.rb new file mode 100644 index 0000000..e7caf65 --- /dev/null +++ b/lib/models/circle_ci/migrate.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module CircleCI + class Migrate + include IssueParser + + def initialize(issue_content, command) + @organization = parameter_from_issue("Organization", issue_content) + @project = command.options["project"] + @target_url = command.options["target-url"] + end + + def to_a + args = [] + args.concat(["--circle-ci-organization", @organization]) unless @organization.nil? + args.concat(["--circle-ci-project", @project]) unless @project.nil? + args.concat(["--target-url", @target_url]) unless @target_url.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/command.rb b/lib/models/command.rb new file mode 100644 index 0000000..3911c8c --- /dev/null +++ b/lib/models/command.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "active_support/core_ext/string" +require_relative "../concerns/output_writer" + +class Command + include OutputWriter + + VALID_COMMANDS = %w[audit migrate dry-run].freeze + + def initialize(comment_body) + @comment_body = comment_body + end + + def command + @command ||= begin + command = @comment_body&.match(%r{/([^\s\\]+)}) + command[1] unless command.nil? || !VALID_COMMANDS.include?(command[1]) + end + end + + def valid? + !command.nil? + end + + def classify + return unless valid? + + command.tr("-", "_").classify + end + + def options + return unless valid? + + @options ||= begin + command_text = "/#{command}" + command_index = @comment_body.index(command_text) + + options_text = @comment_body[command_index + command_text.length..-1] + options_text.split(" ") + .each_slice(2) + .to_h + .transform_keys { |key| key.delete_prefix("--") } + end + end + + def to_output + set_output("command", command) + end +end diff --git a/lib/models/gitlab_ci/audit.rb b/lib/models/gitlab_ci/audit.rb new file mode 100644 index 0000000..bef20bc --- /dev/null +++ b/lib/models/gitlab_ci/audit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module GitlabCI + class Audit + include IssueParser + + def initialize(issue_content, _) + @namespace = parameter_from_issue("Namespace", issue_content) + end + + def to_a + return if @namespace.nil? + + ["--namespace", @namespace] + end + end +end diff --git a/lib/models/gitlab_ci/dry_run.rb b/lib/models/gitlab_ci/dry_run.rb new file mode 100644 index 0000000..5a1b398 --- /dev/null +++ b/lib/models/gitlab_ci/dry_run.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module GitlabCI + class DryRun + include IssueParser + + def initialize(issue_content, command) + @namespace = parameter_from_issue("Namespace", issue_content) + @project = command.options["project"] + end + + def to_a + args = [] + args.concat(["--namespace", @namespace]) unless @namespace.nil? + args.concat(["--project", @project]) unless @project.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/gitlab_ci/migrate.rb b/lib/models/gitlab_ci/migrate.rb new file mode 100644 index 0000000..9655aad --- /dev/null +++ b/lib/models/gitlab_ci/migrate.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module GitlabCI + class Migrate + include IssueParser + + def initialize(issue_content, command) + @namespace = parameter_from_issue("Namespace", issue_content) + @project = command.options["project"] + @target_url = command.options["target-url"] + end + + def to_a + args = [] + args.concat(["--namespace", @namespace]) unless @namespace.nil? + args.concat(["--project", @project]) unless @project.nil? + args.concat(["--target-url", @target_url]) unless @target_url.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/jenkins/audit.rb b/lib/models/jenkins/audit.rb new file mode 100644 index 0000000..df029b9 --- /dev/null +++ b/lib/models/jenkins/audit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Jenkins + class Audit + include IssueParser + + def initialize(issue_content, _) + @folders = parameter_from_issue("Folders", issue_content) + end + + def to_a + return if @folders.nil? + + ["--folders", @folders] + end + end +end diff --git a/lib/models/jenkins/dry_run.rb b/lib/models/jenkins/dry_run.rb new file mode 100644 index 0000000..a09fdce --- /dev/null +++ b/lib/models/jenkins/dry_run.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Jenkins + class DryRun + include IssueParser + + def initialize(_, command) + @source_url = command.options["source-url"] + end + + def to_a + return if @source_url.nil? + + ["--source-url", @source_url] + end + end +end diff --git a/lib/models/jenkins/migrate.rb b/lib/models/jenkins/migrate.rb new file mode 100644 index 0000000..89452ef --- /dev/null +++ b/lib/models/jenkins/migrate.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Jenkins + class Migrate + include IssueParser + + def initialize(_, command) + @source_url = command.options["source-url"] + @target_url = command.options["target-url"] + end + + def to_a + args = [] + args.concat(["--source-url", @source_url]) unless @source_url.nil? + args.concat(["--target-url", @target_url]) unless @target_url.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/provider.rb b/lib/models/provider.rb new file mode 100644 index 0000000..6b2e94b --- /dev/null +++ b/lib/models/provider.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_rel "./azure_devops/**/*.rb" +require_rel "./circle_ci/**/*.rb" +require_rel "./gitlab_ci/**/*.rb" +require_rel "./jenkins/**/*.rb" +require_rel "./travis_ci/**/*.rb" + +class Provider + include OutputWriter + + PROVIDER_MAP = { + "azure-devops" => ::AzureDevops, + "circle-ci" => ::CircleCI, + "gitlab" => ::GitlabCI, + "jenkins" => ::Jenkins, + "travis-ci" => ::TravisCI + }.freeze + + def initialize(labels) + labels = labels.tr("\n", "").delete_prefix("[").delete_suffix("]").split(",").map(&:strip) + providers = labels.select { |label| PROVIDER_MAP.key?(label) } + + raise "One provider must be selected" if providers.empty? + raise "Only one provider can be selected" unless providers.one? + + @provider = providers.first + end + + def cli_command + @provider + end + + def module + PROVIDER_MAP[@provider] + end + + def to_output + set_output("provider", cli_command) + end +end diff --git a/lib/models/travis_ci/audit.rb b/lib/models/travis_ci/audit.rb new file mode 100644 index 0000000..52cfaee --- /dev/null +++ b/lib/models/travis_ci/audit.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module TravisCI + class Audit + include IssueParser + + def initialize(issue_content, _) + @organization = parameter_from_issue("Organization", issue_content) + end + + def to_a + return if @organization.nil? + + ["--travis-ci-organization", @organization] + end + end +end diff --git a/lib/models/travis_ci/dry_run.rb b/lib/models/travis_ci/dry_run.rb new file mode 100644 index 0000000..2146e16 --- /dev/null +++ b/lib/models/travis_ci/dry_run.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module TravisCI + class DryRun + include IssueParser + + def initialize(issue_content, command) + @organization = parameter_from_issue("Organization", issue_content) + @repository = command.options["repository"] + end + + def to_a + args = [] + args.concat(["--travis-ci-organization", @organization]) unless @organization.nil? + args.concat(["--travis-ci-repository", @repository]) unless @repository.nil? + + return args unless args.empty? + end + end +end diff --git a/lib/models/travis_ci/migrate.rb b/lib/models/travis_ci/migrate.rb new file mode 100644 index 0000000..132e591 --- /dev/null +++ b/lib/models/travis_ci/migrate.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module TravisCI + class Migrate + include IssueParser + + def initialize(issue_content, command) + @organization = parameter_from_issue("Organization", issue_content) + @repository = command.options["repository"] + @target_url = command.options["target-url"] + end + + def to_a + args = [] + args.concat(["--travis-ci-organization", @organization]) unless @organization.nil? + args.concat(["--travis-ci-repository", @repository]) unless @repository.nil? + args.concat(["--target-url", @target_url]) unless @target_url.nil? + + return args unless args.empty? + end + end +end diff --git a/spec/concerns/output_writer_spec.rb b/spec/concerns/output_writer_spec.rb new file mode 100644 index 0000000..79d9d27 --- /dev/null +++ b/spec/concerns/output_writer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +RSpec.describe OutputWriter do + let(:test_class) do + class TestClass + include OutputWriter + end + + TestClass.new + end + + describe "#set_output" do + let(:name) { "var_name" } + let(:value) { "var_value" } + + subject { test_class.set_output(name, value) } + + it { expect { subject }.to output(/::set-output name=#{name}::#{value}/).to_stdout } + + context "when value is nil" do + let(:value) { nil } + + it { expect { subject }.not_to output(/::set-output name=#{name}::#{value}/).to_stdout } + end + end +end diff --git a/spec/models/arguments_spec.rb b/spec/models/arguments_spec.rb new file mode 100644 index 0000000..e72deca --- /dev/null +++ b/spec/models/arguments_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +RSpec.describe Arguments do + let(:arguments) { Arguments.new(provider, command, issue_content) } + let(:provider) { instance_double(Provider) } + let(:command) { instance_double(Command) } + let(:issue_content) { "some issue content" } + + describe "#argument_class" do + subject { arguments.argument_class(provider, command, issue_content) } + + context "when the command is found" do + before do + expect(provider).to receive(:module).and_return(::Jenkins).at_least(:once) + expect(command).to receive(:classify).and_return("Audit").at_least(:once) + end + + it { is_expected.to be_a(::Jenkins::Audit) } + end + + context "when the command is not found" do + before do + expect(provider).to receive(:module).and_return(::Jenkins).at_least(:once) + expect(command).to receive(:classify).and_return("Whoopsie").at_least(:once) + end + + it { expect { subject }.to raise_error(NameError) } + end + end + + describe "#to_output" do + subject { arguments.to_output } + + before do + expect(provider).to receive(:module).and_return(::AzureDevops).at_least(:once) + expect(command).to receive(:classify).and_return("Audit").at_least(:once) + expect_any_instance_of(::AzureDevops::Audit).to receive(:to_a).and_return(output) + end + + context "when the output is nil" do + let(:output) { nil } + + it "does not write any output variable" do + expect(arguments).not_to receive(:set_output) + subject + end + end + + context "when the output is not nil" do + let(:output) { ["--option", "value"] } + + it "writes an output variable" do + expect(arguments).to receive(:set_output).with("args", "--option value") + subject + end + end + + context "when the output contains a space" do + let(:output) { ["--option", "some value"] } + + it "writes an output variable" do + expect(arguments).to receive(:set_output).with("args", "--option \"some value\"") + subject + end + end + end +end diff --git a/spec/models/azure_devops/audit_spec.rb b/spec/models/azure_devops/audit_spec.rb new file mode 100644 index 0000000..ae9b3cd --- /dev/null +++ b/spec/models/azure_devops/audit_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.describe AzureDevops::Audit do + let(:audit) { described_class.new(issue_content, nil) } + + describe "#to_a" do + subject { audit.to_a } + + context "when issue_content contains no args" do + let(:issue_content) do + <<~ISSUE + Organization: + Project: + ISSUE + end + + it { is_expected.to be_nil } + end + + context "when issue_content contains an organization" do + let(:issue_content) do + <<~ISSUE + Organization: my-organization + Project: + ISSUE + end + + it { is_expected.to eq(["--azure-devops-organization", "my-organization"]) } + end + + context "when issue_content contains a project" do + let(:issue_content) do + <<~ISSUE + Organization: my-organization + Project: my-project + ISSUE + end + + it { is_expected.to eq(["--azure-devops-organization", "my-organization", "--azure-devops-project", "my-project"]) } + end + end +end diff --git a/spec/models/azure_devops/dry_run_spec.rb b/spec/models/azure_devops/dry_run_spec.rb new file mode 100644 index 0000000..ff35f00 --- /dev/null +++ b/spec/models/azure_devops/dry_run_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +RSpec.describe AzureDevops::DryRun do + let(:dry_run) { described_class.new(issue_content, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + let(:issue_content) do + <<~ISSUE + Organization: my-organization + Project: my-project + ISSUE + end + + context "when the comment body does not contain a pipeline type" do + let(:comment_body) { "/dry-run" } + + it { is_expected.to eq(["pipeline", "--azure-devops-organization", "my-organization", "--azure-devops-project", "my-project"]) } + end + + context "when the comment body contains a pipeline id" do + let(:comment_body) { "/dry-run --pipeline-id 42" } + + it { is_expected.to eq(["pipeline", "--azure-devops-organization", "my-organization", "--azure-devops-project", "my-project", "--pipeline-id", "42"]) } + end + end +end diff --git a/spec/models/azure_devops/migrate_spec.rb b/spec/models/azure_devops/migrate_spec.rb new file mode 100644 index 0000000..b027964 --- /dev/null +++ b/spec/models/azure_devops/migrate_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +RSpec.describe AzureDevops::Migrate do + let(:dry_run) { described_class.new(issue_content, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + let(:issue_content) do + <<~ISSUE + Organization: my-organization + Project: my-project + ISSUE + end + + context "when the comment body does not contain a pipeline type" do + let(:comment_body) { "/migrate" } + + it { is_expected.to eq(["pipeline", "--azure-devops-organization", "my-organization", "--azure-devops-project", "my-project"]) } + end + + context "when the comment body contains a pipeline id" do + let(:comment_body) { "/migrate --pipeline-id 42 --target-url https://github.com/org/repo" } + + it { is_expected.to eq(["pipeline", "--azure-devops-organization", "my-organization", "--azure-devops-project", "my-project", "--pipeline-id", "42", "--target-url", "https://github.com/org/repo"]) } + end + end +end diff --git a/spec/models/circle_ci/audit_spec.rb b/spec/models/circle_ci/audit_spec.rb new file mode 100644 index 0000000..daefc71 --- /dev/null +++ b/spec/models/circle_ci/audit_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe CircleCI::Audit do + let(:audit) { described_class.new(issue_content, nil) } + + describe "#to_a" do + subject { audit.to_a } + + context "when issue_content contains no args" do + let(:issue_content) do + <<~ISSUE + Organization: + ISSUE + end + + it { is_expected.to be_nil } + end + + context "when issue_content contains a organization" do + let(:issue_content) do + <<~ISSUE + Organization: testing + ISSUE + end + + it { is_expected.to eq(["--circle-ci-organization", "testing"]) } + end + end +end diff --git a/spec/models/circle_ci/dry_run_spec.rb b/spec/models/circle_ci/dry_run_spec.rb new file mode 100644 index 0000000..4f9215c --- /dev/null +++ b/spec/models/circle_ci/dry_run_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe CircleCI::DryRun do + let(:dry_run) { described_class.new(issue_content, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + let(:issue_content) do + <<~ISSUE + Organization: testing + ISSUE + end + + context "when the comment body contains a project" do + let(:comment_body) { "/dry-run --project repo" } + + it { is_expected.to eq(["--circle-ci-organization", "testing", "--circle-ci-project", "repo"]) } + end + end +end diff --git a/spec/models/circle_ci/migrate_spec.rb b/spec/models/circle_ci/migrate_spec.rb new file mode 100644 index 0000000..f6acaf7 --- /dev/null +++ b/spec/models/circle_ci/migrate_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe CircleCI::Migrate do + let(:dry_run) { described_class.new(issue_content, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + let(:issue_content) do + <<~ISSUE + Organization: testing + ISSUE + end + + context "when the comment body contains a project" do + let(:comment_body) { "/migrate --project repo --target-url https://github.com/org/repo" } + + it { is_expected.to eq(["--circle-ci-organization", "testing", "--circle-ci-project", "repo", "--target-url", "https://github.com/org/repo"]) } + end + end +end diff --git a/spec/models/command_spec.rb b/spec/models/command_spec.rb new file mode 100644 index 0000000..26bd005 --- /dev/null +++ b/spec/models/command_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +RSpec.describe Command do + let(:command) { described_class.new(comment_body) } + + describe "#command" do + subject { command.command } + + context "when no command is present" do + let(:comment_body) do + <<~COMMENT + This is just a comment. + COMMENT + end + + it { is_expected.to be_nil } + end + + context "when an unsupported comment is present" do + let(:comment_body) do + <<~COMMENT + /do-something-unsupported + COMMENT + end + + it { is_expected.to be_nil } + end + + described_class::VALID_COMMANDS.each do |c| + context "when a supported command is present" do + let(:comment_body) do + <<~COMMENT + /#{c} + COMMENT + end + + it { is_expected.to eq(c) } + end + end + end + + describe "#options" do + subject { command.options } + + context "when the command is not valid" do + let(:comment_body) { "/do-something-unsupported --option-one value" } + + it { is_expected.to be_nil } + end + + context "when no options are present" do + let(:comment_body) { "/dry-run" } + + it { is_expected.to be_blank } + end + + context "when options are present" do + let(:comment_body) { "/dry-run --option-one value-one --option-two value-two" } + + it { is_expected.to eq({ "option-one" => "value-one", "option-two" => "value-two" }) } + end + end + + describe "#valid?" do + subject { command.valid? } + + context "when the command is supported" do + let(:comment_body) { "/dry-run" } + + it { is_expected.to be_truthy } + end + + context "when the command is not supported" do + let(:comment_body) { "/run-something-else" } + + it { is_expected.to be_falsey } + end + end + + describe "#classify" do + subject { command.classify } + + [%w[audit Audit], %w[dry-run DryRun], %w[migrate Migrate]].each do |command, klass| + context "when the command is #{command}" do + let(:comment_body) { "/#{command}" } + + it { is_expected.to eq(klass) } + end + end + + context "when the command is invalid" do + let(:comment_body) { "/do-something-unsupported" } + + it { is_expected.to be_nil } + end + end + + describe "to_output" do + subject { command.to_output } + + let(:comment_body) do + <<~COMMENT + /audit + COMMENT + end + + it { expect { subject }.to output(/::set-output name=command::audit/).to_stdout } + end +end diff --git a/spec/models/gitlab_ci/audit_spec.rb b/spec/models/gitlab_ci/audit_spec.rb new file mode 100644 index 0000000..40410a2 --- /dev/null +++ b/spec/models/gitlab_ci/audit_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe GitlabCI::Audit do + let(:audit) { described_class.new(issue_content, nil) } + + describe "#to_a" do + subject { audit.to_a } + + context "when issue_content contains no args" do + let(:issue_content) do + <<~ISSUE + Namespace: + ISSUE + end + + it { is_expected.to be_nil } + end + + context "when issue_content contains a namespace" do + let(:issue_content) do + <<~ISSUE + Namespace: testing + ISSUE + end + + it { is_expected.to eq(["--namespace", "testing"]) } + end + end +end diff --git a/spec/models/gitlab_ci/dry_run_spec.rb b/spec/models/gitlab_ci/dry_run_spec.rb new file mode 100644 index 0000000..4068466 --- /dev/null +++ b/spec/models/gitlab_ci/dry_run_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe GitlabCI::DryRun do + let(:dry_run) { described_class.new(issue_content, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + let(:issue_content) do + <<~ISSUE + Namespace: testing + ISSUE + end + + context "when the comment body contains a pipeline id" do + let(:comment_body) { "/dry-run --project project" } + + it { is_expected.to eq(["--namespace", "testing", "--project", "project"]) } + end + end +end diff --git a/spec/models/gitlab_ci/migrate_spec.rb b/spec/models/gitlab_ci/migrate_spec.rb new file mode 100644 index 0000000..aff8035 --- /dev/null +++ b/spec/models/gitlab_ci/migrate_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe GitlabCI::Migrate do + let(:dry_run) { described_class.new(issue_content, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + let(:issue_content) do + <<~ISSUE + Namespace: testing + ISSUE + end + + context "when the comment body contains a pipeline id" do + let(:comment_body) { "/migrate --project project --target-url https://github.com/org/repo" } + + it { is_expected.to eq(["--namespace", "testing", "--project", "project", "--target-url", "https://github.com/org/repo"]) } + end + end +end diff --git a/spec/models/jenkins/audit_spec.rb b/spec/models/jenkins/audit_spec.rb new file mode 100644 index 0000000..442a43a --- /dev/null +++ b/spec/models/jenkins/audit_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe Jenkins::Audit do + let(:audit) { described_class.new(issue_content, nil) } + + describe "#to_a" do + subject { audit.to_a } + + context "when issue_content contains no args" do + let(:issue_content) do + <<~ISSUE + Folders: + ISSUE + end + + it { is_expected.to be_nil } + end + + context "when issue_content contains a folder" do + let(:issue_content) do + <<~ISSUE + Folders: test, prod + ISSUE + end + + it { is_expected.to eq(["--folders", "test, prod"]) } + end + end +end diff --git a/spec/models/jenkins/dry_run_spec.rb b/spec/models/jenkins/dry_run_spec.rb new file mode 100644 index 0000000..7bb4dd1 --- /dev/null +++ b/spec/models/jenkins/dry_run_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.describe Jenkins::DryRun do + let(:dry_run) { described_class.new(nil, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + + context "when the comment body contains a pipeline id" do + let(:comment_body) { "/dry-run --source-url https://jenkins.company.com/job/pipeline" } + + it { is_expected.to eq(["--source-url", "https://jenkins.company.com/job/pipeline"]) } + end + end +end diff --git a/spec/models/jenkins/migrate_spec.rb b/spec/models/jenkins/migrate_spec.rb new file mode 100644 index 0000000..31822a5 --- /dev/null +++ b/spec/models/jenkins/migrate_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.describe Jenkins::Migrate do + let(:dry_run) { described_class.new(nil, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + + context "when the comment body contains a pipeline id" do + let(:comment_body) { "/migrate --source-url https://jenkins.company.com/job/pipeline --target-url https://github.com/org/repo" } + + it { is_expected.to eq(["--source-url", "https://jenkins.company.com/job/pipeline", "--target-url", "https://github.com/org/repo"]) } + end + end +end diff --git a/spec/models/provider_spec.rb b/spec/models/provider_spec.rb new file mode 100644 index 0000000..edd7572 --- /dev/null +++ b/spec/models/provider_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.describe Provider do + let(:provider) { described_class.new(labels) } + + describe "#initialize" do + subject { provider } + + context "when no providers are selected" do + let(:labels) do + <<~LABELS + [] + LABELS + end + + it { expect { subject }.to raise_error("One provider must be selected") } + end + + context "when multiple providers are selected" do + let(:labels) do + <<~LABELS + [ + jenkins, + azure-devops + ] + LABELS + end + + it { expect { subject }.to raise_error("Only one provider can be selected") } + end + + context "when an unsupported provider is selected" do + let(:labels) do + <<~LABELS + [ + something-unsupported + ] + LABELS + end + + it { expect { subject }.to raise_error("One provider must be selected") } + end + + context "when a single provider is selected" do + let(:labels) do + <<~LABELS + [ + jenkins + ] + LABELS + end + + it { expect { subject }.not_to raise_error } + end + end + + describe "#cli_command" do + subject { provider.cli_command } + let(:labels) do + <<~LABELS + [ + gitlab + ] + LABELS + end + + it { is_expected.to eq("gitlab") } + end + + describe "#module" do + subject { provider.module } + + let(:labels) do + <<~LABELS + [ + travis-ci + ] + LABELS + end + + it { is_expected.to eq(::TravisCI) } + end + + describe "to_output" do + subject { provider.to_output } + + let(:labels) do + <<~LABELS + [ + azure-devops + ] + LABELS + end + + it { expect { subject }.to output(/::set-output name=provider::azure-devops/).to_stdout } + end +end diff --git a/spec/models/travis_ci/audit_spec.rb b/spec/models/travis_ci/audit_spec.rb new file mode 100644 index 0000000..4bc3a1e --- /dev/null +++ b/spec/models/travis_ci/audit_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe TravisCI::Audit do + let(:audit) { described_class.new(issue_content, nil) } + + describe "#to_a" do + subject { audit.to_a } + + context "when issue_content contains no args" do + let(:issue_content) do + <<~ISSUE + Organization: + ISSUE + end + + it { is_expected.to be_nil } + end + + context "when issue_content contains an organization" do + let(:issue_content) do + <<~ISSUE + Organization: testing + ISSUE + end + + it { is_expected.to eq(["--travis-ci-organization", "testing"]) } + end + end +end diff --git a/spec/models/travis_ci/dry_run_spec.rb b/spec/models/travis_ci/dry_run_spec.rb new file mode 100644 index 0000000..cb132d2 --- /dev/null +++ b/spec/models/travis_ci/dry_run_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe TravisCI::DryRun do + let(:dry_run) { described_class.new(issue_content, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + let(:issue_content) do + <<~ISSUE + Organization: testing + ISSUE + end + + context "when the comment body contains a pipeline id" do + let(:comment_body) { "/dry-run --repository repo" } + + it { is_expected.to eq(["--travis-ci-organization", "testing", "--travis-ci-repository", "repo"]) } + end + end +end diff --git a/spec/models/travis_ci/migrate_spec.rb b/spec/models/travis_ci/migrate_spec.rb new file mode 100644 index 0000000..51b0dbf --- /dev/null +++ b/spec/models/travis_ci/migrate_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.describe TravisCI::Migrate do + let(:dry_run) { described_class.new(issue_content, command) } + let(:command) { Command.new(comment_body) } + + describe "#to_a" do + subject { dry_run.to_a } + let(:issue_content) do + <<~ISSUE + Organization: testing + ISSUE + end + + context "when the comment body contains a pipeline id" do + let(:comment_body) { "/migrate --repository repo --target-url https://github.com/org/repo" } + + it { is_expected.to eq(["--travis-ci-organization", "testing", "--travis-ci-repository", "repo", "--target-url", "https://github.com/org/repo"]) } + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..54818c6 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "bundler/setup" +require "factory_bot" +require "faker" + +require_relative "./../cli" + +Dir[File.join(__dir__, "support/**/*.rb")].sort.each { |f| require f } + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = ".rspec_status" + + # Disable RSpec exposing methods globally on `Module` and `main` + config.disable_monkey_patching! + + config.expect_with :rspec do |c| + # Override truncation of expected/got output + c.max_formatted_output_length = 1000 + c.syntax = :expect + end + + config.include FactoryBot::Syntax::Methods + + config.before(:suite) do + FactoryBot.find_definitions + end +end diff --git a/spec/support/should_matchers.rb b/spec/support/should_matchers.rb new file mode 100644 index 0000000..1f21366 --- /dev/null +++ b/spec/support/should_matchers.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "shoulda-matchers" + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + end +end