Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-09-01 15:10:20 +00:00
parent 2195019331
commit 9c191c0b94
34 changed files with 516 additions and 214 deletions

View file

@ -2,6 +2,15 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 14.2.3 (2021-09-01)
### Fixed (4 changes)
- [Fix Live Markdown Preview in personal and subgroup projects](gitlab-org/gitlab@20553f93703c0bc076c8e1a4fbc4ce07e2e914b7) ([merge request](gitlab-org/gitlab!69316))
- [Fix OrphanedInviteTokensCleanup migration](gitlab-org/gitlab@9c59b2fbdfeb250de66a9d2b9424cde9680f86c3) ([merge request](gitlab-org/gitlab!69316))
- [Reset severity_levels default](gitlab-org/gitlab@34e65788679cfbdeec28357a01a8b303ba61418f) ([merge request](gitlab-org/gitlab!69316))
- [Geo: Replicate multi-arch containers](gitlab-org/gitlab@fdf88767320016a84c83e896b9f9b90291de89e0) ([merge request](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67624)) **GitLab Enterprise Edition**
## 14.2.2 (2021-08-31) ## 14.2.2 (2021-08-31)
### Security (9 changes) ### Security (9 changes)

View file

@ -1,16 +1,21 @@
<script> <script>
import { GlButtonGroup, GlButton } from '@gitlab/ui'; import { GlButtonGroup, GlButton } from '@gitlab/ui';
const SCALE_STEP_SIZE = 0.2;
const DEFAULT_SCALE = 1; const DEFAULT_SCALE = 1;
const MIN_SCALE = 1; const MIN_SCALE = 1;
const MAX_SCALE = 2; const ZOOM_LEVELS = 5;
export default { export default {
components: { components: {
GlButtonGroup, GlButtonGroup,
GlButton, GlButton,
}, },
props: {
maxScale: {
type: Number,
required: true,
},
},
data() { data() {
return { return {
scale: DEFAULT_SCALE, scale: DEFAULT_SCALE,
@ -24,7 +29,10 @@ export default {
return this.scale === DEFAULT_SCALE; return this.scale === DEFAULT_SCALE;
}, },
disableIncrease() { disableIncrease() {
return this.scale >= MAX_SCALE; return this.scale >= this.maxScale;
},
stepSize() {
return (this.maxScale - MIN_SCALE) / ZOOM_LEVELS;
}, },
}, },
methods: { methods: {
@ -37,10 +45,10 @@ export default {
this.$emit('scale', this.scale); this.$emit('scale', this.scale);
}, },
incrementScale() { incrementScale() {
this.setScale(this.scale + SCALE_STEP_SIZE); this.setScale(Math.min(this.scale + this.stepSize, this.maxScale));
}, },
decrementScale() { decrementScale() {
this.setScale(this.scale - SCALE_STEP_SIZE); this.setScale(Math.max(this.scale - this.stepSize, MIN_SCALE));
}, },
resetScale() { resetScale() {
this.setScale(DEFAULT_SCALE); this.setScale(DEFAULT_SCALE);

View file

@ -57,6 +57,7 @@ export default {
methods: { methods: {
onImgLoad() { onImgLoad() {
requestIdleCallback(this.setBaseImageSize, { timeout: 1000 }); requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
requestIdleCallback(this.setImageNaturalScale, { timeout: 1000 });
performanceMarkAndMeasure({ performanceMarkAndMeasure({
measures: [ measures: [
{ {
@ -79,6 +80,27 @@ export default {
}; };
this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height }); this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
}, },
setImageNaturalScale() {
const { contentImg } = this.$refs;
if (!contentImg) {
return;
}
const { naturalHeight, naturalWidth } = contentImg;
// In case image 404s
if (naturalHeight === 0 || naturalWidth === 0) {
return;
}
const { height, width } = this.baseImageSize;
this.$parent.$emit(
'setMaxScale',
Math.round(((height + width) / (naturalHeight + naturalWidth)) * 100) / 100,
);
},
onResize({ width, height }) { onResize({ width, height }) {
this.$emit('resize', { width, height }); this.$emit('resize', { width, height });
}, },

View file

@ -46,6 +46,7 @@ import {
import { trackDesignDetailView, servicePingDesignDetailView } from '../../utils/tracking'; import { trackDesignDetailView, servicePingDesignDetailView } from '../../utils/tracking';
const DEFAULT_SCALE = 1; const DEFAULT_SCALE = 1;
const DEFAULT_MAX_SCALE = 2;
export default { export default {
components: { components: {
@ -96,6 +97,7 @@ export default {
scale: DEFAULT_SCALE, scale: DEFAULT_SCALE,
resolvedDiscussionsExpanded: false, resolvedDiscussionsExpanded: false,
prevCurrentUserTodos: null, prevCurrentUserTodos: null,
maxScale: DEFAULT_MAX_SCALE,
}; };
}, },
apollo: { apollo: {
@ -328,6 +330,9 @@ export default {
toggleResolvedComments() { toggleResolvedComments() {
this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded; this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
}, },
setMaxScale(event) {
this.maxScale = 1 / event;
},
}, },
createImageDiffNoteMutation, createImageDiffNoteMutation,
DESIGNS_ROUTE_NAME, DESIGNS_ROUTE_NAME,
@ -376,12 +381,13 @@ export default {
@openCommentForm="openCommentForm" @openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm" @closeCommentForm="closeCommentForm"
@moveNote="onMoveNote" @moveNote="onMoveNote"
@setMaxScale="setMaxScale"
/> />
<div <div
class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
> >
<design-scaler @scale="scale = $event" /> <design-scaler :max-scale="maxScale" @scale="scale = $event" />
</div> </div>
</div> </div>
<design-sidebar <design-sidebar

View file

@ -9,8 +9,8 @@ import {
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus'; import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { cloneDeep } from 'lodash';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants'; import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@ -21,7 +21,6 @@ import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import { import {
CREATED_DESC, CREATED_DESC,
i18n, i18n,
issuesCountSmartQueryBase,
MAX_LIST_SIZE, MAX_LIST_SIZE,
PAGE_SIZE, PAGE_SIZE,
PARAM_DUE_DATE, PARAM_DUE_DATE,
@ -164,18 +163,16 @@ export default {
}, },
}, },
data() { data() {
const filterTokens = getFilterTokens(window.location.search);
const state = getParameterByName(PARAM_STATE); const state = getParameterByName(PARAM_STATE);
const sortKey = getSortKey(getParameterByName(PARAM_SORT)); const sortKey = getSortKey(getParameterByName(PARAM_SORT));
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
this.initialFilterTokens = cloneDeep(filterTokens);
return { return {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)), dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens, filterTokens: getFilterTokens(window.location.search),
issues: [], issues: [],
issuesCounts: {},
pageInfo: {}, pageInfo: {},
pageParams: getInitialPageParams(sortKey), pageParams: getInitialPageParams(sortKey),
showBulkEditSidebar: false, showBulkEditSidebar: false,
@ -202,40 +199,21 @@ export default {
}, },
debounce: 200, debounce: 200,
}, },
countOpened: { issuesCounts: {
...issuesCountSmartQueryBase, query: getIssuesCountsQuery,
variables() { variables() {
return { return this.queryVariables;
...this.queryVariables, },
state: IssuableStates.Opened, update: ({ project }) => project ?? {},
}; error(error) {
createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error });
}, },
skip() { skip() {
return !this.hasAnyIssues; return !this.hasAnyIssues;
}, },
}, debounce: 200,
countClosed: { context: {
...issuesCountSmartQueryBase, isSingleRequest: true,
variables() {
return {
...this.queryVariables,
state: IssuableStates.Closed,
};
},
skip() {
return !this.hasAnyIssues;
},
},
countAll: {
...issuesCountSmartQueryBase,
variables() {
return {
...this.queryVariables,
state: IssuableStates.All,
};
},
skip() {
return !this.hasAnyIssues;
}, },
}, },
}, },
@ -263,6 +241,9 @@ export default {
isOpenTab() { isOpenTab() {
return this.state === IssuableStates.Opened; return this.state === IssuableStates.Opened;
}, },
showCsvButtons() {
return this.isSignedIn;
},
apiFilterParams() { apiFilterParams() {
return convertToApiParams(this.filterTokens); return convertToApiParams(this.filterTokens);
}, },
@ -405,10 +386,11 @@ export default {
return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature); return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
}, },
tabCounts() { tabCounts() {
const { openedIssues, closedIssues, allIssues } = this.issuesCounts;
return { return {
[IssuableStates.Opened]: this.countOpened, [IssuableStates.Opened]: openedIssues?.count,
[IssuableStates.Closed]: this.countClosed, [IssuableStates.Closed]: closedIssues?.count,
[IssuableStates.All]: this.countAll, [IssuableStates.All]: allIssues?.count,
}; };
}, },
currentTabCount() { currentTabCount() {
@ -584,13 +566,13 @@ export default {
}) })
.then(() => { .then(() => {
const serializedVariables = JSON.stringify(this.queryVariables); const serializedVariables = JSON.stringify(this.queryVariables);
this.$apollo.mutate({ return this.$apollo.mutate({
mutation: reorderIssuesMutation, mutation: reorderIssuesMutation,
variables: { oldIndex, newIndex, serializedVariables }, variables: { oldIndex, newIndex, serializedVariables },
}); });
}) })
.catch(() => { .catch((error) => {
createFlash({ message: this.$options.i18n.reorderError }); createFlash({ message: this.$options.i18n.reorderError, captureError: true, error });
}); });
}, },
handleSort(sortKey) { handleSort(sortKey) {
@ -613,7 +595,7 @@ export default {
recent-searches-storage-key="issues" recent-searches-storage-key="issues"
:search-input-placeholder="$options.i18n.searchPlaceholder" :search-input-placeholder="$options.i18n.searchPlaceholder"
:search-tokens="searchTokens" :search-tokens="searchTokens"
:initial-filter-value="initialFilterTokens" :initial-filter-value="filterTokens"
:sort-options="sortOptions" :sort-options="sortOptions"
:initial-sort-by="sortKey" :initial-sort-by="sortKey"
:issuables="issues" :issuables="issues"
@ -653,7 +635,7 @@ export default {
:aria-label="$options.i18n.calendarLabel" :aria-label="$options.i18n.calendarLabel"
/> />
<csv-import-export-buttons <csv-import-export-buttons
v-if="isSignedIn" v-if="showCsvButtons"
class="gl-md-mr-3" class="gl-md-mr-3"
:export-csv-path="exportCsvPathWithQuery" :export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount" :issuable-count="currentTabCount"
@ -766,6 +748,7 @@ export default {
{{ $options.i18n.newIssueLabel }} {{ $options.i18n.newIssueLabel }}
</gl-button> </gl-button>
<csv-import-export-buttons <csv-import-export-buttons
v-if="showCsvButtons"
class="gl-mr-3" class="gl-mr-3"
:export-csv-path="exportCsvPathWithQuery" :export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount" :issuable-count="currentTabCount"

View file

@ -1,5 +1,3 @@
import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql';
import createFlash from '~/flash';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { import {
FILTER_ANY, FILTER_ANY,
@ -351,15 +349,3 @@ export const filters = {
}, },
}, },
}; };
export const issuesCountSmartQueryBase = {
query: getIssuesCountQuery,
context: {
isSingleRequest: true,
},
update: ({ project }) => project?.issues.count,
error(error) {
createFlash({ message: i18n.errorFetchingCounts, captureError: true, error });
},
debounce: 200,
};

View file

@ -1,30 +0,0 @@
query getIssuesCount(
$fullPath: ID!
$search: String
$state: IssuableState
$assigneeId: String
$assigneeUsernames: [String!]
$authorUsername: String
$labelName: [String]
$milestoneTitle: [String]
$milestoneWildcardId: MilestoneWildcardId
$types: [IssueType!]
$not: NegatedIssueFilterInput
) {
project(fullPath: $fullPath) {
issues(
search: $search
state: $state
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
types: $types
not: $not
) {
count
}
}
}

View file

@ -0,0 +1,57 @@
query getIssuesCount(
$fullPath: ID!
$search: String
$assigneeId: String
$assigneeUsernames: [String!]
$authorUsername: String
$labelName: [String]
$milestoneTitle: [String]
$milestoneWildcardId: MilestoneWildcardId
$types: [IssueType!]
$not: NegatedIssueFilterInput
) {
project(fullPath: $fullPath) {
openedIssues: issues(
state: opened
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
types: $types
not: $not
) {
count
}
closedIssues: issues(
state: closed
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
types: $types
not: $not
) {
count
}
allIssues: issues(
state: all
search: $search
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
types: $types
not: $not
) {
count
}
}
}

View file

@ -3,6 +3,7 @@
module IssuablesHelper module IssuablesHelper
include GitlabRoutingHelper include GitlabRoutingHelper
include IssuablesDescriptionTemplatesHelper include IssuablesDescriptionTemplatesHelper
include ::Sidebars::Concerns::HasPill
def sidebar_gutter_toggle_icon def sidebar_gutter_toggle_icon
content_tag(:span, class: 'js-sidebar-toggle-container', data: { is_expanded: !sidebar_gutter_collapsed? }) do content_tag(:span, class: 'js-sidebar-toggle-container', data: { is_expanded: !sidebar_gutter_collapsed? }) do
@ -187,19 +188,18 @@ module IssuablesHelper
end end
def issuables_state_counter_text(issuable_type, state, display_count) def issuables_state_counter_text(issuable_type, state, display_count)
titles = { titles = { opened: "Open" }
opened: "Open"
}
state_title = titles[state] || state.to_s.humanize state_title = titles[state] || state.to_s.humanize
html = content_tag(:span, state_title) html = content_tag(:span, state_title)
return html.html_safe unless display_count return html.html_safe unless display_count
count = issuables_count_for_state(issuable_type, state) count = issuables_count_for_state(issuable_type, state)
if count != -1 if count != -1
html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm') html << " " << content_tag(:span,
format_count(issuable_type, count, Gitlab::IssuablesCountForState::THRESHOLD),
class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm'
)
end end
html.html_safe html.html_safe
@ -284,7 +284,9 @@ module IssuablesHelper
end end
def issuables_count_for_state(issuable_type, state) def issuables_count_for_state(issuable_type, state)
Gitlab::IssuablesCountForState.new(finder)[state] store_in_cache = parent.is_a?(Group) ? parent.cached_issues_state_count_enabled? : false
Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: store_in_cache)[state]
end end
def close_issuable_path(issuable) def close_issuable_path(issuable)
@ -438,6 +440,14 @@ module IssuablesHelper
def parent def parent
@project || @group @project || @group
end end
def format_count(issuable_type, count, threshold)
if issuable_type == :issues && parent.is_a?(Group) && parent.cached_issues_state_count_enabled?
format_cached_count(threshold, count)
else
number_with_delimiter(count)
end
end
end end
IssuablesHelper.prepend_mod_with('IssuablesHelper') IssuablesHelper.prepend_mod_with('IssuablesHelper')

View file

@ -735,6 +735,10 @@ class Group < Namespace
Timelog.in_group(self) Timelog.in_group(self)
end end
def cached_issues_state_count_enabled?
Feature.enabled?(:cached_issues_state_count, self, default_enabled: :yaml)
end
private private
def max_member_access(user_ids) def max_member_access(user_ids)

View file

@ -2117,9 +2117,12 @@ class User < ApplicationRecord
project_creation_levels << nil project_creation_levels << nil
end end
developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants if Feature.enabled?(:linear_user_groups_with_developer_maintainer_project_access, self, default_enabled: :yaml)
::Group.where(id: developer_groups_hierarchy.select(:id), developer_groups.self_and_descendants.where(project_creation_level: project_creation_levels)
project_creation_level: project_creation_levels) else
developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants
::Group.where(id: developer_groups_hierarchy.select(:id), project_creation_level: project_creation_levels)
end
end end
def no_recent_activity? def no_recent_activity?

View file

@ -0,0 +1,8 @@
---
name: cached_issues_state_count
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67418
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333089
milestone: '14.3'
type: development
group: group::product planning
default_enabled: false

View file

@ -0,0 +1,8 @@
---
name: linear_user_groups_with_developer_maintainer_project_access
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68851
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339436
milestone: '14.3'
type: development
group: group::access
default_enabled: false

View file

@ -1,18 +0,0 @@
---
data_category: operational
key_path: usage_activity_by_stage_monthly.secure.sast_scans
description: ''
product_section: ''
product_stage: ''
product_group: ''
product_category: ''
value_type: number
status: data_available
time_frame: 28d
data_source:
distribution:
- ce
tier:
- free
skip_validation: true
performance_indicator_type: []

View file

@ -1,18 +0,0 @@
---
data_category: operational
key_path: usage_activity_by_stage_monthly.secure.container_scanning_scans
description: ''
product_section: ''
product_stage: ''
product_group: ''
product_category: ''
value_type: number
status: data_available
time_frame: 28d
data_source:
distribution:
- ce
tier:
- free
skip_validation: true
performance_indicator_type: []

View file

@ -1,18 +0,0 @@
---
data_category: operational
key_path: usage_activity_by_stage_monthly.secure.secret_detection_scans
description: ''
product_section: ''
product_stage: ''
product_group: ''
product_category: ''
value_type: number
status: data_available
time_frame: 28d
data_source:
distribution:
- ce
tier:
- free
skip_validation: true
performance_indicator_type: []

View file

@ -24,10 +24,9 @@ tests access to the GitLab API.
GITLAB_ROOT_PASSWORD: "password" # to access the api with user root:password GITLAB_ROOT_PASSWORD: "password" # to access the api with user root:password
``` ```
1. To set values for the `GITLAB_HTTPS` and `GITLAB_ROOT_PASSWORD`, NOTE:
[assign them to a variable in the user interface](../variables/index.md#add-a-cicd-variable-to-a-project). Variables set in the GitLab UI are not passed down to the service containers.
Then assign that variable to the corresponding variable in your [Learn more](../variables/index.md#).
`.gitlab-ci.yml` file.
Then, commands in `script:` sections in your `.gitlab-ci.yml` file can access the API at `http://gitlab/api/v4`. Then, commands in `script:` sections in your `.gitlab-ci.yml` file can access the API at `http://gitlab/api/v4`.

View file

@ -16,11 +16,9 @@ If you want to use a MySQL container, you can use [GitLab Runner](../runners/ind
This example shows you how to set a username and password that GitLab uses to access the MySQL container. If you do not set a username and password, you must use `root`. This example shows you how to set a username and password that GitLab uses to access the MySQL container. If you do not set a username and password, you must use `root`.
1. [Create CI/CD variables](../variables/index.md#custom-cicd-variables) for your NOTE:
MySQL database and password by going to **Settings > CI/CD**, expanding **Variables**, Variables set in the GitLab UI are not passed down to the service containers.
and clicking **Add Variable**. [Learn more](../variables/index.md).
This example uses `$MYSQL_DB` and `$MYSQL_PASS` as the keys.
1. To specify a MySQL image, add the following to your `.gitlab-ci.yml` file: 1. To specify a MySQL image, add the following to your `.gitlab-ci.yml` file:
@ -39,8 +37,8 @@ This example shows you how to set a username and password that GitLab uses to ac
```yaml ```yaml
variables: variables:
# Configure mysql environment variables (https://hub.docker.com/_/mysql/) # Configure mysql environment variables (https://hub.docker.com/_/mysql/)
MYSQL_DATABASE: $MYSQL_DB MYSQL_DATABASE: $MYSQL_DATABASE
MYSQL_ROOT_PASSWORD: $MYSQL_PASS MYSQL_ROOT_PASSWORD: $MYSQL_ROOT_PASSWORD
``` ```
The MySQL container uses `MYSQL_DATABASE` and `MYSQL_ROOT_PASSWORD` to connect to the database. The MySQL container uses `MYSQL_DATABASE` and `MYSQL_ROOT_PASSWORD` to connect to the database.

View file

@ -16,6 +16,10 @@ do this with the Docker and Shell executors of GitLab Runner.
If you're using [GitLab Runner](../runners/index.md) with the Docker executor, If you're using [GitLab Runner](../runners/index.md) with the Docker executor,
you basically have everything set up already. you basically have everything set up already.
NOTE:
Variables set in the GitLab UI are not passed down to the service containers.
[Learn more](../variables/index.md).
First, in your `.gitlab-ci.yml` add: First, in your `.gitlab-ci.yml` add:
```yaml ```yaml
@ -23,25 +27,19 @@ services:
- postgres:12.2-alpine - postgres:12.2-alpine
variables: variables:
POSTGRES_DB: nice_marmot POSTGRES_DB: $POSTGRES_DB
POSTGRES_USER: runner POSTGRES_USER: $POSTGRES_USER
POSTGRES_PASSWORD: "" POSTGRES_PASSWORD: $POSTGRES_PASSWORD
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
``` ```
To set values for the `POSTGRES_DB`, `POSTGRES_USER`,
`POSTGRES_PASSWORD` and `POSTGRES_HOST_AUTH_METHOD`,
[assign them to a CI/CD variable in the user interface](../variables/index.md#custom-cicd-variables),
then assign that variable to the corresponding variable in your
`.gitlab-ci.yml` file.
And then configure your application to use the database, for example: And then configure your application to use the database, for example:
```yaml ```yaml
Host: postgres Host: postgres
User: runner User: $PG_USER
Password: '' Password: $PG_PASSWORD
Database: nice_marmot Database: $PG_DB
``` ```
If you're wondering why we used `postgres` for the `Host`, read more at If you're wondering why we used `postgres` for the `Host`, read more at

View file

@ -20,6 +20,15 @@ You can use [predefined CI/CD variables](#predefined-cicd-variables) or define c
- [Group CI/CD variables](#add-a-cicd-variable-to-a-group). - [Group CI/CD variables](#add-a-cicd-variable-to-a-group).
- [Instance CI/CD variables](#add-a-cicd-variable-to-an-instance). - [Instance CI/CD variables](#add-a-cicd-variable-to-an-instance).
NOTE:
Variables set in the GitLab UI are **not** passed down to [service containers](../docker/using_docker_images.md).
To set them, assign them to variables in the UI, then re-assign them in your `.gitlab-ci.yml`:
```yaml
variables:
SA_PASSWORD: $SA_PASSWORD
```
> For more information about advanced use of GitLab CI/CD: > For more information about advanced use of GitLab CI/CD:
> >
> - <i class="fa fa-youtube-play youtube" aria-hidden="true"></i>&nbsp;Get to productivity faster with these [7 advanced GitLab CI workflow hacks](https://about.gitlab.com/webcast/7cicd-hacks/) > - <i class="fa fa-youtube-play youtube" aria-hidden="true"></i>&nbsp;Get to productivity faster with these [7 advanced GitLab CI workflow hacks](https://about.gitlab.com/webcast/7cicd-hacks/)

View file

@ -5,11 +5,14 @@ module Gitlab
class IssuablesCountForState class IssuablesCountForState
# The name of the Gitlab::SafeRequestStore cache key. # The name of the Gitlab::SafeRequestStore cache key.
CACHE_KEY = :issuables_count_for_state CACHE_KEY = :issuables_count_for_state
# The expiration time for the Rails cache.
CACHE_EXPIRES_IN = 10.minutes
THRESHOLD = 1000
# The state values that can be safely casted to a Symbol. # The state values that can be safely casted to a Symbol.
STATES = %w[opened closed merged all].freeze STATES = %w[opened closed merged all].freeze
attr_reader :project attr_reader :project, :finder
def self.declarative_policy_class def self.declarative_policy_class
'IssuablePolicy' 'IssuablePolicy'
@ -18,11 +21,12 @@ module Gitlab
# finder - The finder class to use for retrieving the issuables. # finder - The finder class to use for retrieving the issuables.
# fast_fail - restrict counting to a shorter period, degrading gracefully on # fast_fail - restrict counting to a shorter period, degrading gracefully on
# failure # failure
def initialize(finder, project = nil, fast_fail: false) def initialize(finder, project = nil, fast_fail: false, store_in_redis_cache: false)
@finder = finder @finder = finder
@project = project @project = project
@fast_fail = fast_fail @fast_fail = fast_fail
@cache = Gitlab::SafeRequestStore[CACHE_KEY] ||= initialize_cache @cache = Gitlab::SafeRequestStore[CACHE_KEY] ||= initialize_cache
@store_in_redis_cache = store_in_redis_cache
end end
def for_state_or_opened(state = nil) def for_state_or_opened(state = nil)
@ -52,7 +56,16 @@ module Gitlab
private private
def cache_for_finder def cache_for_finder
@cache[@finder] cached_counts = Rails.cache.read(redis_cache_key, cache_options) if cache_issues_count?
cached_counts ||= @cache[finder]
return cached_counts if cached_counts.empty?
if cache_issues_count? && cached_counts.values.all? { |count| count >= THRESHOLD }
Rails.cache.write(redis_cache_key, cached_counts, cache_options)
end
cached_counts
end end
def cast_state_to_symbol?(state) def cast_state_to_symbol?(state)
@ -108,5 +121,33 @@ module Gitlab
"Count of failed calls to IssuableFinder#count_by_state with fast failure" "Count of failed calls to IssuableFinder#count_by_state with fast failure"
).increment ).increment
end end
def cache_issues_count?
@store_in_redis_cache &&
finder.instance_of?(IssuesFinder) &&
parent_group.present? &&
!params_include_filters?
end
def parent_group
finder.params.group
end
def redis_cache_key
['group', parent_group&.id, 'issues']
end
def cache_options
{ expires_in: CACHE_EXPIRES_IN }
end
def params_include_filters?
non_filtering_params = %i[
scope state sort group_id include_subgroups
attempt_group_search_optimizations non_archived issue_types
]
finder.params.except(*non_filtering_params).values.any?
end
end end
end end

View file

@ -21,8 +21,8 @@ module Sidebars
{} {}
end end
def format_cached_count(count_service, count) def format_cached_count(threshold, count)
if count > count_service::CACHED_COUNT_THRESHOLD if count > threshold
number_to_human( number_to_human(
count, count,
units: { thousand: 'k', million: 'm' }, precision: 1, significant: false, format: '%n%u' units: { thousand: 'k', million: 'm' }, precision: 1, significant: false, format: '%n%u'

View file

@ -38,7 +38,7 @@ module Sidebars
count_service = ::Groups::OpenIssuesCountService count_service = ::Groups::OpenIssuesCountService
count = count_service.new(context.group, context.current_user).count count = count_service.new(context.group, context.current_user).count
format_cached_count(count_service, count) format_cached_count(count_service::CACHED_COUNT_THRESHOLD, count)
end end
end end

View file

@ -37,7 +37,7 @@ module Sidebars
count_service = ::Groups::MergeRequestsCountService count_service = ::Groups::MergeRequestsCountService
count = count_service.new(context.group, context.current_user).count count = count_service.new(context.group, context.current_user).count
format_cached_count(count_service, count) format_cached_count(count_service::CACHED_COUNT_THRESHOLD, count)
end end
end end

View file

@ -37,8 +37,9 @@ function bundle_install_script() {
fi; fi;
bundle --version bundle --version
bundle config set path 'vendor' bundle config set path "$(pwd)/vendor"
bundle config set clean 'true' bundle config set clean 'true'
test -d jh && bundle config set gemfile 'jh/Gemfile'
echo "${BUNDLE_WITHOUT}" echo "${BUNDLE_WITHOUT}"
bundle config bundle config

View file

@ -94,6 +94,41 @@ RSpec.describe 'Group issues page' do
expect(page).not_to have_content issue.title[0..80] expect(page).not_to have_content issue.title[0..80]
end end
end end
context 'when cached issues state count is enabled', :clean_gitlab_redis_cache do
before do
stub_feature_flags(cached_issues_state_count: true)
end
it 'truncates issue counts if over the threshold' do
allow(Rails.cache).to receive(:read).and_call_original
allow(Rails.cache).to receive(:read).with(
['group', group.id, 'issues'],
{ expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN }
).and_return({ opened: 1050, closed: 500, all: 1550 })
visit issues_group_path(group)
expect(page).to have_text('Open 1.1k Closed 500 All 1.6k')
end
end
context 'when cached issues state count is disabled', :clean_gitlab_redis_cache do
before do
stub_feature_flags(cached_issues_state_count: false)
end
it 'does not truncate counts if they are over the threshold' do
allow_next_instance_of(IssuesFinder) do |finder|
allow(finder).to receive(:count_by_state).and_return(true)
.and_return({ opened: 1050, closed: 500, all: 1550 })
end
visit issues_group_path(group)
expect(page).to have_text('Open 1,050 Closed 500 All 1,550')
end
end
end end
context 'manual ordering', :js do context 'manual ordering', :js do

View file

@ -13,7 +13,11 @@ describe('Design management design scaler component', () => {
const setScale = (scale) => wrapper.vm.setScale(scale); const setScale = (scale) => wrapper.vm.setScale(scale);
const createComponent = () => { const createComponent = () => {
wrapper = shallowMount(DesignScaler); wrapper = shallowMount(DesignScaler, {
propsData: {
maxScale: 2,
},
});
}; };
beforeEach(() => { beforeEach(() => {
@ -61,6 +65,18 @@ describe('Design management design scaler component', () => {
expect(wrapper.emitted('scale')).toEqual([[1.2]]); expect(wrapper.emitted('scale')).toEqual([[1.2]]);
}); });
it('computes & increments correct stepSize based on maxScale', async () => {
wrapper.setProps({ maxScale: 11 });
await wrapper.vm.$nextTick();
getIncreaseScaleButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(wrapper.emitted().scale[0][0]).toBe(3);
});
describe('when `scale` value is 1', () => { describe('when `scale` value is 1', () => {
it('disables the "reset" button', () => { it('disables the "reset" button', () => {
const resetButton = getResetScaleButton(); const resetButton = getResetScaleButton();
@ -77,7 +93,7 @@ describe('Design management design scaler component', () => {
}); });
}); });
describe('when `scale` value is 2 (maximum)', () => { describe('when `scale` value is maximum', () => {
beforeEach(async () => { beforeEach(async () => {
setScale(2); setScale(2);
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();

View file

@ -25,7 +25,9 @@ exports[`Design management design index page renders design index 1`] = `
<div <div
class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
> >
<design-scaler-stub /> <design-scaler-stub
maxscale="2"
/>
</div> </div>
</div> </div>
@ -186,7 +188,9 @@ exports[`Design management design index page with error GlAlert is rendered in c
<div <div
class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center" class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
> >
<design-scaler-stub /> <design-scaler-stub
maxscale="2"
/>
</div> </div>
</div> </div>

View file

@ -5,17 +5,17 @@ import { cloneDeep } from 'lodash';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql'; import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { import {
getIssuesCountsQueryResponse,
getIssuesQueryResponse, getIssuesQueryResponse,
filteredTokens, filteredTokens,
locationSearch, locationSearch,
urlParams, urlParams,
getIssuesCountQueryResponse,
} from 'jest/issues_list/mock_data'; } from 'jest/issues_list/mock_data';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@ -97,12 +97,12 @@ describe('IssuesListApp component', () => {
const mountComponent = ({ const mountComponent = ({
provide = {}, provide = {},
issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse),
issuesQueryCountResponse = jest.fn().mockResolvedValue(getIssuesCountQueryResponse), issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse),
mountFn = shallowMount, mountFn = shallowMount,
} = {}) => { } = {}) => {
const requestHandlers = [ const requestHandlers = [
[getIssuesQuery, issuesQueryResponse], [getIssuesQuery, issuesQueryResponse],
[getIssuesCountQuery, issuesQueryCountResponse], [getIssuesCountsQuery, issuesCountsQueryResponse],
]; ];
const apolloProvider = createMockApollo(requestHandlers); const apolloProvider = createMockApollo(requestHandlers);
@ -571,9 +571,9 @@ describe('IssuesListApp component', () => {
describe('errors', () => { describe('errors', () => {
describe.each` describe.each`
error | mountOption | message error | mountOption | message
${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues}
${'fetching issue counts'} | ${'issuesQueryCountResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} ${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts}
`('when there is an error $error', ({ mountOption, message }) => { `('when there is an error $error', ({ mountOption, message }) => {
beforeEach(() => { beforeEach(() => {
wrapper = mountComponent({ wrapper = mountComponent({
@ -696,7 +696,11 @@ describe('IssuesListApp component', () => {
await waitForPromises(); await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError }); expect(createFlash).toHaveBeenCalledWith({
message: IssuesListApp.i18n.reorderError,
captureError: true,
error: new Error('Request failed with status code 500'),
});
}); });
}); });
}); });

View file

@ -70,10 +70,16 @@ export const getIssuesQueryResponse = {
}, },
}; };
export const getIssuesCountQueryResponse = { export const getIssuesCountsQueryResponse = {
data: { data: {
project: { project: {
issues: { openedIssues: {
count: 1,
},
closedIssues: {
count: 1,
},
allIssues: {
count: 1, count: 1,
}, },
}, },

View file

@ -123,7 +123,7 @@ RSpec.describe IssuablesHelper do
end end
describe '#issuables_state_counter_text' do describe '#issuables_state_counter_text' do
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
describe 'state text' do describe 'state text' do
context 'when number of issuables can be generated' do context 'when number of issuables can be generated' do
@ -159,6 +159,38 @@ RSpec.describe IssuablesHelper do
.to eq('<span>All</span>') .to eq('<span>All</span>')
end end
end end
context 'when count is over the threshold' do
let_it_be(:group) { create(:group) }
before do
allow(helper).to receive(:issuables_count_for_state).and_return(1100)
allow(helper).to receive(:parent).and_return(group)
stub_const("Gitlab::IssuablesCountForState::THRESHOLD", 1000)
end
context 'when feature flag cached_issues_state_count is disabled' do
before do
stub_feature_flags(cached_issues_state_count: false)
end
it 'returns complete count' do
expect(helper.issuables_state_counter_text(:issues, :opened, true))
.to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">1,100</span>')
end
end
context 'when feature flag cached_issues_state_count is enabled' do
before do
stub_feature_flags(cached_issues_state_count: true)
end
it 'returns truncated count' do
expect(helper.issuables_state_counter_text(:issues, :opened, true))
.to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm">1.1k</span>')
end
end
end
end end
end end

View file

@ -7,7 +7,7 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909
subject(:migrate_pages_metadata) { described_class.new } subject(:migrate_pages_metadata) { described_class.new }
describe '#perform_on_relation' do describe '#perform' do
let(:namespaces) { table(:namespaces) } let(:namespaces) { table(:namespaces) }
let(:builds) { table(:ci_builds) } let(:builds) { table(:ci_builds) }
let(:pages_metadata) { table(:project_pages_metadata) } let(:pages_metadata) { table(:project_pages_metadata) }
@ -23,9 +23,9 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909
not_migrated_no_pages = projects.create!(namespace_id: namespace.id, name: 'Not Migrated No Pages') not_migrated_no_pages = projects.create!(namespace_id: namespace.id, name: 'Not Migrated No Pages')
project_not_in_relation_scope = projects.create!(namespace_id: namespace.id, name: 'Other') project_not_in_relation_scope = projects.create!(namespace_id: namespace.id, name: 'Other')
projects_relation = projects.where(id: [not_migrated_with_pages, not_migrated_no_pages, migrated]) ids = [not_migrated_no_pages.id, not_migrated_with_pages.id, migrated.id]
migrate_pages_metadata.perform_on_relation(projects_relation) migrate_pages_metadata.perform(ids.min, ids.max)
expect(pages_metadata.find_by_project_id(not_migrated_with_pages.id).deployed).to eq(true) expect(pages_metadata.find_by_project_id(not_migrated_with_pages.id).deployed).to eq(true)
expect(pages_metadata.find_by_project_id(not_migrated_no_pages.id).deployed).to eq(false) expect(pages_metadata.find_by_project_id(not_migrated_no_pages.id).deployed).to eq(false)
@ -33,12 +33,4 @@ RSpec.describe Gitlab::BackgroundMigration::MigratePagesMetadata, schema: 201909
expect(pages_metadata.find_by_project_id(project_not_in_relation_scope.id)).to be_nil expect(pages_metadata.find_by_project_id(project_not_in_relation_scope.id)).to be_nil
end end
end end
describe '#perform' do
it 'creates relation and delegates to #perform_on_relation' do
expect(migrate_pages_metadata).to receive(:perform_on_relation).with(projects.where(id: 3..5))
migrate_pages_metadata.perform(3, 5)
end
end
end end

View file

@ -66,4 +66,106 @@ RSpec.describe Gitlab::IssuablesCountForState do
end end
end end
end end
context 'when store_in_redis_cache is `true`', :clean_gitlab_redis_cache do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let(:cache_options) { { expires_in: 10.minutes } }
let(:cache_key) { ['group', group.id, 'issues'] }
let(:threshold) { described_class::THRESHOLD }
let(:states_count) { { opened: 1, closed: 1, all: 2 } }
let(:params) { {} }
subject { described_class.new(finder, fast_fail: true, store_in_redis_cache: true ) }
before do
allow(finder).to receive(:count_by_state).and_return(states_count)
allow_next_instance_of(described_class) do |counter|
allow(counter).to receive(:parent_group).and_return(group)
end
end
shared_examples 'calculating counts without caching' do
it 'does not store in redis store' do
expect(Rails.cache).not_to receive(:read)
expect(finder).to receive(:count_by_state)
expect(Rails.cache).not_to receive(:write)
expect(subject[:all]).to eq(states_count[:all])
end
end
context 'with Issues' do
let(:finder) { IssuesFinder.new(user, params) }
it 'returns -1 for the requested state' do
allow(finder).to receive(:count_by_state).and_raise(ActiveRecord::QueryCanceled)
expect(Rails.cache).not_to receive(:write)
expect(subject[:all]).to eq(-1)
end
context 'when parent group is not present' do
let(:group) { nil }
it_behaves_like 'calculating counts without caching'
end
context 'when params include search filters' do
let(:parent) { group }
before do
finder.params[:assignee_username] = [user.username, 'root']
end
it_behaves_like 'calculating counts without caching'
end
context 'when counts are stored in cache' do
before do
allow(Rails.cache).to receive(:read).with(cache_key, cache_options)
.and_return({ opened: 1000, closed: 1000, all: 2000 })
end
it 'does not call finder count_by_state' do
expect(finder).not_to receive(:count_by_state)
expect(subject[:all]).to eq(2000)
end
end
context 'when cache is empty' do
context 'when state counts are under threshold' do
let(:states_count) { { opened: 1, closed: 1, all: 2 } }
it 'does not store state counts in cache' do
expect(Rails.cache).to receive(:read).with(cache_key, cache_options)
expect(finder).to receive(:count_by_state)
expect(Rails.cache).not_to receive(:write)
expect(subject[:all]).to eq(states_count[:all])
end
end
context 'when state counts are over threshold' do
let(:states_count) do
{ opened: threshold + 1, closed: threshold + 1, all: (threshold + 1) * 2 }
end
it 'stores state counts in cache' do
expect(Rails.cache).to receive(:read).with(cache_key, cache_options)
expect(finder).to receive(:count_by_state)
expect(Rails.cache).to receive(:write).with(cache_key, states_count, cache_options)
expect(subject[:all]).to eq((threshold + 1) * 2)
end
end
end
end
context 'with Merge Requests' do
let(:finder) { MergeRequestsFinder.new(user, params) }
it_behaves_like 'calculating counts without caching'
end
end
end end

View file

@ -5999,4 +5999,49 @@ RSpec.describe User do
end end
end end
end end
describe '#groups_with_developer_maintainer_project_access' do
let_it_be(:user) { create(:user) }
let_it_be(:group1) { create(:group) }
let_it_be(:developer_group1) do
create(:group).tap do |g|
g.add_developer(user)
end
end
let_it_be(:developer_group2) do
create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g|
g.add_developer(user)
end
end
let_it_be(:guest_group1) do
create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g|
g.add_guest(user)
end
end
let_it_be(:developer_group1) do
create(:group, project_creation_level: ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS).tap do |g|
g.add_maintainer(user)
end
end
subject { user.send(:groups_with_developer_maintainer_project_access) }
shared_examples 'groups_with_developer_maintainer_project_access examples' do
specify { is_expected.to contain_exactly(developer_group2) }
end
it_behaves_like 'groups_with_developer_maintainer_project_access examples'
context 'when feature flag :linear_user_groups_with_developer_maintainer_project_access is disabled' do
before do
stub_feature_flags(linear_user_groups_with_developer_maintainer_project_access: false)
end
it_behaves_like 'groups_with_developer_maintainer_project_access examples'
end
end
end end