diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue index 265bb253ac1be1d17a37f0df27ffb3061e429e31..35981ec0fee7c1ab752dcf8d01a055a776076e6c 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue @@ -157,6 +157,9 @@ export default { // ci_show_pipeline_name_instead_of_commit_title FF return this.glFeatures?.ciShowPipelineNameInsteadOfCommitTitle; }, + EnvList() { + return this.pipeline?.environment_id_and_name || []; + }, }, methods: { trackClick(action) { @@ -291,6 +294,14 @@ export default { :tooltip-text="commitAuthor.name" class="gl-ml-1" /> +
+ {{ env.name }}    + +
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 1fb1cf4132c30a0bb788e8701e3cad538c4c086f..837fbc4216f533ec38ba793bd36aa8f2c011bc4b 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -168,6 +168,7 @@ class Pipeline < Ci::ApplicationRecord has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :pipeline, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :job_environments, class_name: 'Environments::Job', inverse_of: :pipeline + has_many :environments, through: :job_environments accepts_nested_attributes_for :variables, reject_if: :persisted? @@ -1656,6 +1657,13 @@ def cancel_async_on_job_failure end end + def environment_id_and_name + job_environments + .joins(:environment) + .pluck('environments.id', 'environments.name') + .map { |id, name| { id: id, name: name } } + end + private def add_message(severity, content) diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index 6c20f665bfa7ecc2a14212903fe7c7b3adbf7080..6bcc8b98d372450d3f8d351b8b36f38c96e71024 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -20,4 +20,7 @@ class PipelineDetailsEntity < Ci::PipelineEntity expose :triggered_by_pipeline, as: :triggered_by, with: TriggeredPipelineEntity expose :triggered_pipelines, as: :triggered, using: TriggeredPipelineEntity + expose :environment_id_and_name, + documentation: { type: 'array', desc: 'Environments associated with the pipeline, including ID and name', + items: { type: 'object' }, example: [{ id: 7, name: 'staging' }] } end diff --git a/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js index f8cb91f3bdc54dde6025f3c19ffefaae3d88d670..5e7e3dfc3cfb945ecbbac1e3af30b734e51841e4 100644 --- a/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js +++ b/spec/frontend/ci/pipelines_page/components/pipeline_url_spec.js @@ -26,6 +26,10 @@ describe('Pipeline Url Component', () => { const findCommitIcon = () => wrapper.findByTestId('commit-icon'); const findCommitIconType = () => wrapper.findByTestId('commit-icon-type'); const findCommitRefName = () => wrapper.findByTestId('commit-ref-name'); + const findEnvironmentLinks = () => + wrapper + .findAllComponents({ name: 'GlLink' }) + .filter((link) => link.attributes('href')?.includes('/-/environments/')); const defaultProps = { ...mockPipeline(projectPath) }; @@ -259,4 +263,41 @@ describe('Pipeline Url Component', () => { }); }); }); + + describe('environment links', () => { + it('renders environment links when environment_id_and_name is present', () => { + createComponent({ + props: merge(mockPipeline(projectPath), { + pipeline: { + project: { full_path: projectPath }, + environment_id_and_name: [ + { id: 1, name: 'staging' }, + { id: 2, name: 'production' }, + ], + }, + }), + }); + + const envLinks = findEnvironmentLinks(); + + expect(envLinks).toHaveLength(2); + expect(envLinks.at(0).text()).toContain('staging'); + expect(envLinks.at(0).attributes('href')).toBe('test/test/-/environments/1'); + + expect(envLinks.at(1).text()).toContain('production'); + expect(envLinks.at(1).attributes('href')).toBe('test/test/-/environments/2'); + }); + + it('does not render environment links when environment_id_and_name is empty', () => { + createComponent({ + props: merge(mockPipeline(projectPath), { + pipeline: { + environment_id_and_name: [], + }, + }), + }); + + expect(findEnvironmentLinks()).toHaveLength(0); + }); + }); }); diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 562f984a325ac9e3d72a8fc757e233cfdbf96735..bcbb93cda7113a7a32ebcb5ffa860ed6e45fe8dd 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -7425,4 +7425,41 @@ def add_bridge_dependant_dag_job end end end + + describe '.environment_id_and_name' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository, creator: user) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project, ref: 'main') } + let_it_be(:ci_job_1) { create(:ci_build) } + let_it_be(:ci_job_2) { create(:ci_build) } + let_it_be(:environment1) { create(:environment, project: project, name: 'Production') } + let_it_be(:environment2) { create(:environment, project: project, name: 'Staging') } + + let_it_be(:job_env1) do + create(:job_environment, + project: project, + environment: environment1, + pipeline: pipeline, + job: ci_job_1) + end + + let_it_be(:job_env2) do + create(:job_environment, + project: project, + environment: environment2, + pipeline: pipeline, + job: ci_job_2) + end + + it 'returns an array of hashes with environment id and name' do + result = pipeline.environment_id_and_name + + expected = [ + { id: environment1.id, name: environment1.name }, + { id: environment2.id, name: environment2.name } + ] + + expect(result).to match_array(expected) + end + end end diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb index 71b088e4e0dd27e4739b82c02b2823877b60b23a..dc13cd960c3b71ffa7a5c0c862a7c6447000fdab 100644 --- a/spec/serializers/pipeline_details_entity_spec.rb +++ b/spec/serializers/pipeline_details_entity_spec.rb @@ -207,5 +207,24 @@ expect(source_jobs[child_pipeline.id][:retried]).to eq false end end + + context 'when pipeline has environments' do + let(:project) { create(:project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:environment) { create(:environment, project: project, name: 'staging') } + + before do + create(:job_environment, pipeline: pipeline, environment: environment, project: project) + end + + it 'contains environment id and name' do + expect(subject).to include(:environment_id_and_name) + + expect(subject[:environment_id_and_name]).to be_an(Array) + expect(subject[:environment_id_and_name]).to contain_exactly( + a_hash_including(id: environment.id, name: 'staging') + ) + end + end end end