summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYorick Peterse <yorickpeterse@gmail.com>2019-01-21 17:19:22 +0100
committerYorick Peterse <yorickpeterse@gmail.com>2019-02-04 16:19:53 +0100
commit814d9c82e32008f51ee0b0e97a52e58335951508 (patch)
treeb43f5197bc6fbd1807b1226bb12b213ecabb77cc
parent2b0f4df0217b4a4aee53f964610d66ceedb68dca (diff)
downloadgitlab-ce-build-annotations.tar.gz
WIP: Add support for CI build annotationsbuild-annotations
-rw-r--r--app/models/ci/build.rb5
-rw-r--r--app/models/ci/build_annotation.rb42
-rw-r--r--app/models/ci/job_artifact.rb7
-rw-r--r--app/services/ci/create_build_annotations_service.rb46
-rw-r--r--app/workers/build_finished_worker.rb1
-rw-r--r--app/workers/ci/create_build_annotations_worker.rb15
-rw-r--r--db/migrate/20190121144851_add_build_annotations.rb35
-rw-r--r--db/schema.rb11
-rw-r--r--lib/gitlab/ci/parsers.rb3
-rw-r--r--lib/gitlab/ci/parsers/build_annotation.rb135
-rw-r--r--spec/lib/gitlab/ci/parsers/build_annotation_spec.rb213
-rw-r--r--spec/models/ci/build_annotation_spec.rb84
-rw-r--r--spec/models/ci/build_spec.rb45
-rw-r--r--spec/services/ci/create_build_annotations_service_spec.rb118
-rw-r--r--spec/workers/ci/create_build_annotations_worker_spec.rb37
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