Commit 4d7e489e authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 0530cc86 5e900d3e
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
import PackageTypeToken from './tokens/package_type_token.vue';
import { ASCENDING_ODER, DESCENDING_ORDER } from '../constants';
import getTableHeaders from '../utils';
export default {
name: 'PackageSort',
components: {
GlSorting,
GlSortingItem,
GlFilteredSearch,
},
computed: {
...mapState({
isGroupPage: (state) => state.config.isGroupPage,
orderBy: (state) => state.sorting.orderBy,
sort: (state) => state.sorting.sort,
filter: (state) => state.filter,
}),
internalFilter: {
get() {
return this.filter;
},
set(value) {
this.setFilter(value);
},
},
sortText() {
const field = this.sortableFields.find((s) => s.orderBy === this.orderBy);
return field ? field.label : '';
......@@ -26,9 +37,21 @@ export default {
isSortAscending() {
return this.sort === ASCENDING_ODER;
},
tokens() {
return [
{
type: 'type',
icon: 'package',
title: s__('PackageRegistry|Type'),
unique: true,
token: PackageTypeToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
},
];
},
},
methods: {
...mapActions(['setSorting']),
...mapActions(['setSorting', 'setFilter']),
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER;
this.setSorting({ sort });
......@@ -38,23 +61,37 @@ export default {
this.setSorting({ orderBy: item });
this.$emit('sort:changed');
},
clearSearch() {
this.setFilter([]);
this.$emit('filter:changed');
},
},
};
</script>
<template>
<gl-sorting
:text="sortText"
:is-ascending="isSortAscending"
@sortDirectionChange="onDirectionChange"
>
<gl-sorting-item
v-for="item in sortableFields"
ref="packageListSortItem"
:key="item.orderBy"
@click="onSortItemClick(item.orderBy)"
<div class="gl-display-flex gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100">
<gl-filtered-search
v-model="internalFilter"
class="gl-mr-4 gl-flex-fill-1"
:placeholder="__('Filter results')"
:available-tokens="tokens"
@submit="$emit('filter:changed')"
@clear="clearSearch"
/>
<gl-sorting
:text="sortText"
:is-ascending="isSortAscending"
@sortDirectionChange="onDirectionChange"
>
{{ item.label }}
</gl-sorting-item>
</gl-sorting>
<gl-sorting-item
v-for="item in sortableFields"
ref="packageListSortItem"
:key="item.orderBy"
@click="onSortItemClick(item.orderBy)"
>
{{ item.label }}
</gl-sorting-item>
</gl-sorting>
</div>
</template>
<script>
import { GlSearchBoxByClick } from '@gitlab/ui';
import { mapActions } from 'vuex';
export default {
components: {
GlSearchBoxByClick,
},
methods: {
...mapActions(['setFilter']),
},
};
</script>
<template>
<gl-search-box-by-click
:placeholder="s__('PackageRegistry|Filter by name')"
@submit="$emit('filter')"
@input="setFilter"
/>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import { GlEmptyState, GlTab, GlTabs, GlLink, GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import PackageFilter from './packages_filter.vue';
import PackageList from './packages_list.vue';
import PackageSort from './packages_sort.vue';
import { PACKAGE_REGISTRY_TABS, DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
import PackageTitle from './package_title.vue';
import PackageSearch from './package_search.vue';
export default {
components: {
GlEmptyState,
GlTab,
GlTabs,
GlLink,
GlSprintf,
PackageFilter,
PackageList,
PackageSort,
PackageTitle,
PackageSearch,
},
computed: {
...mapState({
emptyListIllustration: (state) => state.config.emptyListIllustration,
emptyListHelpUrl: (state) => state.config.emptyListHelpUrl,
filterQuery: (state) => state.filterQuery,
filter: (state) => state.filter,
selectedType: (state) => state.selectedType,
packageHelpUrl: (state) => state.config.packageHelpUrl,
packagesCount: (state) => state.pagination?.total,
}),
tabsToRender() {
return PACKAGE_REGISTRY_TABS;
emptySearch() {
return (
this.filter.filter((f) => f.type !== 'filtered-search-term' || f.value?.data).length === 0
);
},
emptyStateTitle() {
return this.emptySearch
? s__('PackageRegistry|There are no packages yet')
: s__('PackageRegistry|Sorry, your filter produced no results');
},
},
mounted() {
......@@ -48,27 +52,6 @@ export default {
onPackageDeleteRequest(item) {
return this.requestDeletePackage(item);
},
tabChanged(index) {
const selected = PACKAGE_REGISTRY_TABS[index];
if (selected !== this.selectedType) {
this.setSelectedType(selected);
this.requestPackagesList();
}
},
emptyStateTitle({ title, type }) {
if (this.filterQuery) {
return s__('PackageRegistry|Sorry, your filter produced no results');
}
if (type) {
return sprintf(s__('PackageRegistry|There are no %{packageType} packages yet'), {
packageType: title,
});
}
return s__('PackageRegistry|There are no packages yet');
},
checkDeleteAlert() {
const urlParams = new URLSearchParams(window.location.search);
const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
......@@ -92,33 +75,21 @@ export default {
<template>
<div>
<package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" />
<package-search @sort:changed="requestPackagesList" @filter:changed="requestPackagesList" />
<gl-tabs @input="tabChanged">
<template #tabs-end>
<div
class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end"
>
<package-filter class="gl-mr-2" @filter="requestPackagesList" />
<package-sort @sort:changed="requestPackagesList" />
</div>
</template>
<gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title">
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state>
<gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration">
<template #description>
<gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="$options.i18n.noResults">
<template #noPackagesLink="{ content }">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
<package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
<template #empty-state>
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
<template #description>
<gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" />
<gl-sprintf v-else :message="$options.i18n.noResults">
<template #noPackagesLink="{ content }">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
</template>
</gl-empty-state>
</gl-sprintf>
</template>
</package-list>
</gl-tab>
</gl-tabs>
</gl-empty-state>
</template>
</package-list>
</div>
</template>
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { PACKAGE_TYPES } from '../../constants';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
},
PACKAGE_TYPES,
};
</script>
<template>
<gl-filtered-search-token v-bind="{ ...$attrs }" v-on="$listeners">
<template #suggestions>
<gl-filtered-search-suggestion
v-for="(type, index) in $options.PACKAGE_TYPES"
:key="index"
:value="type.type"
>
{{ type.title }}
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
......@@ -55,11 +55,7 @@ export const SORT_FIELDS = [
},
];
export const PACKAGE_REGISTRY_TABS = [
{
title: __('All'),
type: null,
},
export const PACKAGE_TYPES = [
{
title: s__('PackageRegistry|Composer'),
type: PackageType.COMPOSER,
......
......@@ -15,7 +15,6 @@ import { getNewPaginationPage } from '../utils';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
export const setLoading = ({ commit }, data) => commit(types.SET_MAIN_LOADING, data);
export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data);
export const setSelectedType = ({ commit }, data) => commit(types.SET_SELECTED_TYPE, data);
export const setFilter = ({ commit }, data) => commit(types.SET_FILTER, data);
export const receivePackagesListSuccess = ({ commit }, { data, headers }) => {
......@@ -29,9 +28,9 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => {
const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params;
const { sort, orderBy } = state.sorting;
const type = state.selectedType?.type?.toLowerCase();
const nameFilter = state.filterQuery?.toLowerCase();
const packageFilters = { package_type: type, package_name: nameFilter };
const type = state.filter.find((f) => f.type === 'type');
const name = state.filter.find((f) => f.type === 'filtered-search-term');
const packageFilters = { package_type: type?.value?.data, package_name: name?.value?.data };
const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages';
......
......@@ -4,5 +4,4 @@ export const SET_PACKAGE_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
export const SET_SORTING = 'SET_SORTING';
export const SET_SELECTED_TYPE = 'SET_SELECTED_TYPE';
export const SET_FILTER = 'SET_FILTER';
......@@ -28,11 +28,7 @@ export default {
state.sorting = { ...state.sorting, ...sorting };
},
[types.SET_SELECTED_TYPE](state, type) {
state.selectedType = type;
},
[types.SET_FILTER](state, query) {
state.filterQuery = query;
[types.SET_FILTER](state, filter) {
state.filter = filter;
},
};
import { PACKAGE_REGISTRY_TABS } from '../constants';
export default () => ({
/**
* Determine if the component is loading data from the API
......@@ -49,9 +47,8 @@ export default () => ({
/**
* The search query that is used to filter packages by name
*/
filterQuery: '',
filter: [],
/**
* The selected TAB of the package types tabs
*/
selectedType: PACKAGE_REGISTRY_TABS[0],
});
# frozen_string_literal: true
module MergeRequests
# OldestPerCommitFinder is used to retrieve the oldest merge requests for
# every given commit, grouped per commit SHA.
#
# This finder is useful when you need to efficiently retrieve the first/oldest
# merge requests for multiple commits, and you want to do so in batches;
# instead of running a query for every commit.
class OldestPerCommitFinder
def initialize(project)
@project = project
end
# Returns a Hash that maps a commit ID to the oldest merge request that
# introduced that commit.
def execute(commits)
id_rows = MergeRequestDiffCommit
.oldest_merge_request_id_per_commit(@project.id, commits.map(&:id))
mrs = MergeRequest
.preload_target_project
.id_in(id_rows.map { |r| r[:merge_request_id] })
.index_by(&:id)
id_rows.each_with_object({}) do |row, hash|
if (mr = mrs[row[:merge_request_id]])
hash[row[:sha]] = mr
end
end
end
end
end
......@@ -35,4 +35,23 @@ class MergeRequestDiffCommit < ApplicationRecord
Gitlab::Database.bulk_insert(self.table_name, rows) # rubocop:disable Gitlab/BulkInsert
end
def self.oldest_merge_request_id_per_commit(project_id, shas)
# This method is defined here and not on MergeRequest, otherwise the SHA
# values used in the WHERE below won't be encoded correctly.
select(['merge_request_diff_commits.sha AS sha', 'min(merge_requests.id) AS merge_request_id'])
.joins(:merge_request_diff)
.joins(
'INNER JOIN merge_requests ' \
'ON merge_requests.latest_merge_request_diff_id = merge_request_diffs.id'
)
.where(sha: shas)
.where(
merge_requests: {
target_project_id: project_id,
state_id: MergeRequest.available_states[:merged]
}
)
.group(:sha)
end
end
# frozen_string_literal: true
module Repositories
# A service class for generating a changelog section.
class ChangelogService
DEFAULT_TRAILER = 'Changelog'
DEFAULT_FILE = 'CHANGELOG.md'
# The `project` specifies the `Project` to generate the changelog section
# for.
#
# The `user` argument specifies a `User` to use for committing the changes
# to the Git repository.
#
# The `version` arguments must be a version `String` using semantic
# versioning as the format.
#
# The arguments `from` and `to` must specify a Git ref or SHA to use for
# fetching the commits to include in the changelog. The SHA/ref set in the
# `from` argument isn't included in the list.
#
# The `date` argument specifies the date of the release, and defaults to the
# current time/date.
#
# The `branch` argument specifies the branch to commit the changes to. The
# branch must already exist.
#
# The `trailer` argument is the Git trailer to use for determining what
# commits to include in the changelog.
#
# The `file` arguments specifies the name/path of the file to commit the
# changes to. If the file doesn't exist, it's created automatically.
#
# The `message` argument specifies the commit message to use when committing
# the changelog changes.
#
# rubocop: disable Metrics/ParameterLists
def initialize(
project,
user,
version:,
from:,
to:,
date: DateTime.now,
branch: project.default_branch_or_master,
trailer: DEFAULT_TRAILER,
file: DEFAULT_FILE,
message: "Add changelog for version #{version}"
)
@project = project
@user = user
@version = version
@from = from
@to = to
@date = date
@branch = branch
@trailer = trailer
@file = file
@message = message
end
# rubocop: enable Metrics/ParameterLists
def execute
# For every entry we want to only include the merge request that
# originally introduced the commit, which is the oldest merge request that
# contains the commit. We fetch there merge requests in batches, reducing
# the number of SQL queries needed to get this data.
mrs_finder = MergeRequests::OldestPerCommitFinder.new(@project)
config = Gitlab::Changelog::Config.from_git(@project)
release = Gitlab::Changelog::Release
.new(version: @version, date: @date, config: config)
commits =
CommitsWithTrailerFinder.new(project: @project, from: @from, to: @to)
commits.each_page(@trailer) do |page|
mrs = mrs_finder.execute(page)
# Preload the authors. This ensures we only need a single SQL query per
# batch of commits, instead of needing a query for every commit.
page.each(&:lazy_author)
page.each do |commit|
release.add_entry(
title: commit.title,
commit: commit,
category: commit.trailers.fetch(@trailer),
author: commit.author,
merge_request: mrs[commit.id]
)
end
end
Gitlab::Changelog::Committer
.new(@project, @user)
.commit(release: release, file: @file, branch: @branch, message: @message)
end
end
end
---
title: Redesign the search UI for the package list
merge_request: 52575
author:
type: changed
---
title: Add API for generating Markdown changelogs
merge_request: 52116
author:
type: added
---
name: changelog_api
introduced_by_url: '13.9'
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/300043
milestone: '13.9'
type: development
group: group::source code
default_enabled: false
# frozen_string_literal: true
class AddOldestMergeRequestsIndex < ActiveRecord::Migration[6.0]
include Gitlab::Database::SchemaHelpers
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
INDEX = 'index_on_merge_requests_for_latest_diffs'
def up
return if index_exists_by_name?('merge_requests', INDEX)
execute "CREATE INDEX CONCURRENTLY #{INDEX} ON merge_requests " \
'USING btree (target_project_id) INCLUDE (id, latest_merge_request_diff_id)'
create_comment(
'INDEX',
INDEX,
'Index used to efficiently obtain the oldest merge request for a commit SHA'
)
end
def down
return unless index_exists_by_name?('merge_requests', INDEX)
execute "DROP INDEX CONCURRENTLY #{INDEX}"
end
end
c173ba86340efe39977f1b319d1ebcead634e3bfe819a30e230fb4f81766f28a
\ No newline at end of file
......@@ -22508,6 +22508,10 @@ CREATE UNIQUE INDEX index_on_instance_statistics_recorded_at_and_identifier ON a
CREATE INDEX index_on_label_links_all_columns ON label_links USING btree (target_id, label_id, target_type);
CREATE INDEX index_on_merge_requests_for_latest_diffs ON merge_requests USING btree (target_project_id) INCLUDE (id, latest_merge_request_diff_id);
COMMENT ON INDEX index_on_merge_requests_for_latest_diffs IS 'Index used to efficiently obtain the oldest merge request for a commit SHA';
CREATE INDEX index_on_namespaces_lower_name ON namespaces USING btree (lower((name)::text));
CREATE INDEX index_on_namespaces_lower_path ON namespaces USING btree (lower((path)::text));
......
This diff is collapsed.
......@@ -109,6 +109,48 @@ Refer to [`override.rb`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gi
Because only a class or prepended module can actually override a method.
Including or extending a module into another cannot override anything.
### Interactions with `ActiveSupport::Concern`, `prepend`, and `class_methods`
When you use `ActiveSupport::Concern` that includes class methods, you do not
get expected results because `ActiveSupport::Concern` doesn't work like a
regular Ruby module.
Since we already have `Prependable` as a patch for `ActiveSupport::Concern`
to enable `prepend`, it has consequences with how it would interact with
`override` and `class_methods`. We add a workaround directly into
`Prependable` to resolve the problem, by `extend`ing `ClassMethods` into the
defining module.
This allows us to use `override` to verify `class_methods` used in the
context mentioned above. This workaround only applies when we run the
verification, not when running the application itself.
Here are example code blocks that demonstrate the effect of this workaround:
following codes:
```ruby
module Base
extend ActiveSupport::Concern
class_methods do
def f
end
end
end
module Derived
include Base
end
# Without the workaround
Base.f # => NoMethodError
Derived.f # => nil
# With the workaround
Base.f # => nil
Derived.f # => nil
```
## `StrongMemoize`
Refer to [`strong_memoize.rb`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/utils/strong_memoize.rb):
......
......@@ -555,7 +555,7 @@ username, you can create a new group and transfer projects to it.
You can change settings that are specific to repositories in your group.
#### Custom initial branch name **(FREE SELF)**
#### Custom initial branch name **(FREE)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43290) in GitLab 13.6.
......
......@@ -6,18 +6,13 @@ import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlFilteredSearchToken,
} from '@gitlab/ui';
import { __ } from '~/locale';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { visitUrl, mergeUrlParams, updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import EpicsFilteredSearchMixin from '../mixins/filtered_search_mixin';
import { EPICS_STATES, PRESET_TYPES } from '../constants';
......@@ -54,16 +49,9 @@ export default {
GlDropdownDivider,
FilteredSearchBar,
},
mixins: [EpicsFilteredSearchMixin],
computed: {
...mapState([
'presetType',
'epicsState',
'sortedBy',
'fullPath',
'groupLabelsEndpoint',
'groupMilestonesEndpoint',
'filterParams',
]),
...mapState(['presetType', 'epicsState', 'sortedBy', 'filterParams']),
selectedEpicStateTitle() {
if (this.epicsState === EPICS_STATES.ALL) {
return __('All epics');
......@@ -73,213 +61,37 @@ export default {
return __('Closed epics');
},
},
watch: {
urlParams: {
deep: true,
immediate: true,
handler(params) {
if (Object.keys(params).length) {
updateHistory({
url: setUrlParams(params, window.location.href, true),
title: document.title,
replace: true,
});
}
},
},
},
methods: {
...mapActions(['setEpicsState', 'setFilterParams', 'setSortedBy', 'fetchEpics']),
getFilteredSearchTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchAuthors: Api.users.bind(Api),
},
{
type: 'label_name',
icon: 'labels',
title: __('Label'),
unique: false,
symbol: '~',
token: LabelToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchLabels: (search = '') => {
const params = {
only_group_labels: true,
include_ancestor_groups: true,
include_descendant_groups: true,
};
if (search) {
params.search = search;
}
return axios.get(this.groupLabelsEndpoint, {
params,
});
},
},
{
type: 'milestone_title',
icon: 'clock',
title: __('Milestone'),
unique: true,
symbol: '%',
token: MilestoneToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchMilestones: (search = '') => {
return axios.get(this.groupMilestonesEndpoint).then(({ data }) => {
// TODO: Remove below condition check once either of the following is supported.
// a) Milestones Private API supports search param.
// b) Milestones Public API supports including child projects' milestones.
if (search) {
return {
data: data.filter((m) => m.title.toLowerCase().includes(search.toLowerCase())),
};
}
return { data };
});
},
},
{
type: 'confidential',
icon: 'eye-slash',
title: __('Confidential'),
unique: true,
token: GlFilteredSearchToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
options: [
{ icon: 'eye-slash', value: true, title: __('Yes') },
{ icon: 'eye', value: false, title: __('No') },
],
},
];
},
getFilteredSearchValue() {
const { authorUsername, labelName, milestoneTitle, confidential, search } =
this.filterParams || {};
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername },
});
}
if (milestoneTitle) {
filteredSearchValue.push({
type: 'milestone_title',
value: { data: milestoneTitle },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label },
})),
);
}
if (confidential !== undefined) {
filteredSearchValue.push({
type: 'confidential',
value: { data: confidential },
});
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
updateUrl() {
const queryParams = urlParamsToObject(window.location.search);
const { authorUsername, labelName, milestoneTitle, confidential, search } =
this.filterParams || {};
queryParams.state = this.epicsState;
queryParams.sort = this.sortedBy;
if (authorUsername) {
queryParams.author_username = authorUsername;
} else {
delete queryParams.author_username;
}
if (milestoneTitle) {
queryParams.milestone_title = milestoneTitle;
} else {
delete queryParams.milestone_title;
}
delete queryParams.label_name;
if (labelName?.length) {
queryParams['label_name[]'] = labelName;
}
if (confidential !== undefined) {
queryParams.confidential = confidential;
} else {
delete queryParams.confidential;
}
if (search) {
queryParams.search = search;
} else {
delete queryParams.search;
}
// We want to replace the history state so that back button
// correctly reloads the page with previous URL.
updateHistory({
url: setUrlParams(queryParams, window.location.href, true),
title: document.title,
replace: true,
});
},
handleRoadmapLayoutChange(presetType) {
visitUrl(mergeUrlParams({ layout: presetType }, window.location.href));
},
handleEpicStateChange(epicsState) {
this.setEpicsState(epicsState);
this.fetchEpics();
this.updateUrl();
},
handleFilterEpics(filters) {
const filterParams = filters.length ? {} : null;
const labels = [];
filters.forEach((filter) => {
if (typeof filter === 'object') {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'milestone_title':
filterParams.milestoneTitle = filter.value.data;
break;
case 'label_name':
labels.push(filter.value.data);
break;
case 'confidential':
filterParams.confidential = filter.value.data;
break;
default:
break;
}
} else {
filterParams.search = filter;
}
});
if (labels.length) {
filterParams.labelName = labels;
}
this.setFilterParams(filterParams);
this.setFilterParams(this.getFilterParams(filters));
this.fetchEpics();
this.updateUrl();
},
handleSortEpics(sortedBy) {
this.setSortedBy(sortedBy);
this.fetchEpics();
this.updateUrl();
},
},
};
......@@ -325,7 +137,7 @@ export default {
>
</gl-dropdown>
<filtered-search-bar
:namespace="fullPath"
:namespace="groupFullPath"
:search-input-placeholder="__('Search or filter results...')"
:tokens="getFilteredSearchTokens()"
:sort-options="$options.availableSortOptions"
......
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
/*
Update the counterparts in roadmap.scss when making changes.
......@@ -67,6 +67,11 @@ export const EPIC_LEVEL_MARGIN = {
4: 'ml-10',
};
export const FilterTokenOperators = [
{ value: '=', description: __('is'), default: 'true' },
// { value: '!=', description: __('is not') },
];
export const EPICS_LIMIT_DISMISSED_COOKIE_NAME = 'epics_limit_warning_dismissed';
export const EPICS_LIMIT_DISMISSED_COOKIE_TIMEOUT = 365;
import { GlFilteredSearchToken } from '@gitlab/ui';
import { __ } from '~/locale';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import { FilterTokenOperators } from '../constants';
export default {
inject: ['groupFullPath', 'groupMilestonesPath'],
computed: {
urlParams() {
const { search, authorUsername, labelName, milestoneTitle, confidential } =
this.filterParams || {};
return {
state: this.currentState || this.epicsState,
page: this.currentPage,
sort: this.sortedBy,
prev: this.prevPageCursor || undefined,
next: this.nextPageCursor || undefined,
author_username: authorUsername,
'label_name[]': labelName,
milestone_title: milestoneTitle,
confidential,
search,
};
},
},
methods: {
getFilteredSearchTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: FilterTokenOperators,
fetchAuthors: Api.users.bind(Api),
},
{
type: 'label_name',
icon: 'labels',
title: __('Label'),
unique: false,
symbol: '~',
token: LabelToken,
operators: FilterTokenOperators,
fetchLabels: (search = '') => {
const params = {
only_group_labels: true,
include_ancestor_groups: true,
include_descendant_groups: true,
};
if (search) {
params.search = search;
}
return Api.groupLabels(this.groupFullPath, {
params,
});
},
},
{
type: 'milestone_title',
icon: 'clock',
title: __('Milestone'),
unique: true,
symbol: '%',
token: MilestoneToken,
operators: FilterTokenOperators,
fetchMilestones: (search = '') => {
return axios.get(this.groupMilestonesPath).then(({ data }) => {
// TODO: Remove below condition check once either of the following is supported.
// a) Milestones Private API supports search param.
// b) Milestones Public API supports including child projects' milestones.
if (search) {
return {
data: data.filter((m) => m.title.toLowerCase().includes(search.toLowerCase())),
};
}
return { data };
});
},
},
{
type: 'confidential',
icon: 'eye-slash',
title: __('Confidential'),
unique: true,
token: GlFilteredSearchToken,
operators: FilterTokenOperators,
options: [
{ icon: 'eye-slash', value: true, title: __('Yes') },
{ icon: 'eye', value: false, title: __('No') },
],
},
];
},
getFilteredSearchValue() {
const { authorUsername, labelName, milestoneTitle, confidential, search } =
this.filterParams || {};
const filteredSearchValue = [];
if (authorUsername) {
filteredSearchValue.push({
type: 'author_username',
value: { data: authorUsername },
});
}
if (labelName?.length) {
filteredSearchValue.push(
...labelName.map((label) => ({
type: 'label_name',
value: { data: label },
})),
);
}
if (milestoneTitle) {
filteredSearchValue.push({
type: 'milestone_title',
value: { data: milestoneTitle },
});
}
if (confidential !== undefined) {
filteredSearchValue.push({
type: 'confidential',
value: { data: confidential },
});
}
if (search) {
filteredSearchValue.push(search);
}
return filteredSearchValue;
},
getFilterParams(filters = []) {
const filterParams = {};
const labels = [];
const plainText = [];
filters.forEach((filter) => {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'label_name':
labels.push(filter.value.data);
break;
case 'milestone_title':
filterParams.milestoneTitle = filter.value.data;
break;
case 'confidential':
filterParams.confidential = filter.value.data;
break;
case 'filtered-search-term':
if (filter.value.data) plainText.push(filter.value.data);
break;
default:
break;
}
});
if (labels.length) {
filterParams.labelName = labels;
}
if (plainText.length) {
filterParams.search = plainText.join(' ');
}
return filterParams;
},
},
};
......@@ -57,6 +57,9 @@ export default () => {
newEpicPath: dataset.newEpicPath,
listEpicsPath: dataset.listEpicsPath,
epicsDocsPath: dataset.epicsDocsPath,
groupFullPath: dataset.fullPath,
groupLabelsPath: dataset.groupLabelsEndpoint,
groupMilestonesPath: dataset.groupMilestonesEndpoint,
};
},
data() {
......@@ -92,8 +95,6 @@ export default () => {
basePath: dataset.epicsPath,
fullPath: dataset.fullPath,
epicIid: dataset.iid,
groupLabelsEndpoint: dataset.groupLabelsEndpoint,
groupMilestonesEndpoint: dataset.groupMilestonesEndpoint,
epicsState: dataset.epicsState,
sortedBy: dataset.sortedBy,
filterParams,
......@@ -112,8 +113,6 @@ export default () => {
timeframe: this.timeframe,
basePath: this.basePath,
filterParams: this.filterParams,
groupLabelsEndpoint: this.groupLabelsEndpoint,
groupMilestonesEndpoint: this.groupMilestonesEndpoint,
defaultInnerHeight: this.defaultInnerHeight,
isChildEpics: this.isChildEpics,
hasFiltersApplied: this.hasFiltersApplied,
......
......@@ -3,8 +3,6 @@ export default () => ({
basePath: '',
epicsState: '',
filterParams: null,
groupLabelsEndpoint: '',
groupMilestonesEndpoint: '',
// Data
epicIid: '',
......
<script>
import {
GlButton,
GlDeprecatedDropdownItem,
GlDeprecatedDropdown,
GlDeprecatedDropdownDivider,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { healthStatusTextMap } from '../../constants';
import { GlDropdownItem, GlDropdown, GlDropdownDivider } from '@gitlab/ui';
import { healthStatusTextMap, I18N_DROPDOWN } from '../../constants';
export default {
components: {
GlButton,
GlDeprecatedDropdown,
GlDeprecatedDropdownItem,
GlDeprecatedDropdownDivider,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
},
props: {
isEditable: {
......@@ -44,23 +37,16 @@ export default {
},
computed: {
statusText() {
return this.status ? healthStatusTextMap[this.status] : s__('Sidebar|None');
return this.status ? healthStatusTextMap[this.status] : this.$options.i18n.noneText;
},
dropdownText() {
if (this.status === null) {
return s__('No status');
return this.$options.i18n.noStatusText;
}
return this.status ? healthStatusTextMap[this.status] : s__('Select health status');
},
tooltipText() {
let tooltipText = s__('Sidebar|Health status');
if (this.status) {
tooltipText += `: ${this.statusText}`;
}
return tooltipText;
return this.status
? healthStatusTextMap[this.status]
: this.$options.i18n.selectPlaceholderText;
},
},
watch: {
......@@ -81,56 +67,40 @@ export default {
return this.status === status;
},
},
i18n: I18N_DROPDOWN,
};
</script>
<template>
<div class="dropdown dropdown-menu-selectable">
<gl-deprecated-dropdown
<div class="dropdown">
<gl-dropdown
ref="dropdown"
class="w-100"
class="gl-w-full"
:header-text="$options.i18n.dropdownHeaderText"
:text="dropdownText"
@keydown.esc.native="hideDropdown"
@hide="hideDropdown"
>
<div class="dropdown-title gl-display-flex">
<span class="health-title gl-ml-auto">{{ s__('Sidebar|Assign health status') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-gray-200!"
icon="close"
@click="hideDropdown"
/>
</div>
<div class="dropdown-content dropdown-body">
<gl-deprecated-dropdown-item @click="handleDropdownClick(null)">
<gl-button
variant="link"
class="dropdown-item health-dropdown-item"
:class="{ 'is-active': isSelected(null) }"
>
{{ s__('Sidebar|No status') }}
</gl-button>
</gl-deprecated-dropdown-item>
<gl-dropdown-item
:is-check-item="true"
:is-checked="isSelected(null)"
@click="handleDropdownClick(null)"
>
{{ $options.i18n.noStatusText }}
</gl-dropdown-item>
<gl-deprecated-dropdown-divider class="divider health-divider" />
<gl-dropdown-divider />
<gl-deprecated-dropdown-item
v-for="option in statusOptions"
:key="option.key"
@click="handleDropdownClick(option.key)"
>
<gl-button
variant="link"
class="dropdown-item health-dropdown-item"
:class="{ 'is-active': isSelected(option.key) }"
>
{{ option.value }}
</gl-button>
</gl-deprecated-dropdown-item>
</div>
</gl-deprecated-dropdown>
<gl-dropdown-item
v-for="option in statusOptions"
:key="option.key"
:is-check-item="true"
:is-checked="isSelected(option.key)"
data-testid="health-status-dropdown-item"
@click="handleDropdownClick(option.key)"
>
{{ option.value }}
</gl-dropdown-item>
</gl-dropdown>
</div>
</template>
......@@ -10,7 +10,7 @@ import {
} from '@gitlab/ui';
import Tracking from '~/tracking';
import { s__ } from '~/locale';
import { healthStatusTextMap } from '../../constants';
import { healthStatusTextMap, I18N_DROPDOWN } from '../../constants';
export default {
directives: {
......@@ -62,10 +62,12 @@ export default {
return this.isEditable && this.status;
},
statusText() {
return this.status ? healthStatusTextMap[this.status] : s__('Sidebar|None');
return this.status ? healthStatusTextMap[this.status] : this.$options.i18n.noneText;
},
dropdownText() {
return this.status ? healthStatusTextMap[this.status] : s__('Select health status');
return this.status
? healthStatusTextMap[this.status]
: this.$options.i18n.selectPlaceholderText;
},
statusTooltip() {
let tooltipText = s__('Sidebar|Health status');
......@@ -137,6 +139,7 @@ export default {
}
},
},
i18n: I18N_DROPDOWN,
};
</script>
......@@ -173,61 +176,43 @@ export default {
<div
data-testid="dropdownWrapper"
class="dropdown dropdown-menu-selectable"
class="dropdown"
:class="{ show: isDropdownShowing, 'gl-display-none': !isDropdownShowing }"
>
<gl-dropdown
ref="dropdown"
class="gl-w-full"
:header-text="$options.i18n.dropdownHeaderText"
:text="dropdownText"
@keydown.esc.native="hideDropdown"
@hide="hideDropdown"
>
<div class="dropdown-title gl-display-flex">
<span class="health-title gl-ml-auto">{{ s__('Sidebar|Assign health status') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
class="dropdown-title-button dropdown-menu-close gl-ml-auto gl-text-gray-200!"
icon="close"
@click="hideDropdown"
/>
</div>
<div class="dropdown-content dropdown-body">
<gl-dropdown-item @click="handleDropdownClick(null)">
<gl-button
variant="link"
class="dropdown-item health-dropdown-item gl-px-8!"
:class="{ 'is-active': isSelected(null) }"
>
{{ s__('Sidebar|No status') }}
</gl-button>
</gl-dropdown-item>
<gl-dropdown-item
:is-check-item="true"
:is-checked="isSelected(null)"
@click="handleDropdownClick(null)"
>
{{ $options.i18n.noStatusText }}
</gl-dropdown-item>
<gl-dropdown-divider class="divider health-divider" />
<gl-dropdown-divider />
<gl-dropdown-item
v-for="option in statusOptions"
:key="option.key"
@click="handleDropdownClick(option.key)"
>
<gl-button
variant="link"
class="dropdown-item health-dropdown-item gl-px-8!"
:class="{ 'is-active': isSelected(option.key) }"
>
{{ option.value }}
</gl-button>
</gl-dropdown-item>
</div>
<gl-dropdown-item
v-for="option in statusOptions"
:key="option.key"
:is-check-item="true"
:is-checked="isSelected(option.key)"
@click="handleDropdownClick(option.key)"
>
{{ option.value }}
</gl-dropdown-item>
</gl-dropdown>
</div>
<gl-loading-icon v-if="isFetching" :inline="true" />
<p v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !status }">
<span v-if="status" class="text-plain gl-font-weight-bold">{{ statusText }}</span>
<span v-else>{{ __('None') }}</span>
<span v-else>{{ $options.i18n.noneText }}</span>
</p>
</div>
</div>
......
import { __ } from '~/locale';
import { s__, __ } from '~/locale';
export const healthStatus = {
ON_TRACK: 'onTrack',
......@@ -29,3 +29,10 @@ export const healthStatusForRestApi = {
};
export const MAX_DISPLAY_WEIGHT = 99999;
export const I18N_DROPDOWN = {
dropdownHeaderText: s__('Sidebar|Assign health status'),
noStatusText: s__('Sidebar|No status'),
noneText: s__('Sidebar|None'),
selectPlaceholderText: s__('Select health status'),
};
......@@ -6,7 +6,9 @@ module EE
class_methods do
include ::Gitlab::Utils::StrongMemoize
extend ::Gitlab::Utils::Override
override :available_features_for_issue_types
def available_features_for_issue_types
strong_memoize(:available_features_for_issue_types) do
super.merge(epics: %w(issue), sla: %w(incident))
......
......@@ -5,7 +5,7 @@
= _("Start your Free Ultimate Trial")
%p.center
= _('Your Gitlab Ultimate trial will last 30 days after which point you can keep your free Gitlab account forever. We just need some additional information to activate your trial.')
= _('Your GitLab Ultimate trial will last 30 days after which point you can keep your free GitLab account forever. We just need some additional information to activate your trial.')
= render 'errors'
......
---
title: Migrate health status dropdown to use gitlab ui
merge_request: 52273
author:
type: changed
---
title: Forbid git pushes to group wikis when repo is read only
merge_request: 52801
author:
type: added
......@@ -9,7 +9,8 @@ module EE
ERROR_MESSAGES = {
write_to_group_wiki: "You are not allowed to write to this group's wiki.",
group_not_found: 'The group you were looking for could not be found.',
no_group_repo: 'A repository for this group wiki does not exist yet.'
no_group_repo: 'A repository for this group wiki does not exist yet.',
repo_read_only: 'The repository is temporarily read-only. Please try again later.'
}.freeze
override :group
......@@ -26,9 +27,13 @@ module EE
override :check_push_access!
def check_push_access!
return check_change_access! if group?
return super unless group?
super
if group.repository_read_only?
raise ::Gitlab::GitAccess::ForbiddenError, ERROR_MESSAGES[:repo_read_only]
end
check_change_access!
end
override :write_to_wiki_message
......
......@@ -113,7 +113,7 @@ RSpec.describe 'Issues > Health status bulk assignment' do
page.within('.issues-bulk-update') do
click_button 'Select health status'
items.map do |item|
find('.gl-button-text', text: item).click
find('[data-testid="health-status-dropdown-item"]', text: item).click
end
end
end
......
......@@ -46,6 +46,8 @@ describe('RoadmapApp', () => {
},
provide: {
glFeatures: { asyncFiltering: true },
groupFullPath: 'gitlab-org',
groupMilestonesPath: '/groups/gitlab-org/-/milestones.json',
},
store,
});
......
......@@ -26,8 +26,8 @@ const createComponent = ({
presetType = PRESET_TYPES.MONTHS,
epicsState = EPICS_STATES.ALL,
sortedBy = mockSortedBy,
fullPath = 'gitlab-org',
groupLabelsEndpoint = '/groups/gitlab-org/-/labels.json',
groupFullPath = 'gitlab-org',
groupMilestonesPath = '/groups/gitlab-org/-/milestones.json',
timeframe = getTimeframeForMonthsView(mockTimeframeInitialDate),
filterParams = {},
} = {}) => {
......@@ -40,8 +40,6 @@ const createComponent = ({
presetType,
epicsState,
sortedBy,
fullPath,
groupLabelsEndpoint,
filterParams,
timeframe,
});
......@@ -49,6 +47,10 @@ const createComponent = ({
return shallowMount(RoadmapFilters, {
localVue,
store,
provide: {
groupFullPath,
groupMilestonesPath,
},
});
};
......@@ -81,8 +83,8 @@ describe('RoadmapFilters', () => {
});
});
describe('methods', () => {
describe('updateUrl', () => {
describe('watch', () => {
describe('urlParams', () => {
it('updates window URL based on presence of props for state, filtered search and sort criteria', async () => {
wrapper.vm.$store.dispatch('setEpicsState', EPICS_STATES.CLOSED);
wrapper.vm.$store.dispatch('setFilterParams', {
......@@ -95,10 +97,8 @@ describe('RoadmapFilters', () => {
await wrapper.vm.$nextTick();
wrapper.vm.updateUrl();
expect(global.window.location.href).toBe(
`${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&author_username=root&milestone_title=4.0&label_name%5B%5D=Bug&confidential=true`,
`${TEST_HOST}/?state=${EPICS_STATES.CLOSED}&sort=end_date_asc&author_username=root&label_name%5B%5D=Bug&milestone_title=4.0&confidential=true`,
);
});
});
......@@ -144,14 +144,14 @@ describe('RoadmapFilters', () => {
type: 'author_username',
value: { data: 'root' },
},
{
type: 'milestone_title',
value: { data: '4.0' },
},
{
type: 'label_name',
value: { data: 'Bug' },
},
{
type: 'milestone_title',
value: { data: '4.0' },
},
{
type: 'confidential',
value: { data: true },
......@@ -253,7 +253,6 @@ describe('RoadmapFilters', () => {
it('fetches filtered epics when `onFilter` event is emitted', async () => {
jest.spyOn(wrapper.vm, 'setFilterParams');
jest.spyOn(wrapper.vm, 'fetchEpics');
jest.spyOn(wrapper.vm, 'updateUrl');
await wrapper.vm.$nextTick();
......@@ -268,13 +267,11 @@ describe('RoadmapFilters', () => {
confidential: true,
});
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
it('fetches epics with updated sort order when `onSort` event is emitted', async () => {
jest.spyOn(wrapper.vm, 'setSortedBy');
jest.spyOn(wrapper.vm, 'fetchEpics');
jest.spyOn(wrapper.vm, 'updateUrl');
await wrapper.vm.$nextTick();
......@@ -284,7 +281,6 @@ describe('RoadmapFilters', () => {
expect(wrapper.vm.setSortedBy).toHaveBeenCalledWith('end_date_asc');
expect(wrapper.vm.fetchEpics).toHaveBeenCalled();
expect(wrapper.vm.updateUrl).toHaveBeenCalled();
});
});
});
......
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Status from 'ee/sidebar/components/status/status.vue';
import { healthStatus, healthStatusTextMap } from 'ee/sidebar/constants';
import { healthStatus, healthStatusTextMap, I18N_DROPDOWN } from 'ee/sidebar/constants';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
const getStatusText = (wrapper) => wrapper.find('.value .text-plain').text();
......@@ -182,7 +182,7 @@ describe('Status', () => {
});
it('shows "None"', () => {
expect(wrapper.find('.no-value').text()).toBe('None');
expect(wrapper.find('.no-value').text()).toBe(I18N_DROPDOWN.noneText);
});
it('shows "Status" in the tooltip', () => {
......@@ -258,9 +258,9 @@ describe('Status', () => {
});
it('shows text to ask the user to pick an option', () => {
const message = 'Assign health status';
expect(getDropdownElement(wrapper).find('.health-title').text()).toContain(message);
expect(getDropdownElement(wrapper).props('headerText')).toBe(
I18N_DROPDOWN.dropdownHeaderText,
);
});
it('hides form when the `edit` button is clicked', async () => {
......
......@@ -42,7 +42,16 @@ RSpec.describe Gitlab::GitAccessWiki do
end
it 'does not give access to upload wiki code' do
expect { subject }.to raise_error(Gitlab::GitAccess::ForbiddenError, "You can't push code to a read-only GitLab instance.")
expect { subject }.to raise_forbidden("You can't push code to a read-only GitLab instance.")
end
end
context 'when group is read-only' do
it 'does not allow push and allows pull access' do
allow(group).to receive(:repository_read_only?).and_return(true)
expect { push_changes(changes) }.to raise_forbidden('The repository is temporarily read-only. Please try again later.')
expect { pull_changes(changes) }.not_to raise_error
end
end
end
......
......@@ -170,6 +170,67 @@ module API
not_found!("Merge Base")
end
end
desc 'Generates a changelog section for a release' do
detail 'This feature was introduced in GitLab 13.9'
end
params do
requires :version,
type: String,
regexp: Gitlab::Regex.unbounded_semver_regex,
desc: 'The version of the release, using the semantic versioning format'
requires :from,
type: String,
desc: 'The first commit in the range of commits to use for the changelog'
requires :to,
type: String,
desc: 'The last commit in the range of commits to use for the changelog'
optional :date,
type: DateTime,
desc: 'The date and time of the release'
optional :branch,
type: String,
desc: 'The branch to commit the changelog changes to'
optional :trailer,
type: String,
desc: 'The Git trailer to use for determining if commits are to be included in the changelog',
default: ::Repositories::ChangelogService::DEFAULT_TRAILER
optional :file,
type: String,
desc: 'The file to commit the changelog changes to',
default: ::Repositories::ChangelogService::DEFAULT_FILE
optional :message,
type: String,
desc: 'The commit message to use when committing the changelog'
end
post ':id/repository/changelog' do
not_found! unless Feature.enabled?(:changelog_api, user_project)
branch = params[:branch] || user_project.default_branch_or_master
access = Gitlab::UserAccess.new(current_user, container: user_project)
unless access.can_push_to_branch?(branch)
forbidden!("You are not allowed to commit a changelog on this branch")
end
service = ::Repositories::ChangelogService.new(
user_project,
current_user,
**declared_params(include_missing: false)
)
service.execute
status(200)
rescue => ex
render_api_error!("Failed to generate the changelog: #{ex.message}", 500)
end
end
end
end
......@@ -26,7 +26,13 @@ module Gitlab
# scratch, otherwise we may end up throwing away changes. As such, all
# the logic is contained within the retry block.
Retriable.retriable(on: CommitError) do
commit = @project.commit(branch)
commit = Gitlab::Git::Commit.last_for_path(
@project.repository,
branch,
file,
literal_pathspec: true
)
content = blob_content(file, commit)
# If the release has already been added (e.g. concurrently by another
......
......@@ -37,7 +37,10 @@ module Gitlab
end
if (template = hash['template'])
config.template = Template::Compiler.new.compile(template)
# We use the full namespace here (and further down) as otherwise Rails
# may use the wrong constant when autoloading is used.
config.template =
::Gitlab::Changelog::Template::Compiler.new.compile(template)
end
if (categories = hash['categories'])
......@@ -54,7 +57,8 @@ module Gitlab
def initialize(project)
@project = project
@date_format = DEFAULT_DATE_FORMAT
@template = Template::Compiler.new.compile(DEFAULT_TEMPLATE)
@template =
::Gitlab::Changelog::Template::Compiler.new.compile(DEFAULT_TEMPLATE)
@categories = {}
end
......
......@@ -98,19 +98,27 @@ module Gitlab
ESCAPED_NEWLINE = /\\\n$/.freeze
# The start tag for ERB tags. These tags will be escaped, preventing
# users FROM USING erb DIRECTLY.
ERB_START_TAG = '<%'
# users from using ERB directly.
ERB_START_TAG = /<\\?\s*\\?\s*%/.freeze
def compile(template)
transformed_lines = ['<% it = variables %>']
# ERB tags must be stripped here, otherwise a user may introduce ERB
# tags by making clever use of whitespace. See
# https://gitlab.com/gitlab-org/gitlab/-/issues/300224 for more
# information.
template = template.gsub(ERB_START_TAG, '<%%')
template.each_line { |line| transformed_lines << transform(line) }
Template.new(transformed_lines.join)
# We use the full namespace here as otherwise Rails may use the wrong
# constant when autoloading is used.
::Gitlab::Changelog::Template::Template.new(transformed_lines.join)
end
def transform(line)
line.gsub!(ESCAPED_NEWLINE, '')
line.gsub!(ERB_START_TAG, '<%%')
# This replacement ensures that "end" blocks on their own lines
# don't add extra newlines. Using an ERB -%> tag sadly swallows too
......
# frozen_string_literal: true
module Gitlab
module Database
module MigrationHelpers
module V2
include Gitlab::Database::MigrationHelpers
# Renames a column without requiring downtime.
#
# Concurrent renames work by using database triggers to ensure both the
# old and new column are in sync. However, this method will _not_ remove
# the triggers or the old column automatically; this needs to be done
# manually in a post-deployment migration. This can be done using the
# method `cleanup_concurrent_column_rename`.
#
# table - The name of the database table containing the column.
# old_column - The old column name.
# new_column - The new column name.
# type - The type of the new column. If no type is given the old column's
# type is used.
# batch_column_name - option is for tables without primary key, in this
# case another unique integer column can be used. Example: :user_id
def rename_column_concurrently(table, old_column, new_column, type: nil, batch_column_name: :id)
setup_renamed_column(__callee__, table, old_column, new_column, type, batch_column_name)
with_lock_retries do
install_bidirectional_triggers(table, old_column, new_column)
end
end
# Reverses operations performed by rename_column_concurrently.
#
# This method takes care of removing previously installed triggers as well
# as removing the new column.
#
# table - The name of the database table.
# old_column - The name of the old column.
# new_column - The name of the new column.
def undo_rename_column_concurrently(table, old_column, new_column)
teardown_rename_mechanism(table, old_column, new_column, column_to_remove: new_column)
end
# Cleans up a concurrent column name.
#
# This method takes care of removing previously installed triggers as well
# as removing the old column.
#
# table - The name of the database table.
# old_column - The name of the old column.
# new_column - The name of the new column.
def cleanup_concurrent_column_rename(table, old_column, new_column)
teardown_rename_mechanism(table, old_column, new_column, column_to_remove: old_column)
end
# Reverses the operations performed by cleanup_concurrent_column_rename.
#
# This method adds back the old_column removed
# by cleanup_concurrent_column_rename.
# It also adds back the triggers that are removed
# by cleanup_concurrent_column_rename.
#
# table - The name of the database table containing the column.
# old_column - The old column name.
# new_column - The new column name.
# type - The type of the old column. If no type is given the new column's
# type is used.
# batch_column_name - option is for tables without primary key, in this
# case another unique integer column can be used. Example: :user_id
#
def undo_cleanup_concurrent_column_rename(table, old_column, new_column, type: nil, batch_column_name: :id)
setup_renamed_column(__callee__, table, new_column, old_column, type, batch_column_name)
with_lock_retries do
install_bidirectional_triggers(table, old_column, new_column)
end
end
private
def setup_renamed_column(calling_operation, table, old_column, new_column, type, batch_column_name)
if transaction_open?
raise "#{calling_operation} can not be run inside a transaction"
end
column = columns(table).find { |column| column.name == old_column.to_s }
unless column
raise "Column #{old_column} does not exist on #{table}"
end
if column.default
raise "#{calling_operation} does not currently support columns with default values"
end
unless column_exists?(table, batch_column_name)
raise "Column #{batch_column_name} does not exist on #{table}"
end
check_trigger_permissions!(table)
unless column_exists?(table, new_column)
create_column_from(table, old_column, new_column, type: type, batch_column_name: batch_column_name)
end
end
def teardown_rename_mechanism(table, old_column, new_column, column_to_remove:)
return unless column_exists?(table, column_to_remove)
with_lock_retries do
check_trigger_permissions!(table)
remove_bidirectional_triggers(table, old_column, new_column)
remove_column(table, column_to_remove)
end
end
def install_bidirectional_triggers(table, old_column, new_column)
insert_trigger_name, update_old_trigger_name, update_new_trigger_name =
bidirectional_trigger_names(table, old_column, new_column)
quoted_table = quote_table_name(table)
quoted_old = quote_column_name(old_column)
quoted_new = quote_column_name(new_column)
create_insert_trigger(insert_trigger_name, quoted_table, quoted_old, quoted_new)
create_update_trigger(update_old_trigger_name, quoted_table, quoted_new, quoted_old)
create_update_trigger(update_new_trigger_name, quoted_table, quoted_old, quoted_new)
end
def remove_bidirectional_triggers(table, old_column, new_column)
insert_trigger_name, update_old_trigger_name, update_new_trigger_name =
bidirectional_trigger_names(table, old_column, new_column)
quoted_table = quote_table_name(table)
drop_trigger(insert_trigger_name, quoted_table)
drop_trigger(update_old_trigger_name, quoted_table)
drop_trigger(update_new_trigger_name, quoted_table)
end
def bidirectional_trigger_names(table, old_column, new_column)
%w[insert update_old update_new].map do |operation|
'trigger_' + Digest::SHA256.hexdigest("#{table}_#{old_column}_#{new_column}_#{operation}").first(12)
end
end
def function_name_for_trigger(trigger_name)
"function_for_#{trigger_name}"
end
def create_insert_trigger(trigger_name, quoted_table, quoted_old_column, quoted_new_column)
function_name = function_name_for_trigger(trigger_name)
execute(<<~SQL)
CREATE OR REPLACE FUNCTION #{function_name}()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.#{quoted_old_column} IS NULL AND NEW.#{quoted_new_column} IS NOT NULL THEN
NEW.#{quoted_old_column} = NEW.#{quoted_new_column};
END IF;
IF NEW.#{quoted_new_column} IS NULL AND NEW.#{quoted_old_column} IS NOT NULL THEN
NEW.#{quoted_new_column} = NEW.#{quoted_old_column};
END IF;
RETURN NEW;
END
$$;
DROP TRIGGER IF EXISTS #{trigger_name}
ON #{quoted_table};
CREATE TRIGGER #{trigger_name}
BEFORE INSERT ON #{quoted_table}
FOR EACH ROW EXECUTE FUNCTION #{function_name}();
SQL
end
def create_update_trigger(trigger_name, quoted_table, quoted_source_column, quoted_target_column)
function_name = function_name_for_trigger(trigger_name)
execute(<<~SQL)
CREATE OR REPLACE FUNCTION #{function_name}()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.#{quoted_target_column} := NEW.#{quoted_source_column};
RETURN NEW;
END
$$;
DROP TRIGGER IF EXISTS #{trigger_name}
ON #{quoted_table};
CREATE TRIGGER #{trigger_name}
BEFORE UPDATE OF #{quoted_source_column} ON #{quoted_table}
FOR EACH ROW EXECUTE FUNCTION #{function_name}();
SQL
end
def drop_trigger(trigger_name, quoted_table)
function_name = function_name_for_trigger(trigger_name)
execute(<<~SQL)
DROP TRIGGER IF EXISTS #{trigger_name}
ON #{quoted_table};
DROP FUNCTION IF EXISTS #{function_name};
SQL
end
end
end
end
end
......@@ -39,9 +39,14 @@ module Gitlab
def class_methods
super
class_methods_module = const_get(:ClassMethods, false)
if instance_variable_defined?(:@_prepended_class_methods)
const_get(:ClassMethods, false).prepend @_prepended_class_methods
class_methods_module.prepend @_prepended_class_methods
end
# Hack to resolve https://gitlab.com/gitlab-org/gitlab/-/issues/23932
extend class_methods_module if ENV['STATIC_VERIFICATION']
end
def prepended(base = nil, &block)
......
......@@ -153,7 +153,13 @@ module Gitlab
def extended(mod = nil)
super
queue_verification(mod.singleton_class) if mod
# Hack to resolve https://gitlab.com/gitlab-org/gitlab/-/issues/23932
is_not_concern_hack =
(mod.is_a?(Class) || !name&.end_with?('::ClassMethods'))
if mod && is_not_concern_hack
queue_verification(mod.singleton_class)
end
end
def queue_verification(base, verify: false)
......@@ -174,7 +180,7 @@ module Gitlab
end
def self.verify!
extensions.values.each(&:verify!)
extensions.each_value(&:verify!)
end
end
end
......
......@@ -19884,9 +19884,6 @@ msgstr ""
msgid "No start date"
msgstr ""
msgid "No status"
msgstr ""
msgid "No template"
msgstr ""
......@@ -20804,9 +20801,6 @@ msgstr ""
msgid "PackageRegistry|Delete package"
msgstr ""
msgid "PackageRegistry|Filter by name"
msgstr ""
msgid "PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}"
msgstr ""
......@@ -20900,9 +20894,6 @@ msgstr ""
msgid "PackageRegistry|Source project located at %{link}"
msgstr ""
msgid "PackageRegistry|There are no %{packageType} packages yet"
msgstr ""
msgid "PackageRegistry|There are no other versions of this package."
msgstr ""
......@@ -20918,6 +20909,9 @@ msgstr ""
msgid "PackageRegistry|To widen your search, change or remove the filters above."
msgstr ""
msgid "PackageRegistry|Type"
msgstr ""
msgid "PackageRegistry|Unable to fetch package version information."
msgstr ""
......@@ -33393,13 +33387,13 @@ msgstr ""
msgid "Your GPG keys (%{count})"
msgstr ""
msgid "Your GitLab account request has been approved!"
msgid "Your GitLab Ultimate trial will last 30 days after which point you can keep your free GitLab account forever. We just need some additional information to activate your trial."
msgstr ""
msgid "Your GitLab group"
msgid "Your GitLab account request has been approved!"
msgstr ""
msgid "Your Gitlab Ultimate trial will last 30 days after which point you can keep your free Gitlab account forever. We just need some additional information to activate your trial."
msgid "Your GitLab group"
msgstr ""
msgid "Your Groups"
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe MergeRequests::OldestPerCommitFinder do
describe '#execute' do
it 'returns a Hash mapping commit SHAs to their oldest merge requests' do
project = create(:project)
mr1 = create(:merge_request, :merged, target_project: project)
mr2 = create(:merge_request, :merged, target_project: project)
mr1_diff = create(:merge_request_diff, merge_request: mr1)
mr2_diff = create(:merge_request_diff, merge_request: mr2)
sha1 = Digest::SHA1.hexdigest('foo')
sha2 = Digest::SHA1.hexdigest('bar')
create(:merge_request_diff_commit, merge_request_diff: mr1_diff, sha: sha1)
create(:merge_request_diff_commit, merge_request_diff: mr2_diff, sha: sha1)
create(
:merge_request_diff_commit,
merge_request_diff: mr2_diff,
sha: sha2,
relative_order: 1
)
commits = [double(:commit, id: sha1), double(:commit, id: sha2)]
expect(described_class.new(project).execute(commits)).to eq(
sha1 => mr1,
sha2 => mr2
)
end
it 'skips merge requests that are not merged' do
mr = create(:merge_request)
mr_diff = create(:merge_request_diff, merge_request: mr)
sha = Digest::SHA1.hexdigest('foo')
create(:merge_request_diff_commit, merge_request_diff: mr_diff, sha: sha)
commits = [double(:commit, id: sha)]
expect(described_class.new(mr.target_project).execute(commits))
.to be_empty
end
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_filter renders 1`] = `
<gl-search-box-by-click-stub
clearable="true"
clearbuttontitle="Clear"
clearrecentsearchestext="Clear recent searches"
closebuttontitle="Close"
norecentsearchestext="You don't have any recent searches"
placeholder="Filter by name"
recentsearchesheader="Recent searches"
value=""
/>
`;
import Vuex from 'vuex';
import { GlSearchBoxByClick } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import PackagesFilter from '~/packages/list/components/packages_filter.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('packages_filter', () => {
let wrapper;
let store;
const findGlSearchBox = () => wrapper.find(GlSearchBoxByClick);
const mountComponent = () => {
store = new Vuex.Store();
store.dispatch = jest.fn();
wrapper = shallowMount(PackagesFilter, {
localVue,
store,
});
};
beforeEach(mountComponent);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('emits events', () => {
it('sets the filter value in the store on input', () => {
const searchString = 'foo';
findGlSearchBox().vm.$emit('input', searchString);
expect(store.dispatch).toHaveBeenCalledWith('setFilter', searchString);
});
it('emits the filter event when search box is submitted', () => {
findGlSearchBox().vm.$emit('submit');
expect(wrapper.emitted('filter')).toBeTruthy();
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlEmptyState, GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui';
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import * as commonUtils from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import PackageListApp from '~/packages/list/components/packages_list_app.vue';
import PackageSearch from '~/packages/list/components/package_search.vue';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
......@@ -26,9 +27,9 @@ describe('packages_list_app', () => {
const emptyListHelpUrl = 'helpUrl';
const findEmptyState = () => wrapper.find(GlEmptyState);
const findListComponent = () => wrapper.find(PackageList);
const findTabComponent = (index = 0) => wrapper.findAll(GlTab).at(index);
const findPackageSearch = () => wrapper.find(PackageSearch);
const createStore = (filterQuery = '') => {
const createStore = (filter = []) => {
store = new Vuex.Store({
state: {
isLoading: false,
......@@ -38,7 +39,7 @@ describe('packages_list_app', () => {
emptyListHelpUrl,
packageHelpUrl: 'foo',
},
filterQuery,
filter,
},
});
store.dispatch = jest.fn();
......@@ -52,8 +53,6 @@ describe('packages_list_app', () => {
GlEmptyState,
GlLoadingIcon,
PackageList,
GlTab,
GlTabs,
GlSprintf,
GlLink,
},
......@@ -122,27 +121,9 @@ describe('packages_list_app', () => {
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
describe('tab change', () => {
it('calls requestPackagesList when all tab is clicked', () => {
mountComponent();
findTabComponent().trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
});
it('calls requestPackagesList when a package type tab is clicked', () => {
mountComponent();
findTabComponent(1).trigger('click');
expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
});
});
describe('filter without results', () => {
beforeEach(() => {
createStore('foo');
createStore([{ type: 'something' }]);
mountComponent();
});
......@@ -154,12 +135,28 @@ describe('packages_list_app', () => {
});
});
describe('Package Search', () => {
it('exists', () => {
mountComponent();
expect(findPackageSearch().exists()).toBe(true);
});
it.each(['sort:changed', 'filter:changed'])('on %p fetches data from the store', (event) => {
mountComponent();
findPackageSearch().vm.$emit(event);
expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
});
});
describe('delete alert handling', () => {
const { location } = window.location;
const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`;
beforeEach(() => {
createStore('foo');
createStore();
jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {});
delete window.location;
window.location = {
......
import Vuex from 'vuex';
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import component from '~/packages/list/components/package_search.vue';
import PackageTypeToken from '~/packages/list/components/tokens/package_type_token.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Package Search', () => {
let wrapper;
let store;
let sorting;
let sortingItems;
const findPackageListSorting = () => wrapper.find(GlSorting);
const findSortingItems = () => wrapper.findAll(GlSortingItem);
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const createStore = (isGroupPage) => {
const state = {
config: {
isGroupPage,
},
sorting: {
orderBy: 'version',
sort: 'desc',
},
filter: [],
};
store = new Vuex.Store({
state,
});
store.dispatch = jest.fn();
};
const mountComponent = (isGroupPage = false) => {
createStore(isGroupPage);
wrapper = shallowMount(component, {
localVue,
store,
stubs: {
GlSortingItem,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('searching', () => {
it('has a filtered-search component', () => {
mountComponent();
expect(findFilteredSearch().exists()).toBe(true);
});
it('binds the correct props to filtered-search', () => {
mountComponent();
expect(findFilteredSearch().props()).toMatchObject({
value: [],
placeholder: 'Filter results',
availableTokens: wrapper.vm.tokens,
});
});
it('updates vuex when value changes', () => {
mountComponent();
findFilteredSearch().vm.$emit('input', ['foo']);
expect(store.dispatch).toHaveBeenCalledWith('setFilter', ['foo']);
});
it('emits filter:changed on submit event', () => {
mountComponent();
findFilteredSearch().vm.$emit('submit');
expect(wrapper.emitted('filter:changed')).toEqual([[]]);
});
it('emits filter:changed on clear event and reset vuex', () => {
mountComponent();
findFilteredSearch().vm.$emit('clear');
expect(store.dispatch).toHaveBeenCalledWith('setFilter', []);
expect(wrapper.emitted('filter:changed')).toEqual([[]]);
});
it('has a PackageTypeToken token', () => {
mountComponent();
expect(findFilteredSearch().props('availableTokens')).toEqual(
expect.arrayContaining([
expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }),
]),
);
});
});
describe('sorting', () => {
describe('when is in projects', () => {
beforeEach(() => {
mountComponent();
sorting = findPackageListSorting();
sortingItems = findSortingItems();
});
it('has all the sortable items', () => {
expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length);
});
it('on sort change set sorting in vuex and emit event', () => {
sorting.vm.$emit('sortDirectionChange');
expect(store.dispatch).toHaveBeenCalledWith('setSorting', { sort: 'asc' });
expect(wrapper.emitted('sort:changed')).toBeTruthy();
});
it('on sort item click set sorting and emit event', () => {
const item = sortingItems.at(0);
const { orderBy } = wrapper.vm.sortableFields[0];
item.vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('setSorting', { orderBy });
expect(wrapper.emitted('sort:changed')).toBeTruthy();
});
});
describe('when is in group', () => {
beforeEach(() => {
mountComponent(true);
sorting = findPackageListSorting();
sortingItems = findSortingItems();
});
it('has all the sortable items', () => {
expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length);
});
});
});
});
import Vuex from 'vuex';
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import stubChildren from 'helpers/stub_children';
import PackagesSort from '~/packages/list/components/packages_sort.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('packages_sort', () => {
let wrapper;
let store;
let sorting;
let sortingItems;
const findPackageListSorting = () => wrapper.find(GlSorting);
const findSortingItems = () => wrapper.findAll(GlSortingItem);
const createStore = (isGroupPage) => {
const state = {
config: {
isGroupPage,
},
sorting: {
orderBy: 'version',
sort: 'desc',
},
};
store = new Vuex.Store({
state,
});
store.dispatch = jest.fn();
};
const mountComponent = (isGroupPage = false) => {
createStore(isGroupPage);
wrapper = mount(PackagesSort, {
localVue,
store,
stubs: {
...stubChildren(PackagesSort),
GlSortingItem,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when is in projects', () => {
beforeEach(() => {
mountComponent();
sorting = findPackageListSorting();
sortingItems = findSortingItems();
});
it('has all the sortable items', () => {
expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length);
});
it('on sort change set sorting in vuex and emit event', () => {
sorting.vm.$emit('sortDirectionChange');
expect(store.dispatch).toHaveBeenCalledWith('setSorting', { sort: 'asc' });
expect(wrapper.emitted('sort:changed')).toBeTruthy();
});
it('on sort item click set sorting and emit event', () => {
const item = sortingItems.at(0);
const { orderBy } = wrapper.vm.sortableFields[0];
item.vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('setSorting', { orderBy });
expect(wrapper.emitted('sort:changed')).toBeTruthy();
});
});
describe('when is in group', () => {
beforeEach(() => {
mountComponent(true);
sorting = findPackageListSorting();
sortingItems = findSortingItems();
});
it('has all the sortable items', () => {
expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import component from '~/packages/list/components/tokens/package_type_token.vue';
import { PACKAGE_TYPES } from '~/packages/list/constants';
describe('packages_filter', () => {
let wrapper;
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
const mountComponent = ({ attrs, listeners } = {}) => {
wrapper = shallowMount(component, {
attrs,
listeners,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('it binds all of his attrs to filtered search token', () => {
mountComponent({ attrs: { foo: 'bar' } });
expect(findFilteredSearchToken().attributes('foo')).toBe('bar');
});
it('it binds all of his events to filtered search token', () => {
const clickListener = jest.fn();
mountComponent({ listeners: { click: clickListener } });
findFilteredSearchToken().vm.$emit('click');
expect(clickListener).toHaveBeenCalled();
});
it.each(PACKAGE_TYPES.map((p, index) => [p, index]))(
'displays a suggestion for %p',
(packageType, index) => {
mountComponent();
const item = findFilteredSearchSuggestions().at(index);
expect(item.text()).toBe(packageType.title);
expect(item.props('value')).toBe(packageType.type);
},
);
});
......@@ -30,11 +30,13 @@ describe('Actions Package list store', () => {
sort: 'asc',
orderBy: 'version',
};
const filter = [];
it('should fetch the project packages list when isGroupPage is false', (done) => {
testAction(
actions.requestPackagesList,
undefined,
{ config: { isGroupPage: false, resourceId: 1 }, sorting },
{ config: { isGroupPage: false, resourceId: 1 }, sorting, filter },
[],
[
{ type: 'setLoading', payload: true },
......@@ -54,7 +56,7 @@ describe('Actions Package list store', () => {
testAction(
actions.requestPackagesList,
undefined,
{ config: { isGroupPage: true, resourceId: 2 }, sorting },
{ config: { isGroupPage: true, resourceId: 2 }, sorting, filter },
[],
[
{ type: 'setLoading', payload: true },
......@@ -70,7 +72,7 @@ describe('Actions Package list store', () => {
);
});
it('should fetch packages of a certain type when selectedType is present', (done) => {
it('should fetch packages of a certain type when a filter with a type is present', (done) => {
const packageType = 'maven';
testAction(
......@@ -79,7 +81,7 @@ describe('Actions Package list store', () => {
{
config: { isGroupPage: false, resourceId: 1 },
sorting,
selectedType: { type: packageType },
filter: [{ type: 'type', value: { data: 'maven' } }],
},
[],
[
......@@ -107,7 +109,7 @@ describe('Actions Package list store', () => {
testAction(
actions.requestPackagesList,
undefined,
{ config: { isGroupPage: false, resourceId: 2 }, sorting },
{ config: { isGroupPage: false, resourceId: 2 }, sorting, filter },
[],
[
{ type: 'setLoading', payload: true },
......
......@@ -78,17 +78,10 @@ describe('Mutations Registry Store', () => {
});
});
describe('SET_SELECTED_TYPE', () => {
it('should set the selected type', () => {
mutations[types.SET_SELECTED_TYPE](mockState, { type: 'maven' });
expect(mockState.selectedType).toEqual({ type: 'maven' });
});
});
describe('SET_FILTER', () => {
it('should set the filter query', () => {
mutations[types.SET_FILTER](mockState, 'foo');
expect(mockState.filterQuery).toEqual('foo');
expect(mockState.filter).toEqual('foo');
});
});
});
......@@ -86,5 +86,43 @@ RSpec.describe Gitlab::Changelog::Committer do
end.not_to raise_error
end
end
context "when the changelog changes before saving the changes" do
it 'raises a CommitError' do
release1 = Gitlab::Changelog::Release
.new(version: '1.0.0', date: Time.utc(2020, 1, 1), config: config)
release2 = Gitlab::Changelog::Release
.new(version: '2.0.0', date: Time.utc(2020, 1, 1), config: config)
# This creates the initial commit we'll later use to see if the
# changelog changed before saving our changes.
committer.commit(
release: release1,
file: 'CHANGELOG.md',
branch: 'master',
message: 'Initial commit'
)
allow(Gitlab::Git::Commit)
.to receive(:last_for_path)
.with(
project.repository,
'master',
'CHANGELOG.md',
literal_pathspec: true
)
.and_return(double(:commit, sha: 'foo'))
expect do
committer.commit(
release: release2,
file: 'CHANGELOG.md',
branch: 'master',
message: 'Test commit'
)
end.to raise_error(described_class::CommitError)
end
end
end
end
......@@ -125,5 +125,12 @@ RSpec.describe Gitlab::Changelog::Template::Compiler do
expect(compile(input)).to eq(input)
end
it 'ignores malicious code that makes use of whitespace' do
input = "x<\\\n%::Kernel.system(\"id\")%>"
expect(Kernel).not_to receive(:system).with('id')
expect(compile(input)).to eq('x<%::Kernel.system("id")%>')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Database::MigrationHelpers::V2 do
include Database::TriggerHelpers
let(:migration) do
ActiveRecord::Migration.new.extend(described_class)
end
before do
allow(migration).to receive(:puts)
end
shared_examples_for 'Setting up to rename a column' do
let(:model) { Class.new(ActiveRecord::Base) }
before do
model.table_name = :test_table
end
context 'when called inside a transaction block' do
before do
allow(migration).to receive(:transaction_open?).and_return(true)
end
it 'raises an error' do
expect do
migration.public_send(operation, :test_table, :original, :renamed)
end.to raise_error("#{operation} can not be run inside a transaction")
end
end
context 'when the existing column has a default value' do
before do
migration.change_column_default :test_table, existing_column, 'default value'
end
it 'raises an error' do
expect do
migration.public_send(operation, :test_table, :original, :renamed)
end.to raise_error("#{operation} does not currently support columns with default values")
end
end
context 'when passing a batch column' do
context 'when the batch column does not exist' do
it 'raises an error' do
expect do
migration.public_send(operation, :test_table, :original, :renamed, batch_column_name: :missing)
end.to raise_error('Column missing does not exist on test_table')
end
end
context 'when the batch column does exist' do
it 'passes it when creating the column' do
expect(migration).to receive(:create_column_from)
.with(:test_table, existing_column, added_column, type: nil, batch_column_name: :status)
.and_call_original
migration.public_send(operation, :test_table, :original, :renamed, batch_column_name: :status)
end
end
end
it 'creates the renamed column, syncing existing data' do
existing_record_1 = model.create!(status: 0, existing_column => 'existing')
existing_record_2 = model.create!(status: 0, existing_column => nil)
migration.send(operation, :test_table, :original, :renamed)
model.reset_column_information
expect(migration.column_exists?(:test_table, added_column)).to eq(true)
expect(existing_record_1.reload).to have_attributes(status: 0, original: 'existing', renamed: 'existing')
expect(existing_record_2.reload).to have_attributes(status: 0, original: nil, renamed: nil)
end
it 'installs triggers to sync new data' do
migration.public_send(operation, :test_table, :original, :renamed)
model.reset_column_information
new_record_1 = model.create!(status: 1, original: 'first')
new_record_2 = model.create!(status: 1, renamed: 'second')
expect(new_record_1.reload).to have_attributes(status: 1, original: 'first', renamed: 'first')
expect(new_record_2.reload).to have_attributes(status: 1, original: 'second', renamed: 'second')
new_record_1.update!(original: 'updated')
new_record_2.update!(renamed: nil)
expect(new_record_1.reload).to have_attributes(status: 1, original: 'updated', renamed: 'updated')
expect(new_record_2.reload).to have_attributes(status: 1, original: nil, renamed: nil)
end
end
describe '#rename_column_concurrently' do
before do
allow(migration).to receive(:transaction_open?).and_return(false)
migration.create_table :test_table do |t|
t.integer :status, null: false
t.text :original
t.text :other_column
end
end
it_behaves_like 'Setting up to rename a column' do
let(:operation) { :rename_column_concurrently }
let(:existing_column) { :original }
let(:added_column) { :renamed }
end
context 'when the column to rename does not exist' do
it 'raises an error' do
expect do
migration.rename_column_concurrently :test_table, :missing_column, :renamed
end.to raise_error('Column missing_column does not exist on test_table')
end
end
end
describe '#undo_cleanup_concurrent_column_rename' do
before do
allow(migration).to receive(:transaction_open?).and_return(false)
migration.create_table :test_table do |t|
t.integer :status, null: false
t.text :other_column
t.text :renamed
end
end
it_behaves_like 'Setting up to rename a column' do
let(:operation) { :undo_cleanup_concurrent_column_rename }
let(:existing_column) { :renamed }
let(:added_column) { :original }
end
context 'when the renamed column does not exist' do
it 'raises an error' do
expect do
migration.undo_cleanup_concurrent_column_rename :test_table, :original, :missing_column
end.to raise_error('Column missing_column does not exist on test_table')
end
end
end
shared_examples_for 'Cleaning up from renaming a column' do
let(:connection) { migration.connection }
before do
allow(migration).to receive(:transaction_open?).and_return(false)
migration.create_table :test_table do |t|
t.integer :status, null: false
t.text :original
t.text :other_column
end
migration.rename_column_concurrently :test_table, :original, :renamed
end
context 'when the helper is called repeatedly' do
before do
migration.public_send(operation, :test_table, :original, :renamed)
end
it 'does not make repeated attempts to cleanup' do
expect(migration).not_to receive(:remove_column)
expect do
migration.public_send(operation, :test_table, :original, :renamed)
end.not_to raise_error
end
end
context 'when the renamed column exists' do
let(:triggers) do
[
['trigger_7cc71f92fd63', 'function_for_trigger_7cc71f92fd63', before: 'insert'],
['trigger_f1a1f619636a', 'function_for_trigger_f1a1f619636a', before: 'update'],
['trigger_769a49938884', 'function_for_trigger_769a49938884', before: 'update']
]
end
it 'removes the sync triggers and renamed columns' do
triggers.each do |(trigger_name, function_name, event)|
expect_function_to_exist(function_name)
expect_valid_function_trigger(:test_table, trigger_name, function_name, event)
end
expect(migration.column_exists?(:test_table, added_column)).to eq(true)
migration.public_send(operation, :test_table, :original, :renamed)
expect(migration.column_exists?(:test_table, added_column)).to eq(false)
triggers.each do |(trigger_name, function_name, _)|
expect_trigger_not_to_exist(:test_table, trigger_name)
expect_function_not_to_exist(function_name)
end
end
end
end
describe '#undo_rename_column_concurrently' do
it_behaves_like 'Cleaning up from renaming a column' do
let(:operation) { :undo_rename_column_concurrently }
let(:added_column) { :renamed }
end
end
describe '#cleanup_concurrent_column_rename' do
it_behaves_like 'Cleaning up from renaming a column' do
let(:operation) { :cleanup_concurrent_column_rename }
let(:added_column) { :original }
end
end
end
......@@ -231,4 +231,22 @@ RSpec.describe Gitlab::Patch::Prependable do
.to raise_error(described_class::MultiplePrependedBlocks)
end
end
describe 'the extra hack for override verification' do
context 'when ENV["STATIC_VERIFICATION"] is not defined' do
it 'does not extend ClassMethods onto the defining module' do
expect(ee).not_to respond_to(:class_name)
end
end
context 'when ENV["STATIC_VERIFICATION"] is defined' do
before do
stub_env('STATIC_VERIFICATION', 'true')
end
it 'does extend ClassMethods onto the defining module' do
expect(ee).to respond_to(:class_name)
end
end
end
end
......@@ -2,6 +2,9 @@
require 'fast_spec_helper'
# Patching ActiveSupport::Concern
require_relative '../../../../config/initializers/0_as_concern'
RSpec.describe Gitlab::Utils::Override do
let(:base) do
Struct.new(:good) do
......@@ -164,6 +167,70 @@ RSpec.describe Gitlab::Utils::Override do
it_behaves_like 'checking as intended, nothing was overridden'
end
context 'when ActiveSupport::Concern and class_methods are used' do
# We need to give module names before using Override
let(:base) { stub_const('Base', Module.new) }
let(:extension) { stub_const('Extension', Module.new) }
def define_base(method_name:)
base.module_eval do
extend ActiveSupport::Concern
class_methods do
define_method(method_name) do
:f
end
end
end
end
def define_extension(method_name:)
extension.module_eval do
extend ActiveSupport::Concern
class_methods do
extend Gitlab::Utils::Override
override method_name
define_method(method_name) do
:g
end
end
end
end
context 'when it is defining a overriding method' do
before do
define_base(method_name: :f)
define_extension(method_name: :f)
base.prepend(extension)
end
it 'verifies' do
expect(base.f).to eq(:g)
described_class.verify!
end
end
context 'when it is not defining a overriding method' do
before do
define_base(method_name: :f)
define_extension(method_name: :g)
base.prepend(extension)
end
it 'raises NotImplementedError' do
expect(base.f).to eq(:f)
expect { described_class.verify! }
.to raise_error(NotImplementedError)
end
end
end
end
context 'when STATIC_VERIFICATION is not set' do
......
......@@ -610,4 +610,102 @@ RSpec.describe API::Repositories do
end
end
end
describe 'POST /projects/:id/repository/changelog' do
context 'when the changelog_api feature flag is enabled' do
it 'generates the changelog for a version' do
spy = instance_spy(Repositories::ChangelogService)
allow(Repositories::ChangelogService)
.to receive(:new)
.with(
project,
user,
version: '1.0.0',
from: 'foo',
to: 'bar',
date: DateTime.new(2020, 1, 1),
branch: 'kittens',
trailer: 'Foo',
file: 'FOO.md',
message: 'Commit message'
)
.and_return(spy)
allow(spy).to receive(:execute)
post(
api("/projects/#{project.id}/repository/changelog", user),
params: {
version: '1.0.0',
from: 'foo',
to: 'bar',
date: '2020-01-01',
branch: 'kittens',
trailer: 'Foo',
file: 'FOO.md',
message: 'Commit message'
}
)
expect(response).to have_gitlab_http_status(:ok)
end
it 'produces an error when generating the changelog fails' do
spy = instance_spy(Repositories::ChangelogService)
allow(Repositories::ChangelogService)
.to receive(:new)
.with(
project,
user,
version: '1.0.0',
from: 'foo',
to: 'bar',
date: DateTime.new(2020, 1, 1),
branch: 'kittens',
trailer: 'Foo',
file: 'FOO.md',
message: 'Commit message'
)
.and_return(spy)
allow(spy)
.to receive(:execute)
.and_raise(Gitlab::Changelog::Committer::CommitError.new('oops'))
post(
api("/projects/#{project.id}/repository/changelog", user),
params: {
version: '1.0.0',
from: 'foo',
to: 'bar',
date: '2020-01-01',
branch: 'kittens',
trailer: 'Foo',
file: 'FOO.md',
message: 'Commit message'
}
)
expect(response).to have_gitlab_http_status(:internal_server_error)
expect(json_response['message']).to eq('Failed to generate the changelog: oops')
end
end
context 'when the changelog_api feature flag is disabled' do
before do
stub_feature_flags(changelog_api: false)
end
it 'responds with a 404 Not Found' do
post(
api("/projects/#{project.id}/repository/changelog", user),
params: { version: '1.0.0', from: 'foo', to: 'bar' }
)
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Repositories::ChangelogService do
describe '#execute' do
it 'generates and commits a changelog section' do
project = create(:project, :empty_repo)
creator = project.creator
author1 = create(:user)
author2 = create(:user)
project.add_maintainer(author1)
project.add_maintainer(author2)
mr1 = create(:merge_request, :merged, target_project: project)
mr2 = create(:merge_request, :merged, target_project: project)
# The range of commits ignores the first commit, but includes the last
# commit. To ensure both the commits below are included, we must create an
# extra commit.
#
# In the real world, the start commit of the range will be the last commit
# of the previous release, so ignoring that is expected and desired.
sha1 = create_commit(
project,
creator,
commit_message: 'Initial commit',
actions: [{ action: 'create', content: 'test', file_path: 'README.md' }]
)
sha2 = create_commit(
project,
author1,
commit_message: "Title 1\n\nChangelog: feature",
actions: [{ action: 'create', content: 'foo', file_path: 'a.txt' }]
)
sha3 = create_commit(
project,
author2,
commit_message: "Title 2\n\nChangelog: feature",
actions: [{ action: 'create', content: 'bar', file_path: 'b.txt' }]
)
commit1 = project.commit(sha2)
commit2 = project.commit(sha3)
allow(MergeRequestDiffCommit)
.to receive(:oldest_merge_request_id_per_commit)
.with(project.id, [commit2.id, commit1.id])
.and_return([
{ sha: sha2, merge_request_id: mr1.id },
{ sha: sha3, merge_request_id: mr2.id }
])
recorder = ActiveRecord::QueryRecorder.new do
described_class
.new(project, creator, version: '1.0.0', from: sha1, to: sha3)
.execute
end
changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
expect(recorder.count).to eq(10)
expect(changelog).to include('Title 1', 'Title 2')
end
end
def create_commit(project, user, params)
params = { start_branch: 'master', branch_name: 'master' }.merge(params)
Files::MultiService.new(project, user, params).execute.fetch(:result)
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