Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-06-24 15:08:50 +00:00
parent d081e00aa7
commit 4c5468b408
752 changed files with 1974 additions and 1023 deletions

View File

@ -336,7 +336,7 @@ export default {
:sidebar-collapsed="sidebarCollapsed"
@alert-refresh="alertRefresh"
@toggle-sidebar="toggleSidebar"
@alert-sidebar-error="handleAlertSidebarError"
@alert-error="handleAlertSidebarError"
/>
</div>
</div>

View File

@ -6,8 +6,6 @@ import {
GlTable,
GlAlert,
GlIcon,
GlDropdown,
GlDropdownItem,
GlLink,
GlTabs,
GlTab,
@ -16,12 +14,13 @@ import {
GlSearchBoxByType,
GlSprintf,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
import { debounce, trim } from 'lodash';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
import getAlerts from '../graphql/queries/get_alerts.query.graphql';
import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import {
@ -31,9 +30,7 @@ import {
trackAlertListViewsOptions,
trackAlertStatusUpdateOptions,
} from '../constants';
import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
import AlertStatus from './alert_status.vue';
const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center';
const thClass = 'gl-hover-bg-blue-50';
@ -107,11 +104,6 @@ export default {
sortable: true,
},
],
statuses: {
TRIGGERED: s__('AlertManagement|Triggered'),
ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
RESOLVED: s__('AlertManagement|Resolved'),
},
severityLabels: ALERTS_SEVERITY_LABELS,
statusTabs: ALERTS_STATUS_TABS,
components: {
@ -121,8 +113,6 @@ export default {
GlAlert,
GlDeprecatedButton,
TimeAgo,
GlDropdown,
GlDropdownItem,
GlIcon,
GlLink,
GlTabs,
@ -131,6 +121,7 @@ export default {
GlPagination,
GlSearchBoxByType,
GlSprintf,
AlertStatus,
},
props: {
projectPath: {
@ -204,6 +195,7 @@ export default {
return {
searchTerm: '',
errored: false,
errorMessage: '',
isAlertDismissed: false,
isErrorAlertDismissed: false,
sort: 'STARTED_AT_DESC',
@ -275,30 +267,6 @@ export default {
this.searchTerm = trimmedInput;
}
}, 500),
updateAlertStatus(status, iid) {
this.$apollo
.mutate({
mutation: updateAlertStatus,
variables: {
iid,
status: status.toUpperCase(),
projectPath: this.projectPath,
},
})
.then(() => {
this.trackStatusUpdate(status);
this.$apollo.queries.alerts.refetch();
this.$apollo.queries.alertsCount.refetch();
this.resetPagination();
})
.catch(() => {
createFlash(
s__(
'AlertManagement|There was an error while updating the status of the alert. Please try again.',
),
);
});
},
navigateToAlertDetails({ iid }) {
return visitUrl(joinPaths(window.location.pathname, iid, 'details'));
},
@ -338,6 +306,14 @@ export default {
resetPagination() {
this.pagination = initialPaginationState;
},
handleAlertError(errorMessage) {
this.errored = true;
this.errorMessage = errorMessage;
},
dismissError() {
this.isErrorAlertDismissed = true;
this.errorMessage = '';
},
},
};
</script>
@ -357,8 +333,13 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true">
{{ $options.i18n.errorMsg }}
<gl-alert
v-if="showErrorMsg"
variant="danger"
data-testid="alert-error"
@dismiss="dismissError"
>
{{ errorMessage || $options.i18n.errorMsg }}
</gl-alert>
<gl-tabs content-class="gl-p-0" @input="filterAlertsByStatus">
@ -437,22 +418,12 @@ export default {
</template>
<template #cell(status)="{ item }">
<gl-dropdown :text="$options.statuses[item.status]" class="w-100" right>
<gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
@click="updateAlertStatus(label, item.iid)"
>
<span class="d-flex">
<gl-icon
class="flex-shrink-0 append-right-4"
:class="{ invisible: label.toUpperCase() !== item.status }"
name="mobile-issue-close"
/>
{{ label }}
</span>
</gl-dropdown-item>
</gl-dropdown>
<alert-status
:alert="item"
:project-path="projectPath"
:is-sidebar="false"
@alert-error="handleAlertError"
/>
</template>
<template #empty>

View File

@ -49,7 +49,7 @@ export default {
:project-path="projectPath"
:alert="alert"
@toggle-sidebar="$emit('toggle-sidebar')"
@alert-sidebar-error="$emit('alert-sidebar-error', $event)"
@alert-error="$emit('alert-error', $event)"
/>
<sidebar-assignees
:project-path="projectPath"
@ -58,7 +58,7 @@ export default {
:sidebar-collapsed="sidebarCollapsed"
@alert-refresh="$emit('alert-refresh')"
@toggle-sidebar="$emit('toggle-sidebar')"
@alert-sidebar-error="$emit('alert-sidebar-error', $event)"
@alert-error="$emit('alert-error', $event)"
/>
<div class="block"></div>
</div>

View File

@ -0,0 +1,116 @@
<script>
import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import { trackAlertStatusUpdateOptions } from '../constants';
import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
export default {
statuses: {
TRIGGERED: s__('AlertManagement|Triggered'),
ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
RESOLVED: s__('AlertManagement|Resolved'),
},
components: {
GlDropdown,
GlDropdownItem,
GlButton,
},
props: {
projectPath: {
type: String,
required: true,
},
alert: {
type: Object,
required: true,
},
isDropdownShowing: {
type: Boolean,
required: false,
},
isSidebar: {
type: Boolean,
required: true,
},
},
computed: {
dropdownClass() {
// eslint-disable-next-line no-nested-ternary
return this.isSidebar ? (this.isDropdownShowing ? 'show' : 'gl-display-none') : '';
},
},
methods: {
updateAlertStatus(status) {
this.$emit('handle-updating', true);
this.$apollo
.mutate({
mutation: updateAlertStatus,
variables: {
iid: this.alert.iid,
status: status.toUpperCase(),
projectPath: this.projectPath,
},
})
.then(() => {
this.trackStatusUpdate(status);
this.$emit('hide-dropdown');
})
.catch(() => {
this.$emit(
'alert-error',
s__(
'AlertManagement|There was an error while updating the status of the alert. Please try again.',
),
);
})
.finally(() => {
this.$emit('handle-updating', false);
});
},
trackStatusUpdate(status) {
const { category, action, label } = trackAlertStatusUpdateOptions;
Tracking.event(category, action, { label, property: status });
},
},
};
</script>
<template>
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
<gl-dropdown
ref="dropdown"
right
:text="$options.statuses[alert.status]"
class="w-100"
toggle-class="dropdown-menu-toggle"
variant="outline-default"
@keydown.esc.native="$emit('hide-dropdown')"
@hide="$emit('hide-dropdown')"
>
<div class="dropdown-title text-center">
<span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
class="dropdown-title-button dropdown-menu-close"
icon="close"
@click="$emit('hide-dropdown')"
/>
</div>
<div class="dropdown-content dropdown-body">
<gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
data-testid="statusDropdownItem"
class="gl-vertical-align-middle"
:active="label.toUpperCase() === alert.status"
:active-class="'is-active'"
@click="updateAlertStatus(label)"
>
{{ label }}
</gl-dropdown-item>
</div>
</gl-dropdown>
</div>
</template>

View File

@ -142,7 +142,7 @@ export default {
this.users = data;
})
.catch(() => {
this.$emit('alert-sidebar-error', this.$options.FETCH_USERS_ERROR);
this.$emit('alert-error', this.$options.FETCH_USERS_ERROR);
})
.finally(() => {
this.isDropdownSearching = false;
@ -172,7 +172,7 @@ export default {
return this.$emit('alert-refresh');
})
.catch(() => {
this.$emit('alert-sidebar-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR);
this.$emit('alert-error', this.$options.UPDATE_ALERT_ASSIGNEES_ERROR);
})
.finally(() => {
this.isUpdating = false;

View File

@ -1,17 +1,7 @@
<script>
import {
GlIcon,
GlDropdown,
GlDropdownItem,
GlLoadingIcon,
GlTooltip,
GlButton,
GlSprintf,
} from '@gitlab/ui';
import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import { trackAlertStatusUpdateOptions } from '../../constants';
import updateAlertStatus from '../../graphql/mutations/update_alert_status.graphql';
import AlertStatus from '../alert_status.vue';
export default {
statuses: {
@ -21,12 +11,10 @@ export default {
},
components: {
GlIcon,
GlDropdown,
GlDropdownItem,
GlLoadingIcon,
GlTooltip,
GlButton,
GlSprintf,
AlertStatus,
},
props: {
projectPath: {
@ -60,44 +48,13 @@ export default {
},
toggleFormDropdown() {
this.isDropdownShowing = !this.isDropdownShowing;
const { dropdown } = this.$refs.dropdown.$refs;
const { dropdown } = this.$children[2].$refs.dropdown.$refs;
if (dropdown && this.isDropdownShowing) {
dropdown.show();
}
},
isSelected(status) {
return this.alert.status === status;
},
updateAlertStatus(status) {
this.isUpdating = true;
this.$apollo
.mutate({
mutation: updateAlertStatus,
variables: {
iid: this.alert.iid,
status: status.toUpperCase(),
projectPath: this.projectPath,
},
})
.then(() => {
this.trackStatusUpdate(status);
this.hideDropdown();
})
.catch(() => {
this.$emit(
'alert-sidebar-error',
s__(
'AlertManagement|There was an error while updating the status of the alert. Please try again.',
),
);
})
.finally(() => {
this.isUpdating = false;
});
},
trackStatusUpdate(status) {
const { category, action, label } = trackAlertStatusUpdateOptions;
Tracking.event(category, action, { label, property: status });
handleUpdating(updating) {
this.isUpdating = updating;
},
},
};
@ -132,41 +89,15 @@ export default {
</a>
</p>
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
<gl-dropdown
ref="dropdown"
:text="$options.statuses[alert.status]"
class="w-100"
toggle-class="dropdown-menu-toggle"
variant="outline-default"
@keydown.esc.native="hideDropdown"
@hide="hideDropdown"
>
<div class="dropdown-title">
<span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
class="dropdown-title-button dropdown-menu-close"
icon="close"
@click="hideDropdown"
/>
</div>
<div class="dropdown-content dropdown-body">
<gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
data-testid="statusDropdownItem"
class="gl-vertical-align-middle"
:active="label.toUpperCase() === alert.status"
:active-class="'is-active'"
@click="updateAlertStatus(label)"
>
{{ label }}
</gl-dropdown-item>
</div>
</gl-dropdown>
</div>
<alert-status
:alert="alert"
:project-path="projectPath"
:is-dropdown-showing="isDropdownShowing"
:is-sidebar="true"
@alert-error="$emit('alert-error', $event)"
@hide-dropdown="hideDropdown"
@handle-updating="handleUpdating"
/>
<gl-loading-icon v-if="isUpdating" :inline="true" />
<p

View File

@ -1,4 +1,87 @@
import Vue from 'vue';
/**
* An event hub with a Vue instance like API
*
* NOTE: There's an [issue open][4] to eventually remove this when some
* coupling in our codebase has been fixed.
*
* NOTE: This is a derivative work from [mitt][1] v1.2.0 which is licensed by
* [MIT License][2] © [Jason Miller][3]
*
* [1]: https://github.com/developit/mitt
* [2]: https://opensource.org/licenses/MIT
* [3]: https://jasonformat.com/
* [4]: https://gitlab.com/gitlab-org/gitlab/-/issues/223864
*/
class EventHub {
constructor() {
this.$_all = new Map();
}
dispose() {
this.$_all.clear();
}
/**
* Register an event handler for the given type.
*
* @param {string|symbol} type Type of event to listen for
* @param {Function} handler Function to call in response to given event
*/
$on(type, handler) {
const handlers = this.$_all.get(type);
const added = handlers && handlers.push(handler);
if (!added) {
this.$_all.set(type, [handler]);
}
}
/**
* Remove an event handler or all handlers for the given type.
*
* @param {string|symbol} type Type of event to unregister `handler`
* @param {Function} handler Handler function to remove
*/
$off(type, handler) {
const handlers = this.$_all.get(type) || [];
const newHandlers = handler ? handlers.filter(x => x !== handler) : [];
if (newHandlers.length) {
this.$_all.set(type, newHandlers);
} else {
this.$_all.delete(type);
}
}
/**
* Add an event listener to type but only trigger it once
*
* @param {string|symbol} type Type of event to listen for
* @param {Function} handler Handler function to call in response to event
*/
$once(type, handler) {
const wrapHandler = (...args) => {
this.$off(type, wrapHandler);
handler(...args);
};
this.$on(type, wrapHandler);
}
/**
* Invoke all handlers for the given type.
*
* @param {string|symbol} type The event type to invoke
* @param {Any} [evt] Any value passed to each handler
*/
$emit(type, ...args) {
const handlers = this.$_all.get(type) || [];
handlers.forEach(handler => {
handler(...args);
});
}
}
/**
* Return a Vue like event hub
@ -14,5 +97,5 @@ import Vue from 'vue';
* We'd like to shy away from using a full fledged Vue instance from this in the future.
*/
export default () => {
return new Vue();
return new EventHub();
};

View File

@ -1,5 +1,7 @@
export const BYTES_IN_KIB = 1024;
export const HIDDEN_CLASS = 'hidden';
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
export const DATETIME_RANGE_TYPES = {
fixed: 'fixed',

View File

@ -1,4 +1,9 @@
import { isString } from 'lodash';
import { isString, memoize } from 'lodash';
import {
TRUNCATE_WIDTH_DEFAULT_WIDTH,
TRUNCATE_WIDTH_DEFAULT_FONT_SIZE,
} from '~/lib/utils/constants';
/**
* Adds a , to a string composed by numbers, at every 3 chars.
@ -73,7 +78,79 @@ export const slugifyWithUnderscore = str => slugify(str, '_');
* @param {Number} maxLength
* @returns {String}
*/
export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`;
export const truncate = (string, maxLength) => {
if (string.length - 1 > maxLength) {
return `${string.substr(0, maxLength - 1)}`;
}
return string;
};
/**
* This function calculates the average char width. It does so by placing a string in the DOM and measuring the width.
* NOTE: This will cause a reflow and should be used sparsely!
* The default fontFamily is 'sans-serif' and 12px in ECharts, so that is the default basis for calculating the average with.
* https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontFamily
* https://echarts.apache.org/en/option.html#xAxis.nameTextStyle.fontSize
* @param {Object} options
* @param {Number} options.fontSize style to size the text for measurement
* @param {String} options.fontFamily style of font family to measure the text with
* @param {String} options.chars string of chars to use as a basis for calculating average width
* @return {Number}
*/
const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) {
const {
fontSize = 12,
fontFamily = 'sans-serif',
// eslint-disable-next-line @gitlab/require-i18n-strings
chars = ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
} = options;
const div = document.createElement('div');
div.style.fontFamily = fontFamily;
div.style.fontSize = `${fontSize}px`;
// Place outside of view
div.style.position = 'absolute';
div.style.left = -1000;
div.style.top = -1000;
div.innerHTML = chars;
document.body.appendChild(div);
const width = div.clientWidth;
document.body.removeChild(div);
return width / chars.length / fontSize;
});
/**
* This function returns a truncated version of `string` if its estimated rendered width is longer than `maxWidth`,
* otherwise it will return the original `string`
* Inspired by https://bl.ocks.org/tophtucker/62f93a4658387bb61e4510c37e2e97cf
* @param {String} string text to truncate
* @param {Object} options
* @param {Number} options.maxWidth largest rendered width the text may have
* @param {Number} options.fontSize size of the font used to render the text
* @return {String} either the original string or a truncated version
*/
export const truncateWidth = (string, options = {}) => {
const {
maxWidth = TRUNCATE_WIDTH_DEFAULT_WIDTH,
fontSize = TRUNCATE_WIDTH_DEFAULT_FONT_SIZE,
} = options;
const { truncateIndex } = string.split('').reduce(
(memo, char, index) => {
let newIndex = index;
if (memo.width > maxWidth) {
newIndex = memo.truncateIndex;
}
return { width: memo.width + getAverageCharWidth() * fontSize, truncateIndex: newIndex };
},
{ width: 0, truncateIndex: 0 },
);
return truncate(string, truncateIndex);
};
/**
* Truncate SHA to 8 characters

View File

@ -32,7 +32,7 @@ export default class AbuseReports {
$messageCellElement.text(originalMessage);
} else {
$messageCellElement.data('messageTruncated', 'true');
$messageCellElement.text(`${originalMessage.substr(0, MAX_MESSAGE_LENGTH - 3)}...`);
$messageCellElement.text(truncate(originalMessage, MAX_MESSAGE_LENGTH));
}
}
}

View File

@ -4,7 +4,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ASSET_LINK_TYPE } from '../constants';
import { __, s__, sprintf } from '~/locale';
import { difference } from 'lodash';
import { difference, get } from 'lodash';
export default {
name: 'ReleaseBlockAssets',
@ -54,7 +54,7 @@ export default {
sections() {
return [
{
links: this.assets.sources.map(s => ({
links: get(this.assets, 'sources', []).map(s => ({
url: s.url,
name: sprintf(__('Source code (%{fileExtension})'), { fileExtension: s.format }),
})),

View File

@ -74,7 +74,7 @@
content: none !important;
}
div {
div:not(.dropdown-title) {
width: 100% !important;
padding: 0 !important;
}

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Types
class MilestoneStatsType < BaseObject
graphql_name 'MilestoneStats'
description 'Contains statistics about a milestone'
authorize :read_milestone
field :total_issues_count, GraphQL::INT_TYPE, null: true,
description: 'Total number of issues associated with the milestone'
field :closed_issues_count, GraphQL::INT_TYPE, null: true,
description: 'Number of closed issues associated with the milestone'
end
end

View File

@ -9,6 +9,8 @@ module Types
authorize :read_milestone
alias_method :milestone, :object
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the milestone'
@ -47,5 +49,14 @@ module Types
field :subgroup_milestone, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates if milestone is at subgroup level',
method: :subgroup_milestone?
field :stats, Types::MilestoneStatsType, null: true,
description: 'Milestone statistics'
def stats
return unless Feature.enabled?(:graphql_milestone_stats, milestone.project || milestone.group, default_enabled: true)
milestone
end
end
end

View File

@ -0,0 +1,5 @@
---
title: Improve the performance for loading large diffs on a Merge request
merge_request: 33037
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Add ref, released_at, milestones to release yml
merge_request: 34943
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Expose ref, milestones, released_at to releaser-cli
merge_request: 35115
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add milestone stats to GraphQL endpoint
merge_request: 35066
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Fix release assets for Guest users of private projects
merge_request: 35166
author:
type: fixed

View File

@ -7676,6 +7676,11 @@ type Milestone {
"""
state: MilestoneStateEnum!
"""
Milestone statistics
"""
stats: MilestoneStats
"""
Indicates if milestone is at subgroup level
"""
@ -7737,6 +7742,21 @@ enum MilestoneStateEnum {
closed
}
"""
Contains statistics about a milestone
"""
type MilestoneStats {
"""
Number of closed issues associated with the milestone
"""
closedIssuesCount: Int
"""
Total number of issues associated with the milestone
"""
totalIssuesCount: Int
}
"""
The position to which the adjacent object should be moved
"""

View File

@ -21487,6 +21487,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "stats",
"description": "Milestone statistics",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "MilestoneStats",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "subgroupMilestone",
"description": "Indicates if milestone is at subgroup level",
@ -21702,6 +21716,47 @@
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "MilestoneStats",
"description": "Contains statistics about a milestone",
"fields": [
{
"name": "closedIssuesCount",
"description": "Number of closed issues associated with the milestone",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "totalIssuesCount",
"description": "Total number of issues associated with the milestone",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "MoveType",

View File

@ -1180,11 +1180,21 @@ Represents a milestone.
| `projectMilestone` | Boolean! | Indicates if milestone is at project level |
| `startDate` | Time | Timestamp of the milestone start date |
| `state` | MilestoneStateEnum! | State of the milestone |
| `stats` | MilestoneStats | Milestone statistics |
| `subgroupMilestone` | Boolean! | Indicates if milestone is at subgroup level |
| `title` | String! | Title of the milestone |
| `updatedAt` | Time! | Timestamp of last milestone update |
| `webPath` | String! | Web path of the milestone |
## MilestoneStats
Contains statistics about a milestone
| Name | Type | Description |
| --- | ---- | ---------- |
| `closedIssuesCount` | Int | Number of closed issues associated with the milestone |
| `totalIssuesCount` | Int | Total number of issues associated with the milestone |
## Namespace
| Name | Type | Description |

View File

@ -386,9 +386,12 @@ author.
- Learning how to find the right balance takes time; that is why we have
reviewers that become maintainers after some time spent on reviewing merge
requests.
- Finding bugs and improving code style is important, but thinking about good
design is important as well. Building abstractions and good design is what
makes it possible to hide complexity and makes future changes easier.
- Finding bugs is important, but thinking about good design is important as
well. Building abstractions and good design is what makes it possible to hide
complexity and makes future changes easier.
- Enforcing and improving [code style](contributing/style_guides.md) should be primarily done through
[automation](https://about.gitlab.com/handbook/values/#cleanup-over-sign-off)
instead of review comments.
- Asking the author to change the design sometimes means the complete rewrite
of the contributed code. It's usually a good idea to ask another maintainer or
reviewer before doing it, but have the courage to do it when you believe it is

View File

@ -616,8 +616,7 @@ From the project's settings:
To allow or deny a license:
1. Either use the **Manage licenses** button in the merge request widget, or
navigate to the project's **Settings > CI/CD** and expand the
**License Compliance** section.
select **Security & Compliance > License Compliance** in the project's sidebar navigation.
1. Click the **Add a license** button.
![License Compliance Add License](img/license_compliance_add_license_v13_0.png)

View File

@ -6,9 +6,6 @@ module ContainerRegistry
attr_reader :repository, :name
# https://github.com/docker/distribution/commit/3150937b9f2b1b5b096b2634d0e7c44d4a0f89fb
TAG_NAME_REGEX = /^[\w][\w.-]{0,127}$/.freeze
delegate :registry, :client, to: :repository
delegate :revision, :short_revision, to: :config_blob, allow_nil: true
@ -16,10 +13,6 @@ module ContainerRegistry
@repository, @name = repository, name
end
def valid_name?
!name.match(TAG_NAME_REGEX).nil?
end
def valid?
manifest.present?
end

View File

@ -5,6 +5,8 @@ module Gitlab
module Build
class Releaser
BASE_COMMAND = 'release-cli create'
SINGLE_FLAGS = %i[name description tag_name ref released_at].freeze
ARRAY_FLAGS = %i[milestones].freeze
attr_reader :config
@ -14,10 +16,21 @@ module Gitlab
def script
command = BASE_COMMAND.dup
config.each { |k, v| command.concat(" --#{k.to_s.dasherize} \"#{v}\"") }
single_flags.each { |k, v| command.concat(" --#{k.to_s.dasherize} \"#{v}\"") }
array_commands.each { |k, v| v.each { |elem| command.concat(" --#{k.to_s.singularize.dasherize} \"#{elem}\"") } }
[command]
end
private
def single_flags
config.slice(*SINGLE_FLAGS)
end
def array_commands
config.slice(*ARRAY_FLAGS)
end
end
end
end

View File

@ -12,8 +12,9 @@ module Gitlab
include ::Gitlab::Config::Entry::Validatable
include ::Gitlab::Config::Entry::Attributable
ALLOWED_KEYS = %i[tag_name name description assets].freeze
attributes %i[tag_name name assets].freeze
ALLOWED_KEYS = %i[tag_name name description ref released_at milestones assets].freeze
attributes %i[tag_name name ref milestones assets].freeze
attr_reader :released_at
# Attributable description conflicts with
# ::Gitlab::Config::Entry::Node.description
@ -29,8 +30,25 @@ module Gitlab
validations do
validates :config, allowed_keys: ALLOWED_KEYS
validates :tag_name, presence: true
validates :tag_name, type: String, presence: true
validates :description, type: String, presence: true
validates :milestones, array_of_strings_or_string: true, allow_blank: true
validate do
next unless config[:released_at]
begin
@released_at = DateTime.iso8601(config[:released_at])
rescue ArgumentError
errors.add(:released_at, "must be a valid datetime")
end
end
validate do
next unless config[:ref]
unless Commit.reference_valid?(config[:ref])
errors.add(:ref, "must be a valid ref")
end
end
end
def value

View File

@ -18,7 +18,6 @@ license_scanning:
name: "$SECURE_ANALYZERS_PREFIX/license-finder:$LICENSE_MANAGEMENT_VERSION"
entrypoint: [""]
variables:
LM_REPORT_FILE: gl-license-scanning-report.json
LM_REPORT_VERSION: '2.1'
SETUP_CMD: $LICENSE_MANAGEMENT_SETUP_CMD
allow_failure: true
@ -26,7 +25,7 @@ license_scanning:
- /run.sh analyze .
artifacts:
reports:
license_scanning: $LM_REPORT_FILE
license_scanning: gl-license-scanning-report.json
dependencies: []
rules:
- if: $LICENSE_MANAGEMENT_DISABLED

View File

@ -230,11 +230,15 @@ module Gitlab
end
def added_lines
@stats&.additions || diff_lines.count(&:added?)
strong_memoize(:added_lines) do
@stats&.additions || diff_lines.count(&:added?)
end
end
def removed_lines
@stats&.deletions || diff_lines.count(&:removed?)
strong_memoize(:removed_lines) do
@stats&.deletions || diff_lines.count(&:removed?)
end
end
def file_identifier

View File

@ -88,15 +88,18 @@ module Gitlab
def diff_stats_collection
strong_memoize(:diff_stats) do
# There are scenarios where we don't need to request Diff Stats,
# when caching for instance.
next unless @include_stats
next unless diff_refs
next unless fetch_diff_stats?
@repository.diff_stats(diff_refs.base_sha, diff_refs.head_sha)
end
end
def fetch_diff_stats?
# There are scenarios where we don't need to request Diff Stats,
# when caching for instance.
@include_stats && diff_refs
end
def decorate_diff!(diff)
return diff if diff.is_a?(File)

View File

@ -20,7 +20,7 @@ module Gitlab
strong_memoize(:diff_files) do
diff_files = super
diff_files.each { |diff_file| cache.decorate(diff_file) }
diff_files.each { |diff_file| highlight_cache.decorate(diff_file) }
diff_files
end
@ -28,16 +28,14 @@ module Gitlab
override :write_cache
def write_cache
cache.write_if_empty
highlight_cache.write_if_empty
diff_stats_cache&.write_if_empty(diff_stats_collection)
end
override :clear_cache
def clear_cache
cache.clear
end
def cache_key
cache.key
highlight_cache.clear
diff_stats_cache&.clear
end
def real_size
@ -46,8 +44,27 @@ module Gitlab
private
def cache
@cache ||= Gitlab::Diff::HighlightCache.new(self)
def highlight_cache
strong_memoize(:highlight_cache) do
Gitlab::Diff::HighlightCache.new(self)
end
end
def diff_stats_cache
strong_memoize(:diff_stats_cache) do
if Feature.enabled?(:cache_diff_stats_merge_request, project)
Gitlab::Diff::StatsCache.new(cachable_key: @merge_request_diff.cache_key)
end
end
end
override :diff_stats_collection
def diff_stats_collection
strong_memoize(:diff_stats) do
next unless fetch_diff_stats?
diff_stats_cache&.read || super
end
end
end
end

View File

@ -0,0 +1,54 @@
# frozen_string_literal: true
#
module Gitlab
module Diff
class StatsCache
include Gitlab::Metrics::Methods
include Gitlab::Utils::StrongMemoize
EXPIRATION = 1.week
VERSION = 1
def initialize(cachable_key:)
@cachable_key = cachable_key
end
def read
strong_memoize(:cached_values) do
content = cache.fetch(key)
next unless content
stats = content.map { |stat| Gitaly::DiffStats.new(stat) }
Gitlab::Git::DiffStatsCollection.new(stats)
end
end
def write_if_empty(stats)
return if cache.exist?(key)
return unless stats
cache.write(key, stats.as_json, expires_in: EXPIRATION)
end
def clear
cache.delete(key)
end
private
attr_reader :cachable_key
def cache
Rails.cache
end
def key
strong_memoize(:redis_key) do
['diff_stats', cachable_key, VERSION].join(":")
end
end
end
end
end

View File

@ -24656,9 +24656,6 @@ msgstr ""
msgid "UsageQuota|Storage"
msgstr ""
msgid "UsageQuota|Storage usage:"
msgstr ""
msgid "UsageQuota|This namespace has no projects which use shared runners"
msgstr ""
@ -24689,6 +24686,12 @@ msgstr ""
msgid "UsageQuota|Wikis"
msgstr ""
msgid "UsageQuota|You used: %{usage} %{limit}"
msgstr ""
msgid "UsageQuota|out of %{formattedLimit} of your namespace storage"
msgstr ""
msgid "Use %{code_start}::%{code_end} to create a %{link_start}scoped label set%{link_end} (eg. %{code_start}priority::1%{code_end})"
msgstr ""

View File

@ -15,7 +15,6 @@ import {
} from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import createFlash from '~/flash';
import AlertManagementList from '~/alert_management/components/alert_management_list.vue';
import {
ALERTS_STATUS_TABS,
@ -26,8 +25,6 @@ import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert
import mockAlerts from '../mocks/alerts.json';
import Tracking from '~/tracking';
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
@ -391,14 +388,15 @@ describe('AlertManagementList', () => {
});
});
it('calls `createFlash` when request fails', () => {
it('shows an error when request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
findFirstStatusOption().vm.$emit('click');
wrapper.setData({
errored: true,
});
setImmediate(() => {
expect(createFlash).toHaveBeenCalledWith(
'There was an error while updating the status of the alert. Please try again.',
);
wrapper.vm.$nextTick(() => {
expect(wrapper.find('[data-testid="alert-error"]').exists()).toBe(true);
});
});
});

View File

@ -1,4 +1,4 @@
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { trackAlertStatusUpdateOptions } from '~/alert_management/constants';
import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue';
@ -14,7 +14,7 @@ describe('Alert Details Sidebar Status', () => {
const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon);
function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) {
wrapper = shallowMount(AlertSidebarStatus, {
wrapper = mount(AlertSidebarStatus, {
propsData: {
alert: { ...mockAlert },
...data,

View File

@ -110,7 +110,7 @@ describe('DiffGutterAvatars', () => {
it('returns truncated version of comment if it is longer than max length', () => {
const note = wrapper.vm.discussions[0].notes[1];
expect(wrapper.vm.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is r...');
expect(wrapper.vm.getTooltipText(note)).toEqual('Fatih Acet: comment 2 is rea…');
});
});
});

View File

@ -1,17 +1,21 @@
import createEventHub from '~/helpers/event_hub_factory';
const TEST_EVENT = 'foobar';
const TEST_EVENT_2 = 'testevent';
describe('event bus factory', () => {
let eventBus;
let handler;
let otherHandlers;
beforeEach(() => {
eventBus = createEventHub();
handler = jest.fn();
otherHandlers = [jest.fn(), jest.fn()];
});
afterEach(() => {
eventBus.dispose();
eventBus = null;
});
@ -48,22 +52,6 @@ describe('event bus factory', () => {
expect(handler).toHaveBeenCalledTimes(2);
});
it('does not call handler after $off with handler', () => {
eventBus.$off(TEST_EVENT, handler);
eventBus.$emit(TEST_EVENT);
expect(handler).not.toHaveBeenCalled();
});
it('does not call handler after $off', () => {
eventBus.$off(TEST_EVENT);
eventBus.$emit(TEST_EVENT);
expect(handler).not.toHaveBeenCalled();
});
});
describe('$once', () => {
@ -102,4 +90,55 @@ describe('event bus factory', () => {
});
});
});
describe('$off', () => {
beforeEach(() => {
otherHandlers.forEach(x => eventBus.$on(TEST_EVENT, x));
eventBus.$on(TEST_EVENT, handler);
});
it('can be called on event with no handlers', () => {
expect(() => {
eventBus.$off(TEST_EVENT_2);
}).not.toThrow();
});
it('can be called on event with no handlers, with a handler', () => {
expect(() => {
eventBus.$off(TEST_EVENT_2, handler);
}).not.toThrow();
});
it('with a handler, will no longer call that handler', () => {
eventBus.$off(TEST_EVENT, handler);
eventBus.$emit(TEST_EVENT);
expect(handler).not.toHaveBeenCalled();
expect(otherHandlers.map(x => x.mock.calls.length)).toEqual(otherHandlers.map(() => 1));
});
it('without a handler, will no longer call any handlers', () => {
eventBus.$off(TEST_EVENT);
eventBus.$emit(TEST_EVENT);
expect(handler).not.toHaveBeenCalled();
expect(otherHandlers.map(x => x.mock.calls.length)).toEqual(otherHandlers.map(() => 0));
});
});
describe('$emit', () => {
beforeEach(() => {
otherHandlers.forEach(x => eventBus.$on(TEST_EVENT_2, x));
eventBus.$on(TEST_EVENT, handler);
});
it('only calls handlers for given type', () => {
eventBus.$emit(TEST_EVENT, 'arg1');
expect(handler).toHaveBeenCalledWith('arg1');
expect(otherHandlers.map(x => x.mock.calls.length)).toEqual(otherHandlers.map(() => 0));
});
});
});

View File

@ -145,6 +145,56 @@ describe('text_utility', () => {
});
});
describe('truncate', () => {
it('returns the original string when str length is less than maxLength', () => {
const str = 'less than 20 chars';
expect(textUtils.truncate(str, 20)).toEqual(str);
});
it('returns truncated string when str length is more than maxLength', () => {
const str = 'more than 10 chars';
expect(textUtils.truncate(str, 10)).toEqual(`${str.substring(0, 10 - 1)}`);
});
it('returns the original string when rendered width is exactly equal to maxWidth', () => {
const str = 'Exactly 16 chars';
expect(textUtils.truncate(str, 16)).toEqual(str);
});
});
describe('truncateWidth', () => {
const clientWidthDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'clientWidth');
beforeAll(() => {
// Mock measured width of ' ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
Object.defineProperty(Element.prototype, 'clientWidth', {
value: 431,
writable: false,
});
});
afterAll(() => {
Object.defineProperty(Element.prototype, 'clientWidth', clientWidthDescriptor);
});
it('returns the original string when rendered width is less than maxWidth', () => {
const str = '< 80px';
expect(textUtils.truncateWidth(str)).toEqual(str);
});
it('returns truncated string when rendered width is more than maxWidth', () => {
const str = 'This is wider than 80px';
expect(textUtils.truncateWidth(str)).toEqual(`${str.substring(0, 10)}`);
});
it('returns the original string when rendered width is exactly equal to maxWidth', () => {
const str = 'Exactly 159.62962962962965px';
expect(textUtils.truncateWidth(str, { maxWidth: 159.62962962962965, fontSize: 10 })).toEqual(
str,
);
});
});
describe('truncateSha', () => {
it('shortens SHAs to 8 characters', () => {
expect(textUtils.truncateSha('verylongsha')).toBe('verylong');

View File

@ -4,6 +4,7 @@ import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue';
import { ASSET_LINK_TYPE } from '~/releases/constants';
import { trimText } from 'helpers/text_helper';
import { assets } from '../mock_data';
import { cloneDeep } from 'lodash';
describe('Release block assets', () => {
let wrapper;
@ -30,7 +31,7 @@ describe('Release block assets', () => {
wrapper.findAll('h5').filter(h5 => h5.text() === sections[type]);
beforeEach(() => {
defaultProps = { assets };
defaultProps = { assets: cloneDeep(assets) };
});
describe('with default props', () => {
@ -96,6 +97,35 @@ describe('Release block assets', () => {
});
});
describe('sources', () => {
const testSources = ({ shouldSourcesBeRendered }) => {
assets.sources.forEach(s => {
expect(wrapper.find(`a[href="${s.url}"]`).exists()).toBe(shouldSourcesBeRendered);
});
};
describe('when the release has sources', () => {
beforeEach(() => {
createComponent(defaultProps);
});
it('renders sources', () => {
testSources({ shouldSourcesBeRendered: true });
});
});
describe('when the release does not have sources', () => {
beforeEach(() => {
delete defaultProps.assets.sources;
createComponent(defaultProps);
});
it('does not render any sources', () => {
testSources({ shouldSourcesBeRendered: false });
});
});
});
describe('external vs internal links', () => {
const containsExternalSourceIndicator = () =>
wrapper.contains('[data-testid="external-link-indicator"]');

View File

@ -2,7 +2,7 @@ import {
buildUneditableOpenTokens,
buildUneditableCloseToken,
buildUneditableTokens,
} from '~/vue_shared/components/rich_content_editor/services/renderers//build_uneditable_token';
} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import {
originToken,

View File

@ -2,7 +2,7 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/rende
import {
buildUneditableOpenTokens,
buildUneditableCloseToken,
} from '~/vue_shared/components/rich_content_editor/services/renderers//build_uneditable_token';
} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import { kramdownListNode, normalListNode } from '../../mock_data';

View File

@ -1,5 +1,5 @@
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text';
import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers//build_uneditable_token';
import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
import { kramdownTextNode, normalTextNode } from '../../mock_data';

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['MilestoneStats'] do
it { expect(described_class).to require_graphql_authorizations(:read_milestone) }
it 'has the expected fields' do
expected_fields = %w[
total_issues_count closed_issues_count
]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end

View File

@ -6,4 +6,21 @@ RSpec.describe GitlabSchema.types['Milestone'] do
specify { expect(described_class.graphql_name).to eq('Milestone') }
specify { expect(described_class).to require_graphql_authorizations(:read_milestone) }
it 'has the expected fields' do
expected_fields = %w[
id title description state web_path
due_date start_date created_at updated_at
project_milestone group_milestone subgroup_milestone
stats
]
expect(described_class).to have_graphql_fields(*expected_fields)
end
describe 'stats field' do
subject { described_class.fields['stats'] }
it { is_expected.to have_graphql_type(Types::MilestoneStatsType) }
end
end

View File

@ -13,13 +13,15 @@ describe Gitlab::Ci::Build::Releaser do
name: 'Release $CI_COMMIT_SHA',
description: 'Created using the release-cli $EXTRA_DESCRIPTION',
tag_name: 'release-$CI_COMMIT_SHA',
ref: '$CI_COMMIT_SHA'
ref: '$CI_COMMIT_SHA',
milestones: %w[m1 m2 m3],
released_at: '2020-07-15T08:00:00Z'
}
}
end
it 'generates the script' do
expect(subject).to eq(['release-cli create --name "Release $CI_COMMIT_SHA" --description "Created using the release-cli $EXTRA_DESCRIPTION" --tag-name "release-$CI_COMMIT_SHA" --ref "$CI_COMMIT_SHA"'])
expect(subject).to eq(['release-cli create --name "Release $CI_COMMIT_SHA" --description "Created using the release-cli $EXTRA_DESCRIPTION" --tag-name "release-$CI_COMMIT_SHA" --ref "$CI_COMMIT_SHA" --released-at "2020-07-15T08:00:00Z" --milestone "m1" --milestone "m2" --milestone "m3"'])
end
end
@ -27,10 +29,12 @@ describe Gitlab::Ci::Build::Releaser do
using RSpec::Parameterized::TableSyntax
where(:node_name, :node_value, :result) do
'name' | 'Release $CI_COMMIT_SHA' | 'release-cli create --name "Release $CI_COMMIT_SHA"'
'description' | 'Release-cli $EXTRA_DESCRIPTION' | 'release-cli create --description "Release-cli $EXTRA_DESCRIPTION"'
'tag_name' | 'release-$CI_COMMIT_SHA' | 'release-cli create --tag-name "release-$CI_COMMIT_SHA"'
'ref' | '$CI_COMMIT_SHA' | 'release-cli create --ref "$CI_COMMIT_SHA"'
:name | 'Release $CI_COMMIT_SHA' | 'release-cli create --name "Release $CI_COMMIT_SHA"'
:description | 'Release-cli $EXTRA_DESCRIPTION' | 'release-cli create --description "Release-cli $EXTRA_DESCRIPTION"'
:tag_name | 'release-$CI_COMMIT_SHA' | 'release-cli create --tag-name "release-$CI_COMMIT_SHA"'
:ref | '$CI_COMMIT_SHA' | 'release-cli create --ref "$CI_COMMIT_SHA"'
:milestones | %w[m1 m2 m3] | 'release-cli create --milestone "m1" --milestone "m2" --milestone "m3"'
:released_at | '2020-07-15T08:00:00Z' | 'release-cli create --released-at "2020-07-15T08:00:00Z"'
end
with_them do

View File

@ -5,21 +5,32 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::Entry::Release do
let(:entry) { described_class.new(config) }
shared_examples_for 'a valid entry' do
describe '#value' do
it 'returns release configuration' do
expect(entry.value).to eq config
end
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
end
shared_examples_for 'reports error' do |message|
it 'reports error' do
expect(entry.errors)
.to include message
end
end
describe 'validation' do
context 'when entry config value is correct' do
let(:config) { { tag_name: 'v0.06', description: "./release_changelog.txt" } }
describe '#value' do
it 'returns release configuration' do
expect(entry.value).to eq config
end
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
it_behaves_like 'a valid entry'
end
context "when value includes 'assets' keyword" do
@ -36,17 +47,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Release do
}
end
describe '#value' do
it 'returns release configuration' do
expect(entry.value).to eq config
end
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
end
it_behaves_like 'a valid entry'
end
context "when value includes 'name' keyword" do
@ -58,16 +59,104 @@ RSpec.describe Gitlab::Ci::Config::Entry::Release do
}
end
describe '#value' do
it 'returns release configuration' do
expect(entry.value).to eq config
end
it_behaves_like 'a valid entry'
end
context "when value includes 'ref' keyword" do
let(:config) do
{
tag_name: 'v0.06',
description: "./release_changelog.txt",
name: "Release $CI_TAG_NAME",
ref: 'b3235930aa443112e639f941c69c578912189bdd'
}
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
it_behaves_like 'a valid entry'
end
context "when value includes 'released_at' keyword" do
let(:config) do
{
tag_name: 'v0.06',
description: "./release_changelog.txt",
name: "Release $CI_TAG_NAME",
released_at: '2019-03-15T08:00:00Z'
}
end
it_behaves_like 'a valid entry'
end
context "when value includes 'milestones' keyword" do
let(:config) do
{
tag_name: 'v0.06',
description: "./release_changelog.txt",
name: "Release $CI_TAG_NAME",
milestones: milestones
}
end
context 'for an array of milestones' do
let(:milestones) { %w[m1 m2 m3] }
it_behaves_like 'a valid entry'
end
context 'for a single milestone' do
let(:milestones) { 'm1' }
it_behaves_like 'a valid entry'
end
end
context "when value includes 'ref' keyword" do
let(:config) do
{
tag_name: 'v0.06',
description: "./release_changelog.txt",
name: "Release $CI_TAG_NAME",
ref: 'b3235930aa443112e639f941c69c578912189bdd'
}
end
it_behaves_like 'a valid entry'
end
context "when value includes 'released_at' keyword" do
let(:config) do
{
tag_name: 'v0.06',
description: "./release_changelog.txt",
name: "Release $CI_TAG_NAME",
released_at: '2019-03-15T08:00:00Z'
}
end
it_behaves_like 'a valid entry'
end
context "when value includes 'milestones' keyword" do
let(:config) do
{
tag_name: 'v0.06',
description: "./release_changelog.txt",
name: "Release $CI_TAG_NAME",
milestones: milestones
}
end
context 'for an array of milestones' do
let(:milestones) { %w[m1 m2 m3] }
it_behaves_like 'a valid entry'
end
context 'for a single milestone' do
let(:milestones) { 'm1' }
it_behaves_like 'a valid entry'
end
end
@ -76,37 +165,61 @@ RSpec.describe Gitlab::Ci::Config::Entry::Release do
context 'when value of attribute is invalid' do
let(:config) { { description: 10 } }
it 'reports error' do
expect(entry.errors)
.to include 'release description should be a string'
end
it_behaves_like 'reports error', 'release description should be a string'
end
context 'when release description is missing' do
let(:config) { { tag_name: 'v0.06' } }
it 'reports error' do
expect(entry.errors)
.to include "release description can't be blank"
end
it_behaves_like 'reports error', "release description can't be blank"
end
context 'when release tag_name is missing' do
let(:config) { { description: "./release_changelog.txt" } }
it 'reports error' do
expect(entry.errors)
.to include "release tag name can't be blank"
end
it_behaves_like 'reports error', "release tag name can't be blank"
end
context 'when there is an unknown key present' do
let(:config) { { test: 100 } }
it 'reports error' do
expect(entry.errors)
.to include 'release config contains unknown keys: test'
end
it_behaves_like 'reports error', 'release config contains unknown keys: test'
end
context 'when `released_at` is not a valid date' do
let(:config) { { released_at: 'ABC123' } }
it_behaves_like 'reports error', 'release released at must be a valid datetime'
end
context 'when `ref` is not valid' do
let(:config) { { ref: 'ABC123' } }
it_behaves_like 'reports error', 'release ref must be a valid ref'
end
context 'when `milestones` is not an array of strings' do
let(:config) { { milestones: [1, 2, 3] } }
it_behaves_like 'reports error', 'release milestones should be an array of strings or a string'
end
context 'when `released_at` is not a valid date' do
let(:config) { { released_at: 'ABC123' } }
it_behaves_like 'reports error', 'release released at must be a valid datetime'
end
context 'when `ref` is not valid' do
let(:config) { { ref: 'ABC123' } }
it_behaves_like 'reports error', 'release ref must be a valid ref'
end
context 'when `milestones` is not an array of strings' do
let(:config) { { milestones: [1, 2, 3] } }
it_behaves_like 'reports error', 'release milestones should be an array of strings or a string'
end
end
end

View File

@ -1397,6 +1397,9 @@ module Gitlab
tag_name: "$CI_COMMIT_TAG",
name: "Release $CI_TAG_NAME",
description: "./release_changelog.txt",
ref: 'b3235930aa443112e639f941c69c578912189bdd',
released_at: '2019-03-15T08:00:00Z',
milestones: %w[m1 m2 m3],
assets: {
links: [
{

View File

@ -0,0 +1,84 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Diff::StatsCache, :use_clean_rails_memory_store_caching do
subject(:stats_cache) { described_class.new(cachable_key: cachable_key) }
let(:key) { ['diff_stats', cachable_key, described_class::VERSION].join(":") }
let(:cachable_key) { 'cachecachecache' }
let(:stat) { Gitaly::DiffStats.new(path: 'temp', additions: 10, deletions: 15) }
let(:stats) { Gitlab::Git::DiffStatsCollection.new([stat]) }
let(:cache) { Rails.cache }
describe '#read' do
before do
stats_cache.write_if_empty(stats)
end
it 'returns the expected stats' do
expect(stats_cache.read.to_json).to eq(stats.to_json)
end
end
describe '#write_if_empty' do
context 'when the cache already exists' do
before do
Rails.cache.write(key, true)
end
it 'does not write the stats' do
expect(cache).not_to receive(:write)
stats_cache.write_if_empty(stats)
end
end
context 'when the cache does not exist' do
it 'writes the stats' do
expect(cache)
.to receive(:write)
.with(key, stats.as_json, expires_in: described_class::EXPIRATION)
.and_call_original
stats_cache.write_if_empty(stats)
expect(stats_cache.read.to_a).to eq(stats.to_a)
end
context 'when given non utf-8 characters' do
let(:non_utf8_path) { '你好'.b }
let(:stat) { Gitaly::DiffStats.new(path: non_utf8_path, additions: 10, deletions: 15) }
it 'writes the stats' do
expect(cache)
.to receive(:write)
.with(key, stats.as_json, expires_in: described_class::EXPIRATION)
.and_call_original
stats_cache.write_if_empty(stats)
expect(stats_cache.read.to_a).to eq(stats.to_a)
end
end
context 'when given empty stats' do
let(:stats) { nil }
it 'does not write the stats' do
expect(cache).not_to receive(:write)
stats_cache.write_if_empty(stats)
end
end
end
end
describe '#clear' do
it 'clears cache' do
expect(cache).to receive(:delete).with(key)
stats_cache.clear
end
end
end

View File

@ -4,11 +4,11 @@ require "spec_helper"
describe Gitlab::Git::DiffStatsCollection do
let(:stats_a) do
double(Gitaly::DiffStats, additions: 10, deletions: 15, path: 'foo')
Gitaly::DiffStats.new(additions: 10, deletions: 15, path: 'foo')
end
let(:stats_b) do
double(Gitaly::DiffStats, additions: 5, deletions: 1, path: 'bar')
Gitaly::DiffStats.new(additions: 5, deletions: 1, path: 'bar')
end
let(:diff_stats) { [stats_a, stats_b] }

View File

@ -7,16 +7,17 @@ RSpec.describe 'Milestones through GroupQuery' do
let_it_be(:user) { create(:user) }
let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group) }
let_it_be(:milestone_1) { create(:milestone, group: group) }
let_it_be(:milestone_2) { create(:milestone, group: group, state: :closed, start_date: now, due_date: now + 1.day) }
let_it_be(:milestone_3) { create(:milestone, group: group, start_date: now, due_date: now + 2.days) }
let_it_be(:milestone_4) { create(:milestone, group: group, state: :closed, start_date: now - 2.days, due_date: now - 1.day) }
let_it_be(:milestone_from_other_group) { create(:milestone, group: create(:group)) }
let(:milestone_data) { graphql_data['group']['milestones']['edges'] }
describe 'Get list of milestones from a group' do
let_it_be(:group) { create(:group) }
let_it_be(:milestone_1) { create(:milestone, group: group) }
let_it_be(:milestone_2) { create(:milestone, group: group, state: :closed, start_date: now, due_date: now + 1.day) }
let_it_be(:milestone_3) { create(:milestone, group: group, start_date: now, due_date: now + 2.days) }
let_it_be(:milestone_4) { create(:milestone, group: group, state: :closed, start_date: now - 2.days, due_date: now - 1.day) }
let_it_be(:milestone_from_other_group) { create(:milestone, group: create(:group)) }
let(:milestone_data) { graphql_data['group']['milestones']['edges'] }
context 'when the request is correct' do
before do
fetch_milestones(user)
@ -120,4 +121,89 @@ RSpec.describe 'Milestones through GroupQuery' do
node_array(milestone_data, extract_attribute)
end
end
describe 'ensures each field returns the correct value' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:milestone) { create(:milestone, group: group, start_date: now, due_date: now + 1.day) }
let_it_be(:open_issue) { create(:issue, project: project, milestone: milestone) }
let_it_be(:closed_issue) { create(:issue, :closed, project: project, milestone: milestone) }
let(:milestone_query) do
%{
id
title
description
state
webPath
dueDate
startDate
createdAt
updatedAt
projectMilestone
groupMilestone
subgroupMilestone
}
end
def post_query
full_query = graphql_query_for("group",
{ full_path: group.full_path },
[query_graphql_field("milestones", nil, "nodes { #{milestone_query} }")]
)
post_graphql(full_query, current_user: user)
graphql_data.dig('group', 'milestones', 'nodes', 0)
end
it 'returns correct values for scalar fields' do
expect(post_query).to eq({
'id' => global_id_of(milestone),
'title' => milestone.title,
'description' => milestone.description,
'state' => 'active',
'webPath' => milestone_path(milestone),
'dueDate' => milestone.due_date.iso8601,
'startDate' => milestone.start_date.iso8601,
'createdAt' => milestone.created_at.iso8601,
'updatedAt' => milestone.updated_at.iso8601,
'projectMilestone' => false,
'groupMilestone' => true,
'subgroupMilestone' => false
})
end
context 'milestone statistics' do
let(:milestone_query) do
%{
stats {
totalIssuesCount
closedIssuesCount
}
}
end
it 'returns the correct milestone statistics' do
expect(post_query).to eq({
'stats' => {
'totalIssuesCount' => 2,
'closedIssuesCount' => 1
}
})
end
context 'when the graphql_milestone_stats feature flag is disabled' do
before do
stub_feature_flags(graphql_milestone_stats: false)
end
it 'returns nil for the stats field' do
expect(post_query).to eq({
'stats' => nil
})
end
end
end
end
end

View File

@ -668,7 +668,7 @@ RSpec.describe API::Runner, :clean_gitlab_redis_shared_state do
{
"name" => "release",
"script" =>
["release-cli create --ref \"$CI_COMMIT_SHA\" --name \"Release $CI_COMMIT_SHA\" --tag-name \"release-$CI_COMMIT_SHA\" --description \"Created using the release-cli $EXTRA_DESCRIPTION\""],
["release-cli create --name \"Release $CI_COMMIT_SHA\" --description \"Created using the release-cli $EXTRA_DESCRIPTION\" --tag-name \"release-$CI_COMMIT_SHA\" --ref \"$CI_COMMIT_SHA\""],
"timeout" => 3600,
"when" => "on_success",
"allow_failure" => false

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe DesignManagement::GenerateImageVersionsService do
RSpec.describe DesignManagement::GenerateImageVersionsService do
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:version) { create(:design, :with_lfs_file, issue: issue).versions.first }

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
describe DesignManagement::SaveDesignsService do
RSpec.describe DesignManagement::SaveDesignsService do
include DesignManagementTestHelpers
include ConcurrentHelpers

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Discussions::CaptureDiffNotePositionService do
RSpec.describe Discussions::CaptureDiffNotePositionService do
subject { described_class.new(note.noteable, paths) }
context 'image note on diff' do

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Discussions::CaptureDiffNotePositionsService do
RSpec.describe Discussions::CaptureDiffNotePositionsService do
context 'when merge request has a discussion' do
let(:source_branch) { 'compare-with-merge-head-source' }
let(:target_branch) { 'compare-with-merge-head-target' }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Discussions::ResolveService do
RSpec.describe Discussions::ResolveService do
describe '#execute' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user, developer_projects: [project]) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Discussions::UpdateDiffPositionService do
RSpec.describe Discussions::UpdateDiffPositionService do
let(:project) { create(:project, :repository) }
let(:current_user) { project.owner }
let(:create_commit) { project.commit("913c66a37b4a45b9769037c55c2d238bd0942d2e") }

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
describe DraftNotes::CreateService do
RSpec.describe DraftNotes::CreateService do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.target_project }
let(:user) { merge_request.author }

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
describe DraftNotes::DestroyService do
RSpec.describe DraftNotes::DestroyService do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.target_project }
let(:user) { merge_request.author }

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
describe DraftNotes::PublishService do
RSpec.describe DraftNotes::PublishService do
include RepoHelpers
let(:merge_request) { create(:merge_request) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Emails::ConfirmService do
RSpec.describe Emails::ConfirmService do
let(:user) { create(:user) }
subject(:service) { described_class.new(user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Emails::CreateService do
RSpec.describe Emails::CreateService do
let(:user) { create(:user) }
let(:opts) { { email: 'new@email.com', user: user } }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Emails::DestroyService do
RSpec.describe Emails::DestroyService do
let!(:user) { create(:user) }
let!(:email) { create(:email, user: user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Environments::AutoStopService, :clean_gitlab_redis_shared_state do
RSpec.describe Environments::AutoStopService, :clean_gitlab_redis_shared_state do
include CreateEnvironmentsHelpers
include ExclusiveLeaseHelpers

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Environments::ResetAutoStopService do
RSpec.describe Environments::ResetAutoStopService do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let_it_be(:reporter) { create(:user).tap { |user| project.add_reporter(user) } }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe ErrorTracking::BaseService do
RSpec.describe ErrorTracking::BaseService do
describe '#compose_response' do
let(:project) { double('project') }
let(:user) { double('user') }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe ErrorTracking::IssueDetailsService do
RSpec.describe ErrorTracking::IssueDetailsService do
include_context 'sentry error tracking context'
subject { described_class.new(project, user, params) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe ErrorTracking::IssueLatestEventService do
RSpec.describe ErrorTracking::IssueLatestEventService do
include_context 'sentry error tracking context'
subject { described_class.new(project, user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe ErrorTracking::IssueUpdateService do
RSpec.describe ErrorTracking::IssueUpdateService do
include_context 'sentry error tracking context'
let(:arguments) { { issue_id: non_existing_record_id, status: 'resolved' } }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe ErrorTracking::ListIssuesService do
RSpec.describe ErrorTracking::ListIssuesService do
include_context 'sentry error tracking context'
let(:params) { { search_term: 'something', sort: 'last_seen', cursor: 'some-cursor' } }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe ErrorTracking::ListProjectsService do
RSpec.describe ErrorTracking::ListProjectsService do
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe EventCreateService do
RSpec.describe EventCreateService do
let(:service) { described_class.new }
let_it_be(:user, reload: true) { create :user }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Events::RenderService do
RSpec.describe Events::RenderService do
describe '#execute' do
let!(:note) { build(:note) }
let!(:event) { build(:event, target: note, project: note.project) }

View File

@ -2,7 +2,7 @@
require "spec_helper"
describe Files::CreateService do
RSpec.describe Files::CreateService do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:user) { create(:user, :commit_email) }

View File

@ -2,7 +2,7 @@
require "spec_helper"
describe Files::DeleteService do
RSpec.describe Files::DeleteService do
subject { described_class.new(project, user, commit_params) }
let(:project) { create(:project, :repository) }

View File

@ -2,7 +2,7 @@
require "spec_helper"
describe Files::MultiService do
RSpec.describe Files::MultiService do
subject { described_class.new(project, user, commit_params) }
let(:project) { create(:project, :repository) }

View File

@ -2,7 +2,7 @@
require "spec_helper"
describe Files::UpdateService do
RSpec.describe Files::UpdateService do
subject { described_class.new(project, user, commit_params) }
let(:project) { create(:project, :repository) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Git::BaseHooksService do
RSpec.describe Git::BaseHooksService do
include RepoHelpers
include GitHelpers

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Git::BranchHooksService do
RSpec.describe Git::BranchHooksService do
include RepoHelpers
include ProjectForksHelper

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Git::BranchPushService, services: true do
RSpec.describe Git::BranchPushService, services: true do
include RepoHelpers
let_it_be(:user) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Git::ProcessRefChangesService do
RSpec.describe Git::ProcessRefChangesService do
let(:project) { create(:project, :repository) }
let(:user) { project.owner }
let(:params) { { changes: git_changes } }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Git::TagHooksService, :service do
RSpec.describe Git::TagHooksService, :service do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Git::TagPushService do
RSpec.describe Git::TagPushService do
include RepoHelpers
include GitHelpers

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Git::WikiPushService::Change do
RSpec.describe Git::WikiPushService::Change do
subject { described_class.new(project_wiki, change, raw_change) }
let(:project_wiki) { double('ProjectWiki') }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Git::WikiPushService, services: true do
RSpec.describe Git::WikiPushService, services: true do
include RepoHelpers
let_it_be(:key_id) { create(:key, user: current_user).shell_id }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe GpgKeys::CreateService do
RSpec.describe GpgKeys::CreateService do
let(:user) { create(:user) }
let(:params) { attributes_for(:gpg_key) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Grafana::ProxyService do
RSpec.describe Grafana::ProxyService do
include ReactiveCachingHelpers
let_it_be(:project) { create(:project) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe GravatarService do
RSpec.describe GravatarService do
describe '#execute' do
let(:url) { 'http://example.com/avatar?hash=%{hash}&size=%{size}&email=%{email}&username=%{username}' }

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
describe Groups::AutoDevopsService, '#execute' do
RSpec.describe Groups::AutoDevopsService, '#execute' do
let_it_be(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let(:group_params) { { auto_devops_enabled: '0' } }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::CreateService, '#execute' do
RSpec.describe Groups::CreateService, '#execute' do
let!(:user) { create(:user) }
let!(:group_params) { { path: "group_path", visibility_level: Gitlab::VisibilityLevel::PUBLIC } }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::DeployTokens::CreateService do
RSpec.describe Groups::DeployTokens::CreateService do
it_behaves_like 'a deploy token creation service' do
let(:entity) { create(:group) }
let(:deploy_token_class) { GroupDeployToken }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::DeployTokens::DestroyService do
RSpec.describe Groups::DeployTokens::DestroyService do
it_behaves_like 'a deploy token deletion service' do
let_it_be(:entity) { create(:group) }
let_it_be(:deploy_token_class) { GroupDeployToken }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::DestroyService do
RSpec.describe Groups::DestroyService do
include DatabaseConnectionHelpers
let!(:user) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::GroupLinks::CreateService, '#execute' do
RSpec.describe Groups::GroupLinks::CreateService, '#execute' do
let(:parent_group_user) { create(:user) }
let(:group_user) { create(:user) }
let(:child_group_user) { create(:user) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::GroupLinks::DestroyService, '#execute' do
RSpec.describe Groups::GroupLinks::DestroyService, '#execute' do
let(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::GroupLinks::UpdateService, '#execute' do
RSpec.describe Groups::GroupLinks::UpdateService, '#execute' do
let(:user) { create(:user) }
let_it_be(:group) { create(:group, :private) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::ImportExport::ExportService do
RSpec.describe Groups::ImportExport::ExportService do
describe '#async_execute' do
let(:user) { create(:user) }
let(:group) { create(:group) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::ImportExport::ImportService do
RSpec.describe Groups::ImportExport::ImportService do
describe '#async_execute' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::NestedCreateService do
RSpec.describe Groups::NestedCreateService do
let(:user) { create(:user) }
subject(:service) { described_class.new(user, params) }

View File

@ -2,7 +2,7 @@
require 'spec_helper'
describe Groups::TransferService do
RSpec.describe Groups::TransferService do
let(:user) { create(:user) }
let(:new_parent_group) { create(:group, :public) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }

Some files were not shown because too many files have changed in this diff Show More