Commit 7ec89692 authored by Z.J. van de Weg's avatar Z.J. van de Weg

Merge branch 'master' into zj-realtime-env-list

parents 2f62af68 78de1c05
...@@ -52,7 +52,7 @@ stages: ...@@ -52,7 +52,7 @@ stages:
.use-pg: &use-pg .use-pg: &use-pg
services: services:
- postgres:latest - postgres:9.2
- redis:alpine - redis:alpine
.use-mysql: &use-mysql .use-mysql: &use-mysql
...@@ -63,6 +63,7 @@ stages: ...@@ -63,6 +63,7 @@ stages:
.only-master-and-ee-or-mysql: &only-master-and-ee-or-mysql .only-master-and-ee-or-mysql: &only-master-and-ee-or-mysql
only: only:
- /mysql/ - /mysql/
- /-stable$/
- master@gitlab-org/gitlab-ce - master@gitlab-org/gitlab-ce
- master@gitlab/gitlabhq - master@gitlab/gitlabhq
- tags@gitlab-org/gitlab-ce - tags@gitlab-org/gitlab-ce
...@@ -149,6 +150,7 @@ stages: ...@@ -149,6 +150,7 @@ stages:
# Trigger a package build on omnibus-gitlab repository # Trigger a package build on omnibus-gitlab repository
build-package: build-package:
image: ruby:2.3-alpine
before_script: [] before_script: []
services: [] services: []
variables: variables:
......
...@@ -2,6 +2,11 @@ ...@@ -2,6 +2,11 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 9.2.1 (2017-05-23)
- Fix placement of note emoji on hover.
- Fix migration for older PostgreSQL versions.
## 9.2.0 (2017-05-22) ## 9.2.0 (2017-05-22)
- API: Filter merge requests by milestone and labels. (10924) - API: Filter merge requests by milestone and labels. (10924)
......
...@@ -18,12 +18,12 @@ const gfmRules = { ...@@ -18,12 +18,12 @@ const gfmRules = {
}, },
}, },
TaskListFilter: { TaskListFilter: {
'input[type=checkbox].task-list-item-checkbox'(el, text) { 'input[type=checkbox].task-list-item-checkbox'(el) {
return `[${el.checked ? 'x' : ' '}]`; return `[${el.checked ? 'x' : ' '}]`;
}, },
}, },
ReferenceFilter: { ReferenceFilter: {
'.tooltip'(el, text) { '.tooltip'(el) {
return ''; return '';
}, },
'a.gfm:not([data-link=true])'(el, text) { 'a.gfm:not([data-link=true])'(el, text) {
...@@ -39,15 +39,15 @@ const gfmRules = { ...@@ -39,15 +39,15 @@ const gfmRules = {
}, },
}, },
TableOfContentsFilter: { TableOfContentsFilter: {
'ul.section-nav'(el, text) { 'ul.section-nav'(el) {
return '[[_TOC_]]'; return '[[_TOC_]]';
}, },
}, },
EmojiFilter: { EmojiFilter: {
'img.emoji'(el, text) { 'img.emoji'(el) {
return el.getAttribute('alt'); return el.getAttribute('alt');
}, },
'gl-emoji'(el, text) { 'gl-emoji'(el) {
return `:${el.getAttribute('data-name')}:`; return `:${el.getAttribute('data-name')}:`;
}, },
}, },
...@@ -57,13 +57,13 @@ const gfmRules = { ...@@ -57,13 +57,13 @@ const gfmRules = {
}, },
}, },
VideoLinkFilter: { VideoLinkFilter: {
'.video-container'(el, text) { '.video-container'(el) {
const videoEl = el.querySelector('video'); const videoEl = el.querySelector('video');
if (!videoEl) return false; if (!videoEl) return false;
return CopyAsGFM.nodeToGFM(videoEl); return CopyAsGFM.nodeToGFM(videoEl);
}, },
'video'(el, text) { 'video'(el) {
return `![${el.dataset.title}](${el.getAttribute('src')})`; return `![${el.dataset.title}](${el.getAttribute('src')})`;
}, },
}, },
...@@ -74,19 +74,19 @@ const gfmRules = { ...@@ -74,19 +74,19 @@ const gfmRules = {
'code.code.math[data-math-style=inline]'(el, text) { 'code.code.math[data-math-style=inline]'(el, text) {
return `$\`${text}\`$`; return `$\`${text}\`$`;
}, },
'span.katex-display span.katex-mathml'(el, text) { 'span.katex-display span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false; if (!mathAnnotation) return false;
return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
}, },
'span.katex-mathml'(el, text) { 'span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false; if (!mathAnnotation) return false;
return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
}, },
'span.katex-html'(el, text) { 'span.katex-html'(el) {
// We don't want to include the content of this element in the copied text. // We don't want to include the content of this element in the copied text.
return ''; return '';
}, },
...@@ -95,7 +95,7 @@ const gfmRules = { ...@@ -95,7 +95,7 @@ const gfmRules = {
}, },
}, },
SanitizationFilter: { SanitizationFilter: {
'a[name]:not([href]):empty'(el, text) { 'a[name]:not([href]):empty'(el) {
return el.outerHTML; return el.outerHTML;
}, },
'dl'(el, text) { 'dl'(el, text) {
...@@ -143,7 +143,7 @@ const gfmRules = { ...@@ -143,7 +143,7 @@ const gfmRules = {
}, },
}, },
MarkdownFilter: { MarkdownFilter: {
'br'(el, text) { 'br'(el) {
// Two spaces at the end of a line are turned into a BR // Two spaces at the end of a line are turned into a BR
return ' '; return ' ';
}, },
...@@ -162,7 +162,7 @@ const gfmRules = { ...@@ -162,7 +162,7 @@ const gfmRules = {
'blockquote'(el, text) { 'blockquote'(el, text) {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
}, },
'img'(el, text) { 'img'(el) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
}, },
'a.anchor'(el, text) { 'a.anchor'(el, text) {
...@@ -222,10 +222,10 @@ const gfmRules = { ...@@ -222,10 +222,10 @@ const gfmRules = {
'sup'(el, text) { 'sup'(el, text) {
return `^${text}`; return `^${text}`;
}, },
'hr'(el, text) { 'hr'(el) {
return '-----'; return '-----';
}, },
'table'(el, text) { 'table'(el) {
const theadEl = el.querySelector('thead'); const theadEl = el.querySelector('thead');
const tbodyEl = el.querySelector('tbody'); const tbodyEl = el.querySelector('tbody');
if (!theadEl || !tbodyEl) return false; if (!theadEl || !tbodyEl) return false;
...@@ -233,11 +233,11 @@ const gfmRules = { ...@@ -233,11 +233,11 @@ const gfmRules = {
const theadText = CopyAsGFM.nodeToGFM(theadEl); const theadText = CopyAsGFM.nodeToGFM(theadEl);
const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
return theadText + tbodyText; return [theadText, tbodyText].join('\n');
}, },
'thead'(el, text) { 'thead'(el, text) {
const cells = _.map(el.querySelectorAll('th'), (cell) => { const cells = _.map(el.querySelectorAll('th'), (cell) => {
let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2; let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
let before = ''; let before = '';
let after = ''; let after = '';
...@@ -262,10 +262,15 @@ const gfmRules = { ...@@ -262,10 +262,15 @@ const gfmRules = {
return before + middle + after; return before + middle + after;
}); });
return `${text}|${cells.join('|')}|`; const separatorRow = `|${cells.join('|')}|`;
return [text, separatorRow].join('\n');
}, },
'tr'(el, text) { 'tr'(el) {
const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim()); const cellEls = el.querySelectorAll('td, th');
if (cellEls.length === 0) return false;
const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
return `| ${cells.join(' | ')} |`; return `| ${cells.join(' | ')} |`;
}, },
}, },
...@@ -273,12 +278,12 @@ const gfmRules = { ...@@ -273,12 +278,12 @@ const gfmRules = {
class CopyAsGFM { class CopyAsGFM {
constructor() { constructor() {
$(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); });
$(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); });
$(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this)); $(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM);
} }
copyAsGFM(e, transformer) { static copyAsGFM(e, transformer) {
const clipboardData = e.originalEvent.clipboardData; const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return; if (!clipboardData) return;
...@@ -292,27 +297,60 @@ class CopyAsGFM { ...@@ -292,27 +297,60 @@ class CopyAsGFM {
e.stopPropagation(); e.stopPropagation();
clipboardData.setData('text/plain', el.textContent); clipboardData.setData('text/plain', el.textContent);
clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el)); clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
} }
pasteGFM(e) { static pasteGFM(e) {
const clipboardData = e.originalEvent.clipboardData; const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return; if (!clipboardData) return;
const text = clipboardData.getData('text/plain');
const gfm = clipboardData.getData('text/x-gfm'); const gfm = clipboardData.getData('text/x-gfm');
if (!gfm) return; if (!gfm) return;
e.preventDefault(); e.preventDefault();
window.gl.utils.insertText(e.target, gfm); window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
// If the text before the cursor contains an odd number of backticks,
// we are either inside an inline code span that starts with 1 backtick
// or a code block that starts with 3 backticks.
// This logic still holds when there are one or more _closed_ code spans
// or blocks that will have 2 or 6 backticks.
// This will break down when the actual code block contains an uneven
// number of backticks, but this is a rare edge case.
const backtickMatch = textBefore.match(/`/g);
const insideCodeBlock = backtickMatch && (backtickMatch.length % 2) === 1;
if (insideCodeBlock) {
return text;
} }
static transformGFMSelection(documentFragment) { return gfm;
// If the documentFragment contains more than just Markdown, don't copy as GFM. });
if (documentFragment.querySelector('.md, .wiki')) return null; }
static transformGFMSelection(documentFragment) {
const gfmEls = documentFragment.querySelectorAll('.md, .wiki');
switch (gfmEls.length) {
case 0: {
return documentFragment; return documentFragment;
} }
case 1: {
return gfmEls[0];
}
default: {
const allGfmEl = document.createElement('div');
for (let i = 0; i < gfmEls.length; i += 1) {
const lineEl = gfmEls[i];
allGfmEl.appendChild(lineEl);
allGfmEl.appendChild(document.createTextNode('\n\n'));
}
return allGfmEl;
}
}
}
static transformCodeSelection(documentFragment) { static transformCodeSelection(documentFragment) {
const lineEls = documentFragment.querySelectorAll('.line'); const lineEls = documentFragment.querySelectorAll('.line');
...@@ -343,7 +381,7 @@ class CopyAsGFM { ...@@ -343,7 +381,7 @@ class CopyAsGFM {
return codeEl; return codeEl;
} }
static nodeToGFM(node) { static nodeToGFM(node, respectWhitespaceParam = false) {
if (node.nodeType === Node.COMMENT_NODE) { if (node.nodeType === Node.COMMENT_NODE) {
return ''; return '';
} }
...@@ -352,7 +390,9 @@ class CopyAsGFM { ...@@ -352,7 +390,9 @@ class CopyAsGFM {
return node.textContent; return node.textContent;
} }
const text = this.innerGFM(node); const respectWhitespace = respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
const text = this.innerGFM(node, respectWhitespace);
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return text; return text;
...@@ -366,7 +406,17 @@ class CopyAsGFM { ...@@ -366,7 +406,17 @@ class CopyAsGFM {
if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue; if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue;
const result = func(node, text); let result;
if (func.length === 2) {
// if `func` takes 2 arguments, it depends on text.
// if there is no text, we don't need to generate GFM for this node.
if (text.length === 0) continue;
result = func(node, text);
} else {
result = func(node);
}
if (result === false) continue; if (result === false) continue;
return result; return result;
...@@ -376,7 +426,7 @@ class CopyAsGFM { ...@@ -376,7 +426,7 @@ class CopyAsGFM {
return text; return text;
} }
static innerGFM(parentNode) { static innerGFM(parentNode, respectWhitespace = false) {
const nodes = parentNode.childNodes; const nodes = parentNode.childNodes;
const clonedParentNode = parentNode.cloneNode(true); const clonedParentNode = parentNode.cloneNode(true);
...@@ -386,13 +436,19 @@ class CopyAsGFM { ...@@ -386,13 +436,19 @@ class CopyAsGFM {
const node = nodes[i]; const node = nodes[i];
const clonedNode = clonedNodes[i]; const clonedNode = clonedNodes[i];
const text = this.nodeToGFM(node); const text = this.nodeToGFM(node, respectWhitespace);
// `clonedNode.replaceWith(text)` is not yet widely supported // `clonedNode.replaceWith(text)` is not yet widely supported
clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
} }
return clonedParentNode.innerText || clonedParentNode.textContent; let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
if (!respectWhitespace) {
nodeText = nodeText.trim();
}
return nodeText;
} }
} }
......
...@@ -40,6 +40,7 @@ import Group from './group'; ...@@ -40,6 +40,7 @@ import Group from './group';
import GroupName from './group_name'; import GroupName from './group_name';
import GroupsList from './groups_list'; import GroupsList from './groups_list';
import ProjectsList from './projects_list'; import ProjectsList from './projects_list';
import setupProjectEdit from './project_edit';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
import Landing from './landing'; import Landing from './landing';
...@@ -264,6 +265,9 @@ import ShortcutsBlob from './shortcuts_blob'; ...@@ -264,6 +265,9 @@ import ShortcutsBlob from './shortcuts_blob';
new BlobViewer(); new BlobViewer();
} }
break; break;
case 'projects:edit':
setupProjectEdit();
break;
case 'projects:pipelines:builds': case 'projects:pipelines:builds':
case 'projects:pipelines:failures': case 'projects:pipelines:failures':
case 'projects:pipelines:show': case 'projects:pipelines:show':
......
/* eslint-disable */ /* eslint-disable */
import AjaxCache from '../../lib/utils/ajax_cache';
const AjaxFilter = { const AjaxFilter = {
init: function(hook) { init: function(hook) {
...@@ -58,50 +59,24 @@ const AjaxFilter = { ...@@ -58,50 +59,24 @@ const AjaxFilter = {
this.loading = true; this.loading = true;
var params = config.params || {}; var params = config.params || {};
params[config.searchKey] = searchValue; params[config.searchKey] = searchValue;
var self = this;
self.cache = self.cache || {};
var url = config.endpoint + this.buildParams(params); var url = config.endpoint + this.buildParams(params);
var urlCachedData = self.cache[url]; return AjaxCache.retrieve(url)
if (urlCachedData) { .then((data) => {
self._loadData(urlCachedData, config, self); this._loadData(data, config);
} else { })
this._loadUrlData(url) .catch(config.onError);
.then(function(data) {
self._loadData(data, config, self);
}, config.onError).catch(config.onError);
}
}, },
_loadUrlData: function _loadUrlData(url) { _loadData(data, config) {
var self = this; const list = this.hook.list;
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest;
xhr.open('GET', url, true);
xhr.onreadystatechange = function () {
if(xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
self.cache[url] = data;
return resolve(data);
} else {
return reject([xhr.responseText, xhr.status]);
}
}
};
xhr.send();
});
},
_loadData: function _loadData(data, config, self) {
const list = self.hook.list;
if (config.loadingTemplate && list.data === undefined || if (config.loadingTemplate && list.data === undefined ||
list.data.length === 0) { list.data.length === 0) {
const dataLoadingTemplate = list.list.querySelector('[data-loading-template]'); const dataLoadingTemplate = list.list.querySelector('[data-loading-template]');
if (dataLoadingTemplate) { if (dataLoadingTemplate) {
dataLoadingTemplate.outerHTML = self.listTemplate; dataLoadingTemplate.outerHTML = this.listTemplate;
} }
} }
if (!self.destroyed) { if (!this.destroyed) {
var hookListChildren = list.list.children; var hookListChildren = list.list.children;
var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic'); var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
if (onlyDynamicList && data.length === 0) { if (onlyDynamicList && data.length === 0) {
...@@ -109,7 +84,7 @@ const AjaxFilter = { ...@@ -109,7 +84,7 @@ const AjaxFilter = {
} }
list.setData.call(list, data); list.setData.call(list, data);
} }
self.notLoading(); this.notLoading();
list.currentIndex = 0; list.currentIndex = 0;
}, },
......
...@@ -13,13 +13,17 @@ export default { ...@@ -13,13 +13,17 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
allowedKeys: {
type: Array,
required: true,
},
}, },
computed: { computed: {
processedItems() { processedItems() {
return this.items.map((item) => { return this.items.map((item) => {
const { tokens, searchToken } const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(item); = gl.FilteredSearchTokenizer.processTokens(item, this.allowedKeys);
const resultantTokens = tokens.map(token => ({ const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`, prefix: `${token.key}:`,
......
...@@ -2,14 +2,18 @@ import Filter from '~/droplab/plugins/filter'; ...@@ -2,14 +2,18 @@ import Filter from '~/droplab/plugins/filter';
import './filtered_search_dropdown'; import './filtered_search_dropdown';
class DropdownHint extends gl.FilteredSearchDropdown { class DropdownHint extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) { constructor(droplab, dropdown, input, tokenKeys, filter) {
super(droplab, dropdown, input, filter); super(droplab, dropdown, input, filter);
this.config = { this.config = {
Filter: { Filter: {
template: 'hint', template: 'hint',
filterFunction: gl.DropdownUtils.filterHint.bind(null, input), filterFunction: gl.DropdownUtils.filterHint.bind(null, {
input,
allowedKeys: tokenKeys.getKeys(),
}),
}, },
}; };
this.tokenKeys = tokenKeys;
} }
itemClicked(e) { itemClicked(e) {
...@@ -52,20 +56,13 @@ class DropdownHint extends gl.FilteredSearchDropdown { ...@@ -52,20 +56,13 @@ class DropdownHint extends gl.FilteredSearchDropdown {
} }
renderContent() { renderContent() {
const dropdownData = []; const dropdownData = gl.FilteredSearchTokenKeys.get()
.map(tokenKey => ({
[].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { icon: `fa-${tokenKey.icon}`,
const { icon, hint, tag, type } = dropdownMenu.dataset; hint: tokenKey.key,
if (icon && hint && tag) { tag: `<${tokenKey.symbol}${tokenKey.key}>`,
dropdownData.push( type: tokenKey.type,
Object.assign({ }));
icon: `fa-${icon}`,
hint,
tag: `<${tag}>`,
}, type && { type }),
);
}
});
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData); this.droplab.setData(this.hookId, dropdownData);
......
...@@ -5,7 +5,7 @@ import Filter from '~/droplab/plugins/filter'; ...@@ -5,7 +5,7 @@ import Filter from '~/droplab/plugins/filter';
import './filtered_search_dropdown'; import './filtered_search_dropdown';
class DropdownNonUser extends gl.FilteredSearchDropdown { class DropdownNonUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter, endpoint, symbol) { constructor(droplab, dropdown, input, tokenKeys, filter, endpoint, symbol) {
super(droplab, dropdown, input, filter); super(droplab, dropdown, input, filter);
this.symbol = symbol; this.symbol = symbol;
this.config = { this.config = {
......
...@@ -4,7 +4,7 @@ import AjaxFilter from '~/droplab/plugins/ajax_filter'; ...@@ -4,7 +4,7 @@ import AjaxFilter from '~/droplab/plugins/ajax_filter';
import './filtered_search_dropdown'; import './filtered_search_dropdown';
class DropdownUser extends gl.FilteredSearchDropdown { class DropdownUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) { constructor(droplab, dropdown, input, tokenKeys, filter) {
super(droplab, dropdown, input, filter); super(droplab, dropdown, input, filter);
this.config = { this.config = {
AjaxFilter: { AjaxFilter: {
...@@ -25,6 +25,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { ...@@ -25,6 +25,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
}, },
}, },
}; };
this.tokenKeys = tokenKeys;
} }
itemClicked(e) { itemClicked(e) {
...@@ -43,7 +44,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { ...@@ -43,7 +44,7 @@ class DropdownUser extends gl.FilteredSearchDropdown {
getSearchInput() { getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input); const query = gl.DropdownUtils.getSearchInput(this.input);
const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || ''; let value = lastToken || '';
......
...@@ -50,10 +50,12 @@ class DropdownUtils { ...@@ -50,10 +50,12 @@ class DropdownUtils {
return updatedItem; return updatedItem;
} }
static filterHint(input, item) { static filterHint(config, item) {
const { input, allowedKeys } = config;
const updatedItem = item; const updatedItem = item;
const searchInput = gl.DropdownUtils.getSearchQuery(input); const searchInput = gl.DropdownUtils.getSearchQuery(input);
const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput); const { lastToken, tokens } =
gl.FilteredSearchTokenizer.processTokens(searchInput, allowedKeys);
const lastKey = lastToken.key || lastToken || ''; const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array'; const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint); const itemInExistingTokens = tokens.some(t => t.key === item.hint);
......
...@@ -2,10 +2,10 @@ import DropLab from '~/droplab/drop_lab'; ...@@ -2,10 +2,10 @@ import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
class FilteredSearchDropdownManager { class FilteredSearchDropdownManager {
constructor(baseEndpoint = '', page) { constructor(baseEndpoint = '', tokenizer, page) {
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
this.tokenizer = gl.FilteredSearchTokenizer; this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page; this.page = page;
...@@ -98,7 +98,8 @@ class FilteredSearchDropdownManager { ...@@ -98,7 +98,8 @@ class FilteredSearchDropdownManager {
if (!mappingKey.reference) { if (!mappingKey.reference) {
const dl = this.droplab; const dl = this.droplab;
const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; const defaultArguments =
[null, dl, element, this.filteredSearchInput, this.filteredSearchTokenKeys, key];
const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); const glArguments = defaultArguments.concat(mappingKey.extraArguments || []);
// Passing glArguments to `new gl[glClass](<arguments>)` // Passing glArguments to `new gl[glClass](<arguments>)`
...@@ -141,7 +142,8 @@ class FilteredSearchDropdownManager { ...@@ -141,7 +142,8 @@ class FilteredSearchDropdownManager {
setDropdown() { setDropdown() {
const query = gl.DropdownUtils.getSearchQuery(true); const query = gl.DropdownUtils.getSearchQuery(true);
const { lastToken, searchToken } = this.tokenizer.processTokens(query); const { lastToken, searchToken } =
this.tokenizer.processTokens(query, this.filteredSearchTokenKeys.getKeys());
if (this.currentDropdown) { if (this.currentDropdown) {
this.updateCurrentDropdownOffset(); this.updateCurrentDropdownOffset();
......
...@@ -15,6 +15,7 @@ class FilteredSearchManager { ...@@ -15,6 +15,7 @@ class FilteredSearchManager {
this.recentSearchesStore = new RecentSearchesStore({ this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(), isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(),
}); });
const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
const projectPath = searchHistoryDropdownElement ? const projectPath = searchHistoryDropdownElement ?
...@@ -46,7 +47,7 @@ class FilteredSearchManager { ...@@ -46,7 +47,7 @@ class FilteredSearchManager {
if (this.filteredSearchInput) { if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer; this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, page);
this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore, this.recentSearchesStore,
...@@ -318,7 +319,7 @@ class FilteredSearchManager { ...@@ -318,7 +319,7 @@ class FilteredSearchManager {
handleInputVisualToken() { handleInputVisualToken() {
const input = this.filteredSearchInput; const input = this.filteredSearchInput;
const { tokens, searchToken } const { tokens, searchToken }
= gl.FilteredSearchTokenizer.processTokens(input.value); = this.tokenizer.processTokens(input.value, this.filteredSearchTokenKeys.getKeys());
const { isLastVisualTokenValid } const { isLastVisualTokenValid }
= gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
...@@ -444,7 +445,7 @@ class FilteredSearchManager { ...@@ -444,7 +445,7 @@ class FilteredSearchManager {
this.saveCurrentSearchQuery(); this.saveCurrentSearchQuery();
const { tokens, searchToken } const { tokens, searchToken }
= this.tokenizer.processTokens(searchQuery); = this.tokenizer.processTokens(searchQuery, this.filteredSearchTokenKeys.getKeys());
const currentState = gl.utils.getParameterByName('state') || 'opened'; const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
......
...@@ -3,21 +3,25 @@ const tokenKeys = [{ ...@@ -3,21 +3,25 @@ const tokenKeys = [{
type: 'string', type: 'string',
param: 'username', param: 'username',
symbol: '@', symbol: '@',
icon: 'pencil',
}, { }, {
key: 'assignee', key: 'assignee',
type: 'string', type: 'string',
param: 'username', param: 'username',
symbol: '@', symbol: '@',
icon: 'user',
}, { }, {
key: 'milestone', key: 'milestone',
type: 'string', type: 'string',
param: 'title', param: 'title',
symbol: '%', symbol: '%',
icon: 'clock-o',
}, { }, {
key: 'label', key: 'label',
type: 'array', type: 'array',
param: 'name[]', param: 'name[]',
symbol: '~', symbol: '~',
icon: 'tag',
}]; }];
const alternativeTokenKeys = [{ const alternativeTokenKeys = [{
...@@ -56,6 +60,10 @@ class FilteredSearchTokenKeys { ...@@ -56,6 +60,10 @@ class FilteredSearchTokenKeys {
return tokenKeys; return tokenKeys;
} }
static getKeys() {
return tokenKeys.map(i => i.key);
}
static getAlternatives() { static getAlternatives() {
return alternativeTokenKeys; return alternativeTokenKeys;
} }
......
import './filtered_search_token_keys'; import './filtered_search_token_keys';
class FilteredSearchTokenizer { class FilteredSearchTokenizer {
static processTokens(input) { static processTokens(input, allowedKeys) {
const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key);
// Regex extracts `(token):(symbol)(value)` // Regex extracts `(token):(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single) // Values that start with a double quote must end in a double quote (same for single)
const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g'); const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g');
......
...@@ -37,6 +37,7 @@ class RecentSearchesRoot { ...@@ -37,6 +37,7 @@ class RecentSearchesRoot {
<recent-searches-dropdown-content <recent-searches-dropdown-content
:items="recentSearches" :items="recentSearches"
:is-local-storage-available="isLocalStorageAvailable" :is-local-storage-available="isLocalStorageAvailable"
:allowed-keys="allowedKeys"
/> />
`, `,
components: { components: {
......
import _ from 'underscore'; import _ from 'underscore';
class RecentSearchesStore { class RecentSearchesStore {
constructor(initialState = {}) { constructor(initialState = {}, allowedKeys) {
this.state = Object.assign({ this.state = Object.assign({
isLocalStorageAvailable: true, isLocalStorageAvailable: true,
recentSearches: [], recentSearches: [],
allowedKeys,
}, initialState); }, initialState);
} }
......
...@@ -44,18 +44,18 @@ export default class GroupName { ...@@ -44,18 +44,18 @@ export default class GroupName {
showToggle() { showToggle() {
this.title.classList.add('wrap'); this.title.classList.add('wrap');
this.toggle.classList.remove('hidden'); this.toggle.classList.remove('hidden');
if (this.isHidden) this.groupTitle.classList.add('is-hidden'); if (this.isHidden) this.groupTitle.classList.add('hidden');
} }
hideToggle() { hideToggle() {
this.title.classList.remove('wrap'); this.title.classList.remove('wrap');
this.toggle.classList.add('hidden'); this.toggle.classList.add('hidden');
if (this.isHidden) this.groupTitle.classList.remove('is-hidden'); if (this.isHidden) this.groupTitle.classList.remove('hidden');
} }
toggleGroups() { toggleGroups() {
this.isHidden = !this.isHidden; this.isHidden = !this.isHidden;
this.groupTitle.classList.toggle('is-hidden'); this.groupTitle.classList.toggle('hidden');
} }
render() { render() {
......
class AjaxCache { import Cache from './cache';
class AjaxCache extends Cache {
constructor() { constructor() {
this.internalStorage = { }; super();
this.pendingRequests = { }; this.pendingRequests = { };
} }
get(endpoint) {
return this.internalStorage[endpoint];
}
hasData(endpoint) {
return Object.prototype.hasOwnProperty.call(this.internalStorage, endpoint);
}
remove(endpoint) {
delete this.internalStorage[endpoint];
}
retrieve(endpoint) { retrieve(endpoint) {
if (this.hasData(endpoint)) { if (this.hasData(endpoint)) {
return Promise.resolve(this.get(endpoint)); return Promise.resolve(this.get(endpoint));
......
class Cache {
constructor() {
this.internalStorage = { };
}
get(key) {
return this.internalStorage[key];
}
hasData(key) {
return Object.prototype.hasOwnProperty.call(this.internalStorage, key);
}
remove(key) {
delete this.internalStorage[key];
}
}
export default Cache;
...@@ -198,10 +198,12 @@ ...@@ -198,10 +198,12 @@
const textBefore = value.substring(0, selectionStart); const textBefore = value.substring(0, selectionStart);
const textAfter = value.substring(selectionEnd, value.length); const textAfter = value.substring(selectionEnd, value.length);
const newText = textBefore + text + textAfter;
const insertedText = text instanceof Function ? text(textBefore, textAfter) : text;
const newText = textBefore + insertedText + textAfter;
target.value = newText; target.value = newText;
target.selectionStart = target.selectionEnd = selectionStart + text.length; target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
// Trigger autosave // Trigger autosave
$(target).trigger('input'); $(target).trigger('input');
......
import Api from '../../api';
import Cache from './cache';
class UsersCache extends Cache {
retrieve(username) {
if (this.hasData(username)) {
return Promise.resolve(this.get(username));
}
return Api.users('', { username })
.then((users) => {
if (!users.length) {
throw new Error(`User "${username}" could not be found!`);
}
if (users.length > 1) {
throw new Error(`Expected username "${username}" to be unique!`);
}
const user = users[0];
this.internalStorage[username] = user;
return user;
});
// missing catch is intentional, error handling depends on use case
}
}
export default new UsersCache();
export default function setupProjectEdit() {
const $transferForm = $('.js-project-transfer-form');
const $selectNamespace = $transferForm.find('.select2');
$selectNamespace.on('change', () => {
$transferForm.find(':submit').prop('disabled', !$selectNamespace.val());
});
$selectNamespace.trigger('change');
}
...@@ -6,6 +6,10 @@ const index = function index() { ...@@ -6,6 +6,10 @@ const index = function index() {
currentUserId: gon.current_user_id, currentUserId: gon.current_user_id,
whitelistUrls: [gon.gitlab_url], whitelistUrls: [gon.gitlab_url],
isProduction: process.env.NODE_ENV, isProduction: process.env.NODE_ENV,
release: gon.revision,
tags: {
revision: gon.revision,
},
}); });
return RavenConfig; return RavenConfig;
......
import Raven from 'raven-js'; import Raven from 'raven-js';
import $ from 'jquery';
const IGNORE_ERRORS = [ const IGNORE_ERRORS = [
// Random plugins/extensions // Random plugins/extensions
...@@ -57,6 +58,8 @@ const RavenConfig = { ...@@ -57,6 +58,8 @@ const RavenConfig = {
configure() { configure() {
Raven.config(this.options.sentryDsn, { Raven.config(this.options.sentryDsn, {
release: this.options.release,
tags: this.options.tags,
whitelistUrls: this.options.whitelistUrls, whitelistUrls: this.options.whitelistUrls,
environment: this.options.isProduction ? 'production' : 'development', environment: this.options.isProduction ? 'production' : 'development',
ignoreErrors: this.IGNORE_ERRORS, ignoreErrors: this.IGNORE_ERRORS,
...@@ -72,7 +75,7 @@ const RavenConfig = { ...@@ -72,7 +75,7 @@ const RavenConfig = {
}, },
bindRavenErrors() { bindRavenErrors() {
window.$(document).on('ajaxError.raven', this.handleRavenErrors); $(document).on('ajaxError.raven', this.handleRavenErrors);
}, },
handleRavenErrors(event, req, config, err) { handleRavenErrors(event, req, config, err) {
......
...@@ -38,7 +38,7 @@ import './shortcuts_navigation'; ...@@ -38,7 +38,7 @@ import './shortcuts_navigation';
} }
ShortcutsIssuable.prototype.replyWithSelectedText = function() { ShortcutsIssuable.prototype.replyWithSelectedText = function() {
var quote, documentFragment, selected, separator; var quote, documentFragment, el, selected, separator;
var replyField = $('.js-main-target-form #note_note'); var replyField = $('.js-main-target-form #note_note');
documentFragment = window.gl.utils.getSelectedFragment(); documentFragment = window.gl.utils.getSelectedFragment();
...@@ -47,10 +47,8 @@ import './shortcuts_navigation'; ...@@ -47,10 +47,8 @@ import './shortcuts_navigation';
return; return;
} }
// If the documentFragment contains more than just Markdown, don't copy as GFM. el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
if (documentFragment.querySelector('.md, .wiki')) return; selected = window.gl.CopyAsGFM.nodeToGFM(el);
selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment);
if (selected.trim() === "") { if (selected.trim() === "") {
return; return;
......
...@@ -69,10 +69,11 @@ export default { ...@@ -69,10 +69,11 @@ export default {
<div> <div>
<assignee-title <assignee-title
:number-of-assignees="store.assignees.length" :number-of-assignees="store.assignees.length"
:loading="loading" :loading="loading || store.isFetching.assignees"
:editable="store.editable" :editable="store.editable"
/> />
<assignees <assignees
v-if="!store.isFetching.assignees"
class="value" class="value"
:root-path="store.rootPath" :root-path="store.rootPath"
:users="store.assignees" :users="store.assignees"
......
...@@ -10,6 +10,9 @@ export default class SidebarStore { ...@@ -10,6 +10,9 @@ export default class SidebarStore {
this.humanTimeEstimate = ''; this.humanTimeEstimate = '';
this.humanTimeSpent = ''; this.humanTimeSpent = '';
this.assignees = []; this.assignees = [];
this.isFetching = {
assignees: true,
};
SidebarStore.singleton = this; SidebarStore.singleton = this;
} }
...@@ -18,6 +21,7 @@ export default class SidebarStore { ...@@ -18,6 +21,7 @@ export default class SidebarStore {
} }
setAssigneeData(data) { setAssigneeData(data) {
this.isFetching.assignees = false;
if (data.assignees) { if (data.assignees) {
this.assignees = data.assignees; this.assignees = data.assignees;
} }
......
...@@ -11,10 +11,6 @@ export default function deviseState(data) { ...@@ -11,10 +11,6 @@ export default function deviseState(data) {
return 'conflicts'; return 'conflicts';
} else if (data.work_in_progress) { } else if (data.work_in_progress) {
return 'workInProgress'; return 'workInProgress';
} else if (this.mergeWhenPipelineSucceeds) {
return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds';
} else if (!this.canMerge) {
return 'notAllowedToMerge';
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return 'pipelineFailed'; return 'pipelineFailed';
} else if (this.hasMergeableDiscussionsState) { } else if (this.hasMergeableDiscussionsState) {
...@@ -23,6 +19,10 @@ export default function deviseState(data) { ...@@ -23,6 +19,10 @@ export default function deviseState(data) {
return 'pipelineBlocked'; return 'pipelineBlocked';
} else if (this.hasSHAChanged) { } else if (this.hasSHAChanged) {
return 'shaMismatch'; return 'shaMismatch';
} else if (this.mergeWhenPipelineSucceeds) {
return this.mergeError ? 'autoMergeFailed' : 'mergeWhenPipelineSucceeds';
} else if (!this.canMerge) {
return 'notAllowedToMerge';
} else if (this.canBeMerged) { } else if (this.canBeMerged) {
return 'readyToMerge'; return 'readyToMerge';
} }
......
...@@ -4,7 +4,7 @@ import { getStateKey } from '../dependencies'; ...@@ -4,7 +4,7 @@ import { getStateKey } from '../dependencies';
export default class MergeRequestStore { export default class MergeRequestStore {
constructor(data) { constructor(data) {
this.startingSha = data.diff_head_sha; this.sha = data.diff_head_sha;
this.setData(data); this.setData(data);
} }
...@@ -16,7 +16,6 @@ export default class MergeRequestStore { ...@@ -16,7 +16,6 @@ export default class MergeRequestStore {
this.targetBranch = data.target_branch; this.targetBranch = data.target_branch;
this.sourceBranch = data.source_branch; this.sourceBranch = data.source_branch;
this.mergeStatus = data.merge_status; this.mergeStatus = data.merge_status;
this.sha = data.diff_head_sha;
this.commitMessage = data.merge_commit_message; this.commitMessage = data.merge_commit_message;
this.commitMessageWithDescription = data.merge_commit_message_with_description; this.commitMessageWithDescription = data.merge_commit_message_with_description;
this.commitsCount = data.commits_count; this.commitsCount = data.commits_count;
...@@ -69,7 +68,7 @@ export default class MergeRequestStore { ...@@ -69,7 +68,7 @@ export default class MergeRequestStore {
this.canMerge = !!data.merge_path; this.canMerge = !!data.merge_path;
this.canCreateIssue = currentUser.can_create_issue || false; this.canCreateIssue = currentUser.can_create_issue || false;
this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
this.hasSHAChanged = this.sha !== this.startingSha; this.hasSHAChanged = this.sha !== data.diff_head_sha;
this.canBeMerged = data.can_be_merged || false; this.canBeMerged = data.can_be_merged || false;
// Cherry-pick and Revert actions related // Cherry-pick and Revert actions related
......
...@@ -4,7 +4,7 @@ import VueResource from 'vue-resource'; ...@@ -4,7 +4,7 @@ import VueResource from 'vue-resource';
Vue.use(VueResource); Vue.use(VueResource);
// Maintain a global counter for active requests // Maintain a global counter for active requests
// see: spec/support/wait_for_vue_resource.rb // see: spec/support/wait_for_requests.rb
Vue.http.interceptors.push((request, next) => { Vue.http.interceptors.push((request, next) => {
window.activeVueResources = window.activeVueResources || 0; window.activeVueResources = window.activeVueResources || 0;
window.activeVueResources += 1; window.activeVueResources += 1;
......
...@@ -110,6 +110,7 @@ ...@@ -110,6 +110,7 @@
.award-control { .award-control {
margin: 0 5px 6px 0; margin: 0 5px 6px 0;
outline: 0; outline: 0;
position: relative;
&.disabled { &.disabled {
cursor: default; cursor: default;
...@@ -227,8 +228,8 @@ ...@@ -227,8 +228,8 @@
.award-control-icon-positive, .award-control-icon-positive,
.award-control-icon-super-positive { .award-control-icon-super-positive {
position: absolute; position: absolute;
left: 11px; left: 10px;
bottom: 7px; bottom: 6px;
opacity: 0; opacity: 0;
@include transition(opacity, transform); @include transition(opacity, transform);
} }
...@@ -237,7 +238,3 @@ ...@@ -237,7 +238,3 @@
vertical-align: middle; vertical-align: middle;
} }
} }
.note-awards .award-control-icon-positive {
left: 6px;
}
...@@ -263,7 +263,9 @@ ...@@ -263,7 +263,9 @@
} }
.filtered-search-input-dropdown-menu { .filtered-search-input-dropdown-menu {
max-height: 215px;
max-width: 280px; max-width: 280px;
overflow: auto;
@media (max-width: $screen-xs-min) { @media (max-width: $screen-xs-min) {
width: auto; width: auto;
...@@ -372,11 +374,6 @@ ...@@ -372,11 +374,6 @@
padding: 0; padding: 0;
} }
.filter-dropdown {
max-height: 215px;
overflow: auto;
}
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
.issue-bulk-update-dropdown-toggle { .issue-bulk-update-dropdown-toggle {
width: 100px; width: 100px;
......
...@@ -40,7 +40,17 @@ header { ...@@ -40,7 +40,17 @@ header {
} }
&.with-horizontal-nav { &.with-horizontal-nav {
border-color: transparent; border-bottom: 0;
.navbar-border {
height: 1px;
position: absolute;
right: 0;
left: 0;
bottom: -1px;
background-color: $border-color;
opacity: 0;
}
} }
.container-fluid { .container-fluid {
...@@ -114,16 +124,6 @@ header { ...@@ -114,16 +124,6 @@ header {
} }
} }
.navbar-border {
height: 1px;
position: absolute;
right: 0;
left: 0;
bottom: 0;
background-color: $border-color;
opacity: 0;
}
.global-dropdown { .global-dropdown {
position: absolute; position: absolute;
left: -10px; left: -10px;
......
...@@ -83,4 +83,8 @@ ...@@ -83,4 +83,8 @@
position: fixed; position: fixed;
top: $header-height; top: $header-height;
} }
&:not(.affix-top) {
min-height: 100%;
}
} }
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
.note-text { .note-text {
p:last-child { p:last-child {
margin-bottom: 0; margin-bottom: 0 !important;
} }
} }
...@@ -23,7 +23,6 @@ ...@@ -23,7 +23,6 @@
} }
.timeline-entry { .timeline-entry {
padding: $gl-padding $gl-btn-padding 0;
border-color: $white-normal; border-color: $white-normal;
color: $gl-text-color; color: $gl-text-color;
border-bottom: 1px solid $border-white-light; border-bottom: 1px solid $border-white-light;
......
...@@ -4,11 +4,7 @@ ...@@ -4,11 +4,7 @@
color: $gl-text-color; color: $gl-text-color;
line-height: 34px; line-height: 34px;
.author { a {
color: $gl-text-color;
}
.identifier {
color: $gl-text-color; color: $gl-text-color;
} }
......
...@@ -164,10 +164,6 @@ ...@@ -164,10 +164,6 @@
.discussion-body, .discussion-body,
.diff-file { .diff-file {
.notes .note {
padding: 10px 15px;
}
.discussion-reply-holder { .discussion-reply-holder {
background-color: $white-light; background-color: $white-light;
padding: 10px 16px; padding: 10px 16px;
......
...@@ -43,7 +43,11 @@ ul.notes { ...@@ -43,7 +43,11 @@ ul.notes {
} }
.discussion-body { .discussion-body {
padding-top: 15px; padding-top: 8px;
.panel {
margin-bottom: 0;
}
} }
.discussion { .discussion {
...@@ -53,6 +57,7 @@ ul.notes { ...@@ -53,6 +57,7 @@ ul.notes {
} }
.note { .note {
padding: $gl-padding $gl-btn-padding 0;
display: block; display: block;
position: relative; position: relative;
border-bottom: 1px solid $white-normal; border-bottom: 1px solid $white-normal;
...@@ -78,11 +83,7 @@ ul.notes { ...@@ -78,11 +83,7 @@ ul.notes {
&.note-discussion { &.note-discussion {
&.timeline-entry { &.timeline-entry {
padding: 14px 10px; padding: $gl-padding 10px;
}
.system-note {
padding: 0;
} }
} }
...@@ -167,7 +168,7 @@ ul.notes { ...@@ -167,7 +168,7 @@ ul.notes {
margin-left: 65px; margin-left: 65px;
} }
.note-header { .note-header-info {
padding-bottom: 0; padding-bottom: 0;
} }
...@@ -377,7 +378,11 @@ ul.notes { ...@@ -377,7 +378,11 @@ ul.notes {
.note-header-info { .note-header-info {
min-width: 0; min-width: 0;
padding-bottom: 5px; padding-bottom: 8px;
}
.system-note .note-header-info {
padding-bottom: 0;
} }
.note-headline-light { .note-headline-light {
...@@ -582,6 +587,17 @@ ul.notes { ...@@ -582,6 +587,17 @@ ul.notes {
} }
} }
.discussion-body,
.diff-file {
.notes .note {
padding: 10px 15px;
&.system-note {
padding: 0;
}
}
}
.diff-file { .diff-file {
.is-over { .is-over {
.add-diff-note { .add-diff-note {
......
...@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base ...@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
include EnforcesTwoFactorAuthentication include EnforcesTwoFactorAuthentication
before_action :authenticate_user_from_private_token! before_action :authenticate_user_from_private_token!
before_action :authenticate_user_from_rss_token!
before_action :authenticate_user! before_action :authenticate_user!
before_action :validate_user_service_ticket! before_action :validate_user_service_ticket!
before_action :check_password_expiration before_action :check_password_expiration
...@@ -72,13 +73,20 @@ class ApplicationController < ActionController::Base ...@@ -72,13 +73,20 @@ class ApplicationController < ActionController::Base
user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token) user = User.find_by_authentication_token(token) || User.find_by_personal_access_token(token)
if user && can?(user, :log_in) sessionless_sign_in(user)
# Notice we are passing store false, so the user is not
# actually stored in the session and a token is needed
# for every request. If you want the token to work as a
# sign in token, you can simply remove store: false.
sign_in user, store: false
end end
# This filter handles authentication for atom request with an rss_token
def authenticate_user_from_rss_token!
return unless request.format.atom?
token = params[:rss_token].presence
return unless token.present?
user = User.find_by_rss_token(token)
sessionless_sign_in(user)
end end
def log_exception(exception) def log_exception(exception)
...@@ -282,4 +290,14 @@ class ApplicationController < ActionController::Base ...@@ -282,4 +290,14 @@ class ApplicationController < ActionController::Base
ensure ensure
Gitlab::I18n.reset_locale Gitlab::I18n.reset_locale
end end
def sessionless_sign_in(user)
if user && can?(user, :log_in)
# Notice we are passing store false, so the user is not
# actually stored in the session and a token is needed
# for every request. If you want the token to work as a
# sign in token, you can simply remove store: false.
sign_in user, store: false
end
end
end end
...@@ -40,6 +40,14 @@ class ProfilesController < Profiles::ApplicationController ...@@ -40,6 +40,14 @@ class ProfilesController < Profiles::ApplicationController
redirect_to profile_account_path redirect_to profile_account_path
end end
def reset_rss_token
if current_user.reset_rss_token!
flash[:notice] = "RSS token was successfully reset"
end
redirect_to profile_account_path
end
def audit_log def audit_log
@events = AuditEvent.where(entity_type: "User", entity_id: current_user.id). @events = AuditEvent.where(entity_type: "User", entity_id: current_user.id).
order("created_at DESC"). order("created_at DESC").
......
...@@ -33,6 +33,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -33,6 +33,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def folder def folder
folder_environments = project.environments.where(environment_type: params[:id]) folder_environments = project.environments.where(environment_type: params[:id])
@environments = folder_environments.with_state(params[:scope] || :available) @environments = folder_environments.with_state(params[:scope] || :available)
.order(:name)
respond_to do |format| respond_to do |format|
format.html format.html
......
...@@ -257,7 +257,7 @@ class ProjectsController < Projects::ApplicationController ...@@ -257,7 +257,7 @@ class ProjectsController < Projects::ApplicationController
# #
# pages list order: repository readme, wiki home, issues list, customize workflow # pages list order: repository readme, wiki home, issues list, customize workflow
def render_landing_page def render_landing_page
if @project.feature_available?(:repository, current_user) if can?(current_user, :download_code, @project)
return render 'projects/no_repo' unless @project.repository_exists? return render 'projects/no_repo' unless @project.repository_exists?
render 'projects/empty' if @project.empty_repo? render 'projects/empty' if @project.empty_repo?
else else
......
...@@ -49,7 +49,7 @@ module PreferencesHelper ...@@ -49,7 +49,7 @@ module PreferencesHelper
user_view = current_user.project_view user_view = current_user.project_view
if @project.feature_available?(:repository, current_user) if can?(current_user, :download_code, @project)
user_view user_view
elsif user_view == "activity" elsif user_view == "activity"
"activity" "activity"
......
module RssHelper module RssHelper
def rss_url_options def rss_url_options
{ format: :atom, private_token: current_user.try(:private_token) } { format: :atom, rss_token: current_user.try(:rss_token) }
end end
end end
...@@ -17,7 +17,8 @@ module SystemNoteHelper ...@@ -17,7 +17,8 @@ module SystemNoteHelper
'visible' => 'icon_eye', 'visible' => 'icon_eye',
'milestone' => 'icon_clock_o', 'milestone' => 'icon_clock_o',
'discussion' => 'icon_comment_o', 'discussion' => 'icon_comment_o',
'moved' => 'icon_arrow_circle_o_right' 'moved' => 'icon_arrow_circle_o_right',
'outdated' => 'icon_edit'
}.freeze }.freeze
def icon_for_system_note(note) def icon_for_system_note(note)
......
...@@ -89,6 +89,7 @@ class CommitStatus < ActiveRecord::Base ...@@ -89,6 +89,7 @@ class CommitStatus < ActiveRecord::Base
else else
PipelineUpdateWorker.perform_async(pipeline.id) PipelineUpdateWorker.perform_async(pipeline.id)
end end
ExpireJobCacheWorker.perform_async(commit_status.id)
end end
end end
end end
......
...@@ -47,4 +47,12 @@ module DiscussionOnDiff ...@@ -47,4 +47,12 @@ module DiscussionOnDiff
prev_lines prev_lines
end end
def line_code_in_diffs(diff_refs)
if active?(diff_refs)
line_code
elsif diff_refs && created_at_diff?(diff_refs)
original_line_code
end
end
end end
...@@ -19,21 +19,9 @@ class DiffDiscussion < Discussion ...@@ -19,21 +19,9 @@ class DiffDiscussion < Discussion
def merge_request_version_params def merge_request_version_params
return unless for_merge_request? return unless for_merge_request?
return {} if active?
if active? noteable.version_params_for(position.diff_refs)
{}
else
diff_refs = position.diff_refs
if diff = noteable.merge_request_diff_for(diff_refs)
{ diff_id: diff.id }
elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha)
{
diff_id: diff.id,
start_sha: diff_refs.start_sha
}
end
end
end end
def reply_attributes def reply_attributes
......
...@@ -8,6 +8,7 @@ class DiffNote < Note ...@@ -8,6 +8,7 @@ class DiffNote < Note
serialize :original_position, Gitlab::Diff::Position serialize :original_position, Gitlab::Diff::Position
serialize :position, Gitlab::Diff::Position serialize :position, Gitlab::Diff::Position
serialize :change_position, Gitlab::Diff::Position
validates :original_position, presence: true validates :original_position, presence: true
validates :position, presence: true validates :position, presence: true
...@@ -25,7 +26,7 @@ class DiffNote < Note ...@@ -25,7 +26,7 @@ class DiffNote < Note
DiffDiscussion DiffDiscussion
end end
%i(original_position position).each do |meth| %i(original_position position change_position).each do |meth|
define_method "#{meth}=" do |new_position| define_method "#{meth}=" do |new_position|
if new_position.is_a?(String) if new_position.is_a?(String)
new_position = JSON.parse(new_position) rescue nil new_position = JSON.parse(new_position) rescue nil
...@@ -36,6 +37,8 @@ class DiffNote < Note ...@@ -36,6 +37,8 @@ class DiffNote < Note
new_position = Gitlab::Diff::Position.new(new_position) new_position = Gitlab::Diff::Position.new(new_position)
end end
return if new_position == read_attribute(meth)
super(new_position) super(new_position)
end end
end end
...@@ -45,7 +48,7 @@ class DiffNote < Note ...@@ -45,7 +48,7 @@ class DiffNote < Note
end end
def diff_line def diff_line
@diff_line ||= diff_file.line_for_position(self.original_position) if diff_file @diff_line ||= diff_file&.line_for_position(self.original_position)
end end
def for_line?(line) def for_line?(line)
......
...@@ -416,13 +416,24 @@ class MergeRequest < ActiveRecord::Base ...@@ -416,13 +416,24 @@ class MergeRequest < ActiveRecord::Base
@merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha] @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha]
end end
def version_params_for(diff_refs)
if diff = merge_request_diff_for(diff_refs)
{ diff_id: diff.id }
elsif diff = merge_request_diff_for(diff_refs.head_sha)
{
diff_id: diff.id,
start_sha: diff_refs.start_sha
}
end
end
def reload_diff_if_branch_changed def reload_diff_if_branch_changed
if source_branch_changed? || target_branch_changed? if source_branch_changed? || target_branch_changed?
reload_diff reload_diff
end end
end end
def reload_diff def reload_diff(current_user = nil)
return unless open? return unless open?
old_diff_refs = self.diff_refs old_diff_refs = self.diff_refs
...@@ -432,7 +443,8 @@ class MergeRequest < ActiveRecord::Base ...@@ -432,7 +443,8 @@ class MergeRequest < ActiveRecord::Base
update_diff_notes_positions( update_diff_notes_positions(
old_diff_refs: old_diff_refs, old_diff_refs: old_diff_refs,
new_diff_refs: new_diff_refs new_diff_refs: new_diff_refs,
current_user: current_user
) )
end end
...@@ -861,7 +873,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -861,7 +873,7 @@ class MergeRequest < ActiveRecord::Base
diff_sha_refs && diff_sha_refs.complete? diff_sha_refs && diff_sha_refs.complete?
end end
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:) def update_diff_notes_positions(old_diff_refs:, new_diff_refs:, current_user: nil)
return unless has_complete_diff_refs? return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs return if new_diff_refs == old_diff_refs
...@@ -875,7 +887,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -875,7 +887,7 @@ class MergeRequest < ActiveRecord::Base
service = Notes::DiffPositionUpdateService.new( service = Notes::DiffPositionUpdateService.new(
self.project, self.project,
nil, current_user,
old_diff_refs: old_diff_refs, old_diff_refs: old_diff_refs,
new_diff_refs: new_diff_refs, new_diff_refs: new_diff_refs,
paths: paths paths: paths
......
...@@ -175,12 +175,11 @@ class MergeRequestDiff < ActiveRecord::Base ...@@ -175,12 +175,11 @@ class MergeRequestDiff < ActiveRecord::Base
self == merge_request.merge_request_diff self == merge_request.merge_request_diff
end end
def compare_with(sha, straight: true) def compare_with(sha)
# When compare merge request versions we want diff A..B instead of A...B # When compare merge request versions we want diff A..B instead of A...B
# so we handle cases when user does squash and rebase of the commits between versions. # so we handle cases when user does squash and rebase of the commits between versions.
# For this reason we set straight to true by default. # For this reason we set straight to true by default.
CompareService.new(project, head_commit_sha) CompareService.new(project, head_commit_sha).execute(project, sha, straight: true)
.execute(project, sha, straight: straight)
end end
def commits_count def commits_count
......
...@@ -124,13 +124,12 @@ class Note < ActiveRecord::Base ...@@ -124,13 +124,12 @@ class Note < ActiveRecord::Base
groups = {} groups = {}
diff_notes.fresh.discussions.each do |discussion| diff_notes.fresh.discussions.each do |discussion|
if discussion.active?(diff_refs) line_code = discussion.line_code_in_diffs(diff_refs)
discussions = groups[discussion.line_code] ||= []
elsif diff_refs && discussion.created_at_diff?(diff_refs)
discussions = groups[discussion.original_line_code] ||= []
end
discussions << discussion if discussions if line_code
discussions = groups[line_code] ||= []
discussions << discussion
end
end end
groups groups
......
...@@ -205,7 +205,7 @@ class Project < ActiveRecord::Base ...@@ -205,7 +205,7 @@ class Project < ActiveRecord::Base
presence: true, presence: true,
dynamic_path: true, dynamic_path: true,
length: { maximum: 255 }, length: { maximum: 255 },
format: { with: Gitlab::Regex.project_path_regex, format: { with: Gitlab::Regex.project_path_format_regex,
message: Gitlab::Regex.project_path_regex_message }, message: Gitlab::Regex.project_path_regex_message },
uniqueness: { scope: :namespace_id } uniqueness: { scope: :namespace_id }
......
...@@ -76,7 +76,7 @@ class IssueTrackerService < Service ...@@ -76,7 +76,7 @@ class IssueTrackerService < Service
message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}"
result = true result = true
end end
rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED => error rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error
message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}"
end end
Rails.logger.info(message) Rails.logger.info(message)
......
...@@ -294,7 +294,7 @@ class JiraService < IssueTrackerService ...@@ -294,7 +294,7 @@ class JiraService < IssueTrackerService
def jira_request def jira_request
yield yield
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError => e rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e
Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}"
nil nil
end end
......
...@@ -2,6 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base ...@@ -2,6 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base
ICON_TYPES = %w[ ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference commit description merge confidential visible label assignee cross_reference
title time_tracking branch milestone discussion task moved opened closed merged title time_tracking branch milestone discussion task moved opened closed merged
outdated
].freeze ].freeze
validates :note, presence: true validates :note, presence: true
......
...@@ -15,6 +15,7 @@ class User < ActiveRecord::Base ...@@ -15,6 +15,7 @@ class User < ActiveRecord::Base
add_authentication_token_field :authentication_token add_authentication_token_field :authentication_token
add_authentication_token_field :incoming_email_token add_authentication_token_field :incoming_email_token
add_authentication_token_field :rss_token
default_value_for :admin, false default_value_for :admin, false
default_value_for(:external) { current_application_settings.user_default_external } default_value_for(:external) { current_application_settings.user_default_external }
...@@ -1004,6 +1005,13 @@ class User < ActiveRecord::Base ...@@ -1004,6 +1005,13 @@ class User < ActiveRecord::Base
save save
end end
# each existing user needs to have an `rss_token`.
# we do this on read since migrating all existing users is not a feasible
# solution.
def rss_token
ensure_rss_token!
end
protected protected
# override, from Devise::Validatable # override, from Devise::Validatable
......
...@@ -66,12 +66,12 @@ module MergeRequests ...@@ -66,12 +66,12 @@ module MergeRequests
filter_merge_requests(merge_requests).each do |merge_request| filter_merge_requests(merge_requests).each do |merge_request|
if merge_request.source_branch == @branch_name || force_push? if merge_request.source_branch == @branch_name || force_push?
merge_request.reload_diff merge_request.reload_diff(current_user)
else else
mr_commit_ids = merge_request.commits_sha mr_commit_ids = merge_request.commits_sha
push_commit_ids = @commits.map(&:id) push_commit_ids = @commits.map(&:id)
matches = mr_commit_ids & push_commit_ids matches = mr_commit_ids & push_commit_ids
merge_request.reload_diff if matches.any? merge_request.reload_diff(current_user) if matches.any?
end end
merge_request.mark_as_unchecked merge_request.mark_as_unchecked
......
...@@ -8,7 +8,7 @@ module MergeRequests ...@@ -8,7 +8,7 @@ module MergeRequests
create_note(merge_request) create_note(merge_request)
notification_service.reopen_mr(merge_request, current_user) notification_service.reopen_mr(merge_request, current_user)
execute_hooks(merge_request, 'reopen') execute_hooks(merge_request, 'reopen')
merge_request.reload_diff merge_request.reload_diff(current_user)
merge_request.mark_as_unchecked merge_request.mark_as_unchecked
end end
......
module Notes module Notes
class DiffPositionUpdateService < BaseService class DiffPositionUpdateService < BaseService
def execute(note) def execute(note)
new_position = tracer.trace(note.position) results = tracer.trace(note.position)
return unless results
# Don't update the position if the type doesn't match, since that means position = results[:position]
# the diff line commented on was changed, and the comment is now outdated outdated = results[:outdated]
old_position = note.position
if new_position &&
new_position != old_position &&
new_position.type == old_position.type
note.position = new_position if outdated
end note.change_position = position
note if note.persisted? && current_user
SystemNoteService.diff_discussion_outdated(note.to_discussion, project, current_user, position)
end
else
note.position = position
note.change_position = nil
end
end end
private private
def tracer def tracer
@tracer ||= Gitlab::Diff::PositionTracer.new( @tracer ||= Gitlab::Diff::PositionTracer.new(
repository: project.repository, project: project,
old_diff_refs: params[:old_diff_refs], old_diff_refs: params[:old_diff_refs],
new_diff_refs: params[:new_diff_refs], new_diff_refs: params[:new_diff_refs],
paths: params[:paths] paths: params[:paths]
......
...@@ -12,12 +12,13 @@ module Projects ...@@ -12,12 +12,13 @@ module Projects
TransferError = Class.new(StandardError) TransferError = Class.new(StandardError)
def execute(new_namespace) def execute(new_namespace)
if allowed_transfer?(current_user, project, new_namespace) if new_namespace.blank?
transfer(project, new_namespace) raise TransferError, 'Please select a new namespace for your project.'
else
project.errors.add(:new_namespace, 'is invalid')
false
end end
unless allowed_transfer?(current_user, project, new_namespace)
raise TransferError, 'Transfer failed, please contact an admin.'
end
transfer(project, new_namespace)
rescue Projects::TransferService::TransferError => ex rescue Projects::TransferService::TransferError => ex
project.reload project.reload
project.errors.add(:new_namespace, ex.message) project.errors.add(:new_namespace, ex.message)
......
...@@ -258,7 +258,7 @@ module SystemNoteService ...@@ -258,7 +258,7 @@ module SystemNoteService
create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) create_note(NoteSummary.new(noteable, project, author, body, action: 'title'))
end end
def self.resolve_all_discussions(merge_request, project, author) def resolve_all_discussions(merge_request, project, author)
body = "resolved all discussions" body = "resolved all discussions"
create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion')) create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion'))
...@@ -274,6 +274,28 @@ module SystemNoteService ...@@ -274,6 +274,28 @@ module SystemNoteService
note note
end end
def diff_discussion_outdated(discussion, project, author, change_position)
merge_request = discussion.noteable
diff_refs = change_position.diff_refs
version_index = merge_request.merge_request_diffs.viewable.count
body = "changed this line in"
if version_params = merge_request.version_params_for(diff_refs)
line_code = change_position.line_code(project.repository)
url = url_helpers.diffs_namespace_project_merge_request_url(project.namespace, project, merge_request, version_params.merge(anchor: line_code))
body << " [version #{version_index} of the diff](#{url})"
else
body << " version #{version_index} of the diff"
end
note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
note = Note.create(note_attributes.merge(system: true))
note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated')
note
end
# Called when the title of a Noteable is changed # Called when the title of a Noteable is changed
# #
# noteable - Noteable object that responds to `title` # noteable - Noteable object that responds to `title`
......
...@@ -6,199 +6,26 @@ ...@@ -6,199 +6,26 @@
# Values are checked for formatting and exclusion from a list of reserved path # Values are checked for formatting and exclusion from a list of reserved path
# names. # names.
class DynamicPathValidator < ActiveModel::EachValidator class DynamicPathValidator < ActiveModel::EachValidator
# All routes that appear on the top level must be listed here. class << self
# This will make sure that groups cannot be created with these names def valid_namespace_path?(path)
# as these routes would be masked by the paths already in place. "#{path}/" =~ Gitlab::Regex.full_namespace_path_regex
#
# Example:
# /api/api-project
#
# the path `api` shouldn't be allowed because it would be masked by `api/*`
#
TOP_LEVEL_ROUTES = %w[
-
.well-known
abuse_reports
admin
all
api
assets
autocomplete
ci
dashboard
explore
files
groups
health_check
help
hooks
import
invites
issues
jwt
koding
member
merge_requests
new
notes
notification_settings
oauth
profile
projects
public
repository
robots.txt
s
search
sent_notifications
services
snippets
teams
u
unicorn_test
unsubscribes
uploads
users
].freeze
# This list should contain all words following `/*namespace_id/:project_id` in
# routes that contain a second wildcard.
#
# Example:
# /*namespace_id/:project_id/badges/*ref/build
#
# If `badges` was allowed as a project/group name, we would not be able to access the
# `badges` route for those projects:
#
# Consider a namespace with path `foo/bar` and a project called `badges`.
# The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg`
#
# When accessing this path the route would be matched to the `badges` path
# with the following params:
# - namespace_id: `foo`
# - project_id: `bar`
# - ref: `badges/master`
#
# Failing to find the project, this would result in a 404.
#
# By rejecting `badges` the router can _count_ on the fact that `badges` will
# be preceded by the `namespace/project`.
WILDCARD_ROUTES = %w[
badges
blame
blob
builds
commits
create
create_dir
edit
environments/folders
files
find_file
gitlab-lfs/objects
info/lfs/objects
new
preview
raw
refs
tree
update
wikis
].freeze
# These are all the paths that follow `/groups/*id/ or `/groups/*group_id`
# We need to reject these because we have a `/groups/*id` page that is the same
# as the `/*id`.
#
# If we would allow a subgroup to be created with the name `activity` then
# this group would not be accessible through `/groups/parent/activity` since
# this would map to the activity-page of it's parent.
GROUP_ROUTES = %w[
activity
analytics
audit_events
avatar
edit
group_members
hooks
issues
labels
ldap
ldap_group_links
merge_requests
milestones
notification_setting
pipeline_quota
projects
subgroups
].freeze
CHILD_ROUTES = (WILDCARD_ROUTES | GROUP_ROUTES).freeze
def self.without_reserved_wildcard_paths_regex
@without_reserved_wildcard_paths_regex ||= regex_excluding_child_paths(WILDCARD_ROUTES)
end
def self.without_reserved_child_paths_regex
@without_reserved_child_paths_regex ||= regex_excluding_child_paths(CHILD_ROUTES)
end
# This is used to validate a full path.
# It doesn't match paths
# - Starting with one of the top level words
# - Containing one of the child level words in the middle of a path
def self.regex_excluding_child_paths(child_routes)
reserved_top_level_words = Regexp.union(TOP_LEVEL_ROUTES)
not_starting_in_reserved_word = %r{\A/?(?!(#{reserved_top_level_words})(/|\z))}
reserved_child_level_words = Regexp.union(child_routes)
not_containing_reserved_child = %r{(?!\S+/(#{reserved_child_level_words})(/|\z))}
%r{#{not_starting_in_reserved_word}
#{not_containing_reserved_child}
#{Gitlab::Regex.full_namespace_regex}}x
end end
def self.valid?(path) def valid_project_path?(path)
path =~ Gitlab::Regex.full_namespace_regex && !full_path_reserved?(path) "#{path}/" =~ Gitlab::Regex.full_project_path_regex
end end
def self.full_path_reserved?(path)
path = path.to_s.downcase
_project_part, namespace_parts = path.reverse.split('/', 2).map(&:reverse)
wildcard_reserved?(path) || child_reserved?(namespace_parts)
end
def self.child_reserved?(path)
return false unless path
path !~ without_reserved_child_paths_regex
end end
def self.wildcard_reserved?(path) def path_valid_for_record?(record, value)
return false unless path
path !~ without_reserved_wildcard_paths_regex
end
delegate :full_path_reserved?,
:child_reserved?,
to: :class
def path_reserved_for_record?(record, value)
full_path = record.respond_to?(:full_path) ? record.full_path : value full_path = record.respond_to?(:full_path) ? record.full_path : value
# For group paths the entire path cannot contain a reserved child word return true unless full_path
# The path doesn't contain the last `_project_part` so we need to validate
# if the entire path. case record
# Example: when Project
# A *group* with full path `parent/activity` is reserved. self.class.valid_project_path?(full_path)
# A *project* with full path `parent/activity` is allowed.
if record.is_a? Group
child_reserved?(full_path)
else else
full_path_reserved?(full_path) self.class.valid_namespace_path?(full_path)
end end
end end
...@@ -208,7 +35,7 @@ class DynamicPathValidator < ActiveModel::EachValidator ...@@ -208,7 +35,7 @@ class DynamicPathValidator < ActiveModel::EachValidator
return return
end end
if path_reserved_for_record?(record, value) unless path_valid_for_record?(record, value)
record.errors.add(attribute, "#{value} is a reserved name") record.errors.add(attribute, "#{value} is a reserved name")
end end
end end
......
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
%ul.content-list %ul.content-list
- profiles.each do |profile| - profiles.each do |profile|
%li %li
= link_to profile.time.to_s(:long), admin_requests_profile_path(profile), data: {no_turbolink: true} = link_to profile.time.to_s(:long), admin_requests_profile_path(profile)
- else - else
%p %p
No profiles found No profiles found
...@@ -32,10 +32,9 @@ ...@@ -32,10 +32,9 @@
- elsif discussion.diff_discussion? - elsif discussion.diff_discussion?
on on
= conditional_link_to url.present?, url do = conditional_link_to url.present?, url do
- if discussion.active? - unless discussion.active?
an old version of
the diff the diff
- else
an outdated diff
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
= render "discussions/headline", discussion: discussion = render "discussions/headline", discussion: discussion
......
- name = label.parameterize
- attribute = name.underscore
.reset-action
%p.cgray
= label_tag name, label, class: "label-light"
= text_field_tag name, current_user.send(attribute), class: 'form-control', readonly: true, onclick: 'this.select()'
%p.help-block
= help_text
.prepend-top-default
= link_to button_label, [:reset, attribute, :profile], method: :put, data: { confirm: 'Are you sure?' }, class: 'btn btn-default private-token'
...@@ -8,35 +8,17 @@ ...@@ -8,35 +8,17 @@
.row.prepend-top-default .row.prepend-top-default
.col-lg-3.profile-settings-sidebar .col-lg-3.profile-settings-sidebar
%h4.prepend-top-0 %h4.prepend-top-0
= incoming_email_token_enabled? ? "Private Tokens" : "Private Token" Private Tokens
%p %p
Keep Keep these tokens secret, anyone with access to them can interact with
= incoming_email_token_enabled? ? "these tokens" : "this token" GitLab as if they were you.
secret, anyone with access to them can interact with GitLab as if they were you.
.col-lg-9.private-tokens-reset .col-lg-9.private-tokens-reset
.reset-action = render partial: 'reset_token', locals: { label: 'Private token', button_label: 'Reset private token', help_text: 'Your private token is used to access the API and Atom feeds without username/password authentication.' }
%p.cgray
- if current_user.private_token = render partial: 'reset_token', locals: { label: 'RSS token', button_label: 'Reset RSS token', help_text: 'Your RSS token is used to create urls for personalized RSS feeds.' }
= label_tag "private-token", "Private token", class: "label-light"
= text_field_tag "private-token", current_user.private_token, class: "form-control", readonly: true, onclick: "this.select()"
- else
%span You don't have one yet. Click generate to fix it.
%p.help-block
Your private token is used to access the API and Atom feeds without username/password authentication.
.prepend-top-default
- if current_user.private_token
= link_to 'Reset private token', reset_private_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default private-token"
- else
= f.submit 'Generate', class: "btn btn-default"
- if incoming_email_token_enabled? - if incoming_email_token_enabled?
.reset-action = render partial: 'reset_token', locals: { label: 'Incoming email token', button_label: 'Reset incoming email token', help_text: 'Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.' }
%p.cgray
= label_tag "incoming-email-token", "Incoming Email Token", class: 'label-light'
= text_field_tag "incoming-email-token", current_user.incoming_email_token, class: "form-control", readonly: true, onclick: "this.select()"
%p.help-block
Your incoming email token is used to create new issues by email, and is included in your project-specific email addresses.
.prepend-top-default
= link_to 'Reset incoming email token', reset_incoming_email_token_profile_path, method: :put, data: { confirm: "Are you sure?" }, class: "btn btn-default incoming-email-token"
%hr %hr
.row.prepend-top-default .row.prepend-top-default
......
...@@ -37,9 +37,9 @@ ...@@ -37,9 +37,9 @@
= f.select :dashboard, dashboard_choices, {}, class: 'form-control' = f.select :dashboard, dashboard_choices, {}, class: 'form-control'
.form-group .form-group
= f.label :project_view, class: 'label-light' do = f.label :project_view, class: 'label-light' do
Project view Project home page content
= f.select :project_view, project_view_choices, {}, class: 'form-control' = f.select :project_view, project_view_choices, {}, class: 'form-control'
.help-block .help-block
Choose what content you want to see on a project's home page. Choose what content you want to see on a project’s home page
.form-group .form-group
= f.submit 'Save changes', class: 'btn btn-save' = f.submit 'Save changes', class: 'btn btn-save'
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
= render 'projects/buttons/fork' = render 'projects/buttons/fork'
%span.hidden-xs %span.hidden-xs
- if @project.feature_available?(:repository, current_user) - if can?(current_user, :download_code, @project)
.project-clone-holder .project-clone-holder
= render "shared/clone_panel" = render "shared/clone_panel"
......
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
= icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.')
- if retried - if retried
= icon('spinner', class: 'text-warning has-tooltip', title: 'Job was retried') = icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried')
.label-container .label-container
- if job.tags.any? - if job.tags.any?
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
.block-connector .block-connector
= render "projects/diffs/diffs", diffs: @diffs, environment: @environment = render "projects/diffs/diffs", diffs: @diffs, environment: @environment
= render "shared/notes/notes_with_form" = render "shared/notes/notes_with_form", :autocomplete => true
- if can_collaborate_with_project? - if can_collaborate_with_project?
- %w(revert cherry-pick).each do |type| - %w(revert cherry-pick).each do |type|
= render "projects/commit/change", type: type, commit: @commit, title: @commit.title = render "projects/commit/change", type: type, commit: @commit, title: @commit.title
...@@ -246,14 +246,16 @@ ...@@ -246,14 +246,16 @@
.row.prepend-top-default .row.prepend-top-default
.col-lg-3 .col-lg-3
%h4.prepend-top-0.danger-title %h4.prepend-top-0.danger-title
Transfer project Transfer project to new group
%p.append-bottom-0
Please select the group you want to transfer this project to in the dropdown to the right.
.col-lg-9 .col-lg-9
= form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true) do |f| = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_namespace_project_path(@project.namespace, @project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f|
.form-group .form-group
= label_tag :new_namespace_id, nil, class: 'label-light' do = label_tag :new_namespace_id, nil, class: 'label-light' do
%span Namespace %span Select a new namespace
.form-group .form-group
= select_tag :new_namespace_id, namespaces_options(@project.namespace_id), { prompt: 'Choose a project namespace', class: 'select2' } = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
%ul %ul
%li Be careful. Changing the project's namespace can have unintended side effects. %li Be careful. Changing the project's namespace can have unintended side effects.
%li You can only transfer the project to namespaces you manage. %li You can only transfer the project to namespaces you manage.
......
- content_for :note_actions do - content_for :note_actions do
- if can?(current_user, :update_issue, @issue) - if can?(current_user, :update_issue, @issue)
= link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {no_turbolink: true, original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {no_turbolink: true, original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
#notes #notes
= render 'shared/notes/notes_with_form' = render 'shared/notes/notes_with_form', :autocomplete => true
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description - page_description @issue.description
- page_card_attributes @issue.card_attributes - page_card_attributes @issue.card_attributes
- can_update_issue = can?(current_user, :update_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user)
.clearfix.detail-page-header .clearfix.detail-page-header
.issuable-header .issuable-header
...@@ -27,27 +29,29 @@ ...@@ -27,27 +29,29 @@
= icon('caret-down') = icon('caret-down')
.dropdown-menu.dropdown-menu-align-right.hidden-lg .dropdown-menu.dropdown-menu-align-right.hidden-lg
%ul %ul
- if can_update_issue
%li %li
= link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link' = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue)
- if can?(current_user, :update_issue, @issue)
%li %li
= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%li %li
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
%li - if can_report_spam
= link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue)
- if @issue.submittable_as_spam_by?(current_user)
%li %li
= link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
- if can_update_issue || can_report_spam
%li.divider
%li
= link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project), title: 'New issue', id: 'new_issue_link'
- if can_update_issue
= link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- if can_report_spam
= link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
= link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
New issue New issue
- if can?(current_user, :update_issue, @issue)
= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- if @issue.submittable_as_spam_by?(current_user)
= link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
= link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
.issue-details.issuable-details .issue-details.issuable-details
.detail-page-description.content-block .detail-page-description.content-block
......
...@@ -8,4 +8,4 @@ ...@@ -8,4 +8,4 @@
%button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
{{ buttonText }} {{ buttonText }}
#notes= render "shared/notes/notes_with_form" #notes= render "shared/notes/notes_with_form", :autocomplete => true
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
= icon('angle-double-left') = icon('angle-double-left')
.issuable-meta .issuable-meta
= issuable_meta(@merge_request, @project, "Merge Request") = issuable_meta(@merge_request, @project, "Merge request")
- if can?(current_user, :update_merge_request, @merge_request) - if can?(current_user, :update_merge_request, @merge_request)
.issuable-actions .issuable-actions
......
...@@ -91,7 +91,7 @@ ...@@ -91,7 +91,7 @@
comparing two versions comparing two versions
- else - else
viewing an old version viewing an old version
of this merge request. of the diff.
.pull-right .pull-right
= link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm'
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
None None
%td.next-run-cell %td.next-run-cell
- if pipeline_schedule.active? - if pipeline_schedule.active?
= time_ago_with_tooltip(pipeline_schedule.next_run_at) = time_ago_with_tooltip(pipeline_schedule.real_next_run)
- else - else
Inactive Inactive
%td %td
......
...@@ -9,4 +9,4 @@ ...@@ -9,4 +9,4 @@
.row-content-block.top-block.content-component-block .row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true = render 'award_emoji/awards_block', awardable: @snippet, inline: true
#notes= render "shared/notes/notes_with_form" #notes= render "shared/notes/notes_with_form", :autocomplete => true
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M7.69 7.7l-.905.905a.7.7 0 0 0 .99.99l1.85-1.85c.411-.412.411-1.078 0-1.49l-1.85-1.85a.7.7 0 0 0-.99.99l.905.905H4.48a.7.7 0 0 0 0 1.4h3.21z"/></g></svg> <svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M7 14A7 7 0 1 1 7 0a7 7 0 0 1 0 14z"/><path d="M7 13A6 6 0 1 0 7 1a6 6 0 0 0 0 12z" fill="#FFF" fill-rule="nonzero"/><path d="M6.415 7.04L4.579 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L5.341 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L6.415 7.04zm2.54 0L7.119 5.203a.295.295 0 0 1 .004-.416l.349-.349a.29.29 0 0 1 .416-.004l2.214 2.214a.289.289 0 0 1 .019.021l.132.133c.11.11.108.291 0 .398L7.881 9.573a.282.282 0 0 1-.398 0l-.331-.331a.285.285 0 0 1 0-.399L8.955 7.04z"/></svg>
<svg width="22px" height="22px" viewBox="0 0 22 22" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M12.0846,12.1 L10.6623,13.5223 C10.2454306,13.9539168 10.2513924,14.6399933 10.6756996,15.0643004 C11.1000067,15.4886076 11.7860832,15.4945694 12.2177,15.0777 L15.1261,12.1693 C15.7708612,11.5230891 15.7708612,10.4769109 15.1261,9.8307 L12.2177,6.9223 C11.7860832,6.50543057 11.1000067,6.51139239 10.6756996,6.93569957 C10.2513924,7.36000675 10.2454306,8.04608322 10.6623,8.4777 L12.0846,9.9 L7.04,9.9 C6.43248678,9.9 5.94,10.3924868 5.94,11 C5.94,11.6075132 6.43248678,12.1 7.04,12.1 L12.0846,12.1 L12.0846,12.1 Z" id="Shape"></path></svg> <svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"><path d="M14.072 11.063l-2.82 2.82a.46.46 0 0 0-.001.652l.495.495a.457.457 0 0 0 .653-.001l3.7-3.7a.46.46 0 0 0 .001-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.479-3.479a.464.464 0 0 0-.654.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/><path d="M10.08 11.063l-2.819 2.82a.46.46 0 0 0-.002.652l.496.495a.457.457 0 0 0 .652-.001l3.7-3.7a.46.46 0 0 0 .002-.653l-.196-.196a.453.453 0 0 0-.03-.033l-3.48-3.479a.464.464 0 0 0-.653.007l-.548.548a.463.463 0 0 0-.007.654l2.886 2.886z"/></svg>
...@@ -45,7 +45,7 @@ ...@@ -45,7 +45,7 @@
{{hint}} {{hint}}
%span.js-filter-tag.dropdown-light-content %span.js-filter-tag.dropdown-light-content
{{tag}} {{tag}}
#js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link.dropdown-user %button.btn.btn-link.dropdown-user
...@@ -55,7 +55,7 @@ ...@@ -55,7 +55,7 @@
{{name}} {{name}}
%span.dropdown-light-content %span.dropdown-light-content
@{{username}} @{{username}}
#js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link
...@@ -70,7 +70,7 @@ ...@@ -70,7 +70,7 @@
{{name}} {{name}}
%span.dropdown-light-content %span.dropdown-light-content
@{{username}} @{{username}}
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link
...@@ -86,7 +86,7 @@ ...@@ -86,7 +86,7 @@
%li.filter-dropdown-item %li.filter-dropdown-item
%button.btn.btn-link.js-data-value %button.btn.btn-link.js-data-value
{{title}} {{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link
......
- if issuable.is_a?(Issue) - if issuable.is_a?(Issue)
#js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } } #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } }
.title.hide-collapsed
Assignee
= icon('spinner spin')
- else - else
.sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) } .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) }
- if issuable.assignee - if issuable.assignee
......
...@@ -23,4 +23,4 @@ ...@@ -23,4 +23,4 @@
to post a comment to post a comment
:javascript :javascript
var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}", false) var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}", #{autocomplete})
...@@ -9,4 +9,4 @@ ...@@ -9,4 +9,4 @@
.row-content-block.top-block.content-component-block .row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true = render 'award_emoji/awards_block', awardable: @snippet, inline: true
#notes= render "shared/notes/notes_with_form" #notes= render "shared/notes/notes_with_form", :autocomplete => false
...@@ -71,7 +71,7 @@ ...@@ -71,7 +71,7 @@
= @user.location = @user.location
- unless @user.organization.blank? - unless @user.organization.blank?
.profile-link-holder.middle-dot-divider .profile-link-holder.middle-dot-divider
= icon('building') = icon('briefcase')
= @user.organization = @user.organization
- if @user.bio.present? - if @user.bio.present?
......
class ExpireJobCacheWorker
include Sidekiq::Worker
include BuildQueue
def perform(job_id)
job = CommitStatus.joins(:pipeline, :project).find_by(id: job_id)
return unless job
pipeline = job.pipeline
project = job.project
Gitlab::EtagCaching::Store.new.tap do |store|
store.touch(project_pipeline_path(project, pipeline))
store.touch(project_job_path(project, job))
end
end
private
def project_pipeline_path(project, pipeline)
Gitlab::Routing.url_helpers.namespace_project_pipeline_path(
project.namespace,
project,
pipeline,
format: :json)
end
def project_job_path(project, job)
Gitlab::Routing.url_helpers.namespace_project_build_path(
project.namespace,
project,
job.id,
format: :json)
end
end
...@@ -10,6 +10,7 @@ class ExpirePipelineCacheWorker ...@@ -10,6 +10,7 @@ class ExpirePipelineCacheWorker
store = Gitlab::EtagCaching::Store.new store = Gitlab::EtagCaching::Store.new
store.touch(project_pipelines_path(project)) store.touch(project_pipelines_path(project))
store.touch(project_pipeline_path(project, pipeline))
store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit store.touch(commit_pipelines_path(project, pipeline.commit)) if pipeline.commit
store.touch(new_merge_request_pipelines_path(project)) store.touch(new_merge_request_pipelines_path(project))
each_pipelines_merge_request_path(project, pipeline) do |path| each_pipelines_merge_request_path(project, pipeline) do |path|
...@@ -28,6 +29,14 @@ class ExpirePipelineCacheWorker ...@@ -28,6 +29,14 @@ class ExpirePipelineCacheWorker
format: :json) format: :json)
end end
def project_pipeline_path(project, pipeline)
Gitlab::Routing.url_helpers.namespace_project_pipeline_path(
project.namespace,
project,
pipeline,
format: :json)
end
def commit_pipelines_path(project, commit) def commit_pipelines_path(project, commit)
Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path( Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path(
project.namespace, project.namespace,
......
---
title: Hide clone panel and file list when user is only a guest
merge_request:
author: James Clark
---
title: Reorder Issue action buttons in order of usability
merge_request: 11642
author:
---
title: Fix LaTeX formatting for AsciiDoc wiki
merge_request: 11212
author:
---
title: Change links in issuable meta to black
merge_request:
author:
---
title: Adds new icon for CI skipped status
merge_request:
author:
---
title: Make all notes use equal padding
merge_request:
author:
---
title: Remove redundant data-turbolink attributes from links
merge_request: 11672
author: blackst0ne
---
title: Use briefcase icon for company in profile page
merge_request:
author:
---
title: Add a feature test for Unicode trace
merge_request: 10736
author: dosuken123
---
title: Don't copy empty elements that were not selected on purpose as GFM
merge_request:
author:
---
title: Copy as GFM even when parts of other elements are selected
merge_request:
author:
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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