Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
6b9d3a4e83
commit
27d314277b
27 changed files with 668 additions and 233 deletions
223
app/assets/javascripts/lib/utils/datetime_range.js
Normal file
223
app/assets/javascripts/lib/utils/datetime_range.js
Normal file
|
@ -0,0 +1,223 @@
|
|||
import dateformat from 'dateformat';
|
||||
import { secondsToMilliseconds } from './datetime_utility';
|
||||
|
||||
const MINIMUM_DATE = new Date(0);
|
||||
|
||||
const DEFAULT_DIRECTION = 'before';
|
||||
|
||||
const durationToMillis = duration => {
|
||||
if (Object.entries(duration).length === 1 && Number.isFinite(duration.seconds)) {
|
||||
return secondsToMilliseconds(duration.seconds);
|
||||
}
|
||||
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
|
||||
throw new Error('Invalid duration: only `seconds` is supported');
|
||||
};
|
||||
|
||||
const dateMinusDuration = (date, duration) => new Date(date.getTime() - durationToMillis(duration));
|
||||
|
||||
const datePlusDuration = (date, duration) => new Date(date.getTime() + durationToMillis(duration));
|
||||
|
||||
const isValidDuration = duration => Boolean(duration && Number.isFinite(duration.seconds));
|
||||
|
||||
const isValidDateString = dateString => {
|
||||
if (typeof dateString !== 'string' || !dateString.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// dateformat throws error that can be caught.
|
||||
// This is better than using `new Date()`
|
||||
dateformat(dateString, 'isoUtcDateTime');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => {
|
||||
let startDate;
|
||||
let endDate;
|
||||
|
||||
if (direction === DEFAULT_DIRECTION) {
|
||||
startDate = minDate;
|
||||
endDate = anchorDate;
|
||||
} else {
|
||||
startDate = anchorDate;
|
||||
endDate = maxDate;
|
||||
}
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a fixed range to a fixed range
|
||||
* @param {Object} fixedRange - A range with fixed start and
|
||||
* end (e.g. "midnight January 1st 2020 to midday January31st 2020")
|
||||
*/
|
||||
const convertFixedToFixed = ({ start, end }) => ({
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts an anchored range to a fixed range
|
||||
* @param {Object} anchoredRange - A duration of time
|
||||
* relative to a fixed point in time (e.g., "the 30 minutes
|
||||
* before midnight January 1st 2020", or "the 2 days
|
||||
* after midday on the 11th of May 2019")
|
||||
*/
|
||||
const convertAnchoredToFixed = ({ anchor, duration, direction }) => {
|
||||
const anchorDate = new Date(anchor);
|
||||
|
||||
const { startDate, endDate } = handleRangeDirection({
|
||||
minDate: dateMinusDuration(anchorDate, duration),
|
||||
maxDate: datePlusDuration(anchorDate, duration),
|
||||
direction,
|
||||
anchorDate,
|
||||
});
|
||||
|
||||
return {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a rolling change to a fixed range
|
||||
*
|
||||
* @param {Object} rollingRange - A time range relative to
|
||||
* now (e.g., "last 2 minutes", or "next 2 days")
|
||||
*/
|
||||
const convertRollingToFixed = ({ duration, direction }) => {
|
||||
// Use Date.now internally for easier mocking in tests
|
||||
const now = new Date(Date.now());
|
||||
|
||||
return convertAnchoredToFixed({
|
||||
duration,
|
||||
direction,
|
||||
anchor: now.toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an open range to a fixed range
|
||||
*
|
||||
* @param {Object} openRange - A time range relative
|
||||
* to an anchor (e.g., "before midnight on the 1st of
|
||||
* January 2020", or "after midday on the 11th of May 2019")
|
||||
*/
|
||||
const convertOpenToFixed = ({ anchor, direction }) => {
|
||||
// Use Date.now internally for easier mocking in tests
|
||||
const now = new Date(Date.now());
|
||||
|
||||
const { startDate, endDate } = handleRangeDirection({
|
||||
minDate: MINIMUM_DATE,
|
||||
maxDate: now,
|
||||
direction,
|
||||
anchorDate: new Date(anchor),
|
||||
});
|
||||
|
||||
return {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles invalid date ranges
|
||||
*/
|
||||
const handleInvalidRange = () => {
|
||||
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
|
||||
throw new Error('The input range does not have the right format.');
|
||||
};
|
||||
|
||||
const handlers = {
|
||||
invalid: handleInvalidRange,
|
||||
fixed: convertFixedToFixed,
|
||||
anchored: convertAnchoredToFixed,
|
||||
rolling: convertRollingToFixed,
|
||||
open: convertOpenToFixed,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and returns the type of range
|
||||
*
|
||||
* @param {Object} Date time range
|
||||
* @returns {String} `key` value for one of the handlers
|
||||
*/
|
||||
export function getRangeType(range) {
|
||||
const { start, end, anchor, duration } = range;
|
||||
|
||||
if ((start || end) && !anchor && !duration) {
|
||||
return isValidDateString(start) && isValidDateString(end) ? 'fixed' : 'invalid';
|
||||
}
|
||||
if (anchor && duration) {
|
||||
return isValidDateString(anchor) && isValidDuration(duration) ? 'anchored' : 'invalid';
|
||||
}
|
||||
if (duration && !anchor) {
|
||||
return isValidDuration(duration) ? 'rolling' : 'invalid';
|
||||
}
|
||||
if (anchor && !duration) {
|
||||
return isValidDateString(anchor) ? 'open' : 'invalid';
|
||||
}
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
/**
|
||||
* convertToFixedRange Transforms a `range of time` into a `fixed range of time`.
|
||||
*
|
||||
* The following types of a `ranges of time` can be represented:
|
||||
*
|
||||
* Fixed Range: A range with fixed start and end (e.g. "midnight January 1st 2020 to midday January 31st 2020")
|
||||
* Anchored Range: A duration of time relative to a fixed point in time (e.g., "the 30 minutes before midnight January 1st 2020", or "the 2 days after midday on the 11th of May 2019")
|
||||
* Rolling Range: A time range relative to now (e.g., "last 2 minutes", or "next 2 days")
|
||||
* Open Range: A time range relative to an anchor (e.g., "before midnight on the 1st of January 2020", or "after midday on the 11th of May 2019")
|
||||
*
|
||||
* @param {Object} dateTimeRange - A Time Range representation
|
||||
* It contains the data needed to create a fixed time range plus
|
||||
* a label (recommended) to indicate the range that is covered.
|
||||
*
|
||||
* A definition via a TypeScript notation is presented below:
|
||||
*
|
||||
*
|
||||
* type Duration = { // A duration of time, always in seconds
|
||||
* seconds: number;
|
||||
* }
|
||||
*
|
||||
* type Direction = 'before' | 'after'; // Direction of time relative to an anchor
|
||||
*
|
||||
* type FixedRange = {
|
||||
* start: ISO8601;
|
||||
* end: ISO8601;
|
||||
* label: string;
|
||||
* }
|
||||
*
|
||||
* type AnchoredRange = {
|
||||
* anchor: ISO8601;
|
||||
* duration: Duration;
|
||||
* direction: Direction; // defaults to 'before'
|
||||
* label: string;
|
||||
* }
|
||||
*
|
||||
* type RollingRange = {
|
||||
* duration: Duration;
|
||||
* direction: Direction; // defaults to 'before'
|
||||
* label: string;
|
||||
* }
|
||||
*
|
||||
* type OpenRange = {
|
||||
* anchor: ISO8601;
|
||||
* direction: Direction; // defaults to 'before'
|
||||
* label: string;
|
||||
* }
|
||||
*
|
||||
* type DateTimeRange = FixedRange | AnchoredRange | RollingRange | OpenRange;
|
||||
*
|
||||
*
|
||||
* @returns {FixedRange} An object with a start and end in ISO8601 format.
|
||||
*/
|
||||
export const convertToFixedRange = dateTimeRange =>
|
||||
handlers[getRangeType(dateTimeRange)](dateTimeRange);
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
|
||||
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
|
||||
import { GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
|
||||
import ciHeader from '~/vue_shared/components/header_ci_component.vue';
|
||||
import LoadingButton from '~/vue_shared/components/loading_button.vue';
|
||||
import eventHub from '../event_hub';
|
||||
import { __ } from '~/locale';
|
||||
|
||||
|
@ -12,6 +13,10 @@ export default {
|
|||
ciHeader,
|
||||
GlLoadingIcon,
|
||||
GlModal,
|
||||
LoadingButton,
|
||||
},
|
||||
directives: {
|
||||
GlModal: GlModalDirective,
|
||||
},
|
||||
props: {
|
||||
pipeline: {
|
||||
|
@ -25,7 +30,9 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
actions: this.getActions(),
|
||||
isCanceling: false,
|
||||
isRetrying: false,
|
||||
isDeleting: false,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -43,67 +50,18 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
pipeline() {
|
||||
this.actions = this.getActions();
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onActionClicked(action) {
|
||||
if (action.modal) {
|
||||
this.$root.$emit('bv::show::modal', action.modal);
|
||||
} else {
|
||||
this.postAction(action);
|
||||
}
|
||||
cancelPipeline() {
|
||||
this.isCanceling = true;
|
||||
eventHub.$emit('headerPostAction', this.pipeline.cancel_path);
|
||||
},
|
||||
postAction(action) {
|
||||
const index = this.actions.indexOf(action);
|
||||
|
||||
this.$set(this.actions[index], 'isLoading', true);
|
||||
|
||||
eventHub.$emit('headerPostAction', action);
|
||||
retryPipeline() {
|
||||
this.isRetrying = true;
|
||||
eventHub.$emit('headerPostAction', this.pipeline.retry_path);
|
||||
},
|
||||
deletePipeline() {
|
||||
const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID);
|
||||
|
||||
this.$set(this.actions[index], 'isLoading', true);
|
||||
|
||||
eventHub.$emit('headerDeleteAction', this.actions[index]);
|
||||
},
|
||||
|
||||
getActions() {
|
||||
const actions = [];
|
||||
|
||||
if (this.pipeline.retry_path) {
|
||||
actions.push({
|
||||
label: __('Retry'),
|
||||
path: this.pipeline.retry_path,
|
||||
cssClass: 'js-retry-button btn btn-inverted-secondary',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pipeline.cancel_path) {
|
||||
actions.push({
|
||||
label: __('Cancel running'),
|
||||
path: this.pipeline.cancel_path,
|
||||
cssClass: 'js-btn-cancel-pipeline btn btn-danger',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.pipeline.delete_path) {
|
||||
actions.push({
|
||||
label: __('Delete'),
|
||||
path: this.pipeline.delete_path,
|
||||
modal: DELETE_MODAL_ID,
|
||||
cssClass: 'js-btn-delete-pipeline btn btn-danger btn-inverted',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
this.isDeleting = true;
|
||||
eventHub.$emit('headerDeleteAction', this.pipeline.delete_path);
|
||||
},
|
||||
},
|
||||
DELETE_MODAL_ID,
|
||||
|
@ -117,10 +75,38 @@ export default {
|
|||
:item-id="pipeline.id"
|
||||
:time="pipeline.created_at"
|
||||
:user="pipeline.user"
|
||||
:actions="actions"
|
||||
item-name="Pipeline"
|
||||
@actionClicked="onActionClicked"
|
||||
/>
|
||||
>
|
||||
<loading-button
|
||||
v-if="pipeline.retry_path"
|
||||
:loading="isRetrying"
|
||||
:disabled="isRetrying"
|
||||
class="js-retry-button btn btn-inverted-secondary"
|
||||
container-class="d-inline"
|
||||
:label="__('Retry')"
|
||||
@click="retryPipeline()"
|
||||
/>
|
||||
|
||||
<loading-button
|
||||
v-if="pipeline.cancel_path"
|
||||
:loading="isCanceling"
|
||||
:disabled="isCanceling"
|
||||
class="js-btn-cancel-pipeline btn btn-danger"
|
||||
container-class="d-inline"
|
||||
:label="__('Cancel running')"
|
||||
@click="cancelPipeline()"
|
||||
/>
|
||||
|
||||
<loading-button
|
||||
v-if="pipeline.delete_path"
|
||||
v-gl-modal="$options.DELETE_MODAL_ID"
|
||||
:loading="isDeleting"
|
||||
:disabled="isDeleting"
|
||||
class="js-btn-delete-pipeline btn btn-danger btn-inverted"
|
||||
container-class="d-inline"
|
||||
:label="__('Delete')"
|
||||
/>
|
||||
</ci-header>
|
||||
|
||||
<gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" />
|
||||
|
||||
|
|
|
@ -70,16 +70,16 @@ export default () => {
|
|||
eventHub.$off('headerDeleteAction', this.deleteAction);
|
||||
},
|
||||
methods: {
|
||||
postAction(action) {
|
||||
postAction(path) {
|
||||
this.mediator.service
|
||||
.postAction(action.path)
|
||||
.postAction(path)
|
||||
.then(() => this.mediator.refreshPipeline())
|
||||
.catch(() => Flash(__('An error occurred while making the request.')));
|
||||
},
|
||||
deleteAction(action) {
|
||||
deleteAction(path) {
|
||||
this.mediator.stopPipelinePoll();
|
||||
this.mediator.service
|
||||
.deleteAction(action.path)
|
||||
.deleteAction(path)
|
||||
.then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success')))
|
||||
.catch(() => Flash(__('An error occurred while deleting the pipeline.')));
|
||||
},
|
||||
|
|
|
@ -4,7 +4,6 @@ import { __, sprintf } from '~/locale';
|
|||
import CiIconBadge from './ci_badge_link.vue';
|
||||
import TimeagoTooltip from './time_ago_tooltip.vue';
|
||||
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
|
||||
import LoadingButton from '~/vue_shared/components/loading_button.vue';
|
||||
|
||||
/**
|
||||
* Renders header component for job and pipeline page based on UI mockups
|
||||
|
@ -20,7 +19,6 @@ export default {
|
|||
UserAvatarImage,
|
||||
GlLink,
|
||||
GlButton,
|
||||
LoadingButton,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
|
@ -47,11 +45,6 @@ export default {
|
|||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
actions: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
hasSidebarButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
@ -71,9 +64,6 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
onClickAction(action) {
|
||||
this.$emit('actionClicked', action);
|
||||
},
|
||||
onClickSidebarButton() {
|
||||
this.$emit('clickedSidebarButton');
|
||||
},
|
||||
|
@ -115,18 +105,8 @@ export default {
|
|||
</template>
|
||||
</section>
|
||||
|
||||
<section v-if="actions.length" class="header-action-buttons">
|
||||
<template v-for="(action, i) in actions">
|
||||
<loading-button
|
||||
:key="i"
|
||||
:loading="action.isLoading"
|
||||
:disabled="action.isLoading"
|
||||
:class="action.cssClass"
|
||||
container-class="d-inline"
|
||||
:label="action.label"
|
||||
@click="onClickAction(action)"
|
||||
/>
|
||||
</template>
|
||||
<section v-if="$slots.default" class="header-action-buttons">
|
||||
<slot></slot>
|
||||
</section>
|
||||
<gl-button
|
||||
v-if="hasSidebarButton"
|
||||
|
|
|
@ -26,7 +26,7 @@ module MilestonesHelper
|
|||
end
|
||||
end
|
||||
|
||||
def milestones_issues_path(opts = {})
|
||||
def milestones_label_path(opts = {})
|
||||
if @project
|
||||
project_issues_path(@project, opts)
|
||||
elsif @group
|
||||
|
@ -281,26 +281,6 @@ module MilestonesHelper
|
|||
can?(current_user, :admin_milestone, @project.group)
|
||||
end
|
||||
end
|
||||
|
||||
def display_issues_count_warning?
|
||||
milestone_visible_issues_count > Milestone::DISPLAY_ISSUES_LIMIT
|
||||
end
|
||||
|
||||
def milestone_issues_count_message
|
||||
total_count = milestone_visible_issues_count
|
||||
limit = Milestone::DISPLAY_ISSUES_LIMIT
|
||||
|
||||
message = _('Showing %{limit} of %{total_count} issues. ') % { limit: limit, total_count: total_count }
|
||||
message += link_to(_('View all issues'), milestones_issues_path)
|
||||
|
||||
message.html_safe
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def milestone_visible_issues_count
|
||||
@milestone_visible_issues_count ||= @milestone.issues_visible_to_user(current_user).size
|
||||
end
|
||||
end
|
||||
|
||||
MilestonesHelper.prepend_if_ee('EE::MilestonesHelper')
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Milestoneish
|
||||
DISPLAY_ISSUES_LIMIT = 20
|
||||
|
||||
def total_issues_count(user)
|
||||
count_issues_by_state(user).values.sum
|
||||
end
|
||||
|
@ -55,11 +53,7 @@ module Milestoneish
|
|||
end
|
||||
|
||||
def sorted_issues(user)
|
||||
# This method is used on milestone view to filter opened assigned, opened unassigned and closed issues columns.
|
||||
# We want a limit of DISPLAY_ISSUES_LIMIT for total issues present on all columns.
|
||||
limited_ids = issues_visible_to_user(user).limit(DISPLAY_ISSUES_LIMIT).select(:id)
|
||||
|
||||
Issue.where(id: limited_ids).preload_associated_models.sort_by_attribute('label_priority')
|
||||
issues_visible_to_user(user).preload_associated_models.sort_by_attribute('label_priority')
|
||||
end
|
||||
|
||||
def sorted_merge_requests(user)
|
||||
|
|
|
@ -24,6 +24,11 @@ class DeployToken < ApplicationRecord
|
|||
message: "can contain only letters, digits, '_', '-', '+', and '.'"
|
||||
}
|
||||
|
||||
enum deploy_token_type: {
|
||||
group_type: 1,
|
||||
project_type: 2
|
||||
}
|
||||
|
||||
before_save :ensure_token
|
||||
|
||||
accepts_nested_attributes_for :project_deploy_tokens
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
- args = { show_project_name: local_assigns.fetch(:show_project_name, false),
|
||||
show_full_project_name: local_assigns.fetch(:show_full_project_name, false) }
|
||||
|
||||
- if display_issues_count_warning?
|
||||
.flash-container
|
||||
.flash-warning#milestone-issue-count-warning
|
||||
= milestone_issues_count_message
|
||||
|
||||
.row.prepend-top-default
|
||||
.col-md-4
|
||||
= render 'shared/milestones/issuables', args.merge(title: 'Unstarted Issues (open and unassigned)', issuables: issues.opened.unassigned, id: 'unassigned', show_counter: true)
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
%li.no-border
|
||||
%span.label-row
|
||||
%span.label-name
|
||||
= render_label(label, tooltip: false, link: milestones_issues_path(options))
|
||||
= render_label(label, tooltip: false, link: milestones_label_path(options))
|
||||
%span.prepend-description-left
|
||||
= markdown_field(label, :description)
|
||||
|
||||
.float-right.d-none.d-lg-block.d-xl-block
|
||||
= link_to milestones_issues_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do
|
||||
= link_to milestones_label_path(options.merge(state: 'opened')), class: 'btn btn-transparent btn-action' do
|
||||
- pluralize milestone_issues_by_label_count(@milestone, label, state: :opened), 'open issue'
|
||||
= link_to milestones_issues_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do
|
||||
= link_to milestones_label_path(options.merge(state: 'closed')), class: 'btn btn-transparent btn-action' do
|
||||
- pluralize milestone_issues_by_label_count(@milestone, label, state: :closed), 'closed issue'
|
||||
|
|
5
changelogs/unreleased/21765-deploy-token-add-type.yml
Normal file
5
changelogs/unreleased/21765-deploy-token-add-type.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add deploy_token_type column to deploy_tokens table.
|
||||
merge_request: 23530
|
||||
author:
|
||||
type: added
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
title: Limits issues displayed on milestones
|
||||
merge_request: 23102
|
||||
author:
|
||||
type: performance
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Replace custom action array in CI header bar with <slot>
|
||||
merge_request: 22839
|
||||
author: Fabio Huser
|
||||
type: other
|
5
changelogs/unreleased/update-geo-node-service.yml
Normal file
5
changelogs/unreleased/update-geo-node-service.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Use NodeUpdateService for updating Geo node
|
||||
merge_request: 23894
|
||||
author: Rajendra Kadam
|
||||
type: changed
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddDeployTokenTypeToDeployTokens < ActiveRecord::Migration[5.2]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
add_column_with_default :deploy_tokens, :deploy_token_type, :integer, default: 2, limit: 2, allow_null: false # rubocop: disable Migration/AddColumnWithDefault
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :deploy_tokens, :deploy_token_type
|
||||
end
|
||||
end
|
|
@ -1358,6 +1358,7 @@ ActiveRecord::Schema.define(version: 2020_01_27_090233) do
|
|||
t.string "token"
|
||||
t.string "username"
|
||||
t.string "token_encrypted", limit: 255
|
||||
t.integer "deploy_token_type", limit: 2, default: 2, null: false
|
||||
t.index ["token", "expires_at", "id"], name: "index_deploy_tokens_on_token_and_expires_at_and_id", where: "(revoked IS FALSE)"
|
||||
t.index ["token"], name: "index_deploy_tokens_on_token", unique: true
|
||||
t.index ["token_encrypted"], name: "index_deploy_tokens_on_token_encrypted", unique: true
|
||||
|
|
|
@ -174,7 +174,7 @@ The following documentation relates to the DevOps **Create** stage:
|
|||
| [Delete merged branches](user/project/repository/branches/index.md#delete-merged-branches) | Bulk delete branches after their changes are merged. |
|
||||
| [File templates](user/project/repository/web_editor.md#template-dropdowns) | File templates for common files. |
|
||||
| [Files](user/project/repository/index.md#files) | Files management. |
|
||||
| [Jupyter Notebook files](user/project/repository/index.md#jupyter-notebook-files) | GitLab's support for `.ipynb` files. |
|
||||
| [Jupyter Notebook files](user/project/repository/jupyter_notebooks/index.md#jupyter-notebook-files) | GitLab's support for `.ipynb` files. |
|
||||
| [Protected branches](user/project/protected_branches.md) | Use protected branches. |
|
||||
| [Push rules](push_rules/push_rules.md) **(STARTER)** | Additional control over pushes to your projects. |
|
||||
| [Repositories](user/project/repository/index.md) | Manage source code repositories in GitLab's user interface. |
|
||||
|
|
|
@ -102,19 +102,11 @@ Some things to note about precedence:
|
|||
|
||||
### Jupyter Notebook files
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/2508) in GitLab 9.1
|
||||
|
||||
[Jupyter](https://jupyter.org) Notebook (previously IPython Notebook) files are used for
|
||||
[Jupyter](https://jupyter.org/) Notebook (previously IPython Notebook) files are used for
|
||||
interactive computing in many fields and contain a complete record of the
|
||||
user's sessions and include code, narrative text, equations and rich output.
|
||||
|
||||
When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be
|
||||
rendered to HTML when viewed.
|
||||
|
||||
![Jupyter Notebook Rich Output](img/jupyter_notebook.png)
|
||||
|
||||
Interactive features, including JavaScript plots, will not work when viewed in
|
||||
GitLab.
|
||||
[Read how to use Jupyter notebooks with GitLab.](jupyter_notebooks/index.md)
|
||||
|
||||
### OpenAPI viewer
|
||||
|
||||
|
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
23
doc/user/project/repository/jupyter_notebooks/index.md
Normal file
23
doc/user/project/repository/jupyter_notebooks/index.md
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Jupyter Notebook Files
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/issues/2508/) in GitLab 9.1.
|
||||
|
||||
[Jupyter](https://jupyter.org/) Notebook (previously IPython Notebook) files are used for
|
||||
interactive computing in many fields and contain a complete record of the
|
||||
user's sessions and include code, narrative text, equations and rich output.
|
||||
|
||||
When added to a repository, Jupyter Notebooks with a `.ipynb` extension will be
|
||||
rendered to HTML when viewed.
|
||||
|
||||
![Jupyter Notebook Rich Output](img/jupyter_notebook.png)
|
||||
|
||||
Interactive features, including JavaScript plots, will not work when viewed in
|
||||
GitLab.
|
||||
|
||||
## Jupyter Hub as a GitLab Managed App
|
||||
|
||||
You can deploy [Jupyter Hub as a GitLab managed app](./../../../clusters/applications.md#jupyterhub).
|
||||
|
||||
## Jupyter Git integration
|
||||
|
||||
Find out how to [leverage JupyterLab’s Git extension on your Kubernetes cluster](./../../../clusters/applications.md#jupyter-git-integration).
|
|
@ -17254,9 +17254,6 @@ msgid_plural "Showing %d events"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "Showing %{limit} of %{total_count} issues. "
|
||||
msgstr ""
|
||||
|
||||
msgid "Showing %{pageSize} of %{total} issues"
|
||||
msgstr ""
|
||||
|
||||
|
@ -20937,9 +20934,6 @@ msgstr ""
|
|||
msgid "View Documentation"
|
||||
msgstr ""
|
||||
|
||||
msgid "View all issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "View blame prior to this change"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -25,37 +25,6 @@ describe "User views milestone" do
|
|||
expect { visit_milestone }.not_to exceed_query_limit(control)
|
||||
end
|
||||
|
||||
context 'limiting milestone issues' do
|
||||
before_all do
|
||||
2.times do
|
||||
create(:issue, milestone: milestone, project: project)
|
||||
create(:issue, milestone: milestone, project: project, assignees: [user])
|
||||
create(:issue, milestone: milestone, project: project, state: :closed)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when issues on milestone are over DISPLAY_ISSUES_LIMIT' do
|
||||
it "limits issues to display and shows warning" do
|
||||
stub_const('Milestoneish::DISPLAY_ISSUES_LIMIT', 3)
|
||||
|
||||
visit(project_milestone_path(project, milestone))
|
||||
|
||||
expect(page).to have_selector('.issuable-row', count: 3)
|
||||
expect(page).to have_selector('#milestone-issue-count-warning', text: 'Showing 3 of 6 issues. View all issues')
|
||||
expect(page).to have_link('View all issues', href: project_issues_path(project))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when issues on milestone are below DISPLAY_ISSUES_LIMIT' do
|
||||
it 'does not display warning' do
|
||||
visit(project_milestone_path(project, milestone))
|
||||
|
||||
expect(page).not_to have_selector('#milestone-issue-count-warning', text: 'Showing 3 of 6 issues. View all issues')
|
||||
expect(page).to have_selector('.issuable-row', count: 6)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def visit_milestone
|
||||
|
|
231
spec/frontend/lib/utils/datetime_range_spec.js
Normal file
231
spec/frontend/lib/utils/datetime_range_spec.js
Normal file
|
@ -0,0 +1,231 @@
|
|||
import _ from 'lodash';
|
||||
import { getRangeType, convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||
|
||||
const MOCK_NOW = Date.UTC(2020, 0, 23, 20);
|
||||
|
||||
const MOCK_NOW_ISO_STRING = new Date(MOCK_NOW).toISOString();
|
||||
|
||||
describe('Date time range utils', () => {
|
||||
describe('getRangeType', () => {
|
||||
it('infers correctly the range type from the input object', () => {
|
||||
const rangeTypes = {
|
||||
fixed: [{ start: MOCK_NOW_ISO_STRING, end: MOCK_NOW_ISO_STRING }],
|
||||
anchored: [{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: 0 } }],
|
||||
rolling: [{ duration: { seconds: 0 } }],
|
||||
open: [{ anchor: MOCK_NOW_ISO_STRING }],
|
||||
invalid: [
|
||||
{},
|
||||
{ start: MOCK_NOW_ISO_STRING },
|
||||
{ end: MOCK_NOW_ISO_STRING },
|
||||
{ start: 'NOT_A_DATE', end: 'NOT_A_DATE' },
|
||||
{ duration: { seconds: 'NOT_A_NUMBER' } },
|
||||
{ duration: { seconds: Infinity } },
|
||||
{ duration: { minutes: 20 } },
|
||||
{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: 'NOT_A_NUMBER' } },
|
||||
{ anchor: MOCK_NOW_ISO_STRING, duration: { seconds: Infinity } },
|
||||
{ junk: 'exists' },
|
||||
],
|
||||
};
|
||||
|
||||
Object.entries(rangeTypes).forEach(([type, examples]) => {
|
||||
examples.forEach(example => expect(getRangeType(example)).toEqual(type));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertToFixedRange', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Date.now.mockRestore();
|
||||
});
|
||||
|
||||
describe('When a fixed range is input', () => {
|
||||
const defaultFixedRange = {
|
||||
start: '2020-01-01T00:00:00.000Z',
|
||||
end: '2020-01-31T23:59:00.000Z',
|
||||
label: 'January 2020',
|
||||
};
|
||||
|
||||
const mockFixedRange = params => ({ ...defaultFixedRange, ...params });
|
||||
|
||||
it('converts a fixed range to an equal fixed range', () => {
|
||||
const aFixedRange = mockFixedRange();
|
||||
|
||||
expect(convertToFixedRange(aFixedRange)).toEqual({
|
||||
start: defaultFixedRange.start,
|
||||
end: defaultFixedRange.end,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error when fixed range does not contain an end time', () => {
|
||||
const aFixedRangeMissingEnd = _.omit(mockFixedRange(), 'end');
|
||||
|
||||
expect(() => convertToFixedRange(aFixedRangeMissingEnd)).toThrow();
|
||||
});
|
||||
|
||||
it('throws an error when fixed range does not contain a start time', () => {
|
||||
const aFixedRangeMissingStart = _.omit(mockFixedRange(), 'start');
|
||||
|
||||
expect(() => convertToFixedRange(aFixedRangeMissingStart)).toThrow();
|
||||
});
|
||||
|
||||
it('throws an error when the dates cannot be parsed', () => {
|
||||
const wrongStart = mockFixedRange({ start: 'I_CANNOT_BE_PARSED' });
|
||||
const wrongEnd = mockFixedRange({ end: 'I_CANNOT_BE_PARSED' });
|
||||
|
||||
expect(() => convertToFixedRange(wrongStart)).toThrow();
|
||||
expect(() => convertToFixedRange(wrongEnd)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When an anchored range is input', () => {
|
||||
const defaultAnchoredRange = {
|
||||
anchor: '2020-01-01T00:00:00.000Z',
|
||||
direction: 'after',
|
||||
duration: {
|
||||
seconds: 60 * 2,
|
||||
},
|
||||
label: 'First two minutes of 2020',
|
||||
};
|
||||
const mockAnchoredRange = params => ({ ...defaultAnchoredRange, ...params });
|
||||
|
||||
it('converts to a fixed range', () => {
|
||||
const anAnchoredRange = mockAnchoredRange();
|
||||
|
||||
expect(convertToFixedRange(anAnchoredRange)).toEqual({
|
||||
start: '2020-01-01T00:00:00.000Z',
|
||||
end: '2020-01-01T00:02:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts to a fixed range with a `before` direction', () => {
|
||||
const anAnchoredRange = mockAnchoredRange({ direction: 'before' });
|
||||
|
||||
expect(convertToFixedRange(anAnchoredRange)).toEqual({
|
||||
start: '2019-12-31T23:58:00.000Z',
|
||||
end: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts to a fixed range without an explicit direction, defaulting to `before`', () => {
|
||||
const anAnchoredRange = _.omit(mockAnchoredRange(), 'direction');
|
||||
|
||||
expect(convertToFixedRange(anAnchoredRange)).toEqual({
|
||||
start: '2019-12-31T23:58:00.000Z',
|
||||
end: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error when the anchor cannot be parsed', () => {
|
||||
const wrongAnchor = mockAnchoredRange({ anchor: 'I_CANNOT_BE_PARSED' });
|
||||
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a rolling range is input', () => {
|
||||
it('converts to a fixed range', () => {
|
||||
const aRollingRange = {
|
||||
direction: 'after',
|
||||
duration: {
|
||||
seconds: 60 * 2,
|
||||
},
|
||||
label: 'Next 2 minutes',
|
||||
};
|
||||
|
||||
expect(convertToFixedRange(aRollingRange)).toEqual({
|
||||
start: '2020-01-23T20:00:00.000Z',
|
||||
end: '2020-01-23T20:02:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts to a fixed range with an implicit `before` direction', () => {
|
||||
const aRollingRangeWithNoDirection = {
|
||||
duration: {
|
||||
seconds: 60 * 2,
|
||||
},
|
||||
label: 'Last 2 minutes',
|
||||
};
|
||||
|
||||
expect(convertToFixedRange(aRollingRangeWithNoDirection)).toEqual({
|
||||
start: '2020-01-23T19:58:00.000Z',
|
||||
end: '2020-01-23T20:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error when the duration is not in the right format', () => {
|
||||
const wrongDuration = {
|
||||
direction: 'before',
|
||||
duration: {
|
||||
minutes: 20,
|
||||
},
|
||||
label: 'Last 20 minutes',
|
||||
};
|
||||
|
||||
expect(() => convertToFixedRange(wrongDuration)).toThrow();
|
||||
});
|
||||
|
||||
it('throws an error when the anchor is not valid', () => {
|
||||
const wrongAnchor = {
|
||||
anchor: 'CAN_T_PARSE_THIS',
|
||||
direction: 'after',
|
||||
label: '2020 so far',
|
||||
};
|
||||
|
||||
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when an open range is input', () => {
|
||||
it('converts to a fixed range with an `after` direction', () => {
|
||||
const soFar2020 = {
|
||||
anchor: '2020-01-01T00:00:00.000Z',
|
||||
direction: 'after',
|
||||
label: '2020 so far',
|
||||
};
|
||||
|
||||
expect(convertToFixedRange(soFar2020)).toEqual({
|
||||
start: '2020-01-01T00:00:00.000Z',
|
||||
end: '2020-01-23T20:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts to a fixed range with the explicit `before` direction', () => {
|
||||
const before2020 = {
|
||||
anchor: '2020-01-01T00:00:00.000Z',
|
||||
direction: 'before',
|
||||
label: 'Before 2020',
|
||||
};
|
||||
|
||||
expect(convertToFixedRange(before2020)).toEqual({
|
||||
start: '1970-01-01T00:00:00.000Z',
|
||||
end: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts to a fixed range with the implicit `before` direction', () => {
|
||||
const alsoBefore2020 = {
|
||||
anchor: '2020-01-01T00:00:00.000Z',
|
||||
label: 'Before 2020',
|
||||
};
|
||||
|
||||
expect(convertToFixedRange(alsoBefore2020)).toEqual({
|
||||
start: '1970-01-01T00:00:00.000Z',
|
||||
end: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error when the anchor cannot be parsed', () => {
|
||||
const wrongAnchor = {
|
||||
anchor: 'CAN_T_PARSE_THIS',
|
||||
direction: 'after',
|
||||
label: '2020 so far',
|
||||
};
|
||||
|
||||
expect(() => convertToFixedRange(wrongAnchor)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -8,6 +8,7 @@ describe('Pipeline details header', () => {
|
|||
let props;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(eventHub, '$emit');
|
||||
HeaderComponent = Vue.extend(headerComponent);
|
||||
|
||||
const threeWeeksAgo = new Date();
|
||||
|
@ -33,8 +34,9 @@ describe('Pipeline details header', () => {
|
|||
email: 'foo@bar.com',
|
||||
avatar_url: 'link',
|
||||
},
|
||||
retry_path: 'path',
|
||||
delete_path: 'path',
|
||||
retry_path: 'retry',
|
||||
cancel_path: 'cancel',
|
||||
delete_path: 'delete',
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
|
@ -43,9 +45,14 @@ describe('Pipeline details header', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
eventHub.$off();
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
const findDeleteModal = () => document.getElementById(headerComponent.DELETE_MODAL_ID);
|
||||
const findDeleteModalSubmit = () =>
|
||||
[...findDeleteModal().querySelectorAll('.btn')].find(x => x.textContent === 'Delete pipeline');
|
||||
|
||||
it('should render provided pipeline info', () => {
|
||||
expect(
|
||||
vm.$el
|
||||
|
@ -56,22 +63,46 @@ describe('Pipeline details header', () => {
|
|||
});
|
||||
|
||||
describe('action buttons', () => {
|
||||
it('should call postAction when retry button action is clicked', done => {
|
||||
eventHub.$on('headerPostAction', action => {
|
||||
expect(action.path).toEqual('path');
|
||||
done();
|
||||
});
|
||||
|
||||
vm.$el.querySelector('.js-retry-button').click();
|
||||
it('should not trigger eventHub when nothing happens', () => {
|
||||
expect(eventHub.$emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fire modal event when delete button action is clicked', done => {
|
||||
vm.$root.$on('bv::modal::show', action => {
|
||||
expect(action.componentId).toEqual('pipeline-delete-modal');
|
||||
done();
|
||||
it('should call postAction when retry button action is clicked', () => {
|
||||
vm.$el.querySelector('.js-retry-button').click();
|
||||
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry');
|
||||
});
|
||||
|
||||
it('should call postAction when cancel button action is clicked', () => {
|
||||
vm.$el.querySelector('.js-btn-cancel-pipeline').click();
|
||||
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel');
|
||||
});
|
||||
|
||||
it('does not show delete modal', () => {
|
||||
expect(findDeleteModal()).not.toBeVisible();
|
||||
});
|
||||
|
||||
describe('when delete button action is clicked', () => {
|
||||
beforeEach(done => {
|
||||
vm.$el.querySelector('.js-btn-delete-pipeline').click();
|
||||
|
||||
// Modal needs two ticks to show
|
||||
vm.$nextTick()
|
||||
.then(() => vm.$nextTick())
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
|
||||
vm.$el.querySelector('.js-btn-delete-pipeline').click();
|
||||
it('should show delete modal', () => {
|
||||
expect(findDeleteModal()).toBeVisible();
|
||||
});
|
||||
|
||||
it('should call delete when modal is submitted', () => {
|
||||
findDeleteModalSubmit().click();
|
||||
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('headerDeleteAction', 'delete');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Vue from 'vue';
|
||||
import mountComponent from 'spec/helpers/vue_mount_component_helper';
|
||||
import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
|
||||
import headerCi from '~/vue_shared/components/header_ci_component.vue';
|
||||
|
||||
describe('Header CI Component', () => {
|
||||
|
@ -27,14 +27,6 @@ describe('Header CI Component', () => {
|
|||
email: 'foo@bar.com',
|
||||
avatar_url: 'link',
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
label: 'Retry',
|
||||
path: 'path',
|
||||
cssClass: 'btn',
|
||||
isLoading: false,
|
||||
},
|
||||
],
|
||||
hasSidebarButton: true,
|
||||
};
|
||||
});
|
||||
|
@ -43,6 +35,8 @@ describe('Header CI Component', () => {
|
|||
vm.$destroy();
|
||||
});
|
||||
|
||||
const findActionButtons = () => vm.$el.querySelector('.header-action-buttons');
|
||||
|
||||
describe('render', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(HeaderCi, props);
|
||||
|
@ -68,25 +62,24 @@ describe('Header CI Component', () => {
|
|||
expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name);
|
||||
});
|
||||
|
||||
it('should render provided actions', () => {
|
||||
const btn = vm.$el.querySelector('.btn');
|
||||
|
||||
expect(btn.tagName).toEqual('BUTTON');
|
||||
expect(btn.textContent.trim()).toEqual(props.actions[0].label);
|
||||
});
|
||||
|
||||
it('should show loading icon', done => {
|
||||
vm.actions[0].isLoading = true;
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelector('.btn .gl-spinner').getAttribute('style')).toBeFalsy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render sidebar toggle button', () => {
|
||||
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should not render header action buttons when empty', () => {
|
||||
expect(findActionButtons()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slot', () => {
|
||||
it('should render header action buttons', () => {
|
||||
vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } });
|
||||
|
||||
const buttons = findActionButtons();
|
||||
|
||||
expect(buttons).not.toBeNull();
|
||||
expect(buttons.textContent).toEqual('Test Actions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRenderTriggeredLabel', () => {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
require Rails.root.join('db', 'migrate', '20200122161638_add_deploy_token_type_to_deploy_tokens.rb')
|
||||
|
||||
describe AddDeployTokenTypeToDeployTokens, :migration do
|
||||
let(:deploy_tokens) { table(:deploy_tokens) }
|
||||
let(:deploy_token) do
|
||||
deploy_tokens.create(name: 'token_test',
|
||||
username: 'gitlab+deploy-token-1',
|
||||
token_encrypted: 'dr8rPXwM+Mbs2p3Bg1+gpnXqrnH/wu6vaHdcc7A3isPR67WB',
|
||||
read_repository: true,
|
||||
expires_at: Time.now + 1.year)
|
||||
end
|
||||
|
||||
it 'updates the deploy_token_type column to 2' do
|
||||
expect(deploy_token).not_to respond_to(:deploy_token_type)
|
||||
|
||||
migrate!
|
||||
|
||||
deploy_token.reload
|
||||
expect(deploy_token.deploy_token_type).to eq(2)
|
||||
end
|
||||
end
|
|
@ -33,32 +33,17 @@ describe Milestone, 'Milestoneish' do
|
|||
end
|
||||
|
||||
describe '#sorted_issues' do
|
||||
before do
|
||||
it 'sorts issues by label priority' do
|
||||
issue.labels << label_1
|
||||
security_issue_1.labels << label_2
|
||||
closed_issue_1.labels << label_3
|
||||
end
|
||||
|
||||
it 'sorts issues by label priority' do
|
||||
issues = milestone.sorted_issues(member)
|
||||
|
||||
expect(issues.first).to eq(issue)
|
||||
expect(issues.second).to eq(security_issue_1)
|
||||
expect(issues.third).not_to eq(closed_issue_1)
|
||||
end
|
||||
|
||||
it 'limits issue count' do
|
||||
stub_const('Milestoneish::DISPLAY_ISSUES_LIMIT', 4)
|
||||
|
||||
issues = milestone.sorted_issues(member)
|
||||
|
||||
# Cannot use issues.count here because it is sorting
|
||||
# by a virtual column 'highest_priority' and it will break
|
||||
# the query.
|
||||
total_issues_count = issues.opened.unassigned.length + issues.opened.assigned.length + issues.closed.length
|
||||
expect(issues.length).to eq(4)
|
||||
expect(total_issues_count).to eq(4)
|
||||
end
|
||||
end
|
||||
|
||||
context 'attributes visibility' do
|
||||
|
|
|
@ -8,6 +8,8 @@ describe DeployToken do
|
|||
it { is_expected.to have_many :project_deploy_tokens }
|
||||
it { is_expected.to have_many(:projects).through(:project_deploy_tokens) }
|
||||
|
||||
it_behaves_like 'having unique enum values'
|
||||
|
||||
describe 'validations' do
|
||||
let(:username_format_message) { "can contain only letters, digits, '_', '-', '+', and '.'" }
|
||||
|
||||
|
|
Loading…
Reference in a new issue