Commit 46bc6b5d authored by Kamil Trzcinski's avatar Kamil Trzcinski

Merge branch '38464-k8s-apps' into add-ingress-to-cluster-applications

parents 3cb46142 760a154a
...@@ -11,7 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { ...@@ -11,7 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async // Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests // instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true; this.isHandledAsync = true;
this.cantEdit = cantEdit; this.cantEdit = cantEdit.filter(i => typeof i === 'string');
this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object');
} }
updateObject(path) { updateObject(path) {
...@@ -42,7 +43,9 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { ...@@ -42,7 +43,9 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new Event('input')); this.filteredSearchInput.dispatchEvent(new Event('input'));
} }
canEdit(tokenName) { canEdit(tokenName, tokenValue) {
return this.cantEdit.indexOf(tokenName) === -1; if (this.cantEdit.includes(tokenName)) return false;
return this.cantEditWithValue.findIndex(token => token.name === tokenName &&
token.value === tokenValue) === -1;
} }
} }
...@@ -14,16 +14,18 @@ gl.issueBoards.BoardsStore = { ...@@ -14,16 +14,18 @@ gl.issueBoards.BoardsStore = {
}, },
state: {}, state: {},
detail: { detail: {
issue: {} issue: {},
}, },
moving: { moving: {
issue: {}, issue: {},
list: {} list: {},
}, },
create () { create () {
this.state.lists = []; this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&'); this.filter.path = getUrlParamsArray().join('&');
this.detail = { issue: {} }; this.detail = {
issue: {},
};
}, },
addList (listObj, defaultAvatar) { addList (listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar); const list = new List(listObj, defaultAvatar);
......
...@@ -147,6 +147,16 @@ class DropdownUtils { ...@@ -147,6 +147,16 @@ class DropdownUtils {
return dataValue !== null; return dataValue !== null;
} }
static getVisualTokenValues(visualToken) {
const tokenName = visualToken && visualToken.querySelector('.name').textContent.trim();
let tokenValue = visualToken && visualToken.querySelector('.value') && visualToken.querySelector('.value').textContent.trim();
if (tokenName === 'label' && tokenValue) {
// remove leading symbol and wrapping quotes
tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, '');
}
return { tokenName, tokenValue };
}
// Determines the full search query (visual tokens + input) // Determines the full search query (visual tokens + input)
static getSearchQuery(untilInput = false) { static getSearchQuery(untilInput = false) {
const container = FilteredSearchContainer.container; const container = FilteredSearchContainer.container;
......
...@@ -185,8 +185,8 @@ class FilteredSearchManager { ...@@ -185,8 +185,8 @@ class FilteredSearchManager {
if (e.keyCode === 8 || e.keyCode === 46) { if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim(); const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(lastVisualToken);
const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName); const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue);
if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) { if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial(); gl.FilteredSearchVisualTokens.removeLastTokenPartial();
...@@ -336,8 +336,8 @@ class FilteredSearchManager { ...@@ -336,8 +336,8 @@ class FilteredSearchManager {
let canClearToken = t.classList.contains('js-visual-token'); let canClearToken = t.classList.contains('js-visual-token');
if (canClearToken) { if (canClearToken) {
const tokenKey = t.querySelector('.name').textContent.trim(); const { tokenName, tokenValue } = gl.DropdownUtils.getVisualTokenValues(t);
canClearToken = this.canEdit && this.canEdit(tokenKey); canClearToken = this.canEdit && this.canEdit(tokenName, tokenValue);
} }
if (canClearToken) { if (canClearToken) {
...@@ -469,7 +469,7 @@ class FilteredSearchManager { ...@@ -469,7 +469,7 @@ class FilteredSearchManager {
} }
hasFilteredSearch = true; hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(sanitizedKey); const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
gl.FilteredSearchVisualTokens.addFilterVisualToken( gl.FilteredSearchVisualTokens.addFilterVisualToken(
sanitizedKey, sanitizedKey,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
......
...@@ -38,21 +38,14 @@ class FilteredSearchVisualTokens { ...@@ -38,21 +38,14 @@ class FilteredSearchVisualTokens {
} }
static createVisualTokenElementHTML(canEdit = true) { static createVisualTokenElementHTML(canEdit = true) {
let removeTokenMarkup = '';
if (canEdit) {
removeTokenMarkup = `
<div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
`;
}
return ` return `
<div class="selectable" role="button"> <div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="name"></div> <div class="name"></div>
<div class="value-container"> <div class="value-container">
<div class="value"></div> <div class="value"></div>
${removeTokenMarkup} <div class="remove-token" role="button">
<i class="fa fa-close"></i>
</div>
</div> </div>
</div> </div>
`; `;
......
...@@ -7,7 +7,7 @@ import DropdownUtils from './filtered_search/dropdown_utils'; ...@@ -7,7 +7,7 @@ import DropdownUtils from './filtered_search/dropdown_utils';
import CreateLabelDropdown from './create_label'; import CreateLabelDropdown from './create_label';
export default class LabelsSelect { export default class LabelsSelect {
constructor(els) { constructor(els, options = {}) {
var _this, $els; var _this, $els;
_this = this; _this = this;
...@@ -57,6 +57,7 @@ export default class LabelsSelect { ...@@ -57,6 +57,7 @@ export default class LabelsSelect {
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>'); labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
labelNoneHTMLTemplate = '<span class="no-value">None</span>'; labelNoneHTMLTemplate = '<span class="no-value">None</span>';
} }
const handleClick = options.handleClick;
$sidebarLabelTooltip.tooltip(); $sidebarLabelTooltip.tooltip();
...@@ -390,6 +391,10 @@ export default class LabelsSelect { ...@@ -390,6 +391,10 @@ export default class LabelsSelect {
.then(fadeOutLoader) .then(fadeOutLoader)
.catch(fadeOutLoader); .catch(fadeOutLoader);
} }
else if (handleClick) {
e.preventDefault();
handleClick(label);
}
else { else {
if ($dropdown.hasClass('js-multiselect')) { if ($dropdown.hasClass('js-multiselect')) {
......
...@@ -5,7 +5,7 @@ import _ from 'underscore'; ...@@ -5,7 +5,7 @@ import _ from 'underscore';
(function() { (function() {
this.MilestoneSelect = (function() { this.MilestoneSelect = (function() {
function MilestoneSelect(currentProject, els) { function MilestoneSelect(currentProject, els, options = {}) {
var _this, $els; var _this, $els;
if (currentProject != null) { if (currentProject != null) {
_this = this; _this = this;
...@@ -136,19 +136,26 @@ import _ from 'underscore'; ...@@ -136,19 +136,26 @@ import _ from 'underscore';
}, },
opened: function(e) { opened: function(e) {
const $el = $(e.currentTarget); const $el = $(e.currentTarget);
if ($dropdown.hasClass('js-issue-board-sidebar')) { if ($dropdown.hasClass('js-issue-board-sidebar') || options.handleClick) {
selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault; selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
} }
$('a.is-active', $el).removeClass('is-active'); $('a.is-active', $el).removeClass('is-active');
$(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active'); $(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active');
}, },
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(options) { clicked: function(clickEvent) {
const { $el, e } = options; const { $el, e } = clickEvent;
let selected = options.selectedObj; let selected = clickEvent.selectedObj;
var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore; var data, isIssueIndex, isMRIndex, isSelecting, page, boardsStore;
if (!selected) return; if (!selected) return;
if (options.handleClick) {
e.preventDefault();
options.handleClick(selected);
return;
}
page = $('body').attr('data-page'); page = $('body').attr('data-page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index'); isMRIndex = (page === page && page === 'projects:merge_requests:index');
......
...@@ -138,7 +138,7 @@ ...@@ -138,7 +138,7 @@
renderAxesPaths() { renderAxesPaths() {
this.timeSeries = createTimeSeries( this.timeSeries = createTimeSeries(
this.graphData.queries[0], this.graphData.queries,
this.graphWidth, this.graphWidth,
this.graphHeight, this.graphHeight,
this.graphHeightOffset, this.graphHeightOffset,
...@@ -153,8 +153,9 @@ ...@@ -153,8 +153,9 @@
const axisYScale = d3.scale.linear() const axisYScale = d3.scale.linear()
.range([this.graphHeight - this.graphHeightOffset, 0]); .range([this.graphHeight - this.graphHeightOffset, 0]);
axisXScale.domain(d3.extent(this.timeSeries[0].values, d => d.time)); const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []);
axisYScale.domain([0, d3.max(this.timeSeries[0].values.map(d => d.value))]); axisXScale.domain(d3.extent(allValues, d => d.time));
axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
const xAxis = d3.svg.axis() const xAxis = d3.svg.axis()
.scale(axisXScale) .scale(axisXScale)
...@@ -246,6 +247,7 @@ ...@@ -246,6 +247,7 @@
:key="index" :key="index"
:generated-line-path="path.linePath" :generated-line-path="path.linePath"
:generated-area-path="path.areaPath" :generated-area-path="path.areaPath"
:line-style="path.lineStyle"
:line-color="path.lineColor" :line-color="path.lineColor"
:area-color="path.areaColor" :area-color="path.areaColor"
/> />
......
...@@ -79,7 +79,8 @@ ...@@ -79,7 +79,8 @@
}, },
formatMetricUsage(series) { formatMetricUsage(series) {
const value = series.values[this.currentDataIndex].value; const value = series.values[this.currentDataIndex] &&
series.values[this.currentDataIndex].value;
if (isNaN(value)) { if (isNaN(value)) {
return '-'; return '-';
} }
...@@ -92,6 +93,12 @@ ...@@ -92,6 +93,12 @@
} }
return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`; return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`;
}, },
strokeDashArray(type) {
if (type === 'dashed') return '6, 3';
if (type === 'dotted') return '3, 3';
return null;
},
}, },
mounted() { mounted() {
this.$nextTick(() => { this.$nextTick(() => {
...@@ -162,13 +169,15 @@ ...@@ -162,13 +169,15 @@
v-for="(series, index) in timeSeries" v-for="(series, index) in timeSeries"
:key="index" :key="index"
:transform="translateLegendGroup(index)"> :transform="translateLegendGroup(index)">
<rect <line
:fill="series.areaColor" :stroke="series.lineColor"
:width="measurements.legends.width" :stroke-width="measurements.legends.height"
:height="measurements.legends.height" :stroke-dasharray="strokeDashArray(series.lineStyle)"
x="20" :x1="measurements.legends.offsetX"
:y="graphHeight - measurements.legendOffset"> :x2="measurements.legends.offsetX + measurements.legends.width"
</rect> :y1="graphHeight - measurements.legends.offsetY"
:y2="graphHeight - measurements.legends.offsetY">
</line>
<text <text
v-if="timeSeries.length > 1" v-if="timeSeries.length > 1"
class="legend-metric-title" class="legend-metric-title"
......
...@@ -9,6 +9,10 @@ ...@@ -9,6 +9,10 @@
type: String, type: String,
required: true, required: true,
}, },
lineStyle: {
type: String,
required: false,
},
lineColor: { lineColor: {
type: String, type: String,
required: true, required: true,
...@@ -18,6 +22,13 @@ ...@@ -18,6 +22,13 @@
required: true, required: true,
}, },
}, },
computed: {
strokeDashArray() {
if (this.lineStyle === 'dashed') return '3, 1';
if (this.lineStyle === 'dotted') return '1, 1';
return null;
},
},
}; };
</script> </script>
<template> <template>
...@@ -34,6 +45,7 @@ ...@@ -34,6 +45,7 @@
:stroke="lineColor" :stroke="lineColor"
fill="none" fill="none"
stroke-width="1" stroke-width="1"
:stroke-dasharray="strokeDashArray"
transform="translate(-5, 20)"> transform="translate(-5, 20)">
</path> </path>
</g> </g>
......
...@@ -7,15 +7,16 @@ export default { ...@@ -7,15 +7,16 @@ export default {
left: 40, left: 40,
}, },
legends: { legends: {
width: 10, width: 15,
height: 3, height: 3,
offsetX: 20,
offsetY: 32,
}, },
backgroundLegend: { backgroundLegend: {
width: 30, width: 30,
height: 50, height: 50,
}, },
axisLabelLineOffset: -20, axisLabelLineOffset: -20,
legendOffset: 33,
}, },
large: { // This covers both md and lg screen sizes large: { // This covers both md and lg screen sizes
margin: { margin: {
...@@ -27,13 +28,14 @@ export default { ...@@ -27,13 +28,14 @@ export default {
legends: { legends: {
width: 15, width: 15,
height: 3, height: 3,
offsetX: 20,
offsetY: 34,
}, },
backgroundLegend: { backgroundLegend: {
width: 30, width: 30,
height: 150, height: 150,
}, },
axisLabelLineOffset: 20, axisLabelLineOffset: 20,
legendOffset: 36,
}, },
xTicks: 8, xTicks: 8,
yTicks: 3, yTicks: 3,
......
...@@ -11,7 +11,9 @@ const defaultColorPalette = { ...@@ -11,7 +11,9 @@ const defaultColorPalette = {
const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple']; const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple'];
export default function createTimeSeries(queryData, graphWidth, graphHeight, graphHeightOffset) { const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) {
let usedColors = []; let usedColors = [];
function pickColor(name) { function pickColor(name) {
...@@ -31,17 +33,7 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra ...@@ -31,17 +33,7 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra
return defaultColorPalette[pick]; return defaultColorPalette[pick];
} }
const maxValues = queryData.result.map((timeSeries, index) => { return query.result.map((timeSeries, timeSeriesNumber) => {
const maxValue = d3.max(timeSeries.values.map(d => d.value));
return {
maxValue,
index,
};
});
const maxValueFromSeries = _.max(maxValues, val => val.maxValue);
return queryData.result.map((timeSeries, timeSeriesNumber) => {
let metricTag = ''; let metricTag = '';
let lineColor = ''; let lineColor = '';
let areaColor = ''; let areaColor = '';
...@@ -52,9 +44,9 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra ...@@ -52,9 +44,9 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra
const timeSeriesScaleY = d3.scale.linear() const timeSeriesScaleY = d3.scale.linear()
.range([graphHeight - graphHeightOffset, 0]); .range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(d3.extent(timeSeries.values, d => d.time)); timeSeriesScaleX.domain(xDom);
timeSeriesScaleX.ticks(d3.time.minute, 60); timeSeriesScaleX.ticks(d3.time.minute, 60);
timeSeriesScaleY.domain([0, maxValueFromSeries.maxValue]); timeSeriesScaleY.domain(yDom);
const defined = d => !isNaN(d.value) && d.value != null; const defined = d => !isNaN(d.value) && d.value != null;
...@@ -72,10 +64,10 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra ...@@ -72,10 +64,10 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra
.y1(d => timeSeriesScaleY(d.value)); .y1(d => timeSeriesScaleY(d.value));
const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
const seriesCustomizationData = queryData.series != null && const seriesCustomizationData = query.series != null &&
_.findWhere(queryData.series[0].when, _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
{ value: timeSeriesMetricLabel });
if (seriesCustomizationData != null) { if (seriesCustomizationData) {
metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
[lineColor, areaColor] = pickColor(seriesCustomizationData.color); [lineColor, areaColor] = pickColor(seriesCustomizationData.color);
} else { } else {
...@@ -83,14 +75,35 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra ...@@ -83,14 +75,35 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra
[lineColor, areaColor] = pickColor(); [lineColor, areaColor] = pickColor();
} }
if (query.track) {
metricTag += ` - ${query.track}`;
}
return { return {
linePath: lineFunction(timeSeries.values), linePath: lineFunction(timeSeries.values),
areaPath: areaFunction(timeSeries.values), areaPath: areaFunction(timeSeries.values),
timeSeriesScaleX, timeSeriesScaleX,
values: timeSeries.values, values: timeSeries.values,
lineStyle,
lineColor, lineColor,
areaColor, areaColor,
metricTag, metricTag,
}; };
}); });
} }
export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat(
query.result.reduce((allResults, result) => allResults.concat(result.values), []),
), []);
const xDom = d3.extent(allValues, d => d.time);
const yDom = [0, d3.max(allValues.map(d => d.value))];
return queries.reduce((series, query, index) => {
const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length];
return series.concat(
queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle),
);
}, []);
}
...@@ -6,7 +6,7 @@ import _ from 'underscore'; ...@@ -6,7 +6,7 @@ import _ from 'underscore';
// TODO: remove eventHub hack after code splitting refactor // TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop; window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
function UsersSelect(currentUser, els) { function UsersSelect(currentUser, els, options = {}) {
var $els; var $els;
this.users = this.users.bind(this); this.users = this.users.bind(this);
this.user = this.user.bind(this); this.user = this.user.bind(this);
...@@ -20,6 +20,8 @@ function UsersSelect(currentUser, els) { ...@@ -20,6 +20,8 @@ function UsersSelect(currentUser, els) {
} }
} }
const { handleClick } = options;
$els = $(els); $els = $(els);
if (!els) { if (!els) {
...@@ -442,6 +444,9 @@ function UsersSelect(currentUser, els) { ...@@ -442,6 +444,9 @@ function UsersSelect(currentUser, els) {
} }
if ($el.closest('.add-issues-modal').length) { if ($el.closest('.add-issues-modal').length) {
gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id;
} else if (handleClick) {
e.preventDefault();
handleClick(user, isMarking);
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
return Issuable.filterResults($dropdown.closest('form')); return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) { } else if ($dropdown.hasClass('js-filter-submit')) {
......
...@@ -5,17 +5,27 @@ export default { ...@@ -5,17 +5,27 @@ export default {
props: { props: {
title: { title: {
type: String, type: String,
required: true, required: false,
}, },
text: { text: {
type: String, type: String,
required: false, required: false,
}, },
hideFooter: {
type: Boolean,
required: false,
default: false,
},
kind: { kind: {
type: String, type: String,
required: false, required: false,
default: 'primary', default: 'primary',
}, },
modalDialogClass: {
type: String,
required: false,
default: '',
},
closeKind: { closeKind: {
type: String, type: String,
required: false, required: false,
...@@ -30,6 +40,11 @@ export default { ...@@ -30,6 +40,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
submitDisabled: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
...@@ -57,43 +72,58 @@ export default { ...@@ -57,43 +72,58 @@ export default {
</script> </script>
<template> <template>
<div <div class="modal-open">
class="modal popup-dialog" <div
role="dialog" class="modal popup-dialog"
tabindex="-1"> role="dialog"
<div class="modal-dialog" role="document"> tabindex="-1"
<div class="modal-content"> >
<div class="modal-header"> <div
<button type="button" :class="modalDialogClass"
class="close" class="modal-dialog"
@click="close" role="document"
aria-label="Close"> >
<span aria-hidden="true">&times;</span> <div class="modal-content">
</button> <div class="modal-header">
<h4 class="modal-title">{{this.title}}</h4> <slot name="header">
</div> <h4 class="modal-title pull-left">
<div class="modal-body"> {{this.title}}
<slot name="body" :text="text"> </h4>
<p>{{text}}</p> <button
</slot> type="button"
</div> class="close pull-right"
<div class="modal-footer"> @click="close"
<button aria-label="Close"
type="button" >
class="btn" <span aria-hidden="true">&times;</span>
:class="btnCancelKindClass" </button>
@click="close"> </slot>
{{ closeButtonLabel }} </div>
</button> <div class="modal-body">
<button <slot name="body" :text="text">
type="button" <p>{{this.text}}</p>
class="btn" </slot>
:class="btnKindClass" </div>
@click="emitSubmit(true)"> <div class="modal-footer" v-if="!hideFooter">
{{ primaryButtonLabel }} <button
</button> type="button"
class="btn pull-left"
:class="btnCancelKindClass"
@click="close">
{{ closeButtonLabel }}
</button>
<button
type="button"
class="btn pull-right"
:disabled="submitDisabled"
:class="btnKindClass"
@click="emitSubmit(true)">
{{ primaryButtonLabel }}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-backdrop fade in" />
</div> </div>
</template> </template>
...@@ -4,6 +4,9 @@ ...@@ -4,6 +4,9 @@
.cred { color: $common-red; } .cred { color: $common-red; }
.cgreen { color: $common-green; } .cgreen { color: $common-green; }
.cdark { color: $common-gray-dark; } .cdark { color: $common-gray-dark; }
.text-secondary {
color: $gl-text-color-secondary;
}
.underlined-link { text-decoration: underline; } .underlined-link { text-decoration: underline; }
.hint { font-style: italic; color: $hint-color; } .hint { font-style: italic; color: $hint-color; }
......
...@@ -37,6 +37,7 @@ ...@@ -37,6 +37,7 @@
.dropdown-menu-nav { .dropdown-menu-nav {
@include set-visible; @include set-visible;
display: block; display: block;
min-height: 40px;
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
width: 100%; width: 100%;
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
} }
.modal-body { .modal-body {
background-color: $modal-body-bg;
position: relative; position: relative;
padding: #{3 * $grid-size} #{2 * $grid-size}; padding: #{3 * $grid-size} #{2 * $grid-size};
...@@ -42,3 +43,8 @@ body.modal-open { ...@@ -42,3 +43,8 @@ body.modal-open {
width: 98%; width: 98%;
} }
} }
.modal.popup-dialog {
display: block;
}
...@@ -164,3 +164,36 @@ $pre-border-color: $border-color; ...@@ -164,3 +164,36 @@ $pre-border-color: $border-color;
$table-bg-accent: $gray-light; $table-bg-accent: $gray-light;
$zindex-popover: 900; $zindex-popover: 900;
//== Modals
//
//##
//** Padding applied to the modal body
$modal-inner-padding: $gl-padding;
//** Padding applied to the modal title
$modal-title-padding: $gl-padding;
//** Modal title line-height
// $modal-title-line-height: $line-height-base
//** Background color of modal content area
$modal-content-bg: $gray-light;
$modal-body-bg: $white-light;
//** Modal content border color
// $modal-content-border-color: rgba(0,0,0,.2)
//** Modal content border color **for IE8**
// $modal-content-fallback-border-color: #999
//** Modal backdrop background color
// $modal-backdrop-bg: #000
//** Modal backdrop opacity
// $modal-backdrop-opacity: .5
//** Modal header border color
// $modal-header-border-color: #e5e5e5
//** Modal footer border color
// $modal-footer-border-color: $modal-header-border-color
// $modal-lg: 900px
// $modal-md: 600px
// $modal-sm: 300px
class Projects::ClustersController < Projects::ApplicationController class Projects::ClustersController < Projects::ApplicationController
before_action :cluster, except: [:login, :index, :new, :create] before_action :cluster, except: [:login, :index, :new, :new_gcp, :create]
before_action :authorize_read_cluster! before_action :authorize_read_cluster!
before_action :authorize_create_cluster!, only: [:new, :create] before_action :authorize_create_cluster!, only: [:new, :new_gcp, :create]
before_action :authorize_google_api, only: [:new, :create] before_action :authorize_google_api, only: [:new_gcp, :create]
before_action :authorize_update_cluster!, only: [:update] before_action :authorize_update_cluster!, only: [:update]
before_action :authorize_admin_cluster!, only: [:destroy] before_action :authorize_admin_cluster!, only: [:destroy]
...@@ -16,7 +16,7 @@ class Projects::ClustersController < Projects::ApplicationController ...@@ -16,7 +16,7 @@ class Projects::ClustersController < Projects::ApplicationController
def login def login
begin begin
state = generate_session_key_redirect(namespace_project_clusters_url.to_s) state = generate_session_key_redirect(providers_gcp_new_namespace_project_clusters_url.to_s)
@authorize_url = GoogleApi::CloudPlatform::Client.new( @authorize_url = GoogleApi::CloudPlatform::Client.new(
nil, callback_google_api_auth_url, nil, callback_google_api_auth_url,
...@@ -27,6 +27,9 @@ class Projects::ClustersController < Projects::ApplicationController ...@@ -27,6 +27,9 @@ class Projects::ClustersController < Projects::ApplicationController
end end
def new def new
end
def new_gcp
@cluster = Clusters::Cluster.new.tap do |cluster| @cluster = Clusters::Cluster.new.tap do |cluster|
cluster.build_provider_gcp cluster.build_provider_gcp
end end
...@@ -40,7 +43,7 @@ class Projects::ClustersController < Projects::ApplicationController ...@@ -40,7 +43,7 @@ class Projects::ClustersController < Projects::ApplicationController
if @cluster.persisted? if @cluster.persisted?
redirect_to project_cluster_path(project, @cluster) redirect_to project_cluster_path(project, @cluster)
else else
render :new render :new_gcp
end end
end end
......
...@@ -15,6 +15,8 @@ ...@@ -15,6 +15,8 @@
# Anonymous users will never return any `owned` groups. They will return all # Anonymous users will never return any `owned` groups. They will return all
# public groups instead, even if `all_available` is set to false. # public groups instead, even if `all_available` is set to false.
class GroupsFinder < UnionFinder class GroupsFinder < UnionFinder
include CustomAttributesFilter
def initialize(current_user = nil, params = {}) def initialize(current_user = nil, params = {})
@current_user = current_user @current_user = current_user
@params = params @params = params
...@@ -22,8 +24,12 @@ class GroupsFinder < UnionFinder ...@@ -22,8 +24,12 @@ class GroupsFinder < UnionFinder
def execute def execute
items = all_groups.map do |item| items = all_groups.map do |item|
by_parent(item) item = by_parent(item)
item = by_custom_attributes(item)
item
end end
find_union(items, Group).with_route.order_id_desc find_union(items, Group).with_route.order_id_desc
end end
......
...@@ -18,6 +18,8 @@ ...@@ -18,6 +18,8 @@
# non_archived: boolean # non_archived: boolean
# #
class ProjectsFinder < UnionFinder class ProjectsFinder < UnionFinder
include CustomAttributesFilter
attr_accessor :params attr_accessor :params
attr_reader :current_user, :project_ids_relation attr_reader :current_user, :project_ids_relation
...@@ -44,6 +46,7 @@ class ProjectsFinder < UnionFinder ...@@ -44,6 +46,7 @@ class ProjectsFinder < UnionFinder
collection = by_tags(collection) collection = by_tags(collection)
collection = by_search(collection) collection = by_search(collection)
collection = by_archived(collection) collection = by_archived(collection)
collection = by_custom_attributes(collection)
sort(collection) sort(collection)
end end
......
...@@ -20,17 +20,6 @@ module BoardsHelper ...@@ -20,17 +20,6 @@ module BoardsHelper
project_issues_path(@project) project_issues_path(@project)
end end
def current_board_json
board = @board || @boards.first
board.to_json(
only: [:id, :name, :milestone_id],
include: {
milestone: { only: [:title] }
}
)
end
def board_base_url def board_base_url
project_boards_path(@project) project_boards_path(@project)
end end
......
...@@ -18,6 +18,8 @@ module Clusters ...@@ -18,6 +18,8 @@ module Clusters
end end
def set_initial_status def set_initial_status
return unless not_installable?
self.status = 'installable' if cluster&.platform_kubernetes_active? self.status = 'installable' if cluster&.platform_kubernetes_active?
end end
......
...@@ -13,15 +13,15 @@ module Clusters ...@@ -13,15 +13,15 @@ module Clusters
state :installed, value: 3 state :installed, value: 3
event :make_scheduled do event :make_scheduled do
transition %i(installable errored) => :scheduled transition [:installable, :errored] => :scheduled
end end
event :make_installing do event :make_installing do
transition %i(scheduled) => :installing transition [:scheduled] => :installing
end end
event :make_installed do event :make_installed do
transition %i(installing) => :installed transition [:installing] => :installed
end end
event :make_errored do event :make_errored do
......
...@@ -26,6 +26,7 @@ class Group < Namespace ...@@ -26,6 +26,7 @@ class Group < Namespace
has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
has_many :labels, class_name: 'GroupLabel' has_many :labels, class_name: 'GroupLabel'
has_many :variables, class_name: 'Ci::GroupVariable' has_many :variables, class_name: 'Ci::GroupVariable'
has_many :custom_attributes, class_name: 'GroupCustomAttribute'
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validate :visibility_level_allowed_by_projects validate :visibility_level_allowed_by_projects
......
class GroupCustomAttribute < ActiveRecord::Base
belongs_to :group
validates :group, :key, :value, presence: true
validates :key, uniqueness: { scope: [:group_id] }
end
...@@ -216,6 +216,7 @@ class Project < ActiveRecord::Base ...@@ -216,6 +216,7 @@ class Project < ActiveRecord::Base
has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_one :auto_devops, class_name: 'ProjectAutoDevops' has_one :auto_devops, class_name: 'ProjectAutoDevops'
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :project_feature, update_only: true
......
class ProjectCustomAttribute < ActiveRecord::Base
belongs_to :project
validates :project, :key, :value, presence: true
validates :key, uniqueness: { scope: [:project_id] }
end
...@@ -39,7 +39,7 @@ module ChatMessage ...@@ -39,7 +39,7 @@ module ChatMessage
private private
def message def message
if state == 'opened' if opened_issue?
"[#{project_link}] Issue #{state} by #{user_combined_name}" "[#{project_link}] Issue #{state} by #{user_combined_name}"
else else
"[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}" "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}"
......
...@@ -906,13 +906,13 @@ class Repository ...@@ -906,13 +906,13 @@ class Repository
branch = Gitlab::Git::Branch.find(self, branch_or_name) branch = Gitlab::Git::Branch.find(self, branch_or_name)
if branch if branch
root_ref_sha = commit(root_ref).sha @root_ref_sha ||= commit(root_ref).sha
same_head = branch.target == root_ref_sha same_head = branch.target == @root_ref_sha
merged = merged =
if pre_loaded_merged_branches if pre_loaded_merged_branches
pre_loaded_merged_branches.include?(branch.name) pre_loaded_merged_branches.include?(branch.name)
else else
ancestor?(branch.target, root_ref_sha) ancestor?(branch.target, @root_ref_sha)
end end
!same_head && merged !same_head && merged
......
...@@ -10,5 +10,5 @@ ...@@ -10,5 +10,5 @@
%label.text-danger %label.text-danger
= s_('ClusterIntegration|Remove cluster integration') = s_('ClusterIntegration|Remove cluster integration')
%p %p
= s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.') = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your cluster on Google Container Engine.')
= link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"}) = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
...@@ -11,4 +11,4 @@ ...@@ -11,4 +11,4 @@
= s_('ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters').html_safe % { link_to_requirements: link_to_requirements } = s_('ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters').html_safe % { link_to_requirements: link_to_requirements }
%li %li
- link_to_container_project = link_to(s_('ClusterIntegration|Google Container Engine project'), target: '_blank', rel: 'noopener noreferrer') - link_to_container_project = link_to(s_('ClusterIntegration|Google Container Engine project'), target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|A %{link_to_container_project} must have been created under this account').html_safe % { link_to_container_project: link_to_container_project } = s_('ClusterIntegration|This account must have permissions to create a cluster in the %{link_to_container_project} specified below').html_safe % { link_to_container_project: link_to_container_project }
- breadcrumb_title "Cluster" - breadcrumb_title "Cluster"
- page_title _("New Cluster") - page_title _("Cluster")
.row.prepend-top-default .row.prepend-top-default
.col-sm-4 .col-sm-4
= render 'sidebar' = render 'sidebar'
.col-sm-8 .col-sm-8
= render 'header' - if @project.kubernetes_service&.active?
= render 'form' %h4.prepend-top-0= s_('ClusterIntegration|Cluster management')
%p= s_('ClusterIntegration|A cluster has been set up on this project through the Kubernetes integration page')
= link_to s_('ClusterIntegration|Manage Kubernetes integration'), edit_project_service_path(@project, :kubernetes), class: 'btn append-bottom-20'
- else
%h4.prepend-top-0= s_('ClusterIntegration|Choose how to set up cluster integration')
%p= s_('ClusterIntegration|Create a new cluster on Google Container Engine right from GitLab')
= link_to s_('ClusterIntegration|Create on GKE'), providers_gcp_new_namespace_project_clusters_path(@project.namespace, @project), class: 'btn append-bottom-20'
%p= s_('ClusterIntegration|Enter the details for an existing Kubernetes cluster')
= link_to s_('ClusterIntegration|Add an existing cluster'), edit_project_service_path(@project, :kubernetes), class: 'btn append-bottom-20'
- breadcrumb_title "Cluster"
- page_title _("New Cluster")
.row.prepend-top-default
.col-sm-4
= render 'sidebar'
.col-sm-8
= render 'header'
= render 'form'
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
- @options && @options.each do |key, value| - @options && @options.each do |key, value|
= hidden_field_tag key, value, id: nil = hidden_field_tag key, value, id: nil
.dropdown .dropdown
= dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project), field_name: 'ref', submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown" } = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project, sort: 'updated_desc'), field_name: 'ref', submit_form_on_click: true, visit: true }, { toggle_class: "js-project-refs-dropdown" }
.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } .dropdown-menu.dropdown-menu-selectable.git-revision-dropdown.dropdown-menu-paging{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
.dropdown-page-one .dropdown-page-one
= dropdown_title _("Switch branch/tag") = dropdown_title _("Switch branch/tag")
......
---
title: Improve performance of the /projects/:id/repository/branches API endpoint
merge_request: 15215
author:
type: performance
---
title: Include link to issue in reopen message for Slack and Mattermost notifications
merge_request:
author:
type: fixed
---
title: Add a count of changes to the merge requests API
merge_request:
author:
type: changed
---
title: Fix GFM reference links for closed milestones
merge_request: 15234
author: Vitaliy @blackst0ne Klachkov
type: fixed
---
title: Support custom attributes on groups and projects
merge_request: 14593
author: Markus Koller
type: changed
---
title: Change tags order in refs dropdown
merge_request: 15235
author: Vitaliy @blackst0ne Klachkov
type: changed
---
title: Allow multiple queries in a single Prometheus graph to support additional environments
(Canary, Staging, et al.)
merge_request: 15201
author:
type: added
...@@ -145,7 +145,7 @@ ...@@ -145,7 +145,7 @@
- container_memory_usage_bytes - container_memory_usage_bytes
weight: 1 weight: 1
queries: queries:
- query_range: '(sum(container_memory_usage_bytes{container_name!="POD",%{environment_filter}}) / count(container_memory_usage_bytes{container_name!="POD",%{environment_filter}})) /1024/1024' - query_range: '(sum(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"})) /1024/1024'
label: Average label: Average
unit: MB unit: MB
- title: "CPU Utilization" - title: "CPU Utilization"
...@@ -154,7 +154,7 @@ ...@@ -154,7 +154,7 @@
- container_cpu_usage_seconds_total - container_cpu_usage_seconds_total
weight: 1 weight: 1
queries: queries:
- query_range: 'sum(rate(container_cpu_usage_seconds_total{container_name!="POD",%{environment_filter}}[2m])) by (cpu) * 100' - query_range: 'sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="%{ci_environment_slug}"}[2m])) * 100'
label: CPU label: CPU
unit: "%" unit: "%"
series: series:
......
...@@ -186,13 +186,14 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -186,13 +186,14 @@ constraints(ProjectUrlConstrainer.new) do
resources :clusters, except: [:edit] do resources :clusters, except: [:edit] do
collection do collection do
get :login get :login
get '/providers/gcp/new', action: :new_gcp
end end
member do member do
get :status, format: :json get :status, format: :json
scope :applications do scope :applications do
post '/*application', to: 'clusters/applications#create', as: :install_applications post '/:application', to: 'clusters/applications#create', as: :install_applications
end end
end end
end end
......
class CreateProjectCustomAttributes < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :project_custom_attributes do |t|
t.timestamps_with_timezone null: false
t.references :project, null: false, foreign_key: { on_delete: :cascade }
t.string :key, null: false
t.string :value, null: false
t.index [:project_id, :key], unique: true
t.index [:key, :value]
end
end
end
class CreateGroupCustomAttributes < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :group_custom_attributes do |t|
t.timestamps_with_timezone null: false
t.references :group, null: false
t.string :key, null: false
t.string :value, null: false
t.index [:group_id, :key], unique: true
t.index [:key, :value]
end
add_foreign_key :group_custom_attributes, :namespaces, column: :group_id, on_delete: :cascade # rubocop: disable Migration/AddConcurrentForeignKey
end
end
...@@ -770,6 +770,17 @@ ActiveRecord::Schema.define(version: 20171106101200) do ...@@ -770,6 +770,17 @@ ActiveRecord::Schema.define(version: 20171106101200) do
add_index "gpg_signatures", ["gpg_key_subkey_id"], name: "index_gpg_signatures_on_gpg_key_subkey_id", using: :btree add_index "gpg_signatures", ["gpg_key_subkey_id"], name: "index_gpg_signatures_on_gpg_key_subkey_id", using: :btree
add_index "gpg_signatures", ["project_id"], name: "index_gpg_signatures_on_project_id", using: :btree add_index "gpg_signatures", ["project_id"], name: "index_gpg_signatures_on_project_id", using: :btree
create_table "group_custom_attributes", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "group_id", null: false
t.string "key", null: false
t.string "value", null: false
end
add_index "group_custom_attributes", ["group_id", "key"], name: "index_group_custom_attributes_on_group_id_and_key", unique: true, using: :btree
add_index "group_custom_attributes", ["key", "value"], name: "index_group_custom_attributes_on_key_and_value", using: :btree
create_table "identities", force: :cascade do |t| create_table "identities", force: :cascade do |t|
t.string "extern_uid" t.string "extern_uid"
t.string "provider" t.string "provider"
...@@ -1290,6 +1301,17 @@ ActiveRecord::Schema.define(version: 20171106101200) do ...@@ -1290,6 +1301,17 @@ ActiveRecord::Schema.define(version: 20171106101200) do
add_index "project_auto_devops", ["project_id"], name: "index_project_auto_devops_on_project_id", unique: true, using: :btree add_index "project_auto_devops", ["project_id"], name: "index_project_auto_devops_on_project_id", unique: true, using: :btree
create_table "project_custom_attributes", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "project_id", null: false
t.string "key", null: false
t.string "value", null: false
end
add_index "project_custom_attributes", ["key", "value"], name: "index_project_custom_attributes_on_key_and_value", using: :btree
add_index "project_custom_attributes", ["project_id", "key"], name: "index_project_custom_attributes_on_project_id_and_key", unique: true, using: :btree
create_table "project_features", force: :cascade do |t| create_table "project_features", force: :cascade do |t|
t.integer "project_id" t.integer "project_id"
t.integer "merge_requests_access_level" t.integer "merge_requests_access_level"
...@@ -1912,6 +1934,7 @@ ActiveRecord::Schema.define(version: 20171106101200) do ...@@ -1912,6 +1934,7 @@ ActiveRecord::Schema.define(version: 20171106101200) do
add_foreign_key "gpg_signatures", "gpg_key_subkeys", on_delete: :nullify add_foreign_key "gpg_signatures", "gpg_key_subkeys", on_delete: :nullify
add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify add_foreign_key "gpg_signatures", "gpg_keys", on_delete: :nullify
add_foreign_key "gpg_signatures", "projects", on_delete: :cascade add_foreign_key "gpg_signatures", "projects", on_delete: :cascade
add_foreign_key "group_custom_attributes", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade add_foreign_key "issue_assignees", "issues", name: "fk_b7d881734a", on_delete: :cascade
add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade
add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade
...@@ -1942,6 +1965,7 @@ ActiveRecord::Schema.define(version: 20171106101200) do ...@@ -1942,6 +1965,7 @@ ActiveRecord::Schema.define(version: 20171106101200) do
add_foreign_key "project_authorizations", "projects", on_delete: :cascade add_foreign_key "project_authorizations", "projects", on_delete: :cascade
add_foreign_key "project_authorizations", "users", on_delete: :cascade add_foreign_key "project_authorizations", "users", on_delete: :cascade
add_foreign_key "project_auto_devops", "projects", on_delete: :cascade add_foreign_key "project_auto_devops", "projects", on_delete: :cascade
add_foreign_key "project_custom_attributes", "projects", on_delete: :cascade
add_foreign_key "project_features", "projects", name: "fk_18513d9b92", on_delete: :cascade add_foreign_key "project_features", "projects", name: "fk_18513d9b92", on_delete: :cascade
add_foreign_key "project_group_links", "projects", name: "fk_daa8cee94c", on_delete: :cascade add_foreign_key "project_group_links", "projects", name: "fk_daa8cee94c", on_delete: :cascade
add_foreign_key "project_import_data", "projects", name: "fk_ffb9ee3a10", on_delete: :cascade add_foreign_key "project_import_data", "projects", name: "fk_ffb9ee3a10", on_delete: :cascade
......
...@@ -2,17 +2,22 @@ ...@@ -2,17 +2,22 @@
Every API call to custom attributes must be authenticated as administrator. Every API call to custom attributes must be authenticated as administrator.
Custom attributes are currently available on users, groups, and projects,
which will be referred to as "resource" in this documentation.
## List custom attributes ## List custom attributes
Get all custom attributes on a user. Get all custom attributes on a resource.
``` ```
GET /users/:id/custom_attributes GET /users/:id/custom_attributes
GET /groups/:id/custom_attributes
GET /projects/:id/custom_attributes
``` ```
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a user | | `id` | integer | yes | The ID of a resource |
```bash ```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/42/custom_attributes curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/42/custom_attributes
...@@ -35,15 +40,17 @@ Example response: ...@@ -35,15 +40,17 @@ Example response:
## Single custom attribute ## Single custom attribute
Get a single custom attribute on a user. Get a single custom attribute on a resource.
``` ```
GET /users/:id/custom_attributes/:key GET /users/:id/custom_attributes/:key
GET /groups/:id/custom_attributes/:key
GET /projects/:id/custom_attributes/:key
``` ```
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a user | | `id` | integer | yes | The ID of a resource |
| `key` | string | yes | The key of the custom attribute | | `key` | string | yes | The key of the custom attribute |
```bash ```bash
...@@ -61,16 +68,18 @@ Example response: ...@@ -61,16 +68,18 @@ Example response:
## Set custom attribute ## Set custom attribute
Set a custom attribute on a user. The attribute will be updated if it already exists, Set a custom attribute on a resource. The attribute will be updated if it already exists,
or newly created otherwise. or newly created otherwise.
``` ```
PUT /users/:id/custom_attributes/:key PUT /users/:id/custom_attributes/:key
PUT /groups/:id/custom_attributes/:key
PUT /projects/:id/custom_attributes/:key
``` ```
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a user | | `id` | integer | yes | The ID of a resource |
| `key` | string | yes | The key of the custom attribute | | `key` | string | yes | The key of the custom attribute |
| `value` | string | yes | The value of the custom attribute | | `value` | string | yes | The value of the custom attribute |
...@@ -89,15 +98,17 @@ Example response: ...@@ -89,15 +98,17 @@ Example response:
## Delete custom attribute ## Delete custom attribute
Delete a custom attribute on a user. Delete a custom attribute on a resource.
``` ```
DELETE /users/:id/custom_attributes/:key DELETE /users/:id/custom_attributes/:key
DELETE /groups/:id/custom_attributes/:key
DELETE /projects/:id/custom_attributes/:key
``` ```
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a user | | `id` | integer | yes | The ID of a resource |
| `key` | string | yes | The key of the custom attribute | | `key` | string | yes | The key of the custom attribute |
```bash ```bash
......
...@@ -74,6 +74,12 @@ GET /groups?statistics=true ...@@ -74,6 +74,12 @@ GET /groups?statistics=true
You can search for groups by name or path, see below. You can search for groups by name or path, see below.
You can filter by [custom attributes](custom_attributes.md) with:
```
GET /groups?custom_attributes[key]=value&custom_attributes[other_key]=other_value
```
## List a group's projects ## List a group's projects
Get a list of projects in this group. When accessed without authentication, only Get a list of projects in this group. When accessed without authentication, only
......
...@@ -15,6 +15,11 @@ given state (`opened`, `closed`, or `merged`) or all of them (`all`). ...@@ -15,6 +15,11 @@ given state (`opened`, `closed`, or `merged`) or all of them (`all`).
The pagination parameters `page` and `per_page` can be used to The pagination parameters `page` and `per_page` can be used to
restrict the list of merge requests. restrict the list of merge requests.
**Note**: the `changes_count` value in the response is a string, not an
integer. This is because when an MR has too many changes to display and store,
it will be capped at 1,000. In that case, the API will return the string
`"1000+"` for the changes count.
``` ```
GET /merge_requests GET /merge_requests
GET /merge_requests?state=opened GET /merge_requests?state=opened
...@@ -92,6 +97,7 @@ Parameters: ...@@ -92,6 +97,7 @@ Parameters:
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": null, "merge_commit_sha": null,
"user_notes_count": 1, "user_notes_count": 1,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1", "web_url": "http://example.com/example/example/merge_requests/1",
...@@ -130,6 +136,11 @@ will be the same. In the case of a merge request from a fork, ...@@ -130,6 +136,11 @@ will be the same. In the case of a merge request from a fork,
`target_project_id` and `project_id` will be the same and `target_project_id` and `project_id` will be the same and
`source_project_id` will be the fork project's ID. `source_project_id` will be the fork project's ID.
**Note**: the `changes_count` value in the response is a string, not an
integer. This is because when an MR has too many changes to display and store,
it will be capped at 1,000. In that case, the API will return the string
`"1000+"` for the changes count.
Parameters: Parameters:
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
...@@ -198,6 +209,7 @@ Parameters: ...@@ -198,6 +209,7 @@ Parameters:
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": null, "merge_commit_sha": null,
"user_notes_count": 1, "user_notes_count": 1,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1", "web_url": "http://example.com/example/example/merge_requests/1",
...@@ -274,6 +286,7 @@ Parameters: ...@@ -274,6 +286,7 @@ Parameters:
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": "9999999999999999999999999999999999999999", "merge_commit_sha": "9999999999999999999999999999999999999999",
"user_notes_count": 1, "user_notes_count": 1,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1", "web_url": "http://example.com/example/example/merge_requests/1",
...@@ -386,6 +399,7 @@ Parameters: ...@@ -386,6 +399,7 @@ Parameters:
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": null, "merge_commit_sha": null,
"user_notes_count": 1, "user_notes_count": 1,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1", "web_url": "http://example.com/example/example/merge_requests/1",
...@@ -480,6 +494,7 @@ POST /projects/:id/merge_requests ...@@ -480,6 +494,7 @@ POST /projects/:id/merge_requests
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": null, "merge_commit_sha": null,
"user_notes_count": 0, "user_notes_count": 0,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1", "web_url": "http://example.com/example/example/merge_requests/1",
...@@ -565,6 +580,7 @@ Must include at least one non-required attribute from above. ...@@ -565,6 +580,7 @@ Must include at least one non-required attribute from above.
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": null, "merge_commit_sha": null,
"user_notes_count": 1, "user_notes_count": 1,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1", "web_url": "http://example.com/example/example/merge_requests/1",
...@@ -670,6 +686,7 @@ Parameters: ...@@ -670,6 +686,7 @@ Parameters:
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": "9999999999999999999999999999999999999999", "merge_commit_sha": "9999999999999999999999999999999999999999",
"user_notes_count": 1, "user_notes_count": 1,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1", "web_url": "http://example.com/example/example/merge_requests/1",
...@@ -747,6 +764,7 @@ Parameters: ...@@ -747,6 +764,7 @@ Parameters:
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": null, "merge_commit_sha": null,
"user_notes_count": 1, "user_notes_count": 1,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1", "web_url": "http://example.com/example/example/merge_requests/1",
...@@ -822,7 +840,8 @@ Example response when the GitLab issue tracker is used: ...@@ -822,7 +840,8 @@ Example response when the GitLab issue tracker is used:
"created_at" : "2016-01-04T15:31:51.081Z", "created_at" : "2016-01-04T15:31:51.081Z",
"iid" : 6, "iid" : 6,
"labels" : [], "labels" : [],
"user_notes_count": 1 "user_notes_count": 1,
"changes_count": "1"
}, },
] ]
``` ```
...@@ -1077,6 +1096,7 @@ Example response: ...@@ -1077,6 +1096,7 @@ Example response:
"sha": "8888888888888888888888888888888888888888", "sha": "8888888888888888888888888888888888888888",
"merge_commit_sha": null, "merge_commit_sha": null,
"user_notes_count": 7, "user_notes_count": 7,
"changes_count": "1",
"should_remove_source_branch": true, "should_remove_source_branch": true,
"force_remove_source_branch": false, "force_remove_source_branch": false,
"web_url": "http://example.com/example/example/merge_requests/1" "web_url": "http://example.com/example/example/merge_requests/1"
......
...@@ -192,6 +192,12 @@ GET /projects ...@@ -192,6 +192,12 @@ GET /projects
] ]
``` ```
You can filter by [custom attributes](custom_attributes.md) with:
```
GET /projects?custom_attributes[key]=value&custom_attributes[other_key]=other_value
```
## List user projects ## List user projects
Get a list of visible projects for the given user. When accessed without Get a list of visible projects for the given user. When accessed without
......
...@@ -77,15 +77,32 @@ having their priority set to null. ...@@ -77,15 +77,32 @@ having their priority set to null.
![Prioritize labels](img/labels_prioritize.png) ![Prioritize labels](img/labels_prioritize.png)
Now that you have labels prioritized, you can use the 'Priority' and 'Label Now that you have labels prioritized, you can use the 'Label priority' and 'Priority'
priority' filters in the issues or merge requests tracker. sort orders in the issues or merge requests tracker.
The 'Label priority' filter puts issues with the highest priority label on top. In the following, everything applies to both issues and merge requests, but we'll
refer to just issues for brevity.
The 'Priority' filter sorts issues by their soonest milestone due date, then by The 'Label priority' sort order positions issues with higher priority labels
label priority. toward the top, and issues with lower priority labels toward the bottom. A non-prioritized
label is considered to have the lowest priority. For a given issue, we _only_ consider the
highest priority label assigned to it in the comparison. ([We are discussing](https://gitlab.com/gitlab-org/gitlab-ce/issues/18554)
including all the labels in a given issue for this comparison.) Given two issues
are equal according to this sort comparison, their relative order is equal, and
therefore it's not guaranteed that one will be always above the other.
![Label priority sort order](img/label_priority_sort_order.png)
The 'Priority' sort order comparison first considers an issue's milestone's due date,
(if the issue is assigned a milestone and the milestone's due date exists), and then
secondarily considers the label priority comparison above. Sooner due dates results
a higher sort order. If an issue doesn't have a milestone due date, it is equivalent to
being assigned to a milestone that has a due date in the infinite future. Given two issues
are equal according to this two-stage sort comparison, their relative order is equal, and
therefore it's not guaranteed that one will be always above the other.
![Priority sort order](img/priority_sort_order.png)
![Filter labels by priority](img/labels_filter_by_priority.png)
## Subscribe to labels ## Subscribe to labels
......
...@@ -29,12 +29,11 @@ module API ...@@ -29,12 +29,11 @@ module API
use :pagination use :pagination
end end
get ':id/repository/branches' do get ':id/repository/branches' do
branches = ::Kaminari.paginate_array(user_project.repository.branches.sort_by(&:name)) repository = user_project.repository
branches = ::Kaminari.paginate_array(repository.branches.sort_by(&:name))
merged_branch_names = repository.merged_branch_names(branches.map(&:name))
# n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37442 present paginate(branches), with: Entities::Branch, project: user_project, merged_branch_names: merged_branch_names
Gitlab::GitalyClient.allow_n_plus_1_calls do
present paginate(branches), with: Entities::Branch, project: user_project
end
end end
resource ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do resource ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
......
...@@ -242,10 +242,7 @@ module API ...@@ -242,10 +242,7 @@ module API
end end
expose :merged do |repo_branch, options| expose :merged do |repo_branch, options|
# n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37442 options[:project].repository.merged_to_root_ref?(repo_branch, options[:merged_branch_names])
Gitlab::GitalyClient.allow_n_plus_1_calls do
options[:project].repository.merged_to_root_ref?(repo_branch.name)
end
end end
expose :protected do |repo_branch, options| expose :protected do |repo_branch, options|
...@@ -478,6 +475,10 @@ module API ...@@ -478,6 +475,10 @@ module API
expose :subscribed do |merge_request, options| expose :subscribed do |merge_request, options|
merge_request.subscribed?(options[:current_user], options[:project]) merge_request.subscribed?(options[:current_user], options[:project])
end end
expose :changes_count do |merge_request, _options|
merge_request.merge_request_diff.real_size
end
end end
class MergeRequestChanges < MergeRequest class MergeRequestChanges < MergeRequest
......
...@@ -37,6 +37,8 @@ module API ...@@ -37,6 +37,8 @@ module API
end end
resource :groups do resource :groups do
include CustomAttributesEndpoints
desc 'Get a groups list' do desc 'Get a groups list' do
success Entities::Group success Entities::Group
end end
...@@ -51,7 +53,12 @@ module API ...@@ -51,7 +53,12 @@ module API
use :pagination use :pagination
end end
get do get do
find_params = { all_available: params[:all_available], owned: params[:owned] } find_params = {
all_available: params[:all_available],
owned: params[:owned],
custom_attributes: params[:custom_attributes]
}
groups = GroupsFinder.new(current_user, find_params).execute groups = GroupsFinder.new(current_user, find_params).execute
groups = groups.search(params[:search]) if params[:search].present? groups = groups.search(params[:search]) if params[:search].present?
groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present? groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present?
......
...@@ -328,6 +328,7 @@ module API ...@@ -328,6 +328,7 @@ module API
finder_params[:archived] = params[:archived] finder_params[:archived] = params[:archived]
finder_params[:search] = params[:search] if params[:search] finder_params[:search] = params[:search] if params[:search]
finder_params[:user] = params.delete(:user) if params[:user] finder_params[:user] = params.delete(:user) if params[:user]
finder_params[:custom_attributes] = params[:custom_attributes] if params[:custom_attributes]
finder_params finder_params
end end
......
...@@ -119,6 +119,8 @@ module API ...@@ -119,6 +119,8 @@ module API
end end
resource :projects do resource :projects do
include CustomAttributesEndpoints
desc 'Get a list of visible projects for authenticated user' do desc 'Get a list of visible projects for authenticated user' do
success Entities::BasicProjectDetails success Entities::BasicProjectDetails
end end
......
...@@ -14,9 +14,11 @@ module API ...@@ -14,9 +14,11 @@ module API
success ::API::Entities::Branch success ::API::Entities::Branch
end end
get ":id/repository/branches" do get ":id/repository/branches" do
branches = user_project.repository.branches.sort_by(&:name) repository = user_project.repository
branches = repository.branches.sort_by(&:name)
merged_branch_names = repository.merged_branch_names(branches.map(&:name))
present branches, with: ::API::Entities::Branch, project: user_project present branches, with: ::API::Entities::Branch, project: user_project, merged_branch_names: merged_branch_names
end end
desc 'Delete a branch' desc 'Delete a branch'
......
...@@ -56,7 +56,7 @@ module Banzai ...@@ -56,7 +56,7 @@ module Banzai
end end
def find_milestone_with_finder(project, params) def find_milestone_with_finder(project, params)
finder_params = { project_ids: [project.id], order: nil } finder_params = { project_ids: [project.id], order: nil, state: 'all' }
# We don't support IID lookups for group milestones, because IIDs can # We don't support IID lookups for group milestones, because IIDs can
# clash between group and project milestones. # clash between group and project milestones.
......
...@@ -62,6 +62,7 @@ project_tree: ...@@ -62,6 +62,7 @@ project_tree:
- protected_tags: - protected_tags:
- :create_access_levels - :create_access_levels
- :project_feature - :project_feature
- :custom_attributes
# Only include the following attributes for the models specified. # Only include the following attributes for the models specified.
included_attributes: included_attributes:
......
...@@ -15,7 +15,8 @@ module Gitlab ...@@ -15,7 +15,8 @@ module Gitlab
labels: :project_labels, labels: :project_labels,
priorities: :label_priorities, priorities: :label_priorities,
auto_devops: :project_auto_devops, auto_devops: :project_auto_devops,
label: :project_label }.freeze label: :project_label,
custom_attributes: 'ProjectCustomAttribute' }.freeze
USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze
......
...@@ -14,6 +14,7 @@ module Gitlab ...@@ -14,6 +14,7 @@ module Gitlab
def initialize(kubeclient) def initialize(kubeclient)
@kubeclient = kubeclient @kubeclient = kubeclient
@namespace = Namespace.new(NAMESPACE, kubeclient)
end end
def init! def init!
...@@ -21,7 +22,7 @@ module Gitlab ...@@ -21,7 +22,7 @@ module Gitlab
end end
def install(app) def install(app)
create_namespace! unless has_namespace? @namespace.ensure_exists!
@kubeclient.create_pod(pod_resource(app)) @kubeclient.create_pod(pod_resource(app))
end end
...@@ -33,15 +34,15 @@ module Gitlab ...@@ -33,15 +34,15 @@ module Gitlab
# values: "Pending", "Running", "Succeeded", "Failed", "Unknown" # values: "Pending", "Running", "Succeeded", "Failed", "Unknown"
# #
def installation_status(app) def installation_status(app)
@kubeclient.get_pod(pod_name(app), NAMESPACE).status.phase @kubeclient.get_pod(pod_name(app), @namespace.name).status.phase
end end
def installation_log(app) def installation_log(app)
@kubeclient.get_pod_log(pod_name(app), NAMESPACE).body @kubeclient.get_pod_log(pod_name(app), @namespace.name).body
end end
def delete_installation_pod!(app) def delete_installation_pod!(app)
@kubeclient.delete_pod(pod_name(app), NAMESPACE) @kubeclient.delete_pod(pod_name(app), @namespace.name)
end end
private private
...@@ -52,7 +53,7 @@ module Gitlab ...@@ -52,7 +53,7 @@ module Gitlab
def pod_resource(app) def pod_resource(app)
labels = { 'gitlab.org/action': 'install', 'gitlab.org/application': app.name } labels = { 'gitlab.org/action': 'install', 'gitlab.org/application': app.name }
metadata = { name: pod_name(app), namespace: NAMESPACE, labels: labels } metadata = { name: pod_name(app), namespace: @namespace.name, labels: labels }
container = { container = {
name: 'helm', name: 'helm',
image: 'alpine:3.6', image: 'alpine:3.6',
...@@ -83,26 +84,6 @@ module Gitlab ...@@ -83,26 +84,6 @@ module Gitlab
def helm_install_comand(app) def helm_install_comand(app)
"install #{app.chart} --name #{app.name} --namespace #{NAMESPACE}" "install #{app.chart} --name #{app.name} --namespace #{NAMESPACE}"
end end
def has_namespace?
return @has_namespace if defined?(@has_namespace)
begin
@kubeclient.get_namespace(NAMESPACE)
@has_namespace = true
rescue KubeException => ke
raise ke unless ke.error_code == 404
false
end
end
def create_namespace!
namespace_resource = ::Kubeclient::Resource.new
namespace_resource.metadata = {}
namespace_resource.metadata.name = NAMESPACE
@kubeclient.create_namespace(namespace_resource)
end
end end
end end
end end
module Gitlab
module Kubernetes
class Namespace
attr_accessor :name
def initialize(name, client)
self.name = name
@client = client
end
def exists?
@client.get_namespace(name)
rescue ::KubeException => ke
raise ke unless ke.error_code == 404
false
end
def create!
resource = ::Kubeclient::Resource.new(metadata: { name: name })
@client.create_namespace(resource)
end
def ensure_exists!
exists? || create!
end
end
end
end
...@@ -399,7 +399,7 @@ msgstr "" ...@@ -399,7 +399,7 @@ msgstr ""
msgid "Cluster" msgid "Cluster"
msgstr "" msgstr ""
msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account" msgid "ClusterIntegration|This account must have permissions to create a cluster in the %{link_to_container_project} specified below"
msgstr "" msgstr ""
msgid "ClusterIntegration|Cluster details" msgid "ClusterIntegration|Cluster details"
...@@ -480,7 +480,7 @@ msgstr "" ...@@ -480,7 +480,7 @@ msgstr ""
msgid "ClusterIntegration|Remove integration" msgid "ClusterIntegration|Remove integration"
msgstr "" msgstr ""
msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project." msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your cluster on Google Container Engine."
msgstr "" msgstr ""
msgid "ClusterIntegration|See and edit the details for your cluster" msgid "ClusterIntegration|See and edit the details for your cluster"
......
...@@ -72,7 +72,7 @@ describe Projects::ClustersController do ...@@ -72,7 +72,7 @@ describe Projects::ClustersController do
go go
expect(assigns(:authorize_url)).to include(key) expect(assigns(:authorize_url)).to include(key)
expect(session[session_key_for_redirect_uri]).to eq(project_clusters_url(project)) expect(session[session_key_for_redirect_uri]).to eq(providers_gcp_new_project_clusters_url(project))
end end
end end
...@@ -113,7 +113,7 @@ describe Projects::ClustersController do ...@@ -113,7 +113,7 @@ describe Projects::ClustersController do
end end
end end
describe 'GET new' do describe 'GET new_gcp' do
let(:project) { create(:project) } let(:project) { create(:project) }
describe 'functionality' do describe 'functionality' do
...@@ -161,7 +161,7 @@ describe Projects::ClustersController do ...@@ -161,7 +161,7 @@ describe Projects::ClustersController do
end end
def go def go
get :new, namespace_id: project.namespace, project_id: project get :new_gcp, namespace_id: project.namespace, project_id: project
end end
end end
......
...@@ -503,13 +503,14 @@ describe ProjectsController do ...@@ -503,13 +503,14 @@ describe ProjectsController do
describe "GET refs" do describe "GET refs" do
let(:public_project) { create(:project, :public, :repository) } let(:public_project) { create(:project, :public, :repository) }
it "gets a list of branches and tags" do it 'gets a list of branches and tags' do
get :refs, namespace_id: public_project.namespace, id: public_project get :refs, namespace_id: public_project.namespace, id: public_project, sort: 'updated_desc'
parsed_body = JSON.parse(response.body) parsed_body = JSON.parse(response.body)
expect(parsed_body["Branches"]).to include("master") expect(parsed_body['Branches']).to include('master')
expect(parsed_body["Tags"]).to include("v1.0.0") expect(parsed_body['Tags'].first).to eq('v1.1.0')
expect(parsed_body["Commits"]).to be_nil expect(parsed_body['Tags'].last).to eq('v1.0.0')
expect(parsed_body['Commits']).to be_nil
end end
it "gets a list of branches, tags and commits" do it "gets a list of branches, tags and commits" do
......
...@@ -2,6 +2,10 @@ FactoryGirl.define do ...@@ -2,6 +2,10 @@ FactoryGirl.define do
factory :cluster_applications_helm, class: Clusters::Applications::Helm do factory :cluster_applications_helm, class: Clusters::Applications::Helm do
cluster factory: %i(cluster provided_by_gcp) cluster factory: %i(cluster provided_by_gcp)
trait :not_installable do
status -2
end
trait :installable do trait :installable do
status 0 status 0
end end
......
FactoryGirl.define do
factory :group_custom_attribute do
group
sequence(:key) { |n| "key#{n}" }
sequence(:value) { |n| "value#{n}" }
end
end
FactoryGirl.define do
factory :project_custom_attribute do
project
sequence(:key) { |n| "key#{n}" }
sequence(:value) { |n| "value#{n}" }
end
end
...@@ -22,6 +22,8 @@ feature 'Clusters', :js do ...@@ -22,6 +22,8 @@ feature 'Clusters', :js do
context 'when user does not have a cluster and visits cluster index page' do context 'when user does not have a cluster and visits cluster index page' do
before do before do
visit project_clusters_path(project) visit project_clusters_path(project)
click_link 'Create on GKE'
end end
it 'user sees a new page' do it 'user sees a new page' do
...@@ -98,7 +100,7 @@ feature 'Clusters', :js do ...@@ -98,7 +100,7 @@ feature 'Clusters', :js do
it 'user sees creation form with the succeccful message' do it 'user sees creation form with the succeccful message' do
expect(page).to have_content('Cluster integration was successfully removed.') expect(page).to have_content('Cluster integration was successfully removed.')
expect(page).to have_button('Create cluster') expect(page).to have_link('Create on GKE')
end end
end end
end end
...@@ -107,6 +109,8 @@ feature 'Clusters', :js do ...@@ -107,6 +109,8 @@ feature 'Clusters', :js do
context 'when user has not signed in Google' do context 'when user has not signed in Google' do
before do before do
visit project_clusters_path(project) visit project_clusters_path(project)
click_link 'Create on GKE'
end end
it 'user sees a login page' do it 'user sees a login page' do
......
...@@ -70,6 +70,7 @@ ...@@ -70,6 +70,7 @@
"sha": { "type": "string" }, "sha": { "type": "string" },
"merge_commit_sha": { "type": ["string", "null"] }, "merge_commit_sha": { "type": ["string", "null"] },
"user_notes_count": { "type": "integer" }, "user_notes_count": { "type": "integer" },
"changes_count": { "type": "string" },
"should_remove_source_branch": { "type": ["boolean", "null"] }, "should_remove_source_branch": { "type": ["boolean", "null"] },
"force_remove_source_branch": { "type": ["boolean", "null"] }, "force_remove_source_branch": { "type": ["boolean", "null"] },
"discussion_locked": { "type": ["boolean", "null"] }, "discussion_locked": { "type": ["boolean", "null"] },
......
...@@ -28,7 +28,7 @@ const defaultValuesComponent = { ...@@ -28,7 +28,7 @@ const defaultValuesComponent = {
currentDataIndex: 0, currentDataIndex: 0,
}; };
const timeSeries = createTimeSeries(convertedMetrics[0].queries[0], const timeSeries = createTimeSeries(convertedMetrics[0].queries,
defaultValuesComponent.graphWidth, defaultValuesComponent.graphHeight, defaultValuesComponent.graphWidth, defaultValuesComponent.graphHeight,
defaultValuesComponent.graphHeightOffset); defaultValuesComponent.graphHeightOffset);
......
...@@ -13,7 +13,7 @@ const createComponent = (propsData) => { ...@@ -13,7 +13,7 @@ const createComponent = (propsData) => {
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const timeSeries = createTimeSeries(convertedMetrics[0].queries[0], 428, 272, 120); const timeSeries = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
const firstTimeSeries = timeSeries[0]; const firstTimeSeries = timeSeries[0];
describe('Monitoring Paths', () => { describe('Monitoring Paths', () => {
......
...@@ -2,7 +2,7 @@ import createTimeSeries from '~/monitoring/utils/multiple_time_series'; ...@@ -2,7 +2,7 @@ import createTimeSeries from '~/monitoring/utils/multiple_time_series';
import { convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from '../mock_data'; import { convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from '../mock_data';
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const timeSeries = createTimeSeries(convertedMetrics[0].queries[0], 428, 272, 120); const timeSeries = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
const firstTimeSeries = timeSeries[0]; const firstTimeSeries = timeSeries[0];
describe('Multiple time series', () => { describe('Multiple time series', () => {
......
...@@ -294,8 +294,7 @@ describe Banzai::Filter::MilestoneReferenceFilter do ...@@ -294,8 +294,7 @@ describe Banzai::Filter::MilestoneReferenceFilter do
end end
end end
context 'project milestones' do shared_context 'project milestones' do
let(:milestone) { create(:milestone, project: project) }
let(:reference) { milestone.to_reference(format: :iid) } let(:reference) { milestone.to_reference(format: :iid) }
include_examples 'reference parsing' include_examples 'reference parsing'
...@@ -309,8 +308,7 @@ describe Banzai::Filter::MilestoneReferenceFilter do ...@@ -309,8 +308,7 @@ describe Banzai::Filter::MilestoneReferenceFilter do
it_behaves_like 'cross project shorthand reference' it_behaves_like 'cross project shorthand reference'
end end
context 'group milestones' do shared_context 'group milestones' do
let(:milestone) { create(:milestone, group: group) }
let(:reference) { milestone.to_reference(format: :name) } let(:reference) { milestone.to_reference(format: :name) }
include_examples 'reference parsing' include_examples 'reference parsing'
...@@ -354,4 +352,32 @@ describe Banzai::Filter::MilestoneReferenceFilter do ...@@ -354,4 +352,32 @@ describe Banzai::Filter::MilestoneReferenceFilter do
expect(result.css('a').first.attr('href')).to eq(urls.milestone_url(milestone)) expect(result.css('a').first.attr('href')).to eq(urls.milestone_url(milestone))
end end
end end
context 'when milestone is open' do
context 'project milestones' do
let(:milestone) { create(:milestone, project: project) }
include_context 'project milestones'
end
context 'group milestones' do
let(:milestone) { create(:milestone, group: group) }
include_context 'group milestones'
end
end
context 'when milestone is closed' do
context 'project milestones' do
let(:milestone) { create(:milestone, :closed, project: project) }
include_context 'project milestones'
end
context 'group milestones' do
let(:milestone) { create(:milestone, :closed, group: group) }
include_context 'group milestones'
end
end
end end
...@@ -274,6 +274,7 @@ project: ...@@ -274,6 +274,7 @@ project:
- root_of_fork_network - root_of_fork_network
- fork_network_member - fork_network_member
- fork_network - fork_network
- custom_attributes
award_emoji: award_emoji:
- awardable - awardable
- user - user
......
...@@ -7408,5 +7408,23 @@ ...@@ -7408,5 +7408,23 @@
"snippets_access_level": 20, "snippets_access_level": 20,
"updated_at": "2016-09-23T11:58:28.000Z", "updated_at": "2016-09-23T11:58:28.000Z",
"wiki_access_level": 20 "wiki_access_level": 20
} },
"custom_attributes": [
{
"id": 1,
"created_at": "2017-10-19T15:36:23.466Z",
"updated_at": "2017-10-19T15:36:23.466Z",
"project_id": 5,
"key": "foo",
"value": "foo"
},
{
"id": 2,
"created_at": "2017-10-19T15:37:21.904Z",
"updated_at": "2017-10-19T15:37:21.904Z",
"project_id": 5,
"key": "bar",
"value": "bar"
}
]
} }
...@@ -133,6 +133,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do ...@@ -133,6 +133,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
expect(@project.project_feature).not_to be_nil expect(@project.project_feature).not_to be_nil
end end
it 'has custom attributes' do
expect(@project.custom_attributes.count).to eq(2)
end
it 'restores the correct service' do it 'restores the correct service' do
expect(CustomIssueTrackerService.first).not_to be_nil expect(CustomIssueTrackerService.first).not_to be_nil
end end
......
...@@ -168,6 +168,10 @@ describe Gitlab::ImportExport::ProjectTreeSaver do ...@@ -168,6 +168,10 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
expect(project_feature["builds_access_level"]).to eq(ProjectFeature::PRIVATE) expect(project_feature["builds_access_level"]).to eq(ProjectFeature::PRIVATE)
end end
it 'has custom attributes' do
expect(saved_project_json['custom_attributes'].count).to eq(2)
end
it 'does not complain about non UTF-8 characters in MR diffs' do it 'does not complain about non UTF-8 characters in MR diffs' do
ActiveRecord::Base.connection.execute("UPDATE merge_request_diffs SET st_diffs = '---\n- :diff: !binary |-\n LS0tIC9kZXYvbnVsbAorKysgYi9pbWFnZXMvbnVjb3IucGRmCkBAIC0wLDAg\n KzEsMTY3OSBAQAorJVBERi0xLjUNJeLjz9MNCisxIDAgb2JqDTw8L01ldGFk\n YXR'") ActiveRecord::Base.connection.execute("UPDATE merge_request_diffs SET st_diffs = '---\n- :diff: !binary |-\n LS0tIC9kZXYvbnVsbAorKysgYi9pbWFnZXMvbnVjb3IucGRmCkBAIC0wLDAg\n KzEsMTY3OSBAQAorJVBERi0xLjUNJeLjz9MNCisxIDAgb2JqDTw8L01ldGFk\n YXR'")
...@@ -279,6 +283,9 @@ describe Gitlab::ImportExport::ProjectTreeSaver do ...@@ -279,6 +283,9 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
create(:event, :created, target: milestone, project: project, author: user) create(:event, :created, target: milestone, project: project, author: user)
create(:service, project: project, type: 'CustomIssueTrackerService', category: 'issue_tracker') create(:service, project: project, type: 'CustomIssueTrackerService', category: 'issue_tracker')
create(:project_custom_attribute, project: project)
create(:project_custom_attribute, project: project)
project project
end end
......
...@@ -525,4 +525,11 @@ ProjectAutoDevops: ...@@ -525,4 +525,11 @@ ProjectAutoDevops:
- updated_at - updated_at
IssueAssignee: IssueAssignee:
- user_id - user_id
- issue_id - issue_id
\ No newline at end of file ProjectCustomAttribute:
- id
- created_at
- updated_at
- project_id
- key
- value
require 'spec_helper'
describe Gitlab::Kubernetes::Namespace do
let(:name) { 'a_namespace' }
let(:client) { double('kubernetes client') }
subject { described_class.new(name, client) }
it { expect(subject.name).to eq(name) }
describe '#exists?' do
context 'when namespace do not exits' do
let(:exception) { ::KubeException.new(404, "namespace #{name} not found", nil) }
it 'returns false' do
expect(client).to receive(:get_namespace).with(name).once.and_raise(exception)
expect(subject.exists?).to be_falsey
end
end
context 'when namespace exits' do
let(:namespace) { ::Kubeclient::Resource.new(kind: 'Namespace', metadata: { name: name }) } # partial representation
it 'returns true' do
expect(client).to receive(:get_namespace).with(name).once.and_return(namespace)
expect(subject.exists?).to be_truthy
end
end
context 'when cluster cannot be reached' do
let(:exception) { Errno::ECONNREFUSED.new }
it 'raises exception' do
expect(client).to receive(:get_namespace).with(name).once.and_raise(exception)
expect { subject.exists? }.to raise_error(exception)
end
end
end
describe '#create!' do
it 'creates a namespace' do
matcher = have_attributes(metadata: have_attributes(name: name))
expect(client).to receive(:create_namespace).with(matcher).once
expect { subject.create! }.not_to raise_error
end
end
describe '#ensure_exists!' do
it 'checks for existing namespace before creating' do
expect(subject).to receive(:exists?).once.ordered.and_return(false)
expect(subject).to receive(:create!).once.ordered
subject.ensure_exists!
end
it 'do not re-create an existing namespace' do
expect(subject).to receive(:exists?).once.and_return(true)
expect(subject).not_to receive(:create!)
subject.ensure_exists!
end
end
end
require 'rails_helper' require 'rails_helper'
RSpec.describe Clusters::Applications::Helm, type: :model do describe Clusters::Applications::Helm do
it { is_expected.to belong_to(:cluster) } it { is_expected.to belong_to(:cluster) }
it { is_expected.to validate_presence_of(:cluster) } it { is_expected.to validate_presence_of(:cluster) }
...@@ -21,8 +21,20 @@ RSpec.describe Clusters::Applications::Helm, type: :model do ...@@ -21,8 +21,20 @@ RSpec.describe Clusters::Applications::Helm, type: :model do
end end
describe '#status' do describe '#status' do
it 'defaults to :installable' do let(:cluster) { create(:cluster) }
expect(subject.status_name).to be(:installable)
subject { described_class.new(cluster: cluster) }
it 'defaults to :not_installable' do
expect(subject.status_name).to be(:not_installable)
end
context 'when platform kubernetes is defined' do
let(:cluster) { create(:cluster, :provided_by_gcp) }
it 'defaults to :installable' do
expect(subject.status_name).to be(:installable)
end
end end
end end
......
require 'spec_helper'
describe GroupCustomAttribute do
describe 'assocations' do
it { is_expected.to belong_to(:group) }
end
describe 'validations' do
subject { build :group_custom_attribute }
it { is_expected.to validate_presence_of(:group) }
it { is_expected.to validate_presence_of(:key) }
it { is_expected.to validate_presence_of(:value) }
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:group_id) }
end
end
...@@ -17,6 +17,7 @@ describe Group do ...@@ -17,6 +17,7 @@ describe Group do
it { is_expected.to have_many(:variables).class_name('Ci::GroupVariable') } it { is_expected.to have_many(:variables).class_name('Ci::GroupVariable') }
it { is_expected.to have_many(:uploads).dependent(:destroy) } it { is_expected.to have_many(:uploads).dependent(:destroy) }
it { is_expected.to have_one(:chat_team) } it { is_expected.to have_one(:chat_team) }
it { is_expected.to have_many(:custom_attributes).class_name('GroupCustomAttribute') }
describe '#members & #requesters' do describe '#members & #requesters' do
let(:requester) { create(:user) } let(:requester) { create(:user) }
......
require 'spec_helper'
describe ProjectCustomAttribute do
describe 'assocations' do
it { is_expected.to belong_to(:project) }
end
describe 'validations' do
subject { build :project_custom_attribute }
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:key) }
it { is_expected.to validate_presence_of(:value) }
it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id) }
end
end
...@@ -66,6 +66,19 @@ describe ChatMessage::IssueMessage do ...@@ -66,6 +66,19 @@ describe ChatMessage::IssueMessage do
expect(subject.attachments).to be_empty expect(subject.attachments).to be_empty
end end
end end
context 'reopen' do
before do
args[:object_attributes][:action] = 'reopen'
args[:object_attributes][:state] = 'opened'
end
it 'returns a message regarding reopening of issues' do
expect(subject.pretext)
.to eq('[<http://somewhere.com|project_name>] Issue <http://url.com|#100 Issue title> opened by Test User (test.user)')
expect(subject.attachments).to be_empty
end
end
end end
context 'with markdown' do context 'with markdown' do
......
...@@ -79,6 +79,7 @@ describe Project do ...@@ -79,6 +79,7 @@ describe Project do
it { is_expected.to have_many(:pipeline_schedules) } it { is_expected.to have_many(:pipeline_schedules) }
it { is_expected.to have_many(:members_and_requesters) } it { is_expected.to have_many(:members_and_requesters) }
it { is_expected.to have_one(:cluster) } it { is_expected.to have_one(:cluster) }
it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') }
context 'after initialized' do context 'after initialized' do
it "has a project_feature" do it "has a project_feature" do
......
...@@ -618,4 +618,14 @@ describe API::Groups do ...@@ -618,4 +618,14 @@ describe API::Groups do
end end
end end
end end
it_behaves_like 'custom attributes endpoints', 'groups' do
let(:attributable) { group1 }
let(:other_attributable) { group2 }
let(:user) { user1 }
before do
group2.add_owner(user1)
end
end
end end
...@@ -435,17 +435,7 @@ describe API::MergeRequests do ...@@ -435,17 +435,7 @@ describe API::MergeRequests do
expect(json_response['merge_status']).to eq('can_be_merged') expect(json_response['merge_status']).to eq('can_be_merged')
expect(json_response['should_close_merge_request']).to be_falsy expect(json_response['should_close_merge_request']).to be_falsy
expect(json_response['force_close_merge_request']).to be_falsy expect(json_response['force_close_merge_request']).to be_falsy
end expect(json_response['changes_count']).to eq(merge_request.merge_request_diff.real_size)
it "returns merge_request" do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user)
expect(response).to have_gitlab_http_status(200)
expect(json_response['title']).to eq(merge_request.title)
expect(json_response['iid']).to eq(merge_request.iid)
expect(json_response['work_in_progress']).to eq(false)
expect(json_response['merge_status']).to eq('can_be_merged')
expect(json_response['should_close_merge_request']).to be_falsy
expect(json_response['force_close_merge_request']).to be_falsy
end end
it "returns a 404 error if merge_request_iid not found" do it "returns a 404 error if merge_request_iid not found" do
...@@ -462,12 +452,32 @@ describe API::MergeRequests do ...@@ -462,12 +452,32 @@ describe API::MergeRequests do
context 'Work in Progress' do context 'Work in Progress' do
let!(:merge_request_wip) { create(:merge_request, author: user, assignee: user, source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) } let!(:merge_request_wip) { create(:merge_request, author: user, assignee: user, source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) }
it "returns merge_request" do it "returns merge request" do
get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.iid}", user) get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.iid}", user)
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(json_response['work_in_progress']).to eq(true) expect(json_response['work_in_progress']).to eq(true)
end end
end end
context 'when a merge request has more than the changes limit' do
it "returns a string indicating that more changes were made" do
stub_const('Commit::DIFF_HARD_LIMIT_FILES', 5)
merge_request_overflow = create(:merge_request, :simple,
author: user,
assignee: user,
source_project: project,
source_branch: 'expand-collapse-files',
target_project: project,
target_branch: 'master')
get api("/projects/#{project.id}/merge_requests/#{merge_request_overflow.iid}", user)
expect(response).to have_gitlab_http_status(200)
expect(json_response['changes_count']).to eq('5+')
end
end
end end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/commits' do describe 'GET /projects/:id/merge_requests/:merge_request_iid/commits' do
......
...@@ -1856,4 +1856,9 @@ describe API::Projects do ...@@ -1856,4 +1856,9 @@ describe API::Projects do
end end
end end
end end
it_behaves_like 'custom attributes endpoints', 'projects' do
let(:attributable) { project }
let(:other_attributable) { project2 }
end
end end
...@@ -1880,7 +1880,8 @@ describe API::Users do ...@@ -1880,7 +1880,8 @@ describe API::Users do
end end
end end
include_examples 'custom attributes endpoints', 'users' do it_behaves_like 'custom attributes endpoints', 'users' do
let(:attributable) { user } let(:attributable) { user }
let(:other_attributable) { admin }
end end
end end
...@@ -3,7 +3,9 @@ shared_examples 'custom attributes endpoints' do |attributable_name| ...@@ -3,7 +3,9 @@ shared_examples 'custom attributes endpoints' do |attributable_name|
let!(:custom_attribute2) { attributable.custom_attributes.create key: 'bar', value: 'bar' } let!(:custom_attribute2) { attributable.custom_attributes.create key: 'bar', value: 'bar' }
describe "GET /#{attributable_name} with custom attributes filter" do describe "GET /#{attributable_name} with custom attributes filter" do
let!(:other_attributable) { create attributable.class.name.underscore } before do
other_attributable
end
context 'with an unauthorized user' do context 'with an unauthorized user' do
it 'does not filter by custom attributes' do it 'does not filter by custom attributes' do
...@@ -11,6 +13,7 @@ shared_examples 'custom attributes endpoints' do |attributable_name| ...@@ -11,6 +13,7 @@ shared_examples 'custom attributes endpoints' do |attributable_name|
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(json_response.size).to be 2 expect(json_response.size).to be 2
expect(json_response.map { |r| r['id'] }).to contain_exactly attributable.id, other_attributable.id
end 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