From c17e6a6c68b0412b3433632802b852db474a7b30 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Sat, 6 May 2017 16:45:46 +0000 Subject: Real time pipeline show action --- app/controllers/projects/pipelines_controller.rb | 22 +- app/models/ci/group.rb | 40 +++ app/models/ci/stage.rb | 8 + app/serializers/job_group_entity.rb | 16 + app/serializers/stage_entity.rb | 8 +- app/serializers/status_entity.rb | 7 + changelogs/unreleased/zj-real-time-pipelines.yml | 4 + lib/gitlab/ci/status/group/common.rb | 21 ++ lib/gitlab/ci/status/group/factory.rb | 13 + lib/gitlab/etag_caching/router.rb | 6 +- .../projects/pipelines_controller_spec.rb | 31 ++ spec/fixtures/api/schemas/pipeline.json | 354 +++++++++++++++++++++ spec/lib/gitlab/ci/status/group/common_spec.rb | 20 ++ spec/lib/gitlab/ci/status/group/factory_spec.rb | 13 + spec/models/ci/group_spec.rb | 44 +++ spec/models/ci/stage_spec.rb | 33 +- spec/serializers/stage_entity_spec.rb | 8 + 17 files changed, 637 insertions(+), 11 deletions(-) create mode 100644 app/models/ci/group.rb create mode 100644 app/serializers/job_group_entity.rb create mode 100644 changelogs/unreleased/zj-real-time-pipelines.yml create mode 100644 lib/gitlab/ci/status/group/common.rb create mode 100644 lib/gitlab/ci/status/group/factory.rb create mode 100644 spec/fixtures/api/schemas/pipeline.json create mode 100644 spec/lib/gitlab/ci/status/group/common_spec.rb create mode 100644 spec/lib/gitlab/ci/status/group/factory_spec.rb create mode 100644 spec/models/ci/group_spec.rb diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index f9adedcb074..5cb2e428201 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -8,6 +8,8 @@ class Projects::PipelinesController < Projects::ApplicationController wrap_parameters Ci::Pipeline + POLLING_INTERVAL = 10_000 + def index @scope = params[:scope] @pipelines = PipelinesFinder @@ -31,7 +33,7 @@ class Projects::PipelinesController < Projects::ApplicationController respond_to do |format| format.html format.json do - Gitlab::PollingInterval.set_header(response, interval: 10_000) + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) render json: { pipelines: PipelineSerializer @@ -57,15 +59,25 @@ class Projects::PipelinesController < Projects::ApplicationController @pipeline = Ci::CreatePipelineService .new(project, current_user, create_params) .execute(ignore_skip_ci: true, save_on_errors: false) - unless @pipeline.persisted? + + if @pipeline.persisted? + redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) + else render 'new' - return end - - redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) end def show + respond_to do |format| + format.html + format.json do + Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) + + render json: PipelineSerializer + .new(project: @project, user: @current_user) + .represent(@pipeline, grouped: true) + end + end end def builds diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb new file mode 100644 index 00000000000..87898b086c6 --- /dev/null +++ b/app/models/ci/group.rb @@ -0,0 +1,40 @@ +module Ci + ## + # This domain model is a representation of a group of jobs that are related + # to each other, like `rspec 0 1`, `rspec 0 2`. + # + # It is not persisted in the database. + # + class Group + include StaticModel + + attr_reader :stage, :name, :jobs + + delegate :size, to: :jobs + + def initialize(stage, name:, jobs:) + @stage = stage + @name = name + @jobs = jobs + end + + def status + @status ||= commit_statuses.status + end + + def detailed_status(current_user) + if jobs.one? + jobs.first.detailed_status(current_user) + else + Gitlab::Ci::Status::Group::Factory + .new(self, current_user).fabricate! + end + end + + private + + def commit_statuses + @commit_statuses ||= CommitStatus.where(id: jobs.map(&:id)) + end + end +end diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index e7d6b17d445..9bda3186c30 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -15,6 +15,14 @@ module Ci @warnings = warnings end + def groups + @groups ||= statuses.ordered.latest + .sort_by(&:sortable_name).group_by(&:group_name) + .map do |group_name, grouped_statuses| + Ci::Group.new(self, name: group_name, jobs: grouped_statuses) + end + end + def to_param name end diff --git a/app/serializers/job_group_entity.rb b/app/serializers/job_group_entity.rb new file mode 100644 index 00000000000..a4d3737429c --- /dev/null +++ b/app/serializers/job_group_entity.rb @@ -0,0 +1,16 @@ +class JobGroupEntity < Grape::Entity + include RequestAwareEntity + + expose :name + expose :size + expose :detailed_status, as: :status, with: StatusEntity + expose :jobs, with: BuildEntity + + private + + alias_method :group, :object + + def detailed_status + group.detailed_status(request.user) + end +end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb index 7a047bdc712..97ced8730ed 100644 --- a/app/serializers/stage_entity.rb +++ b/app/serializers/stage_entity.rb @@ -7,9 +7,11 @@ class StageEntity < Grape::Entity "#{stage.name}: #{detailed_status.label}" end - expose :detailed_status, - as: :status, - with: StatusEntity + expose :groups, + if: -> (_, opts) { opts[:grouped] }, + with: JobGroupEntity + + expose :detailed_status, as: :status, with: StatusEntity expose :path do |stage| namespace_project_pipeline_path( diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb index 188c3747f18..3e40ecf1c1c 100644 --- a/app/serializers/status_entity.rb +++ b/app/serializers/status_entity.rb @@ -12,4 +12,11 @@ class StatusEntity < Grape::Entity ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) end + + expose :action, if: -> (status, _) { status.has_action? } do + expose :action_icon, as: :icon + expose :action_title, as: :title + expose :action_path, as: :path + expose :action_method, as: :method + end end diff --git a/changelogs/unreleased/zj-real-time-pipelines.yml b/changelogs/unreleased/zj-real-time-pipelines.yml new file mode 100644 index 00000000000..eec22e67467 --- /dev/null +++ b/changelogs/unreleased/zj-real-time-pipelines.yml @@ -0,0 +1,4 @@ +--- +title: Pipeline view updates in near real time +merge_request: 10777 +author: diff --git a/lib/gitlab/ci/status/group/common.rb b/lib/gitlab/ci/status/group/common.rb new file mode 100644 index 00000000000..cfd4329a923 --- /dev/null +++ b/lib/gitlab/ci/status/group/common.rb @@ -0,0 +1,21 @@ +module Gitlab + module Ci + module Status + module Group + module Common + def has_details? + false + end + + def details_path + nil + end + + def has_action? + false + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/group/factory.rb b/lib/gitlab/ci/status/group/factory.rb new file mode 100644 index 00000000000..d118116cfc3 --- /dev/null +++ b/lib/gitlab/ci/status/group/factory.rb @@ -0,0 +1,13 @@ +module Gitlab + module Ci + module Status + module Group + class Factory < Status::Factory + def self.common_helpers + Status::Group::Common + end + end + end + end + end +end diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index aac210f19e8..692c909d838 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -36,7 +36,11 @@ module Gitlab Gitlab::EtagCaching::Router::Route.new( %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines\.json\z), 'project_pipelines' - ) + ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS})).*/pipelines/\d+\.json\z), + 'project_pipeline' + ), ].freeze def self.match(env) diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 1b47d163c0b..fb4a4721a58 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::PipelinesController do + include ApiHelpers + let(:user) { create(:user) } let(:project) { create(:empty_project, :public) } @@ -24,6 +26,7 @@ describe Projects::PipelinesController do it 'returns JSON with serialized pipelines' do expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline') expect(json_response).to include('pipelines') expect(json_response['pipelines'].count).to eq 4 @@ -34,6 +37,34 @@ describe Projects::PipelinesController do end end + describe 'GET show JSON' do + let!(:pipeline) { create(:ci_pipeline_with_one_job, project: project) } + + it 'returns the pipeline' do + get_pipeline_json + + expect(response).to have_http_status(:ok) + expect(json_response).not_to be_an(Array) + expect(json_response['id']).to be(pipeline.id) + expect(json_response['details']).to have_key 'stages' + end + + context 'when the pipeline has multiple jobs' do + it 'does not perform N + 1 queries' do + control_count = ActiveRecord::QueryRecorder.new { get_pipeline_json }.count + + create(:ci_build, pipeline: pipeline) + + # The plus 2 is needed to group and sort + expect { get_pipeline_json }.not_to exceed_query_limit(control_count + 2) + end + end + + def get_pipeline_json + get :show, namespace_id: project.namespace, project_id: project, id: pipeline, format: :json + end + end + describe 'GET stages.json' do let(:pipeline) { create(:ci_pipeline, project: project) } diff --git a/spec/fixtures/api/schemas/pipeline.json b/spec/fixtures/api/schemas/pipeline.json new file mode 100644 index 00000000000..55511d17b5e --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline.json @@ -0,0 +1,354 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": {}, + "id": "http://example.com/example.json", + "properties": { + "commit": { + "id": "/properties/commit", + "properties": { + "author": { + "id": "/properties/commit/properties/author", + "type": "null" + }, + "author_email": { + "id": "/properties/commit/properties/author_email", + "type": "string" + }, + "author_gravatar_url": { + "id": "/properties/commit/properties/author_gravatar_url", + "type": "string" + }, + "author_name": { + "id": "/properties/commit/properties/author_name", + "type": "string" + }, + "authored_date": { + "id": "/properties/commit/properties/authored_date", + "type": "string" + }, + "commit_path": { + "id": "/properties/commit/properties/commit_path", + "type": "string" + }, + "commit_url": { + "id": "/properties/commit/properties/commit_url", + "type": "string" + }, + "committed_date": { + "id": "/properties/commit/properties/committed_date", + "type": "string" + }, + "committer_email": { + "id": "/properties/commit/properties/committer_email", + "type": "string" + }, + "committer_name": { + "id": "/properties/commit/properties/committer_name", + "type": "string" + }, + "created_at": { + "id": "/properties/commit/properties/created_at", + "type": "string" + }, + "id": { + "id": "/properties/commit/properties/id", + "type": "string" + }, + "message": { + "id": "/properties/commit/properties/message", + "type": "string" + }, + "parent_ids": { + "id": "/properties/commit/properties/parent_ids", + "items": { + "id": "/properties/commit/properties/parent_ids/items", + "type": "string" + }, + "type": "array" + }, + "short_id": { + "id": "/properties/commit/properties/short_id", + "type": "string" + }, + "title": { + "id": "/properties/commit/properties/title", + "type": "string" + } + }, + "type": "object" + }, + "created_at": { + "id": "/properties/created_at", + "type": "string" + }, + "details": { + "id": "/properties/details", + "properties": { + "artifacts": { + "id": "/properties/details/properties/artifacts", + "items": {}, + "type": "array" + }, + "duration": { + "id": "/properties/details/properties/duration", + "type": "integer" + }, + "finished_at": { + "id": "/properties/details/properties/finished_at", + "type": "string" + }, + "manual_actions": { + "id": "/properties/details/properties/manual_actions", + "items": {}, + "type": "array" + }, + "stages": { + "id": "/properties/details/properties/stages", + "items": { + "id": "/properties/details/properties/stages/items", + "properties": { + "dropdown_path": { + "id": "/properties/details/properties/stages/items/properties/dropdown_path", + "type": "string" + }, + "groups": { + "id": "/properties/details/properties/stages/items/properties/groups", + "items": { + "id": "/properties/details/properties/stages/items/properties/groups/items", + "properties": { + "name": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/name", + "type": "string" + }, + "size": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/size", + "type": "integer" + }, + "status": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/details_path", + "type": "null" + }, + "favicon": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/stages/items/properties/groups/items/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "name": { + "id": "/properties/details/properties/stages/items/properties/name", + "type": "string" + }, + "path": { + "id": "/properties/details/properties/stages/items/properties/path", + "type": "string" + }, + "status": { + "id": "/properties/details/properties/stages/items/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/stages/items/properties/status/properties/details_path", + "type": "string" + }, + "favicon": { + "id": "/properties/details/properties/stages/items/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/stages/items/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/stages/items/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/stages/items/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/stages/items/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/stages/items/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + }, + "title": { + "id": "/properties/details/properties/stages/items/properties/title", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "status": { + "id": "/properties/details/properties/status", + "properties": { + "details_path": { + "id": "/properties/details/properties/status/properties/details_path", + "type": "string" + }, + "favicon": { + "id": "/properties/details/properties/status/properties/favicon", + "type": "string" + }, + "group": { + "id": "/properties/details/properties/status/properties/group", + "type": "string" + }, + "has_details": { + "id": "/properties/details/properties/status/properties/has_details", + "type": "boolean" + }, + "icon": { + "id": "/properties/details/properties/status/properties/icon", + "type": "string" + }, + "label": { + "id": "/properties/details/properties/status/properties/label", + "type": "string" + }, + "text": { + "id": "/properties/details/properties/status/properties/text", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "flags": { + "id": "/properties/flags", + "properties": { + "cancelable": { + "id": "/properties/flags/properties/cancelable", + "type": "boolean" + }, + "latest": { + "id": "/properties/flags/properties/latest", + "type": "boolean" + }, + "retryable": { + "id": "/properties/flags/properties/retryable", + "type": "boolean" + }, + "stuck": { + "id": "/properties/flags/properties/stuck", + "type": "boolean" + }, + "triggered": { + "id": "/properties/flags/properties/triggered", + "type": "boolean" + }, + "yaml_errors": { + "id": "/properties/flags/properties/yaml_errors", + "type": "boolean" + } + }, + "type": "object" + }, + "id": { + "id": "/properties/id", + "type": "integer" + }, + "path": { + "id": "/properties/path", + "type": "string" + }, + "ref": { + "id": "/properties/ref", + "properties": { + "branch": { + "id": "/properties/ref/properties/branch", + "type": "boolean" + }, + "name": { + "id": "/properties/ref/properties/name", + "type": "string" + }, + "path": { + "id": "/properties/ref/properties/path", + "type": "string" + }, + "tag": { + "id": "/properties/ref/properties/tag", + "type": "boolean" + } + }, + "type": "object" + }, + "retry_path": { + "id": "/properties/retry_path", + "type": "string" + }, + "updated_at": { + "id": "/properties/updated_at", + "type": "string" + }, + "user": { + "id": "/properties/user", + "properties": { + "avatar_url": { + "id": "/properties/user/properties/avatar_url", + "type": "string" + }, + "id": { + "id": "/properties/user/properties/id", + "type": "integer" + }, + "name": { + "id": "/properties/user/properties/name", + "type": "string" + }, + "state": { + "id": "/properties/user/properties/state", + "type": "string" + }, + "username": { + "id": "/properties/user/properties/username", + "type": "string" + }, + "web_url": { + "id": "/properties/user/properties/web_url", + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" +} diff --git a/spec/lib/gitlab/ci/status/group/common_spec.rb b/spec/lib/gitlab/ci/status/group/common_spec.rb new file mode 100644 index 00000000000..c0ca05881f5 --- /dev/null +++ b/spec/lib/gitlab/ci/status/group/common_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Group::Common do + subject do + Gitlab::Ci::Status::Core.new(double, double) + .extend(described_class) + end + + it 'does not have action' do + expect(subject).not_to have_action + end + + it 'has details' do + expect(subject).not_to have_details + end + + it 'has no details_path' do + expect(subject.details_path).to be_falsy + end +end diff --git a/spec/lib/gitlab/ci/status/group/factory_spec.rb b/spec/lib/gitlab/ci/status/group/factory_spec.rb new file mode 100644 index 00000000000..0cd83123938 --- /dev/null +++ b/spec/lib/gitlab/ci/status/group/factory_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Group::Factory do + it 'inherits from the core factory' do + expect(described_class) + .to be < Gitlab::Ci::Status::Factory + end + + it 'exposes group helpers' do + expect(described_class.common_helpers) + .to eq Gitlab::Ci::Status::Group::Common + end +end diff --git a/spec/models/ci/group_spec.rb b/spec/models/ci/group_spec.rb new file mode 100644 index 00000000000..62e15093089 --- /dev/null +++ b/spec/models/ci/group_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Ci::Group, models: true do + subject do + described_class.new('test', name: 'rspec', jobs: jobs) + end + + let!(:jobs) { build_list(:ci_build, 1, :success) } + + it { is_expected.to include_module(StaticModel) } + + it { is_expected.to respond_to(:stage) } + it { is_expected.to respond_to(:name) } + it { is_expected.to respond_to(:jobs) } + it { is_expected.to respond_to(:status) } + + describe '#size' do + it 'returns the number of statuses in the group' do + expect(subject.size).to eq(1) + end + end + + describe '#detailed_status' do + context 'when there is only one item in the group' do + it 'calls the status from the object itself' do + expect(jobs.first).to receive(:detailed_status) + + expect(subject.detailed_status(double(:user))) + end + end + + context 'when there are more than one commit status in the group' do + let(:jobs) do + [create(:ci_build, :failed), + create(:ci_build, :success)] + end + + it 'fabricates a new detailed status object' do + expect(subject.detailed_status(double(:user))) + .to be_a(Gitlab::Ci::Status::Failed) + end + end + end +end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index c38faf32f7d..372b662fab2 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -28,6 +28,35 @@ describe Ci::Stage, models: true do end end + describe '#groups' do + before do + create_job(:ci_build, name: 'rspec 0 2') + create_job(:ci_build, name: 'rspec 0 1') + create_job(:ci_build, name: 'spinach 0 1') + create_job(:commit_status, name: 'aaaaa') + end + + it 'returns an array of three groups' do + expect(stage.groups).to be_a Array + expect(stage.groups).to all(be_a Ci::Group) + expect(stage.groups.size).to eq 3 + end + + it 'returns groups with correctly ordered statuses' do + expect(stage.groups.first.jobs.map(&:name)) + .to eq ['aaaaa'] + expect(stage.groups.second.jobs.map(&:name)) + .to eq ['rspec 0 1', 'rspec 0 2'] + expect(stage.groups.third.jobs.map(&:name)) + .to eq ['spinach 0 1'] + end + + it 'returns groups with correct names' do + expect(stage.groups.map(&:name)) + .to eq %w[aaaaa rspec spinach] + end + end + describe '#statuses_count' do before do create_job(:ci_build) @@ -223,7 +252,7 @@ describe Ci::Stage, models: true do end end - def create_job(type, status: 'success', stage: stage_name) - create(type, pipeline: pipeline, stage: stage, status: status) + def create_job(type, status: 'success', stage: stage_name, **opts) + create(type, pipeline: pipeline, stage: stage, status: status, **opts) end end diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb index 4ab40d08432..0412b2d7741 100644 --- a/spec/serializers/stage_entity_spec.rb +++ b/spec/serializers/stage_entity_spec.rb @@ -47,5 +47,13 @@ describe StageEntity do it 'contains stage title' do expect(subject[:title]).to eq 'test: passed' end + + context 'when the jobs should be grouped' do + let(:entity) { described_class.new(stage, request: request, grouped: true) } + + it 'exposes the group key' do + expect(subject).to include :groups + end + end end end -- cgit v1.2.1