diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index ad0b63551727ddef9c71fda126e7ef4f4a0f4957..341e78d10a3019b1a0218f84292902c7c84ff967 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -7,6 +7,7 @@ class Projects::BadgesController < Projects::ApplicationController before_action :authorize_read_build!, only: [:pipeline, :coverage] feature_category :continuous_integration, [:index, :pipeline] + feature_category :groups_and_projects, [:custom] feature_category :code_testing, [:coverage] feature_category :release_orchestration, [:release] @@ -47,6 +48,22 @@ def release render_badge latest_release end + def custom + return render_404 unless Feature.enabled?(:custom_project_badges, Feature.current_request) + + custom_badge = Gitlab::Ci::Badge::Custom::CustomBadge + .new(project, opts: { + key_text: params[:key_text], + key_width: params[:key_width], + key_color: params[:key_color], + value_text: params[:value_text], + value_width: params[:value_width], + value_color: params[:value_color] + }) + + render_badge custom_badge + end + private def badge_layout diff --git a/config/feature_flags/gitlab_com_derisk/custom_project_badges.yml b/config/feature_flags/gitlab_com_derisk/custom_project_badges.yml new file mode 100644 index 0000000000000000000000000000000000000000..e4ec7b7bc88f1ca19d91b5d14b140d29c2176d04 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/custom_project_badges.yml @@ -0,0 +1,10 @@ +--- +name: custom_project_badges +description: Precautionary approach for new publicly accessible endpoint that does not require log-in, in order to mitigate unforseen issues. +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/519816 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180832 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/574344 +milestone: '18.5' +group: group::organizations +type: gitlab_com_derisk +default_enabled: false diff --git a/config/routes/project.rb b/config/routes/project.rb index fe25cf67ec850ad0740f41402a3037d13b3ee198..cb81d26c6cf546d9dcc133515fb99cd8386aaa97 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -466,6 +466,7 @@ resources :badges, only: [] do collection do constraints format: /svg/ do + get :custom get :release end end diff --git a/doc/user/project/badges.md b/doc/user/project/badges.md index 8c57a960e1ddb381286773337fc9f39a23b6fa14..435c9339d31525e4759ee5fd1330f7bc8ec5a366 100644 --- a/doc/user/project/badges.md +++ b/doc/user/project/badges.md @@ -25,8 +25,9 @@ GitLab provides the following pipeline badges: - [Pipeline status badge](#pipeline-status-badges) - [Test coverage report badge](#test-coverage-report-badges) - [Latest release badge](#latest-release-badges) +- [Custom badge](#custom-badges) -GitLab also supports [custom badges](#customize-badges). +GitLab also supports [adjusting badge style](#customize-badges). ## Pipeline status badges @@ -217,18 +218,50 @@ The pipeline status badge is based on specific Git revisions (branches). Ensure {{< /alert >}} +## Custom badges + +Custom badges allows changing the following attributes: + +- `key_text` +- `key_color` +- `key_width` +- `value_text` +- `value_color` +- `value_width` + +Colors can be passed as [a named color](https://developer.mozilla.org/en-US/docs/Web/CSS/named-color), for example `blue` or hexadecimal representation like `fff` or `7bc043` (without leading `#`). + +You can access a latest release badge image by using the following link: + +```plaintext +https://gitlab.example.com///-/badges/custom.svg +``` + +For example, you can use [placeholders](#placeholders) to create a badge for the latest tag: + +```plaintext +https://%{gitlab_server}/%{project_path}/badges/custom.svg?key_text=Latest_tag&key_value=%{latest_tag}&key_color=white&value_color=7bc043 +``` + ## Customize badges -You can customize the following aspects of a badge: +You can customize how badges appear in your project: -- Style -- Text -- Width -- Image +- [Basic customization](#basic-customization) works for all badge types +- [Advanced customization](#custom-badges) is available only for custom badges -### Customize badge style +### Basic customization -Pipeline badges can be rendered in different styles by adding the `style=style_name` parameter to the URL. Two styles are available: +You can customize the following aspects of all badge types: + +- [Style](#style) +- [Key text](#key-text) +- [Key width](#key-width) +- [Value width](#value-width) + +#### Style + +Pipeline, coverage, release, and custom badges can be rendered in different styles by adding the `style=style_name` parameter to the URL. Two styles are available: - Flat (default): @@ -246,10 +279,11 @@ Pipeline badges can be rendered in different styles by adding the `style=style_n ![Badge flat square style](img/badge_flat_square.svg) -### Customize badge text +#### Key text + +The text for the left side on the badge can be customize. For example, to differentiate between multiple coverage jobs that run in the same pipeline. -The text for a badge can be customized to differentiate between multiple coverage jobs that run in the same pipeline. -Customize the badge text and width by adding the `key_text=custom_text` and `key_width=custom_key_width` parameters to the URL: +Customize the badge key text by adding the `key_text=custom_text` parameter to the URL: ```plaintext https://gitlab.com/gitlab-org/gitlab/badges/main/coverage.svg?job=karma&key_text=Frontend+Coverage&key_width=130 @@ -257,36 +291,112 @@ https://gitlab.com/gitlab-org/gitlab/badges/main/coverage.svg?job=karma&key_text ![Badge with custom text and width](img/badge_custom_text.svg) -### Customize badge image +#### Key width -Use custom badge images in a project or a group if you want to use badges other than the default -ones. +Customize the badge key width by adding the `key_width=width` parameter to the URL: -Prerequisites: +```plaintext +https://gitlab.com/%{project_path}/-/badges/coverage.svg?key_width=130 +``` -- A valid URL that points directly to the desired image for the badge. - If the image is located in a GitLab repository, use the raw link to the image. +#### Value width -Using placeholders, here is an example badge image URL referring to a raw image at the root of a repository: +Customize the badge value width by adding the `value_width=width` parameter to the URL: ```plaintext -https://gitlab.example.com//-/raw//my-image.svg +https://gitlab.com/%{project_path}/-/badges/coverage.svg?value_width=130 ``` -To add a new badge with a custom image to a group or project: +### Custom badges -1. On the left sidebar, select **Search or go to** and find your project or - group. -1. Select **Settings** > **General**. +Custom badges give you complete control over both sides of the badge. Unlike standard badges that show predefined information (like pipeline status), custom badges let you: + +- Display any text on either side of the badge +- Use custom colors +- Show project-specific information +- Create dynamic badges using [placeholders](#placeholders) + +In addition to the [basic customization options](#basic-customization), custom badges support these additional customization options: + +- [Key color](#key-color) +- [Value color](#value-color) +- [Value text](#value-text) + +You can add a custom badge by using the following link: + +```plaintext +https://gitlab.com/%{project_path}/badges/%{default_branch}/custom.svg +``` + +For example, you can use [placeholders](#placeholders) to create a badge for the latest tag: + +```plaintext +https://%{gitlab_server}/%{project_path}/badges/custom.svg?key_text=Latest_tag&key_value=%{latest_tag}&key_color=white&value_color=7bc043 +``` + +{{< alert type="warning" >}} + +Placeholders allow badges to expose otherwise-private information, such as the default branch or commit SHA when the project is configured to have a private repository. This behavior is intentional, as badges are intended to be used publicly. Avoid using these placeholders if the information is sensitive. + +{{< /alert >}} + +#### Value text + +Customize the text displayed on the right side by adding the `value_text=text` parameter to the URL: + +```plaintext +https://gitlab.com/%{project_path}/-/badges/custom.svg?value_text=badge +``` + +#### Value color + +Customize the background color on the right side by adding the `value_color=color` parameter to the URL: + +Colors can be passed as: + +- [A named color](https://developer.mozilla.org/en-US/docs/Web/CSS/named-color), for example `blue` +- Hexadecimal representation like `fff` or `7bc043` (without leading `#`) + +```plaintext +https://gitlab.com/%{project_path}/-/badges/custom.svg?value_color=red +``` + +#### Key color + +Customize the background color on the left side by adding the `value_color=color` parameter to the URL: + +Colors can be passed as: + +- [A named color](https://developer.mozilla.org/en-US/docs/Web/CSS/named-color), for example `blue` +- Hexadecimal representation like `fff` or `7bc043` (without leading `#`) + +```plaintext +https://gitlab.com/%{project_path}/-/badges/custom.svg?key_color=green +``` + +### Add a custom badge image + +Prerequisites: + +- You must have at least the Developer role for the project or group. +- You must have a valid URL that points directly to the desired image for the badge. If the image is in a GitLab repository, use the raw link to the image. + +To add a custom badge with an image: + +1. On the left sidebar, select **Search or go to** and find your project or group. +1. Select **Settings > General**. 1. Expand **Badges**. 1. Under **Name**, enter the name for the badge. 1. Under **Link**, enter the URL that the badge should point to. -1. Under **Badge image URL**, enter the URL that points directly to the custom image that should be - displayed. +1. Under **Badge image URL**, enter the URL for your custom image. For example, to use an image from your repository: + + ```plaintext + https://gitlab.example.com//-/raw//custom-image.svg + ``` + 1. Select **Add badge**. -To learn how to use custom images generated through a pipeline, see the documentation on -[accessing the latest job artifacts by URL](../../ci/jobs/job_artifacts.md#from-a-url). +To use custom images generated through a pipeline, see [accessing the latest job artifacts by URL](../../ci/jobs/job_artifacts.md#from-a-url). ## Edit a badge @@ -334,7 +444,7 @@ The following placeholders are available: project's repository - `%{latest_tag}`: Latest tag added to the project's repository -{{< alert type="note" >}} +{{< alert type="warning" >}} Placeholders allow badges to expose otherwise-private information, such as the default branch or commit SHA when the project is configured to have a private diff --git a/lib/gitlab/ci/badge/custom/custom_badge.rb b/lib/gitlab/ci/badge/custom/custom_badge.rb new file mode 100644 index 0000000000000000000000000000000000000000..b7bc553731f7eb046fd3a3d3910b983523eeb577 --- /dev/null +++ b/lib/gitlab/ci/badge/custom/custom_badge.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Badge + module Custom + class CustomBadge < Badge::Base + attr_reader :project, :customization + + def initialize(project, opts: {}) + @project = project + @customization = { + key_width: opts[:key_width] ? opts[:key_width].to_i : nil, + key_text: opts[:key_text], + key_color: opts[:key_color], + value_color: opts[:value_color], + value_text: opts[:value_text], + value_width: opts[:value_width] ? opts[:value_width].to_i : nil + } + end + + def entity + 'custom' + end + + def metadata + @metadata ||= Custom::Metadata.new(self) + end + + def template + @template ||= Custom::Template.new(self) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/badge/custom/metadata.rb b/lib/gitlab/ci/badge/custom/metadata.rb new file mode 100644 index 0000000000000000000000000000000000000000..4935d669b13366d425b08b566bfcfd5333056932 --- /dev/null +++ b/lib/gitlab/ci/badge/custom/metadata.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Badge + module Custom + ## + # Class that describes pipeline badge metadata + # + class Metadata < Badge::Metadata + def initialize(badge) + @project = badge.project + end + + def title + 'custom' + end + + def image_url + custom_project_badges_url(@project, format: :svg) + end + + def link_url + project_url(@project) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/badge/custom/template.rb b/lib/gitlab/ci/badge/custom/template.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed91b8f5c3c175953dd5fecdb4aa74d001d22dc8 --- /dev/null +++ b/lib/gitlab/ci/badge/custom/template.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Badge + module Custom + ## + # Class that represents a custom badge template. + # + # Template object will be passed to badge.svg.erb template. + # + class Template < Badge::Template + VALUE_WIDTH_DEFAULT = 54 + VALUE_WIDTH_MAXIMUM = 200 + KEY_COLOR_DEFAULT = '#9f9f9f' + VALUE_COLOR_DEFAULT = '#e05d44' + VALUE_TEXT_DEFAULT = 'none' + MAX_VALUE_TEXT_SIZE = 128 + + HEX_COLOR_3_REGEX = /\A([a-f0-9])\1{2}\z/ # 3-char hex color must be repeated letters, e.g. "fff" + HEX_COLOR_6_REGEX = /\A[a-f0-9]{6}\z/ + NAMED_COLOR_REGEX = /\A[a-z]{3,}\z/ # 3 chars minimum, e.g. "red" + MAX_COLOR_LENGTH = 22 # e.g. "lightgoldenrodyellow" + + def initialize(badge) + @badge = badge + @key_color = badge.customization[:key_color] + @value_width = badge.customization[:value_width] + @value_color = badge.customization[:value_color] + @value_text = badge.customization[:value_text] + + super + end + + def value_width + return @value_width if @value_width && @value_width.between?(1, VALUE_WIDTH_MAXIMUM) + + VALUE_WIDTH_DEFAULT + end + + def key_color + parse_color(@key_color, KEY_COLOR_DEFAULT) + end + + def value_text + return @value_text if @value_text && @value_text.size <= MAX_VALUE_TEXT_SIZE + + VALUE_TEXT_DEFAULT + end + + def value_color + parse_color(@value_color, VALUE_COLOR_DEFAULT) + end + + private + + def parse_color(input_color, default_color) + color = input_color.to_s.downcase.strip.delete_prefix('#') + + if color.present? && color.size <= MAX_COLOR_LENGTH + return "##{color}" if Regexp.union(HEX_COLOR_3_REGEX, HEX_COLOR_6_REGEX).match?(color) + return color if NAMED_COLOR_REGEX.match?(color) + end + + default_color + end + end + end + end + end +end diff --git a/spec/controllers/projects/badges_controller_spec.rb b/spec/controllers/projects/badges_controller_spec.rb index ef2afd7ca3887ba39d22bad28e70beca83983678..40f1ed26af44994be3f29eafba4596234c128eb1 100644 --- a/spec/controllers/projects/badges_controller_spec.rb +++ b/spec/controllers/projects/badges_controller_spec.rb @@ -180,6 +180,13 @@ end end + describe '#custom' do + action = :custom + + it_behaves_like 'a badge resource', action + it_behaves_like 'renders badge irrespective of project access levels', action + end + describe '#coverage' do it_behaves_like 'a badge resource', :coverage end diff --git a/spec/lib/gitlab/ci/badge/custom/custom_badge_spec.rb b/spec/lib/gitlab/ci/badge/custom/custom_badge_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e77c2ee251521fa1580395896c9e02190e7435ad --- /dev/null +++ b/spec/lib/gitlab/ci/badge/custom/custom_badge_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Badge::Custom::CustomBadge, feature_category: :groups_and_projects do + let_it_be(:project) { create(:project, :repository) } + + let(:badge) { described_class.new(project) } + + describe '#entity' do + it 'always says custom' do + expect(badge.entity).to eq 'custom' + end + end + + describe '#template' do + it 'returns badge key_text' do + expect(badge.template.key_text).to eq 'custom' + end + + it 'returns badge value_text' do + expect(badge.template.value_text).to eq 'none' + end + end + + describe '#metadata' do + it 'returns badge metadata' do + expect(badge.metadata.image_url).to include 'badges/custom.svg' + end + end +end diff --git a/spec/lib/gitlab/ci/badge/custom/metadata_spec.rb b/spec/lib/gitlab/ci/badge/custom/metadata_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ae413a51bb5d9f7db1789fd761aae25499e0c460 --- /dev/null +++ b/spec/lib/gitlab/ci/badge/custom/metadata_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'lib/gitlab/ci/badge/shared/metadata' + +RSpec.describe Gitlab::Ci::Badge::Custom::Metadata, feature_category: :groups_and_projects do + let_it_be(:project) { create(:project) } + let(:badge) { Gitlab::Ci::Badge::Custom::CustomBadge.new(project, opts: {}) } + let(:metadata) { described_class.new(badge) } + + it_behaves_like 'badge metadata' + + describe '#title' do + it 'returns badge title' do + expect(metadata.title).to eq 'custom' + end + end + + describe '#image_url' do + it 'returns valid url' do + expect(metadata.image_url).to include '/-/badges/custom.svg' + end + end + + describe '#link_url' do + it 'returns valid link' do + expect(metadata.link_url).to include project.full_path + end + end +end diff --git a/spec/lib/gitlab/ci/badge/custom/template_spec.rb b/spec/lib/gitlab/ci/badge/custom/template_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..18c885b99f9583095f007f3b2d7a5c2cb53dea9f --- /dev/null +++ b/spec/lib/gitlab/ci/badge/custom/template_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Badge::Custom::Template, feature_category: :groups_and_projects do + using RSpec::Parameterized::TableSyntax + + let_it_be(:project) { create(:project) } + let(:options) { {} } + let(:badge) { Gitlab::Ci::Badge::Custom::CustomBadge.new(project, opts: options) } + let(:template) { described_class.new(badge) } + + it_behaves_like 'a badge template', 'custom' + + where(:input_color, :expected_color) do + nil | ref(:default_color) + '' | ref(:default_color) + 'ee4035' | '#ee4035' + ' 4B0EC3 ' | '#4b0ec3' + '#4167baa' | ref(:default_color) + '#c0c0c0' | '#c0c0c0' + '%23fff0' | ref(:default_color) + 'EEE ' | '#eee' + 'fff' | '#fff' + 'ffff' | 'ffff' + '#000' | '#000' + 'f03' | ref(:default_color) + 'blue2' | ref(:default_color) + 'blanchedAlmond ' | 'blanchedalmond' + 'lightgoldenrodyellow' | 'lightgoldenrodyellow' + end + + with_them do + let(:options) { { key_color: input_color, value_color: input_color } } + + describe '#key_color' do + let(:default_color) { described_class::KEY_COLOR_DEFAULT } + + it 'returns expected color' do + expect(template.key_color).to eq(expected_color) + end + end + + describe '#value_color' do + let(:default_color) { described_class::VALUE_COLOR_DEFAULT } + + it 'returns expected color' do + expect(template.value_color).to eq(expected_color) + end + end + end + + describe '#key_text' do + it 'defaults to custom' do + expect(template.key_text).to eq('custom') + end + + context 'with custom key text' do + let(:options) { { key_text: 'Hello' } } + + it 'returns custom key' do + expect(template.key_text).to eq('Hello') + end + end + end + + describe '#value_text' do + it 'defaults to none' do + expect(template.value_text).to eq('none') + end + + context 'with custom value text' do + let(:options) { { value_text: 'world' } } + + it 'returns custom value' do + expect(template.value_text).to eq('world') + end + end + end + + describe '#value_width' do + let(:default_value_width) { described_class::VALUE_WIDTH_DEFAULT } + + where(:input_value_width, :expected_value_width) do + nil | ref(:default_value_width) + 1 | 1 + 100 | 100 + 200 | 200 + 300 | ref(:default_value_width) + 0 | ref(:default_value_width) + -1 | ref(:default_value_width) + '' | ref(:default_value_width) + 'string' | ref(:default_value_width) + end + + with_them do + let(:options) { { value_width: input_value_width } } + + it 'returns valid value width' do + expect(template.value_width).to eq(expected_value_width) + end + end + end +end