diff options
author | Yorick Peterse <yorickpeterse@gmail.com> | 2019-01-21 17:19:22 +0100 |
---|---|---|
committer | Yorick Peterse <yorickpeterse@gmail.com> | 2019-02-04 16:19:53 +0100 |
commit | 814d9c82e32008f51ee0b0e97a52e58335951508 (patch) | |
tree | b43f5197bc6fbd1807b1226bb12b213ecabb77cc | |
parent | 2b0f4df0217b4a4aee53f964610d66ceedb68dca (diff) | |
download | gitlab-ce-build-annotations.tar.gz |
WIP: Add support for CI build annotationsbuild-annotations
-rw-r--r-- | app/models/ci/build.rb | 5 | ||||
-rw-r--r-- | app/models/ci/build_annotation.rb | 42 | ||||
-rw-r--r-- | app/models/ci/job_artifact.rb | 7 | ||||
-rw-r--r-- | app/services/ci/create_build_annotations_service.rb | 46 | ||||
-rw-r--r-- | app/workers/build_finished_worker.rb | 1 | ||||
-rw-r--r-- | app/workers/ci/create_build_annotations_worker.rb | 15 | ||||
-rw-r--r-- | db/migrate/20190121144851_add_build_annotations.rb | 35 | ||||
-rw-r--r-- | db/schema.rb | 11 | ||||
-rw-r--r-- | lib/gitlab/ci/parsers.rb | 3 | ||||
-rw-r--r-- | lib/gitlab/ci/parsers/build_annotation.rb | 135 | ||||
-rw-r--r-- | spec/lib/gitlab/ci/parsers/build_annotation_spec.rb | 213 | ||||
-rw-r--r-- | spec/models/ci/build_annotation_spec.rb | 84 | ||||
-rw-r--r-- | spec/models/ci/build_spec.rb | 45 | ||||
-rw-r--r-- | spec/services/ci/create_build_annotations_service_spec.rb | 118 | ||||
-rw-r--r-- | spec/workers/ci/create_build_annotations_worker_spec.rb | 37 |
15 files changed, 794 insertions, 3 deletions
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 84010e40ef4..0c8a7cf6a94 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -755,6 +755,11 @@ module Ci :creating end + # @return [NilClass|Ci::JobArtifact] + def first_build_annotation_artifact + job_artifacts.build_annotation.first + end + private def erase_old_artifacts! diff --git a/app/models/ci/build_annotation.rb b/app/models/ci/build_annotation.rb new file mode 100644 index 00000000000..f2ba9d0f529 --- /dev/null +++ b/app/models/ci/build_annotation.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Ci + class BuildAnnotation < ActiveRecord::Base + self.table_name = 'ci_build_annotations' + + belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id + + enum severity: { + info: 0, + warning: 1, + error: 2 + } + + # We deliberately validate just the presence of the ID, and not the target + # row. We do this for two reasons: + # + # 1. Foreign key checks already ensure the ID points to a valid row. + # + # 2. When parsing artifacts, we run validations for every row to make sure + # they are in the correct format. Validating an association would result + # in a database query being executed for every entry, slowing down the + # parsing process. + validates :build_id, presence: true + + validates :severity, presence: true + validates :summary, presence: true, length: { maximum: 512 } + + validates :line_number, + numericality: { + greater_than_or_equal_to: 1, + less_than_or_equal_to: 32767, + only_integer: true + }, + allow_nil: true + + # Only giving a file path or line number makes no sense, so if either is + # given we require both to be present. + validates :line_number, presence: true, if: :file_path? + validates :file_path, presence: true, if: :line_number? + end +end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 789bb293811..fdb2db38349 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -21,7 +21,8 @@ module Ci container_scanning: 'gl-container-scanning-report.json', dast: 'gl-dast-report.json', license_management: 'gl-license-management-report.json', - performance: 'performance.json' + performance: 'performance.json', + build_annotation: 'gl-build-annotations.json' }.freeze TYPE_AND_FORMAT_PAIRS = { @@ -29,6 +30,7 @@ module Ci metadata: :gzip, trace: :raw, junit: :gzip, + build_annotation: :gzip, # All these file formats use `raw` as we need to store them uncompressed # for Frontend to fetch the files and do analysis @@ -88,7 +90,8 @@ module Ci dast: 8, ## EE-specific codequality: 9, ## EE-specific license_management: 10, ## EE-specific - performance: 11 ## EE-specific + performance: 11, ## EE-specific + build_annotation: 12 } enum file_format: { diff --git a/app/services/ci/create_build_annotations_service.rb b/app/services/ci/create_build_annotations_service.rb new file mode 100644 index 00000000000..665ba995bc0 --- /dev/null +++ b/app/services/ci/create_build_annotations_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Ci + # Parses and stores the build annotations of a single CI build. + class CreateBuildAnnotationsService + attr_reader :build + + # @param [Ci::Build] build + def initialize(build) + @build = build + end + + def execute + artifact = build.first_build_annotation_artifact + + return unless artifact + + annotations = parse_annotations(artifact) + + insert_annotations(annotations) if annotations.any? + end + + # @param [Ci::JobArtifact] artifact + def parse_annotations(artifact) + Gitlab::Ci::Parsers.fabricate!(:build_annotation).parse!(artifact) + rescue Gitlab::Ci::Parsers::BuildAnnotation::ParserError => error + build_error_annotation(error.message) + end + + # @param [Array<Hash>] rows + def insert_annotations(rows) + Gitlab::Database.bulk_insert(::Ci::BuildAnnotation.table_name, rows) + end + + # @param [String] message + def build_error_annotation(message) + [ + { + build_id: build.id, + severity: Ci::BuildAnnotation.severities[:error], + summary: message + } + ] + end + end +end diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index ae853ec9316..b516d08999b 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -30,5 +30,6 @@ class BuildFinishedWorker # We execute these async as these are independent operations. BuildHooksWorker.perform_async(build.id) ArchiveTraceWorker.perform_async(build.id) + Ci::CreateBuildAnnotationsService.perform_async(build.id) end end diff --git a/app/workers/ci/create_build_annotations_worker.rb b/app/workers/ci/create_build_annotations_worker.rb new file mode 100644 index 00000000000..2467b53a527 --- /dev/null +++ b/app/workers/ci/create_build_annotations_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Ci + # Sidekiq worker for storing the build annotations produced by a CI build. + class CreateBuildAnnotationsWorker + include ApplicationWorker + + # @param [Integer] build_id + def perform(build_id) + if (build = Ci::Build.find_by_id(build_id)) + CreateBuildAnnotationsService.new(build).execute + end + end + end +end diff --git a/db/migrate/20190121144851_add_build_annotations.rb b/db/migrate/20190121144851_add_build_annotations.rb new file mode 100644 index 00000000000..5541069af0b --- /dev/null +++ b/db/migrate/20190121144851_add_build_annotations.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddBuildAnnotations < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def change + create_table(:ci_build_annotations, id: :bigserial) do |t| + t.integer :build_id, null: false, index: true + t.integer :severity, limit: 2, null: false + + # We use a smallint for line numbers as a maximum value of 32 767 should + # be more than sufficient. + t.integer :line_number, limit: 2 + + # The path to the file the check belongs to, if any. + t.text :file_path + + # We may end up displaying multiple summaries on a page. If these do not + # have any limitations on the length, this may result in very big pages. + # To prevent this from happening we enforce a more than reasonable limit + # of 512 characters. + t.string :summary, limit: 512, null: false + + t.text :description + + t.foreign_key :ci_builds, column: :build_id, on_delete: :cascade + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 4b6e4992056..432d8c791d8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -274,6 +274,16 @@ ActiveRecord::Schema.define(version: 20190131122559) do t.index ["namespace_id"], name: "index_chat_teams_on_namespace_id", unique: true, using: :btree end + create_table "ci_build_annotations", id: :bigserial, force: :cascade do |t| + t.integer "build_id", null: false + t.integer "severity", limit: 2, null: false + t.integer "line_number", limit: 2 + t.text "file_path" + t.string "summary", limit: 512, null: false + t.text "description" + t.index ["build_id"], name: "index_ci_build_annotations_on_build_id", using: :btree + end + create_table "ci_build_trace_chunks", id: :bigserial, force: :cascade do |t| t.integer "build_id", null: false t.integer "chunk_index", null: false @@ -2321,6 +2331,7 @@ ActiveRecord::Schema.define(version: 20190131122559) do add_foreign_key "boards", "namespaces", column: "group_id", on_delete: :cascade add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade add_foreign_key "chat_teams", "namespaces", on_delete: :cascade + add_foreign_key "ci_build_annotations", "ci_builds", column: "build_id", on_delete: :cascade add_foreign_key "ci_build_trace_chunks", "ci_builds", column: "build_id", on_delete: :cascade add_foreign_key "ci_build_trace_section_names", "projects", on_delete: :cascade add_foreign_key "ci_build_trace_sections", "ci_build_trace_section_names", column: "section_name_id", name: "fk_264e112c66", on_delete: :cascade diff --git a/lib/gitlab/ci/parsers.rb b/lib/gitlab/ci/parsers.rb index eb63e6c8363..cd73ac9a6a2 100644 --- a/lib/gitlab/ci/parsers.rb +++ b/lib/gitlab/ci/parsers.rb @@ -7,7 +7,8 @@ module Gitlab def self.parsers { - junit: ::Gitlab::Ci::Parsers::Test::Junit + junit: ::Gitlab::Ci::Parsers::Test::Junit, + build_annotation: ::Gitlab::Ci::Parsers::BuildAnnotation } end diff --git a/lib/gitlab/ci/parsers/build_annotation.rb b/lib/gitlab/ci/parsers/build_annotation.rb new file mode 100644 index 00000000000..0ed63f8c031 --- /dev/null +++ b/lib/gitlab/ci/parsers/build_annotation.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Parsers + # Parsing of CI build annotation artifacts. + # + # This class parses a CI build annotations artifact file, and inserts + # valid entries into the database. + class BuildAnnotation + ParserError = Class.new(Parsers::ParserError) + + # The maximum nesting level to allow when parsing JSON. + # + # This value is deliberately set to a low value, as valid annotation + # files won't use deeply nested structures. + MAX_JSON_NESTING = 5 + + # The maximum number of annotations a single artifact is allowed to + # produce. + # + # This limit exists to prevent users from creating tens of thousands + # very small entries, which could still have a negative impact on + # performance and availability. + MAX_ENTRIES = 1_000 + + # The maximum allowed size of an artifacts file. + MAXIMUM_SIZE = 10.megabytes + + # Parses an annotations file in JSON format, returning database rows to + # insert. + # + # The returned rows are validated and can thus be inserted straight into + # a database table. + # + # @param [Ci::JobArtifact] artifact The CI artifact to parse. + # @return [Array<Hash>] + def parse!(artifact) + if artifact.size > MAX_ENTRIES + raise( + ParserError, + _( + 'Build annotation artifacts can not be greater than %{bytes} bytes' + ) % { bytes: MAXIMUM_SIZE } + ) + end + + entries = [] + + artifact.each_blob do |json| + entries.concat(parse_json(json)) + end + + build_annotation_rows(artifact, entries) + end + + # @param [String] json + # @return [Array<Hash>] + def parse_json(json) + begin + entries = JSON.parse(json, max_nesting: MAX_JSON_NESTING) + rescue JSON::NestingError + raise( + ParserError, + _('The annotations artifact has too many nested objects') + ) + end + + if entries.length > MAX_ENTRIES + raise( + ParserError, + _('Only up to %{maximum} annotations are allowed per build') % { + maximum: MAX_ENTRIES + } + ) + end + + entries + end + + # @param [Ci::JobArtifact] artifact + # @param [Hash] annotations + # @return [Array<Hash>] + def build_annotation_rows(artifact, annotations) + annotations.map do |annotation| + unless annotation.is_a?(Hash) + parser_error!( + annotation, + _('each annotation must be a JSON object') + ) + end + + attributes = { + build_id: artifact.job_id, + severity: ::Ci::BuildAnnotation.severities[annotation['severity']], + summary: annotation['summary'], + description: annotation['description'], + line_number: annotation['line_number'], + file_path: annotation['file_path'] + } + + validate_database_attributes!(annotation, attributes) + + # We do not return the model attributes as they may include + # additional columns without the right values, such as the `id` + # column (which will default to `nil`). + attributes + end + end + + # @param [Hash] annotation + # @param [Hash] attributes + def validate_database_attributes!(annotation, attributes) + model = ::Ci::BuildAnnotation.new(attributes) + + return if model.valid? + + parser_error!(annotation, model.errors.full_messages.join(', ')) + end + + # @param [#inspect] annotation + # @param [String] error + def parser_error!(annotation, error) + formatted_error = + _('The annotation %{annotation} is invalid: %{error}') % { + annotation: annotation.inspect, + error: error + } + + raise ParserError, formatted_error + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/parsers/build_annotation_spec.rb b/spec/lib/gitlab/ci/parsers/build_annotation_spec.rb new file mode 100644 index 00000000000..1b2c262f096 --- /dev/null +++ b/spec/lib/gitlab/ci/parsers/build_annotation_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Ci::Parsers::BuildAnnotation do + let(:parser) { described_class.new } + + describe '#parse!' do + context 'when the annotations artifact is too large' do + it 'raises a ParserError' do + artifact = double(:artifact, job_id: 1, size: 20.megabytes) + + expect { parser.parse!(artifact) } + .to raise_error(described_class::ParserError) + end + end + + context 'when the annotations artifact is small enough' do + it 'parses the JSON and returns the build annotations' do + json = JSON.dump([ + { + 'severity' => 'info', + 'summary' => 'summary here', + 'description' => 'description here', + 'line_number' => 14, + 'file_path' => 'README.md' + } + ]) + + artifact = double(:artifact, job_id: 1, size: 4) + + allow(artifact) + .to receive(:each_blob) + .and_yield(json) + + annotations = parser.parse!(artifact) + + expect(annotations).to eq([ + { + build_id: 1, + severity: Ci::BuildAnnotation.severities[:info], + summary: 'summary here', + description: 'description here', + line_number: 14, + file_path: 'README.md' + } + ]) + end + end + end + + describe '#parse_json' do + context 'when the JSON object contains too many nested objects' do + it 'raises a ParserError' do + json = JSON.dump([[[[[[{}]]]]]]) + + expect { parser.parse_json(json) } + .to raise_error(described_class::ParserError) + end + end + + context 'when the JSON contains too many entries' do + it 'raises a ParserError' do + json = JSON.dump(Array.new(described_class::MAX_ENTRIES + 1) { {} }) + + expect { parser.parse_json(json) } + .to raise_error(described_class::ParserError) + end + end + + context 'when the JSON is valid' do + it 'returns the annotations' do + json = JSON.dump([{}]) + + expect(parser.parse_json(json)).to eq([{}]) + end + end + end + + describe '#build_annotation_rows' do + context 'when one of the entries is not a JSON object' do + it 'raises a ParserError' do + artifact = double(:artifact) + + expect { parser.build_annotation_rows(artifact, [10]) } + .to raise_error(described_class::ParserError) + end + end + + context 'when one of the JSON objects is invalid' do + it 'raises a ParserError' do + artifact = double(:artifact, job_id: 1) + + expect { parser.build_annotation_rows(artifact, [{}]) } + .to raise_error(described_class::ParserError) + end + end + + context 'when the JSON objects are valid' do + it 'returns the database attributes' do + artifact = double(:artifact, job_id: 1) + annotations = [ + { + 'severity' => 'info', + 'summary' => 'summary here', + 'description' => 'description here', + 'line_number' => 14, + 'file_path' => 'README.md' + } + ] + + attributes = parser.build_annotation_rows(artifact, annotations) + + expect(attributes).to eq([ + { + build_id: 1, + severity: Ci::BuildAnnotation.severities[:info], + summary: 'summary here', + description: 'description here', + line_number: 14, + file_path: 'README.md' + } + ]) + end + end + end + + describe '#validate_database_attributes!' do + context 'when the attributes are invalid' do + it 'raises a ParserError' do + annotation = { + 'severity' => 'info', + 'summary' => 'summary here', + 'description' => 'description here', + 'line_number' => 14 + } + + attributes = { + build_id: 1, + severity: Ci::BuildAnnotation.severities[:info], + summary: 'summary here', + description: 'description here', + line_number: 14 + } + + expect { parser.validate_database_attributes!(annotation, attributes) } + .to raise_error(described_class::ParserError) + end + end + + context 'when the attributes are valid' do + it 'returns nil' do + annotation = { + 'severity' => 'info', + 'summary' => 'summary here', + 'description' => 'description here' + } + + attributes = { + build_id: 1, + severity: Ci::BuildAnnotation.severities[:info], + summary: 'summary here', + description: 'description here' + } + + expect(parser.validate_database_attributes!(annotation, attributes)) + .to be_nil + end + end + + it 'does not execute any SQL queries' do + annotation = { + 'severity' => 'info', + 'summary' => 'summary here', + 'description' => 'description here', + 'line_number' => 14, + 'file_path' => 'README.md' + } + + attributes = { + build_id: 1, + severity: Ci::BuildAnnotation.severities[:info], + summary: 'summary here', + description: 'description here', + line_number: 14, + file_path: 'README.md' + } + + # It's possible that in a rare case this test is the very first test to + # run. This means the schema for `Ci::BuildAnnotation` might not be loaded + # yet, which would cause the below assertion to fail. + # + # To prevent this from happening, we ensure the schema is always present + # before testing the number of queries executed during validation. + Ci::BuildAnnotation.columns + + queries = ActiveRecord::QueryRecorder + .new { parser.validate_database_attributes!(annotation, attributes) } + .count + + expect(queries).to be_zero + end + end + + describe '#parser_error!' do + it 'raises a ParserError' do + expect { parser.parser_error!({}, 'foo') }.to raise_error( + described_class::ParserError, + 'The annotation {} is invalid: foo' + ) + end + end +end diff --git a/spec/models/ci/build_annotation_spec.rb b/spec/models/ci/build_annotation_spec.rb new file mode 100644 index 00000000000..7b716be48ab --- /dev/null +++ b/spec/models/ci/build_annotation_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::BuildAnnotation do + def annotation(*args) + described_class.new(*args).tap do |annotation| + annotation.validate + end + end + + describe 'when validating the severity' do + it 'produces an error if it is missing' do + expect(annotation.errors[:severity]).not_to be_empty + end + end + + describe 'when validating the CI build ID' do + it 'produces an error if it is missing' do + expect(annotation.errors[:build_id]).not_to be_empty + end + end + + describe 'when validating the summary' do + it 'produces an error if it is missing' do + expect(annotation.errors[:summary]).not_to be_empty + end + + it 'produces an error if it exceeds the maximum length' do + expect(annotation(summary: 'a' * 1024).errors[:summary]).not_to be_empty + end + + it 'does not produce an error if it is valid' do + expect(annotation(summary: 'a').errors[:summary]).to be_empty + end + end + + describe 'when validating the line number' do + it 'produces an error if it is zero' do + expect(annotation(line_number: 0).errors[:line_number]).not_to be_empty + end + + it 'produces an error if it is negative' do + expect(annotation(line_number: -1).errors[:line_number]).not_to be_empty + end + + it 'produces an error if it is too great' do + expect(annotation(line_number: 40_000).errors[:line_number]) + .not_to be_empty + end + + it 'produces an error if the file path is not present' do + expect(annotation(line_number: 1).errors[:file_path]).not_to be_empty + end + + it 'does not produce an error if it is valid' do + row = annotation(line_number: 1, file_path: 'foo.rb') + + expect(row.errors[:line_number]).to be_empty + expect(row.errors[:file_path]).to be_empty + end + + it 'does not produce an error if it and the file path are not given' do + row = annotation + + expect(row.errors[:line_number]).to be_empty + expect(row.errors[:file_path]).to be_empty + end + end + + describe 'when validating the file path' do + it 'produces an error if the line number is not present' do + expect(annotation(file_path: 'foo.rb').errors[:line_number]) + .not_to be_empty + end + + it 'does not produce an error if it is valid' do + row = annotation(line_number: 1, file_path: 'foo.rb') + + expect(row.errors[:file_path]).to be_empty + expect(row.errors[:line_number]).to be_empty + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 8a1bbb26e57..0550b456c21 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -3602,4 +3602,49 @@ describe Ci::Build do end end end + + describe '#first_build_annotation_artifact' do + context 'when there are no build annotations' do + it 'returns nil' do + build = create(:ci_build) + + expect(build.first_build_annotation_artifact).to be_nil + end + end + + context 'when there is one build annotation artifact' do + it 'returns the artifact' do + build = create(:ci_build) + artifact = create( + :ci_job_artifact, + job: build, + file_type: :annotation, + file_format: :gzip + ) + + expect(build.first_build_annotation_artifact).to eq(artifact) + end + end + + context 'when there are multiple build annotation artifacts' do + it 'returns the oldest artifact' do + build = create(:ci_build) + artifact1 = create( + :ci_job_artifact, + job: build, + file_type: :annotation, + file_format: :gzip + ) + + create( + :ci_job_artifact, + job: build, + file_type: :annotation, + file_format: :gzip + ) + + expect(build.first_build_annotation_artifact).to eq(artifact1) + end + end + end end diff --git a/spec/services/ci/create_build_annotations_service_spec.rb b/spec/services/ci/create_build_annotations_service_spec.rb new file mode 100644 index 00000000000..032ee25745d --- /dev/null +++ b/spec/services/ci/create_build_annotations_service_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::CreateBuildAnnotationsService do + describe '#execute' do + let(:ci_build) { create(:ci_build) } + let(:service) { described_class.new(ci_build) } + + context 'when there is no annotations artifact' do + it 'does nothing' do + service.execute + + expect(Ci::BuildAnnotation.count).to be_zero + end + end + + context 'when there is an annotation artifact' do + it 'imports the artifact' do + artifact = double(:artifact, job_id: ci_build.id, size: 1) + json = JSON.dump([{ severity: 'warning', summary: 'hello' }]) + + allow(artifact) + .to receive(:each_blob) + .and_yield(json) + + allow(ci_build) + .to receive(:first_build_annotation_artifact) + .and_return(artifact) + + service.execute + + expect(Ci::BuildAnnotation.count).to eq(1) + end + end + end + + describe '#parse_annotations' do + let(:service) { described_class.new(double(:build, id: 1)) } + + context 'when the annotations are valid' do + it 'returns the database rows to insert' do + artifact = double(:artifact, job_id: 1, size: 1) + json = JSON.dump([{ severity: 'warning', summary: 'hello' }]) + + allow(artifact) + .to receive(:each_blob) + .and_yield(json) + + expect(service.parse_annotations(artifact)).to eq([ + { + build_id: 1, + severity: Ci::BuildAnnotation.severities[:warning], + summary: 'hello', + description: nil, + line_number: nil, + file_path: nil + } + ]) + end + end + + context 'when the annotations are not valid' do + it 'returns an error row to insert' do + artifact = double(:artifact, job_id: 1, size: 1) + json = JSON.dump([{ summary: 'hello' }]) + + allow(artifact) + .to receive(:each_blob) + .and_yield(json) + + annotations = service.parse_annotations(artifact) + + expect(annotations.length).to eq(1) + + expect(annotations[0][:severity]) + .to eq(Ci::BuildAnnotation.severities[:error]) + + expect(annotations[0][:summary]).to be_an_instance_of(String) + end + end + end + + describe '#insert_annotations' do + it 'inserts the build annotations into the database' do + build = create(:ci_build) + row = { + build_id: build.id, + severity: Ci::BuildAnnotation.severities[:warning], + summary: 'hello', + description: nil, + line_number: nil, + file_path: nil + } + + service = described_class.new(build) + + service.insert_annotations([row]) + + expect(Ci::BuildAnnotation.count).to eq(1) + end + end + + describe '#build_error_annotation' do + it 'returns an annotation to use when an error was produced' do + build = create(:ci_build) + service = described_class.new(build) + + expect(service.build_error_annotation('foo')).to eq([ + { + build_id: build.id, + severity: Ci::BuildAnnotation.severities[:error], + summary: 'foo' + } + ]) + end + end +end diff --git a/spec/workers/ci/create_build_annotations_worker_spec.rb b/spec/workers/ci/create_build_annotations_worker_spec.rb new file mode 100644 index 00000000000..63ffba12c6f --- /dev/null +++ b/spec/workers/ci/create_build_annotations_worker_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::CreateBuildAnnotationsWorker do + describe '#perform' do + context 'when the CI build does not exist' do + it 'does nothing' do + expect(Ci::CreateBuildAnnotationsService) + .not_to receive(:new) + + described_class.new.perform(-1) + end + end + + context 'when the CI build exists' do + it 'creates the build annotations for the build' do + build = create(:ci_build) + worker = described_class.new + service = instance_spy(Ci::CreateBuildAnnotationsService) + + allow(Ci::CreateBuildAnnotationsService) + .to receive(:new) + .with(build) + .and_return(service) + + allow(service) + .to receive(:execute) + + worker.perform(build.id) + + expect(service) + .to have_received(:execute) + end + end + end +end |