Commit 3deaf134 authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch 'master' into auto-pipelines-vue

* master: (367 commits)
  Set “Remove branch” button to default size
  remove unused helper method
  reduce common code even further to satisfy rake flay
  remove button class size alteration from revert and cherry pick links
  factor out common code to satisfy rake flay
  homogenize revert and cherry-pick button styles generated by commits_helper
  apply margin on alert banners only when there is one or more alerts
  Rename MattermostNotificationService back to MattermostService
  Rename SlackNotificationService back to SlackService
  Fix stage and pipeline specs and rubocop offenses
  Added QueryRecorder to test N+1 fix on Milestone#show
  Use gitlab-workhorse 1.2.1
  Make 'unmarked as WIP' message more consistent
  Improve specs for Files API
  Allow unauthenticated access to Repositories Files API GET endpoints
  Add isolated view spec for pipeline stage partial
  Move test for HTML stage endpoint to controller specs
  Fix sizing of avatar circles; add border
  Fix broken test
  Fix broken test Changes after review
  ...

Conflicts:
	app/assets/stylesheets/pages/pipelines.scss
	app/controllers/projects/pipelines_controller.rb
	app/views/projects/pipelines/index.html.haml
	spec/features/projects/pipelines/pipelines_spec.rb
parents 0dc5a21c 09b622f8
...@@ -15,6 +15,7 @@ variables: ...@@ -15,6 +15,7 @@ variables:
USE_BUNDLE_INSTALL: "true" USE_BUNDLE_INSTALL: "true"
GIT_DEPTH: "20" GIT_DEPTH: "20"
PHANTOMJS_VERSION: "2.1.1" PHANTOMJS_VERSION: "2.1.1"
GET_SOURCES_ATTEMPTS: "3"
before_script: before_script:
- source ./scripts/prepare_build.sh - source ./scripts/prepare_build.sh
......
...@@ -292,7 +292,8 @@ Style/MultilineMethodDefinitionBraceLayout: ...@@ -292,7 +292,8 @@ Style/MultilineMethodDefinitionBraceLayout:
# Checks indentation of binary operations that span more than one line. # Checks indentation of binary operations that span more than one line.
Style/MultilineOperationIndentation: Style/MultilineOperationIndentation:
Enabled: false Enabled: true
EnforcedStyle: indented
# Avoid multi-line `? :` (the ternary operator), use if/unless instead. # Avoid multi-line `? :` (the ternary operator), use if/unless instead.
Style/MultilineTernaryOperator: Style/MultilineTernaryOperator:
......
...@@ -2,6 +2,21 @@ ...@@ -2,6 +2,21 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 8.14.5 (2016-12-14)
- Moved Leave Project and Leave Group buttons to access_request_buttons from the settings dropdown. !7600
- fix display hook error message. !7775 (basyura)
- Remove wrong '.builds-feature' class from the MR settings fieldset. !7930
- Avoid escaping relative links in Markdown twice. !7940 (winniehell)
- API: Memoize the current_user so that sudo can work properly. !8017
- Displays milestone remaining days only when it's present.
- Allow branch names with dots on API endpoint.
- Issue#visible_to_user moved to IssuesFinder to prevent accidental use.
- Shows group members in project members list.
- Encode input when migrating ProcessCommitWorker jobs to prevent migration errors.
- Fixed timeago re-rendering every timeago.
- Fix missing Note access checks by moving Note#search to updated NoteFinder.
## 8.14.4 (2016-12-08) ## 8.14.4 (2016-12-08)
- Fix diff view permalink highlighting. !7090 - Fix diff view permalink highlighting. !7090
...@@ -264,6 +279,13 @@ entry. ...@@ -264,6 +279,13 @@ entry.
- Fix "Without projects" filter. !6611 (Ben Bodenmiller) - Fix "Without projects" filter. !6611 (Ben Bodenmiller)
- Fix 404 when visit /projects page - Fix 404 when visit /projects page
## 8.13.10 (2016-12-14)
- API: Memoize the current_user so that sudo can work properly. !8017
- Filter `authentication_token`, `incoming_email_token` and `runners_token` parameters.
- Issue#visible_to_user moved to IssuesFinder to prevent accidental use.
- Fix missing Note access checks by moving Note#search to updated NoteFinder.
## 8.13.9 (2016-12-08) ## 8.13.9 (2016-12-08)
- Reenables /user API request to return private-token if user is admin and request is made with sudo. !7615 - Reenables /user API request to return private-token if user is admin and request is made with sudo. !7615
......
...@@ -22,7 +22,6 @@ gem 'doorkeeper', '~> 4.2.0' ...@@ -22,7 +22,6 @@ gem 'doorkeeper', '~> 4.2.0'
gem 'omniauth', '~> 1.3.1' gem 'omniauth', '~> 1.3.1'
gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-auth0', '~> 1.4.1'
gem 'omniauth-azure-oauth2', '~> 0.0.6' gem 'omniauth-azure-oauth2', '~> 0.0.6'
gem 'omniauth-bitbucket', '~> 0.0.2'
gem 'omniauth-cas3', '~> 1.1.2' gem 'omniauth-cas3', '~> 1.1.2'
gem 'omniauth-facebook', '~> 4.0.0' gem 'omniauth-facebook', '~> 4.0.0'
gem 'omniauth-github', '~> 1.1.1' gem 'omniauth-github', '~> 1.1.1'
...@@ -170,7 +169,7 @@ gem 'gitlab-flowdock-git-hook', '~> 1.0.1' ...@@ -170,7 +169,7 @@ gem 'gitlab-flowdock-git-hook', '~> 1.0.1'
gem 'gemnasium-gitlab-service', '~> 0.2' gem 'gemnasium-gitlab-service', '~> 0.2'
# Slack integration # Slack integration
gem 'slack-notifier', '~> 1.2.0' gem 'slack-notifier', '~> 1.5.1'
# Asana integration # Asana integration
gem 'asana', '~> 0.4.0' gem 'asana', '~> 0.4.0'
......
...@@ -432,10 +432,6 @@ GEM ...@@ -432,10 +432,6 @@ GEM
jwt (~> 1.0) jwt (~> 1.0)
omniauth (~> 1.0) omniauth (~> 1.0)
omniauth-oauth2 (~> 1.1) omniauth-oauth2 (~> 1.1)
omniauth-bitbucket (0.0.2)
multi_json (~> 1.7)
omniauth (~> 1.1)
omniauth-oauth (~> 1.0)
omniauth-cas3 (1.1.3) omniauth-cas3 (1.1.3)
addressable (~> 2.3) addressable (~> 2.3)
nokogiri (~> 1.6.6) nokogiri (~> 1.6.6)
...@@ -687,7 +683,7 @@ GEM ...@@ -687,7 +683,7 @@ GEM
json (>= 1.8, < 3) json (>= 1.8, < 3)
simplecov-html (~> 0.10.0) simplecov-html (~> 0.10.0)
simplecov-html (0.10.0) simplecov-html (0.10.0)
slack-notifier (1.2.1) slack-notifier (1.5.1)
slop (3.6.0) slop (3.6.0)
spinach (0.8.10) spinach (0.8.10)
colorize colorize
...@@ -902,7 +898,6 @@ DEPENDENCIES ...@@ -902,7 +898,6 @@ DEPENDENCIES
omniauth (~> 1.3.1) omniauth (~> 1.3.1)
omniauth-auth0 (~> 1.4.1) omniauth-auth0 (~> 1.4.1)
omniauth-azure-oauth2 (~> 0.0.6) omniauth-azure-oauth2 (~> 0.0.6)
omniauth-bitbucket (~> 0.0.2)
omniauth-cas3 (~> 1.1.2) omniauth-cas3 (~> 1.1.2)
omniauth-facebook (~> 4.0.0) omniauth-facebook (~> 4.0.0)
omniauth-github (~> 1.1.1) omniauth-github (~> 1.1.1)
...@@ -957,7 +952,7 @@ DEPENDENCIES ...@@ -957,7 +952,7 @@ DEPENDENCIES
sidekiq-cron (~> 0.4.4) sidekiq-cron (~> 0.4.4)
sidekiq-limit_fetch (~> 3.4) sidekiq-limit_fetch (~> 3.4)
simplecov (= 0.12.0) simplecov (= 0.12.0)
slack-notifier (~> 1.2.0) slack-notifier (~> 1.5.1)
spinach-rails (~> 0.2.1) spinach-rails (~> 0.2.1)
spinach-rerun-reporter (~> 0.0.2) spinach-rerun-reporter (~> 0.0.2)
spring (~> 1.7.0) spring (~> 1.7.0)
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
licensePath: "/api/:version/templates/licenses/:key", licensePath: "/api/:version/templates/licenses/:key",
gitignorePath: "/api/:version/templates/gitignores/:key", gitignorePath: "/api/:version/templates/gitignores/:key",
gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key", gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key",
dockerfilePath: "/api/:version/dockerfiles/:key",
issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key",
group: function(group_id, callback) { group: function(group_id, callback) {
var url = Api.buildUrl(Api.groupPath) var url = Api.buildUrl(Api.groupPath)
...@@ -120,6 +121,10 @@ ...@@ -120,6 +121,10 @@
return callback(file); return callback(file);
}); });
}, },
dockerfileYml: function(key, callback) {
var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key);
$.get(url, callback);
},
issueTemplate: function(namespacePath, projectPath, key, type, callback) { issueTemplate: function(namespacePath, projectPath, key, type, callback) {
var url = Api.buildUrl(Api.issuableTemplatePath) var url = Api.buildUrl(Api.issuableTemplatePath)
.replace(':key', key) .replace(':key', key)
......
/* global Api */
/*= require blob/template_selector */
(() => {
const global = window.gl || (window.gl = {});
class BlobDockerfileSelector extends gl.TemplateSelector {
requestFile(query) {
return Api.dockerfileYml(query.name, this.requestFileSuccess.bind(this));
}
requestFileSuccess(file) {
return super.requestFileSuccess(file);
}
}
global.BlobDockerfileSelector = BlobDockerfileSelector;
})();
(() => {
const global = window.gl || (window.gl = {});
class BlobDockerfileSelectors {
constructor({ editor, $dropdowns } = {}) {
this.editor = editor;
this.$dropdowns = $dropdowns || $('.js-dockerfile-selector');
this.initSelectors();
}
initSelectors() {
const editor = this.editor;
this.$dropdowns.each((i, dropdown) => {
const $dropdown = $(dropdown);
return new gl.BlobDockerfileSelector({
editor,
pattern: /(Dockerfile)/,
data: $dropdown.data('data'),
wrapper: $dropdown.closest('.js-dockerfile-selector-wrap'),
dropdown: $dropdown,
});
});
}
}
global.BlobDockerfileSelectors = BlobDockerfileSelectors;
})();
...@@ -36,6 +36,9 @@ ...@@ -36,6 +36,9 @@
new gl.BlobCiYamlSelectors({ new gl.BlobCiYamlSelectors({
editor: this.editor editor: this.editor
}); });
new gl.BlobDockerfileSelectors({
editor: this.editor
});
} }
EditBlob.prototype.initModePanesAndLinks = function() { EditBlob.prototype.initModePanesAndLinks = function() {
......
...@@ -141,6 +141,11 @@ ...@@ -141,6 +141,11 @@
case 'projects:merge_requests:builds': case 'projects:merge_requests:builds':
new MergedButtons(); new MergedButtons();
break; break;
case 'projects:merge_requests:pipelines':
new gl.MiniPipelineGraph({
container: '.js-pipeline-table',
});
break;
case "projects:merge_requests:diffs": case "projects:merge_requests:diffs":
new gl.Diff(); new gl.Diff();
new ZenMode(); new ZenMode();
...@@ -158,6 +163,11 @@ ...@@ -158,6 +163,11 @@
new ZenMode(); new ZenMode();
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
break; break;
case 'projects:commit:pipelines':
new gl.MiniPipelineGraph({
container: '.js-pipeline-table',
});
break;
case 'projects:commit:builds': case 'projects:commit:builds':
new gl.Pipelines(); new gl.Pipelines();
break; break;
...@@ -172,6 +182,11 @@ ...@@ -172,6 +182,11 @@
new TreeView(); new TreeView();
} }
break; break;
case 'projects:pipelines:index':
new gl.MiniPipelineGraph({
container: '.js-pipeline-table',
});
break;
case 'projects:pipelines:builds': case 'projects:pipelines:builds':
case 'projects:pipelines:show': case 'projects:pipelines:show':
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
* The environments array is a recursive tree structure and we need to filter * The environments array is a recursive tree structure and we need to filter
* both root level environments and children environments. * both root level environments and children environments.
* *
* In order to acomplish that, both `filterState` and `filterEnvironmnetsByState` * In order to acomplish that, both `filterState` and `filterEnvironmentsByState`
* functions work together. * functions work together.
* The first one works as the filter that verifies if the given environment matches * The first one works as the filter that verifies if the given environment matches
* the given state. * the given state.
...@@ -34,9 +34,9 @@ ...@@ -34,9 +34,9 @@
* @param {Array} array * @param {Array} array
* @return {Array} * @return {Array}
*/ */
const filterEnvironmnetsByState = (fn, arr) => arr.map((item) => { const filterEnvironmentsByState = (fn, arr) => arr.map((item) => {
if (item.children) { if (item.children) {
const filteredChildren = filterEnvironmnetsByState(fn, item.children).filter(Boolean); const filteredChildren = filterEnvironmentsByState(fn, item.children).filter(Boolean);
if (filteredChildren.length) { if (filteredChildren.length) {
item.children = filteredChildren; item.children = filteredChildren;
return item; return item;
...@@ -76,12 +76,13 @@ ...@@ -76,12 +76,13 @@
helpPagePath: environmentsData.helpPagePath, helpPagePath: environmentsData.helpPagePath,
commitIconSvg: environmentsData.commitIconSvg, commitIconSvg: environmentsData.commitIconSvg,
playIconSvg: environmentsData.playIconSvg, playIconSvg: environmentsData.playIconSvg,
terminalIconSvg: environmentsData.terminalIconSvg,
}; };
}, },
computed: { computed: {
filteredEnvironments() { filteredEnvironments() {
return filterEnvironmnetsByState(filterState(this.visibility), this.state.environments); return filterEnvironmentsByState(filterState(this.visibility), this.state.environments);
}, },
scope() { scope() {
...@@ -102,7 +103,7 @@ ...@@ -102,7 +103,7 @@
}, },
/** /**
* Fetches all the environmnets and stores them. * Fetches all the environments and stores them.
* Toggles loading property. * Toggles loading property.
*/ */
created() { created() {
...@@ -230,6 +231,7 @@ ...@@ -230,6 +231,7 @@
:can-create-deployment="canCreateDeploymentParsed" :can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed" :can-read-environment="canReadEnvironmentParsed"
:play-icon-svg="playIconSvg" :play-icon-svg="playIconSvg"
:terminal-icon-svg="terminalIconSvg"
:commit-icon-svg="commitIconSvg"></tr> :commit-icon-svg="commitIconSvg"></tr>
<tr v-if="model.isOpen && model.children && model.children.length > 0" <tr v-if="model.isOpen && model.children && model.children.length > 0"
...@@ -240,6 +242,7 @@ ...@@ -240,6 +242,7 @@
:can-create-deployment="canCreateDeploymentParsed" :can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed" :can-read-environment="canReadEnvironmentParsed"
:play-icon-svg="playIconSvg" :play-icon-svg="playIconSvg"
:terminal-icon-svg="terminalIconSvg"
:commit-icon-svg="commitIconSvg"> :commit-icon-svg="commitIconSvg">
</tr> </tr>
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
/*= require ./environment_external_url */ /*= require ./environment_external_url */
/*= require ./environment_stop */ /*= require ./environment_stop */
/*= require ./environment_rollback */ /*= require ./environment_rollback */
/*= require ./environment_terminal_button */
(() => { (() => {
/** /**
...@@ -33,6 +34,7 @@ ...@@ -33,6 +34,7 @@
'external-url-component': window.gl.environmentsList.ExternalUrlComponent, 'external-url-component': window.gl.environmentsList.ExternalUrlComponent,
'stop-component': window.gl.environmentsList.StopComponent, 'stop-component': window.gl.environmentsList.StopComponent,
'rollback-component': window.gl.environmentsList.RollbackComponent, 'rollback-component': window.gl.environmentsList.RollbackComponent,
'terminal-button-component': window.gl.environmentsList.TerminalButtonComponent,
}, },
props: { props: {
...@@ -68,6 +70,12 @@ ...@@ -68,6 +70,12 @@
type: String, type: String,
required: false, required: false,
}, },
terminalIconSvg: {
type: String,
required: false,
},
}, },
data() { data() {
...@@ -449,7 +457,7 @@ ...@@ -449,7 +457,7 @@
</span> </span>
</td> </td>
<td> <td class="environments-build-cell">
<a v-if="shouldRenderBuildName" <a v-if="shouldRenderBuildName"
class="build-link" class="build-link"
:href="model.last_deployment.deployable.build_path"> :href="model.last_deployment.deployable.build_path">
...@@ -506,6 +514,14 @@ ...@@ -506,6 +514,14 @@
</stop-component> </stop-component>
</div> </div>
<div v-if="model.terminal_path"
class="inline js-terminal-button-container">
<terminal-button-component
:terminal-icon-svg="terminalIconSvg"
:terminal-path="model.terminal_path">
</terminal-button-component>
</div>
<div v-if="canRetry && canCreateDeployment" <div v-if="canRetry && canCreateDeployment"
class="inline js-rollback-component-container"> class="inline js-rollback-component-container">
<rollback-component <rollback-component
......
/*= require vue */
/* global Vue */
(() => {
window.gl = window.gl || {};
window.gl.environmentsList = window.gl.environmentsList || {};
window.gl.environmentsList.TerminalButtonComponent = Vue.component('terminal-button-component', {
props: {
terminalPath: {
type: String,
default: '',
},
terminalIconSvg: {
type: String,
default: '',
},
},
template: `
<a class="btn terminal-button"
:href="terminalPath">
<span class="js-terminal-icon-container" v-html="terminalIconSvg"></span>
</a>
`,
});
})();
...@@ -343,16 +343,18 @@ ...@@ -343,16 +343,18 @@
selector = ".dropdown-page-one .dropdown-content a"; selector = ".dropdown-page-one .dropdown-content a";
} }
this.dropdown.on("click", selector, function(e) { this.dropdown.on("click", selector, function(e) {
var $el, selected; var $el, selected, selectedObj, isMarking;
$el = $(this); $el = $(this);
selected = self.rowClicked($el); selected = self.rowClicked($el);
selectedObj = selected ? selected[0] : null;
isMarking = selected ? selected[1] : null;
if (self.options.clicked) { if (self.options.clicked) {
self.options.clicked(selected[0], $el, e, selected[1]); self.options.clicked(selectedObj, $el, e, isMarking);
} }
// Update label right after all modifications in dropdown has been done // Update label right after all modifications in dropdown has been done
if (self.options.toggleLabel) { if (self.options.toggleLabel) {
self.updateLabel(selected[0], $el, self); self.updateLabel(selectedObj, $el, self);
} }
$el.trigger('blur'); $el.trigger('blur');
......
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
this.form.addClass('gfm-form'); this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes // remove notify commit author checkbox for non-commit notes
gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button'));
GitLab.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
new DropzoneInput(this.form); new DropzoneInput(this.form);
autosize(this.textarea); autosize(this.textarea);
// form and textarea event listeners // form and textarea event listeners
......
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
this.renderWipExplanation = bind(this.renderWipExplanation, this); this.renderWipExplanation = bind(this.renderWipExplanation, this);
this.resetAutosave = bind(this.resetAutosave, this); this.resetAutosave = bind(this.resetAutosave, this);
this.handleSubmit = bind(this.handleSubmit, this); this.handleSubmit = bind(this.handleSubmit, this);
GitLab.GfmAutoComplete.setup(); gl.GfmAutoComplete.setup();
new UsersSelect(); new UsersSelect();
new ZenMode(); new ZenMode();
this.titleField = this.form.find("input[name*='[title]']"); this.titleField = this.form.find("input[name*='[title]']");
......
/* eslint-disable func-names, space-before-function-paren, no-var, space-before-blocks, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, padded-blocks, max-len */ /* eslint-disable func-names, space-before-function-paren, no-var, space-before-blocks, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, padded-blocks, max-len, prefer-arrow-callback */
/* global MergeRequestTabs */ /* global MergeRequestTabs */
/*= require jquery.waitforimages */ /*= require jquery.waitforimages */
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
// Prevent duplicate event bindings // Prevent duplicate event bindings
this.disableTaskList(); this.disableTaskList();
this.initMRBtnListeners(); this.initMRBtnListeners();
this.initCommitMessageListeners();
if ($("a.btn-close").length) { if ($("a.btn-close").length) {
this.initTaskList(); this.initTaskList();
} }
...@@ -108,6 +109,26 @@ ...@@ -108,6 +109,26 @@
// note so that we can re-use its form here // note so that we can re-use its form here
}; };
MergeRequest.prototype.initCommitMessageListeners = function() {
var textarea = $('textarea.js-commit-message');
$('a.js-with-description-link').on('click', function(e) {
e.preventDefault();
textarea.val(textarea.data('messageWithDescription'));
$('p.js-with-description-hint').hide();
$('p.js-without-description-hint').show();
});
$('a.js-without-description-link').on('click', function(e) {
e.preventDefault();
textarea.val(textarea.data('messageWithoutDescription'));
$('p.js-with-description-hint').show();
$('p.js-without-description-hint').hide();
});
};
return MergeRequest; return MergeRequest;
})(); })();
......
/* eslint-disable no-new */
/* global Flash */
/**
* In each pipelines table we have a mini pipeline graph for each pipeline.
*
* When we click in a pipeline stage, we need to make an API call to get the
* builds list to render in a dropdown.
*
* The container should be the table element.
*
* The stage icon clicked needs to have the following HTML structure:
* <div>
* <button class="dropdown js-builds-dropdown-button"></button>
* <div class="js-builds-dropdown-container"></div>
* </div>
*/
(() => {
class MiniPipelineGraph {
constructor(opts = {}) {
this.container = opts.container || '';
this.dropdownListSelector = '.js-builds-dropdown-container';
this.getBuildsList = this.getBuildsList.bind(this);
this.bindEvents();
}
/**
* Adds and removes the event listener.
*/
bindEvents() {
const dropdownButtonSelector = 'button.js-builds-dropdown-button';
$(this.container).off('click', dropdownButtonSelector, this.getBuildsList)
.on('click', dropdownButtonSelector, this.getBuildsList);
}
/**
* For the clicked stage, renders the given data in the dropdown list.
*
* @param {HTMLElement} stageContainer
* @param {Object} data
*/
renderBuildsList(stageContainer, data) {
const dropdownContainer = stageContainer.parentElement.querySelector(
`${this.dropdownListSelector} .js-builds-dropdown-list`,
);
dropdownContainer.innerHTML = data;
}
/**
* For the clicked stage, gets the list of builds.
*
* @param {Object} e
* @return {Promise}
*/
getBuildsList(e) {
const button = e.currentTarget;
const endpoint = button.dataset.stageEndpoint;
return $.ajax({
dataType: 'json',
type: 'GET',
url: endpoint,
beforeSend: () => {
this.renderBuildsList(button, '');
this.toggleLoading(button);
},
success: (data) => {
this.toggleLoading(button);
this.renderBuildsList(button, data.html);
},
error: () => {
this.toggleLoading(button);
new Flash('An error occurred while fetching the builds.', 'alert');
},
});
}
/**
* Toggles the visibility of the loading icon.
*
* @param {HTMLElement} stageContainer
* @return {type}
*/
toggleLoading(stageContainer) {
stageContainer.parentElement.querySelector(
`${this.dropdownListSelector} .js-builds-dropdown-loading`,
).classList.toggle('hidden');
}
}
window.gl = window.gl || {};
window.gl.MiniPipelineGraph = MiniPipelineGraph;
})();
...@@ -356,7 +356,7 @@ ...@@ -356,7 +356,7 @@
icon = this.image(gon.relative_url_root + commit.author.icon, x, y, 20, 20); icon = this.image(gon.relative_url_root + commit.author.icon, x, y, 20, 20);
nameText = this.text(x + 25, y + 10, commit.author.name); nameText = this.text(x + 25, y + 10, commit.author.name);
idText = this.text(x, y + 35, commit.id); idText = this.text(x, y + 35, commit.id);
messageText = this.text(x, y + 50, commit.message); messageText = this.text(x, y + 50, commit.message.replace(/\r?\n/g, " \n "));
textSet = this.set(icon, nameText, idText, messageText).attr({ textSet = this.set(icon, nameText, idText, messageText).attr({
"text-anchor": "start", "text-anchor": "start",
font: "12px Monaco, monospace" font: "12px Monaco, monospace"
...@@ -368,6 +368,7 @@ ...@@ -368,6 +368,7 @@
idText.attr({ idText.attr({
fill: "#AAA" fill: "#AAA"
}); });
messageText.node.style["white-space"] = "pre";
this.textWrap(messageText, boxWidth - 50); this.textWrap(messageText, boxWidth - 50);
rect = this.rect(x - 10, y - 10, boxWidth, 100, 4).attr({ rect = this.rect(x - 10, y - 10, boxWidth, 100, 4).attr({
fill: "#FFF", fill: "#FFF",
...@@ -404,16 +405,21 @@ ...@@ -404,16 +405,21 @@
s.push("\n"); s.push("\n");
x = 0; x = 0;
} }
x += word.length * letterWidth; if (word === "\n") {
s.push(word + " "); s.push("\n");
x = 0;
} else {
s.push(word + " ");
x += word.length * letterWidth;
}
} }
t.attr({ t.attr({
text: s.join("") text: s.join("").trim()
}); });
b = t.getBBox(); b = t.getBBox();
h = Math.abs(b.y2) - Math.abs(b.y) + 1; h = Math.abs(b.y2) + 1;
return t.attr({ return t.attr({
y: b.y + h y: h
}); });
}; };
......
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
}); });
$(document).off('ajax:success', '.notification-form').on('ajax:success', '.notification-form', function(e, data) { $(document).off('ajax:success', '.notification-form').on('ajax:success', '.notification-form', function(e, data) {
if (data.saved) { if (data.saved) {
return $(e.currentTarget).closest('.notification-dropdown').replaceWith(data.html); return $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html);
} else { } else {
return new Flash('Failed to save new settings', 'alert'); return new Flash('Failed to save new settings', 'alert');
} }
......
/* global Terminal */
(() => {
class GLTerminal {
constructor(options) {
this.options = options || {};
this.options.cursorBlink = options.cursorBlink || true;
this.options.screenKeys = options.screenKeys || true;
this.container = document.querySelector(options.selector);
this.setSocketUrl();
this.createTerminal();
$(window).off('resize.terminal').on('resize.terminal', () => {
this.terminal.fit();
});
}
setSocketUrl() {
const { protocol, hostname, port } = window.location;
const wsProtocol = protocol === 'https:' ? 'wss://' : 'ws://';
const path = this.container.dataset.projectPath;
this.socketUrl = `${wsProtocol}${hostname}:${port}${path}`;
}
createTerminal() {
this.terminal = new Terminal(this.options);
this.socket = new WebSocket(this.socketUrl, ['terminal.gitlab.com']);
this.socket.binaryType = 'arraybuffer';
this.terminal.open(this.container);
this.socket.onopen = () => { this.runTerminal(); };
this.socket.onerror = () => { this.handleSocketFailure(); };
}
runTerminal() {
const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder('utf-8');
this.terminal.on('data', (data) => {
this.socket.send(encoder.encode(data));
});
this.socket.addEventListener('message', (ev) => {
this.terminal.write(decoder.decode(ev.data));
});
this.isTerminalInitialized = true;
this.terminal.fit();
}
handleSocketFailure() {
this.terminal.write('\r\nConnection failure');
}
}
window.gl = window.gl || {};
gl.Terminal = GLTerminal;
})();
//= require xterm/xterm.js
//= require xterm/fit.js
//= require ./terminal.js
$(() => new gl.Terminal({ selector: '#terminal' }));
...@@ -89,7 +89,8 @@ ...@@ -89,7 +89,8 @@
U2FAuthenticate.prototype.renderError = function(error) { U2FAuthenticate.prototype.renderError = function(error) {
this.renderTemplate('error', { this.renderTemplate('error', {
error_message: error.message() error_message: error.message(),
error_code: error.errorCode
}); });
return this.container.find('#js-u2f-try-again').on('click', this.renderSetup); return this.container.find('#js-u2f-try-again').on('click', this.renderSetup);
}; };
......
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
this.errorCode = errorCode; this.errorCode = errorCode;
this.message = bind(this.message, this); this.message = bind(this.message, this);
this.httpsDisabled = window.location.protocol !== 'https:'; this.httpsDisabled = window.location.protocol !== 'https:';
console.error("U2F Error Code: " + this.errorCode);
} }
U2FError.prototype.message = function() { U2FError.prototype.message = function() {
......
...@@ -76,7 +76,8 @@ ...@@ -76,7 +76,8 @@
U2FRegister.prototype.renderError = function(error) { U2FRegister.prototype.renderError = function(error) {
this.renderTemplate('error', { this.renderTemplate('error', {
error_message: error.message() error_message: error.message(),
error_code: error.errorCode
}); });
return this.container.find('#js-u2f-try-again').on('click', this.renderSetup); return this.container.find('#js-u2f-try-again').on('click', this.renderSetup);
}; };
......
...@@ -64,7 +64,7 @@ ...@@ -64,7 +64,7 @@
&.s32 { font-size: 20px; line-height: 30px; } &.s32 { font-size: 20px; line-height: 30px; }
&.s40 { font-size: 16px; line-height: 38px; } &.s40 { font-size: 16px; line-height: 38px; }
&.s60 { font-size: 32px; line-height: 58px; } &.s60 { font-size: 32px; line-height: 58px; }
&.s70 { font-size: 34px; line-height: 68px; } &.s70 { font-size: 34px; line-height: 70px; }
&.s90 { font-size: 36px; line-height: 88px; } &.s90 { font-size: 36px; line-height: 88px; }
&.s110 { font-size: 40px; line-height: 108px; font-weight: 300; } &.s110 { font-size: 40px; line-height: 108px; font-weight: 300; }
&.s140 { font-size: 72px; line-height: 138px; } &.s140 { font-size: 72px; line-height: 138px; }
......
...@@ -230,6 +230,13 @@ ...@@ -230,6 +230,13 @@
} }
} }
.btn-terminal {
svg {
height: 14px;
width: 18px;
}
}
.btn-lg { .btn-lg {
padding: 12px 20px; padding: 12px 20px;
} }
......
...@@ -98,7 +98,7 @@ ...@@ -98,7 +98,7 @@
@extend .dropdown-toggle; @extend .dropdown-toggle;
padding-right: 20px; padding-right: 20px;
position: relative; position: relative;
width: 160px; width: 163px;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
......
...@@ -96,6 +96,10 @@ label { ...@@ -96,6 +96,10 @@ label {
code { code {
line-height: 1.8; line-height: 1.8;
} }
img {
margin-right: $gl-padding;
}
} }
@media(max-width: $screen-xs-max) { @media(max-width: $screen-xs-max) {
......
...@@ -50,3 +50,11 @@ ...@@ -50,3 +50,11 @@
fill: $gray-darkest; fill: $gray-darkest;
} }
} }
.ci-status-icon-manual {
color: $gl-text-color;
svg {
fill: $gl-text-color;
}
}
...@@ -32,6 +32,43 @@ body { ...@@ -32,6 +32,43 @@ body {
} }
} }
.alert-wrapper {
.alert {
margin-bottom: 0;
&:last-child {
margin-bottom: $gl-padding;
}
}
/* Stripe the background colors so that adjacent alert-warnings are distinct from one another */
.alert-warning {
transition: background-color 0.15s, border-color 0.15s;
background-color: lighten($gl-warning, 4%);
border-color: lighten($gl-warning, 4%);
}
.alert-warning + .alert-warning {
background-color: $gl-warning;
border-color: $gl-warning;
}
.alert-warning + .alert-warning + .alert-warning {
background-color: darken($gl-warning, 4%);
border-color: darken($gl-warning, 4%);
}
.alert-warning + .alert-warning + .alert-warning + .alert-warning {
background-color: darken($gl-warning, 8%);
border-color: darken($gl-warning, 8%);
}
.alert-warning:only-of-type {
background-color: $gl-warning;
border-color: $gl-warning;
}
}
/* The following prevents side effects related to iOS Safari's implementation of -webkit-overflow-scrolling: touch, /* The following prevents side effects related to iOS Safari's implementation of -webkit-overflow-scrolling: touch,
which is applied to the body by jquery.nicescroling plugin to force hardware acceleration for momentum scrolling. Side which is applied to the body by jquery.nicescroling plugin to force hardware acceleration for momentum scrolling. Side
......
...@@ -54,7 +54,7 @@ ...@@ -54,7 +54,7 @@
} }
// Display Star and Fork buttons without counters on mobile. // Display Star and Fork buttons without counters on mobile.
.project-action-buttons { .project-repo-buttons {
display: block; display: block;
.count-buttons .btn { .count-buttons .btn {
......
...@@ -57,7 +57,6 @@ ...@@ -57,7 +57,6 @@
} }
.ci-status-link { .ci-status-link {
svg { svg {
position: relative; position: relative;
top: 2px; top: 2px;
......
...@@ -18,6 +18,20 @@ ...@@ -18,6 +18,20 @@
margin-top: -2px; margin-top: -2px;
margin-left: 5px; margin-left: 5px;
} }
&.split {
display: flex;
align-items: center;
}
.left {
flex: 1 1 auto;
}
.right {
flex: 0 0 auto;
text-align: right;
}
} }
.panel-body { .panel-body {
......
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
@import "bootstrap/alerts"; @import "bootstrap/alerts";
// @import "bootstrap/progress-bars"; // @import "bootstrap/progress-bars";
@import "bootstrap/list-group"; @import "bootstrap/list-group";
// @import "bootstrap/wells"; @import "bootstrap/wells";
@import "bootstrap/close"; @import "bootstrap/close";
@import "bootstrap/panels"; @import "bootstrap/panels";
......
...@@ -24,6 +24,7 @@ $gray-lightest: #fdfdfd; ...@@ -24,6 +24,7 @@ $gray-lightest: #fdfdfd;
$gray-light: #fafafa; $gray-light: #fafafa;
$gray-lighter: #f9f9f9; $gray-lighter: #f9f9f9;
$gray-normal: #f5f5f5; $gray-normal: #f5f5f5;
$gray-dark: darken($gray-light, $darken-dark-factor);
$gray-darker: #eee; $gray-darker: #eee;
$gray-darkest: #c4c4c4; $gray-darkest: #c4c4c4;
...@@ -438,7 +439,7 @@ $jq-ui-default-color: #777; ...@@ -438,7 +439,7 @@ $jq-ui-default-color: #777;
$label-gray-bg: #f8fafc; $label-gray-bg: #f8fafc;
$label-inverse-bg: #333; $label-inverse-bg: #333;
$label-remove-border: rgba(0, 0, 0, .1); $label-remove-border: rgba(0, 0, 0, .1);
$label-border-radius: 14px; $label-border-radius: 100px;
/* /*
* Lint * Lint
...@@ -474,7 +475,6 @@ $project-option-descr-color: #54565b; ...@@ -474,7 +475,6 @@ $project-option-descr-color: #54565b;
$project-breadcrumb-color: #999; $project-breadcrumb-color: #999;
$project-private-forks-notice-odd: #2aa056; $project-private-forks-notice-odd: #2aa056;
$project-network-controls-color: #888; $project-network-controls-color: #888;
$project-limit-message-bg: #f28d35;
/* /*
* Runners * Runners
...@@ -524,3 +524,9 @@ $body-text-shadow: rgba(255,255,255,0.01); ...@@ -524,3 +524,9 @@ $body-text-shadow: rgba(255,255,255,0.01);
*/ */
$ui-dev-kit-example-color: #bbb; $ui-dev-kit-example-color: #bbb;
$ui-dev-kit-example-border: #ddd; $ui-dev-kit-example-border: #ddd;
/*
Pipeline Graph
*/
$stage-hover-bg: #eaf3fc;
$stage-hover-border: #d1e7fc;
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
padding: $gl-padding-top 0; padding: $gl-padding-top 0;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
color: $gl-text-color-dark; color: $gl-text-color-dark;
font-size: 16px;
line-height: 34px; line-height: 34px;
.author { .author {
......
...@@ -75,7 +75,8 @@ ...@@ -75,7 +75,8 @@
.soft-wrap-toggle, .soft-wrap-toggle,
.license-selector, .license-selector,
.gitignore-selector, .gitignore-selector,
.gitlab-ci-yml-selector { .gitlab-ci-yml-selector,
.dockerfile-selector {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
font-family: $regular_font; font-family: $regular_font;
...@@ -105,7 +106,8 @@ ...@@ -105,7 +106,8 @@
.gitignore-selector, .gitignore-selector,
.license-selector, .license-selector,
.gitlab-ci-yml-selector { .gitlab-ci-yml-selector,
.dockerfile-selector {
.dropdown { .dropdown {
line-height: 21px; line-height: 21px;
} }
......
...@@ -30,19 +30,25 @@ ...@@ -30,19 +30,25 @@
display: table-cell; display: table-cell;
} }
.environments-name,
.environments-commit, .environments-commit,
.environments-actions { .environments-actions {
width: 20%; width: 20%;
} }
.environments-deploy,
.environments-build,
.environments-date { .environments-date {
width: 10%; width: 10%;
} }
.environments-name { .environments-deploy,
width: 30%; .environments-build {
width: 15%;
}
.environment-name,
.environments-build-cell,
.deployment-column {
word-break: break-all;
} }
.deployment-column { .deployment-column {
......
...@@ -27,12 +27,6 @@ ...@@ -27,12 +27,6 @@
} }
} }
.group-buttons {
.notification-dropdown {
display: inline-block;
}
}
.groups-header { .groups-header {
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.nav-links { .nav-links {
......
...@@ -98,7 +98,7 @@ ...@@ -98,7 +98,7 @@
} }
.label { .label {
padding: 9px; padding: 8px 9px 9px;
font-size: 14px; font-size: 14px;
} }
} }
...@@ -201,6 +201,8 @@ ...@@ -201,6 +201,8 @@
.label-remove { .label-remove {
border-left: 1px solid $label-remove-border; border-left: 1px solid $label-remove-border;
z-index: 3; z-index: 3;
border-radius: $label-border-radius;
padding: 6px 10px 6px 9px;
} }
.btn { .btn {
......
...@@ -78,6 +78,21 @@ ...@@ -78,6 +78,21 @@
float: right; float: right;
} }
.dropdown {
width: 100%;
margin-top: 5px;
.dropdown-menu-toggle {
vertical-align: middle;
width: 100%;
}
@media (min-width: $screen-sm-min) {
margin-top: 0;
width: 155px;
}
}
.form-control { .form-control {
width: 100%; width: 100%;
padding-right: 35px; padding-right: 35px;
...@@ -85,12 +100,22 @@ ...@@ -85,12 +100,22 @@
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
width: 350px; width: 350px;
} }
&.input-short {
@media (min-width: $screen-md-min) {
width: 170px;
}
@media (min-width: $screen-lg-min) {
width: 210px;
}
}
} }
} }
.member-search-btn { .member-search-btn {
position: absolute; position: absolute;
right: 0; right: 4px;
top: 0; top: 0;
height: 35px; height: 35px;
padding-left: 10px; padding-left: 10px;
...@@ -99,4 +124,8 @@ ...@@ -99,4 +124,8 @@
background: transparent; background: transparent;
border: 0; border: 0;
outline: 0; outline: 0;
@media (min-width: $screen-sm-min) {
right: 160px;
}
} }
This diff is collapsed.
...@@ -262,3 +262,13 @@ table.u2f-registrations { ...@@ -262,3 +262,13 @@ table.u2f-registrations {
border-right: solid 1px transparent; border-right: solid 1px transparent;
} }
} }
.oauth-application-show {
.scope-name {
font-weight: 600;
}
.scopes-list {
padding-left: 18px;
}
}
\ No newline at end of file
...@@ -6,12 +6,6 @@ ...@@ -6,12 +6,6 @@
} }
} }
.no-ssh-key-message,
.project-limit-message {
background-color: $project-limit-message-bg;
margin-bottom: 0;
}
.new_project, .new_project,
.edit-project { .edit-project {
...@@ -99,7 +93,6 @@ ...@@ -99,7 +93,6 @@
.group-avatar { .group-avatar {
float: none; float: none;
margin: 0 auto; margin: 0 auto;
border: none;
&.identicon { &.identicon {
border-radius: 50%; border-radius: 50%;
...@@ -151,8 +144,6 @@ ...@@ -151,8 +144,6 @@
.project-repo-buttons, .project-repo-buttons,
.group-buttons { .group-buttons {
margin-top: 15px;
.btn { .btn {
@include btn-gray; @include btn-gray;
padding: 3px 10px; padding: 3px 10px;
...@@ -181,11 +172,9 @@ ...@@ -181,11 +172,9 @@
} }
} }
.download-button, .project-action-button {
.dropdown-toggle, margin: 15px 5px 0;
.notification-dropdown, vertical-align: top;
.project-dropdown {
margin-left: 10px;
} }
.notification-dropdown .dropdown-menu { .notification-dropdown .dropdown-menu {
...@@ -201,13 +190,15 @@ ...@@ -201,13 +190,15 @@
.count-buttons { .count-buttons {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
margin-top: 15px;
} }
.project-clone-holder { .project-clone-holder {
display: inline-block; display: inline-block;
margin: 15px 5px 0 0;
input { input {
height: 29px; height: 28px;
} }
} }
...@@ -261,7 +252,7 @@ ...@@ -261,7 +252,7 @@
line-height: 13px; line-height: 13px;
padding: $gl-vert-padding $gl-padding; padding: $gl-vert-padding $gl-padding;
letter-spacing: .4px; letter-spacing: .4px;
padding: 7px 14px; padding: 6px 14px;
text-align: center; text-align: center;
vertical-align: middle; vertical-align: middle;
touch-action: manipulation; touch-action: manipulation;
...@@ -498,6 +489,7 @@ a.deploy-project-label { ...@@ -498,6 +489,7 @@ a.deploy-project-label {
.project-stats { .project-stats {
font-size: 0; font-size: 0;
text-align: center;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
.nav { .nav {
......
...@@ -15,8 +15,7 @@ ...@@ -15,8 +15,7 @@
height: 13px; height: 13px;
width: 13px; width: 13px;
position: relative; position: relative;
top: 1px; top: 2px;
margin-right: 3px;
overflow: visible; overflow: visible;
} }
...@@ -25,7 +24,7 @@ ...@@ -25,7 +24,7 @@
border-color: $gl-danger; border-color: $gl-danger;
&:not(span):hover { &:not(span):hover {
background-color: rgba( $gl-danger, .07); background-color: rgba($gl-danger, .07);
} }
svg { svg {
...@@ -39,7 +38,7 @@ ...@@ -39,7 +38,7 @@
border-color: $gl-success; border-color: $gl-success;
&:not(span):hover { &:not(span):hover {
background-color: rgba( $gl-success, .07); background-color: rgba($gl-success, .07);
} }
svg { svg {
...@@ -66,7 +65,7 @@ ...@@ -66,7 +65,7 @@
border-color: $gl-info; border-color: $gl-info;
&:not(span):hover { &:not(span):hover {
background-color: rgba( $gl-info, .07); background-color: rgba($gl-info, .07);
} }
svg { svg {
...@@ -80,7 +79,7 @@ ...@@ -80,7 +79,7 @@
border-color: $gl-gray; border-color: $gl-gray;
&:not(span):hover { &:not(span):hover {
background-color: rgba( $gl-gray, .07); background-color: rgba($gl-gray, .07);
} }
svg { svg {
...@@ -93,7 +92,7 @@ ...@@ -93,7 +92,7 @@
border-color: $gl-warning; border-color: $gl-warning;
&:not(span):hover { &:not(span):hover {
background-color: rgba( $gl-warning, .07); background-color: rgba($gl-warning, .07);
} }
svg { svg {
...@@ -106,7 +105,7 @@ ...@@ -106,7 +105,7 @@
border-color: $blue-normal; border-color: $blue-normal;
&:not(span):hover { &:not(span):hover {
background-color: rgba( $blue-normal, .07); background-color: rgba($blue-normal, .07);
} }
svg { svg {
...@@ -120,13 +119,26 @@ ...@@ -120,13 +119,26 @@
border-color: $gl-gray-light; border-color: $gl-gray-light;
&:not(span):hover { &:not(span):hover {
background-color: rgba( $gl-gray-light, .07); background-color: rgba($gl-gray-light, .07);
} }
svg { svg {
fill: $gl-gray-light; fill: $gl-gray-light;
} }
} }
&.ci-manual {
color: $gl-text-color;
border-color: $gl-text-color;
&:not(span):hover {
background-color: rgba($gl-text-color, .07);
}
svg {
fill: $gl-text-color;
}
}
} }
} }
......
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
.add-to-tree { .add-to-tree {
vertical-align: top; vertical-align: top;
padding: 6px 10px;
} }
.tree-table { .tree-table {
...@@ -172,7 +173,7 @@ ...@@ -172,7 +173,7 @@
position: relative; position: relative;
z-index: 2; z-index: 2;
.download-button { .project-action-button {
margin-left: $btn-side-margin; margin-left: $btn-side-margin;
} }
} }
class Admin::ApplicationsController < Admin::ApplicationController class Admin::ApplicationsController < Admin::ApplicationController
include OauthApplications
before_action :set_application, only: [:show, :edit, :update, :destroy] before_action :set_application, only: [:show, :edit, :update, :destroy]
before_action :load_scopes, only: [:new, :edit]
def index def index
@applications = Doorkeeper::Application.where("owner_id IS NULL") @applications = Doorkeeper::Application.where("owner_id IS NULL")
...@@ -47,6 +50,6 @@ class Admin::ApplicationsController < Admin::ApplicationController ...@@ -47,6 +50,6 @@ class Admin::ApplicationsController < Admin::ApplicationController
# Only allow a trusted parameter "white list" through. # Only allow a trusted parameter "white list" through.
def application_params def application_params
params[:doorkeeper_application].permit(:name, :redirect_uri) params[:doorkeeper_application].permit(:name, :redirect_uri, :scopes)
end end
end end
...@@ -25,7 +25,7 @@ class ApplicationController < ActionController::Base ...@@ -25,7 +25,7 @@ class ApplicationController < ActionController::Base
protect_from_forgery with: :exception protect_from_forgery with: :exception
helper_method :can?, :current_application_settings helper_method :can?, :current_application_settings
helper_method :import_sources_enabled?, :github_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled? helper_method :import_sources_enabled?, :github_import_enabled?, :gitea_import_enabled?, :github_import_configured?, :gitlab_import_enabled?, :gitlab_import_configured?, :bitbucket_import_enabled?, :bitbucket_import_configured?, :google_code_import_enabled?, :fogbugz_import_enabled?, :git_import_enabled?, :gitlab_project_import_enabled?
rescue_from Encoding::CompatibilityError do |exception| rescue_from Encoding::CompatibilityError do |exception|
log_exception(exception) log_exception(exception)
...@@ -245,6 +245,10 @@ class ApplicationController < ActionController::Base ...@@ -245,6 +245,10 @@ class ApplicationController < ActionController::Base
current_application_settings.import_sources.include?('github') current_application_settings.import_sources.include?('github')
end end
def gitea_import_enabled?
current_application_settings.import_sources.include?('gitea')
end
def github_import_configured? def github_import_configured?
Gitlab::OAuth::Provider.enabled?(:github) Gitlab::OAuth::Provider.enabled?(:github)
end end
...@@ -262,7 +266,7 @@ class ApplicationController < ActionController::Base ...@@ -262,7 +266,7 @@ class ApplicationController < ActionController::Base
end end
def bitbucket_import_configured? def bitbucket_import_configured?
Gitlab::OAuth::Provider.enabled?(:bitbucket) && Gitlab::BitbucketImport.public_key.present? Gitlab::OAuth::Provider.enabled?(:bitbucket)
end end
def google_code_import_enabled? def google_code_import_enabled?
......
module OauthApplications
extend ActiveSupport::Concern
included do
before_action :prepare_scopes, only: [:create, :update]
end
def prepare_scopes
scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil)
if scopes
params[:doorkeeper_application][:scopes] = scopes.join(' ')
end
end
def load_scopes
@scopes = Doorkeeper.configuration.scopes
end
end
class Groups::GroupMembersController < Groups::ApplicationController class Groups::GroupMembersController < Groups::ApplicationController
include MembershipActions include MembershipActions
include SortingHelper
# Authorize # Authorize
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access]
def index def index
@sort = params[:sort].presence || sort_value_name
@project = @group.projects.find(params[:project_id]) if params[:project_id] @project = @group.projects.find(params[:project_id]) if params[:project_id]
@members = @group.group_members @members = @group.group_members
@members = @members.non_invite unless can?(current_user, :admin_group, @group) @members = @members.non_invite unless can?(current_user, :admin_group, @group)
@members = @members.search(params[:search]) if params[:search].present?
@members = @members.sort(@sort)
@members = @members.page(params[:page]).per(50)
if params[:search].present?
users = @group.users.search(params[:search]).to_a
@members = @members.where(user_id: users)
end
@members = @members.order('access_level DESC').page(params[:page]).per(50)
@requesters = AccessRequestsFinder.new(@group).execute(current_user) @requesters = AccessRequestsFinder.new(@group).execute(current_user)
@group_member = @group.group_members.new @group_member = @group.group_members.new
......
...@@ -2,50 +2,57 @@ class Import::BitbucketController < Import::BaseController ...@@ -2,50 +2,57 @@ class Import::BitbucketController < Import::BaseController
before_action :verify_bitbucket_import_enabled before_action :verify_bitbucket_import_enabled
before_action :bitbucket_auth, except: :callback before_action :bitbucket_auth, except: :callback
rescue_from OAuth::Error, with: :bitbucket_unauthorized rescue_from OAuth2::Error, with: :bitbucket_unauthorized
rescue_from Gitlab::BitbucketImport::Client::Unauthorized, with: :bitbucket_unauthorized rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized
def callback def callback
request_token = session.delete(:oauth_request_token) response = client.auth_code.get_token(params[:code], redirect_uri: callback_import_bitbucket_url)
raise "Session expired!" if request_token.nil?
request_token.symbolize_keys! session[:bitbucket_token] = response.token
session[:bitbucket_expires_at] = response.expires_at
access_token = client.get_token(request_token, params[:oauth_verifier], callback_import_bitbucket_url) session[:bitbucket_expires_in] = response.expires_in
session[:bitbucket_refresh_token] = response.refresh_token
session[:bitbucket_access_token] = access_token.token
session[:bitbucket_access_token_secret] = access_token.secret
redirect_to status_import_bitbucket_url redirect_to status_import_bitbucket_url
end end
def status def status
@repos = client.projects bitbucket_client = Bitbucket::Client.new(credentials)
@incompatible_repos = client.incompatible_projects repos = bitbucket_client.repos
@repos, @incompatible_repos = repos.partition { |repo| repo.valid? }
@already_added_projects = current_user.created_projects.where(import_type: "bitbucket") @already_added_projects = current_user.created_projects.where(import_type: 'bitbucket')
already_added_projects_names = @already_added_projects.pluck(:import_source) already_added_projects_names = @already_added_projects.pluck(:import_source)
@repos.to_a.reject!{ |repo| already_added_projects_names.include? "#{repo["owner"]}/#{repo["slug"]}" } @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.full_name) }
end end
def jobs def jobs
jobs = current_user.created_projects.where(import_type: "bitbucket").to_json(only: [:id, :import_status]) render json: current_user.created_projects
render json: jobs .where(import_type: 'bitbucket')
.to_json(only: [:id, :import_status])
end end
def create def create
bitbucket_client = Bitbucket::Client.new(credentials)
@repo_id = params[:repo_id].to_s @repo_id = params[:repo_id].to_s
repo = client.project(@repo_id.gsub('___', '/')) name = @repo_id.gsub('___', '/')
@project_name = repo['slug'] repo = bitbucket_client.repo(name)
@target_namespace = find_or_create_namespace(repo['owner'], client.user['user']['username']) @project_name = params[:new_name].presence || repo.name
unless Gitlab::BitbucketImport::KeyAdder.new(repo, current_user, access_params).execute repo_owner = repo.owner
render 'deploy_key' and return repo_owner = current_user.username if repo_owner == bitbucket_client.user.username
end @target_namespace = params[:new_namespace].presence || repo_owner
namespace = find_or_create_namespace(@target_namespace, current_user)
if current_user.can?(:create_projects, @target_namespace) if current_user.can?(:create_projects, namespace)
@project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @target_namespace, current_user, access_params).execute # The token in a session can be expired, we need to get most recent one because
# Bitbucket::Connection class refreshes it.
session[:bitbucket_token] = bitbucket_client.connection.token
@project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @project_name, namespace, current_user, credentials).execute
else else
render 'unauthorized' render 'unauthorized'
end end
...@@ -54,8 +61,15 @@ class Import::BitbucketController < Import::BaseController ...@@ -54,8 +61,15 @@ class Import::BitbucketController < Import::BaseController
private private
def client def client
@client ||= Gitlab::BitbucketImport::Client.new(session[:bitbucket_access_token], @client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options)
session[:bitbucket_access_token_secret]) end
def provider
Gitlab::OAuth::Provider.config_for('bitbucket')
end
def options
OmniAuth::Strategies::Bitbucket.default_options[:client_options].deep_symbolize_keys
end end
def verify_bitbucket_import_enabled def verify_bitbucket_import_enabled
...@@ -63,26 +77,23 @@ class Import::BitbucketController < Import::BaseController ...@@ -63,26 +77,23 @@ class Import::BitbucketController < Import::BaseController
end end
def bitbucket_auth def bitbucket_auth
if session[:bitbucket_access_token].blank? go_to_bitbucket_for_permissions if session[:bitbucket_token].blank?
go_to_bitbucket_for_permissions
end
end end
def go_to_bitbucket_for_permissions def go_to_bitbucket_for_permissions
request_token = client.request_token(callback_import_bitbucket_url) redirect_to client.auth_code.authorize_url(redirect_uri: callback_import_bitbucket_url)
session[:oauth_request_token] = request_token
redirect_to client.authorize_url(request_token, callback_import_bitbucket_url)
end end
def bitbucket_unauthorized def bitbucket_unauthorized
go_to_bitbucket_for_permissions go_to_bitbucket_for_permissions
end end
def access_params def credentials
{ {
bitbucket_access_token: session[:bitbucket_access_token], token: session[:bitbucket_token],
bitbucket_access_token_secret: session[:bitbucket_access_token_secret] expires_at: session[:bitbucket_expires_at],
expires_in: session[:bitbucket_expires_in],
refresh_token: session[:bitbucket_refresh_token]
} }
end end
end end
class Import::GiteaController < Import::GithubController
def new
if session[access_token_key].present? && session[host_key].present?
redirect_to status_import_url
end
end
def personal_access_token
session[host_key] = params[host_key]
super
end
def status
@gitea_host_url = session[host_key]
super
end
private
def host_key
:"#{provider}_host_url"
end
# Overriden methods
def provider
:gitea
end
# Gitea is not yet an OAuth provider
# See https://github.com/go-gitea/gitea/issues/27
def logged_in_with_provider?
false
end
def provider_auth
if session[access_token_key].blank? || session[host_key].blank?
redirect_to new_import_gitea_url,
alert: 'You need to specify both an Access Token and a Host URL.'
end
end
def client_options
{ host: session[host_key], api_version: 'v1' }
end
end
class Import::GithubController < Import::BaseController class Import::GithubController < Import::BaseController
before_action :verify_github_import_enabled before_action :verify_import_enabled
before_action :github_auth, only: [:status, :jobs, :create] before_action :provider_auth, only: [:status, :jobs, :create]
rescue_from Octokit::Unauthorized, with: :github_unauthorized rescue_from Octokit::Unauthorized, with: :provider_unauthorized
helper_method :logged_in_with_github?
def new def new
if logged_in_with_github? if logged_in_with_provider?
go_to_github_for_permissions go_to_provider_for_permissions
elsif session[:github_access_token] elsif session[access_token_key]
redirect_to status_import_github_url redirect_to status_import_url
end end
end end
def callback def callback
session[:github_access_token] = client.get_token(params[:code]) session[access_token_key] = client.get_token(params[:code])
redirect_to status_import_github_url redirect_to status_import_url
end end
def personal_access_token def personal_access_token
session[:github_access_token] = params[:personal_access_token] session[access_token_key] = params[:personal_access_token]
redirect_to status_import_github_url redirect_to status_import_url
end end
def status def status
@repos = client.repos @repos = client.repos
@already_added_projects = current_user.created_projects.where(import_type: "github") @already_added_projects = current_user.created_projects.where(import_type: provider)
already_added_projects_names = @already_added_projects.pluck(:import_source) already_added_projects_names = @already_added_projects.pluck(:import_source)
@repos.reject!{ |repo| already_added_projects_names.include? repo.full_name } @repos.reject! { |repo| already_added_projects_names.include? repo.full_name }
end end
def jobs def jobs
jobs = current_user.created_projects.where(import_type: "github").to_json(only: [:id, :import_status]) jobs = current_user.created_projects.where(import_type: provider).to_json(only: [:id, :import_status])
render json: jobs render json: jobs
end end
...@@ -44,8 +42,8 @@ class Import::GithubController < Import::BaseController ...@@ -44,8 +42,8 @@ class Import::GithubController < Import::BaseController
namespace_path = params[:target_namespace].presence || current_user.namespace_path namespace_path = params[:target_namespace].presence || current_user.namespace_path
@target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path) @target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path)
if current_user.can?(:create_projects, @target_namespace) if can?(current_user, :create_projects, @target_namespace)
@project = Gitlab::GithubImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, access_params).execute @project = Gitlab::GithubImport::ProjectCreator.new(repo, @project_name, @target_namespace, current_user, access_params, type: provider).execute
else else
render 'unauthorized' render 'unauthorized'
end end
...@@ -54,34 +52,63 @@ class Import::GithubController < Import::BaseController ...@@ -54,34 +52,63 @@ class Import::GithubController < Import::BaseController
private private
def client def client
@client ||= Gitlab::GithubImport::Client.new(session[:github_access_token]) @client ||= Gitlab::GithubImport::Client.new(session[access_token_key], client_options)
end end
def verify_github_import_enabled def verify_import_enabled
render_404 unless github_import_enabled? render_404 unless import_enabled?
end end
def github_auth def go_to_provider_for_permissions
if session[:github_access_token].blank? redirect_to client.authorize_url(callback_import_url)
go_to_github_for_permissions
end
end end
def go_to_github_for_permissions def import_enabled?
redirect_to client.authorize_url(callback_import_github_url) __send__("#{provider}_import_enabled?")
end end
def github_unauthorized def new_import_url
session[:github_access_token] = nil public_send("new_import_#{provider}_url")
redirect_to new_import_github_url,
alert: 'Access denied to your GitHub account.'
end end
def logged_in_with_github? def status_import_url
current_user.identities.exists?(provider: 'github') public_send("status_import_#{provider}_url")
end
def callback_import_url
public_send("callback_import_#{provider}_url")
end
def provider_unauthorized
session[access_token_key] = nil
redirect_to new_import_url,
alert: "Access denied to your #{Gitlab::ImportSources.title(provider.to_s)} account."
end
def access_token_key
:"#{provider}_access_token"
end end
def access_params def access_params
{ github_access_token: session[:github_access_token] } { github_access_token: session[access_token_key] }
end
# The following methods are overriden in subclasses
def provider
:github
end
def logged_in_with_provider?
current_user.identities.exists?(provider: provider)
end
def provider_auth
if session[access_token_key].blank?
go_to_provider_for_permissions
end
end
def client_options
{}
end end
end end
...@@ -26,7 +26,7 @@ class JwtController < ApplicationController ...@@ -26,7 +26,7 @@ class JwtController < ApplicationController
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip) @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
render_unauthorized unless @authentication_result.success? && render_unauthorized unless @authentication_result.success? &&
(@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User)) (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User))
end end
rescue Gitlab::Auth::MissingPersonalTokenError rescue Gitlab::Auth::MissingPersonalTokenError
render_missing_personal_token render_missing_personal_token
......
...@@ -2,10 +2,12 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController ...@@ -2,10 +2,12 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
include Gitlab::CurrentSettings include Gitlab::CurrentSettings
include Gitlab::GonHelper include Gitlab::GonHelper
include PageLayoutHelper include PageLayoutHelper
include OauthApplications
before_action :verify_user_oauth_applications_enabled before_action :verify_user_oauth_applications_enabled
before_action :authenticate_user! before_action :authenticate_user!
before_action :add_gon_variables before_action :add_gon_variables
before_action :load_scopes, only: [:index, :create, :edit]
layout 'profile' layout 'profile'
......
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
before_action :load_personal_access_tokens, only: :index
def index def index
@personal_access_token = current_user.personal_access_tokens.build set_index_vars
end end
def create def create
...@@ -12,7 +10,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController ...@@ -12,7 +10,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
flash[:personal_access_token] = @personal_access_token.token flash[:personal_access_token] = @personal_access_token.token
redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created." redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created."
else else
load_personal_access_tokens set_index_vars
render :index render :index
end end
end end
...@@ -32,10 +30,12 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController ...@@ -32,10 +30,12 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
private private
def personal_access_token_params def personal_access_token_params
params.require(:personal_access_token).permit(:name, :expires_at) params.require(:personal_access_token).permit(:name, :expires_at, scopes: [])
end end
def load_personal_access_tokens def set_index_vars
@personal_access_token ||= current_user.personal_access_tokens.build
@scopes = Gitlab::Auth::SCOPES
@active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at)
@inactive_personal_access_tokens = current_user.personal_access_tokens.inactive @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive
end end
......
class Projects::AutocompleteSourcesController < Projects::ApplicationController
before_action :load_autocomplete_service, except: [:emojis, :members]
def emojis
render json: Gitlab::AwardEmoji.urls
end
def members
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
end
def issues
render json: @autocomplete_service.issues
end
def merge_requests
render json: @autocomplete_service.merge_requests
end
def labels
render json: @autocomplete_service.labels
end
def milestones
render json: @autocomplete_service.milestones
end
def commands
render json: @autocomplete_service.commands(noteable, params[:type])
end
private
def load_autocomplete_service
@autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user)
end
def noteable
case params[:type]
when 'Issue'
IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
when 'MergeRequest'
MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
when 'Commit'
@project.commit(params[:type_id])
end
end
end
...@@ -8,6 +8,9 @@ class Projects::BlameController < Projects::ApplicationController ...@@ -8,6 +8,9 @@ class Projects::BlameController < Projects::ApplicationController
def show def show
@blob = @repository.blob_at(@commit.id, @path) @blob = @repository.blob_at(@commit.id, @path)
return render_404 unless @blob
@blame_groups = Gitlab::Blame.new(@blob, @commit).groups @blame_groups = Gitlab::Blame.new(@blob, @commit).groups
end end
end end
...@@ -4,17 +4,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -4,17 +4,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_create_environment!, only: [:new, :create]
before_action :authorize_create_deployment!, only: [:stop] before_action :authorize_create_deployment!, only: [:stop]
before_action :authorize_update_environment!, only: [:edit, :update] before_action :authorize_update_environment!, only: [:edit, :update]
before_action :environment, only: [:show, :edit, :update, :stop] before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize]
before_action :verify_api_request!, only: :terminal_websocket_authorize
def index def index
@scope = params[:scope] @scope = params[:scope]
@environments = project.environments @environments = project.environments
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
render json: EnvironmentSerializer render json: EnvironmentSerializer
.new(project: @project) .new(project: @project, user: current_user)
.represent(@environments) .represent(@environments)
end end
end end
...@@ -56,8 +58,33 @@ class Projects::EnvironmentsController < Projects::ApplicationController ...@@ -56,8 +58,33 @@ class Projects::EnvironmentsController < Projects::ApplicationController
redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, new_action]) redirect_to polymorphic_path([project.namespace.becomes(Namespace), project, new_action])
end end
def terminal
# Currently, this acts as a hint to load the terminal details into the cache
# if they aren't there already. In the future, users will need these details
# to choose between terminals to connect to.
@terminals = environment.terminals
end
# GET .../terminal.ws : implemented in gitlab-workhorse
def terminal_websocket_authorize
# Just return the first terminal for now. If the list is in the process of
# being looked up, this may result in a 404 response, so the frontend
# should retry those errors
terminal = environment.terminals.try(:first)
if terminal
set_workhorse_internal_api_content_type
render json: Gitlab::Workhorse.terminal_websocket(terminal)
else
render text: 'Not found', status: 404
end
end
private private
def verify_api_request!
Gitlab::Workhorse.verify_api_request!(request.headers)
end
def environment_params def environment_params
params.require(:environment).permit(:name, :external_url) params.require(:environment).permit(:name, :external_url)
end end
......
...@@ -12,6 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -12,6 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController
.execute(scope: @scope) .execute(scope: @scope)
.page(params[:page]) .page(params[:page])
.per(30) .per(30)
.includes(project: :namespace)
@running_or_pending_count = PipelinesFinder @running_or_pending_count = PipelinesFinder
.new(project).execute(scope: 'running').count .new(project).execute(scope: 'running').count
...@@ -63,6 +64,15 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -63,6 +64,15 @@ class Projects::PipelinesController < Projects::ApplicationController
end end
end end
def stage
@stage = pipeline.stage(params[:stage])
return not_found unless @stage
respond_to do |format|
format.json { render json: { html: view_to_html_string('projects/pipelines/_stage') } }
end
end
def retry def retry
pipeline.retry_failed(current_user) pipeline.retry_failed(current_user)
......
class Projects::ProjectMembersController < Projects::ApplicationController class Projects::ProjectMembersController < Projects::ApplicationController
include MembershipActions include MembershipActions
include SortingHelper
# Authorize # Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
def index def index
@sort = params[:sort].presence || sort_value_name
@group_links = @project.project_group_links @group_links = @project.project_group_links
@project_members = @project.project_members @project_members = @project.project_members
...@@ -35,12 +37,13 @@ class Projects::ProjectMembersController < Projects::ApplicationController ...@@ -35,12 +37,13 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@group_links = @project.project_group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id)) @group_links = @project.project_group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id))
end end
wheres = ["id IN (#{@project_members.select(:id).to_sql})"] wheres = ["members.id IN (#{@project_members.select(:id).to_sql})"]
wheres << "id IN (#{group_members.select(:id).to_sql})" if group_members wheres << "members.id IN (#{group_members.select(:id).to_sql})" if group_members
@project_members = Member. @project_members = Member.
where(wheres.join(' OR ')). where(wheres.join(' OR ')).
order(access_level: :desc).page(params[:page]) sort(@sort).
page(params[:page])
@requesters = AccessRequestsFinder.new(@project).execute(current_user) @requesters = AccessRequestsFinder.new(@project).execute(current_user)
......
...@@ -127,39 +127,6 @@ class ProjectsController < Projects::ApplicationController ...@@ -127,39 +127,6 @@ class ProjectsController < Projects::ApplicationController
redirect_to edit_project_path(@project), alert: ex.message redirect_to edit_project_path(@project), alert: ex.message
end end
def autocomplete_sources
noteable =
case params[:type]
when 'Issue'
IssuesFinder.new(current_user, project_id: @project.id).
execute.find_by(iid: params[:type_id])
when 'MergeRequest'
MergeRequestsFinder.new(current_user, project_id: @project.id).
execute.find_by(iid: params[:type_id])
when 'Commit'
@project.commit(params[:type_id])
else
nil
end
autocomplete = ::Projects::AutocompleteService.new(@project, current_user)
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
@suggestions = {
emojis: Gitlab::AwardEmoji.urls,
issues: autocomplete.issues,
milestones: autocomplete.milestones,
mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels,
members: participants,
commands: autocomplete.commands(noteable, params[:type])
}
respond_to do |format|
format.json { render json: @suggestions }
end
end
def new_issue_address def new_issue_address
return render_404 unless Gitlab::IncomingEmail.supports_issue_creation? return render_404 unless Gitlab::IncomingEmail.supports_issue_creation?
......
...@@ -114,7 +114,7 @@ class SessionsController < Devise::SessionsController ...@@ -114,7 +114,7 @@ class SessionsController < Devise::SessionsController
def valid_otp_attempt?(user) def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) || user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt]) user.invalidate_otp_backup_code!(user_params[:otp_attempt])
end end
def log_audit_event(user, options = {}) def log_audit_event(user, options = {})
......
...@@ -191,6 +191,10 @@ module BlobHelper ...@@ -191,6 +191,10 @@ module BlobHelper
@gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names
end end
def dockerfile_names
@dockerfile_names ||= Gitlab::Template::DockerfileTemplate.dropdown_names
end
def blob_editor_paths def blob_editor_paths
{ {
'relative-url-root' => Rails.application.config.relative_url_root, 'relative-url-root' => Rails.application.config.relative_url_root,
......
...@@ -128,50 +128,11 @@ module CommitsHelper ...@@ -128,50 +128,11 @@ module CommitsHelper
end end
def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true) def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true)
return unless current_user commit_action_link('revert', commit, continue_to_path, btn_class: btn_class, has_tooltip: has_tooltip)
tooltip = "Revert this #{commit.change_type_title(current_user)} in a new merge request" if has_tooltip
if can_collaborate_with_project?
btn_class = "btn btn-warning btn-#{btn_class}" unless btn_class.nil?
link_to 'Revert', '#modal-revert-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}"
elsif can?(current_user, :fork_project, @project)
continue_params = {
to: continue_to_path,
notice: edit_in_new_fork_notice + ' Try to revert this commit again.',
notice_now: edit_in_new_fork_notice_now
}
fork_path = namespace_project_forks_path(@project.namespace, @project,
namespace_key: current_user.namespace.id,
continue: continue_params)
btn_class = "btn btn-grouped btn-warning" unless btn_class.nil?
link_to 'Revert', fork_path, class: btn_class, method: :post, 'data-toggle' => 'tooltip', 'data-container' => 'body', title: (tooltip if has_tooltip)
end
end end
def cherry_pick_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true) def cherry_pick_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true)
return unless current_user commit_action_link('cherry-pick', commit, continue_to_path, btn_class: btn_class, has_tooltip: has_tooltip)
tooltip = "Cherry-pick this #{commit.change_type_title(current_user)} in a new merge request"
if can_collaborate_with_project?
btn_class = "btn btn-default btn-#{btn_class}" unless btn_class.nil?
link_to 'Cherry-pick', '#modal-cherry-pick-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}"
elsif can?(current_user, :fork_project, @project)
continue_params = {
to: continue_to_path,
notice: edit_in_new_fork_notice + ' Try to cherry-pick this commit again.',
notice_now: edit_in_new_fork_notice_now
}
fork_path = namespace_project_forks_path(@project.namespace, @project,
namespace_key: current_user.namespace.id,
continue: continue_params)
btn_class = "btn btn-grouped btn-close" unless btn_class.nil?
link_to 'Cherry-pick', fork_path, class: "#{btn_class}", method: :post, 'data-toggle' => 'tooltip', 'data-container' => 'body', title: (tooltip if has_tooltip)
end
end end
protected protected
...@@ -211,6 +172,28 @@ module CommitsHelper ...@@ -211,6 +172,28 @@ module CommitsHelper
end end
end end
def commit_action_link(action, commit, continue_to_path, btn_class: nil, has_tooltip: true)
return unless current_user
tooltip = "#{action.capitalize} this #{commit.change_type_title(current_user)} in a new merge request" if has_tooltip
btn_class = "btn btn-#{btn_class}" unless btn_class.nil?
if can_collaborate_with_project?
link_to action.capitalize, "#modal-#{action}-commit", 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}"
elsif can?(current_user, :fork_project, @project)
continue_params = {
to: continue_to_path,
notice: "#{edit_in_new_fork_notice} Try to #{action} this commit again.",
notice_now: edit_in_new_fork_notice_now
}
fork_path = namespace_project_forks_path(@project.namespace, @project,
namespace_key: current_user.namespace.id,
continue: continue_params)
link_to action.capitalize, fork_path, class: btn_class, method: :post, 'data-toggle' => 'tooltip', 'data-container' => 'body', title: (tooltip if has_tooltip)
end
end
def view_file_btn(commit_sha, diff_new_path, project) def view_file_btn(commit_sha, diff_new_path, project)
link_to( link_to(
namespace_project_blob_path(project.namespace, project, namespace_project_blob_path(project.namespace, project,
......
...@@ -7,12 +7,12 @@ module FormHelper ...@@ -7,12 +7,12 @@ module FormHelper
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) << content_tag(:h4, headline) <<
content_tag(:ul) do content_tag(:ul) do
model.errors.full_messages. model.errors.full_messages.
map { |msg| content_tag(:li, msg) }. map { |msg| content_tag(:li, msg) }.
join. join.
html_safe html_safe
end end
end end
end end
end end
...@@ -4,8 +4,10 @@ module ImportHelper ...@@ -4,8 +4,10 @@ module ImportHelper
"#{namespace}/#{name}" "#{namespace}/#{name}"
end end
def github_project_link(path_with_namespace) def provider_project_link(provider, path_with_namespace)
link_to path_with_namespace, github_project_url(path_with_namespace), target: '_blank' url = __send__("#{provider}_project_url", path_with_namespace)
link_to path_with_namespace, url, target: '_blank'
end end
private private
...@@ -20,4 +22,8 @@ module ImportHelper ...@@ -20,4 +22,8 @@ module ImportHelper
provider = Gitlab.config.omniauth.providers.find { |p| p.name == 'github' } provider = Gitlab.config.omniauth.providers.find { |p| p.name == 'github' }
@github_url = provider.fetch('url', 'https://github.com') if provider @github_url = provider.fetch('url', 'https://github.com') if provider
end end
def gitea_project_url(path_with_namespace)
"#{@gitea_host_url.sub(%r{/+\z}, '')}/#{path_with_namespace}"
end
end end
...@@ -36,4 +36,12 @@ module MembersHelper ...@@ -36,4 +36,12 @@ module MembersHelper
"Are you sure you want to leave the " \ "Are you sure you want to leave the " \
"\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?" "\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?"
end end
def filter_group_project_member_path(options = {})
options = params.slice(:search, :sort).merge(options)
path = request.path
path << "?#{options.to_param}"
path
end
end end
...@@ -59,6 +59,10 @@ module MergeRequestsHelper ...@@ -59,6 +59,10 @@ module MergeRequestsHelper
@mr_closes_issues ||= @merge_request.closes_issues @mr_closes_issues ||= @merge_request.closes_issues
end end
def mr_issues_mentioned_but_not_closing
@mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing
end
def mr_change_branches_path(merge_request) def mr_change_branches_path(merge_request)
new_namespace_project_merge_request_path( new_namespace_project_merge_request_path(
@project.namespace, @project, @project.namespace, @project,
......
...@@ -7,12 +7,12 @@ module NavHelper ...@@ -7,12 +7,12 @@ module NavHelper
def page_gutter_class def page_gutter_class
if current_path?('merge_requests#show') || if current_path?('merge_requests#show') ||
current_path?('merge_requests#diffs') || current_path?('merge_requests#diffs') ||
current_path?('merge_requests#commits') || current_path?('merge_requests#commits') ||
current_path?('merge_requests#builds') || current_path?('merge_requests#builds') ||
current_path?('merge_requests#conflicts') || current_path?('merge_requests#conflicts') ||
current_path?('merge_requests#pipelines') || current_path?('merge_requests#pipelines') ||
current_path?('issues#show') current_path?('issues#show')
if cookies[:collapsed_gutter] == 'true' if cookies[:collapsed_gutter] == 'true'
"page-gutter right-sidebar-collapsed" "page-gutter right-sidebar-collapsed"
else else
...@@ -21,9 +21,9 @@ module NavHelper ...@@ -21,9 +21,9 @@ module NavHelper
elsif current_path?('builds#show') elsif current_path?('builds#show')
"page-gutter build-sidebar right-sidebar-expanded" "page-gutter build-sidebar right-sidebar-expanded"
elsif current_path?('wikis#show') || elsif current_path?('wikis#show') ||
current_path?('wikis#edit') || current_path?('wikis#edit') ||
current_path?('wikis#history') || current_path?('wikis#history') ||
current_path?('wikis#git_access') current_path?('wikis#git_access')
"page-gutter wiki-sidebar right-sidebar-expanded" "page-gutter wiki-sidebar right-sidebar-expanded"
end end
end end
......
...@@ -25,7 +25,7 @@ module SortingHelper ...@@ -25,7 +25,7 @@ module SortingHelper
sort_value_recently_updated => sort_title_recently_updated, sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated, sort_value_oldest_updated => sort_title_oldest_updated,
sort_value_recently_created => sort_title_recently_created, sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created, sort_value_oldest_created => sort_title_oldest_created
} }
if current_controller?('admin/projects') if current_controller?('admin/projects')
...@@ -35,6 +35,19 @@ module SortingHelper ...@@ -35,6 +35,19 @@ module SortingHelper
options options
end end
def member_sort_options_hash
{
sort_value_access_level_asc => sort_title_access_level_asc,
sort_value_access_level_desc => sort_title_access_level_desc,
sort_value_last_joined => sort_title_last_joined,
sort_value_oldest_joined => sort_title_oldest_joined,
sort_value_name => sort_title_name_asc,
sort_value_name_desc => sort_title_name_desc,
sort_value_recently_signin => sort_title_recently_signin,
sort_value_oldest_signin => sort_title_oldest_signin
}
end
def sort_title_priority def sort_title_priority
'Priority' 'Priority'
end end
...@@ -95,6 +108,50 @@ module SortingHelper ...@@ -95,6 +108,50 @@ module SortingHelper
'Most popular' 'Most popular'
end end
def sort_title_last_joined
'Last joined'
end
def sort_title_oldest_joined
'Oldest joined'
end
def sort_title_access_level_asc
'Access level, ascending'
end
def sort_title_access_level_desc
'Access level, descending'
end
def sort_title_name_asc
'Name, ascending'
end
def sort_title_name_desc
'Name, descending'
end
def sort_value_last_joined
'last_joined'
end
def sort_value_oldest_joined
'oldest_joined'
end
def sort_value_access_level_asc
'access_level_asc'
end
def sort_value_access_level_desc
'access_level_desc'
end
def sort_value_name_desc
'name_desc'
end
def sort_value_priority def sort_value_priority
'priority' 'priority'
end end
......
...@@ -106,9 +106,9 @@ module TabHelper ...@@ -106,9 +106,9 @@ module TabHelper
def branches_tab_class def branches_tab_class
if current_controller?(:protected_branches) || if current_controller?(:protected_branches) ||
current_controller?(:branches) || current_controller?(:branches) ||
current_page?(namespace_project_repository_path(@project.namespace, current_page?(namespace_project_repository_path(@project.namespace,
@project)) @project))
'active' 'active'
end end
end end
......
...@@ -18,7 +18,7 @@ module Ci ...@@ -18,7 +18,7 @@ module Ci
end end
serialize :options serialize :options
serialize :yaml_variables serialize :yaml_variables, Gitlab::Serialize::Ci::Variables
validates :coverage, numericality: true, allow_blank: true validates :coverage, numericality: true, allow_blank: true
validates_presence_of :ref validates_presence_of :ref
...@@ -155,7 +155,7 @@ module Ci ...@@ -155,7 +155,7 @@ module Ci
end end
def has_environment? def has_environment?
self.environment.present? environment.present?
end end
def starts_environment? def starts_environment?
...@@ -221,6 +221,7 @@ module Ci ...@@ -221,6 +221,7 @@ module Ci
variables += pipeline.predefined_variables variables += pipeline.predefined_variables
variables += runner.predefined_variables if runner variables += runner.predefined_variables if runner
variables += project.container_registry_variables variables += project.container_registry_variables
variables += project.deployment_variables if has_environment?
variables += yaml_variables variables += yaml_variables
variables += user_variables variables += user_variables
variables += project.secret_variables variables += project.secret_variables
......
...@@ -116,6 +116,11 @@ module Ci ...@@ -116,6 +116,11 @@ module Ci
where.not(duration: nil).sum(:duration) where.not(duration: nil).sum(:duration)
end end
def stage(name)
stage = Ci::Stage.new(self, name: name)
stage unless stage.statuses_count.zero?
end
def stages_count def stages_count
statuses.select(:stage).distinct.count statuses.select(:stage).distinct.count
end end
......
...@@ -18,6 +18,10 @@ module Ci ...@@ -18,6 +18,10 @@ module Ci
name name
end end
def statuses_count
@statuses_count ||= statuses.count
end
def status def status
@status ||= statuses.latest.status @status ||= statuses.latest.status
end end
......
module Milestoneish module Milestoneish
def closed_items_count(user) def closed_items_count(user)
issues_visible_to_user(user).closed.size + merge_requests.closed_and_merged.size memoize_per_user(user, :closed_items_count) do
(count_issues_by_state(user)['closed'] || 0) + merge_requests.closed_and_merged.size
end
end end
def total_items_count(user) def total_items_count(user)
issues_visible_to_user(user).size + merge_requests.size memoize_per_user(user, :total_items_count) do
issues_count = count_issues_by_state(user).values.sum
issues_count + merge_requests.size
end
end end
def complete?(user) def complete?(user)
...@@ -30,7 +35,10 @@ module Milestoneish ...@@ -30,7 +35,10 @@ module Milestoneish
end end
def issues_visible_to_user(user) def issues_visible_to_user(user)
IssuesFinder.new(user).execute.where(id: issues) memoize_per_user(user, :issues_visible_to_user) do
params = try(:project_id) ? { project_id: project_id } : {}
IssuesFinder.new(user, params).execute.where(milestone_id: milestoneish_ids)
end
end end
def upcoming? def upcoming?
...@@ -50,4 +58,18 @@ module Milestoneish ...@@ -50,4 +58,18 @@ module Milestoneish
def expired? def expired?
due_date && due_date.past? due_date && due_date.past?
end end
private
def count_issues_by_state(user)
memoize_per_user(user, :count_issues_by_state) do
issues_visible_to_user(user).reorder(nil).group(:state).count
end
end
def memoize_per_user(user, method_name)
@memoized ||= {}
@memoized[method_name] ||= {}
@memoized[method_name][user.try!(:id)] ||= yield
end
end end
# The ReactiveCaching concern is used to fetch some data in the background and
# store it in the Rails cache, keeping it up-to-date for as long as it is being
# requested. If the data hasn't been requested for +reactive_cache_lifetime+,
# it stop being refreshed, and then be removed.
#
# Example of use:
#
# class Foo < ActiveRecord::Base
# include ReactiveCaching
#
# self.reactive_cache_key = ->(thing) { ["foo", thing.id] }
#
# after_save :clear_reactive_cache!
#
# def calculate_reactive_cache
# # Expensive operation here. The return value of this method is cached
# end
#
# def result
# with_reactive_cache do |data|
# # ...
# end
# end
# end
#
# In this example, the first time `#result` is called, it will return `nil`.
# However, it will enqueue a background worker to call `#calculate_reactive_cache`
# and set an initial cache lifetime of ten minutes.
#
# Each time the background job completes, it stores the return value of
# `#calculate_reactive_cache`. It is also re-enqueued to run again after
# `reactive_cache_refresh_interval`, so keeping the stored value up to date.
# Calculations are never run concurrently.
#
# Calling `#result` while a value is in the cache will call the block given to
# `#with_reactive_cache`, yielding the cached value. It will also extend the
# lifetime by `reactive_cache_lifetime`.
#
# Once the lifetime has expired, no more background jobs will be enqueued and
# calling `#result` will again return `nil` - starting the process all over
# again
module ReactiveCaching
extend ActiveSupport::Concern
included do
class_attribute :reactive_cache_lease_timeout
class_attribute :reactive_cache_key
class_attribute :reactive_cache_lifetime
class_attribute :reactive_cache_refresh_interval
# defaults
self.reactive_cache_lease_timeout = 2.minutes
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
def calculate_reactive_cache
raise NotImplementedError
end
def with_reactive_cache(&blk)
within_reactive_cache_lifetime do
data = Rails.cache.read(full_reactive_cache_key)
yield data if data.present?
end
ensure
Rails.cache.write(full_reactive_cache_key('alive'), true, expires_in: self.class.reactive_cache_lifetime)
ReactiveCachingWorker.perform_async(self.class, id)
end
def clear_reactive_cache!
Rails.cache.delete(full_reactive_cache_key)
end
def exclusively_update_reactive_cache!
locking_reactive_cache do
within_reactive_cache_lifetime do
enqueuing_update do
value = calculate_reactive_cache
Rails.cache.write(full_reactive_cache_key, value)
end
end
end
end
private
def full_reactive_cache_key(*qualifiers)
prefix = self.class.reactive_cache_key
prefix = prefix.call(self) if prefix.respond_to?(:call)
([prefix].flatten + qualifiers).join(':')
end
def locking_reactive_cache
lease = Gitlab::ExclusiveLease.new(full_reactive_cache_key, timeout: reactive_cache_lease_timeout)
uuid = lease.try_obtain
yield if uuid
ensure
Gitlab::ExclusiveLease.cancel(full_reactive_cache_key, uuid)
end
def within_reactive_cache_lifetime
yield if Rails.cache.read(full_reactive_cache_key('alive'))
end
def enqueuing_update
yield
ensure
ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id)
end
end
end
...@@ -128,6 +128,14 @@ class Environment < ActiveRecord::Base ...@@ -128,6 +128,14 @@ class Environment < ActiveRecord::Base
end end
end end
def has_terminals?
project.deployment_service.present? && available? && last_deployment.present?
end
def terminals
project.deployment_service.terminals(self) if has_terminals?
end
# An environment name is not necessarily suitable for use in URLs, DNS # An environment name is not necessarily suitable for use in URLs, DNS
# or other third-party contexts, so provide a slugified version. A slug has # or other third-party contexts, so provide a slugified version. A slug has
# the following properties: # the following properties:
......
...@@ -24,12 +24,16 @@ class GlobalMilestone ...@@ -24,12 +24,16 @@ class GlobalMilestone
@first_milestone = milestones.find {|m| m.description.present? } || milestones.first @first_milestone = milestones.find {|m| m.description.present? } || milestones.first
end end
def milestoneish_ids
milestones.select(:id)
end
def safe_title def safe_title
@title.to_slug.normalize.to_s @title.to_slug.normalize.to_s
end end
def projects def projects
@projects ||= Project.for_milestones(milestones.select(:id)) @projects ||= Project.for_milestones(milestoneish_ids)
end end
def state def state
...@@ -49,11 +53,11 @@ class GlobalMilestone ...@@ -49,11 +53,11 @@ class GlobalMilestone
end end
def issues def issues
@issues ||= Issue.of_milestones(milestones.select(:id)).includes(:project, :assignee, :labels) @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignee, :labels)
end end
def merge_requests def merge_requests
@merge_requests ||= MergeRequest.of_milestones(milestones.select(:id)).includes(:target_project, :assignee, :labels) @merge_requests ||= MergeRequest.of_milestones(milestoneish_ids).includes(:target_project, :assignee, :labels)
end end
def participants def participants
......
...@@ -39,6 +39,8 @@ class Issue < ActiveRecord::Base ...@@ -39,6 +39,8 @@ class Issue < ActiveRecord::Base
scope :created_after, -> (datetime) { where("created_at >= ?", datetime) } scope :created_after, -> (datetime) { where("created_at >= ?", datetime) }
scope :include_associations, -> { includes(:assignee, :labels, project: :namespace) }
attr_spammable :title, spam_title: true attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true attr_spammable :description, spam_description: true
......
...@@ -57,6 +57,11 @@ class Member < ActiveRecord::Base ...@@ -57,6 +57,11 @@ class Member < ActiveRecord::Base
scope :owners, -> { active.where(access_level: OWNER) } scope :owners, -> { active.where(access_level: OWNER) }
scope :owners_and_masters, -> { active.where(access_level: [OWNER, MASTER]) } scope :owners_and_masters, -> { active.where(access_level: [OWNER, MASTER]) }
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) }
before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }
after_create :send_invite, if: :invite?, unless: :importing? after_create :send_invite, if: :invite?, unless: :importing?
...@@ -72,6 +77,34 @@ class Member < ActiveRecord::Base ...@@ -72,6 +77,34 @@ class Member < ActiveRecord::Base
default_value_for :notification_level, NotificationSetting.levels[:global] default_value_for :notification_level, NotificationSetting.levels[:global]
class << self class << self
def search(query)
joins(:user).merge(User.search(query))
end
def sort(method)
case method.to_s
when 'access_level_asc' then reorder(access_level: :asc)
when 'access_level_desc' then reorder(access_level: :desc)
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
when 'last_joined' then order_created_desc
when 'oldest_joined' then order_created_asc
else
order_by(method)
end
end
def left_join_users
users = User.arel_table
members = Member.arel_table
member_users = members.join(users, Arel::Nodes::OuterJoin).
on(members[:user_id].eq(users[:id])).
join_sources
joins(member_users)
end
def access_for_user_ids(user_ids) def access_for_user_ids(user_ids)
where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h
end end
...@@ -89,8 +122,8 @@ class Member < ActiveRecord::Base ...@@ -89,8 +122,8 @@ class Member < ActiveRecord::Base
member = member =
if user.is_a?(User) if user.is_a?(User)
source.members.find_by(user_id: user.id) || source.members.find_by(user_id: user.id) ||
source.requesters.find_by(user_id: user.id) || source.requesters.find_by(user_id: user.id) ||
source.members.build(user_id: user.id) source.members.build(user_id: user.id)
else else
source.members.build(invite_email: user) source.members.build(invite_email: user)
end end
......
...@@ -97,7 +97,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -97,7 +97,7 @@ class MergeRequest < ActiveRecord::Base
validates :source_branch, presence: true validates :source_branch, presence: true
validates :target_project, presence: true validates :target_project, presence: true
validates :target_branch, presence: true validates :target_branch, presence: true
validates :merge_user, presence: true, if: :merge_when_build_succeeds? validates :merge_user, presence: true, if: :merge_when_build_succeeds?, unless: :importing?
validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?] validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?]
validate :validate_fork, unless: :closed_without_fork? validate :validate_fork, unless: :closed_without_fork?
...@@ -568,6 +568,19 @@ class MergeRequest < ActiveRecord::Base ...@@ -568,6 +568,19 @@ class MergeRequest < ActiveRecord::Base
end end
end end
def issues_mentioned_but_not_closing(current_user = self.author)
return [] unless target_branch == project.default_branch
ext = Gitlab::ReferenceExtractor.new(project, current_user)
ext.analyze(description)
issues = ext.issues
closing_issues = Gitlab::ClosingIssueExtractor.new(project, current_user).
closed_by_message(description)
issues - closing_issues
end
def target_project_path def target_project_path
if target_project if target_project
target_project.path_with_namespace target_project.path_with_namespace
...@@ -612,13 +625,24 @@ class MergeRequest < ActiveRecord::Base ...@@ -612,13 +625,24 @@ class MergeRequest < ActiveRecord::Base
self.target_project.repository.branch_names.include?(self.target_branch) self.target_project.repository.branch_names.include?(self.target_branch)
end end
def merge_commit_message def merge_commit_message(include_description: false)
message = "Merge branch '#{source_branch}' into '#{target_branch}'\n\n" closes_issues_references = closes_issues.map do |issue|
message << "#{title}\n\n" issue.to_reference(target_project)
message << "#{description}\n\n" if description.present? end
message = [
"Merge branch '#{source_branch}' into '#{target_branch}'",
title
]
if !include_description && closes_issues_references.present?
message << "Closes #{closes_issues_references.to_sentence}"
end
message << "#{description}" if include_description && description.present?
message << "See merge request #{to_reference}" message << "See merge request #{to_reference}"
message message.join("\n\n")
end end
def reset_merge_when_build_succeeds def reset_merge_when_build_succeeds
......
...@@ -129,6 +129,10 @@ class Milestone < ActiveRecord::Base ...@@ -129,6 +129,10 @@ class Milestone < ActiveRecord::Base
self.title self.title
end end
def milestoneish_ids
id
end
def can_be_closed? def can_be_closed?
active? && issues.opened.count.zero? active? && issues.opened.count.zero?
end end
......
...@@ -161,8 +161,8 @@ module Network ...@@ -161,8 +161,8 @@ module Network
def is_overlap?(range, overlap_space) def is_overlap?(range, overlap_space)
range.each do |i| range.each do |i|
if i != range.first && if i != range.first &&
i != range.last && i != range.last &&
@commits[i].spaces.include?(overlap_space) @commits[i].spaces.include?(overlap_space)
return true return true
end end
......
...@@ -2,6 +2,8 @@ class PersonalAccessToken < ActiveRecord::Base ...@@ -2,6 +2,8 @@ class PersonalAccessToken < ActiveRecord::Base
include TokenAuthenticatable include TokenAuthenticatable
add_authentication_token_field :token add_authentication_token_field :token
serialize :scopes, Array
belongs_to :user belongs_to :user
scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") } scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") }
......
...@@ -79,7 +79,6 @@ class Project < ActiveRecord::Base ...@@ -79,7 +79,6 @@ class Project < ActiveRecord::Base
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
has_many :boards, before_add: :validate_board_limit, dependent: :destroy has_many :boards, before_add: :validate_board_limit, dependent: :destroy
has_many :chat_services
# Project services # Project services
has_one :campfire_service, dependent: :destroy has_one :campfire_service, dependent: :destroy
...@@ -95,8 +94,9 @@ class Project < ActiveRecord::Base ...@@ -95,8 +94,9 @@ class Project < ActiveRecord::Base
has_one :asana_service, dependent: :destroy has_one :asana_service, dependent: :destroy
has_one :gemnasium_service, dependent: :destroy has_one :gemnasium_service, dependent: :destroy
has_one :mattermost_slash_commands_service, dependent: :destroy has_one :mattermost_slash_commands_service, dependent: :destroy
has_one :mattermost_notification_service, dependent: :destroy has_one :mattermost_service, dependent: :destroy
has_one :slack_notification_service, dependent: :destroy has_one :slack_slash_commands_service, dependent: :destroy
has_one :slack_service, dependent: :destroy
has_one :buildkite_service, dependent: :destroy has_one :buildkite_service, dependent: :destroy
has_one :bamboo_service, dependent: :destroy has_one :bamboo_service, dependent: :destroy
has_one :teamcity_service, dependent: :destroy has_one :teamcity_service, dependent: :destroy
...@@ -533,6 +533,10 @@ class Project < ActiveRecord::Base ...@@ -533,6 +533,10 @@ class Project < ActiveRecord::Base
import_type == 'gitlab_project' import_type == 'gitlab_project'
end end
def gitea_import?
import_type == 'gitea'
end
def check_limit def check_limit
unless creator.can_create_project? or namespace.kind == 'group' unless creator.can_create_project? or namespace.kind == 'group'
projects_limit = creator.projects_limit projects_limit = creator.projects_limit
...@@ -1230,6 +1234,12 @@ class Project < ActiveRecord::Base ...@@ -1230,6 +1234,12 @@ class Project < ActiveRecord::Base
end end
end end
def deployment_variables
return [] unless deployment_service
deployment_service.predefined_variables
end
def append_or_update_attribute(name, value) def append_or_update_attribute(name, value)
old_values = public_send(name.to_s) old_values = public_send(name.to_s)
......
...@@ -5,4 +5,17 @@ class ProjectAuthorization < ActiveRecord::Base ...@@ -5,4 +5,17 @@ class ProjectAuthorization < ActiveRecord::Base
validates :project, presence: true validates :project, presence: true
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
def self.insert_authorizations(rows, per_batch = 1000)
rows.each_slice(per_batch) do |slice|
tuples = slice.map do |tuple|
tuple.map { |value| connection.quote(value) }
end
connection.execute <<-EOF.strip_heredoc
INSERT INTO project_authorizations (user_id, project_id, access_level)
VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')}
EOF
end
end
end end
# Base class for Chat services # Base class for Chat services
# This class is not meant to be used directly, but only to inherit from. # This class is not meant to be used directly, but only to inherrit from.
class ChatService < Service class ChatSlashCommandsService < Service
default_value_for :category, 'chat' default_value_for :category, 'chat'
has_many :chat_names, foreign_key: :service_id prop_accessor :token
has_many :chat_names, foreign_key: :service_id, dependent: :destroy
def valid_token?(token) def valid_token?(token)
self.respond_to?(:token) && self.respond_to?(:token) &&
...@@ -15,7 +17,40 @@ class ChatService < Service ...@@ -15,7 +17,40 @@ class ChatService < Service
[] []
end end
def can_test?
false
end
def fields
[
{ type: 'text', name: 'token', placeholder: '' }
]
end
def trigger(params) def trigger(params)
raise NotImplementedError return unless valid_token?(params[:token])
user = find_chat_user(params)
unless user
url = authorize_chat_name_url(params)
return presenter.authorize_chat_name(url)
end
Gitlab::ChatCommands::Command.new(project, user,
params).execute
end
private
def find_chat_user(params)
ChatNames::FindUserService.new(self, params).execute
end
def authorize_chat_name_url(params)
ChatNames::AuthorizeUserService.new(self, params).execute
end
def presenter
Gitlab::ChatCommands::Presenter.new
end end
end end
...@@ -8,4 +8,26 @@ class DeploymentService < Service ...@@ -8,4 +8,26 @@ class DeploymentService < Service
def supported_events def supported_events
[] []
end end
def predefined_variables
[]
end
# Environments may have a number of terminals. Should return an array of
# hashes describing them, e.g.:
#
# [{
# :selectors => {"a" => "b", "foo" => "bar"},
# :url => "wss://external.example.com/exec",
# :headers => {"Authorization" => "Token xxx"},
# :subprotocols => ["foo"],
# :ca_pem => "----BEGIN CERTIFICATE...", # optional
# :created_at => Time.now.utc
# }]
#
# Selectors should be a set of values that uniquely identify a particular
# terminal
def terminals(environment)
raise NotImplementedError
end
end end
...@@ -85,8 +85,8 @@ class IssueTrackerService < Service ...@@ -85,8 +85,8 @@ class IssueTrackerService < Service
def enabled_in_gitlab_config def enabled_in_gitlab_config
Gitlab.config.issues_tracker && Gitlab.config.issues_tracker &&
Gitlab.config.issues_tracker.values.any? && Gitlab.config.issues_tracker.values.any? &&
issues_tracker issues_tracker
end end
def issues_tracker def issues_tracker
......
class KubernetesService < DeploymentService class KubernetesService < DeploymentService
include Gitlab::Kubernetes
include ReactiveCaching
self.reactive_cache_key = ->(service) { [ service.class.model_name.singular, service.project_id ] }
# Namespace defaults to the project path, but can be overridden in case that # Namespace defaults to the project path, but can be overridden in case that
# is an invalid or inappropriate name # is an invalid or inappropriate name
prop_accessor :namespace prop_accessor :namespace
...@@ -25,6 +30,8 @@ class KubernetesService < DeploymentService ...@@ -25,6 +30,8 @@ class KubernetesService < DeploymentService
length: 1..63 length: 1..63
end end
after_save :clear_reactive_cache!
def initialize_properties def initialize_properties
if properties.nil? if properties.nil?
self.properties = {} self.properties = {}
...@@ -41,7 +48,8 @@ class KubernetesService < DeploymentService ...@@ -41,7 +48,8 @@ class KubernetesService < DeploymentService
end end
def help def help
'' 'To enable terminal access to Kubernetes environments, label your ' \
'deployments with `app=$CI_ENVIRONMENT_SLUG`'
end end
def to_param def to_param
...@@ -75,28 +83,66 @@ class KubernetesService < DeploymentService ...@@ -75,28 +83,66 @@ class KubernetesService < DeploymentService
# Check we can connect to the Kubernetes API # Check we can connect to the Kubernetes API
def test(*args) def test(*args)
kubeclient = build_kubeclient kubeclient = build_kubeclient!
kubeclient.discover
kubeclient.discover
{ success: kubeclient.discovered, result: "Checked API discovery endpoint" } { success: kubeclient.discovered, result: "Checked API discovery endpoint" }
rescue => err rescue => err
{ success: false, result: err } { success: false, result: err }
end end
private def predefined_variables
variables = [
{ key: 'KUBE_URL', value: api_url, public: true },
{ key: 'KUBE_TOKEN', value: token, public: false },
{ key: 'KUBE_NAMESPACE', value: namespace, public: true }
]
variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } if ca_pem.present?
variables
end
# Constructs a list of terminals from the reactive cache
#
# Returns nil if the cache is empty, in which case you should try again a
# short time later
def terminals(environment)
with_reactive_cache do |data|
pods = data.fetch(:pods, nil)
filter_pods(pods, app: environment.slug).
flat_map { |pod| terminals_for_pod(api_url, namespace, pod) }.
map { |terminal| add_terminal_auth(terminal, token, ca_pem) }
end
end
def build_kubeclient(api_path = '/api', api_version = 'v1') # Caches all pods in the namespace so other calls don't need to block on
return nil unless api_url && namespace && token # network access.
def calculate_reactive_cache
return unless active? && project && !project.pending_delete?
url = URI.parse(api_url) kubeclient = build_kubeclient!
url.path = url.path[0..-2] if url.path[-1] == "/"
url.path += api_path # Store as hashes, rather than as third-party types
pods = begin
kubeclient.get_pods(namespace: namespace).as_json
rescue KubeException => err
raise err unless err.error_code == 404
[]
end
# We may want to cache extra things in the future
{ pods: pods }
end
private
def build_kubeclient!(api_path: 'api', api_version: 'v1')
raise "Incomplete settings" unless api_url && namespace && token
::Kubeclient::Client.new( ::Kubeclient::Client.new(
url, join_api_url(api_path),
api_version, api_version,
ssl_options: kubeclient_ssl_options,
auth_options: kubeclient_auth_options, auth_options: kubeclient_auth_options,
ssl_options: kubeclient_ssl_options,
http_proxy_uri: ENV['http_proxy'] http_proxy_uri: ENV['http_proxy']
) )
end end
...@@ -115,4 +161,13 @@ class KubernetesService < DeploymentService ...@@ -115,4 +161,13 @@ class KubernetesService < DeploymentService
def kubeclient_auth_options def kubeclient_auth_options
{ bearer_token: token } { bearer_token: token }
end end
def join_api_url(*parts)
url = URI.parse(api_url)
prefix = url.path.sub(%r{/+\z}, '')
url.path = [ prefix, *parts ].join("/")
url.to_s
end
end end
class MattermostNotificationService < ChatNotificationService class MattermostService < ChatNotificationService
def title def title
'Mattermost notifications' 'Mattermost notifications'
end end
...@@ -8,7 +8,7 @@ class MattermostNotificationService < ChatNotificationService ...@@ -8,7 +8,7 @@ class MattermostNotificationService < ChatNotificationService
end end
def to_param def to_param
'mattermost_notification' 'mattermost'
end end
def help def help
......
class MattermostSlashCommandsService < ChatService class MattermostSlashCommandsService < ChatSlashCommandsService
include TriggersHelper include TriggersHelper
prop_accessor :token prop_accessor :token
...@@ -18,32 +18,4 @@ class MattermostSlashCommandsService < ChatService ...@@ -18,32 +18,4 @@ class MattermostSlashCommandsService < ChatService
def to_param def to_param
'mattermost_slash_commands' 'mattermost_slash_commands'
end end
def fields
[
{ type: 'text', name: 'token', placeholder: '' }
]
end
def trigger(params)
return nil unless valid_token?(params[:token])
user = find_chat_user(params)
unless user
url = authorize_chat_name_url(params)
return Mattermost::Presenter.authorize_chat_name(url)
end
Gitlab::ChatCommands::Command.new(project, user, params).execute
end
private
def find_chat_user(params)
ChatNames::FindUserService.new(self, params).execute
end
def authorize_chat_name_url(params)
ChatNames::AuthorizeUserService.new(self, params).execute
end
end end
class SlackNotificationService < ChatNotificationService class SlackService < ChatNotificationService
def title def title
'Slack notifications' 'Slack notifications'
end end
...@@ -8,7 +8,7 @@ class SlackNotificationService < ChatNotificationService ...@@ -8,7 +8,7 @@ class SlackNotificationService < ChatNotificationService
end end
def to_param def to_param
'slack_notification' 'slack'
end end
def help def help
......
class SlackSlashCommandsService < ChatSlashCommandsService
include TriggersHelper
def title
'Slack Command'
end
def description
"Perform common operations on GitLab in Slack"
end
def to_param
'slack_slash_commands'
end
def trigger(params)
# Format messages to be Slack-compatible
super.tap do |result|
result[:text] = format(result[:text])
end
end
private
def format(text)
Slack::Notifier::LinkFormatter.format(text) if text
end
end
...@@ -13,7 +13,7 @@ class Route < ActiveRecord::Base ...@@ -13,7 +13,7 @@ class Route < ActiveRecord::Base
def rename_children def rename_children
# We update each row separately because MySQL does not have regexp_replace. # We update each row separately because MySQL does not have regexp_replace.
# rubocop:disable Rails/FindEach # rubocop:disable Rails/FindEach
Route.where('path LIKE ?', "#{path_was}%").each do |route| Route.where('path LIKE ?', "#{path_was}/%").each do |route|
# Note that update column skips validation and callbacks. # Note that update column skips validation and callbacks.
# We need this to avoid recursive call of rename_children method # We need this to avoid recursive call of rename_children method
route.update_column(:path, route.path.sub(path_was, path)) route.update_column(:path, route.path.sub(path_was, path))
......
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.
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