Skip to content
Snippets Groups Projects
Commit 2914011d authored by 🤖 GitLab Bot 🤖's avatar 🤖 GitLab Bot 🤖
Browse files

Automatic merge of gitlab-org/gitlab master

parents 1e3bff7e 462e131b
No related branches found
No related tags found
No related merge requests found
Showing
with 456 additions and 40 deletions
<script>
import { GlAlert, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import csrf from '~/lib/utils/csrf';
import TopicSelect from './topic_select.vue';
export default {
components: {
GlAlert,
GlButton,
GlModal,
GlSprintf,
TopicSelect,
},
directives: {
GlModal: GlModalDirective,
},
inject: ['path'],
data() {
return {
sourceTopic: {},
targetTopic: {},
};
},
computed: {
sourceTopicId() {
return getIdFromGraphQLId(this.sourceTopic?.id);
},
targetTopicId() {
return getIdFromGraphQLId(this.targetTopic?.id);
},
validSelectedTopics() {
return (
Object.keys(this.sourceTopic).length &&
Object.keys(this.targetTopic).length &&
this.sourceTopic !== this.targetTopic
);
},
actionPrimary() {
return {
text: __('Merge'),
attributes: {
variant: 'danger',
disabled: !this.validSelectedTopics,
},
};
},
},
methods: {
selectSourceTopic(topic) {
this.sourceTopic = topic;
},
selectTargetTopic(topic) {
this.targetTopic = topic;
},
mergeTopics() {
this.$refs.mergeForm.submit();
},
},
i18n: {
title: s__('MergeTopics|Merge topics'),
body: s__(
'MergeTopics|Move all assigned projects from the source topic to the target topic and remove the source topic.',
),
sourceTopic: s__('MergeTopics|Source topic'),
targetTopic: s__('MergeTopics|Target topic'),
warningTitle: s__('MergeTopics|Merging topics will cause the following:'),
warningBody: s__('MergeTopics|This action cannot be undone.'),
warningRemoveTopic: s__('MergeTopics|%{sourceTopic} will be removed'),
warningMoveProjects: s__('MergeTopics|All assigned projects will be moved to %{targetTopic}'),
},
modal: {
id: 'merge-topics',
actionSecondary: {
text: __('Cancel'),
attributes: {
variant: 'default',
},
},
},
csrf,
};
</script>
<template>
<div class="gl-mr-3">
<gl-button v-gl-modal="$options.modal.id" category="secondary">{{
$options.i18n.title
}}</gl-button>
<gl-modal
:title="$options.i18n.title"
:action-primary="actionPrimary"
:action-secondary="$options.modal.actionSecondary"
:modal-id="$options.modal.id"
size="sm"
@primary="mergeTopics"
>
<p>{{ $options.i18n.body }}</p>
<topic-select
:selected-topic="sourceTopic"
:label-text="$options.i18n.sourceTopic"
@click="selectSourceTopic"
/>
<topic-select
:selected-topic="targetTopic"
:label-text="$options.i18n.targetTopic"
@click="selectTargetTopic"
/>
<gl-alert
v-if="validSelectedTopics"
:title="$options.i18n.warningTitle"
:dismissible="false"
variant="danger"
>
<ul>
<li>
<gl-sprintf :message="$options.i18n.warningRemoveTopic">
<template #sourceTopic>
<strong>{{ sourceTopic.name }}</strong>
</template>
</gl-sprintf>
</li>
<li>
<gl-sprintf :message="$options.i18n.warningMoveProjects">
<template #targetTopic>
<strong>{{ targetTopic.name }}</strong>
</template>
</gl-sprintf>
</li>
</ul>
{{ $options.i18n.warningBody }}
</gl-alert>
<form ref="mergeForm" method="post" :action="path">
<input type="hidden" name="_method" value="post" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<input type="hidden" name="source_topic_id" :value="sourceTopicId" />
<input type="hidden" name="target_topic_id" :value="targetTopicId" />
</form>
</gl-modal>
</div>
</template>
<script>
import {
GlAvatarLabeled,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
export default {
components: {
GlAvatarLabeled,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
},
props: {
selectedTopic: {
type: Object,
required: false,
default: () => ({}),
},
labelText: {
type: String,
required: false,
default: null,
},
},
apollo: {
topics: {
query: searchProjectTopics,
variables() {
return {
search: this.search,
};
},
update(data) {
return data.topics?.nodes || [];
},
debounce: 250,
},
},
data() {
return {
topics: [],
search: '',
};
},
computed: {
loading() {
return this.$apollo.queries.topics.loading;
},
isResultEmpty() {
return this.topics.length === 0;
},
dropdownText() {
if (Object.keys(this.selectedTopic).length) {
return this.selectedTopic.name;
}
return this.$options.i18n.dropdownText;
},
},
methods: {
selectTopic(topic) {
this.$emit('click', topic);
},
},
i18n: {
dropdownText: s__('TopicSelect|Select a topic'),
searchPlaceholder: s__('TopicSelect|Search topics'),
emptySearchResult: s__('TopicSelect|No matching results'),
},
AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
<div>
<label v-if="labelText">{{ labelText }}</label>
<gl-dropdown block :text="dropdownText">
<gl-search-box-by-type
v-model="search"
:is-loading="loading"
:placeholder="$options.i18n.searchPlaceholder"
/>
<gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)">
<gl-avatar-labeled
:label="topic.title"
:sub-label="topic.name"
:src="topic.avatarUrl"
:entity-name="topic.name"
:size="32"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
</gl-dropdown-item>
<gl-dropdown-text v-if="isResultEmpty && !loading">
<span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
</gl-dropdown-text>
</gl-dropdown>
</div>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import showToast from '~/vue_shared/plugins/global_toast';
import RemoveAvatar from './components/remove_avatar.vue';
import MergeTopics from './components/merge_topics.vue';
export default () => {
const toasts = document.querySelectorAll('.js-toast-message');
toasts.forEach((toast) => showToast(toast.dataset.message));
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export const initRemoveAvatar = () => {
const el = document.querySelector('.js-remove-topic-avatar');
if (!el) {
......@@ -21,3 +34,20 @@ export default () => {
},
});
};
export const initMergeTopics = () => {
const el = document.querySelector('.js-merge-topics');
if (!el) return false;
const { path } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: { path },
render(createElement) {
return createElement(MergeTopics);
},
});
};
......@@ -2,7 +2,7 @@ import $ from 'jquery';
import GLForm from '~/gl_form';
import initFilePickers from '~/file_pickers';
import ZenMode from '~/zen_mode';
import initRemoveAvatar from '~/admin/topics';
import { initRemoveAvatar } from '~/admin/topics';
new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
initFilePickers();
......
import { initMergeTopics } from '~/admin/topics';
initMergeTopics();
......@@ -2,7 +2,7 @@
import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import searchProjectTopics from '../queries/project_topics_search.query.graphql';
import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
export default {
components: {
......
......@@ -85,6 +85,9 @@ export default {
};
},
computed: {
collapseButtonLabel() {
return sprintf(this.isCollapsed ? __('Show details') : __('Hide details'));
},
statusIcon() {
return this.error ? EXTENSION_ICONS.failed : this.statusIconName;
},
......@@ -106,9 +109,6 @@ export default {
this.isLoading = false;
},
methods: {
collapseButtonLabel() {
return sprintf(this.isCollapsed ? __('Show details') : __('Hide details'));
},
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
},
......
......@@ -56,9 +56,8 @@ def merge
end
message = _('Topic %{source_topic} was successfully merged into topic %{target_topic}.')
redirect_to admin_topics_path,
status: :found,
notice: message % { source_topic: source_topic.name, target_topic: target_topic.name }
flash[:toast] = message % { source_topic: source_topic.name, target_topic: target_topic.name }
redirect_to admin_topics_path, status: :found
end
private
......
......@@ -1318,7 +1318,6 @@ def for_same_project?
# running `ReferenceExtractor` on each of them separately.
# This optimization does not apply to issues from external sources.
def cache_merge_request_closes_issues!(current_user = self.author)
return unless project.issues_enabled?
return if closed? || merged?
transaction do
......
- page_title _("Topics")
= form_tag admin_topics_path, method: :get do |f|
.gl-py-3.gl-display-flex.gl-flex-direction-column-reverse.gl-md-flex-direction-row.gl-border-b-solid.gl-border-gray-100.gl-border-b-1
.gl-flex-grow-1.gl-mt-3.gl-md-mt-0
.inline.gl-w-full.gl-md-w-auto
- search = params.fetch(:search, nil)
.search-field-holder
= search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' }
= sprite_icon('search', css_class: 'search-icon')
.nav-controls
= link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do
= _('New topic')
.top-area
.nav-controls.gl-w-full.gl-mt-3.gl-mb-3
= form_tag admin_topics_path, method: :get do |f|
- search = params.fetch(:search, nil)
.search-field-holder
= search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' }
= sprite_icon('search', css_class: 'search-icon')
.gl-flex-grow-1
.js-merge-topics{ data: { path: merge_admin_topics_path } }
= link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do
= _('New topic')
%ul.content-list
= render partial: 'topic', collection: @topics
......
......@@ -276,7 +276,7 @@ listed in the descriptions of the relevant settings.
| `deactivate_dormant_users` | boolean | no | Enable [automatic deactivation of dormant users](../user/admin_area/moderate_users.md#automatically-deactivate-dormant-users). |
| `deactivate_dormant_users_period` | integer | no | Length of time (in days) after which a user is considered dormant. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/336747) in GitLab 15.3. |
| `default_artifacts_expire_in` | string | no | Set the default expiration time for each job's artifacts. |
| `default_branch_name` | string | no | [Instance-level custom initial branch name](../user/project/repository/branches/default.md#instance-level-custom-initial-branch-name) ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/225258) in GitLab 13.2). |
| `default_branch_name` | string | no | [Instance-level custom initial branch name](../user/project/repository/branches/default.md#instance-level-custom-initial-branch-name). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/225258) in GitLab 13.2. |
| `default_branch_protection` | integer | no | Determine if developers can push to the default branch. Can take: `0` _(not protected, both users with the Developer role or Maintainer role can push new commits and force push)_, `1` _(partially protected, users with the Developer role or Maintainer role can push new commits, but cannot force push)_ or `2` _(fully protected, users with the Developer or Maintainer role cannot push new commits, but users with the Developer or Maintainer role can; no one can force push)_ as a parameter. Default is `2`. |
| `default_ci_config_path` | string | no | Default CI/CD configuration file and path for new projects (`.gitlab-ci.yml` if not set). |
| `default_group_visibility` | string | no | What visibility level new groups receive. Can take `private`, `internal` and `public` as a parameter. Default is `private`. |
......@@ -291,7 +291,7 @@ listed in the descriptions of the relevant settings.
| `diff_max_patch_bytes` | integer | no | Maximum [diff patch size](../user/admin_area/diff_limits.md), in bytes. |
| `diff_max_files` | integer | no | Maximum [files in a diff](../user/admin_area/diff_limits.md). |
| `diff_max_lines` | integer | no | Maximum [lines in a diff](../user/admin_area/diff_limits.md). |
| `disable_feed_token` | boolean | no | Disable display of RSS/Atom and calendar feed tokens ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/231493) in GitLab 13.7) |
| `disable_feed_token` | boolean | no | Disable display of RSS/Atom and calendar feed tokens. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/231493) in GitLab 13.7. |
| `disabled_oauth_sign_in_sources` | array of strings | no | Disabled OAuth sign-in sources. |
| `dns_rebinding_protection_enabled` | boolean | no | Enforce DNS rebinding attack protection. |
| `domain_denylist_enabled` | boolean | no | (**If enabled, requires:** `domain_denylist`) Allows blocking sign-ups from emails from specific domains. |
......@@ -377,7 +377,7 @@ listed in the descriptions of the relevant settings.
| `max_artifacts_size` | integer | no | Maximum artifacts size in MB. |
| `max_attachment_size` | integer | no | Limit attachment size in MB. |
| `max_export_size` | integer | no | Maximum export size in MB. 0 for unlimited. Default = 0 (unlimited). |
| `max_import_size` | integer | no | Maximum import size in MB. 0 for unlimited. Default = 0 (unlimited) [Modified](https://gitlab.com/gitlab-org/gitlab/-/issues/251106) from 50MB to 0 in GitLab 13.8. |
| `max_import_size` | integer | no | Maximum import size in MB. 0 for unlimited. Default = 0 (unlimited). [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/251106) from 50 MB to 0 in GitLab 13.8. |
| `max_pages_size` | integer | no | Maximum size of pages repositories in MB. |
| `max_personal_access_token_lifetime` **(ULTIMATE SELF)** | integer | no | Maximum allowable lifetime for access tokens in days. |
| `max_ssh_key_lifetime` **(ULTIMATE SELF)** | integer | no | Maximum allowable lifetime for SSH keys in days. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1007) in GitLab 14.6. |
......
......@@ -288,6 +288,8 @@ To edit a topic, select **Edit** in that topic's row.
To remove a topic, select **Remove** in that topic's row.
To remove a topic and move all assigned projects to another topic, select **Merge topics**.
To search for topics by name, enter your criteria in the search box. The topic search is case
insensitive and applies partial matching.
......
......@@ -458,7 +458,8 @@ The default issue closing pattern regex:
#### Disable automatic issue closing
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/19754) in GitLab 12.7.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/19754) in GitLab 12.7.
> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/240922) in GitLab 15.4: The referenced issue's project setting is checked instead of the project of the commit or merge request.
You can disable the automatic issue closing feature on a per-project basis
in the [project's settings](../settings/index.md).
......@@ -472,20 +473,15 @@ To disable automatic issue closing:
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > Repository**.
1. Expand **Default branch**.
1. Select **Auto-close referenced issues on default branch**.
1. Clear the **Auto-close referenced issues on default branch** checkbox.
1. Select **Save changes**.
Referenced issues are still displayed, but are not closed automatically.
The automatic issue closing is disabled by default in a project if the project has the issue tracker
disabled. If you want to enable automatic issue closing, make sure to
[enable GitLab Issues](../settings/index.md#configure-project-visibility-features-and-permissions).
Changing this setting applies only to new merge requests or commits. Already
closed issues remain as they are.
If issue tracking is enabled, disabling automatic issue closing only applies to merge requests
attempting to automatically close issues in the same project.
Merge requests in other projects can still close another project's issues.
Disabling automatic issue closing only applies to issues in the project where the setting was disabled.
Merge requests and commits in this project can still close another project's issues.
#### Customize the issue closing pattern **(FREE SELF)**
......
......@@ -58,6 +58,15 @@ def new_group_member_with_confirmation_email
def new_epic_email
::Notify.new_epic_email(user.id, epic.id).message
end
def merge_commits_csv_email
::Notify.merge_commits_csv_email(
user,
project.group,
'one,two,three',
'filename.csv'
).message
end
end
private
......
%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
- group_link = link_to(@group.name, group_url(@group), style: "color:#3777b0; text-decoration:none; display:block;")
= _('Your Chain of Custody CSV export for the group %{group_link} has been added to this email as an attachment.') % { group_link: group_link }
= _('Your Chain of Custody CSV export for the group %{group_link} has been added to this email as an attachment.').html_safe % { group_link: group_link }
......@@ -20,8 +20,15 @@
let(:frozen_time) { Time.current }
let(:filename) { "#{group.id}-merge-commits-#{frozen_time.to_i}.csv" }
let(:csv_data) { MergeCommits::ExportCsvService.new(current_user, group).csv_data.payload }
let(:group_url) { Gitlab::Routing.url_helpers.group_url(group) }
let(:expected_text) do
"Your Chain of Custody CSV export for the group #{group.name} has been added to this email as an attachment."
"Your Chain of Custody CSV export for the group %{group_name} has been added to this email as an attachment."
end
let(:expected_plain_text) { format(expected_text, group_name: group.name) }
let(:expected_html_text) do
group_name_with_link = %r{<a .*?href="#{group_url}".*?>#{group.name}</a>}
Regexp.new(format(expected_text, group_name: group_name_with_link))
end
subject do
......@@ -32,7 +39,7 @@
it { expect(subject.subject).to eq("#{group.name} | Exported Chain of Custody Report") }
it { expect(subject.to).to contain_exactly(current_user.notification_email_for(project.group)) }
it { expect(subject.text_part).to have_content(expected_text) }
it { expect(subject.html_part).to have_content("Your Chain of Custody CSV export for the group") }
it { expect(subject.text_part.to_s).to match(expected_plain_text) }
it { expect(subject.html_part.to_s).to match(expected_html_text) }
end
end
......@@ -17,7 +17,6 @@ def initialize(project, current_user = nil)
def closed_by_message(message)
return [] if message.nil?
return [] unless @project.autoclose_referenced_issues
closing_statements = []
message.scan(ISSUE_CLOSING_REGEX) do
......@@ -27,8 +26,9 @@ def closed_by_message(message)
@extractor.analyze(closing_statements.join(" "))
@extractor.issues.reject do |issue|
# Don't extract issues from the project this project was forked from
@extractor.project.forked_from?(issue.project)
@extractor.project.forked_from?(issue.project) ||
!issue.project.autoclose_referenced_issues ||
!issue.project.issues_enabled?
end
end
end
......
......@@ -24876,6 +24876,30 @@ msgstr ""
msgid "MergeRequest|Search (e.g. *.vue) (%{modifier_key}P)"
msgstr ""
 
msgid "MergeTopics|%{sourceTopic} will be removed"
msgstr ""
msgid "MergeTopics|All assigned projects will be moved to %{targetTopic}"
msgstr ""
msgid "MergeTopics|Merge topics"
msgstr ""
msgid "MergeTopics|Merging topics will cause the following:"
msgstr ""
msgid "MergeTopics|Move all assigned projects from the source topic to the target topic and remove the source topic."
msgstr ""
msgid "MergeTopics|Source topic"
msgstr ""
msgid "MergeTopics|Target topic"
msgstr ""
msgid "MergeTopics|This action cannot be undone."
msgstr ""
msgid "Merged"
msgstr ""
 
......@@ -41075,6 +41099,15 @@ msgstr ""
msgid "Topic was successfully updated."
msgstr ""
 
msgid "TopicSelect|No matching results"
msgstr ""
msgid "TopicSelect|Search topics"
msgstr ""
msgid "TopicSelect|Select a topic"
msgstr ""
msgid "Topics"
msgstr ""
 
import { GlAvatarLabeled, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TopicSelect from '~/admin/topics/components/topic_select.vue';
const mockTopics = [
{ id: 1, name: 'topic1', title: 'Topic 1', avatarUrl: 'avatar.com/topic1.png' },
{ id: 2, name: 'GitLab', title: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' },
];
describe('TopicSelect', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
function createComponent(props = {}) {
wrapper = shallowMount(TopicSelect, {
propsData: props,
data() {
return {
topics: mockTopics,
search: '',
};
},
mocks: {
$apollo: {
queries: {
topics: { loading: false },
},
},
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('mounts', () => {
createComponent();
expect(wrapper.exists()).toBe(true);
});
it('`selectedTopic` prop defaults to `{}`', () => {
createComponent();
expect(wrapper.props('selectedTopic')).toEqual({});
});
it('`labelText` prop defaults to `null`', () => {
createComponent();
expect(wrapper.props('labelText')).toBe(null);
});
it('renders default text if no selected topic', () => {
createComponent();
expect(findDropdown().props('text')).toBe('Select a topic');
});
it('renders selected topic', () => {
createComponent({ selectedTopic: mockTopics[0] });
expect(findDropdown().props('text')).toBe('topic1');
});
it('renders label', () => {
createComponent({ labelText: 'my label' });
expect(wrapper.find('label').text()).toBe('my label');
});
it('renders dropdown items', () => {
createComponent();
const dropdownItems = findAllDropdownItems();
expect(dropdownItems.at(0).find(GlAvatarLabeled).props('label')).toBe('Topic 1');
expect(dropdownItems.at(1).find(GlAvatarLabeled).props('label')).toBe('GitLab');
});
it('emits `click` event when topic selected', () => {
createComponent();
findAllDropdownItems().at(0).vm.$emit('click');
expect(wrapper.emitted('click')).toEqual([[mockTopics[0]]]);
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment