Commit 7515ec41 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent a77db6bc
<script>
import { __ } from '~/locale';
import _ from 'underscore';
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
import { xAxisLabelFormatter, dateFormatter } from '../utils';
export default {
components: {
GlAreaChart,
GlLoadingIcon,
},
props: {
endpoint: {
type: String,
required: true,
},
branch: {
type: String,
required: true,
},
},
data() {
return {
masterChart: null,
individualCharts: [],
svgs: {},
masterChartHeight: 264,
individualChartHeight: 216,
};
},
computed: {
...mapState(['chartData', 'loading']),
...mapGetters(['showChart', 'parsedData']),
masterChartData() {
const data = {};
this.xAxisRange.forEach(date => {
data[date] = this.parsedData.total[date] || 0;
});
return [
{
name: __('Commits'),
data: Object.entries(data),
},
];
},
masterChartOptions() {
return {
...this.getCommonChartOptions(true),
yAxis: {
name: __('Number of commits'),
},
grid: {
bottom: 64,
left: 64,
right: 20,
top: 20,
},
};
},
individualChartsData() {
const maxNumberOfIndividualContributorsCharts = 100;
return Object.keys(this.parsedData.byAuthor)
.map(name => {
const author = this.parsedData.byAuthor[name];
return {
name,
email: author.email,
commits: author.commits,
dates: [
{
name: __('Commits'),
data: this.xAxisRange.map(date => [date, author.dates[date] || 0]),
},
],
};
})
.sort((a, b) => b.commits - a.commits)
.slice(0, maxNumberOfIndividualContributorsCharts);
},
individualChartOptions() {
return {
...this.getCommonChartOptions(false),
yAxis: {
name: __('Commits'),
max: this.individualChartYAxisMax,
},
grid: {
bottom: 27,
left: 64,
right: 20,
top: 8,
},
};
},
individualChartYAxisMax() {
return this.individualChartsData.reduce((acc, item) => {
const values = item.dates[0].data.map(value => value[1]);
return Math.max(acc, ...values);
}, 0);
},
xAxisRange() {
const dates = Object.keys(this.parsedData.total).sort((a, b) => new Date(a) - new Date(b));
const firstContributionDate = new Date(dates[0]);
const lastContributionDate = new Date(dates[dates.length - 1]);
return getDatesInRange(firstContributionDate, lastContributionDate, dateFormatter);
},
firstContributionDate() {
return this.xAxisRange[0];
},
lastContributionDate() {
return this.xAxisRange[this.xAxisRange.length - 1];
},
charts() {
return _.uniq(this.individualCharts);
},
},
mounted() {
this.fetchChartData(this.endpoint);
},
methods: {
...mapActions(['fetchChartData']),
getCommonChartOptions(isMasterChart) {
return {
xAxis: {
type: 'time',
name: '',
data: this.xAxisRange,
axisLabel: {
formatter: xAxisLabelFormatter,
showMaxLabel: false,
showMinLabel: false,
},
boundaryGap: false,
splitNumber: isMasterChart ? 24 : 18,
// 28 days
minInterval: 28 * 86400 * 1000,
min: this.firstContributionDate,
max: this.lastContributionDate,
},
};
},
setSvg(name) {
return getSvgIconPathContent(name)
.then(path => {
if (path) {
this.$set(this.svgs, name, `path://${path}`);
}
})
.catch(() => {});
},
onMasterChartCreated(chart) {
this.masterChart = chart;
this.setSvg('scroll-handle')
.then(() => {
this.masterChart.setOption({
dataZoom: [
{
type: 'slider',
handleIcon: this.svgs['scroll-handle'],
},
],
});
})
.catch(() => {});
this.masterChart.on('datazoom', _.debounce(this.setIndividualChartsZoom, 200));
},
onIndividualChartCreated(chart) {
this.individualCharts.push(chart);
},
setIndividualChartsZoom(options) {
this.charts.forEach(chart =>
chart.setOption(
{
dataZoom: {
start: options.start,
end: options.end,
show: false,
},
},
{ lazyUpdate: true },
),
);
},
},
};
</script>
<template>
<div>
<div v-if="loading" class="contributors-loader text-center">
<gl-loading-icon :inline="true" :size="4" />
</div>
<div v-else-if="showChart" class="contributors-charts">
<h4>{{ __('Commits to') }} {{ branch }}</h4>
<span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
<div>
<gl-area-chart
:data="masterChartData"
:option="masterChartOptions"
:height="masterChartHeight"
@created="onMasterChartCreated"
/>
</div>
<div class="row">
<div v-for="contributor in individualChartsData" :key="contributor.name" class="col-6">
<h4>{{ contributor.name }}</h4>
<p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p>
<gl-area-chart
:data="contributor.dates"
:option="individualChartOptions"
:height="individualChartHeight"
@created="onIndividualChartCreated"
/>
</div>
</div>
</div>
</div>
</template>
import Vue from 'vue';
import ContributorsGraphs from './components/contributors.vue';
import store from './stores';
export default () => {
const el = document.querySelector('.js-contributors-graph');
if (!el) return null;
return new Vue({
el,
store,
render(createElement) {
return createElement(ContributorsGraphs, {
props: {
endpoint: el.dataset.projectGraphPath,
branch: el.dataset.projectBranch,
},
});
},
});
};
import axios from '~/lib/utils/axios_utils';
export default {
fetchChartData(endpoint) {
return axios.get(endpoint);
},
};
import flash from '~/flash';
import { __ } from '~/locale';
import service from '../services/contributors_service';
import * as types from './mutation_types';
export const fetchChartData = ({ commit }, endpoint) => {
commit(types.SET_LOADING_STATE, true);
return service
.fetchChartData(endpoint)
.then(res => res.data)
.then(data => {
commit(types.SET_CHART_DATA, data);
commit(types.SET_LOADING_STATE, false);
})
.catch(() => flash(__('An error occurred while loading chart data')));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const showChart = state => Boolean(!state.loading && state.chartData);
export const parsedData = state => {
const byAuthor = {};
const total = {};
state.chartData.forEach(({ date, author_name, author_email }) => {
total[date] = total[date] ? total[date] + 1 : 1;
const authorData = byAuthor[author_name];
if (!authorData) {
byAuthor[author_name] = {
email: author_email.toLowerCase(),
commits: 1,
dates: {
[date]: 1,
},
};
} else {
authorData.commits += 1;
authorData.dates[date] = authorData.dates[date] ? authorData.dates[date] + 1 : 1;
}
});
return {
total,
byAuthor,
};
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import mutations from './mutations';
import * as getters from './getters';
import * as actions from './actions';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
actions,
mutations,
getters,
state: state(),
});
export default createStore();
export const SET_CHART_DATA = 'SET_CHART_DATA';
export const SET_LOADING_STATE = 'SET_LOADING_STATE';
export const SET_ACTIVE_BRANCH = 'SET_ACTIVE_BRANCH';
import * as types from './mutation_types';
export default {
[types.SET_LOADING_STATE](state, value) {
state.loading = value;
},
[types.SET_CHART_DATA](state, chartData) {
Object.assign(state, {
chartData,
});
},
[types.SET_ACTIVE_BRANCH](state, branch) {
Object.assign(state, {
branch,
});
},
};
export default () => ({
loading: false,
chartData: null,
branch: 'master',
});
import { getMonthNames } from '~/lib/utils/datetime_utility';
/**
* Converts provided string to date and returns formatted value as a year for date in January and month name for the rest
* @param {String}
* @returns {String} - formatted value
*
* xAxisLabelFormatter('01-12-2019') will return '2019'
* xAxisLabelFormatter('02-12-2019') will return 'Feb'
* xAxisLabelFormatter('07-12-2019') will return 'Jul'
*/
export const xAxisLabelFormatter = val => {
const date = new Date(val);
const month = date.getUTCMonth();
const year = date.getUTCFullYear();
return month === 0 ? `${year}` : getMonthNames(true)[month];
};
/**
* Formats provided date to YYYY-MM-DD format
* @param {Date}
* @returns {String} - formatted value
*/
export const dateFormatter = date => {
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const day = date.getUTCDate();
return `${year}-${`0${month + 1}`.slice(-2)}-${`0${day}`.slice(-2)}`;
};
......@@ -564,3 +564,26 @@ export const getDateInPast = (date, daysInPast) => {
export const beginOfDayTime = 'T00:00:00Z';
export const endOfDayTime = 'T23:59:59Z';
/**
* @param {Date} d1
* @param {Date} d2
* @param {Function} formatter
* @return {Any[]} an array of formatted dates between 2 given dates (including start&end date)
*/
export const getDatesInRange = (d1, d2, formatter = x => x) => {
if (!(d1 instanceof Date) || !(d2 instanceof Date)) {
return [];
}
let startDate = d1.getTime();
const endDate = d2.getTime();
const oneDay = 24 * 3600 * 1000;
const range = [d1];
while (startDate < endDate) {
startDate += oneDay;
range.push(new Date(startDate));
}
return range.map(formatter);
};
import $ from 'jquery';
import flash from '~/flash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import ContributorsStatGraph from './stat_graph_contributors';
import initContributorsGraphs from '~/contributors';
document.addEventListener('DOMContentLoaded', () => {
const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath;
axios
.get(url)
.then(({ data }) => {
const graph = new ContributorsStatGraph();
graph.init(data);
$('#brush_change').change(() => {
graph.change_date_header();
graph.redraw_authors();
});
$('.stat-graph').fadeIn();
$('.loading-graph').hide();
})
.catch(() => flash(__('Error fetching contributors data.')));
});
document.addEventListener('DOMContentLoaded', initContributorsGraphs);
/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */
import $ from 'jquery';
import _ from 'underscore';
import { n__, s__, createDateTimeFormat, sprintf } from '~/locale';
import {
ContributorsGraph,
ContributorsAuthorGraph,
ContributorsMasterGraph,
} from './stat_graph_contributors_graph';
import ContributorsStatGraphUtil from './stat_graph_contributors_util';
export default (function() {
function ContributorsStatGraph() {
this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
}
ContributorsStatGraph.prototype.init = function(log) {
var author_commits, total_commits;
this.parsed_log = ContributorsStatGraphUtil.parse_log(log);
this.set_current_field('commits');
total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field);
this.add_master_graph(total_commits);
this.add_authors_graph(author_commits);
return this.change_date_header();
};
ContributorsStatGraph.prototype.add_master_graph = function(total_data) {
this.master_graph = new ContributorsMasterGraph(total_data);
return this.master_graph.draw();
};
ContributorsStatGraph.prototype.add_authors_graph = function(author_data) {
var limited_author_data;
this.authors = [];
limited_author_data = author_data.slice(0, 100);
return _.each(
limited_author_data,
(function(_this) {
return function(d) {
var author_graph, author_header;
author_header = _this.create_author_header(d);
$('.contributors-list').append(author_header);
author_graph = new ContributorsAuthorGraph(d.dates);
_this.authors[d.author_name] = author_graph;
return author_graph.draw();
};
})(this),
);
};
ContributorsStatGraph.prototype.format_author_commit_info = function(author) {
var commits;
commits = $('<span/>', {
class: 'graph-author-commits-count',
});
commits.text(n__('%d commit', '%d commits', author.commits));
return $('<span/>').append(commits);
};
ContributorsStatGraph.prototype.create_author_header = function(author) {
var author_commit_info, author_commit_info_span, author_email, author_name, list_item;
list_item = $('<li/>', {
class: 'person',
style: 'display: block;',
});
author_name = $(`<h4>${author.author_name}</h4>`);
author_email = $(`<p class="graph-author-email">${author.author_email}</p>`);
author_commit_info_span = $('<span/>', {
class: 'commits',
});
author_commit_info = this.format_author_commit_info(author);
author_commit_info_span.html(author_commit_info);
list_item.append(author_name);
list_item.append(author_email);
list_item.append(author_commit_info_span);
return list_item;
};
ContributorsStatGraph.prototype.redraw_master = function() {
var total_data;
total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
this.master_graph.set_data(total_data);
return this.master_graph.redraw();
};
ContributorsStatGraph.prototype.redraw_authors = function() {
$('ol').html('');
const { x_domain } = ContributorsGraph.prototype;
const author_commits = ContributorsStatGraphUtil.get_author_data(
this.parsed_log,
this.field,
x_domain,
);
return _.each(
author_commits,
(function(_this) {
return function(d) {
_this.redraw_author_commit_info(d);
if (_this.authors[d.author_name] != null) {
$(_this.authors[d.author_name].list_item).appendTo('ol');
_this.authors[d.author_name].set_data(d.dates);
return _this.authors[d.author_name].redraw();
}
return '';
};
})(this),
);
};
ContributorsStatGraph.prototype.set_current_field = function(field) {
return (this.field = field);
};
ContributorsStatGraph.prototype.change_date_header = function() {
const { x_domain } = ContributorsGraph.prototype;
const formattedDateRange = sprintf(s__('ContributorsPage|%{startDate} – %{endDate}'), {
startDate: this.dateFormat.format(new Date(x_domain[0])),
endDate: this.dateFormat.format(new Date(x_domain[1])),
});
return $('#date_header').text(formattedDateRange);
};
ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) {
var author_commit_info, author_list_item, $author;
$author = this.authors[author.author_name];
if ($author != null) {
author_list_item = $(this.authors[author.author_name].list_item);
author_commit_info = this.format_author_commit_info(author);
return author_list_item.find('span').html(author_commit_info);
}
return '';
};
return ContributorsStatGraph;
})();
/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */
import _ from 'underscore';
export default {
parse_log(log) {
var by_author, by_email, data, entry, i, len, total, normalized_email;
total = {};
by_author = {};
by_email = {};
for (i = 0, len = log.length; i < len; i += 1) {
entry = log[i];
if (total[entry.date] == null) {
this.add_date(entry.date, total);
}
normalized_email = entry.author_email.toLowerCase();
data = by_author[entry.author_name] || by_email[normalized_email];
if (data == null) {
data = this.add_author(entry, by_author, by_email);
}
if (!data[entry.date]) {
this.add_date(entry.date, data);
}
this.store_data(entry, total[entry.date], data[entry.date]);
}
total = _.toArray(total);
by_author = _.toArray(by_author);
return {
total,
by_author,
};
},
add_date(date, collection) {
collection[date] = {};
return (collection[date].date = date);
},
add_author(author, by_author, by_email) {
var data, normalized_email;
data = {};
data.author_name = author.author_name;
data.author_email = author.author_email;
normalized_email = author.author_email.toLowerCase();
by_author[author.author_name] = data;
by_email[normalized_email] = data;
return data;
},
store_data(entry, total, by_author) {
this.store_commits(total, by_author);
this.store_additions(entry, total, by_author);
return this.store_deletions(entry, total, by_author);
},
store_commits(total, by_author) {
this.add(total, 'commits', 1);
return this.add(by_author, 'commits', 1);
},
add(collection, field, value) {
if (collection[field] == null) {
collection[field] = 0;
}
return (collection[field] += value);
},
store_additions(entry, total, by_author) {
if (entry.additions == null) {
entry.additions = 0;
}
this.add(total, 'additions', entry.additions);
return this.add(by_author, 'additions', entry.additions);
},
store_deletions(entry, total, by_author) {
if (entry.deletions == null) {
entry.deletions = 0;
}
this.add(total, 'deletions', entry.deletions);
return this.add(by_author, 'deletions', entry.deletions);
},
get_total_data(parsed_log, field) {
var log, total_data;
log = parsed_log.total;
total_data = this.pick_field(log, field);
return _.sortBy(total_data, d => d.date);
},
pick_field(log, field) {
var total_data;
total_data = [];
_.each(log, d => total_data.push(_.pick(d, [field, 'date'])));
return total_data;
},
get_author_data(parsed_log, field, date_range) {
var author_data, log;
if (date_range == null) {
date_range = null;
}
log = parsed_log.by_author;
author_data = [];
_.each(
log,
(function(_this) {
return function(log_entry) {
var parsed_log_entry;
parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range);
if (!_.isEmpty(parsed_log_entry.dates)) {
return author_data.push(parsed_log_entry);
}
};
})(this),
);
return _.sortBy(author_data, d => d[field]).reverse();
},
parse_log_entry(log_entry, field, date_range) {
var parsed_entry;
parsed_entry = {};
parsed_entry.author_name = log_entry.author_name;
parsed_entry.author_email = log_entry.author_email;
parsed_entry.dates = {};
parsed_entry.commits = 0;
parsed_entry.additions = 0;
parsed_entry.deletions = 0;
_.each(
_.omit(log_entry, 'author_name', 'author_email'),
(function(_this) {
return function(value) {
if (_this.in_range(value.date, date_range)) {
parsed_entry.dates[value.date] = value[field];
parsed_entry.commits += value.commits;
parsed_entry.additions += value.additions;
return (parsed_entry.deletions += value.deletions);
}
};
})(this),
);
return parsed_entry;
},
in_range(date, date_range) {
var ref;
if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) {
return true;
} else {
return false;
}
},
};
......@@ -345,8 +345,8 @@ export default {
<project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled"
:help-path="pagesHelpPath"
label="Pages access control"
help-text="Access control for the project's static website"
:label="s__('ProjectSettings|Pages')"
:help-text="__('With GitLab Pages you can host your static websites on GitLab')"
>
<project-feature-setting
v-model="pagesAccessLevel"
......
......@@ -17,21 +17,6 @@
}
}
.graphs {
.graph-author-email {
float: right;
color: $gl-gray-500;
}
.graph-additions {
color: $green-600;
}
.graph-deletions {
color: $red-500;
}
}
.svg-graph-container {
width: 100%;
......
.tint-box {
background: $stat-graph-common-bg;
position: relative;
margin-bottom: 10px;
}
.area {
fill: $green-500;
fill-opacity: 0.5;
}
.axis {
font-size: 10px;
}
#contributors-master {
@include media-breakpoint-up(md) {
@include make-col-ready();
@include make-col(12);
}
}
#contributors {
flex: 1;
.contributors-list {
margin: 0 0 10px;
list-style: none;
padding: 0;
}
.person {
@include media-breakpoint-up(md) {
@include make-col-ready();
@include make-col(6);
}
margin-top: 10px;
@include media-breakpoint-down(xs) {
width: 100%;
}
.spark {
display: block;
background: $stat-graph-common-bg;
width: 100%;
}
.area-contributor {
fill: $orange-500;
}
}
}
.selection rect {
fill-opacity: 0.1;
stroke-width: 1px;
stroke-opacity: 0.4;
shape-rendering: crispedges;
stroke-dasharray: 3 3;
}
......@@ -281,10 +281,7 @@ module IssuablesHelper
}
data[:hasClosingMergeRequest] = issuable.merge_requests_count(current_user) != 0 if issuable.is_a?(Issue)
zoom_links = Gitlab::ZoomLinkExtractor.new(issuable.description).links
data[:zoomMeetingUrl] = zoom_links.last if zoom_links.any?
data[:zoomMeetingUrl] = ZoomMeeting.canonical_meeting_url(issuable) if issuable.is_a?(Issue)
if parent.is_a?(Group)
data[:groupPath] = parent.path
......
......@@ -273,7 +273,7 @@ module SearchHelper
sanitize(html, tags: %w(a p ol ul li pre code))
end
def search_tabs?(tab)
def show_user_search_tab?
return false if Feature.disabled?(:users_search, default_enabled: true)
if @project
......
......@@ -40,6 +40,7 @@ class Issue < ApplicationRecord
has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees
has_many :zoom_meetings
validates :project, presence: true
......
......@@ -14,4 +14,13 @@ class ZoomMeeting < ApplicationRecord
scope :added_to_issue, -> { where(issue_status: :added) }
scope :removed_from_issue, -> { where(issue_status: :removed) }
scope :canonical, -> (issue) { where(issue: issue).added_to_issue }
def self.canonical_meeting(issue)
canonical(issue)&.take
end
def self.canonical_meeting_url(issue)
canonical_meeting(issue)&.url
end
end
......@@ -61,8 +61,6 @@ module Issues
if added_mentions.present?
notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user)
end
ZoomNotesService.new(issue, project, current_user, old_description: old_associations[:description]).execute
end
def handle_task_changes(issuable)
......
......@@ -6,32 +6,37 @@ module Issues
super(issue.project, user)
@issue = issue
@added_meeting = ZoomMeeting.canonical_meeting(@issue)
end
def add_link(link)
if can_add_link? && (link = parse_link(link))
track_meeting_added_event
success(_('Zoom meeting added'), append_to_description(link))
begin
add_zoom_meeting(link)
success(_('Zoom meeting added'))
rescue ActiveRecord::RecordNotUnique
error(_('Failed to add a Zoom meeting'))
end
else
error(_('Failed to add a Zoom meeting'))
end
end
def can_add_link?
can? && !link_in_issue_description?
end
def remove_link
if can_remove_link?
track_meeting_removed_event
success(_('Zoom meeting removed'), remove_from_description)
remove_zoom_meeting
success(_('Zoom meeting removed'))
else
error(_('Failed to remove a Zoom meeting'))
end
end
def can_add_link?
can_update_issue? && !@added_meeting
end
def can_remove_link?
can? && link_in_issue_description?
can_update_issue? && !!@added_meeting
end
def parse_link(link)
......@@ -42,10 +47,6 @@ module Issues
attr_reader :issue
def issue_description
issue.description || ''
end
def track_meeting_added_event
::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id)
end
......@@ -54,39 +55,33 @@ module Issues
::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id)
end
def success(message, description)
ServiceResponse
.success(message: message, payload: { description: description })
end
def error(message)
ServiceResponse.error(message: message)
def add_zoom_meeting(link)
ZoomMeeting.create(
issue: @issue,
project: @issue.project,
issue_status: :added,
url: link
)
track_meeting_added_event
SystemNoteService.zoom_link_added(@issue, @project, current_user)
end
def append_to_description(link)
"#{issue_description}\n\n#{link}"
def remove_zoom_meeting
@added_meeting.update(issue_status: :removed)
track_meeting_removed_event
SystemNoteService.zoom_link_removed(@issue, @project, current_user)
end
def remove_from_description
link = parse_link(issue_description)
return issue_description unless link
issue_description.delete_suffix(link).rstrip
def success(message)
ServiceResponse.success(message: message)
end
def link_in_issue_description?
link = extract_link_from_issue_description
return unless link
Gitlab::ZoomLinkExtractor.new(link).match?
end
def extract_link_from_issue_description
issue_description[/(\S+)\z/, 1]
def error(message)
ServiceResponse.error(message: message)
end
def can?
current_user.can?(:update_issue, project)
def can_update_issue?
can?(current_user, :update_issue, project)
end
end
end
# frozen_string_literal: true
class ZoomNotesService
def initialize(issue, project, current_user, old_description: nil)
@issue = issue
@project = project
@current_user = current_user
@old_description = old_description
end
def execute
return if @issue.description == @old_description
if zoom_link_added?
zoom_link_added_notification
elsif zoom_link_removed?
zoom_link_removed_notification
end
end
private
def zoom_link_added?
has_zoom_link?(@issue.description) && !has_zoom_link?(@old_description)
end
def zoom_link_removed?
!has_zoom_link?(@issue.description) && has_zoom_link?(@old_description)
end
def has_zoom_link?(text)
Gitlab::ZoomLinkExtractor.new(text).match?
end
def zoom_link_added_notification
SystemNoteService.zoom_link_added(@issue, @project, @current_user)
end
def zoom_link_removed_notification
SystemNoteService.zoom_link_removed(@issue, @project, @current_user)
end
end
......@@ -16,7 +16,7 @@
.bs-callout.bs-callout-info
= s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.')
%strong
= link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence')
= link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')
.clusters-table.js-clusters-list
.gl-responsive-table-row.table-row-header{ role: "row" }
......
- page_title _('Contributors')
.js-graphs-show{ 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json) }
.sub-header-block
.tree-ref-holder.inline.vertical-align-middle
= render 'shared/ref_switcher', destination: 'graphs'
= link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn'
.sub-header-block.bg-gray-light.gl-p-3
.tree-ref-holder.inline.vertical-align-middle
= render 'shared/ref_switcher', destination: 'graphs'
= link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn'
.loading-graph
.center
%h3.page-title
%i.fa.fa-spinner.fa-spin
= s_('ContributorsPage|Building repository graph.')
%p.slead
= s_('ContributorsPage|Please wait a moment, this page will automatically refresh when ready.')
.stat-graph.hide
.header.clearfix
%h3#date_header.page-title
%p.light
= s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref }
%input#brush_change{ :type => "hidden" }
.graphs.row
#contributors-master.svg-w-100
#contributors.clearfix
%ol.contributors-list.svg-w-100.row
.js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref }
- breadcrumb_title s_("ProjectService|Integrations")
- breadcrumb_title @service.title
- page_title @service.title, s_("ProjectService|Services")
- add_to_breadcrumbs(s_("ProjectService|Settings"), edit_project_path(@project))
- add_to_breadcrumbs(s_("ProjectService|Integrations"), namespace_project_settings_integrations_path)
= render 'deprecated_message' if @service.deprecation_message
......
- users = capture_haml do
- if search_tabs?(:members)
- if show_user_search_tab?
= search_filter_link 'users', _("Users")
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
......
---
title: Add missing breadcrumb in Project > Settings > Integrations
merge_request: 18990
author:
type: fixed
---
title: Store Zoom URLs in a table rather than in the issue description
merge_request: 18620
author:
type: changed
---
title: Fix Kubernetes help text link
merge_request: 19121
author:
type: fixed
---
title: Make `Job`, `Bridge` and `Default` inheritable
merge_request: 18867
author:
type: added
......@@ -11,8 +11,7 @@ module Gitlab
#
class Default < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
DuplicateError = Class.new(Gitlab::Config::Loader::FormatError)
include ::Gitlab::Config::Entry::Inheritable
ALLOWED_KEYS = %i[before_script image services
after_script cache].freeze
......@@ -43,29 +42,16 @@ module Gitlab
helpers :before_script, :image, :services, :after_script, :cache
def compose!(deps = nil)
super(self)
inherit!(deps)
end
private
def inherit!(deps)
return unless deps
def overwrite_entry(deps, key, current_entry)
inherited_entry = deps[key]
self.class.nodes.each do |key, factory|
next unless factory.inheritable?
root_entry = deps[key]
next unless root_entry.specified?
if self[key].specified?
raise DuplicateError, "#{key} is defined in top-level and `default:` entry"
end
@entries[key] = root_entry
if inherited_entry.specified? && current_entry.specified?
raise InheritError, "#{key} is defined in top-level and `default:` entry"
end
inherited_entry unless current_entry.specified?
end
end
end
......
......@@ -10,6 +10,7 @@ module Gitlab
class Job < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Configurable
include ::Gitlab::Config::Entry::Attributable
include ::Gitlab::Config::Entry::Inheritable
ALLOWED_WHEN = %w[on_success on_failure always manual delayed].freeze
ALLOWED_KEYS = %i[tags script only except rules type image services
......@@ -73,13 +74,16 @@ module Gitlab
inherit: true
entry :script, Entry::Commands,
description: 'Commands that will be executed in this job.'
description: 'Commands that will be executed in this job.',
inherit: false
entry :stage, Entry::Stage,
description: 'Pipeline stage this job will be executed into.'
description: 'Pipeline stage this job will be executed into.',
inherit: false
entry :type, Entry::Stage,
description: 'Deprecated: stage this job will be executed into.'
description: 'Deprecated: stage this job will be executed into.',
inherit: false
entry :after_script, Entry::Script,
description: 'Commands that will be executed when finishing job.',
......@@ -99,28 +103,36 @@ module Gitlab
entry :only, Entry::Policy,
description: 'Refs policy this job will be executed for.',
default: Entry::Policy::DEFAULT_ONLY
default: Entry::Policy::DEFAULT_ONLY,
inherit: false
entry :except, Entry::Policy,
description: 'Refs policy this job will be executed for.'
description: 'Refs policy this job will be executed for.',
inherit: false
entry :rules, Entry::Rules,
description: 'List of evaluable Rules to determine job inclusion.'
description: 'List of evaluable Rules to determine job inclusion.',
inherit: false
entry :variables, Entry::Variables,
description: 'Environment variables available for this job.'
description: 'Environment variables available for this job.',
inherit: false
entry :artifacts, Entry::Artifacts,
description: 'Artifacts configuration for this job.'
description: 'Artifacts configuration for this job.',
inherit: false
entry :environment, Entry::Environment,
description: 'Environment configuration for this job.'
description: 'Environment configuration for this job.',
inherit: false
entry :coverage, Entry::Coverage,
description: 'Coverage configuration for this job.'
description: 'Coverage configuration for this job.',
inherit: false
entry :retry, Entry::Retry,
description: 'Retry configuration for this job.'
description: 'Retry configuration for this job.',
inherit: false
helpers :before_script, :script, :stage, :type, :after_script,
:cache, :image, :services, :only, :except, :variables,
......@@ -155,8 +167,6 @@ module Gitlab
@entries.delete(:except)
end
end
inherit!(deps)
end
def name
......@@ -185,21 +195,8 @@ module Gitlab
private
# We inherit config entries from `default:`
# if the entry has the `inherit: true` flag set
def inherit!(deps)
return unless deps
self.class.nodes.each do |key, factory|
next unless factory.inheritable?
default_entry = deps.default[key]
job_entry = self[key]
if default_entry.specified? && !job_entry.specified?
@entries[key] = default_entry
end
end
def overwrite_entry(deps, key, current_entry)
deps.default[key] unless current_entry.specified?
end
def to_hash
......
......@@ -113,9 +113,9 @@ promoteBeta:
promoteProduction:
extends: .promote_job
stage: production
# We only allow production promotion on `master` because
# We only allow production promotion on the default branch because
# it has its own production scoped secret variables
only:
- master
- $CI_DEFAULT_BRANCH
script:
- bundle exec fastlane promote_beta_to_production
......@@ -10,7 +10,7 @@ docker-build-master:
- docker build --pull -t "$CI_REGISTRY_IMAGE" .
- docker push "$CI_REGISTRY_IMAGE"
only:
- master
- $CI_DEFAULT_BRANCH
docker-build:
# Official docker image.
......@@ -24,4 +24,4 @@ docker-build:
- docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG"
except:
- master
- $CI_DEFAULT_BRANCH
......@@ -25,7 +25,7 @@ review:
kubernetes: active
except:
refs:
- master
- $CI_DEFAULT_BRANCH
variables:
- $REVIEW_DISABLED
......@@ -49,7 +49,7 @@ stop_review:
kubernetes: active
except:
refs:
- master
- $CI_DEFAULT_BRANCH
variables:
- $REVIEW_DISABLED
......@@ -74,7 +74,7 @@ staging:
url: http://$CI_PROJECT_PATH_SLUG-staging.$KUBE_INGRESS_BASE_DOMAIN
only:
refs:
- master
- $CI_DEFAULT_BRANCH
kubernetes: active
variables:
- $STAGING_ENABLED
......@@ -99,7 +99,7 @@ canary:
when: manual
only:
refs:
- master
- $CI_DEFAULT_BRANCH
kubernetes: active
variables:
- $CANARY_ENABLED
......@@ -127,7 +127,7 @@ production:
<<: *production_template
only:
refs:
- master
- $CI_DEFAULT_BRANCH
kubernetes: active
except:
variables:
......@@ -142,7 +142,7 @@ production_manual:
allow_failure: false
only:
refs:
- master
- $CI_DEFAULT_BRANCH
kubernetes: active
variables:
- $STAGING_ENABLED
......@@ -152,7 +152,7 @@ production_manual:
- $INCREMENTAL_ROLLOUT_ENABLED
- $INCREMENTAL_ROLLOUT_MODE
# This job implements incremental rollout on for every push to `master`.
# This job implements incremental rollout for every push to the default branch.
.rollout: &rollout_template
extends: .auto-deploy
......@@ -179,7 +179,7 @@ production_manual:
# This selectors are backward compatible mode with $INCREMENTAL_ROLLOUT_ENABLED (before 11.4)
only:
refs:
- master
- $CI_DEFAULT_BRANCH
kubernetes: active
variables:
- $INCREMENTAL_ROLLOUT_MODE == "manual"
......@@ -194,7 +194,7 @@ production_manual:
start_in: 5 minutes
only:
refs:
- master
- $CI_DEFAULT_BRANCH
kubernetes: active
variables:
- $INCREMENTAL_ROLLOUT_MODE == "timed"
......
......@@ -64,7 +64,7 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
# WARNING: This template is using the `julia` images from [Docker
# Hub][3]. One can use custom Julia images and/or the official ones found
......
......@@ -6,7 +6,7 @@
# This template will build and test your projects
# * Caches downloaded dependencies and plugins between invocation.
# * Verify but don't deploy merge requests.
# * Deploy built artifacts from master branch only.
# * Deploy built artifacts from the default branch only.
variables:
# This will suppress any download for dependencies and plugins or upload messages which would clutter the console log.
......@@ -33,7 +33,7 @@ cache:
script:
- 'mvn $MAVEN_CLI_OPTS verify'
except:
- master
- $CI_DEFAULT_BRANCH
# Verify merge requests using JDK8
verify:jdk8:
......@@ -42,7 +42,7 @@ verify:jdk8:
# To deploy packages from CI, create a ci_settings.xml file
# For deploying packages to GitLab's Maven Repository: See https://docs.gitlab.com/ee/user/project/packages/maven_repository.html#creating-maven-packages-with-gitlab-cicd for more details.
# Please note: The GitLab Maven Repository is currently only available in GitLab Premium / Ultimate.
# For `master` branch run `mvn deploy` automatically.
# For the default branch run `mvn deploy` automatically.
deploy:jdk8:
stage: deploy
script:
......@@ -51,4 +51,4 @@ deploy:jdk8:
fi
- 'mvn $MAVEN_CLI_OPTS deploy -s ci_settings.xml'
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -25,7 +25,7 @@ before_script:
release:
stage: deploy
only:
- master
- $CI_DEFAULT_BRANCH
artifacts:
paths:
- build/release/MyProject.exe
......
......@@ -49,7 +49,7 @@ review:
only:
- branches
except:
- master
- $CI_DEFAULT_BRANCH
stop-review:
<<: *deploy
......@@ -66,7 +66,7 @@ stop-review:
only:
- branches
except:
- master
- $CI_DEFAULT_BRANCH
staging:
<<: *deploy
......@@ -78,7 +78,7 @@ staging:
name: staging
url: http://$CI_PROJECT_NAME-staging.$OPENSHIFT_DOMAIN
only:
- master
- $CI_DEFAULT_BRANCH
production:
<<: *deploy
......@@ -91,4 +91,4 @@ production:
name: production
url: http://$CI_PROJECT_NAME.$OPENSHIFT_DOMAIN
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -25,4 +25,4 @@ build:
- find . -maxdepth 1 -name '*.json' -print0 | xargs -t0n1 packer build
when: manual
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -12,4 +12,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -10,4 +10,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -14,4 +14,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -9,4 +9,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -12,4 +12,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -14,4 +14,4 @@ pages:
- node_modules
key: project
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -8,10 +8,10 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
test:
script:
- hugo
except:
- master
- $CI_DEFAULT_BRANCH
......@@ -11,7 +11,7 @@ test:
- pip install hyde
- hyde gen
except:
- master
- $CI_DEFAULT_BRANCH
pages:
stage: deploy
......@@ -22,4 +22,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -17,7 +17,7 @@ test:
paths:
- test
except:
- master
- $CI_DEFAULT_BRANCH
pages:
stage: deploy
......@@ -27,4 +27,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -34,4 +34,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -9,4 +9,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -13,4 +13,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -12,7 +12,7 @@ test:
- bundle install --path vendor
- bundle exec middleman build
except:
- master
- $CI_DEFAULT_BRANCH
pages:
script:
......@@ -24,4 +24,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -9,4 +9,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -12,4 +12,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -26,4 +26,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -48,4 +48,4 @@ pages:
paths:
- public
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -22,7 +22,7 @@ archive_project:
- xcodebuild clean archive -archivePath build/ProjectName -scheme SchemeName
- xcodebuild -exportArchive -exportFormat ipa -archivePath "build/ProjectName.xcarchive" -exportPath "build/ProjectName.ipa" -exportProvisioningProfile "ProvisioningProfileName"
only:
- master
- $CI_DEFAULT_BRANCH
artifacts:
paths:
- build/ProjectName.ipa
......
......@@ -53,4 +53,4 @@ apply:
- plan
when: manual
only:
- master
- $CI_DEFAULT_BRANCH
......@@ -9,10 +9,12 @@ module Gitlab
class Factory
InvalidFactory = Class.new(StandardError)
def initialize(entry)
@entry = entry
attr_reader :entry_class
def initialize(entry_class)
@entry_class = entry_class
@metadata = {}
@attributes = { default: entry.default }
@attributes = { default: entry_class.default }
end
def value(value)
......@@ -34,6 +36,10 @@ module Gitlab
@attributes[:description]
end
def inherit
@attributes[:inherit]
end
def inheritable?
@attributes[:inherit]
end
......@@ -52,7 +58,7 @@ module Gitlab
if @value.nil?
Entry::Unspecified.new(fabricate_unspecified)
else
fabricate(@entry, @value)
fabricate(entry_class, @value)
end
end
......@@ -68,12 +74,12 @@ module Gitlab
if default.nil?
fabricate(Entry::Undefined)
else
fabricate(@entry, default)
fabricate(entry_class, default)
end
end
def fabricate(entry, value = nil)
entry.new(value, @metadata) do |node|
def fabricate(entry_class, value = nil)
entry_class.new(value, @metadata) do |node|
node.key = @attributes[:key]
node.parent = @attributes[:parent]
node.default = @attributes[:default]
......
# frozen_string_literal: true
module Gitlab
module Config
module Entry
##
# Entry that represents an inheritable configs.
#
module Inheritable
InheritError = Class.new(Gitlab::Config::Loader::FormatError)
def compose!(deps = nil, &blk)
super(deps, &blk)
inherit!(deps)
end
private
# We inherit config entries from `default:`
# if the entry has the `inherit: true` flag set
def inherit!(deps)
return unless deps
self.class.nodes.each do |key, factory|
next unless factory.inheritable?
new_entry = overwrite_entry(deps, key, self[key])
entries[key] = new_entry if new_entry&.specified?
end
end
def overwrite_entry(deps, key, current_entry)
raise NotImplementedError
end
end
end
end
end
......@@ -28,6 +28,7 @@ tree:
- label:
- :priorities
- :issue_assignees
- :zoom_meetings
- snippets:
- :award_emoji
- notes:
......
......@@ -174,18 +174,14 @@ module Gitlab
params '<Zoom URL>'
types Issue
condition do
zoom_link_service.can_add_link?
@zoom_service = zoom_link_service
@zoom_service.can_add_link?
end
parse_params do |link|
zoom_link_service.parse_link(link)
@zoom_service.parse_link(link)
end
command :zoom do |link|
result = zoom_link_service.add_link(link)
if result.success?
@updates[:description] = result.payload[:description]
end
result = @zoom_service.add_link(link)
@execution_message[:zoom] = result.message
end
......@@ -194,15 +190,11 @@ module Gitlab
execution_message _('Zoom meeting removed')
types Issue
condition do
zoom_link_service.can_remove_link?
@zoom_service = zoom_link_service
@zoom_service.can_remove_link?
end
command :remove_zoom do
result = zoom_link_service.remove_link
if result.success?
@updates[:description] = result.payload[:description]
end
result = @zoom_service.remove_link
@execution_message[:remove_zoom] = result.message
end
......
......@@ -4226,6 +4226,9 @@ msgstr ""
msgid "Commits per weekday"
msgstr ""
msgid "Commits to"
msgstr ""
msgid "Commits|An error occurred while fetching merge requests data."
msgstr ""
......@@ -4510,18 +4513,6 @@ msgstr ""
msgid "Contributors"
msgstr ""
msgid "ContributorsPage|%{startDate} – %{endDate}"
msgstr ""
msgid "ContributorsPage|Building repository graph."
msgstr ""
msgid "ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits."
msgstr ""
msgid "ContributorsPage|Please wait a moment, this page will automatically refresh when ready."
msgstr ""
msgid "Control emails linked to your account"
msgstr ""
......@@ -6465,9 +6456,6 @@ msgstr ""
msgid "Error deleting %{issuableType}"
msgstr ""
msgid "Error fetching contributors data."
msgstr ""
msgid "Error fetching diverging counts for branches. Please try again."
msgstr ""
......@@ -6705,6 +6693,9 @@ msgstr ""
msgid "Except policy:"
msgstr ""
msgid "Excluding merge commits. Limited to 6,000 commits."
msgstr ""
msgid "Existing"
msgstr ""
......@@ -11322,6 +11313,9 @@ msgstr ""
msgid "Number of changes (branches or tags) in a single push to determine whether webhooks and services will be fired or not. Webhooks and services won't be submitted if it surpasses that value."
msgstr ""
msgid "Number of commits"
msgstr ""
msgid "Number of commits per MR"
msgstr ""
......@@ -12917,6 +12911,9 @@ msgstr ""
msgid "ProjectSettings|Only signed commits can be pushed to this repository."
msgstr ""
msgid "ProjectSettings|Pages"
msgstr ""
msgid "ProjectSettings|Pipelines must succeed"
msgstr ""
......@@ -18905,6 +18902,9 @@ msgstr ""
msgid "Will deploy to"
msgstr ""
msgid "With GitLab Pages you can host your static websites on GitLab"
msgstr ""
msgid "With contribution analytics you can have an overview for the activity of issues, merge requests and push events of your organization and its members."
msgstr ""
......
......@@ -92,19 +92,6 @@ describe "User creates issue" do
.and have_content(label_titles.first)
end
end
context "with Zoom link" do
it "adds Zoom button" do
issue_title = "Issue containing Zoom meeting link"
zoom_url = "https://gitlab.zoom.us/j/123456789"
fill_in("Title", with: issue_title)
fill_in("Description", with: zoom_url)
click_button("Submit issue")
expect(page).to have_link('Join Zoom meeting', href: zoom_url)
end
end
end
context "when signed in as user with special characters in their name" do
......
......@@ -29,12 +29,6 @@ describe 'Project Graph', :js do
end
end
it 'renders graphs' do
visit project_graph_path(project, 'master')
expect(page).to have_selector('.stat-graph', visible: false)
end
context 'commits graph' do
before do
visit commits_project_graph_path(project, 'master')
......
......@@ -80,6 +80,17 @@
"issue_id": 40
}
],
"zoom_meetings": [
{
"id": 1,
"project_id": 5,
"issue_id": 40,
"url": "https://zoom.us/j/123456789",
"issue_status": 1,
"created_at": "2016-06-14T15:02:04.418Z",
"updated_at": "2016-06-14T15:02:04.418Z"
}
],
"milestone": {
"id": 1,
"title": "test milestone",
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Contributors charts should render charts when loading completed and there is chart data 1`] = `
<div>
<div
class="contributors-charts"
>
<h4>
Commits to master
</h4>
<span>
Excluding merge commits. Limited to 6,000 commits.
</span>
<div>
<glareachart-stub
data="[object Object]"
height="264"
option="[object Object]"
/>
</div>
<div
class="row"
>
<div
class="col-6"
>
<h4>
John
</h4>
<p>
2 commits (jawnnypoo@gmail.com)
</p>
<glareachart-stub
data="[object Object]"
height="216"
option="[object Object]"
/>
</div>
</div>
</div>
</div>
`;
import Vue from 'vue';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { createStore } from '~/contributors/stores';
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import ContributorsCharts from '~/contributors/components/contributors.vue';
const localVue = createLocalVue();
let wrapper;
let mock;
let store;
const Component = Vue.extend(ContributorsCharts);
const endpoint = 'contributors';
const branch = 'master';
const chartData = [
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' },
];
function factory() {
mock = new MockAdapter(axios);
jest.spyOn(axios, 'get');
mock.onGet().reply(200, chartData);
store = createStore();
wrapper = shallowMount(Component, {
propsData: {
endpoint,
branch,
},
stubs: {
GlLoadingIcon: true,
GlAreaChart: true,
},
store,
});
}
describe('Contributors charts', () => {
beforeEach(() => {
factory();
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
it('should fetch chart data when mounted', () => {
expect(axios.get).toHaveBeenCalledWith(endpoint);
});
it('should display loader whiled loading data', () => {
wrapper.vm.$store.state.loading = true;
return localVue.nextTick(() => {
expect(wrapper.find('.contributors-loader').exists()).toBe(true);
});
});
it('should render charts when loading completed and there is chart data', () => {
wrapper.vm.$store.state.loading = false;
wrapper.vm.$store.state.chartData = chartData;
return localVue.nextTick(() => {
expect(wrapper.find('.contributors-loader').exists()).toBe(false);
expect(wrapper.find('.contributors-charts').exists()).toBe(true);
expect(wrapper.element).toMatchSnapshot();
});
});
});
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import flashError from '~/flash';
import * as actions from '~/contributors/stores/actions';
import * as types from '~/contributors/stores/mutation_types';
jest.mock('~/flash.js');
describe('Contributors store actions', () => {
describe('fetchChartData', () => {
let mock;
const endpoint = '/contributors';
const chartData = { '2017-11': 0, '2017-12': 2 };
beforeEach(() => {
mock = new MockAdapter(axios);
});
it('should commit SET_CHART_DATA with received response', done => {
mock.onGet().reply(200, chartData);
testAction(
actions.fetchChartData,
{ endpoint },
{},
[
{ type: types.SET_LOADING_STATE, payload: true },
{ type: types.SET_CHART_DATA, payload: chartData },
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
() => {
mock.restore();
done();
},
);
});
it('should show flash on API error', done => {
mock.onGet().reply(400, 'Not Found');
testAction(
actions.fetchChartData,
{ endpoint },
{},
[{ type: types.SET_LOADING_STATE, payload: true }],
[],
() => {
expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error'));
mock.restore();
done();
},
);
});
});
});
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import * as getters from '~/contributors/stores/getters';
describe('Contributors Store Getters', () => {
const state = {};
describe('showChart', () => {
it('should NOT show chart if loading', () => {
state.loading = true;
expect(getters.showChart(state)).toEqual(false);
});
it('should NOT show chart there is not data', () => {
state.loading = false;
state.chartData = null;
expect(getters.showChart(state)).toEqual(false);
});
it('should show the chart in case loading complated and there is data', () => {
state.loading = false;
state.chartData = true;
expect(getters.showChart(state)).toEqual(true);
});
describe('parsedData', () => {
let parsed;
beforeAll(() => {
state.chartData = [
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
{ author_name: 'Carlson', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' },
{ author_name: 'Carlson', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' },
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' },
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' },
];
parsed = getters.parsedData(state);
});
it('should group contributions by date ', () => {
expect(parsed.total).toMatchObject({ '2019-05-05': 3, '2019-03-03': 2, '2019-04-04': 2 });
});
it('should group contributions by author ', () => {
expect(parsed.byAuthor).toMatchObject({
Carlson: {
email: 'jawnnypoo@gmail.com',
commits: 2,
dates: {
'2019-03-03': 1,
'2019-05-05': 1,
},
},
John: {
email: 'jawnnypoo@gmail.com',
commits: 5,
dates: {
'2019-03-03': 1,
'2019-04-04': 2,
'2019-05-05': 2,
},
},
});
});
});
});
});
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import state from '~/contributors/stores/state';
import mutations from '~/contributors/stores/mutations';
import * as types from '~/contributors/stores/mutation_types';
describe('Contributors mutations', () => {
let stateCopy;
beforeEach(() => {
stateCopy = state();
});
describe('SET_LOADING_STATE', () => {
it('should set loading flag', () => {
const loading = true;
mutations[types.SET_LOADING_STATE](stateCopy, loading);
expect(stateCopy.loading).toEqual(loading);
});
});
describe('SET_CHART_DATA', () => {
const chartData = { '2017-11': 0, '2017-12': 2 };
it('should set chart data', () => {
mutations[types.SET_CHART_DATA](stateCopy, chartData);
expect(stateCopy.chartData).toEqual(chartData);
});
});
describe('SET_ACTIVE_BRANCH', () => {
it('should set search query', () => {
const branch = 'feature-branch';
mutations[types.SET_ACTIVE_BRANCH](stateCopy, branch);
expect(stateCopy.branch).toEqual(branch);
});
});
});
import * as utils from '~/contributors/utils';
describe('Contributors Util Functions', () => {
describe('xAxisLabelFormatter', () => {
it('should return year if the date is in January', () => {
expect(utils.xAxisLabelFormatter(new Date('01-12-2019'))).toEqual('2019');
});
it('should return month name otherwise', () => {
expect(utils.xAxisLabelFormatter(new Date('12-02-2019'))).toEqual('Dec');
expect(utils.xAxisLabelFormatter(new Date('07-12-2019'))).toEqual('Jul');
});
});
describe('dateFormatter', () => {
it('should format provided date to YYYY-MM-DD format', () => {
expect(utils.dateFormatter(new Date('December 17, 1995 03:24:00'))).toEqual('1995-12-17');
expect(utils.dateFormatter(new Date(1565308800000))).toEqual('2019-08-09');
});
});
});
......@@ -441,3 +441,34 @@ describe('getDateInPast', () => {
expect(date).toStrictEqual(new Date(1563235200000));
});
});
describe('getDatesInRange', () => {
it('returns an empty array if 1st or 2nd argument is not a Date object', () => {
const d1 = new Date('2019-01-01');
const d2 = 90;
const range = datetimeUtility.getDatesInRange(d1, d2);
expect(range).toEqual([]);
});
it('returns a range of dates between two given dates', () => {
const d1 = new Date('2019-01-01');
const d2 = new Date('2019-01-31');
const range = datetimeUtility.getDatesInRange(d1, d2);
expect(range.length).toEqual(31);
});
it('applies mapper function if provided fro each item in range', () => {
const d1 = new Date('2019-01-01');
const d2 = new Date('2019-01-31');
const formatter = date => date.getDate();
const range = datetimeUtility.getDatesInRange(d1, d2, formatter);
range.forEach((formattedItem, index) => {
expect(formattedItem).toEqual(index + 1);
});
});
});
......@@ -203,42 +203,53 @@ describe IssuablesHelper do
end
describe '#zoomMeetingUrl in issue' do
let(:issue) { create(:issue, author: user, description: description) }
let(:issue) { create(:issue, author: user) }
before do
assign(:project, issue.project)
end
context 'no zoom links in the issue description' do
let(:description) { 'issue text' }
it 'does not set zoomMeetingUrl' do
expect(helper.issuable_initial_data(issue))
.not_to include(:zoomMeetingUrl)
shared_examples 'sets zoomMeetingUrl to nil' do
specify do
expect(helper.issuable_initial_data(issue)[:zoomMeetingUrl])
.to be_nil
end
end
context 'no zoom links in the issue description if it has link but not a zoom link' do
let(:description) { 'issue text https://stackoverflow.com/questions/22' }
context 'with no "added" zoom mettings' do
it_behaves_like 'sets zoomMeetingUrl to nil'
context 'with multiple removed meetings' do
before do
create(:zoom_meeting, issue: issue, issue_status: :removed)
create(:zoom_meeting, issue: issue, issue_status: :removed)
end
it 'does not set zoomMeetingUrl' do
expect(helper.issuable_initial_data(issue))
.not_to include(:zoomMeetingUrl)
it_behaves_like 'sets zoomMeetingUrl to nil'
end
end
context 'with two zoom links in description' do
let(:description) do
<<~TEXT
issue text and
zoom call on https://zoom.us/j/123456789 this url
and new zoom url https://zoom.us/s/lastone and some more text
TEXT
context 'with "added" zoom meeting' do
before do
create(:zoom_meeting, issue: issue)
end
it 'sets zoomMeetingUrl value to the last url' do
expect(helper.issuable_initial_data(issue))
.to include(zoomMeetingUrl: 'https://zoom.us/s/lastone')
shared_examples 'sets zoomMeetingUrl to canonical meeting url' do
specify do
expect(helper.issuable_initial_data(issue))
.to include(zoomMeetingUrl: 'https://zoom.us/j/123456789')
end
end
it_behaves_like 'sets zoomMeetingUrl to canonical meeting url'
context 'with muliple "removed" zoom meetings' do
before do
create(:zoom_meeting, issue: issue, issue_status: :removed)
create(:zoom_meeting, issue: issue, issue_status: :removed)
end
it_behaves_like 'sets zoomMeetingUrl to canonical meeting url'
end
end
end
......
......@@ -271,4 +271,50 @@ describe SearchHelper do
expect(link).to have_css('li[data-foo="bar"]')
end
end
describe '#show_user_search_tab?' do
subject { show_user_search_tab? }
context 'when users_search feature is disabled' do
before do
stub_feature_flags(users_search: false)
end
it { is_expected.to eq(false) }
end
context 'when project search' do
before do
@project = :some_project
expect(self).to receive(:project_search_tabs?)
.with(:members)
.and_return(:value)
end
it 'delegates to project_search_tabs?' do
expect(subject).to eq(:value)
end
end
context 'when not project search' do
context 'when current_user can read_users_list' do
before do
allow(self).to receive(:current_user).and_return(:the_current_user)
allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(true)
end
it { is_expected.to eq(true) }
end
context 'when current_user cannot read_users_list' do
before do
allow(self).to receive(:current_user).and_return(:the_current_user)
allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(false)
end
it { is_expected.to eq(false) }
end
end
end
end
/* eslint-disable jasmine/no-suite-dupes, vars-on-top, no-var */
import { scaleLinear, scaleTime } from 'd3-scale';
import { timeParse } from 'd3-time-format';
import {
ContributorsGraph,
ContributorsMasterGraph,
} from '~/pages/projects/graphs/show/stat_graph_contributors_graph';
const d3 = { scaleLinear, scaleTime, timeParse };
describe('ContributorsGraph', function() {
describe('#set_x_domain', function() {
it('set the x_domain', function() {
ContributorsGraph.set_x_domain(20);
expect(ContributorsGraph.prototype.x_domain).toEqual(20);
});
});
describe('#set_y_domain', function() {
it('sets the y_domain', function() {
ContributorsGraph.set_y_domain([{ commits: 30 }]);
expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30]);
});
});
describe('#init_x_domain', function() {
it('sets the initial x_domain', function() {
ContributorsGraph.init_x_domain([{ date: '2013-01-31' }, { date: '2012-01-31' }]);
expect(ContributorsGraph.prototype.x_domain).toEqual(['2012-01-31', '2013-01-31']);
});
});
describe('#init_y_domain', function() {
it('sets the initial y_domain', function() {
ContributorsGraph.init_y_domain([{ commits: 30 }]);
expect(ContributorsGraph.prototype.y_domain).toEqual([0, 30]);
});
});
describe('#init_domain', function() {
it('calls init_x_domain and init_y_domain', function() {
spyOn(ContributorsGraph, 'init_x_domain');
spyOn(ContributorsGraph, 'init_y_domain');
ContributorsGraph.init_domain();
expect(ContributorsGraph.init_x_domain).toHaveBeenCalled();
expect(ContributorsGraph.init_y_domain).toHaveBeenCalled();
});
});
describe('#set_dates', function() {
it('sets the dates', function() {
ContributorsGraph.set_dates('2013-12-01');
expect(ContributorsGraph.prototype.dates).toEqual('2013-12-01');
});
});
describe('#set_x_domain', function() {
it("sets the instance's x domain using the prototype's x_domain", function() {
ContributorsGraph.prototype.x_domain = 20;
var instance = new ContributorsGraph();
instance.x = d3
.scaleTime()
.range([0, 100])
.clamp(true);
spyOn(instance.x, 'domain');
instance.set_x_domain();
expect(instance.x.domain).toHaveBeenCalledWith(20);
});
});
describe('#set_y_domain', function() {
it("sets the instance's y domain using the prototype's y_domain", function() {
ContributorsGraph.prototype.y_domain = 30;
var instance = new ContributorsGraph();
instance.y = d3
.scaleLinear()
.range([100, 0])
.nice();
spyOn(instance.y, 'domain');
instance.set_y_domain();
expect(instance.y.domain).toHaveBeenCalledWith(30);
});
});
describe('#set_domain', function() {
it('calls set_x_domain and set_y_domain', function() {
var instance = new ContributorsGraph();
spyOn(instance, 'set_x_domain');
spyOn(instance, 'set_y_domain');
instance.set_domain();
expect(instance.set_x_domain).toHaveBeenCalled();
expect(instance.set_y_domain).toHaveBeenCalled();
});
});
describe('#set_data', function() {
it('sets the data', function() {
var instance = new ContributorsGraph();
instance.set_data('20');
expect(instance.data).toEqual('20');
});
});
});
describe('ContributorsMasterGraph', function() {
// TODO: fix or remove
// describe("#process_dates", function () {
// it("gets and parses dates", function () {
// var graph = new ContributorsMasterGraph();
// var data = 'random data here';
// spyOn(graph, 'parse_dates');
// spyOn(graph, 'get_dates').andReturn("get");
// spyOn(ContributorsGraph,'set_dates').andCallThrough();
// graph.process_dates(data);
// expect(graph.parse_dates).toHaveBeenCalledWith(data);
// expect(graph.get_dates).toHaveBeenCalledWith(data);
// expect(ContributorsGraph.set_dates).toHaveBeenCalledWith("get");
// });
// });
describe('#get_dates', function() {
it('plucks the date field from data collection', function() {
var graph = new ContributorsMasterGraph();
var data = [{ date: '2013-01-01' }, { date: '2012-12-15' }];
expect(graph.get_dates(data)).toEqual(['2013-01-01', '2012-12-15']);
});
});
describe('#parse_dates', function() {
it('parses the dates', function() {
var graph = new ContributorsMasterGraph();
var parseDate = d3.timeParse('%Y-%m-%d');
var data = [{ date: '2013-01-01' }, { date: '2012-12-15' }];
var correct = [{ date: parseDate(data[0].date) }, { date: parseDate(data[1].date) }];
graph.parse_dates(data);
expect(data).toEqual(correct);
});
});
});
import ContributorsStatGraph from '~/pages/projects/graphs/show/stat_graph_contributors';
import { ContributorsGraph } from '~/pages/projects/graphs/show/stat_graph_contributors_graph';
import { setLanguage } from '../helpers/locale_helper';
describe('ContributorsStatGraph', () => {
describe('change_date_header', () => {
beforeAll(() => {
setLanguage('de');
});
afterAll(() => {
setLanguage(null);
});
it('uses the locale to display date ranges', () => {
ContributorsGraph.init_x_domain([{ date: '2013-01-31' }, { date: '2012-01-31' }]);
setFixtures('<div id="date_header"></div>');
const graph = new ContributorsStatGraph();
graph.change_date_header();
expect(document.getElementById('date_header').innerText).toBe(
'31. Januar 2012 – 31. Januar 2013',
);
});
});
});
......@@ -5,6 +5,18 @@ require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Default do
let(:entry) { described_class.new(config) }
it_behaves_like 'with inheritable CI config' do
let(:inheritable_key) { nil }
let(:inheritable_class) { Gitlab::Ci::Config::Entry::Root }
# These are entries defined in Root
# that we know that we don't want to inherit
# as they do not have sense in context of Default
let(:ignored_inheritable_columns) do
%i[default include variables stages types]
end
end
describe '.nodes' do
it 'returns a hash' do
expect(described_class.nodes).to be_a(Hash)
......@@ -87,7 +99,7 @@ describe Gitlab::Ci::Config::Entry::Default do
it 'raises error' do
expect { entry.compose!(deps) }.to raise_error(
Gitlab::Ci::Config::Entry::Default::DuplicateError)
Gitlab::Ci::Config::Entry::Default::InheritError)
end
end
......
......@@ -5,6 +5,18 @@ require 'spec_helper'
describe Gitlab::Ci::Config::Entry::Job do
let(:entry) { described_class.new(config, name: :rspec) }
it_behaves_like 'with inheritable CI config' do
let(:inheritable_key) { 'default' }
let(:inheritable_class) { Gitlab::Ci::Config::Entry::Default }
# These are entries defined in Default
# that we know that we don't want to inherit
# as they do not have sense in context of Job
let(:ignored_inheritable_columns) do
%i[]
end
end
describe '.nodes' do
context 'when filtering all the entry/node names' do
subject { described_class.nodes.keys }
......
......@@ -12,6 +12,11 @@ describe Gitlab::Ci::Config::Entry::Root do
context 'when filtering all the entry/node names' do
it 'contains the expected node names' do
# No inheritable fields should be added to the `Root`
#
# Inheritable configuration can only be added to `default:`
#
# The purpose of `Root` is have only globally defined configuration.
expect(described_class.nodes.keys)
.to match_array(%i[before_script image services
after_script variables cache
......
......@@ -29,6 +29,7 @@ issues:
- prometheus_alerts
- prometheus_alert_events
- self_managed_prometheus_alert_events
- zoom_meetings
events:
- author
- project
......@@ -529,4 +530,6 @@ versions: &version
- issue
- designs
- actions
zoom_meetings:
- issue
design_versions: *version
......@@ -211,6 +211,13 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
expect(CustomIssueTrackerService.first).not_to be_nil
end
it 'restores zoom meetings' do
meetings = @project.issues.first.zoom_meetings
expect(meetings.count).to eq(1)
expect(meetings.first.url).to eq('https://zoom.us/j/123456789')
end
context 'Merge requests' do
it 'always has the new project as a target' do
expect(MergeRequest.find_by_title('MR1').target_project).to eq(@project)
......
......@@ -753,3 +753,11 @@ DesignManagement::Version:
- sha
- issue_id
- author_id
ZoomMeeting:
- id
- issue_id
- project_id
- issue_status
- url
- created_at
- updated_at
......@@ -187,7 +187,6 @@ describe Issues::UpdateService, :mailer do
it 'creates system note about issue reassign' do
note = find_note('assigned to')
expect(note).not_to be_nil
expect(note.note).to include "assigned to #{user2.to_reference}"
end
......@@ -202,14 +201,12 @@ describe Issues::UpdateService, :mailer do
it 'creates system note about title change' do
note = find_note('changed title')
expect(note).not_to be_nil
expect(note.note).to eq 'changed title from **{-Old-} title** to **{+New+} title**'
end
it 'creates system note about discussion lock' do
note = find_note('locked this issue')
expect(note).not_to be_nil
expect(note.note).to eq 'locked this issue'
end
end
......@@ -221,20 +218,10 @@ describe Issues::UpdateService, :mailer do
note = find_note('changed the description')
expect(note).not_to be_nil
expect(note.note).to eq('changed the description')
end
end
it 'creates zoom_link_added system note when a zoom link is added to the description' do
update_issue(description: 'Changed description https://zoom.us/j/5873603787')
note = find_note('added a Zoom call')
expect(note).not_to be_nil
expect(note.note).to eq('added a Zoom call to this issue')
end
context 'when issue turns confidential' do
let(:opts) do
{
......@@ -252,7 +239,6 @@ describe Issues::UpdateService, :mailer do
note = find_note('made the issue confidential')
expect(note).not_to be_nil
expect(note.note).to eq 'made the issue confidential'
end
......
......@@ -14,27 +14,16 @@ describe Issues::ZoomLinkService do
project.add_reporter(user)
end
shared_context 'with Zoom link' do
shared_context '"added" Zoom meeting' do
before do
issue.update!(description: "Description\n\n#{zoom_link}")
create(:zoom_meeting, issue: issue)
end
end
shared_context 'with Zoom link not at the end' do
shared_context '"removed" zoom meetings' do
before do
issue.update!(description: "Description with #{zoom_link} some where")
end
end
shared_context 'without Zoom link' do
before do
issue.update!(description: "Description\n\nhttp://example.com")
end
end
shared_context 'without issue description' do
before do
issue.update!(description: nil)
create(:zoom_meeting, issue: issue, issue_status: :removed)
create(:zoom_meeting, issue: issue, issue_status: :removed)
end
end
......@@ -45,11 +34,10 @@ describe Issues::ZoomLinkService do
end
describe '#add_link' do
shared_examples 'can add link' do
it 'appends the link to issue description' do
shared_examples 'can add meeting' do
it 'appends the new meeting to zoom_meetings' do
expect(result).to be_success
expect(result.payload[:description])
.to eq("#{issue.description}\n\n#{zoom_link}")
expect(ZoomMeeting.canonical_meeting_url(issue)).to eq(zoom_link)
end
it 'tracks the add event' do
......@@ -57,55 +45,63 @@ describe Issues::ZoomLinkService do
.with('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id)
result
end
it 'creates a zoom_link_added notification' do
expect(SystemNoteService).to receive(:zoom_link_added).with(issue, project, user)
expect(SystemNoteService).not_to receive(:zoom_link_removed)
result
end
end
shared_examples 'cannot add link' do
it 'cannot add the link' do
shared_examples 'cannot add meeting' do
it 'cannot add the meeting' do
expect(result).to be_error
expect(result.message).to eq('Failed to add a Zoom meeting')
end
it 'creates no notification' do
expect(SystemNoteService).not_to receive(:zoom_link_added)
expect(SystemNoteService).not_to receive(:zoom_link_removed)
result
end
end
subject(:result) { service.add_link(zoom_link) }
context 'without Zoom link in the issue description' do
include_context 'without Zoom link'
include_examples 'can add link'
context 'without existing Zoom meeting' do
include_examples 'can add meeting'
context 'with invalid Zoom link' do
context 'with invalid Zoom url' do
let(:zoom_link) { 'https://not-zoom.link' }
include_examples 'cannot add link'
include_examples 'cannot add meeting'
end
context 'with insufficient permissions' do
include_context 'insufficient permissions'
include_examples 'cannot add link'
include_examples 'cannot add meeting'
end
end
context 'with Zoom link in the issue description' do
include_context 'with Zoom link'
include_examples 'cannot add link'
context 'with "added" Zoom meeting' do
include_context '"added" Zoom meeting'
include_examples 'cannot add meeting'
end
context 'but not at the end' do
include_context 'with Zoom link not at the end'
include_examples 'can add link'
context 'with "added" Zoom meeting and race condition' do
include_context '"added" Zoom meeting'
before do
allow(service).to receive(:can_add_link?).and_return(true)
end
end
context 'without issue description' do
include_context 'without issue description'
include_examples 'can add link'
include_examples 'cannot add meeting'
end
end
describe '#can_add_link?' do
subject { service.can_add_link? }
context 'without Zoom link in the issue description' do
include_context 'without Zoom link'
context 'without "added" zoom meeting' do
it { is_expected.to eq(true) }
context 'with insufficient permissions' do
......@@ -115,81 +111,93 @@ describe Issues::ZoomLinkService do
end
end
context 'with Zoom link in the issue description' do
include_context 'with Zoom link'
context 'with Zoom meeting in the issue description' do
include_context '"added" Zoom meeting'
it { is_expected.to eq(false) }
end
end
describe '#remove_link' do
shared_examples 'cannot remove link' do
it 'cannot remove the link' do
shared_examples 'cannot remove meeting' do
it 'cannot remove the meeting' do
expect(result).to be_error
expect(result.message).to eq('Failed to remove a Zoom meeting')
end
end
subject(:result) { service.remove_link }
it 'creates no notification' do
expect(SystemNoteService).not_to receive(:zoom_link_added)
expect(SystemNoteService).not_to receive(:zoom_link_removed)
result
end
end
context 'with Zoom link in the issue description' do
include_context 'with Zoom link'
shared_examples 'can remove meeting' do
it 'creates no notification' do
expect(SystemNoteService).not_to receive(:zoom_link_added).with(issue, project, user)
expect(SystemNoteService).to receive(:zoom_link_removed)
result
end
it 'removes the link from the issue description' do
it 'can remove the meeting' do
expect(result).to be_success
expect(result.payload[:description])
.to eq(issue.description.delete_suffix("\n\n#{zoom_link}"))
expect(ZoomMeeting.canonical_meeting_url(issue)).to eq(nil)
end
it 'tracks the remove event' do
expect(Gitlab::Tracking).to receive(:event)
.with('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id)
.with('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id)
result
end
end
context 'with insufficient permissions' do
include_context 'insufficient permissions'
include_examples 'cannot remove link'
end
subject(:result) { service.remove_link }
context 'but not at the end' do
include_context 'with Zoom link not at the end'
include_examples 'cannot remove link'
context 'with Zoom meeting' do
include_context '"added" Zoom meeting'
context 'removes the link' do
include_examples 'can remove meeting'
end
end
context 'without Zoom link in the issue description' do
include_context 'without Zoom link'
include_examples 'cannot remove link'
context 'with insufficient permissions' do
include_context 'insufficient permissions'
include_examples 'cannot remove meeting'
end
end
context 'without issue description' do
include_context 'without issue description'
include_examples 'cannot remove link'
context 'without "added" Zoom meeting' do
include_context '"removed" zoom meetings'
include_examples 'cannot remove meeting'
end
end
describe '#can_remove_link?' do
subject { service.can_remove_link? }
context 'with Zoom link in the issue description' do
include_context 'with Zoom link'
context 'without Zoom meeting' do
it { is_expected.to eq(false) }
end
context 'with only "removed" zoom meetings' do
include_context '"removed" zoom meetings'
it { is_expected.to eq(false) }
end
context 'with "added" Zoom meeting' do
include_context '"added" Zoom meeting'
it { is_expected.to eq(true) }
context 'with "removed" zoom meetings' do
include_context '"removed" zoom meetings'
it { is_expected.to eq(true) }
end
context 'with insufficient permissions' do
include_context 'insufficient permissions'
it { is_expected.to eq(false) }
end
end
context 'without Zoom link in the issue description' do
include_context 'without Zoom link'
it { is_expected.to eq(false) }
end
end
describe '#parse_link' do
......
# frozen_string_literal: true
require 'spec_helper'
describe ZoomNotesService do
describe '#execute' do
let(:issue) { OpenStruct.new(description: description) }
let(:project) { Object.new }
let(:user) { Object.new }
let(:description) { 'an issue description' }
let(:old_description) { nil }
subject { described_class.new(issue, project, user, old_description: old_description) }
shared_examples 'no notifications' do
it "doesn't create notifications" do
expect(SystemNoteService).not_to receive(:zoom_link_added)
expect(SystemNoteService).not_to receive(:zoom_link_removed)
subject.execute
end
end
it_behaves_like 'no notifications'
context 'when the zoom link exists in both description and old_description' do
let(:description) { 'a changed issue description https://zoom.us/j/123' }
let(:old_description) { 'an issue description https://zoom.us/j/123' }
it_behaves_like 'no notifications'
end
context "when the zoom link doesn't exist in both description and old_description" do
let(:description) { 'a changed issue description' }
let(:old_description) { 'an issue description' }
it_behaves_like 'no notifications'
end
context 'when description == old_description' do
let(:old_description) { 'an issue description' }
it_behaves_like 'no notifications'
end
context 'when the description contains a zoom link and old_description is nil' do
let(:description) { 'a changed issue description https://zoom.us/j/123' }
it 'creates a zoom_link_added notification' do
expect(SystemNoteService).to receive(:zoom_link_added).with(issue, project, user)
expect(SystemNoteService).not_to receive(:zoom_link_removed)
subject.execute
end
end
context 'when the zoom link has been added to the description' do
let(:description) { 'a changed issue description https://zoom.us/j/123' }
let(:old_description) { 'an issue description' }
it 'creates a zoom_link_added notification' do
expect(SystemNoteService).to receive(:zoom_link_added).with(issue, project, user)
expect(SystemNoteService).not_to receive(:zoom_link_removed)
subject.execute
end
end
context 'when the zoom link has been removed from the description' do
let(:description) { 'a changed issue description' }
let(:old_description) { 'an issue description https://zoom.us/j/123' }
it 'creates a zoom_link_removed notification' do
expect(SystemNoteService).not_to receive(:zoom_link_added).with(issue, project, user)
expect(SystemNoteService).to receive(:zoom_link_removed)
subject.execute
end
end
end
end
# frozen_string_literal: true
RSpec.shared_examples 'with inheritable CI config' do
using RSpec::Parameterized::TableSyntax
let(:ignored_inheritable_columns) { [] }
it 'does prepend an Inheritable mixin' do
expect(described_class).to include_module(Gitlab::Config::Entry::Inheritable)
end
it 'all inheritable entries are covered' do
inheritable_entries = inheritable_class.nodes.keys
entries = described_class.nodes.keys
expect(entries + ignored_inheritable_columns).to include(
*inheritable_entries)
end
it 'all entries do have inherit flag' do
without_inherit_flag = described_class.nodes.map do |key, factory|
key if factory.inherit.nil?
end.compact
expect(without_inherit_flag).to be_empty
end
context 'for non-inheritable entries' do
where(:entry_key) do
described_class.nodes.map do |key, factory|
[key] unless factory.inherit
end.compact
end
with_them do
it 'inheritable_class does not define entry' do
expect(inheritable_class.nodes).not_to include(entry_key)
end
end
end
context 'for inheritable entries' do
where(:entry_key, :entry_class) do
described_class.nodes.map do |key, factory|
[key, factory.entry_class] if factory.inherit
end.compact
end
with_them do
let(:specified) { double('deps_specified', 'specified?' => true, value: 'specified') }
let(:unspecified) { double('unspecified', 'specified?' => false) }
let(:inheritable) { double(inheritable_key, '[]' => unspecified) }
let(:deps) do
if inheritable_key
double('deps', inheritable_key => inheritable, '[]' => unspecified)
else
inheritable
end
end
it 'inheritable_class does define entry' do
expect(inheritable_class.nodes).to include(entry_key)
expect(inheritable_class.nodes[entry_key].entry_class).to eq(entry_class)
end
context 'when is specified' do
it 'does inherit value' do
expect(inheritable).to receive('[]').with(entry_key).and_return(specified)
entry.compose!(deps)
expect(entry[entry_key]).to eq(specified)
end
context 'when entry is specified' do
let(:entry_specified) do
double('entry_specified', 'specified?' => true, value: 'specified', errors: [])
end
it 'does not inherit value' do
entry.send(:entries)[entry_key] = entry_specified
allow(inheritable).to receive('[]').with(entry_key).and_return(specified)
expect do
# we ignore exceptions as `#overwrite_entry`
# can raise exception on duplicates
entry.compose!(deps) rescue described_class::InheritError
end.not_to change { entry[entry_key] }
end
end
end
context 'when inheritable does not specify' do
it 'does not inherit value' do
entry.compose!(deps)
expect(entry[entry_key]).to be_a(
Gitlab::Config::Entry::Undefined)
end
end
end
end
end
......@@ -2,22 +2,19 @@
shared_examples 'zoom quick actions' do
let(:zoom_link) { 'https://zoom.us/j/123456789' }
let(:existing_zoom_link) { 'https://zoom.us/j/123456780' }
let(:invalid_zoom_link) { 'https://invalid-zoom' }
before do
issue.update!(description: description)
end
describe '/zoom' do
shared_examples 'skip silently' do
it 'skip addition silently' do
it 'skips addition silently' do
add_note("/zoom #{zoom_link}")
wait_for_requests
expect(page).not_to have_content('Zoom meeting added')
expect(page).not_to have_content('Failed to add a Zoom meeting')
expect(issue.reload.description).to eq(description)
expect(ZoomMeeting.canonical_meeting_url(issue.reload)).not_to eq(zoom_link)
end
end
......@@ -28,13 +25,11 @@ shared_examples 'zoom quick actions' do
wait_for_requests
expect(page).to have_content('Zoom meeting added')
expect(issue.reload.description).to end_with(zoom_link)
expect(ZoomMeeting.canonical_meeting_url(issue.reload)).to eq(zoom_link)
end
end
context 'without issue description' do
let(:description) { nil }
context 'without zoom_meetings' do
include_examples 'success'
it 'cannot add invalid zoom link' do
......@@ -47,14 +42,18 @@ shared_examples 'zoom quick actions' do
end
end
context 'with Zoom link not at the end of the issue description' do
let(:description) { "A link #{zoom_link} not at the end" }
context 'with "removed" zoom meeting' do
before do
create(:zoom_meeting, issue_status: :removed, url: existing_zoom_link, issue: issue)
end
include_examples 'success'
end
context 'with Zoom link at end of the issue description' do
let(:description) { "Text\n#{zoom_link}" }
context 'with "added" zoom meeting' do
before do
create(:zoom_meeting, issue_status: :added, url: existing_zoom_link, issue: issue)
end
include_examples 'skip silently'
end
......@@ -62,19 +61,19 @@ shared_examples 'zoom quick actions' do
describe '/remove_zoom' do
shared_examples 'skip silently' do
it 'skip removal silently' do
it 'skips removal silently' do
add_note('/remove_zoom')
wait_for_requests
expect(page).not_to have_content('Zoom meeting removed')
expect(page).not_to have_content('Failed to remove a Zoom meeting')
expect(issue.reload.description).to eq(description)
expect(ZoomMeeting.canonical_meeting_url(issue.reload)).to be_nil
end
end
context 'with Zoom link in the description' do
let(:description) { "Text with #{zoom_link}\n\n\n#{zoom_link}" }
context 'with added zoom meeting' do
let!(:added_zoom_meeting) { create(:zoom_meeting, url: zoom_link, issue: issue, issue_status: :added) }
it 'removes last Zoom link' do
add_note('/remove_zoom')
......@@ -82,14 +81,8 @@ shared_examples 'zoom quick actions' do
wait_for_requests
expect(page).to have_content('Zoom meeting removed')
expect(issue.reload.description).to eq("Text with #{zoom_link}")
expect(ZoomMeeting.canonical_meeting_url(issue.reload)).to be_nil
end
end
context 'with a Zoom link not at the end of the description' do
let(:description) { "A link #{zoom_link} not at the end" }
include_examples 'skip silently'
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment