# frozen_string_literal: true require 'date' require_relative '../../command' require_relative '../../utils/table' require_relative '../../utils/constants' module Dri module Commands class Fetch class Pipelines < Dri::Command # rubocop:disable Metrics/ClassLength include Dri::Utils::Table include Dri::Utils::Constants using Refinements NUM_OF_TESTS_LIVE_ENV = 1000 NOT_FOUND = "Not found" def initialize(options) @options = options end def execute(input: $stdin, output: $stdout) verify_config_exists logger.info "Fetching pipelines' status, this might take a while..." logger.warn "This command needs a large window to correctly print the table" pipelines = [] table_labels = define_table_labels spinner.run do PIPELINE_ENVIRONMENTS.each do |environment, details| logger.info "Fetching last executed #{environment} pipeline" pipelines << fetch_pipeline(pipeline_name: environment.to_s, details: details) logger.info "Fetching complete for #{environment} ✓" end end print_table( table_labels, pipelines, alignments: [:center, :center, :center, :center, :center], padding: [1, 1, 1, 1] ) pipelines # Returning the array mainly for spec end private # Format a past date # @param [Integer] hours_ago the amount of hours from now # @return [String] formatted datetime def past_timestamp(hours_ago) timestamp = Time.now - (hours_ago * 60 * 60) timestamp.utc.strftime("%Y-%m-%dT%H:%M:%SZ") end # Get the first downstream pipeline of a project # @param [Integer] project_id the id of the project # @param [Integer] pipeline_id the pipeline id # @return [Gitlab::ObjectifiedHash,nil] nil if downstream (bridge) pipeline does not exist def bridge_pipeline(project_id, pipeline_id) bridges = api_client.pipeline_bridges(project_id, pipeline_id) return if bridges.empty? # If downstream pipeline doesn't exist, which triggers the QA tests, return bridges.find { |it| it.name == "e2e:package-and-test" }&.downstream_pipeline end # Get jobs from a pipeline # @param [Integer] project_id the id of the project # @param [Integer] pipeline_id the pipeline id # @param [Boolean] ops true if ops instance # @return [Array::ObjectifiedHash,nil] nil if downstream (bridge) pipeline does not exist def jobs(project_id:, pipeline_id:, ops: false) api_client(ops: ops).pipeline_jobs(project_id, pipeline_id) end # Checks if tests count exceeds threshold in a pipeline # @param [Integer] project_id the id of the project # @param [Integer] pipeline_id the pipeline id # @param [Boolean] ops true if ops instance # @return [Boolean] true if count exceeds threshold defined in constant NUM_OF_TESTS_LIVE_ENV def tests_exceed_threshold?(project_id:, pipeline_id:, ops: true) api_client(ops: ops).pipeline_test_report(project_id, pipeline_id).total_count > NUM_OF_TESTS_LIVE_ENV end # Checks if a job is present in an array of jobs # @param [Array] jobs # @param [String] job_name name of the job # @return [Boolean] true if job is present def contains_job?(jobs, job_name:) jobs.any? { |job| job["name"].include?(job_name) } end # Checks if a stage is present from a list of jobs # @param [Array] jobs # @param [String] stage_name name of the stage # @return [Boolean] true if stage is present def contains_stage?(jobs, stage_name) jobs.any? { |job| job["stage"].include?(stage_name) } end # Checks if pipeline ran only the QA smoke tests # @param [Array] jobs # @param [Integer] project_id # @param [Integer] pipeline_id # @param [Boolean] ops true if ops instance def smoke_run?(jobs:, project_id:, pipeline_id:, ops:) contains_stage?(jobs, "sanity") && !tests_exceed_threshold?(project_id: project_id, pipeline_id: pipeline_id, ops: ops) end # Checks if pipeline ran full suite of qa tests # @param [Array] jobs # @param [Integer] project_id # @param [Integer] pipeline_id # @param [Boolean] ops true if ops instance def full_run?(jobs:, project_id:, pipeline_id:, ops:) if ops (contains_stage?(jobs, "qa") || contains_stage?(jobs, "test")) && tests_exceed_threshold?(project_id: project_id, pipeline_id: pipeline_id, ops: ops) else contains_stage?(jobs, "qa") || contains_stage?(jobs, "test") # Nightly pipeline does not execute full E2E suite if sanity fails so can't check tests count end end # Combined logic to check if a pipeline was a sanity run for all pipeline types - ie., live environment # and gitlab-qa-mirror pipelines # @param [Array] pipeline_jobs # @param [Object] pipeline # @param [Boolean] ops def sanity?(pipeline_jobs:, pipeline:, ops:) return true if ops && smoke_run?(jobs: pipeline_jobs, project_id: pipeline.project_id, pipeline_id: pipeline.id, ops: ops) false if full_run?(jobs: pipeline_jobs, project_id: pipeline.project_id, pipeline_id: pipeline.id, ops: ops) end # Master pipeline run # # @param [String] pipeline_name # @return [Boolean] def master?(pipeline_name) pipeline_name == "master" end # Constructs allure report url for each pipeline # @param [String] pipeline_name # @param [Integer] pipeline_id # @param [Boolean] sanity def allure_report(pipeline_name:, pipeline_id:, sanity:) "https://storage.googleapis.com/gitlab-qa-allure-reports/#{allure_bucket_name(pipeline_name, sanity)}"\ "/master/#{pipeline_id}/index.html" end # Returns the GCP bucket name for different pipeline types # @param [String] pipeline_name # @param [Boolean] sanity def allure_bucket_name(pipeline_name, sanity) case pipeline_name when "master" "e2e-package-and-test" when "nightly" pipeline_name when "pre_prod" "preprod-#{run_type(sanity)}" else "#{pipeline_name.sub('_', '-')}-#{run_type(sanity)}" end end def run_type(sanity) sanity == true ? "sanity" : "full" end # Returns table headers # @return [Array] def define_table_labels name = add_color("Pipeline", :magenta) pipeline_last_executed = add_color("Last executed at", :magenta) url = add_color("Pipeline Url", :magenta) report = add_color("Last report", :magenta) result = add_color("Result", :magenta) [name, pipeline_last_executed, url, report, result] end # Checks if pipeline is running on ops.gitlab.net or gitlab.com # @param [String] url def ops_pipeline?(url) url.include?("ops.gitlab.net") end # Slack notification job name # # @param [Boolean] ops # @return [String] def notify_job_name(ops) ops ? "notify-slack-qa-fail" : "notify-slack-fail" end # Returns child pipeline if it is master pipeline # @param [String] pipeline_name # @param [Gitlab::ObjectifiedHash] pipeline def pipeline_with_qa_tests(pipeline_name, pipeline) if master?(pipeline_name) bridge_pipeline(pipeline.project_id, pipeline.id) else pipeline end end # Returns query options for pipelines api call # @param [Hash] details # @param [Boolean] ops def options(details, ops) options = { order_by: "updated_at", scope: "finished", ref: "master", updated_after: past_timestamp(details[:search_hours_ago]) } options.merge(username: "gitlab-bot") if ops || master?(details[:name]) options end def emoji_for_success_failure(status) return add_color("✓", :green) if status.include?("success") add_color("x", :red) end # @param [String] pipeline_name # @param [Hash] details Pipeline environment details # @return [Array] Array of last executed pipeline details # rubocop:disable Metrics/PerceivedComplexity def fetch_pipeline(pipeline_name:, details:) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize ops = ops_pipeline?(details[:url]) options = options(details, ops) # instance is ops.gitlab.net or gitlab.com response = api_client(ops: ops).pipelines(project_id: details[:project_id], options: options, auto_paginate: true) return [pipeline_name, NOT_FOUND, NOT_FOUND, NOT_FOUND, NOT_FOUND] if response.empty? # Return empty data to the table if no matching pipelines were found from the query response.each do |pipeline| pipeline_to_scan = pipeline_with_qa_tests(pipeline_name, pipeline) # Fetch child pipeline if it is master next if pipeline_to_scan.nil? pipeline_jobs = jobs(project_id: pipeline.project_id, pipeline_id: pipeline_to_scan.id, ops: ops) next unless master?(pipeline_name) || contains_job?(pipeline_jobs, job_name: notify_job_name(ops)) # Need to know if it is a sanity or a full run to construct allure report url sanity = sanity?(pipeline_jobs: pipeline_jobs, pipeline: pipeline_to_scan, ops: ops) next if sanity.nil? # To filter out some "clean up" pipelines present in live environments next if sanity && @options[:full_runs_only] # Filter out sanity runs if --full-runs-only option is passed name = ops ? "#{pipeline_name}_#{run_type(sanity)}" : pipeline_name pipeline_last_executed = pipeline_to_scan.updated_at url = pipeline_to_scan.web_url report = allure_report(pipeline_name: pipeline_name, pipeline_id: pipeline_to_scan.id, sanity: sanity) result = emoji_for_success_failure(pipeline_to_scan.status) return [name, pipeline_last_executed, url, report, result] end [pipeline_name, NOT_FOUND, NOT_FOUND, NOT_FOUND, NOT_FOUND] # Parsed through all of the response and # no matching pipelines found end # rubocop:enable Metrics/PerceivedComplexity end end end end