Commit 184c2ced authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 238d22c0
...@@ -130,6 +130,10 @@ export default { ...@@ -130,6 +130,10 @@ export default {
return title; return title;
}, },
shouldRenderHeaderCallout() {
return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure;
},
}, },
watch: { watch: {
// Once the job log is loaded, // Once the job log is loaded,
...@@ -239,10 +243,9 @@ export default { ...@@ -239,10 +243,9 @@ export default {
/> />
</div> </div>
<callout <callout v-if="shouldRenderHeaderCallout">
v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure" <div v-html="job.callout_message"></div>
:message="job.callout_message" </callout>
/>
</header> </header>
<!-- EO Header Section --> <!-- EO Header Section -->
......
/**
* Checks if the first argument is a subset of the second argument.
* @param {Set} subset The set to be considered as the subset.
* @param {Set} superset The set to be considered as the superset.
* @returns {boolean}
*/
// eslint-disable-next-line import/prefer-default-export
export const isSubset = (subset, superset) =>
Array.from(subset).every(value => superset.has(value));
<script> <script>
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon, GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui'; import {
GlLoadingIcon,
GlButton,
GlTooltipDirective,
GlModal,
GlModalDirective,
GlEmptyState,
} from '@gitlab/ui';
import createFlash from '../../flash'; import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import Icon from '../../vue_shared/components/icon.vue'; import Icon from '../../vue_shared/components/icon.vue';
...@@ -17,6 +24,7 @@ export default { ...@@ -17,6 +24,7 @@ export default {
GlButton, GlButton,
Icon, Icon,
GlModal, GlModal,
GlEmptyState,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -103,10 +111,18 @@ export default { ...@@ -103,10 +111,18 @@ export default {
<div v-else-if="!repo.isLoading && isOpen" class="container-image-tags"> <div v-else-if="!repo.isLoading && isOpen" class="container-image-tags">
<table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" /> <table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" />
<gl-empty-state
<div v-else class="nothing-here-block"> v-else
{{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }} :title="s__('ContainerRegistry|This image has no active tags')"
</div> :description="
s__(
`ContainerRegistry|The last tag related to this image was recently removed.
This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
If you have any questions, contact your administrator.`,
)
"
class="mx-auto my-0"
/>
</div> </div>
<gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository"> <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository">
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
......
...@@ -43,6 +43,7 @@ export default { ...@@ -43,6 +43,7 @@ export default {
}, },
data() { data() {
return { return {
selectedItems: [],
itemsToBeDeleted: [], itemsToBeDeleted: [],
modalId: `confirm-image-deletion-modal-${this.repo.id}`, modalId: `confirm-image-deletion-modal-${this.repo.id}`,
selectAllChecked: false, selectAllChecked: false,
...@@ -96,6 +97,7 @@ export default { ...@@ -96,6 +97,7 @@ export default {
}, },
deleteSingleItem(index) { deleteSingleItem(index) {
this.setModalDescription(index); this.setModalDescription(index);
this.itemsToBeDeleted = [index];
this.$refs.deleteModal.$refs.modal.$once('ok', () => { this.$refs.deleteModal.$refs.modal.$once('ok', () => {
this.removeModalEvents(); this.removeModalEvents();
...@@ -103,9 +105,10 @@ export default { ...@@ -103,9 +105,10 @@ export default {
}); });
}, },
deleteMultipleItems() { deleteMultipleItems() {
if (this.itemsToBeDeleted.length === 1) { this.itemsToBeDeleted = [...this.selectedItems];
if (this.selectedItems.length === 1) {
this.setModalDescription(this.itemsToBeDeleted[0]); this.setModalDescription(this.itemsToBeDeleted[0]);
} else if (this.itemsToBeDeleted.length > 1) { } else if (this.selectedItems.length > 1) {
this.setModalDescription(); this.setModalDescription();
} }
...@@ -115,6 +118,7 @@ export default { ...@@ -115,6 +118,7 @@ export default {
}); });
}, },
handleSingleDelete(itemToDelete) { handleSingleDelete(itemToDelete) {
this.itemsToBeDeleted = [];
this.deleteItem(itemToDelete) this.deleteItem(itemToDelete)
.then(() => this.fetchList({ repo: this.repo })) .then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
...@@ -122,6 +126,7 @@ export default { ...@@ -122,6 +126,7 @@ export default {
handleMultipleDelete() { handleMultipleDelete() {
const { itemsToBeDeleted } = this; const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = []; this.itemsToBeDeleted = [];
this.selectedItems = [];
if (this.bulkDeletePath) { if (this.bulkDeletePath) {
this.multiDeleteItems({ this.multiDeleteItems({
...@@ -150,23 +155,23 @@ export default { ...@@ -150,23 +155,23 @@ export default {
} }
}, },
selectAll() { selectAll() {
this.itemsToBeDeleted = this.repo.list.map((x, index) => index); this.selectedItems = this.repo.list.map((x, index) => index);
this.selectAllChecked = true; this.selectAllChecked = true;
}, },
deselectAll() { deselectAll() {
this.itemsToBeDeleted = []; this.selectedItems = [];
this.selectAllChecked = false; this.selectAllChecked = false;
}, },
updateItemsToBeDeleted(index) { updateselectedItems(index) {
const delIndex = this.itemsToBeDeleted.findIndex(x => x === index); const delIndex = this.selectedItems.findIndex(x => x === index);
if (delIndex > -1) { if (delIndex > -1) {
this.itemsToBeDeleted.splice(delIndex, 1); this.selectedItems.splice(delIndex, 1);
this.selectAllChecked = false; this.selectAllChecked = false;
} else { } else {
this.itemsToBeDeleted.push(index); this.selectedItems.push(index);
if (this.itemsToBeDeleted.length === this.repo.list.length) { if (this.selectedItems.length === this.repo.list.length) {
this.selectAllChecked = true; this.selectAllChecked = true;
} }
} }
...@@ -199,7 +204,7 @@ export default { ...@@ -199,7 +204,7 @@ export default {
v-if="canDeleteRepo" v-if="canDeleteRepo"
v-gl-tooltip v-gl-tooltip
v-gl-modal="modalId" v-gl-modal="modalId"
:disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0" :disabled="!selectedItems || selectedItems.length === 0"
class="js-delete-registry float-right" class="js-delete-registry float-right"
data-track-event="click_button" data-track-event="click_button"
data-track-label="bulk_registry_tag_delete" data-track-label="bulk_registry_tag_delete"
...@@ -219,8 +224,8 @@ export default { ...@@ -219,8 +224,8 @@ export default {
<gl-form-checkbox <gl-form-checkbox
v-if="canDeleteRow(item)" v-if="canDeleteRow(item)"
class="js-select-checkbox" class="js-select-checkbox"
:checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)" :checked="selectedItems && selectedItems.includes(index)"
@change="updateItemsToBeDeleted(index)" @change="updateselectedItems(index)"
/> />
</td> </td>
<td class="monospace"> <td class="monospace">
......
...@@ -14,13 +14,12 @@ ...@@ -14,13 +14,12 @@
.blank-state-row { .blank-state-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-around; justify-content: space-between;
height: 100%;
} }
.blank-state-welcome { .blank-state-welcome {
text-align: center; text-align: center;
padding: 20px 0 40px; padding: $gl-padding 0 ($gl-padding * 2);
.blank-state-welcome-title { .blank-state-welcome-title {
font-size: 24px; font-size: 24px;
...@@ -32,23 +31,9 @@ ...@@ -32,23 +31,9 @@
} }
.blank-state-link { .blank-state-link {
display: block;
color: $gl-text-color; color: $gl-text-color;
flex: 0 0 100%;
margin-bottom: 15px; margin-bottom: 15px;
@include media-breakpoint-up(sm) {
flex: 0 0 49%;
&:nth-child(odd) {
margin-right: 5px;
}
&:nth-child(even) {
margin-left: 5px;
}
}
&:hover { &:hover {
background-color: $gray-light; background-color: $gray-light;
text-decoration: none; text-decoration: none;
...@@ -63,15 +48,25 @@ ...@@ -63,15 +48,25 @@
} }
.blank-state { .blank-state {
padding: 20px; display: flex;
align-items: center;
padding: 20px 50px;
border: 1px solid $border-color; border: 1px solid $border-color;
border-radius: $border-radius-default; border-radius: $border-radius-default;
min-height: 240px;
margin-bottom: $gl-padding;
width: calc(50% - #{$gl-padding-8});
@include media-breakpoint-down(sm) {
width: 100%;
flex-direction: column;
justify-content: center;
padding: 50px 20px;
.column-small & {
width: 100%;
}
@include media-breakpoint-up(sm) {
display: flex;
height: 100%;
align-items: center;
padding: 50px 30px;
} }
} }
...@@ -90,7 +85,7 @@ ...@@ -90,7 +85,7 @@
} }
.blank-state-body { .blank-state-body {
@include media-breakpoint-down(xs) { @include media-breakpoint-down(sm) {
text-align: center; text-align: center;
margin-top: 20px; margin-top: 20px;
} }
...@@ -121,9 +116,3 @@ ...@@ -121,9 +116,3 @@
} }
} }
} }
@include media-breakpoint-down(xs) {
.blank-state-icon svg {
width: 315px;
}
}
...@@ -69,10 +69,6 @@ ...@@ -69,10 +69,6 @@
details { details {
margin-bottom: $gl-padding; margin-bottom: $gl-padding;
summary {
margin-bottom: $gl-padding;
}
} }
// Single code lines should wrap // Single code lines should wrap
......
...@@ -4,6 +4,7 @@ module Groups ...@@ -4,6 +4,7 @@ module Groups
class RepositoriesController < Groups::ApplicationController class RepositoriesController < Groups::ApplicationController
before_action :verify_container_registry_enabled! before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image! before_action :authorize_read_container_image!
before_action :feature_flag_group_container_registry_browser!
def index def index
track_event(:list_repositories) track_event(:list_repositories)
...@@ -22,6 +23,10 @@ module Groups ...@@ -22,6 +23,10 @@ module Groups
private private
def feature_flag_group_container_registry_browser!
render_404 unless Feature.enabled?(:group_container_registry_browser, group)
end
def verify_container_registry_enabled! def verify_container_registry_enabled!
render_404 unless Gitlab.config.registry.enabled render_404 unless Gitlab.config.registry.enabled
end end
......
...@@ -22,7 +22,9 @@ module GroupsHelper ...@@ -22,7 +22,9 @@ module GroupsHelper
end end
def group_container_registry_nav? def group_container_registry_nav?
Gitlab.config.registry.enabled && can?(current_user, :read_container_image, @group) Gitlab.config.registry.enabled &&
can?(current_user, :read_container_image, @group) &&
Feature.enabled?(:group_container_registry_browser, @group)
end end
def group_sidebar_links def group_sidebar_links
......
# frozen_string_literal: true
module Emails
module Releases
def new_release_email(user_id, release, reason = nil)
@release = release
@project = @release.project
@target_url = namespace_project_releases_url(
namespace_id: @project.namespace,
project_id: @project
)
user = User.find(user_id)
mail(
to: user.notification_email_for(@project.group),
subject: subject(release_email_subject)
)
end
private
def release_email_subject
release_info = [@release.name, @release.tag].select(&:presence).join(' - ')
"New release: #{release_info}"
end
end
end
...@@ -16,6 +16,7 @@ class Notify < BaseMailer ...@@ -16,6 +16,7 @@ class Notify < BaseMailer
include Emails::Members include Emails::Members
include Emails::AutoDevops include Emails::AutoDevops
include Emails::RemoteMirrors include Emails::RemoteMirrors
include Emails::Releases
helper MilestonesHelper helper MilestonesHelper
helper MergeRequestsHelper helper MergeRequestsHelper
......
...@@ -754,6 +754,10 @@ module Ci ...@@ -754,6 +754,10 @@ module Ci
true true
end end
def invalid_dependencies
dependencies.reject(&:valid_dependency?)
end
def runner_required_feature_names def runner_required_feature_names
strong_memoize(:runner_required_feature_names) do strong_memoize(:runner_required_feature_names) do
RUNNER_FEATURES.select do |feature, method| RUNNER_FEATURES.select do |feature, method|
......
...@@ -25,6 +25,7 @@ class NotificationSetting < ApplicationRecord ...@@ -25,6 +25,7 @@ class NotificationSetting < ApplicationRecord
end end
EMAIL_EVENTS = [ EMAIL_EVENTS = [
:new_release,
:new_note, :new_note,
:new_issue, :new_issue,
:reopen_issue, :reopen_issue,
......
...@@ -26,10 +26,12 @@ class Release < ApplicationRecord ...@@ -26,10 +26,12 @@ class Release < ApplicationRecord
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
scope :sorted, -> { order(released_at: :desc) } scope :sorted, -> { order(released_at: :desc) }
scope :with_project_and_namespace, -> { includes(project: :namespace) }
delegate :repository, to: :project delegate :repository, to: :project
after_commit :create_evidence!, on: :create after_commit :create_evidence!, on: :create
after_commit :notify_new_release, on: :create
def commit def commit
strong_memoize(:commit) do strong_memoize(:commit) do
...@@ -73,6 +75,10 @@ class Release < ApplicationRecord ...@@ -73,6 +75,10 @@ class Release < ApplicationRecord
def create_evidence! def create_evidence!
CreateEvidenceWorker.perform_async(self.id) CreateEvidenceWorker.perform_async(self.id)
end end
def notify_new_release
NewReleaseWorker.perform_async(id)
end
end end
Release.prepend_if_ee('EE::Release') Release.prepend_if_ee('EE::Release')
...@@ -121,4 +121,28 @@ class BuildDetailsEntity < JobEntity ...@@ -121,4 +121,28 @@ class BuildDetailsEntity < JobEntity
def can_admin_build? def can_admin_build?
can?(request.current_user, :admin_build, project) can?(request.current_user, :admin_build, project)
end end
def callout_message
return super unless build.failure_reason.to_sym == :missing_dependency_failure
docs_url = "https://docs.gitlab.com/ce/ci/yaml/README.html#dependencies"
[
failure_message.html_safe,
help_message(docs_url).html_safe
].join("<br />")
end
def invalid_dependencies
build.invalid_dependencies.map(&:name).join(', ')
end
def failure_message
_("This job depends on other jobs with expired/erased artifacts: %{invalid_dependencies}") %
{ invalid_dependencies: invalid_dependencies }
end
def help_message(docs_url)
_("Please refer to <a href=\"%{docs_url}\">%{docs_url}</a>") % { docs_url: docs_url }
end
end end
...@@ -28,6 +28,10 @@ module NotificationRecipientService ...@@ -28,6 +28,10 @@ module NotificationRecipientService
Builder::ProjectMaintainers.new(*args).notification_recipients Builder::ProjectMaintainers.new(*args).notification_recipients
end end
def self.build_new_release_recipients(*args)
Builder::NewRelease.new(*args).notification_recipients
end
module Builder module Builder
class Base class Base
def initialize(*) def initialize(*)
...@@ -359,6 +363,26 @@ module NotificationRecipientService ...@@ -359,6 +363,26 @@ module NotificationRecipientService
end end
end end
class NewRelease < Base
attr_reader :target
def initialize(target)
@target = target
end
def build!
add_recipients(target.project.authorized_users, :custom, nil)
end
def custom_action
:new_release
end
def acting_user
target.author
end
end
class MergeRequestUnmergeable < Base class MergeRequestUnmergeable < Base
attr_reader :target attr_reader :target
def initialize(merge_request) def initialize(merge_request)
......
...@@ -289,6 +289,15 @@ class NotificationService ...@@ -289,6 +289,15 @@ class NotificationService
end end
end end
# Notify users when a new release is created
def send_new_release_notifications(release)
recipients = NotificationRecipientService.build_new_release_recipients(release)
recipients.each do |recipient|
mailer.new_release_email(recipient.user.id, release, recipient.reason).deliver_later
end
end
# Members # Members
def new_access_request(member) def new_access_request(member)
return true unless member.notifiable?(:subscription) return true unless member.notifiable?(:subscription)
......
...@@ -45,12 +45,11 @@ ...@@ -45,12 +45,11 @@
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do = form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
.filtered-search-wrapper.d-flex .filtered-search-wrapper.d-flex
.filtered-search-box .filtered-search-box
= dropdown_tag(custom_icon('icon_history'), = dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper', options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'filtered-search-history-dropdown-toggle-button', toggle_class: 'filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown', dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content', content_class: 'filtered-search-history-dropdown-content' }) do
title: _('Recent searches') }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } } .js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
.filtered-search-box-input-container.droplab-dropdown .filtered-search-box-input-container.droplab-dropdown
.scroll-container .scroll-container
......
.blank-state-row .blank-state-row
= link_to new_project_path, class: "blank-state-link" do - if has_start_trial?
.blank-state = render_if_exists "dashboard/projects/blank_state_ee_trial"
= link_to new_project_path, class: "blank-state blank-state-link" do
.blank-state-icon .blank-state-icon
= custom_icon("add_new_project", size: 50) = image_tag("illustrations/welcome/add_new_project")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Create a project Create a project
...@@ -10,30 +12,27 @@ ...@@ -10,30 +12,27 @@
Projects are where you store your code, access issues, wiki and other features of GitLab. Projects are where you store your code, access issues, wiki and other features of GitLab.
- if current_user.can_create_group? - if current_user.can_create_group?
= link_to new_group_path, class: "blank-state-link" do = link_to new_group_path, class: "blank-state blank-state-link" do
.blank-state
.blank-state-icon .blank-state-icon
= custom_icon("add_new_group", size: 50) = image_tag("illustrations/welcome/add_new_group")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Create a group Create a group
%p.blank-state-text %p.blank-state-text
Groups are a great way to organize projects and people. Groups are a great way to organize projects and people.
= link_to new_admin_user_path, class: "blank-state-link" do = link_to new_admin_user_path, class: "blank-state blank-state-link" do
.blank-state
.blank-state-icon .blank-state-icon
= custom_icon("add_new_user", size: 50) = image_tag("illustrations/welcome/add_new_user")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Add people Add people
%p.blank-state-text %p.blank-state-text
Add your team members and others to GitLab. Add your team members and others to GitLab.
= link_to admin_root_path, class: "blank-state-link" do = link_to admin_root_path, class: "blank-state blank-state-link" do
.blank-state
.blank-state-icon .blank-state-icon
= custom_icon("configure_server", size: 50) = image_tag("illustrations/welcome/configure_server")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Configure GitLab Configure GitLab
......
...@@ -2,10 +2,9 @@ ...@@ -2,10 +2,9 @@
.blank-state-row .blank-state-row
- if current_user.can_create_project? - if current_user.can_create_project?
= link_to new_project_path, class: "blank-state-link" do = link_to new_project_path, class: "blank-state blank-state-link" do
.blank-state
.blank-state-icon .blank-state-icon
= custom_icon("add_new_project", size: 50) = image_tag("illustrations/welcome/add_new_project")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Create a project Create a project
...@@ -14,7 +13,7 @@ ...@@ -14,7 +13,7 @@
- else - else
.blank-state .blank-state
.blank-state-icon .blank-state-icon
= custom_icon("add_new_project", size: 50) = image_tag("illustrations/welcome/add_new_project")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Create a project Create a project
...@@ -22,10 +21,9 @@ ...@@ -22,10 +21,9 @@
If you are added to a project, it will be displayed here. If you are added to a project, it will be displayed here.
- if current_user.can_create_group? - if current_user.can_create_group?
= link_to new_group_path, class: "blank-state-link" do = link_to new_group_path, class: "blank-state blank-state-link" do
.blank-state
.blank-state-icon .blank-state-icon
= custom_icon("add_new_group", size: 50) = image_tag("illustrations/welcome/add_new_group")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Create a group Create a group
...@@ -33,10 +31,9 @@ ...@@ -33,10 +31,9 @@
Groups are the best way to manage projects and members. Groups are the best way to manage projects and members.
- if public_project_count > 0 - if public_project_count > 0
= link_to trending_explore_projects_path, class: "blank-state-link" do = link_to trending_explore_projects_path, class: "blank-state blank-state-link" do
.blank-state
.blank-state-icon .blank-state-icon
= custom_icon("globe", size: 50) = image_tag("illustrations/welcome/globe")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Explore public projects Explore public projects
...@@ -47,10 +44,9 @@ ...@@ -47,10 +44,9 @@
Public projects are an easy way to allow Public projects are an easy way to allow
everyone to have read-only access. everyone to have read-only access.
= link_to "https://docs.gitlab.com/", class: "blank-state-link" do = link_to "https://docs.gitlab.com/", class: "blank-state blank-state-link" do
.blank-state
.blank-state-icon .blank-state-icon
= custom_icon("lightbulb", size: 50) = image_tag("illustrations/welcome/lightbulb")
.blank-state-body .blank-state-body
%h3.blank-state-title %h3.blank-state-title
Learn more about GitLab Learn more about GitLab
......
.blank-state-parent-container{ class: ('has-start-trial-container' if has_start_trial?) } .blank-state-parent-container
.section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" } .section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" }
.container.section-body .container.section-body
.row .row
...@@ -7,12 +7,7 @@ ...@@ -7,12 +7,7 @@
= _('Welcome to GitLab') = _('Welcome to GitLab')
%p.blank-state-text %p.blank-state-text
= _('Faster releases. Better code. Less pain.') = _('Faster releases. Better code. Less pain.')
.blank-state-row
%div{ class: ('column-large' if has_start_trial?) }
- if current_user.admin? - if current_user.admin?
= render "blank_state_admin_welcome" = render "blank_state_admin_welcome"
- else - else
= render "blank_state_welcome" = render "blank_state_welcome"
- if has_start_trial?
.column-small
= render_if_exists "blank_state_ee_trial"
- release_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
- description_details = { tag: @release.tag, name: @project.name, release_link_start: release_link_start, release_link_end: '</a>'.html_safe }
%div{ style: "font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif;" }
%p
= _("A new Release %{tag} for %{name} was published. Visit the %{release_link_start}Releases page%{release_link_end} to read more about it.").html_safe % description_details
%p
%h4= _("Assets:")
%ul
- @release.links.each do |link|
%li= link_to(link.name, link.url)
- @release.sources.each do |source|
%li= link_to(_("Download %{format}") % { format: source.format }, source.url)
%p
%h4= _("Release notes:")
= markdown_field(@release, :description)
<%= _("A new Release %{tag} for %{name} was published. Visit the Releases page to read more about it:").html_safe % { tag: @release.tag, name: @project.name } %> <%= @target_url %>
<%= _("Assets:") %>
<% @release.links.each do |link| -%>
- <%= link.name %>: <%= link.url %>
<% end -%>
<% @release.sources.each do |source| -%>
- <%= _("Download %{format}:") % { format: source.format } %> <%= source.url %>
<% end -%>
<%= _("Release notes:") %>
<%= @release.description %>
...@@ -51,10 +51,10 @@ ...@@ -51,10 +51,10 @@
%hr %hr
.form-group .form-group
= f.label :ci_config_path, _('Custom CI config path'), class: 'label-bold' = f.label :ci_config_path, _('Custom CI configuration path'), class: 'label-bold'
= f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml'
%p.form-text.text-muted %p.form-text.text-muted
= _("The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>").html_safe = _("The path to the CI configuration file. Defaults to <code>.gitlab-ci.yml</code>").html_safe
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank' = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank'
%hr %hr
......
...@@ -17,12 +17,11 @@ ...@@ -17,12 +17,11 @@
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box .filtered-search-box
- if type != :boards_modal && type != :boards - if type != :boards_modal && type != :boards
= dropdown_tag(custom_icon('icon_history'), = dropdown_tag(_('Recent searches'),
options: { wrapper_class: "filtered-search-history-dropdown-wrapper", options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
toggle_class: "filtered-search-history-dropdown-toggle-button", toggle_class: "filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown", dropdown_class: "filtered-search-history-dropdown",
content_class: "filtered-search-history-dropdown-content", content_class: "filtered-search-history-dropdown-content" }) do
title: "Recent searches" }) do
.js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } } .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } }
.filtered-search-box-input-container.droplab-dropdown .filtered-search-box-input-container.droplab-dropdown
.scroll-container .scroll-container
......
...@@ -119,6 +119,8 @@ ...@@ -119,6 +119,8 @@
- container_repository:delete_container_repository - container_repository:delete_container_repository
- container_repository:cleanup_container_repository - container_repository:cleanup_container_repository
- notifications:new_release
- default - default
- mailers # ActionMailer::DeliveryJob.queue_name - mailers # ActionMailer::DeliveryJob.queue_name
......
# frozen_string_literal: true
class NewReleaseWorker
include ApplicationWorker
queue_namespace :notifications
def perform(release_id)
release = Release.with_project_and_namespace.find_by_id(release_id)
return unless release
NotificationService.new.send_new_release_notifications(release)
end
end
---
title: Add 'New release' to the project custom notifications
merge_request: 17877
author:
type: added
---
title: Use text instead of icon for recent searches dropdown
merge_request:
author:
type: changed
---
title: Add more specific message to clarify the role of empty images in container
registry
merge_request: 32919
author:
type: changed
---
title: Allow to view productivity analytics page without a license
merge_request: 33876
author:
type: fixed
---
title: Fix container registry delete tag modal title and button
merge_request: 34032
author:
type: fixed
---
title: Fix formatting welcome screen external users
merge_request: 16667
author:
type: fixed
---
title: Include in the callout message a list of jobs that caused missing dependencies
failure.
merge_request: 18219
author:
type: added
---
title: 'Geo: Enable replicating uploads, LFS objects, and artifacts in Object Storage'
merge_request: 18482
author:
type: added
...@@ -32,6 +32,10 @@ unless Sidekiq.server? ...@@ -32,6 +32,10 @@ unless Sidekiq.server?
payload[:response] = event.payload[:response] if event.payload[:response] payload[:response] = event.payload[:response] if event.payload[:response]
payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id
if cpu_s = Gitlab::Metrics::System.thread_cpu_duration(::Gitlab::RequestContext.start_thread_cpu_time)
payload[:cpu_s] = cpu_s
end
payload payload
end end
end end
......
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
- [process_commit, 3] - [process_commit, 3]
- [new_note, 2] - [new_note, 2]
- [new_issue, 2] - [new_issue, 2]
- [notifications, 2]
- [new_merge_request, 2] - [new_merge_request, 2]
- [pipeline_processing, 5] - [pipeline_processing, 5]
- [pipeline_creation, 4] - [pipeline_creation, 4]
......
# frozen_string_literal: true
class AddNewReleaseToNotificationSettings < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
add_column :notification_settings, :new_release, :boolean
end
end
...@@ -2509,6 +2509,7 @@ ActiveRecord::Schema.define(version: 2019_10_16_220135) do ...@@ -2509,6 +2509,7 @@ ActiveRecord::Schema.define(version: 2019_10_16_220135) do
t.boolean "issue_due" t.boolean "issue_due"
t.boolean "new_epic" t.boolean "new_epic"
t.string "notification_email" t.string "notification_email"
t.boolean "new_release"
t.index ["source_id", "source_type"], name: "index_notification_settings_on_source_id_and_source_type" t.index ["source_id", "source_type"], name: "index_notification_settings_on_source_id_and_source_type"
t.index ["user_id", "source_id", "source_type"], name: "index_notifications_on_user_id_and_source_id_and_source_type", unique: true t.index ["user_id", "source_id", "source_type"], name: "index_notifications_on_user_id_and_source_id_and_source_type", unique: true
t.index ["user_id"], name: "index_notification_settings_on_user_id" t.index ["user_id"], name: "index_notification_settings_on_user_id"
......
...@@ -198,6 +198,11 @@ separately: ...@@ -198,6 +198,11 @@ separately:
These reference architecture examples rely on the general rule that approximately 2 requests per second (RPS) of load is generated for every 100 users. These reference architecture examples rely on the general rule that approximately 2 requests per second (RPS) of load is generated for every 100 users.
The specifications here were performance tested against a specific coded
workload. Your exact needs may be more, depending on your workload. Your
workload is influenced by factors such as - but not limited to - how active your
users are, how much automation you use, mirroring, and repo/change size.
### 10,000 User Configuration ### 10,000 User Configuration
- **Supported Users (approximate):** 10,000 - **Supported Users (approximate):** 10,000
...@@ -211,12 +216,6 @@ environment that supports about 10,000 users. The specifications below are a ...@@ -211,12 +216,6 @@ environment that supports about 10,000 users. The specifications below are a
representation of the work so far. The specifications may be adjusted in the representation of the work so far. The specifications may be adjusted in the
future based on additional testing and iteration. future based on additional testing and iteration.
NOTE: **Note:** The specifications here were performance tested against a
specific coded workload. Your exact needs may be more, depending on your
workload. Your workload is influenced by factors such as - but not limited to -
how active your users are, how much automation you use, mirroring, and
repo/change size.
| Service | Configuration | GCP type | | Service | Configuration | GCP type |
| ------------------------------|-------------------------|----------------| | ------------------------------|-------------------------|----------------|
| 3 GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 32 vCPU, 28.8GB Memory | n1-highcpu-32 | | 3 GitLab Rails <br> - Puma workers on each node set to 90% of available CPUs with 16 threads | 32 vCPU, 28.8GB Memory | n1-highcpu-32 |
......
...@@ -211,6 +211,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph ...@@ -211,6 +211,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `relativePosition` | Int | The relative position of the epic in the Epic tree | | `relativePosition` | Int | The relative position of the epic in the Epic tree |
| `relationPath` | String | | | `relationPath` | String | |
| `reference` | String! | | | `reference` | String! | |
| `subscribed` | Boolean! | Boolean flag for whether the currently logged in user is subscribed to this epic |
### EpicIssue ### EpicIssue
......
...@@ -122,10 +122,6 @@ Example response: ...@@ -122,10 +122,6 @@ Example response:
} }
] ]
}, },
"_links":{
"merge_requests_url": "https://gitlab.example.com/root/awesome_app/merge_requests?release_tag=v0.2&scope=all&state=opened",
"issues_url": "https://gitlab.example.com/root/awesome_app/issues?release_tag=v0.2&scope=all&state=opened"
}
}, },
{ {
"tag_name":"v0.1", "tag_name":"v0.1",
...@@ -182,10 +178,6 @@ Example response: ...@@ -182,10 +178,6 @@ Example response:
] ]
}, },
"_links":{
"merge_requests_url": "https://gitlab.example.com/root/awesome_app/merge_requests?release_tag=v0.1&scope=all&state=opened",
"issues_url": "https://gitlab.example.com/root/awesome_app/issues?release_tag=v0.1&scope=all&state=opened"
}
} }
] ]
``` ```
...@@ -297,10 +289,6 @@ Example response: ...@@ -297,10 +289,6 @@ Example response:
] ]
}, },
"_links":{
"merge_requests_url": "https://gitlab.example.com/root/awesome_app/merge_requests?release_tag=v0.1&scope=all&state=opened",
"issues_url": "https://gitlab.example.com/root/awesome_app/issues?release_tag=v0.1&scope=all&state=opened"
}
} }
``` ```
...@@ -426,10 +414,6 @@ Example response: ...@@ -426,10 +414,6 @@ Example response:
} }
] ]
}, },
"_links":{
"merge_requests_url": "https://gitlab.example.com/root/awesome_app/merge_requests?release_tag=v0.3&scope=all&state=opened",
"issues_url": "https://gitlab.example.com/root/awesome_app/issues?release_tag=v0.3&scope=all&state=opened"
}
} }
``` ```
...@@ -531,10 +515,6 @@ Example response: ...@@ -531,10 +515,6 @@ Example response:
] ]
}, },
"_links":{
"merge_requests_url": "https://gitlab.example.com/root/awesome_app/merge_requests?release_tag=v0.1&scope=all&state=opened",
"issues_url": "https://gitlab.example.com/root/awesome_app/issues?release_tag=v0.1&scope=all&state=opened"
}
} }
``` ```
...@@ -617,10 +597,6 @@ Example response: ...@@ -617,10 +597,6 @@ Example response:
] ]
}, },
"_links":{
"merge_requests_url": "https://gitlab.example.com/root/awesome_app/merge_requests?release_tag=v0.1&scope=all&state=opened",
"issues_url": "https://gitlab.example.com/root/awesome_app/issues?release_tag=v0.1&scope=all&state=opened"
}
} }
``` ```
......
...@@ -85,7 +85,7 @@ GitLab CI/CD supports numerous configuration options: ...@@ -85,7 +85,7 @@ GitLab CI/CD supports numerous configuration options:
| [Job artifacts](../user/project/pipelines/job_artifacts.md) | Output, use, and reuse job artifacts. | | [Job artifacts](../user/project/pipelines/job_artifacts.md) | Output, use, and reuse job artifacts. |
| [Cache dependencies](caching/index.md) | Cache your dependencies for a faster execution. | | [Cache dependencies](caching/index.md) | Cache your dependencies for a faster execution. |
| [Schedule pipelines](../user/project/pipelines/schedules.md) | Schedule pipelines to run as often as you need. | | [Schedule pipelines](../user/project/pipelines/schedules.md) | Schedule pipelines to run as often as you need. |
| [Custom path for `.gitlab-ci.yml`](../user/project/pipelines/settings.md#custom-ci-config-path) | Define a custom path for the CI/CD configuration file. | | [Custom path for `.gitlab-ci.yml`](../user/project/pipelines/settings.md#custom-ci-configuration-path) | Define a custom path for the CI/CD configuration file. |
| [Git submodules for CI/CD](git_submodules.md) | Configure jobs for using Git submodules.| | [Git submodules for CI/CD](git_submodules.md) | Configure jobs for using Git submodules.|
| [SSH keys for CI/CD](ssh_keys/README.md) | Using SSH keys in your CI pipelines. | | [SSH keys for CI/CD](ssh_keys/README.md) | Using SSH keys in your CI pipelines. |
| [Pipelines triggers](triggers/README.md) | Trigger pipelines through the API. | | [Pipelines triggers](triggers/README.md) | Trigger pipelines through the API. |
......
---
type: reference
---
# Code Owners **(STARTER)** # Code Owners **(STARTER)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/6916) > - [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/6916)
in [GitLab Starter](https://about.gitlab.com/pricing/) 11.3. in [GitLab Starter](https://about.gitlab.com/pricing/) 11.3.
> - [Support for group namespaces](https://gitlab.com/gitlab-org/gitlab-foss/issues/53182) added in GitLab Starter 12.1. > - [Support for group namespaces](https://gitlab.com/gitlab-org/gitlab-foss/issues/53182) added in GitLab Starter 12.1.
> - Code Owners for Merge Request approvals was [introduced](https://gitlab.com/gitlab-org/gitlab/issues/4418) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.9.
You can use a `CODEOWNERS` file to specify users or You can use a `CODEOWNERS` file to specify users or
[shared groups](members/share_project_with_groups.md) [shared groups](members/share_project_with_groups.md)
...@@ -10,9 +15,9 @@ that are responsible for certain files in a repository. ...@@ -10,9 +15,9 @@ that are responsible for certain files in a repository.
You can choose and add the `CODEOWNERS` file in three places: You can choose and add the `CODEOWNERS` file in three places:
- to the root directory of the repository - To the root directory of the repository
- inside the `.gitlab/` directory - Inside the `.gitlab/` directory
- inside the `docs/` directory - Inside the `docs/` directory
The `CODEOWNERS` file is scoped to a branch, which means that with the The `CODEOWNERS` file is scoped to a branch, which means that with the
introduction of new files, the person adding the new content can introduction of new files, the person adding the new content can
...@@ -23,6 +28,18 @@ When a file matches multiple entries in the `CODEOWNERS` file, ...@@ -23,6 +28,18 @@ When a file matches multiple entries in the `CODEOWNERS` file,
the users from all entries are displayed on the blob page of the users from all entries are displayed on the blob page of
the given file. the given file.
## Approvals by Code Owners
Once you've set Code Owners to a project, you can configure it to
receive approvals:
- As [merge request eligible approvers](merge_requests/merge_request_approvals.md#code-owners-as-eligible-approvers-starter). **(STARTER)**
- As required approvers for [protected branches](protected_branches.md#protected-branches-approval-by-code-owners-premium). **(PREMIUM)**
Once set, Code Owners are displayed in merge requests widgets:
![MR widget - Code Owners](img/code_owners_mr_widget_v12_4.png)
## The syntax of Code Owners files ## The syntax of Code Owners files
Files can be specified using the same kind of patterns you would use Files can be specified using the same kind of patterns you would use
......
...@@ -101,7 +101,7 @@ any [eligible approver](#eligible-approvers) may approve. ...@@ -101,7 +101,7 @@ any [eligible approver](#eligible-approvers) may approve.
The following can approve merge requests: The following can approve merge requests:
- Users being added as approvers at project or merge request level. - Users being added as approvers at project or merge request level.
- [Code owners](../code_owners.md) related to the merge request ([introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/7933) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.5). - [Code owners](#code-owners-as-eligible-approvers-starter) to the files changed by the merge request.
An individual user can be added as an approver for a project if they are a member of: An individual user can be added as an approver for a project if they are a member of:
...@@ -119,6 +119,31 @@ if [**Prevent author approval**](#allowing-merge-request-authors-to-approve-thei ...@@ -119,6 +119,31 @@ if [**Prevent author approval**](#allowing-merge-request-authors-to-approve-thei
and [**Prevent committers approval**](#prevent-approval-of-merge-requests-by-their-committers) (disabled by default) and [**Prevent committers approval**](#prevent-approval-of-merge-requests-by-their-committers) (disabled by default)
are enabled on the project settings. are enabled on the project settings.
### Code Owners as eligible approvers **(STARTER)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/merge_requests/7933) in [GitLab Starter](https://about.gitlab.com/pricing/) 11.5.
Once you've added [Code Owners](../code_owners.md) to your
repository, the owners to the corresponding files will become
eligible approvers, together with members with Developer or
higher permissions.
To enable this merge request approval rule:
1. Navigate to your project's **Settings > General** and expand
**Merge request approvals**.
1. Locate **All members with Developer role or higher and code owners (if any)** and click **Edit** to choose the number of approvals required.
![MR approvals by Code Owners](img/mr_approvals_by_code_owners_v12_4.png)
Once set, merge requests can only be merged once approved by the
number of approvals you've set. GitLab will accept approvals from
users with Developer or higher permissions, as well as by Code Owners,
indistinguishably.
Alternatively, you can **require**
[Code Owner's approvals for Protected Branches](../protected_branches.md#protected-branches-approval-by-code-owners-premium). **(PREMIUM)**
### Implicit approvers ### Implicit approvers
If the number of required approvals is greater than the number of approvers, If the number of required approvals is greater than the number of approvers,
...@@ -162,26 +187,6 @@ are other conditions that may block it, such as merge conflicts, ...@@ -162,26 +187,6 @@ are other conditions that may block it, such as merge conflicts,
[pending discussions](../../discussions/index.md#only-allow-merge-requests-to-be-merged-if-all-threads-are-resolved) [pending discussions](../../discussions/index.md#only-allow-merge-requests-to-be-merged-if-all-threads-are-resolved)
or a [failed CI/CD pipeline](merge_when_pipeline_succeeds.md). or a [failed CI/CD pipeline](merge_when_pipeline_succeeds.md).
## Code Owners approvals **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4418) in [GitLab Premium](https://about.gitlab.com/pricing/) 11.9.
It is possible to require at least one approval for each entry in the
[`CODEOWNERS` file](../code_owners.md) that matches a file changed in
the merge request. To enable this feature:
1. Navigate to your project's **Settings > General** and expand
**Merge request approvals**.
1. Tick the **Require approval from code owners** checkbox.
1. Click **Save changes**.
When this feature is enabled, all merge requests will need approval
from one code owner per matched rule before it can be merged.
NOTE: **Note:** Only the `CODEOWNERS` file on the default branch is evaluated for
Merge Request approvals. If `CODEOWNERS` is changed on a non-default branch, those
changes will not affect approvals until merged to the default branch.
## Overriding the merge request approvals default settings ## Overriding the merge request approvals default settings
> Introduced in GitLab Enterprise Edition 9.4. > Introduced in GitLab Enterprise Edition 9.4.
......
...@@ -65,14 +65,14 @@ Project defined timeout (either specific timeout set by user or the default ...@@ -65,14 +65,14 @@ Project defined timeout (either specific timeout set by user or the default
For information about setting a maximum artifact size for a project, see For information about setting a maximum artifact size for a project, see
[Maximum artifacts size](../../admin_area/settings/continuous_integration.md#maximum-artifacts-size-core-only). [Maximum artifacts size](../../admin_area/settings/continuous_integration.md#maximum-artifacts-size-core-only).
## Custom CI config path ## Custom CI configuration path
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12509) in GitLab 9.4. > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12509) in GitLab 9.4.
By default we look for the `.gitlab-ci.yml` file in the project's root By default we look for the `.gitlab-ci.yml` file in the project's root
directory. If you require a different location **within** the repository, directory. If you require a different location **within** the repository,
you can set a custom filepath that will be used to lookup the config file, you can set a custom path that will be used to look up the configuration file,
this filepath should be **relative** to the root. this path should be **relative** to the root.
Here are some valid examples: Here are some valid examples:
...@@ -85,7 +85,7 @@ The path can be customized at a project level. To customize the path: ...@@ -85,7 +85,7 @@ The path can be customized at a project level. To customize the path:
1. Go to the project's **Settings > CI / CD**. 1. Go to the project's **Settings > CI / CD**.
1. Expand the **General pipelines** section. 1. Expand the **General pipelines** section.
1. Provide a value in the **Custom CI config path** field. 1. Provide a value in the **Custom CI configuration path** field.
1. Click **Save changes**. 1. Click **Save changes**.
## Test coverage parsing ## Test coverage parsing
......
...@@ -86,20 +86,6 @@ Click **Protect** and the branch will appear in the "Protected branch" list. ...@@ -86,20 +86,6 @@ Click **Protect** and the branch will appear in the "Protected branch" list.
![Roles and users list](img/protected_branches_select_roles_and_users_list.png) ![Roles and users list](img/protected_branches_select_roles_and_users_list.png)
## Code Owners approvals **(PREMIUM)**
It is possible to require at least one approval for each entry in the
[`CODEOWNERS` file](code_owners.md) that matches a file changed in
the merge request. To enable this feature:
1. Toggle the **Require approval from code owners** slider.
1. Click **Protect**.
When this feature is enabled, all merge requests need approval
from one code owner per matched rule before they can be merged. Additionally,
pushes to the protected branch are denied if a rule is matched.
## Wildcard protected branches ## Wildcard protected branches
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/4665) in GitLab 8.10. > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/4665) in GitLab 8.10.
...@@ -166,6 +152,35 @@ Deleting a protected branch is only allowed via the web interface, not via Git. ...@@ -166,6 +152,35 @@ Deleting a protected branch is only allowed via the web interface, not via Git.
This means that you can't accidentally delete a protected branch from your This means that you can't accidentally delete a protected branch from your
command line or a Git client application. command line or a Git client application.
## Protected Branches approval by Code Owners **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/13251) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.4.
It is possible to require at least one approval by a
[Code Owner](code_owners.md) to a file changed by the
merge request. You can either set Code Owners approvals
at the time you protect a new branch, or set it to a branch
already protected, as described below.
To protect a new branch and enable Code Owner's approval:
1. Navigate to your project's **Settings > Repository** and expand **Protected branches**.
1. Scroll down to **Protect a branch**, select a **Branch** or wildcard you'd like to protect, select who's **Allowed to merge** and **Allowed to push**, and toggle the **Require approval from code owners** slider.
1. Click **Protect**.
![Code Owners approval - new protected branch](img/code_owners_approval_new_protected_branch_v12_4.png)
To enable Code Owner's approval to branches already protected:
1. Navigate to your project's **Settings > Repository** and expand **Protected branches**.
1. Scroll down to **Protected branch** and toggle the **Code owner approval** slider for the chosen branch.
![Code Owners approval - branch already protected](img/code_owners_approval_protected_branch_v12_4.png)
When enabled, all merge requests targeting these branches will require approval
by a Code Owner per matched rule before they can be merged.
Additionally, direct pushes to the protected branch are denied if a rule is matched.
## Running pipelines on protected branches ## Running pipelines on protected branches
The permission to merge or push to protected branches is used to define if a user can The permission to merge or push to protected branches is used to define if a user can
......
...@@ -65,6 +65,18 @@ project. ...@@ -65,6 +65,18 @@ project.
![Releases list](img/releases.png) ![Releases list](img/releases.png)
## Notification for Releases
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/26001) in GitLab 12.4.
You can be notified by email when a new Release is created for your project.
To subscribe to these notifications, navigate to your **Project**'s landing page, then click on the
bell icon. Choose **Custom** from the dropdown menu. The
following modal window will be then displayed, from which you can select **New release** to complete your subscription to new Releases notifications.
![Custom notification - New release](img/custom_notifications_new_release_v12_4.png)
<!-- ## Troubleshooting <!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues Include any troubleshooting steps that you can foresee. If you know beforehand what issues
......
...@@ -84,6 +84,7 @@ Below is the table of events users can be notified of: ...@@ -84,6 +84,7 @@ Below is the table of events users can be notified of:
| User added to group | User | Sent when user is added to group | | User added to group | User | Sent when user is added to group |
| Group access level changed | User | Sent when user group access level is changed | | Group access level changed | User | Sent when user group access level is changed |
| Project moved | Project members (1) | (1) not disabled | | Project moved | Project members (1) | (1) not disabled |
| New release | Project members | Custom notification |
### Issue / Epics / Merge request events ### Issue / Epics / Merge request events
......
...@@ -1315,8 +1315,8 @@ module API ...@@ -1315,8 +1315,8 @@ module API
end end
end end
expose :_links do expose :_links do
expose :merge_requests_url expose :merge_requests_url, if: -> (_) { release_mr_issue_urls_available? }
expose :issues_url expose :issues_url, if: -> (_) { release_mr_issue_urls_available? }
end end
private private
...@@ -1347,6 +1347,10 @@ module API ...@@ -1347,6 +1347,10 @@ module API
{ scope: 'all', state: 'opened', release_tag: object.tag } { scope: 'all', state: 'opened', release_tag: object.tag }
end end
def release_mr_issue_urls_available?
::Feature.enabled?(:release_mr_issue_urls, project)
end
def project def project
@project ||= object.project @project ||= object.project
end end
......
...@@ -65,6 +65,8 @@ module Gitlab ...@@ -65,6 +65,8 @@ module Gitlab
end end
def self.thread_cpu_time def self.thread_cpu_time
# Not all OS kernels are supporting `Process::CLOCK_THREAD_CPUTIME_ID`
# Refer: https://gitlab.com/gitlab-org/gitlab/issues/30567#note_221765627
return unless defined?(Process::CLOCK_THREAD_CPUTIME_ID) return unless defined?(Process::CLOCK_THREAD_CPUTIME_ID)
Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID, :float_second) Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID, :float_second)
......
...@@ -6,6 +6,10 @@ module Gitlab ...@@ -6,6 +6,10 @@ module Gitlab
def client_ip def client_ip
Gitlab::SafeRequestStore[:client_ip] Gitlab::SafeRequestStore[:client_ip]
end end
def start_thread_cpu_time
Gitlab::SafeRequestStore[:start_thread_cpu_time]
end
end end
def initialize(app) def initialize(app)
...@@ -23,6 +27,8 @@ module Gitlab ...@@ -23,6 +27,8 @@ module Gitlab
Gitlab::SafeRequestStore[:client_ip] = req.ip Gitlab::SafeRequestStore[:client_ip] = req.ip
Gitlab::SafeRequestStore[:start_thread_cpu_time] = Gitlab::Metrics::System.thread_cpu_time
@app.call(env) @app.call(env)
end end
end end
......
...@@ -659,6 +659,12 @@ msgstr "" ...@@ -659,6 +659,12 @@ msgstr ""
msgid "A merge request approval is required when the license compliance report contains a blacklisted license." msgid "A merge request approval is required when the license compliance report contains a blacklisted license."
msgstr "" msgstr ""
msgid "A new Release %{tag} for %{name} was published. Visit the %{release_link_start}Releases page%{release_link_end} to read more about it."
msgstr ""
msgid "A new Release %{tag} for %{name} was published. Visit the Releases page to read more about it:"
msgstr ""
msgid "A new branch will be created in your fork and a new merge request will be started." msgid "A new branch will be created in your fork and a new merge request will be started."
msgstr "" msgstr ""
...@@ -2009,6 +2015,9 @@ msgstr "" ...@@ -2009,6 +2015,9 @@ msgstr ""
msgid "Assets" msgid "Assets"
msgstr "" msgstr ""
msgid "Assets:"
msgstr ""
msgid "Assign" msgid "Assign"
msgstr "" msgstr ""
...@@ -4358,9 +4367,6 @@ msgstr "" ...@@ -4358,9 +4367,6 @@ msgstr ""
msgid "ContainerRegistry|Last Updated" msgid "ContainerRegistry|Last Updated"
msgstr "" msgstr ""
msgid "ContainerRegistry|No tags in Container Registry for this container image."
msgstr ""
msgid "ContainerRegistry|Quick Start" msgid "ContainerRegistry|Quick Start"
msgstr "" msgstr ""
...@@ -4384,12 +4390,18 @@ msgstr "" ...@@ -4384,12 +4390,18 @@ msgstr ""
msgid "ContainerRegistry|Tag ID" msgid "ContainerRegistry|Tag ID"
msgstr "" msgstr ""
msgid "ContainerRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator."
msgstr ""
msgid "ContainerRegistry|There are no container images available in this group" msgid "ContainerRegistry|There are no container images available in this group"
msgstr "" msgstr ""
msgid "ContainerRegistry|There are no container images stored for this project" msgid "ContainerRegistry|There are no container images stored for this project"
msgstr "" msgstr ""
msgid "ContainerRegistry|This image has no active tags"
msgstr ""
msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}" msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}"
msgstr "" msgstr ""
...@@ -4813,7 +4825,7 @@ msgstr "" ...@@ -4813,7 +4825,7 @@ msgstr ""
msgid "CurrentUser|Settings" msgid "CurrentUser|Settings"
msgstr "" msgstr ""
msgid "Custom CI config path" msgid "Custom CI configuration path"
msgstr "" msgstr ""
msgid "Custom hostname (for private commit emails)" msgid "Custom hostname (for private commit emails)"
...@@ -5709,6 +5721,12 @@ msgstr "" ...@@ -5709,6 +5721,12 @@ msgstr ""
msgid "Download" msgid "Download"
msgstr "" msgstr ""
msgid "Download %{format}"
msgstr ""
msgid "Download %{format}:"
msgstr ""
msgid "Download CSV" msgid "Download CSV"
msgstr "" msgstr ""
...@@ -11139,6 +11157,9 @@ msgstr "" ...@@ -11139,6 +11157,9 @@ msgstr ""
msgid "NotificationEvent|New note" msgid "NotificationEvent|New note"
msgstr "" msgstr ""
msgid "NotificationEvent|New release"
msgstr ""
msgid "NotificationEvent|Reassign issue" msgid "NotificationEvent|Reassign issue"
msgstr "" msgstr ""
...@@ -11969,6 +11990,9 @@ msgstr "" ...@@ -11969,6 +11990,9 @@ msgstr ""
msgid "Please provide a valid email address." msgid "Please provide a valid email address."
msgstr "" msgstr ""
msgid "Please refer to <a href=\"%{docs_url}\">%{docs_url}</a>"
msgstr ""
msgid "Please retype the email address." msgid "Please retype the email address."
msgstr "" msgstr ""
...@@ -13492,6 +13516,9 @@ msgstr "" ...@@ -13492,6 +13516,9 @@ msgstr ""
msgid "Release notes" msgid "Release notes"
msgstr "" msgstr ""
msgid "Release notes:"
msgstr ""
msgid "Release title" msgid "Release title"
msgstr "" msgstr ""
...@@ -16320,7 +16347,7 @@ msgstr "" ...@@ -16320,7 +16347,7 @@ msgstr ""
msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest." msgid "The passphrase required to decrypt the private key. This is optional and the value is encrypted at rest."
msgstr "" msgstr ""
msgid "The path to CI config file. Defaults to <code>.gitlab-ci.yml</code>" msgid "The path to the CI configuration file. Defaults to <code>.gitlab-ci.yml</code>"
msgstr "" msgstr ""
msgid "The phase of the development lifecycle." msgid "The phase of the development lifecycle."
...@@ -16728,6 +16755,9 @@ msgstr "" ...@@ -16728,6 +16755,9 @@ msgstr ""
msgid "This issue is locked." msgid "This issue is locked."
msgstr "" msgstr ""
msgid "This job depends on other jobs with expired/erased artifacts: %{invalid_dependencies}"
msgstr ""
msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered" msgid "This job depends on upstream jobs that need to succeed in order for this job to be triggered"
msgstr "" msgstr ""
......
...@@ -14,3 +14,4 @@ N_('NotificationEvent|Close merge request') ...@@ -14,3 +14,4 @@ N_('NotificationEvent|Close merge request')
N_('NotificationEvent|Reassign merge request') N_('NotificationEvent|Reassign merge request')
N_('NotificationEvent|Merge merge request') N_('NotificationEvent|Merge merge request')
N_('NotificationEvent|Failed pipeline') N_('NotificationEvent|Failed pipeline')
N_('NotificationEvent|New release')
import { isSubset } from '~/lib/utils/set';
describe('utils/set', () => {
describe('isSubset', () => {
it.each`
subset | superset | expected
${new Set()} | ${new Set()} | ${true}
${new Set()} | ${new Set([1])} | ${true}
${new Set([1])} | ${new Set([1])} | ${true}
${new Set([1, 3])} | ${new Set([1, 2, 3])} | ${true}
${new Set([1])} | ${new Set()} | ${false}
${new Set([1])} | ${new Set([2])} | ${false}
${new Set([7, 8, 9])} | ${new Set([1, 2, 3])} | ${false}
${new Set([1, 2, 3, 4])} | ${new Set([1, 2, 3])} | ${false}
`('isSubset($subset, $superset) === $expected', ({ subset, superset, expected }) => {
expect(isSubset(subset, superset)).toBe(expected);
});
});
});
...@@ -112,11 +112,13 @@ describe('table registry', () => { ...@@ -112,11 +112,13 @@ describe('table registry', () => {
Vue.nextTick(() => { Vue.nextTick(() => {
const deleteBtn = findDeleteButton(wrapper); const deleteBtn = findDeleteButton(wrapper);
expect(wrapper.vm.itemsToBeDeleted).toEqual([0, 1]); expect(wrapper.vm.selectedItems).toEqual([0, 1]);
expect(deleteBtn.attributes('disabled')).toEqual(undefined); expect(deleteBtn.attributes('disabled')).toEqual(undefined);
wrapper.setData({ itemsToBeDeleted: [...wrapper.vm.selectedItems] });
wrapper.vm.handleMultipleDelete(); wrapper.vm.handleMultipleDelete();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(wrapper.vm.selectedItems).toEqual([]);
expect(wrapper.vm.itemsToBeDeleted).toEqual([]); expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(wrapper.vm.multiDeleteItems).toHaveBeenCalledWith({ expect(wrapper.vm.multiDeleteItems).toHaveBeenCalledWith({
path: bulkDeletePath, path: bulkDeletePath,
...@@ -143,13 +145,13 @@ describe('table registry', () => { ...@@ -143,13 +145,13 @@ describe('table registry', () => {
describe('delete registry', () => { describe('delete registry', () => {
beforeEach(() => { beforeEach(() => {
wrapper.setData({ itemsToBeDeleted: [0] }); wrapper.setData({ selectedItems: [0] });
}); });
it('should be possible to delete a registry', () => { it('should be possible to delete a registry', () => {
const deleteBtn = findDeleteButton(wrapper); const deleteBtn = findDeleteButton(wrapper);
const deleteBtns = findDeleteButtonsRow(wrapper); const deleteBtns = findDeleteButtonsRow(wrapper);
expect(wrapper.vm.itemsToBeDeleted).toEqual([0]); expect(wrapper.vm.selectedItems).toEqual([0]);
expect(deleteBtn).toBeDefined(); expect(deleteBtn).toBeDefined();
expect(deleteBtn.attributes('disable')).toBe(undefined); expect(deleteBtn.attributes('disable')).toBe(undefined);
expect(deleteBtns.is('button')).toBe(true); expect(deleteBtns.is('button')).toBe(true);
...@@ -212,15 +214,15 @@ describe('table registry', () => { ...@@ -212,15 +214,15 @@ describe('table registry', () => {
describe('modal content', () => { describe('modal content', () => {
it('should show the singular title and image name when deleting a single image', () => { it('should show the singular title and image name when deleting a single image', () => {
wrapper.setData({ itemsToBeDeleted: [1] }); wrapper.setData({ selectedItems: [1, 2, 3] });
wrapper.vm.setModalDescription(0); wrapper.vm.deleteSingleItem(0);
expect(wrapper.vm.modalAction).toBe('Remove tag'); expect(wrapper.vm.modalAction).toBe('Remove tag');
expect(wrapper.vm.modalDescription).toContain(firstImage.tag); expect(wrapper.vm.modalDescription).toContain(firstImage.tag);
}); });
it('should show the plural title and image count when deleting more than one image', () => { it('should show the plural title and image count when deleting more than one image', () => {
wrapper.setData({ itemsToBeDeleted: [1, 2] }); wrapper.setData({ selectedItems: [1, 2] });
wrapper.vm.setModalDescription(); wrapper.vm.deleteMultipleItems();
expect(wrapper.vm.modalAction).toBe('Remove tags'); expect(wrapper.vm.modalAction).toBe('Remove tags');
expect(wrapper.vm.modalDescription).toContain('<b>2</b> tags'); expect(wrapper.vm.modalDescription).toContain('<b>2</b> tags');
......
...@@ -7,6 +7,6 @@ describe GitlabSchema.types['DiffPosition'] do ...@@ -7,6 +7,6 @@ describe GitlabSchema.types['DiffPosition'] do
:new_path, :position_type, :old_line, :new_line, :x, :y, :new_path, :position_type, :old_line, :new_line, :x, :y,
:width, :height] :width, :height]
is_expected.to have_graphql_field(*expected_fields) is_expected.to have_graphql_fields(*expected_fields)
end end
end end
...@@ -34,5 +34,38 @@ describe 'lograge', type: :request do ...@@ -34,5 +34,38 @@ describe 'lograge', type: :request do
subject subject
end end
it 'logs cpu_s on supported platform' do
allow(Gitlab::Metrics::System).to receive(:thread_cpu_time)
.and_return(
0.111222333,
0.222333833
)
expect(Lograge.formatter).to receive(:call)
.with(a_hash_including(cpu_s: 0.1111115))
.and_call_original
expect(Lograge.logger).to receive(:send)
.with(anything, include('"cpu_s":0.1111115'))
.and_call_original
subject
end
it 'does not log cpu_s on unsupported platform' do
allow(Gitlab::Metrics::System).to receive(:thread_cpu_time)
.and_return(nil)
expect(Lograge.formatter).to receive(:call)
.with(hash_not_including(:cpu_s))
.and_call_original
expect(Lograge.logger).not_to receive(:send)
.with(anything, include('"cpu_s":'))
.and_call_original
subject
end
end end
end end
...@@ -40,7 +40,10 @@ describe Gitlab::Cluster::Mixins::PumaCluster do ...@@ -40,7 +40,10 @@ describe Gitlab::Cluster::Mixins::PumaCluster do
yield(process.pid) yield(process.pid)
ensure ensure
Process.kill(:KILL, process.pid) unless process.eof? begin
Process.kill(:KILL, process.pid)
rescue Errno::ESRCH
end
end end
end end
end end
......
...@@ -54,7 +54,10 @@ describe Gitlab::Cluster::Mixins::UnicornHttpServer do ...@@ -54,7 +54,10 @@ describe Gitlab::Cluster::Mixins::UnicornHttpServer do
yield(process.pid) yield(process.pid)
ensure ensure
Process.kill(:KILL, process.pid) unless process.eof? begin
Process.kill(:KILL, process.pid)
rescue Errno::ESRCH
end
end end
end end
end end
......
...@@ -58,4 +58,44 @@ describe Gitlab::Metrics::System do ...@@ -58,4 +58,44 @@ describe Gitlab::Metrics::System do
expect(described_class.monotonic_time).to be_an(Float) expect(described_class.monotonic_time).to be_an(Float)
end end
end end
describe '.thread_cpu_time' do
it 'returns cpu_time on supported platform' do
stub_const("Process::CLOCK_THREAD_CPUTIME_ID", 16)
expect(Process).to receive(:clock_gettime)
.with(16, kind_of(Symbol)) { 0.111222333 }
expect(described_class.thread_cpu_time).to eq(0.111222333)
end
it 'returns nil on unsupported platform' do
hide_const("Process::CLOCK_THREAD_CPUTIME_ID")
expect(described_class.thread_cpu_time).to be_nil
end
end
describe '.thread_cpu_duration' do
let(:start_time) { described_class.thread_cpu_time }
it 'returns difference between start and current time' do
stub_const("Process::CLOCK_THREAD_CPUTIME_ID", 16)
expect(Process).to receive(:clock_gettime)
.with(16, kind_of(Symbol))
.and_return(
0.111222333,
0.222333833
)
expect(described_class.thread_cpu_duration(start_time)).to eq(0.1111115)
end
it 'returns nil on unsupported platform' do
hide_const("Process::CLOCK_THREAD_CPUTIME_ID")
expect(described_class.thread_cpu_duration(start_time)).to be_nil
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
require 'email_spec'
describe Emails::Releases do
include EmailSpec::Matchers
include_context 'gitlab email notification'
describe '#new_release_email' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let(:release) { create(:release, project: project) }
subject { Notify.new_release_email(user.id, release) }
it_behaves_like 'an email sent from GitLab'
context 'when the release has a name' do
it 'shows the correct subject' do
expected_subject = "#{release.project.name} | New release: #{release.name} - #{release.tag}"
is_expected.to have_subject(expected_subject)
end
end
context 'when the release does not have a name' do
it 'shows the correct subject' do
release.name = nil
expected_subject = "#{release.project.name} | New release: #{release.tag}"
is_expected.to have_subject(expected_subject)
end
end
it 'contains a message with the new release tag' do
message = "A new Release #{release.tag} for #{release.project.name} was published."
is_expected.to have_body_text(message)
end
it 'contains the release assets' do
is_expected.to have_body_text('Assets:')
release.sources do |source|
is_expected.to have_body_text("Download #{source.format}")
end
end
it 'contains the release notes' do
is_expected.to have_body_text('Release notes:')
is_expected.to have_body_text(release.description)
end
end
end
...@@ -3918,4 +3918,14 @@ describe Ci::Build do ...@@ -3918,4 +3918,14 @@ describe Ci::Build do
end end
end end
end end
describe '#invalid_dependencies' do
let!(:pre_stage_job_valid) { create(:ci_build, :manual, pipeline: pipeline, name: 'test1', stage_idx: 0) }
let!(:pre_stage_job_invalid) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test2', stage_idx: 1) }
let!(:job) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 2, options: { dependencies: %w(test1 test2) }) }
it 'returns invalid dependencies' do
expect(job.invalid_dependencies).to eq([pre_stage_job_invalid])
end
end
end end
...@@ -98,6 +98,7 @@ RSpec.describe NotificationSetting do ...@@ -98,6 +98,7 @@ RSpec.describe NotificationSetting do
it 'returns email events' do it 'returns email events' do
expect(subject).to include( expect(subject).to include(
:new_release,
:new_note, :new_note,
:new_issue, :new_issue,
:reopen_issue, :reopen_issue,
......
...@@ -109,4 +109,24 @@ RSpec.describe Release do ...@@ -109,4 +109,24 @@ RSpec.describe Release do
end end
end end
end end
describe '#notify_new_release' do
context 'when a release is created' do
it 'instantiates NewReleaseWorker to send notifications' do
expect(NewReleaseWorker).to receive(:perform_async)
create(:release)
end
end
context 'when a release is updated' do
let!(:release) { create(:release) }
it 'does not send any new notification' do
expect(NewReleaseWorker).not_to receive(:perform_async)
release.update!(description: 'new description')
end
end
end
end end
...@@ -123,6 +123,24 @@ describe BuildDetailsEntity do ...@@ -123,6 +123,24 @@ describe BuildDetailsEntity do
end end
it { is_expected.to include(failure_reason: 'unmet_prerequisites') } it { is_expected.to include(failure_reason: 'unmet_prerequisites') }
it { is_expected.to include(callout_message: CommitStatusPresenter.callout_failure_messages[:unmet_prerequisites]) }
end
context 'when the build has failed due to a missing dependency' do
let!(:test1) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test1', stage_idx: 0) }
let!(:test2) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test2', stage_idx: 1) }
let!(:build) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 2, options: { dependencies: %w(test1 test2) }) }
let(:message) { subject[:callout_message] }
before do
build.drop!(:missing_dependency_failure)
end
it { is_expected.to include(failure_reason: 'missing_dependency_failure') }
it 'includes the failing dependencies in the callout message' do
expect(message).to include('test2, test1')
end
end end
context 'when a build has environment with latest deployment' do context 'when a build has environment with latest deployment' do
......
...@@ -678,6 +678,27 @@ describe NotificationService, :mailer do ...@@ -678,6 +678,27 @@ describe NotificationService, :mailer do
end end
end end
describe '#send_new_release_notifications' do
context 'when recipients for a new release exist' do
let(:release) { create(:release) }
it 'calls new_release_email for each relevant recipient' do
user_1 = create(:user)
user_2 = create(:user)
user_3 = create(:user)
recipient_1 = NotificationRecipient.new(user_1, :custom, custom_action: :new_release)
recipient_2 = NotificationRecipient.new(user_2, :custom, custom_action: :new_release)
allow(NotificationRecipientService).to receive(:build_new_release_recipients).and_return([recipient_1, recipient_2])
release
should_email(user_1)
should_email(user_2)
should_not_email(user_3)
end
end
end
describe 'Participating project notification settings have priority over group and global settings if available' do describe 'Participating project notification settings have priority over group and global settings if available' do
let!(:group) { create(:group) } let!(:group) { create(:group) }
let!(:maintainer) { group.add_owner(create(:user, username: 'maintainer')).user } let!(:maintainer) { group.add_owner(create(:user, username: 'maintainer')).user }
......
...@@ -28,9 +28,15 @@ RSpec::Matchers.define :have_graphql_fields do |*expected| ...@@ -28,9 +28,15 @@ RSpec::Matchers.define :have_graphql_fields do |*expected|
end end
end end
RSpec::Matchers.define :have_graphql_field do |field_name| RSpec::Matchers.define :have_graphql_field do |field_name, args = {}|
match do |kls| match do |kls|
expect(kls.fields.keys).to include(GraphqlHelpers.fieldnamerize(field_name)) field = kls.fields[GraphqlHelpers.fieldnamerize(field_name)]
expect(field).to be_present
args.each do |argument, value|
expect(field.send(argument)).to eq(value)
end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe NewReleaseWorker do
let(:release) { create(:release) }
it 'sends a new release notification' do
expect_any_instance_of(NotificationService).to receive(:send_new_release_notifications).with(release)
described_class.new.perform(release.id)
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