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