From af11e77c9cddbf0e1407c8db44487930b9d41eae Mon Sep 17 00:00:00 2001 From: jrgriffiniii <1443986+jrgriffiniii@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:55:34 -0400 Subject: [PATCH] Implementing support for auditing Circle CI configurations and parsing a configuration file --- .prettierignore | 3 +- cli.thor | 296 ++++++++++++++++++++++++++++++++++++++++++++++-- config/cli.yaml | 10 ++ 3 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 config/cli.yaml diff --git a/.prettierignore b/.prettierignore index 42061c0..26c8c87 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -README.md \ No newline at end of file +README.md +tmp/ diff --git a/cli.thor b/cli.thor index 92bedd1..f543f5e 100755 --- a/cli.thor +++ b/cli.thor @@ -2,9 +2,12 @@ require 'octokit' require 'thor' +require 'yaml' class Samvera < Thor + attr_reader :owner, :repo, :label, :project_id + desc "audit_issues", "Audits a repository for all stale issues, labels them, and adds a comment to the issue." option :repo, required: true, type: :string option :updated, type: :string, default: "2021-01-01" @@ -13,13 +16,11 @@ class Samvera < Thor option :project_id, type: :numeric, default: 28 def audit_issues - repo = options[:repo] + @repo = options[:repo] created = options[:created] updated = options[:updated] - label = options[:label] - - # Authenticate with GitHub - client = Octokit::Client.new(access_token: ENV['GH_TOKEN']) + @label = options[:label] + @project_id = options[:project_id] # Define the search criteria query = "repo:#{repo} is:issue is:open created:<#{created} updated:<#{updated}" @@ -63,9 +64,11 @@ class Samvera < Thor say("No milestone to remove from Issue ##{issue.number}", :yellow) end - project_url = "https://github.com/orgs/samvera/projects/#{project_id}" - project_card = client.create_project_card(project_url, content_id: issue.id, content_type: 'Issue') - say("Added Issue ##{issue.number} to Project '#{project_name}'", :green) + # This will fail, as projects are only supported with the GraphQL API + # @see https://docs.github.com/en/rest/projects/projects?apiVersion=2022-11-28 + # project_url = "https://github.com/orgs/samvera/projects/#{project_id}" + # project_card = client.create_project_card(project_url, content_id: issue.id, content_type: 'Issue') + # say("Added Issue ##{issue.number} to Project '#{project_url}'", :green) rescue Octokit::Error => e say("Failed to audit Issue ##{issue.number}: #{e.message}", :red) end @@ -86,8 +89,6 @@ class Samvera < Thor user_login = options[:user] org = options[:org] - client = Octokit::Client.new(access_token: ENV['GH_TOKEN']) - begin team = client.team_by_name(org, team_slug) team_id = team[:id] @@ -113,8 +114,6 @@ class Samvera < Thor user_login = options[:user] org = options[:org] - client = Octokit::Client.new(access_token: ENV['GH_TOKEN']) - begin team = client.team_by_name(org, team_slug) team_id = team[:id] @@ -129,5 +128,278 @@ class Samvera < Thor say("The user is not a member of the team.", :yellow) end end + + desc "audit_repo_ci", "Audit the continuous integration (CI) configuration for a Samvera GitHub Repository" + option :repo, required: true, type: :string + option :owner, type: :string, default: "samvera" + option :label, type: :string, default: "maintenance" + option :project_id, type: :numeric, default: 28 + def audit_repo_ci + + @owner = options[:owner] + @repo = options[:repo] + @label = options[:label] + @project_id = options[:project_id] + + repo_url = "https://github.com/#{owner}/#{repo}.git" + local_dir = "tmp/#{repo}" + + # Clone the repository if it doesn't already exist + unless Dir.exist?(local_dir) + say("Cloning repository...", :green) + system("git clone #{repo_url} #{local_dir}") + end + + file_path = File.join(local_dir, '.circleci', 'config.yml') + + if File.exist?(file_path) + say("File exists: #{file_path}", :green) + + content = File.read(file_path) + config = YAML.load(content) + + if config.key?("orbs") + say("Orbs are specified", :green) + + orbs = config["orbs"] + + if orbs.key?("samvera") + say("samvera/circleci-orb is used", :green) + + samvera_orb = orbs["samvera"] + if samvera_orb != samvera_orb_release + validation_error = "Unsupported release of samvera/circleci-orb is referenced" + handle_error(validation_error: validation_error) + else + say("Latest supported release of samvera/circleci-orb is referenced", :green) + end + else + validation_error = "samvera/circleci-orb is not used" + handle_error(validation_error: validation_error) + end + else + validation_error = "No orbs are specified" + handle_error(validation_error: validation_error) + end + + if config.key?("jobs") + + jobs = config["jobs"] + checks_for_master_branch = false + + jobs.each_pair do |key, job| + + if job.key?("parameters") + parameters = job["parameters"] + + if parameters.key?("ruby_version") + say("Ruby version is parameterized for #{key}", :green) + else + validation_error = "Ruby version is not parameterized for #{key}" + handle_error(validation_error: validation_error) + end + + if parameters.key?("bundler_version") + say("Bundler version is parameterized for #{key}", :green) + else + validation_error = "Bundler version is not parameterized for #{key}" + handle_error(validation_error: validation_error) + end + else + validation_error = "Parameters are not specified for job #{key}" + handle_error(validation_error: validation_error) + end + + if job.key?("steps") + steps = job["steps"] + + if steps.empty? + validation_error = "Steps are empty for #{key}" + handle_error(validation_error: validation_error) + end + + steps.each do |step| + if step.is_a?(Hash) + if step.key?("run") + command = step["run"] + + if command.key?("name") + name = command["name"] + + if name == "Check for a branch named 'master'" + checks_for_master_branch = true + say("Found a job which checks for the existence of a branch named `master`.", :green) + end + end + end + end + end + else + validation_error = "Steps are not specified for job #{key}" + handle_error(validation_error: validation_error) + end + end + + unless checks_for_master_branch + validation_error = "No job checks for the existence of a branch named `master`." + handle_error(validation_error: validation_error) + end + else + validation_error = "No jobs are specified" + handle_error(validation_error: validation_error) + end + + if config.key?("workflows") + + workflows = config["workflows"] + + workflows.each_pair do |key, workflow| + + if workflow.key?("jobs") + jobs = workflow["jobs"] + + jobs.each do |job| + job.each_pair do |key, arg| + if arg.key?("ruby_version") + ruby_version = arg["ruby_version"] + + if supported_ruby_versions.include?(ruby_version) + say("Supported Ruby version #{ruby_version} is used for CircleCI", :green) + else + validation_error = "Unsupported Ruby version #{ruby_version} is used for CircleCI" + handle_error(validation_error: validation_error) + end + end + + if arg.key?("rails_version") + rails_version = arg["rails_version"] + + if supported_rails_versions.include?(rails_version) + say("Supported Rails version #{rails_version} is used for CircleCI", :green) + else + validation_error = "Unsupported Rails version #{rails_version} is used for CircleCI" + handle_error(validation_error: validation_error) + end + end + end + end + else + validation_error = "No workflow jobs are specified" + handle_error(validation_error: validation_error) + end + end + else + validation_error = "No workflows are specified" + handle_error(validation_error: validation_error) + end + else + validation_error = "File does not exist: #{file_path}" + handle_error(validation_error: validation_error) + end + end + + private + + def config + @config ||= begin + file_path = "./config/cli.yaml" + yaml_content = File.read(file_path) + YAML.load(yaml_content) + end + end + + def samvera_orb_release + config["samvera_orb_release"] + end + + def supported_ruby_versions + config["supported_ruby_versions"] + end + + def supported_rails_versions + config["supported_rails_versions"] + end + + def errors + @errors ||= [] + end + + def access_token + ENV['GH_TOKEN'] + end + + def client + @client ||= Octokit::Client.new(access_token: access_token) + end + + def repository + repository ||= client.repo("#{owner}/#{repo}") + end + + def project_url + @project_url ||= "https://github.com/orgs/samvera/projects/#{project_id}" + end + + # This will fail, as projects are only supported with the GraphQL API + # @see https://docs.github.com/en/rest/projects/projects?apiVersion=2022-11-28 + def columns + client.project_columns(project_id) + end + + def column + columns.first + end + + def column_id + column["id"] + end + + def prepare_github_issue(issue:) + unless issue.labels.map(&:name).include?(label) + + begin + client.add_labels_to_an_issue(repository.id, issue.number, [label]) + say("Label ``#{label}\" applied to Issue ##{issue.number}", :green) + + # This will fail, as projects are only supported with the GraphQL API + # @see https://docs.github.com/en/rest/projects/projects?apiVersion=2022-11-28 + # + # say("Using #{column_id} for Project '#{project_id}'", :green) + # project_card = client.create_project_card(column_id, content_id: issue.id, content_type: 'Issue') + # say("Added Issue ##{issue.number} to Project '#{project_id}'", :green) + rescue Octokit::Error => e + say("Failed to audit Issue ##{issue.number}: #{e.message}", :red) + end + end + end + + def create_github_issue(issue_title:, issue_body:) + + issues = client.issues(repository.id) + existing_issues = issues.select { |issue| issue.title == issue_title } + + if !existing_issues.empty? + existing_issues.each do |issue| + say("Issue exists: #{issue.html_url}", :yellow) + prepare_github_issue(issue: issue) + end + else + issue = self.client.create_issue(repository.id, issue_title, issue_body) + say("Issue created: #{issue.html_url}", :green) + prepare_github_issue(issue: issue) + end + rescue Octokit::Error => e + say("Error creating issue: #{e.message}", :red) + end + + def handle_error(validation_error:) + say(validation_error, :red) + + unless errors.include?(validation_error) + issue_title = "CircleCI audit error: #{validation_error}" + create_github_issue(issue_title: issue_title, issue_body: validation_error) + errors << validation_error + end + end end diff --git a/config/cli.yaml b/config/cli.yaml new file mode 100644 index 0000000..333161b --- /dev/null +++ b/config/cli.yaml @@ -0,0 +1,10 @@ +--- +samvera_orb_release: "samvera/circleci-orb@1.0" +supported_ruby_versions: + - "3.1.6" + - "3.2.5" + - "3.3.5" +supported_rails_versions: + - "7.0.8.5" + - "7.1.4.1" + - "7.2.1.1"