Commit c17e6a6c authored by Zeger-Jan van de Weg's avatar Zeger-Jan van de Weg Committed by Kamil Trzciński

Real time pipeline show action

parent 1186dcab
...@@ -8,6 +8,8 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -8,6 +8,8 @@ class Projects::PipelinesController < Projects::ApplicationController
wrap_parameters Ci::Pipeline wrap_parameters Ci::Pipeline
POLLING_INTERVAL = 10_000
def index def index
@scope = params[:scope] @scope = params[:scope]
@pipelines = PipelinesFinder @pipelines = PipelinesFinder
...@@ -31,7 +33,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -31,7 +33,7 @@ class Projects::PipelinesController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
Gitlab::PollingInterval.set_header(response, interval: 10_000) Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
render json: { render json: {
pipelines: PipelineSerializer pipelines: PipelineSerializer
...@@ -57,15 +59,25 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -57,15 +59,25 @@ class Projects::PipelinesController < Projects::ApplicationController
@pipeline = Ci::CreatePipelineService @pipeline = Ci::CreatePipelineService
.new(project, current_user, create_params) .new(project, current_user, create_params)
.execute(ignore_skip_ci: true, save_on_errors: false) .execute(ignore_skip_ci: true, save_on_errors: false)
unless @pipeline.persisted?
render 'new'
return
end
if @pipeline.persisted?
redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline)
else
render 'new'
end
end end
def show 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 end
def builds def builds
......
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
...@@ -15,6 +15,14 @@ module Ci ...@@ -15,6 +15,14 @@ module Ci
@warnings = warnings @warnings = warnings
end 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 def to_param
name name
end end
......
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
...@@ -7,9 +7,11 @@ class StageEntity < Grape::Entity ...@@ -7,9 +7,11 @@ class StageEntity < Grape::Entity
"#{stage.name}: #{detailed_status.label}" "#{stage.name}: #{detailed_status.label}"
end end
expose :detailed_status, expose :groups,
as: :status, if: -> (_, opts) { opts[:grouped] },
with: StatusEntity with: JobGroupEntity
expose :detailed_status, as: :status, with: StatusEntity
expose :path do |stage| expose :path do |stage|
namespace_project_pipeline_path( namespace_project_pipeline_path(
......
...@@ -12,4 +12,11 @@ class StatusEntity < Grape::Entity ...@@ -12,4 +12,11 @@ class StatusEntity < Grape::Entity
ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico")) ActionController::Base.helpers.image_path(File.join(dir, "#{status.favicon}.ico"))
end 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 end
---
title: Pipeline view updates in near real time
merge_request: 10777
author:
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
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
...@@ -36,7 +36,11 @@ module Gitlab ...@@ -36,7 +36,11 @@ module Gitlab
Gitlab::EtagCaching::Router::Route.new( Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines\.json\z), %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines\.json\z),
'project_pipelines' 'project_pipelines'
) ),
Gitlab::EtagCaching::Router::Route.new(
%r(^(?!.*(#{RESERVED_WORDS})).*/pipelines/\d+\.json\z),
'project_pipeline'
),
].freeze ].freeze
def self.match(env) def self.match(env)
......
require 'spec_helper' require 'spec_helper'
describe Projects::PipelinesController do describe Projects::PipelinesController do
include ApiHelpers
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:empty_project, :public) } let(:project) { create(:empty_project, :public) }
...@@ -24,6 +26,7 @@ describe Projects::PipelinesController do ...@@ -24,6 +26,7 @@ describe Projects::PipelinesController do
it 'returns JSON with serialized pipelines' do it 'returns JSON with serialized pipelines' do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response).to match_response_schema('pipeline')
expect(json_response).to include('pipelines') expect(json_response).to include('pipelines')
expect(json_response['pipelines'].count).to eq 4 expect(json_response['pipelines'].count).to eq 4
...@@ -34,6 +37,34 @@ describe Projects::PipelinesController do ...@@ -34,6 +37,34 @@ describe Projects::PipelinesController do
end end
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 describe 'GET stages.json' do
let(:pipeline) { create(:ci_pipeline, project: project) } let(:pipeline) { create(:ci_pipeline, project: project) }
......
{
"$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"
}
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
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
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
...@@ -28,6 +28,35 @@ describe Ci::Stage, models: true do ...@@ -28,6 +28,35 @@ describe Ci::Stage, models: true do
end end
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 describe '#statuses_count' do
before do before do
create_job(:ci_build) create_job(:ci_build)
...@@ -223,7 +252,7 @@ describe Ci::Stage, models: true do ...@@ -223,7 +252,7 @@ describe Ci::Stage, models: true do
end end
end end
def create_job(type, status: 'success', stage: stage_name) def create_job(type, status: 'success', stage: stage_name, **opts)
create(type, pipeline: pipeline, stage: stage, status: status) create(type, pipeline: pipeline, stage: stage, status: status, **opts)
end end
end end
...@@ -47,5 +47,13 @@ describe StageEntity do ...@@ -47,5 +47,13 @@ describe StageEntity do
it 'contains stage title' do it 'contains stage title' do
expect(subject[:title]).to eq 'test: passed' expect(subject[:title]).to eq 'test: passed'
end 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
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment