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