summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMayra Cabrera <mcabrera@gitlab.com>2019-05-02 18:27:35 +0000
committerDouglas Barbosa Alexandre <dbalexandre@gmail.com>2019-05-02 18:27:35 +0000
commit5432f5480f334a0bd15ed06568e0c82f0dd54e45 (patch)
tree350ea2de8e0bdaf171f09c5b5886da026f3fc2e1
parent0c3d7830c5fdaef029457557f7b4ad867805b06a (diff)
downloadgitlab-ce-5432f5480f334a0bd15ed06568e0c82f0dd54e45.tar.gz
Adds a way to start multiple manual jobs in stage
- Adds an endpoint on PipelinesController - Adds a service that iterates over every build in a stage and plays it. - Includes 'play_manual' details on EntitySerializer - Builds a new Stage state: PlayManual. An stage can take this status if it has manual builds or an skipped, scheduled or manual status - Includes FE modifications and specs
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue25
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss1
-rw-r--r--app/controllers/projects/stages_controller.rb25
-rw-r--r--app/models/ci/legacy_stage.rb4
-rw-r--r--app/models/ci/pipeline.rb4
-rw-r--r--app/models/ci/stage.rb4
-rw-r--r--app/services/ci/play_manual_stage_service.rb29
-rw-r--r--changelogs/unreleased/28741-play-all-manual-jobs.yml5
-rw-r--r--config/routes/project.rb6
-rw-r--r--lib/gitlab/ci/status/stage/factory.rb3
-rw-r--r--lib/gitlab/ci/status/stage/play_manual.rb43
-rw-r--r--locale/gitlab.pot3
-rw-r--r--spec/controllers/projects/stages_controller_spec.rb72
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb14
-rw-r--r--spec/javascripts/pipelines/graph/action_component_spec.js11
-rw-r--r--spec/javascripts/pipelines/graph/stage_column_component_spec.js49
-rw-r--r--spec/lib/gitlab/ci/status/stage/factory_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/status/stage/play_manual_spec.rb74
-rw-r--r--spec/models/ci/legacy_stage_spec.rb2
-rw-r--r--spec/models/ci/pipeline_spec.rb32
-rw-r--r--spec/models/ci/stage_spec.rb2
-rw-r--r--spec/serializers/pipeline_serializer_spec.rb2
-rw-r--r--spec/serializers/stage_entity_spec.rb28
-rw-r--r--spec/serializers/stage_serializer_spec.rb31
-rw-r--r--spec/services/ci/play_manual_stage_service_spec.rb79
-rw-r--r--spec/support/shared_examples/ci/stage_shared_examples.rb27
28 files changed, 600 insertions, 8 deletions
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index 8ca539351a7..3c85bb61ce8 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { dasherize } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
@@ -20,6 +20,7 @@ export default {
components: {
Icon,
GlButton,
+ GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -41,6 +42,7 @@ export default {
data() {
return {
isDisabled: false,
+ isLoading: false,
};
},
computed: {
@@ -59,15 +61,19 @@ export default {
onClickAction() {
this.$root.$emit('bv::hide::tooltip', `js-ci-action-${this.link}`);
this.isDisabled = true;
+ this.isLoading = true;
axios
.post(`${this.link}.json`)
.then(() => {
this.isDisabled = false;
+ this.isLoading = false;
+
this.$emit('pipelineActionRequestComplete');
})
.catch(() => {
this.isDisabled = false;
+ this.isLoading = false;
createFlash(__('An error occurred while making the request.'));
});
@@ -82,10 +88,10 @@ export default {
:title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
- class="js-ci-action btn btn-blank
-btn-transparent ci-action-icon-container ci-action-icon-wrapper"
+ class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper"
@click="onClickAction"
>
- <icon :name="actionIcon" />
+ <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
+ <icon v-else :name="actionIcon" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index a49dc311bd0..ba0dea626dc 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -24,6 +24,7 @@ export default {
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
+ :action="stage.status.action"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 348c407f1b5..7e611b93087 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -3,11 +3,13 @@ import _ from 'underscore';
import stageColumnMixin from 'ee_else_ce/pipelines/mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
+import ActionComponent from './action_component.vue';
export default {
components: {
JobItem,
JobGroupDropdown,
+ ActionComponent,
},
mixins: [stageColumnMixin],
props: {
@@ -29,6 +31,16 @@ export default {
required: false,
default: '',
},
+ action: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ hasAction() {
+ return !_.isEmpty(this.action);
+ },
},
methods: {
groupId(group) {
@@ -42,7 +54,18 @@ export default {
</script>
<template>
<li :class="stageConnectorClass" class="stage-column">
- <div class="stage-name">{{ title }}</div>
+ <div class="stage-name position-relative">
+ {{ title }}
+ <action-component
+ v-if="hasAction"
+ :action-icon="action.icon"
+ :tooltip-text="action.title"
+ :link="action.path"
+ class="js-stage-action position-absolute position-top-0 rounded"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </div>
+
<div class="builds-container">
<ul>
<li
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 093fc89a56f..96fdf74267e 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -565,6 +565,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ line-height: 2.2em;
}
.build {
diff --git a/app/controllers/projects/stages_controller.rb b/app/controllers/projects/stages_controller.rb
new file mode 100644
index 00000000000..c8db5b1277f
--- /dev/null
+++ b/app/controllers/projects/stages_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Projects::StagesController < Projects::PipelinesController
+ before_action :authorize_update_pipeline!
+
+ def play_manual
+ ::Ci::PlayManualStageService
+ .new(@project, current_user, pipeline: pipeline)
+ .execute(stage)
+
+ respond_to do |format|
+ format.json do
+ render json: StageSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(stage)
+ end
+ end
+ end
+
+ private
+
+ def stage
+ @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name])
+ end
+end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
index 96dbc7b6895..930c8a71453 100644
--- a/app/models/ci/legacy_stage.rb
+++ b/app/models/ci/legacy_stage.rb
@@ -58,5 +58,9 @@ module Ci
statuses.latest.failed_but_allowed.any?
end
end
+
+ def manual_playable?
+ %[manual scheduled skipped].include?(status.to_s)
+ end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 2b7835d7fab..80401ca0a1e 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -771,6 +771,10 @@ module Ci
Gitlab::Utils.slugify(source_ref.to_s)
end
+ def find_stage_by_name!(name)
+ stages.find_by!(name: name)
+ end
+
private
def ci_yaml_from_repo
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index b25b0369666..d90339d90dc 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -120,5 +120,9 @@ module Ci
.new(self, current_user)
.fabricate!
end
+
+ def manual_playable?
+ blocked? || skipped?
+ end
end
end
diff --git a/app/services/ci/play_manual_stage_service.rb b/app/services/ci/play_manual_stage_service.rb
new file mode 100644
index 00000000000..2497fc52e6b
--- /dev/null
+++ b/app/services/ci/play_manual_stage_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Ci
+ class PlayManualStageService < BaseService
+ def initialize(project, current_user, params)
+ super
+
+ @pipeline = params[:pipeline]
+ end
+
+ def execute(stage)
+ stage.builds.manual.each do |build|
+ next unless build.playable?
+
+ build.play(current_user)
+ rescue Gitlab::Access::AccessDeniedError
+ logger.error(message: 'Unable to play manual action', build_id: build.id)
+ end
+ end
+
+ private
+
+ attr_reader :pipeline, :current_user
+
+ def logger
+ Gitlab::AppLogger
+ end
+ end
+end
diff --git a/changelogs/unreleased/28741-play-all-manual-jobs.yml b/changelogs/unreleased/28741-play-all-manual-jobs.yml
new file mode 100644
index 00000000000..30b26e3c0ed
--- /dev/null
+++ b/changelogs/unreleased/28741-play-all-manual-jobs.yml
@@ -0,0 +1,5 @@
+---
+title: Play all manual jobs in a stage
+merge_request: 27188
+author:
+type: added
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 61eb136f65b..93d746f3282 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -201,6 +201,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get :failures
get :status
end
+
+ member do
+ resources :stages, only: [], param: :name do
+ post :play_manual
+ end
+ end
end
resources :pipeline_schedules, except: [:show] do
diff --git a/lib/gitlab/ci/status/stage/factory.rb b/lib/gitlab/ci/status/stage/factory.rb
index 58f4642510b..e50b0853725 100644
--- a/lib/gitlab/ci/status/stage/factory.rb
+++ b/lib/gitlab/ci/status/stage/factory.rb
@@ -6,7 +6,8 @@ module Gitlab
module Stage
class Factory < Status::Factory
def self.extended_statuses
- [Status::SuccessWarning]
+ [[Status::SuccessWarning],
+ [Status::Stage::PlayManual]]
end
def self.common_helpers
diff --git a/lib/gitlab/ci/status/stage/play_manual.rb b/lib/gitlab/ci/status/stage/play_manual.rb
new file mode 100644
index 00000000000..ac3fc0912fa
--- /dev/null
+++ b/lib/gitlab/ci/status/stage/play_manual.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Ci
+ module Status
+ module Stage
+ class PlayManual < Status::Extended
+ include Gitlab::Routing
+
+ def action_icon
+ 'play'
+ end
+
+ def action_title
+ 'Play all manual'
+ end
+
+ def action_path
+ pipeline = subject.pipeline
+
+ project_stage_play_manual_path(pipeline.project, pipeline, subject.name)
+ end
+
+ def action_method
+ :post
+ end
+
+ def action_button_title
+ _('Play all manual')
+ end
+
+ def self.matches?(stage, user)
+ stage.manual_playable?
+ end
+
+ def has_action?
+ true
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d1b960a4b26..8a51ab80d6f 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -6821,6 +6821,9 @@ msgstr ""
msgid "Play"
msgstr ""
+msgid "Play all manual"
+msgstr ""
+
msgid "Please %{link_to_register} or %{link_to_sign_in} to comment"
msgstr ""
diff --git a/spec/controllers/projects/stages_controller_spec.rb b/spec/controllers/projects/stages_controller_spec.rb
new file mode 100644
index 00000000000..a91e3523fd7
--- /dev/null
+++ b/spec/controllers/projects/stages_controller_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Projects::StagesController do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'POST #play_manual.json' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:stage_name) { 'test' }
+
+ before do
+ create_manual_build(pipeline, 'test', 'rspec 1/2')
+ create_manual_build(pipeline, 'test', 'rspec 2/2')
+
+ pipeline.reload
+ end
+
+ context 'when user does not have access' do
+ it 'returns not authorized' do
+ play_manual_stage!
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ context 'when user has access' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'when the stage does not exists' do
+ let(:stage_name) { 'deploy' }
+
+ it 'fails to play all manual' do
+ play_manual_stage!
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when the stage exists' do
+ it 'starts all manual jobs' do
+ expect(pipeline.builds.manual.count).to eq(2)
+
+ play_manual_stage!
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(pipeline.builds.manual.count).to eq(0)
+ end
+ end
+ end
+
+ def play_manual_stage!
+ post :play_manual, params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ id: pipeline.id,
+ stage_name: stage_name
+ }, format: :json
+ end
+
+ def create_manual_build(pipeline, stage, name)
+ create(:ci_build, :manual, pipeline: pipeline, stage: stage, name: name)
+ end
+ end
+end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 3825468cf71..a1115b514d3 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -236,6 +236,20 @@ describe 'Pipeline', :js do
end
end
+ context 'when the pipeline has manual stage' do
+ before do
+ create(:ci_build, :manual, pipeline: pipeline, stage: 'publish', name: 'CentOS')
+ create(:ci_build, :manual, pipeline: pipeline, stage: 'publish', name: 'Debian')
+ create(:ci_build, :manual, pipeline: pipeline, stage: 'publish', name: 'OpenSUDE')
+
+ visit_pipeline
+ end
+
+ it 'displays play all button' do
+ expect(page).to have_selector('.js-stage-action')
+ end
+ end
+
context 'page tabs' do
before do
visit_pipeline
diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js
index 95717d659b8..321497b35b5 100644
--- a/spec/javascripts/pipelines/graph/action_component_spec.js
+++ b/spec/javascripts/pipelines/graph/action_component_spec.js
@@ -66,5 +66,16 @@ describe('pipeline graph action component', () => {
done();
}, 0);
});
+
+ it('renders a loading icon while waiting for request', done => {
+ component.$el.click();
+
+ component.$nextTick(() => {
+ expect(component.$el.querySelector('.js-action-icon-loading')).not.toBeNull();
+ setTimeout(() => {
+ done();
+ });
+ });
+ });
});
});
diff --git a/spec/javascripts/pipelines/graph/stage_column_component_spec.js b/spec/javascripts/pipelines/graph/stage_column_component_spec.js
index 3240e8e4c1b..5183f8dd2d6 100644
--- a/spec/javascripts/pipelines/graph/stage_column_component_spec.js
+++ b/spec/javascripts/pipelines/graph/stage_column_component_spec.js
@@ -70,4 +70,53 @@ describe('stage column component', () => {
);
});
});
+
+ describe('with action', () => {
+ it('renders action button', () => {
+ component = mountComponent(StageColumnComponent, {
+ groups: [
+ {
+ id: 4259,
+ name: '<img src=x onerror=alert(document.domain)>',
+ status: {
+ icon: 'status_success',
+ label: 'success',
+ tooltip: '<img src=x onerror=alert(document.domain)>',
+ },
+ },
+ ],
+ title: 'test',
+ hasTriggeredBy: false,
+ action: {
+ icon: 'play',
+ title: 'Play all',
+ path: 'action',
+ },
+ });
+
+ expect(component.$el.querySelector('.js-stage-action')).not.toBeNull();
+ });
+ });
+
+ describe('without action', () => {
+ it('does not render action button', () => {
+ component = mountComponent(StageColumnComponent, {
+ groups: [
+ {
+ id: 4259,
+ name: '<img src=x onerror=alert(document.domain)>',
+ status: {
+ icon: 'status_success',
+ label: 'success',
+ tooltip: '<img src=x onerror=alert(document.domain)>',
+ },
+ },
+ ],
+ title: 'test',
+ hasTriggeredBy: false,
+ });
+
+ expect(component.$el.querySelector('.js-stage-action')).toBeNull();
+ });
+ });
});
diff --git a/spec/lib/gitlab/ci/status/stage/factory_spec.rb b/spec/lib/gitlab/ci/status/stage/factory_spec.rb
index dee4f4efd1b..4b299170c87 100644
--- a/spec/lib/gitlab/ci/status/stage/factory_spec.rb
+++ b/spec/lib/gitlab/ci/status/stage/factory_spec.rb
@@ -22,7 +22,7 @@ describe Gitlab::Ci::Status::Stage::Factory do
end
context 'when stage has a core status' do
- HasStatus::AVAILABLE_STATUSES.each do |core_status|
+ (HasStatus::AVAILABLE_STATUSES - %w(manual skipped scheduled)).each do |core_status|
context "when core status is #{core_status}" do
before do
create(:ci_build, pipeline: pipeline, stage: 'test', status: core_status)
@@ -64,4 +64,20 @@ describe Gitlab::Ci::Status::Stage::Factory do
expect(status.details_path).to include "pipelines/#{pipeline.id}##{stage.name}"
end
end
+
+ context 'when stage has manual builds' do
+ (HasStatus::BLOCKED_STATUS + ['skipped']).each do |core_status|
+ context "when status is #{core_status}" do
+ before do
+ create(:ci_build, pipeline: pipeline, stage: 'test', status: core_status)
+ create(:commit_status, pipeline: pipeline, stage: 'test', status: core_status)
+ create(:ci_build, pipeline: pipeline, stage: 'build', status: :manual)
+ end
+
+ it 'fabricates a play manual status' do
+ expect(status).to be_a(Gitlab::Ci::Status::Stage::PlayManual)
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/status/stage/play_manual_spec.rb b/spec/lib/gitlab/ci/status/stage/play_manual_spec.rb
new file mode 100644
index 00000000000..b0113b00ef0
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/stage/play_manual_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::Stage::PlayManual do
+ let(:stage) { double('stage') }
+ let(:play_manual) { described_class.new(stage) }
+
+ describe '#action_icon' do
+ subject { play_manual.action_icon }
+
+ it { is_expected.to eq('play') }
+ end
+
+ describe '#action_button_title' do
+ subject { play_manual.action_button_title }
+
+ it { is_expected.to eq('Play all manual') }
+ end
+
+ describe '#action_title' do
+ subject { play_manual.action_title }
+
+ it { is_expected.to eq('Play all manual') }
+ end
+
+ describe '#action_path' do
+ let(:stage) { create(:ci_stage_entity, status: 'manual') }
+ let(:pipeline) { stage.pipeline }
+ let(:play_manual) { stage.detailed_status(create(:user)) }
+
+ subject { play_manual.action_path }
+
+ it { is_expected.to eq("/#{pipeline.project.full_path}/pipelines/#{pipeline.id}/stages/#{stage.name}/play_manual") }
+ end
+
+ describe '#action_method' do
+ subject { play_manual.action_method }
+
+ it { is_expected.to eq(:post) }
+ end
+
+ describe '.matches?' do
+ let(:user) { double('user') }
+
+ subject { described_class.matches?(stage, user) }
+
+ context 'when stage is skipped' do
+ let(:stage) { create(:ci_stage_entity, status: :skipped) }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when stage is manual' do
+ let(:stage) { create(:ci_stage_entity, status: :manual) }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when stage is scheduled' do
+ let(:stage) { create(:ci_stage_entity, status: :scheduled) }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when stage is success' do
+ let(:stage) { create(:ci_stage_entity, status: :success) }
+
+ context 'and does not have manual builds' do
+ it { is_expected.to be_falsy }
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/legacy_stage_spec.rb b/spec/models/ci/legacy_stage_spec.rb
index be0307518eb..bb812cc0533 100644
--- a/spec/models/ci/legacy_stage_spec.rb
+++ b/spec/models/ci/legacy_stage_spec.rb
@@ -272,4 +272,6 @@ describe Ci::LegacyStage do
def create_job(type, status: 'success', stage: stage_name, **opts)
create(type, pipeline: pipeline, stage: stage, status: status, **opts)
end
+
+ it_behaves_like 'manual playable stage', :ci_stage
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 9d0cd654f13..af455a72f50 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -2942,4 +2942,36 @@ describe Ci::Pipeline, :mailer do
end
end
end
+
+ describe '#find_stage_by_name' do
+ let(:pipeline) { create(:ci_pipeline) }
+ let(:stage_name) { 'test' }
+
+ let(:stage) do
+ create(:ci_stage_entity,
+ pipeline: pipeline,
+ project: pipeline.project,
+ name: 'test')
+ end
+
+ before do
+ create_list(:ci_build, 2, pipeline: pipeline, stage: stage.name)
+ end
+
+ subject { pipeline.find_stage_by_name!(stage_name) }
+
+ context 'when stage exists' do
+ it { is_expected.to eq(stage) }
+ end
+
+ context 'when stage does not exist' do
+ let(:stage_name) { 'build' }
+
+ it 'raises an ActiveRecord exception' do
+ expect do
+ subject
+ end.to raise_exception(ActiveRecord::RecordNotFound)
+ end
+ end
+ end
end
diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb
index 661958390e2..85cd32fb03a 100644
--- a/spec/models/ci/stage_spec.rb
+++ b/spec/models/ci/stage_spec.rb
@@ -285,4 +285,6 @@ describe Ci::Stage, :models do
end
end
end
+
+ it_behaves_like 'manual playable stage', :ci_stage_entity
end
diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb
index d9023036534..54e6abc2d3a 100644
--- a/spec/serializers/pipeline_serializer_spec.rb
+++ b/spec/serializers/pipeline_serializer_spec.rb
@@ -156,8 +156,8 @@ describe PipelineSerializer do
it 'verifies number of queries', :request_store do
recorded = ActiveRecord::QueryRecorder.new { subject }
-
expected_queries = Gitlab.ee? ? 38 : 31
+
expect(recorded.count).to be_within(2).of(expected_queries)
expect(recorded.cached_count).to eq(0)
end
diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb
index 2034c7891ef..6b1185d1283 100644
--- a/spec/serializers/stage_entity_spec.rb
+++ b/spec/serializers/stage_entity_spec.rb
@@ -48,6 +48,10 @@ describe StageEntity do
expect(subject[:title]).to eq 'test: passed'
end
+ it 'does not contain play_details info' do
+ expect(subject[:status][:action]).not_to be_present
+ end
+
context 'when the jobs should be grouped' do
let(:entity) { described_class.new(stage, request: request, grouped: true) }
@@ -66,5 +70,29 @@ describe StageEntity do
end
end
end
+
+ context 'with a skipped stage ' do
+ let(:stage) { create(:ci_stage_entity, status: 'skipped') }
+
+ it 'contains play_all_manual' do
+ expect(subject[:status][:action]).to be_present
+ end
+ end
+
+ context 'with a scheduled stage ' do
+ let(:stage) { create(:ci_stage_entity, status: 'scheduled') }
+
+ it 'contains play_all_manual' do
+ expect(subject[:status][:action]).to be_present
+ end
+ end
+
+ context 'with a manual stage ' do
+ let(:stage) { create(:ci_stage_entity, status: 'manual') }
+
+ it 'contains play_all_manual' do
+ expect(subject[:status][:action]).to be_present
+ end
+ end
end
end
diff --git a/spec/serializers/stage_serializer_spec.rb b/spec/serializers/stage_serializer_spec.rb
new file mode 100644
index 00000000000..aae17cfbcb9
--- /dev/null
+++ b/spec/serializers/stage_serializer_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe StageSerializer do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+ let(:resource) { create(:ci_stage_entity) }
+
+ let(:serializer) do
+ described_class.new(current_user: user, project: project)
+ end
+
+ subject { serializer.represent(resource) }
+
+ describe '#represent' do
+ context 'with a single entity' do
+ it 'serializes the stage object' do
+ expect(subject[:name]).to eq(resource.name)
+ end
+ end
+
+ context 'with an array of entities' do
+ let(:resource) { create_list(:ci_stage_entity, 2) }
+
+ it 'serializes the array of pipelines' do
+ expect(subject).not_to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/play_manual_stage_service_spec.rb b/spec/services/ci/play_manual_stage_service_spec.rb
new file mode 100644
index 00000000000..5d812745c7f
--- /dev/null
+++ b/spec/services/ci/play_manual_stage_service_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Ci::PlayManualStageService, '#execute' do
+ let(:current_user) { create(:user) }
+ let(:pipeline) { create(:ci_pipeline, user: current_user) }
+ let(:project) { pipeline.project }
+ let(:service) { described_class.new(project, current_user, pipeline: pipeline) }
+ let(:stage_status) { 'manual' }
+
+ let(:stage) do
+ create(:ci_stage_entity,
+ pipeline: pipeline,
+ project: project,
+ name: 'test')
+ end
+
+ before do
+ project.add_maintainer(current_user)
+ create_builds_for_stage(status: stage_status)
+ end
+
+ context 'when pipeline has manual builds' do
+ before do
+ service.execute(stage)
+ end
+
+ it 'starts manual builds from pipeline' do
+ expect(pipeline.builds.manual.count).to eq(0)
+ end
+
+ it 'updates manual builds' do
+ pipeline.builds.each do |build|
+ expect(build.user).to eq(current_user)
+ end
+ end
+ end
+
+ context 'when pipeline has no manual builds' do
+ let(:stage_status) { 'failed' }
+
+ before do
+ service.execute(stage)
+ end
+
+ it 'does not update the builds' do
+ expect(pipeline.builds.failed.count).to eq(3)
+ end
+ end
+
+ context 'when user does not have permission on a specific build' do
+ before do
+ allow_any_instance_of(Ci::Build).to receive(:play)
+ .and_raise(Gitlab::Access::AccessDeniedError)
+
+ service.execute(stage)
+ end
+
+ it 'logs the error' do
+ expect(Gitlab::AppLogger).to receive(:error)
+ .exactly(stage.builds.manual.count)
+
+ service.execute(stage)
+ end
+ end
+
+ def create_builds_for_stage(options)
+ options.merge!({
+ when: 'manual',
+ pipeline: pipeline,
+ stage: stage.name,
+ stage_id: stage.id,
+ user: pipeline.user
+ })
+
+ create_list(:ci_build, 3, options)
+ end
+end
diff --git a/spec/support/shared_examples/ci/stage_shared_examples.rb b/spec/support/shared_examples/ci/stage_shared_examples.rb
new file mode 100644
index 00000000000..925974ed11e
--- /dev/null
+++ b/spec/support/shared_examples/ci/stage_shared_examples.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+shared_examples 'manual playable stage' do |stage_type|
+ let(:stage) { build(stage_type, status: status) }
+
+ describe '#manual_playable?' do
+ subject { stage.manual_playable? }
+
+ context 'when is manual' do
+ let(:status) { 'manual' }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when is scheduled' do
+ let(:status) { 'scheduled' }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when is skipped' do
+ let(:status) { 'skipped' }
+
+ it { is_expected.to be_truthy }
+ end
+ end
+end