Merge branch 'master' into issue-edit-inline
This commit is contained in:
commit
3465e1e52c
413 changed files with 7445 additions and 3349 deletions
38
.codeclimate.yml
Normal file
38
.codeclimate.yml
Normal file
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
engines:
|
||||
brakeman:
|
||||
enabled: true
|
||||
bundler-audit:
|
||||
enabled: true
|
||||
duplication:
|
||||
enabled: true
|
||||
config:
|
||||
languages:
|
||||
- ruby
|
||||
- javascript
|
||||
eslint:
|
||||
enabled: true
|
||||
fixme:
|
||||
enabled: true
|
||||
rubocop:
|
||||
enabled: true
|
||||
ratings:
|
||||
paths:
|
||||
- Gemfile.lock
|
||||
- "**.erb"
|
||||
- "**.haml"
|
||||
- "**.rb"
|
||||
- "**.rhtml"
|
||||
- "**.slim"
|
||||
- "**.inc"
|
||||
- "**.js"
|
||||
- "**.jsx"
|
||||
- "**.module"
|
||||
exclude_paths:
|
||||
- config/
|
||||
- db/
|
||||
- features/
|
||||
- node_modules/
|
||||
- spec/
|
||||
- vendor/
|
||||
- lib/api/v3/
|
|
@ -150,6 +150,7 @@ stages:
|
|||
# Trigger a package build on omnibus-gitlab repository
|
||||
|
||||
build-package:
|
||||
image: ruby:2.3-alpine
|
||||
before_script: []
|
||||
services: []
|
||||
variables:
|
||||
|
@ -486,25 +487,6 @@ lint:javascript:report:
|
|||
paths:
|
||||
- eslint-report.html
|
||||
|
||||
# Trigger docs build
|
||||
# https://gitlab.com/gitlab-com/doc-gitlab-com/blob/master/README.md#deployment-process
|
||||
trigger_docs:
|
||||
stage: post-test
|
||||
image: "alpine"
|
||||
<<: *dedicated-runner
|
||||
before_script:
|
||||
- apk update && apk add curl
|
||||
variables:
|
||||
GIT_STRATEGY: "none"
|
||||
cache: {}
|
||||
artifacts: {}
|
||||
script:
|
||||
- "HTTP_STATUS=$(curl -X POST -F token=${DOCS_TRIGGER_TOKEN} -F ref=master -F variables[PROJECT]=${CI_PROJECT_NAME} --silent --output curl.log --write-out '%{http_code}' https://gitlab.com/api/v3/projects/1794617/trigger/builds)"
|
||||
- if [ "${HTTP_STATUS}" -ne "201" ]; then echo "Error ${HTTP_STATUS}"; cat curl.log; echo; exit 1; fi
|
||||
only:
|
||||
- master@gitlab-org/gitlab-ce
|
||||
- master@gitlab-org/gitlab-ee
|
||||
|
||||
pages:
|
||||
before_script: []
|
||||
stage: pages
|
||||
|
|
|
@ -11,11 +11,11 @@ linters:
|
|||
# !global, !important, and !optional flags.
|
||||
BangFormat:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Whether or not to prefer `border: 0` over `border: none`.
|
||||
BorderZero:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Reports when you define a rule set using a selector with chained classes
|
||||
# (a.k.a. adjoining classes).
|
||||
ChainedClasses:
|
||||
|
@ -25,13 +25,13 @@ linters:
|
|||
# (e.g. `color: green` is a color keyword)
|
||||
ColorKeyword:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Prefer color literals (keywords or hexadecimal codes) to be used only in
|
||||
# variable declarations. They should be referred to via variables everywhere
|
||||
# else.
|
||||
ColorVariable:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Which form of comments to prefer in CSS.
|
||||
Comment:
|
||||
enabled: false
|
||||
|
@ -39,7 +39,7 @@ linters:
|
|||
# Reports @debug statements (which you probably left behind accidentally).
|
||||
DebugStatement:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Rule sets should be ordered as follows:
|
||||
# - @extend declarations
|
||||
# - @include declarations without inner @content
|
||||
|
@ -54,19 +54,19 @@ linters:
|
|||
# more information.
|
||||
DisableLinterReason:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Reports when you define the same property twice in a single rule set.
|
||||
DuplicateProperty:
|
||||
enabled: false
|
||||
|
||||
enabled: true
|
||||
|
||||
# Separate rule, function, and mixin declarations with empty lines.
|
||||
EmptyLineBetweenBlocks:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Reports when you have an empty rule set.
|
||||
EmptyRule:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Reports when you have an @extend directive.
|
||||
ExtendDirective:
|
||||
enabled: false
|
||||
|
@ -75,49 +75,49 @@ linters:
|
|||
# when adding lines to the file, since SCM systems such as git won't
|
||||
# think that you touched the last line.
|
||||
FinalNewline:
|
||||
enabled: false
|
||||
|
||||
enabled: true
|
||||
|
||||
# HEX colors should use three-character values where possible.
|
||||
HexLength:
|
||||
enabled: false
|
||||
|
||||
|
||||
# HEX color values should use lower-case colors to differentiate between
|
||||
# letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`.
|
||||
HexNotation:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Avoid using ID selectors.
|
||||
IdSelector:
|
||||
enabled: false
|
||||
|
||||
|
||||
# The basenames of @imported SCSS partials should not begin with an
|
||||
# underscore and should not include the filename extension.
|
||||
ImportPath:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Avoid using !important in properties. It is usually indicative of a
|
||||
# misunderstanding of CSS specificity and can lead to brittle code.
|
||||
ImportantRule:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Indentation should always be done in increments of 2 spaces.
|
||||
Indentation:
|
||||
enabled: true
|
||||
width: 2
|
||||
|
||||
|
||||
# Don't write leading zeros for numeric values with a decimal point.
|
||||
LeadingZero:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Reports when you define the same selector twice in a single sheet.
|
||||
MergeableSelector:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Functions, mixins, variables, and placeholders should be declared
|
||||
# with all lowercase letters and hyphens instead of underscores.
|
||||
NameFormat:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Avoid nesting selectors too deeply.
|
||||
NestingDepth:
|
||||
enabled: false
|
||||
|
@ -129,12 +129,12 @@ linters:
|
|||
# Sort properties in a strict order.
|
||||
PropertySortOrder:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Reports when you use an unknown or disabled CSS property
|
||||
# (ignoring vendor-prefixed properties).
|
||||
PropertySpelling:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Configure which units are allowed for property values.
|
||||
PropertyUnits:
|
||||
enabled: false
|
||||
|
@ -144,25 +144,25 @@ linters:
|
|||
# be declared with one colon.
|
||||
PseudoElement:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Avoid qualifying elements in selectors (also known as "tag-qualifying").
|
||||
QualifyingElement:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Don't write selectors with a depth of applicability greater than 3.
|
||||
SelectorDepth:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Selectors should always use hyphenated-lowercase, rather than camelCase or
|
||||
# snake_case.
|
||||
SelectorFormat:
|
||||
enabled: false
|
||||
convention: hyphenated_lowercase
|
||||
|
||||
|
||||
# Prefer the shortest shorthand form possible for properties that support it.
|
||||
Shorthand:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Each property should have its own line, except in the special case of
|
||||
# single line rulesets.
|
||||
SingleLinePerProperty:
|
||||
|
@ -173,11 +173,11 @@ linters:
|
|||
# individual selector occupy a single line.
|
||||
SingleLinePerSelector:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Commas in lists should be followed by a space.
|
||||
SpaceAfterComma:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Properties should be formatted with a single space separating the colon
|
||||
# from the property's value.
|
||||
SpaceAfterPropertyColon:
|
||||
|
@ -197,12 +197,12 @@ linters:
|
|||
# colon.
|
||||
SpaceAfterVariableName:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Operators should be formatted with a single space on both sides of an
|
||||
# infix operator.
|
||||
SpaceAroundOperator:
|
||||
enabled: true
|
||||
|
||||
|
||||
# Opening braces should be preceded by a single space.
|
||||
SpaceBeforeBrace:
|
||||
enabled: true
|
||||
|
@ -210,7 +210,7 @@ linters:
|
|||
# Parentheses should not be padded with spaces.
|
||||
SpaceBetweenParens:
|
||||
enabled: false
|
||||
|
||||
|
||||
# Enforces that string literals should be written with a consistent form
|
||||
# of quotes (single or double).
|
||||
StringQuotes:
|
||||
|
@ -241,7 +241,7 @@ linters:
|
|||
# be unnecessary.
|
||||
UnnecessaryParentReference:
|
||||
enabled: false
|
||||
|
||||
|
||||
# URLs should be valid and not contain protocols or domain names.
|
||||
UrlFormat:
|
||||
enabled: true
|
||||
|
|
|
@ -341,7 +341,7 @@ GEM
|
|||
grape-entity (0.6.0)
|
||||
activesupport
|
||||
multi_json (>= 1.3.2)
|
||||
grpc (1.2.5)
|
||||
grpc (1.3.4)
|
||||
google-protobuf (~> 3.1)
|
||||
googleauth (~> 0.5.1)
|
||||
haml (4.0.7)
|
||||
|
|
|
@ -2,15 +2,11 @@
|
|||
consistent-return, prefer-rest-params */
|
||||
/* global Breakpoints */
|
||||
|
||||
import _ from 'underscore';
|
||||
import { bytesToKiB } from './lib/utils/number_utils';
|
||||
|
||||
const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; };
|
||||
const AUTO_SCROLL_OFFSET = 75;
|
||||
const DOWN_BUILD_TRACE = '#down-build-trace';
|
||||
|
||||
window.Build = (function () {
|
||||
Build.timeout = null;
|
||||
|
||||
Build.state = null;
|
||||
|
||||
function Build(options) {
|
||||
|
@ -23,21 +19,22 @@ window.Build = (function () {
|
|||
this.buildStage = this.options.buildStage;
|
||||
this.$document = $(document);
|
||||
this.logBytes = 0;
|
||||
this.scrollOffsetPadding = 30;
|
||||
|
||||
this.updateDropdown = bind(this.updateDropdown, this);
|
||||
this.updateDropdown = this.updateDropdown.bind(this);
|
||||
this.getBuildTrace = this.getBuildTrace.bind(this);
|
||||
this.scrollToBottom = this.scrollToBottom.bind(this);
|
||||
|
||||
this.$body = $('body');
|
||||
this.$buildTrace = $('#build-trace');
|
||||
this.$autoScrollContainer = $('.autoscroll-container');
|
||||
this.$autoScrollStatus = $('#autoscroll-status');
|
||||
this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text');
|
||||
this.$upBuildTrace = $('#up-build-trace');
|
||||
this.$downBuildTrace = $(DOWN_BUILD_TRACE);
|
||||
this.$scrollTopBtn = $('#scroll-top');
|
||||
this.$scrollBottomBtn = $('#scroll-bottom');
|
||||
this.$buildRefreshAnimation = $('.js-build-refresh');
|
||||
this.$buildScroll = $('#js-build-scroll');
|
||||
this.$truncatedInfo = $('.js-truncated-info');
|
||||
this.$buildTraceOutput = $('.js-build-output');
|
||||
this.$scrollContainer = $('.js-scroll-container');
|
||||
|
||||
// Scroll controllers
|
||||
this.$scrollTopBtn = $('.js-scroll-up');
|
||||
this.$scrollBottomBtn = $('.js-scroll-down');
|
||||
|
||||
clearTimeout(Build.timeout);
|
||||
// Init breakpoint checker
|
||||
|
@ -56,54 +53,149 @@ window.Build = (function () {
|
|||
.off('click', '.stage-item')
|
||||
.on('click', '.stage-item', this.updateDropdown);
|
||||
|
||||
this.$document.on('scroll', this.initScrollMonitor.bind(this));
|
||||
// add event listeners to the scroll buttons
|
||||
this.$scrollTopBtn
|
||||
.off('click')
|
||||
.on('click', this.scrollToTop.bind(this));
|
||||
|
||||
this.$scrollBottomBtn
|
||||
.off('click')
|
||||
.on('click', this.scrollToBottom.bind(this));
|
||||
|
||||
$(window)
|
||||
.off('resize.build')
|
||||
.on('resize.build', this.sidebarOnResize.bind(this));
|
||||
|
||||
$('a', this.$buildScroll)
|
||||
.off('click.stepTrace')
|
||||
.on('click.stepTrace', this.stepTrace);
|
||||
|
||||
this.updateArtifactRemoveDate();
|
||||
this.initScrollButtonAffix();
|
||||
this.invokeBuildTrace();
|
||||
|
||||
// eslint-disable-next-line
|
||||
this.getBuildTrace()
|
||||
.then(() => this.makeTraceScrollable())
|
||||
.then(() => this.scrollToBottom());
|
||||
|
||||
this.verifyTopPosition();
|
||||
}
|
||||
|
||||
Build.prototype.makeTraceScrollable = function () {
|
||||
this.$scrollContainer.niceScroll({
|
||||
cursorcolor: '#fff',
|
||||
cursoropacitymin: 1,
|
||||
cursorwidth: '3px',
|
||||
railpadding: { top: 5, bottom: 5, right: 5 },
|
||||
});
|
||||
|
||||
this.$scrollContainer.on('scroll', _.throttle(this.toggleScroll.bind(this), 100));
|
||||
|
||||
this.toggleScroll();
|
||||
};
|
||||
|
||||
Build.prototype.canScroll = function () {
|
||||
return (this.$scrollContainer.prop('scrollHeight') - this.scrollOffsetPadding) > this.$scrollContainer.height();
|
||||
};
|
||||
|
||||
/**
|
||||
* | | Up | Down |
|
||||
* |--------------------------|----------|----------|
|
||||
* | on scroll bottom | active | disabled |
|
||||
* | on scroll top | disabled | active |
|
||||
* | no scroll | disabled | disabled |
|
||||
* | on.('scroll') is on top | disabled | active |
|
||||
* | on('scroll) is on bottom | active | disabled |
|
||||
*
|
||||
*/
|
||||
Build.prototype.toggleScroll = function () {
|
||||
const bottomScroll = this.$scrollContainer.scrollTop() +
|
||||
this.scrollOffsetPadding +
|
||||
this.$scrollContainer.height();
|
||||
|
||||
if (this.canScroll()) {
|
||||
if (this.$scrollContainer.scrollTop() === 0) {
|
||||
this.toggleDisableButton(this.$scrollTopBtn, true);
|
||||
this.toggleDisableButton(this.$scrollBottomBtn, false);
|
||||
} else if (bottomScroll === this.$scrollContainer.prop('scrollHeight')) {
|
||||
this.toggleDisableButton(this.$scrollTopBtn, false);
|
||||
this.toggleDisableButton(this.$scrollBottomBtn, true);
|
||||
} else {
|
||||
this.toggleDisableButton(this.$scrollTopBtn, false);
|
||||
this.toggleDisableButton(this.$scrollBottomBtn, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Build.prototype.scrollToTop = function () {
|
||||
this.$scrollContainer.getNiceScroll(0).doScrollTop(0);
|
||||
this.toggleScroll();
|
||||
};
|
||||
|
||||
Build.prototype.scrollToBottom = function () {
|
||||
this.$scrollContainer.getNiceScroll(0).doScrollTo(this.$scrollContainer.prop('scrollHeight'));
|
||||
this.toggleScroll();
|
||||
};
|
||||
|
||||
Build.prototype.toggleDisableButton = function ($button, disable) {
|
||||
if (disable && $button.prop('disabled')) return;
|
||||
$button.prop('disabled', disable);
|
||||
};
|
||||
|
||||
Build.prototype.toggleScrollAnimation = function (toggle) {
|
||||
this.$scrollBottomBtn.toggleClass('animate', toggle);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build trace top position depends on the space ocupied by the elments rendered before
|
||||
*/
|
||||
Build.prototype.verifyTopPosition = function () {
|
||||
const $buildPage = $('.build-page');
|
||||
|
||||
const $header = $('.build-header', $buildPage);
|
||||
const $runnersStuck = $('.js-build-stuck', $buildPage);
|
||||
const $startsEnvironment = $('.js-environment-container', $buildPage);
|
||||
const $erased = $('.js-build-erased', $buildPage);
|
||||
|
||||
let topPostion = 168;
|
||||
|
||||
if ($header) {
|
||||
topPostion += $header.outerHeight();
|
||||
}
|
||||
|
||||
if ($runnersStuck) {
|
||||
topPostion += $runnersStuck.outerHeight();
|
||||
}
|
||||
|
||||
if ($startsEnvironment) {
|
||||
topPostion += $startsEnvironment.outerHeight();
|
||||
}
|
||||
|
||||
if ($erased) {
|
||||
topPostion += $erased.outerHeight() + 10;
|
||||
}
|
||||
|
||||
this.$buildTrace.css({
|
||||
top: topPostion,
|
||||
});
|
||||
};
|
||||
|
||||
Build.prototype.initSidebar = function () {
|
||||
this.$sidebar = $('.js-build-sidebar');
|
||||
this.$sidebar.niceScroll();
|
||||
this.$document
|
||||
.off('click', '.js-sidebar-build-toggle')
|
||||
.on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
|
||||
};
|
||||
|
||||
Build.prototype.invokeBuildTrace = function () {
|
||||
return this.getBuildTrace();
|
||||
};
|
||||
|
||||
Build.prototype.getBuildTrace = function () {
|
||||
return $.ajax({
|
||||
url: `${this.pageUrl}/trace.json`,
|
||||
dataType: 'json',
|
||||
data: {
|
||||
state: this.state,
|
||||
},
|
||||
success: ((log) => {
|
||||
const $buildContainer = $('.js-build-output');
|
||||
|
||||
data: this.state,
|
||||
})
|
||||
.done((log) => {
|
||||
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
|
||||
|
||||
if (log.state) {
|
||||
this.state = log.state;
|
||||
}
|
||||
|
||||
if (log.append) {
|
||||
$buildContainer.append(log.html);
|
||||
this.$buildTraceOutput.append(log.html);
|
||||
this.logBytes += log.size;
|
||||
} else {
|
||||
$buildContainer.html(log.html);
|
||||
this.$buildTraceOutput.html(log.html);
|
||||
this.logBytes = log.size;
|
||||
}
|
||||
|
||||
|
@ -114,141 +206,30 @@ window.Build = (function () {
|
|||
const size = bytesToKiB(this.logBytes);
|
||||
$('.js-truncated-info-size').html(`${size}`);
|
||||
this.$truncatedInfo.removeClass('hidden');
|
||||
this.initAffixTruncatedInfo();
|
||||
} else {
|
||||
this.$truncatedInfo.addClass('hidden');
|
||||
}
|
||||
|
||||
this.checkAutoscroll();
|
||||
|
||||
if (!log.complete) {
|
||||
this.toggleScrollAnimation(true);
|
||||
|
||||
Build.timeout = setTimeout(() => {
|
||||
this.invokeBuildTrace();
|
||||
//eslint-disable-next-line
|
||||
this.getBuildTrace()
|
||||
.then(() => this.scrollToBottom());
|
||||
}, 4000);
|
||||
} else {
|
||||
this.$buildRefreshAnimation.remove();
|
||||
this.toggleScrollAnimation(false);
|
||||
}
|
||||
|
||||
if (log.status !== this.buildStatus) {
|
||||
let pageUrl = this.pageUrl;
|
||||
|
||||
if (this.$autoScrollStatus.data('state') === 'enabled') {
|
||||
pageUrl += DOWN_BUILD_TRACE;
|
||||
}
|
||||
|
||||
gl.utils.visitUrl(pageUrl);
|
||||
gl.utils.visitUrl(this.pageUrl);
|
||||
}
|
||||
}),
|
||||
error: () => {
|
||||
})
|
||||
.fail(() => {
|
||||
this.$buildRefreshAnimation.remove();
|
||||
return this.initScrollMonitor();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Build.prototype.checkAutoscroll = function () {
|
||||
if (this.$autoScrollStatus.data('state') === 'enabled') {
|
||||
return $('html,body').scrollTop(this.$buildTrace.height());
|
||||
}
|
||||
|
||||
// Handle a situation where user started new build
|
||||
// but never scrolled a page
|
||||
if (!this.$scrollTopBtn.is(':visible') &&
|
||||
!this.$scrollBottomBtn.is(':visible') &&
|
||||
!gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
|
||||
this.$scrollBottomBtn.show();
|
||||
}
|
||||
};
|
||||
|
||||
Build.prototype.initScrollButtonAffix = function () {
|
||||
// Hide everything initially
|
||||
this.$scrollTopBtn.hide();
|
||||
this.$scrollBottomBtn.hide();
|
||||
this.$autoScrollContainer.hide();
|
||||
};
|
||||
|
||||
// Page scroll listener to detect if user has scrolling page
|
||||
// and handle following cases
|
||||
// 1) User is at Top of Build Log;
|
||||
// - Hide Top Arrow button
|
||||
// - Show Bottom Arrow button
|
||||
// - Disable Autoscroll and hide indicator (when build is running)
|
||||
// 2) User is at Bottom of Build Log;
|
||||
// - Show Top Arrow button
|
||||
// - Hide Bottom Arrow button
|
||||
// - Enable Autoscroll and show indicator (when build is running)
|
||||
// 3) User is somewhere in middle of Build Log;
|
||||
// - Show Top Arrow button
|
||||
// - Show Bottom Arrow button
|
||||
// - Disable Autoscroll and hide indicator (when build is running)
|
||||
Build.prototype.initScrollMonitor = function () {
|
||||
if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
|
||||
!gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
|
||||
// User is somewhere in middle of Build Log
|
||||
|
||||
this.$scrollTopBtn.show();
|
||||
|
||||
if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed
|
||||
this.$scrollBottomBtn.show();
|
||||
} else if (this.$buildRefreshAnimation.is(':visible') &&
|
||||
!gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) {
|
||||
this.$scrollBottomBtn.show();
|
||||
} else {
|
||||
this.$scrollBottomBtn.hide();
|
||||
}
|
||||
|
||||
// Hide Autoscroll Status Indicator
|
||||
if (this.$scrollBottomBtn.is(':visible')) {
|
||||
this.$autoScrollContainer.hide();
|
||||
this.$autoScrollStatusText.removeClass('animate');
|
||||
} else {
|
||||
this.$autoScrollContainer.css({
|
||||
top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
|
||||
}).show();
|
||||
this.$autoScrollStatusText.addClass('animate');
|
||||
}
|
||||
} else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
|
||||
!gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
|
||||
// User is at Top of Build Log
|
||||
|
||||
this.$scrollTopBtn.hide();
|
||||
this.$scrollBottomBtn.show();
|
||||
|
||||
this.$autoScrollContainer.hide();
|
||||
this.$autoScrollStatusText.removeClass('animate');
|
||||
} else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
|
||||
gl.utils.isInViewport(this.$downBuildTrace.get(0))) ||
|
||||
(this.$buildRefreshAnimation.is(':visible') &&
|
||||
gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) {
|
||||
// User is at Bottom of Build Log
|
||||
|
||||
this.$scrollTopBtn.show();
|
||||
this.$scrollBottomBtn.hide();
|
||||
|
||||
// Show and Reposition Autoscroll Status Indicator
|
||||
this.$autoScrollContainer.css({
|
||||
top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
|
||||
}).show();
|
||||
this.$autoScrollStatusText.addClass('animate');
|
||||
} else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
|
||||
gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
|
||||
// Build Log height is small
|
||||
|
||||
this.$scrollTopBtn.hide();
|
||||
this.$scrollBottomBtn.hide();
|
||||
|
||||
// Hide Autoscroll Status Indicator
|
||||
this.$autoScrollContainer.hide();
|
||||
this.$autoScrollStatusText.removeClass('animate');
|
||||
}
|
||||
|
||||
if (this.buildStatus === 'running' || this.buildStatus === 'pending') {
|
||||
// Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise.
|
||||
this.$autoScrollStatus.data(
|
||||
'state',
|
||||
gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled',
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Build.prototype.shouldHideSidebarForViewport = function () {
|
||||
|
@ -257,18 +238,23 @@ window.Build = (function () {
|
|||
};
|
||||
|
||||
Build.prototype.toggleSidebar = function (shouldHide) {
|
||||
const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
|
||||
const shouldShow = !shouldHide;
|
||||
|
||||
this.$buildScroll.toggleClass('sidebar-expanded', shouldShow)
|
||||
this.$buildTrace
|
||||
.toggleClass('sidebar-expanded', shouldShow)
|
||||
.toggleClass('sidebar-collapsed', shouldHide);
|
||||
this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow)
|
||||
.toggleClass('sidebar-collapsed', shouldHide);
|
||||
this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow)
|
||||
this.$sidebar
|
||||
.toggleClass('right-sidebar-expanded', shouldShow)
|
||||
.toggleClass('right-sidebar-collapsed', shouldHide);
|
||||
};
|
||||
|
||||
Build.prototype.sidebarOnResize = function () {
|
||||
this.toggleSidebar(this.shouldHideSidebarForViewport());
|
||||
this.verifyTopPosition();
|
||||
|
||||
if (this.$scrollContainer.getNiceScroll(0)) {
|
||||
this.toggleScroll();
|
||||
}
|
||||
};
|
||||
|
||||
Build.prototype.sidebarOnClick = function () {
|
||||
|
@ -301,24 +287,5 @@ window.Build = (function () {
|
|||
this.populateJobs(stage);
|
||||
};
|
||||
|
||||
Build.prototype.stepTrace = function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const $currentTarget = $(e.currentTarget);
|
||||
$.scrollTo($currentTarget.attr('href'), {
|
||||
offset: 0,
|
||||
});
|
||||
};
|
||||
|
||||
Build.prototype.initAffixTruncatedInfo = function () {
|
||||
const offsetTop = this.$buildTrace.offset().top;
|
||||
|
||||
this.$truncatedInfo.affix({
|
||||
offset: {
|
||||
top: offsetTop,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return Build;
|
||||
})();
|
||||
|
|
|
@ -123,7 +123,7 @@ import ShortcutsBlob from './shortcuts_blob';
|
|||
break;
|
||||
case 'projects:merge_requests:index':
|
||||
case 'projects:issues:index':
|
||||
if (gl.FilteredSearchManager) {
|
||||
if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
|
||||
new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
|
||||
}
|
||||
Issuable.init();
|
||||
|
|
|
@ -468,8 +468,8 @@ GitLabDropdown = (function() {
|
|||
|
||||
// Process the data to make sure rendered data
|
||||
// matches the correct layout
|
||||
if (this.fullData && hasMultiSelect && this.options.processData) {
|
||||
const inputValue = this.filterInput.val();
|
||||
const inputValue = this.filterInput.val();
|
||||
if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) {
|
||||
this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this));
|
||||
}
|
||||
|
||||
|
@ -740,6 +740,12 @@ GitLabDropdown = (function() {
|
|||
$input.attr('id', this.options.inputId);
|
||||
}
|
||||
|
||||
if (this.options.multiSelect) {
|
||||
Object.keys(selectedObject).forEach((attribute) => {
|
||||
$input.attr(`data-${attribute}`, selectedObject[attribute]);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.options.inputMeta) {
|
||||
$input.attr('data-meta', selectedObject[this.options.inputMeta]);
|
||||
}
|
||||
|
|
|
@ -42,3 +42,13 @@ export function formatRelevantDigits(number) {
|
|||
export function bytesToKiB(number) {
|
||||
return number / BYTES_IN_KIB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function that calculates MiB of the given bytes.
|
||||
*
|
||||
* @param {Number} number bytes
|
||||
* @return {Number} MiB
|
||||
*/
|
||||
export function bytesToMiB(number) {
|
||||
return number / (BYTES_IN_KIB * BYTES_IN_KIB);
|
||||
}
|
||||
|
|
|
@ -285,7 +285,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
|
|||
// Similar to `toggler_behavior` in the discussion tab
|
||||
const hash = window.gl.utils.getLocationHash();
|
||||
const anchor = hash && $container.find(`[id="${hash}"]`);
|
||||
if (anchor) {
|
||||
if (anchor && anchor.length > 0) {
|
||||
const notesContent = anchor.closest('.notes_content');
|
||||
const lineType = notesContent.hasClass('new') ? 'new' : 'old';
|
||||
notes.toggleDiffNote({
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, default-case, prefer-template, consistent-return, no-alert, no-return-assign, no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, newline-per-chained-call, no-useless-escape */
|
||||
/* eslint-disable no-restricted-properties, func-names, space-before-function-paren,
|
||||
no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase,
|
||||
no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line,
|
||||
default-case, prefer-template, consistent-return, no-alert, no-return-assign,
|
||||
no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new,
|
||||
brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow,
|
||||
newline-per-chained-call, no-useless-escape */
|
||||
/* global Flash */
|
||||
/* global Autosave */
|
||||
/* global ResolveService */
|
||||
|
@ -57,7 +63,7 @@ const normalizeNewlines = function(str) {
|
|||
this.updatedNotesTrackingMap = {};
|
||||
this.last_fetched_at = last_fetched_at;
|
||||
this.noteable_url = document.URL;
|
||||
this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge"));
|
||||
this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
|
||||
this.basePollingInterval = 15000;
|
||||
this.maxPollingSteps = 4;
|
||||
this.flashErrors = [];
|
||||
|
@ -87,61 +93,61 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
Notes.prototype.addBinding = function() {
|
||||
// Edit note link
|
||||
$(document).on("click", ".js-note-edit", this.showEditForm.bind(this));
|
||||
$(document).on("click", ".note-edit-cancel", this.cancelEdit);
|
||||
$(document).on('click', '.js-note-edit', this.showEditForm.bind(this));
|
||||
$(document).on('click', '.note-edit-cancel', this.cancelEdit);
|
||||
// Reopen and close actions for Issue/MR combined with note form submit
|
||||
$(document).on("click", ".js-comment-submit-button", this.postComment);
|
||||
$(document).on("click", ".js-comment-save-button", this.updateComment);
|
||||
$(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
|
||||
$(document).on('click', '.js-comment-submit-button', this.postComment);
|
||||
$(document).on('click', '.js-comment-save-button', this.updateComment);
|
||||
$(document).on('keyup input', '.js-note-text', this.updateTargetButtons);
|
||||
// resolve a discussion
|
||||
$(document).on('click', '.js-comment-resolve-button', this.postComment);
|
||||
// remove a note (in general)
|
||||
$(document).on("click", ".js-note-delete", this.removeNote);
|
||||
$(document).on('click', '.js-note-delete', this.removeNote);
|
||||
// delete note attachment
|
||||
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
|
||||
$(document).on('click', '.js-note-attachment-delete', this.removeAttachment);
|
||||
// reset main target form when clicking discard
|
||||
$(document).on("click", ".js-note-discard", this.resetMainTargetForm);
|
||||
$(document).on('click', '.js-note-discard', this.resetMainTargetForm);
|
||||
// update the file name when an attachment is selected
|
||||
$(document).on("change", ".js-note-attachment-input", this.updateFormAttachment);
|
||||
$(document).on('change', '.js-note-attachment-input', this.updateFormAttachment);
|
||||
// reply to diff/discussion notes
|
||||
$(document).on("click", ".js-discussion-reply-button", this.onReplyToDiscussionNote);
|
||||
$(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote);
|
||||
// add diff note
|
||||
$(document).on("click", ".js-add-diff-note-button", this.onAddDiffNote);
|
||||
$(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote);
|
||||
// hide diff note form
|
||||
$(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm);
|
||||
$(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm);
|
||||
// toggle commit list
|
||||
$(document).on("click", '.system-note-commit-list-toggler', this.toggleCommitList);
|
||||
$(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList);
|
||||
// fetch notes when tab becomes visible
|
||||
$(document).on("visibilitychange", this.visibilityChange);
|
||||
$(document).on('visibilitychange', this.visibilityChange);
|
||||
// when issue status changes, we need to refresh data
|
||||
$(document).on("issuable:change", this.refresh);
|
||||
$(document).on('issuable:change', this.refresh);
|
||||
// ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs.
|
||||
$(document).on("ajax:success", ".js-main-target-form", this.addNote);
|
||||
$(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote);
|
||||
$(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm);
|
||||
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
|
||||
$(document).on('ajax:success', '.js-main-target-form', this.addNote);
|
||||
$(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote);
|
||||
$(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm);
|
||||
$(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton);
|
||||
// when a key is clicked on the notes
|
||||
return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
|
||||
return $(document).on('keydown', '.js-note-text', this.keydownNoteText);
|
||||
};
|
||||
|
||||
Notes.prototype.cleanBinding = function() {
|
||||
$(document).off("click", ".js-note-edit");
|
||||
$(document).off("click", ".note-edit-cancel");
|
||||
$(document).off("click", ".js-note-delete");
|
||||
$(document).off("click", ".js-note-attachment-delete");
|
||||
$(document).off("click", ".js-discussion-reply-button");
|
||||
$(document).off("click", ".js-add-diff-note-button");
|
||||
$(document).off("visibilitychange");
|
||||
$(document).off("keyup input", ".js-note-text");
|
||||
$(document).off("click", ".js-note-target-reopen");
|
||||
$(document).off("click", ".js-note-target-close");
|
||||
$(document).off("click", ".js-note-discard");
|
||||
$(document).off("keydown", ".js-note-text");
|
||||
$(document).off('click', '.js-note-edit');
|
||||
$(document).off('click', '.note-edit-cancel');
|
||||
$(document).off('click', '.js-note-delete');
|
||||
$(document).off('click', '.js-note-attachment-delete');
|
||||
$(document).off('click', '.js-discussion-reply-button');
|
||||
$(document).off('click', '.js-add-diff-note-button');
|
||||
$(document).off('visibilitychange');
|
||||
$(document).off('keyup input', '.js-note-text');
|
||||
$(document).off('click', '.js-note-target-reopen');
|
||||
$(document).off('click', '.js-note-target-close');
|
||||
$(document).off('click', '.js-note-discard');
|
||||
$(document).off('keydown', '.js-note-text');
|
||||
$(document).off('click', '.js-comment-resolve-button');
|
||||
$(document).off("click", '.system-note-commit-list-toggler');
|
||||
$(document).off("ajax:success", ".js-main-target-form");
|
||||
$(document).off("ajax:success", ".js-discussion-note-form");
|
||||
$(document).off("ajax:complete", ".js-main-target-form");
|
||||
$(document).off('click', '.system-note-commit-list-toggler');
|
||||
$(document).off('ajax:success', '.js-main-target-form');
|
||||
$(document).off('ajax:success', '.js-discussion-note-form');
|
||||
$(document).off('ajax:complete', '.js-main-target-form');
|
||||
};
|
||||
|
||||
Notes.initCommentTypeToggle = function (form) {
|
||||
|
@ -231,8 +237,8 @@ const normalizeNewlines = function(str) {
|
|||
this.refreshing = true;
|
||||
return $.ajax({
|
||||
url: this.notes_url,
|
||||
headers: { "X-Last-Fetched-At": this.last_fetched_at },
|
||||
dataType: "json",
|
||||
headers: { 'X-Last-Fetched-At': this.last_fetched_at },
|
||||
dataType: 'json',
|
||||
success: (function(_this) {
|
||||
return function(data) {
|
||||
var notes;
|
||||
|
@ -303,7 +309,7 @@ const normalizeNewlines = function(str) {
|
|||
*/
|
||||
|
||||
Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) {
|
||||
if (noteEntity.discussion_html != null) {
|
||||
if (noteEntity.discussion_html) {
|
||||
return this.renderDiscussionNote(noteEntity, $form);
|
||||
}
|
||||
|
||||
|
@ -368,8 +374,8 @@ const normalizeNewlines = function(str) {
|
|||
return;
|
||||
}
|
||||
this.note_ids.push(noteEntity.id);
|
||||
form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']");
|
||||
row = form.closest("tr");
|
||||
form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`);
|
||||
row = form.closest('tr');
|
||||
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
|
||||
diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line');
|
||||
// is this the first note of discussion?
|
||||
|
@ -386,7 +392,7 @@ const normalizeNewlines = function(str) {
|
|||
row.after($discussion);
|
||||
} else {
|
||||
// Merge new discussion HTML in
|
||||
var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]');
|
||||
var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`);
|
||||
var contentContainerClass = '.' + $notes.closest('.notes_content')
|
||||
.attr('class')
|
||||
.split(' ')
|
||||
|
@ -397,7 +403,7 @@ const normalizeNewlines = function(str) {
|
|||
}
|
||||
// Init discussion on 'Discussion' page if it is merge request page
|
||||
const page = $('body').attr('data-page');
|
||||
if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) {
|
||||
if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) {
|
||||
Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list'));
|
||||
}
|
||||
} else {
|
||||
|
@ -450,13 +456,13 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
Notes.prototype.resetMainTargetForm = function(e) {
|
||||
var form;
|
||||
form = $(".js-main-target-form");
|
||||
form = $('.js-main-target-form');
|
||||
// remove validation errors
|
||||
form.find(".js-errors").remove();
|
||||
form.find('.js-errors').remove();
|
||||
// reset text and preview
|
||||
form.find(".js-md-write-button").click();
|
||||
form.find(".js-note-text").val("").trigger("input");
|
||||
form.find(".js-note-text").data("autosave").reset();
|
||||
form.find('.js-md-write-button').click();
|
||||
form.find('.js-note-text').val('').trigger('input');
|
||||
form.find('.js-note-text').data('autosave').reset();
|
||||
|
||||
var event = document.createEvent('Event');
|
||||
event.initEvent('autosize:update', true, false);
|
||||
|
@ -467,8 +473,8 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
Notes.prototype.reenableTargetFormSubmitButton = function() {
|
||||
var form;
|
||||
form = $(".js-main-target-form");
|
||||
return form.find(".js-note-text").trigger("input");
|
||||
form = $('.js-main-target-form');
|
||||
return form.find('.js-note-text').trigger('input');
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -480,18 +486,18 @@ const normalizeNewlines = function(str) {
|
|||
Notes.prototype.setupMainTargetNoteForm = function() {
|
||||
var form;
|
||||
// find the form
|
||||
form = $(".js-new-note-form");
|
||||
form = $('.js-new-note-form');
|
||||
// Set a global clone of the form for later cloning
|
||||
this.formClone = form.clone();
|
||||
// show the form
|
||||
this.setupNoteForm(form);
|
||||
// fix classes
|
||||
form.removeClass("js-new-note-form");
|
||||
form.addClass("js-main-target-form");
|
||||
form.find("#note_line_code").remove();
|
||||
form.find("#note_position").remove();
|
||||
form.find("#note_type").val('');
|
||||
form.find("#in_reply_to_discussion_id").remove();
|
||||
form.removeClass('js-new-note-form');
|
||||
form.addClass('js-main-target-form');
|
||||
form.find('#note_line_code').remove();
|
||||
form.find('#note_position').remove();
|
||||
form.find('#note_type').val('');
|
||||
form.find('#in_reply_to_discussion_id').remove();
|
||||
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
|
||||
this.parentTimeline = form.parents('.timeline');
|
||||
|
||||
|
@ -512,20 +518,20 @@ const normalizeNewlines = function(str) {
|
|||
Notes.prototype.setupNoteForm = function(form) {
|
||||
var textarea, key;
|
||||
new gl.GLForm(form, this.enableGFM);
|
||||
textarea = form.find(".js-note-text");
|
||||
textarea = form.find('.js-note-text');
|
||||
key = [
|
||||
"Note",
|
||||
form.find("#note_noteable_type").val(),
|
||||
form.find("#note_noteable_id").val(),
|
||||
form.find("#note_commit_id").val(),
|
||||
form.find("#note_type").val(),
|
||||
form.find("#in_reply_to_discussion_id").val(),
|
||||
'Note',
|
||||
form.find('#note_noteable_type').val(),
|
||||
form.find('#note_noteable_id').val(),
|
||||
form.find('#note_commit_id').val(),
|
||||
form.find('#note_type').val(),
|
||||
form.find('#in_reply_to_discussion_id').val(),
|
||||
|
||||
// LegacyDiffNote
|
||||
form.find("#note_line_code").val(),
|
||||
form.find('#note_line_code').val(),
|
||||
|
||||
// DiffNote
|
||||
form.find("#note_position").val()
|
||||
form.find('#note_position').val()
|
||||
];
|
||||
return new Autosave(textarea, key);
|
||||
};
|
||||
|
@ -670,7 +676,8 @@ const normalizeNewlines = function(str) {
|
|||
const $newNote = $(this.updatedNotesTrackingMap[noteId].html);
|
||||
$note.replaceWith($newNote);
|
||||
this.setupNewNote($newNote);
|
||||
this.updatedNotesTrackingMap[noteId] = null;
|
||||
// Now that we have taken care of the update, clear it out
|
||||
delete this.updatedNotesTrackingMap[noteId];
|
||||
}
|
||||
else {
|
||||
$note.find('.js-finish-edit-warning').hide();
|
||||
|
@ -722,14 +729,14 @@ const normalizeNewlines = function(str) {
|
|||
lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]')
|
||||
.closest('.notes_holder')
|
||||
.prev('.line_holder');
|
||||
$(".note[id='" + noteElId + "']").each((function(_this) {
|
||||
$(`.note[id="${noteElId}"]`).each((function(_this) {
|
||||
// A same note appears in the "Discussion" and in the "Changes" tab, we have
|
||||
// to remove all. Using $(".note[id='noteId']") ensure we get all the notes,
|
||||
// where $("#noteId") would return only one.
|
||||
// to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
|
||||
// where $('#noteId') would return only one.
|
||||
return function(i, el) {
|
||||
var $note, $notes;
|
||||
$note = $(el);
|
||||
$notes = $note.closest(".discussion-notes");
|
||||
$notes = $note.closest('.discussion-notes');
|
||||
|
||||
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
|
||||
if (gl.diffNoteApps[noteElId]) {
|
||||
|
@ -740,11 +747,11 @@ const normalizeNewlines = function(str) {
|
|||
$note.remove();
|
||||
|
||||
// check if this is the last note for this line
|
||||
if ($notes.find(".note").length === 0) {
|
||||
var notesTr = $notes.closest("tr");
|
||||
if ($notes.find('.note').length === 0) {
|
||||
var notesTr = $notes.closest('tr');
|
||||
|
||||
// "Discussions" tab
|
||||
$notes.closest(".timeline-entry").remove();
|
||||
$notes.closest('.timeline-entry').remove();
|
||||
|
||||
// The notes tr can contain multiple lists of notes, like on the parallel diff
|
||||
if (notesTr.find('.discussion-notes').length > 1) {
|
||||
|
@ -768,11 +775,11 @@ const normalizeNewlines = function(str) {
|
|||
*/
|
||||
|
||||
Notes.prototype.removeAttachment = function() {
|
||||
const $note = $(this).closest(".note");
|
||||
$note.find(".note-attachment").remove();
|
||||
$note.find(".note-body > .note-text").show();
|
||||
$note.find(".note-header").show();
|
||||
return $note.find(".current-note-edit-form").remove();
|
||||
const $note = $(this).closest('.note');
|
||||
$note.find('.note-attachment').remove();
|
||||
$note.find('.note-body > .note-text').show();
|
||||
$note.find('.note-header').show();
|
||||
return $note.find('.current-note-edit-form').remove();
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -788,7 +795,7 @@ const normalizeNewlines = function(str) {
|
|||
Notes.prototype.replyToDiscussionNote = function(target) {
|
||||
var form, replyLink;
|
||||
form = this.cleanForm(this.formClone.clone());
|
||||
replyLink = $(target).closest(".js-discussion-reply-button");
|
||||
replyLink = $(target).closest('.js-discussion-reply-button');
|
||||
// insert the form after the button
|
||||
replyLink
|
||||
.closest('.discussion-reply-holder')
|
||||
|
@ -808,26 +815,26 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) {
|
||||
// setup note target
|
||||
var discussionID = dataHolder.data("discussionId");
|
||||
var discussionID = dataHolder.data('discussionId');
|
||||
|
||||
if (discussionID) {
|
||||
form.attr("data-discussion-id", discussionID);
|
||||
form.find("#in_reply_to_discussion_id").val(discussionID);
|
||||
form.attr('data-discussion-id', discussionID);
|
||||
form.find('#in_reply_to_discussion_id').val(discussionID);
|
||||
}
|
||||
|
||||
form.attr("data-line-code", dataHolder.data("lineCode"));
|
||||
form.find("#line_type").val(dataHolder.data("lineType"));
|
||||
form.attr('data-line-code', dataHolder.data('lineCode'));
|
||||
form.find('#line_type').val(dataHolder.data('lineType'));
|
||||
|
||||
form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
|
||||
form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
|
||||
form.find("#note_commit_id").val(dataHolder.data("commitId"));
|
||||
form.find("#note_type").val(dataHolder.data("noteType"));
|
||||
form.find('#note_noteable_type').val(dataHolder.data('noteableType'));
|
||||
form.find('#note_noteable_id').val(dataHolder.data('noteableId'));
|
||||
form.find('#note_commit_id').val(dataHolder.data('commitId'));
|
||||
form.find('#note_type').val(dataHolder.data('noteType'));
|
||||
|
||||
// LegacyDiffNote
|
||||
form.find("#note_line_code").val(dataHolder.data("lineCode"));
|
||||
form.find('#note_line_code').val(dataHolder.data('lineCode'));
|
||||
|
||||
// DiffNote
|
||||
form.find("#note_position").val(dataHolder.attr("data-position"));
|
||||
form.find('#note_position').val(dataHolder.attr('data-position'));
|
||||
|
||||
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
|
||||
form.find('.js-note-target-close').remove();
|
||||
|
@ -836,7 +843,7 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
form
|
||||
.removeClass('js-main-target-form')
|
||||
.addClass("discussion-form js-discussion-note-form");
|
||||
.addClass('discussion-form js-discussion-note-form');
|
||||
|
||||
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
|
||||
var $commentBtn = form.find('comment-and-resolve-btn');
|
||||
|
@ -845,7 +852,7 @@ const normalizeNewlines = function(str) {
|
|||
gl.diffNotesCompileComponents();
|
||||
}
|
||||
|
||||
form.find(".js-note-text").focus();
|
||||
form.find('.js-note-text').focus();
|
||||
form
|
||||
.find('.js-comment-resolve-button')
|
||||
.attr('data-discussion-id', discussionID);
|
||||
|
@ -878,21 +885,21 @@ const normalizeNewlines = function(str) {
|
|||
}) {
|
||||
var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar;
|
||||
$link = $(target);
|
||||
row = $link.closest("tr");
|
||||
row = $link.closest('tr');
|
||||
const nextRow = row.next();
|
||||
let targetRow = row;
|
||||
if (nextRow.is('.notes_holder')) {
|
||||
targetRow = nextRow;
|
||||
}
|
||||
|
||||
hasNotes = targetRow.is(".notes_holder");
|
||||
hasNotes = nextRow.is('.notes_holder');
|
||||
addForm = false;
|
||||
let lineTypeSelector = '';
|
||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>";
|
||||
rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>';
|
||||
// In parallel view, look inside the correct left/right pane
|
||||
if (this.isParallelView()) {
|
||||
lineTypeSelector = `.${lineType}`;
|
||||
rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>";
|
||||
rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>';
|
||||
}
|
||||
const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
|
||||
let notesContent = targetRow.find(notesContentSelector);
|
||||
|
@ -902,12 +909,12 @@ const normalizeNewlines = function(str) {
|
|||
notesContent = targetRow.find(notesContentSelector);
|
||||
if (notesContent.length) {
|
||||
notesContent.show();
|
||||
replyButton = notesContent.find(".js-discussion-reply-button:visible");
|
||||
replyButton = notesContent.find('.js-discussion-reply-button:visible');
|
||||
if (replyButton.length) {
|
||||
this.replyToDiscussionNote(replyButton[0]);
|
||||
} else {
|
||||
// In parallel view, the form may not be present in one of the panes
|
||||
noteForm = notesContent.find(".js-discussion-note-form");
|
||||
noteForm = notesContent.find('.js-discussion-note-form');
|
||||
if (noteForm.length === 0) {
|
||||
addForm = true;
|
||||
}
|
||||
|
@ -945,15 +952,15 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
Notes.prototype.removeDiscussionNoteForm = function(form) {
|
||||
var glForm, row;
|
||||
row = form.closest("tr");
|
||||
row = form.closest('tr');
|
||||
glForm = form.data('gl-form');
|
||||
glForm.destroy();
|
||||
form.find(".js-note-text").data("autosave").reset();
|
||||
form.find('.js-note-text').data('autosave').reset();
|
||||
// show the reply button (will only work for replies)
|
||||
form
|
||||
.prev('.discussion-reply-holder')
|
||||
.show();
|
||||
if (row.is(".js-temp-notes-holder")) {
|
||||
if (row.is('.js-temp-notes-holder')) {
|
||||
// remove temporary row for diff lines
|
||||
return row.remove();
|
||||
} else {
|
||||
|
@ -965,7 +972,7 @@ const normalizeNewlines = function(str) {
|
|||
Notes.prototype.cancelDiscussionForm = function(e) {
|
||||
var form;
|
||||
e.preventDefault();
|
||||
form = $(e.target).closest(".js-discussion-note-form");
|
||||
form = $(e.target).closest('.js-discussion-note-form');
|
||||
return this.removeDiscussionNoteForm(form);
|
||||
};
|
||||
|
||||
|
@ -977,10 +984,10 @@ const normalizeNewlines = function(str) {
|
|||
|
||||
Notes.prototype.updateFormAttachment = function() {
|
||||
var filename, form;
|
||||
form = $(this).closest("form");
|
||||
form = $(this).closest('form');
|
||||
// get only the basename
|
||||
filename = $(this).val().replace(/^.*[\\\/]/, "");
|
||||
return form.find(".js-attachment-filename").text(filename);
|
||||
filename = $(this).val().replace(/^.*[\\\/]/, '');
|
||||
return form.find('.js-attachment-filename').text(filename);
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -1212,7 +1219,7 @@ const normalizeNewlines = function(str) {
|
|||
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
|
||||
<div class="timeline-entry-inner">
|
||||
<div class="timeline-icon">
|
||||
<a href="/${currentUsername}"><span class="dummy-avatar"></span></a>
|
||||
<a href="/${currentUsername}"><span class="avatar dummy-avatar"></span></a>
|
||||
</div>
|
||||
<div class="timeline-content ${discussionClass}">
|
||||
<div class="note-header">
|
||||
|
|
|
@ -1,68 +1,32 @@
|
|||
<script>
|
||||
/* global Flash */
|
||||
import Visibility from 'visibilityjs';
|
||||
import Poll from '../../../lib/utils/poll';
|
||||
import PipelineService from '../../services/pipeline_service';
|
||||
import PipelineStore from '../../stores/pipeline_store';
|
||||
import stageColumnComponent from './stage_column_component.vue';
|
||||
import loadingIcon from '../../../vue_shared/components/loading_icon.vue';
|
||||
import '../../../flash';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
pipeline: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
components: {
|
||||
stageColumnComponent,
|
||||
loadingIcon,
|
||||
},
|
||||
|
||||
data() {
|
||||
const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset;
|
||||
const store = new PipelineStore();
|
||||
|
||||
return {
|
||||
isLoading: false,
|
||||
endpoint: DOMdata.endpoint,
|
||||
store,
|
||||
state: store.state,
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
this.service = new PipelineService(this.endpoint);
|
||||
|
||||
const poll = new Poll({
|
||||
resource: this.service,
|
||||
method: 'getPipeline',
|
||||
successCallback: this.successCallback,
|
||||
errorCallback: this.errorCallback,
|
||||
});
|
||||
|
||||
if (!Visibility.hidden()) {
|
||||
this.isLoading = true;
|
||||
poll.makeRequest();
|
||||
}
|
||||
|
||||
Visibility.change(() => {
|
||||
if (!Visibility.hidden()) {
|
||||
poll.restart();
|
||||
} else {
|
||||
poll.stop();
|
||||
}
|
||||
});
|
||||
computed: {
|
||||
graph() {
|
||||
return this.pipeline.details && this.pipeline.details.stages;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
successCallback(response) {
|
||||
const data = response.json();
|
||||
|
||||
this.isLoading = false;
|
||||
this.store.storeGraph(data.details.stages);
|
||||
},
|
||||
|
||||
errorCallback() {
|
||||
this.isLoading = false;
|
||||
return new Flash('An error occurred while fetching the pipeline.');
|
||||
},
|
||||
|
||||
capitalizeStageName(name) {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
},
|
||||
|
@ -101,7 +65,7 @@
|
|||
v-if="!isLoading"
|
||||
class="stage-column-list">
|
||||
<stage-column-component
|
||||
v-for="(stage, index) in state.graph"
|
||||
v-for="(stage, index) in graph"
|
||||
:title="capitalizeStageName(stage.name)"
|
||||
:jobs="stage.groups"
|
||||
:key="stage.name"
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
|
||||
export default {
|
||||
props: [
|
||||
'pipeline',
|
||||
],
|
||||
computed: {
|
||||
user() {
|
||||
return !!this.pipeline.user;
|
||||
},
|
||||
},
|
||||
components: {
|
||||
userAvatarLink,
|
||||
},
|
||||
template: `
|
||||
<td>
|
||||
<a
|
||||
:href="pipeline.path"
|
||||
class="js-pipeline-url-link">
|
||||
<span class="pipeline-id">#{{pipeline.id}}</span>
|
||||
</a>
|
||||
<span>by</span>
|
||||
<user-avatar-link
|
||||
v-if="user"
|
||||
class="js-pipeline-url-user"
|
||||
:link-href="pipeline.user.web_url"
|
||||
:img-src="pipeline.user.avatar_url"
|
||||
:tooltip-text="pipeline.user.name"
|
||||
/>
|
||||
<span
|
||||
v-if="!user"
|
||||
class="js-pipeline-url-api api">
|
||||
API
|
||||
</span>
|
||||
<span
|
||||
v-if="pipeline.flags.latest"
|
||||
class="js-pipeline-url-lastest label label-success has-tooltip"
|
||||
title="Latest pipeline for this branch"
|
||||
data-original-title="Latest pipeline for this branch">
|
||||
latest
|
||||
</span>
|
||||
<span
|
||||
v-if="pipeline.flags.yaml_errors"
|
||||
class="js-pipeline-url-yaml label label-danger has-tooltip"
|
||||
:title="pipeline.yaml_errors"
|
||||
:data-original-title="pipeline.yaml_errors">
|
||||
yaml invalid
|
||||
</span>
|
||||
<span
|
||||
v-if="pipeline.flags.stuck"
|
||||
class="js-pipeline-url-stuck label label-warning">
|
||||
stuck
|
||||
</span>
|
||||
</td>
|
||||
`,
|
||||
};
|
65
app/assets/javascripts/pipelines/components/pipeline_url.vue
Normal file
65
app/assets/javascripts/pipelines/components/pipeline_url.vue
Normal file
|
@ -0,0 +1,65 @@
|
|||
<script>
|
||||
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
import tooltipMixin from '../../vue_shared/mixins/tooltip';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
pipeline: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
userAvatarLink,
|
||||
},
|
||||
mixins: [
|
||||
tooltipMixin,
|
||||
],
|
||||
computed: {
|
||||
user() {
|
||||
return this.pipeline.user;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<td>
|
||||
<a
|
||||
:href="pipeline.path"
|
||||
class="js-pipeline-url-link">
|
||||
<span class="pipeline-id">#{{pipeline.id}}</span>
|
||||
</a>
|
||||
<span>by</span>
|
||||
<user-avatar-link
|
||||
v-if="user"
|
||||
class="js-pipeline-url-user"
|
||||
:link-href="pipeline.user.web_url"
|
||||
:img-src="pipeline.user.avatar_url"
|
||||
:tooltip-text="pipeline.user.name"
|
||||
/>
|
||||
<span
|
||||
v-if="!user"
|
||||
class="js-pipeline-url-api api">
|
||||
API
|
||||
</span>
|
||||
<span
|
||||
v-if="pipeline.flags.latest"
|
||||
class="js-pipeline-url-lastest label label-success"
|
||||
title="Latest pipeline for this branch"
|
||||
ref="tooltip">
|
||||
latest
|
||||
</span>
|
||||
<span
|
||||
v-if="pipeline.flags.yaml_errors"
|
||||
class="js-pipeline-url-yaml label label-danger"
|
||||
:title="pipeline.yaml_errors"
|
||||
ref="tooltip">
|
||||
yaml invalid
|
||||
</span>
|
||||
<span
|
||||
v-if="pipeline.flags.stuck"
|
||||
class="js-pipeline-url-stuck label label-warning">
|
||||
stuck
|
||||
</span>
|
||||
</td>
|
||||
</template>
|
|
@ -1,10 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import pipelineGraph from './components/graph/graph_component.vue';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => new Vue({
|
||||
el: '#js-pipeline-graph-vue',
|
||||
components: {
|
||||
pipelineGraph,
|
||||
},
|
||||
render: createElement => createElement('pipeline-graph'),
|
||||
}));
|
33
app/assets/javascripts/pipelines/pipeline_details_bundle.js
Normal file
33
app/assets/javascripts/pipelines/pipeline_details_bundle.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import Vue from 'vue';
|
||||
import PipelinesMediator from './pipeline_details_mediatior';
|
||||
import pipelineGraph from './components/graph/graph_component.vue';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
|
||||
|
||||
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
|
||||
|
||||
mediator.fetchPipeline();
|
||||
|
||||
const pipelineGraphApp = new Vue({
|
||||
el: '#js-pipeline-graph-vue',
|
||||
data() {
|
||||
return {
|
||||
mediator,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
pipelineGraph,
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement('pipeline-graph', {
|
||||
props: {
|
||||
isLoading: this.mediator.state.isLoading,
|
||||
pipeline: this.mediator.store.state.pipeline,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return pipelineGraphApp;
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
/* global Flash */
|
||||
|
||||
import Visibility from 'visibilityjs';
|
||||
import Poll from '../lib/utils/poll';
|
||||
import PipelineStore from './stores/pipeline_store';
|
||||
import PipelineService from './services/pipeline_service';
|
||||
|
||||
export default class pipelinesMediator {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
this.store = new PipelineStore();
|
||||
this.service = new PipelineService(options.endpoint);
|
||||
|
||||
this.state = {};
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
|
||||
fetchPipeline() {
|
||||
this.poll = new Poll({
|
||||
resource: this.service,
|
||||
method: 'getPipeline',
|
||||
successCallback: this.successCallback.bind(this),
|
||||
errorCallback: this.errorCallback.bind(this),
|
||||
});
|
||||
|
||||
if (!Visibility.hidden()) {
|
||||
this.state.isLoading = true;
|
||||
this.poll.makeRequest();
|
||||
}
|
||||
|
||||
Visibility.change(() => {
|
||||
if (!Visibility.hidden()) {
|
||||
this.poll.restart();
|
||||
} else {
|
||||
this.poll.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
successCallback(response) {
|
||||
const data = response.json();
|
||||
|
||||
this.state.isLoading = false;
|
||||
this.store.storePipeline(data);
|
||||
}
|
||||
|
||||
errorCallback() {
|
||||
this.state.isLoading = false;
|
||||
return new Flash('An error occurred while fetching the pipeline.');
|
||||
}
|
||||
}
|
|
@ -2,10 +2,10 @@ export default class PipelineStore {
|
|||
constructor() {
|
||||
this.state = {};
|
||||
|
||||
this.state.graph = [];
|
||||
this.state.pipeline = {};
|
||||
}
|
||||
|
||||
storeGraph(graph = []) {
|
||||
this.state.graph = graph;
|
||||
storePipeline(pipeline = {}) {
|
||||
this.state.pipeline = pipeline;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
window.SingleFileDiff = (function() {
|
||||
var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER;
|
||||
|
||||
WRAPPER = '<div class="diff-content diff-wrap-lines"></div>';
|
||||
WRAPPER = '<div class="diff-content"></div>';
|
||||
|
||||
LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
|
||||
|
|
|
@ -35,6 +35,7 @@ function UsersSelect(currentUser, els) {
|
|||
options.showCurrentUser = $dropdown.data('current-user');
|
||||
options.todoFilter = $dropdown.data('todo-filter');
|
||||
options.todoStateFilter = $dropdown.data('todo-state-filter');
|
||||
options.perPage = $dropdown.data('per-page');
|
||||
showNullUser = $dropdown.data('null-user');
|
||||
defaultNullUser = $dropdown.data('null-user-default');
|
||||
showMenuAbove = $dropdown.data('showMenuAbove');
|
||||
|
@ -214,7 +215,36 @@ function UsersSelect(currentUser, els) {
|
|||
glDropdown.options.processData(term, users, callback);
|
||||
}.bind(this));
|
||||
},
|
||||
processData: function(term, users, callback) {
|
||||
processData: function(term, data, callback) {
|
||||
let users = data;
|
||||
|
||||
// Only show assigned user list when there is no search term
|
||||
if ($dropdown.hasClass('js-multiselect') && term.length === 0) {
|
||||
const selectedInputs = getSelectedUserInputs();
|
||||
|
||||
// Potential duplicate entries when dealing with issue board
|
||||
// because issue board is also managed by vue
|
||||
const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
|
||||
.filter((input) => {
|
||||
const userId = parseInt(input.value, 10);
|
||||
const inUsersArray = users.find(u => u.id === userId);
|
||||
|
||||
return !inUsersArray && userId !== 0;
|
||||
})
|
||||
.map((input) => {
|
||||
const userId = parseInt(input.value, 10);
|
||||
const { avatarUrl, avatar_url, name, username } = input.dataset;
|
||||
return {
|
||||
avatar_url: avatarUrl || avatar_url,
|
||||
id: userId,
|
||||
name,
|
||||
username,
|
||||
};
|
||||
});
|
||||
|
||||
users = data.concat(selectedUsers);
|
||||
}
|
||||
|
||||
let anyUser;
|
||||
let index;
|
||||
let j;
|
||||
|
@ -645,7 +675,7 @@ UsersSelect.prototype.users = function(query, options, callback) {
|
|||
url: url,
|
||||
data: {
|
||||
search: query,
|
||||
per_page: 20,
|
||||
per_page: options.perPage || 20,
|
||||
active: true,
|
||||
project_id: options.projectId || null,
|
||||
group_id: options.groupId || null,
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import statusCodes from '~/lib/utils/http_status';
|
||||
import { bytesToMiB } from '~/lib/utils/number_utils';
|
||||
|
||||
import MemoryGraph from '../../vue_shared/components/memory_graph';
|
||||
import MRWidgetService from '../services/mr_widget_service';
|
||||
|
||||
|
@ -9,8 +11,8 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
// memoryFrom: 0,
|
||||
// memoryTo: 0,
|
||||
memoryFrom: 0,
|
||||
memoryTo: 0,
|
||||
memoryMetrics: [],
|
||||
deploymentTime: 0,
|
||||
hasMetrics: false,
|
||||
|
@ -35,18 +37,38 @@ export default {
|
|||
shouldShowMetricsUnavailable() {
|
||||
return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
|
||||
},
|
||||
memoryChangeType() {
|
||||
const memoryTo = Number(this.memoryTo);
|
||||
const memoryFrom = Number(this.memoryFrom);
|
||||
|
||||
if (memoryTo > memoryFrom) {
|
||||
return 'increased';
|
||||
} else if (memoryTo < memoryFrom) {
|
||||
return 'decreased';
|
||||
}
|
||||
|
||||
return 'unchanged';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getMegabytes(bytesString) {
|
||||
const valueInBytes = Number(bytesString).toFixed(2);
|
||||
return (bytesToMiB(valueInBytes)).toFixed(2);
|
||||
},
|
||||
computeGraphData(metrics, deploymentTime) {
|
||||
this.loadingMetrics = false;
|
||||
const { memory_values } = metrics;
|
||||
// if (memory_previous.length > 0) {
|
||||
// this.memoryFrom = Number(memory_previous[0].value[1]).toFixed(2);
|
||||
// }
|
||||
//
|
||||
// if (memory_current.length > 0) {
|
||||
// this.memoryTo = Number(memory_current[0].value[1]).toFixed(2);
|
||||
// }
|
||||
const { memory_before, memory_after, memory_values } = metrics;
|
||||
|
||||
// Both `memory_before` and `memory_after` objects
|
||||
// have peculiar structure where accessing only a specific
|
||||
// index yeilds correct value that we can use to show memory delta.
|
||||
if (memory_before.length > 0) {
|
||||
this.memoryFrom = this.getMegabytes(memory_before[0].value[1]);
|
||||
}
|
||||
|
||||
if (memory_after.length > 0) {
|
||||
this.memoryTo = this.getMegabytes(memory_after[0].value[1]);
|
||||
}
|
||||
|
||||
if (memory_values.length > 0) {
|
||||
this.hasMetrics = true;
|
||||
|
@ -102,7 +124,7 @@ export default {
|
|||
<p
|
||||
v-if="shouldShowMemoryGraph"
|
||||
class="usage-info js-usage-info">
|
||||
Deployment memory usage:
|
||||
Memory usage <b>{{memoryChangeType}}</b> from {{memoryFrom}}MB to {{memoryTo}}MB
|
||||
</p>
|
||||
<p
|
||||
v-if="shouldShowLoadFailure"
|
||||
|
|
|
@ -13,7 +13,7 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
removeSourceBranch: true,
|
||||
removeSourceBranch: this.mr.shouldRemoveSourceBranch,
|
||||
mergeWhenBuildSucceeds: false,
|
||||
useCommitMessageWithDescription: false,
|
||||
setToMergeWhenPipelineSucceeds: false,
|
||||
|
@ -69,6 +69,9 @@ export default {
|
|||
|| this.isMakingRequest
|
||||
|| this.mr.preventMerge);
|
||||
},
|
||||
isRemoveSourceBranchButtonDisabled() {
|
||||
return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch;
|
||||
},
|
||||
shouldShowSquashBeforeMerge() {
|
||||
const { commitsCount, enableSquashBeforeMerge } = this.mr;
|
||||
return enableSquashBeforeMerge && commitsCount > 1;
|
||||
|
@ -252,8 +255,9 @@ export default {
|
|||
<template v-if="isMergeAllowed()">
|
||||
<label class="spacing">
|
||||
<input
|
||||
id="remove-source-branch-input"
|
||||
v-model="removeSourceBranch"
|
||||
:disabled="isMergeButtonDisabled"
|
||||
:disabled="isRemoveSourceBranchButtonDisabled"
|
||||
type="checkbox"/> Remove source branch
|
||||
</label>
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ export default class MergeRequestStore {
|
|||
this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path;
|
||||
this.removeWIPPath = data.remove_wip_path;
|
||||
this.sourceBranchRemoved = !data.source_branch_exists;
|
||||
this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false;
|
||||
this.shouldRemoveSourceBranch = data.remove_source_branch || false;
|
||||
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
|
||||
this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false;
|
||||
this.mergePath = data.merge_path;
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
<script>
|
||||
import ciIconBadge from './ci_badge_link.vue';
|
||||
import timeagoTooltip from './time_ago_tooltip.vue';
|
||||
import tooltipMixin from '../mixins/tooltip';
|
||||
import userAvatarLink from './user_avatar/user_avatar_link.vue';
|
||||
|
||||
/**
|
||||
* Renders header component for job and pipeline page based on UI mockups
|
||||
*
|
||||
* Used in:
|
||||
* - job show page
|
||||
* - pipeline show page
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
status: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
itemName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
itemId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
time: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
actions: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [
|
||||
tooltipMixin,
|
||||
],
|
||||
|
||||
components: {
|
||||
ciIconBadge,
|
||||
timeagoTooltip,
|
||||
userAvatarLink,
|
||||
},
|
||||
|
||||
computed: {
|
||||
userAvatarAltText() {
|
||||
return `${this.user.name}'s avatar`;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClickAction(action) {
|
||||
this.$emit('postAction', action);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<header class="page-content-header top-area">
|
||||
<section class="header-main-content">
|
||||
|
||||
<ci-icon-badge :status="status" />
|
||||
|
||||
<strong>
|
||||
{{itemName}} #{{itemId}}
|
||||
</strong>
|
||||
|
||||
triggered
|
||||
|
||||
<timeago-tooltip :time="time" />
|
||||
|
||||
by
|
||||
|
||||
<user-avatar-link
|
||||
:link-href="user.web_url"
|
||||
:img-src="user.avatar_url"
|
||||
:img-alt="userAvatarAltText"
|
||||
:tooltip-text="user.name"
|
||||
:img-size="24"
|
||||
/>
|
||||
|
||||
<a
|
||||
:href="user.web_url"
|
||||
:title="user.email"
|
||||
class="js-user-link commit-committer-link"
|
||||
ref="tooltip">
|
||||
{{user.name}}
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="header-action-button nav-controls"
|
||||
v-if="actions.length">
|
||||
<template
|
||||
v-for="action in actions">
|
||||
<a
|
||||
v-if="action.type === 'link'"
|
||||
:href="action.path"
|
||||
:class="action.cssClass">
|
||||
{{action.label}}
|
||||
</a>
|
||||
|
||||
<button
|
||||
v-else="action.type === 'button'"
|
||||
@click="onClickAction(action)"
|
||||
:class="action.cssClass"
|
||||
type="button">
|
||||
{{action.label}}
|
||||
</button>
|
||||
|
||||
</template>
|
||||
</section>
|
||||
</header>
|
||||
</template>
|
|
@ -4,7 +4,7 @@ import PipelinesActionsComponent from '../../pipelines/components/pipelines_acti
|
|||
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
|
||||
import ciBadge from './ci_badge_link.vue';
|
||||
import PipelinesStageComponent from '../../pipelines/components/stage.vue';
|
||||
import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
|
||||
import PipelinesUrlComponent from '../../pipelines/components/pipeline_url.vue';
|
||||
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
|
||||
import CommitComponent from './commit';
|
||||
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
<script>
|
||||
import tooltipMixin from '../mixins/tooltip';
|
||||
import timeagoMixin from '../mixins/timeago';
|
||||
import '../../lib/utils/datetime_utility';
|
||||
|
||||
/**
|
||||
* Port of ruby helper time_ago_with_tooltip
|
||||
*/
|
||||
|
||||
export default {
|
||||
props: {
|
||||
time: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
tooltipPlacement: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'top',
|
||||
},
|
||||
|
||||
shortFormat: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
cssClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [
|
||||
tooltipMixin,
|
||||
timeagoMixin,
|
||||
],
|
||||
|
||||
computed: {
|
||||
timeagoCssClass() {
|
||||
return this.shortFormat ? 'js-short-timeago' : 'js-timeago';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<time
|
||||
:class="[timeagoCssClass, cssClass]"
|
||||
class="js-timeago js-timeago-render"
|
||||
:title="tooltipTitle(time)"
|
||||
:data-placement="tooltipPlacement"
|
||||
data-container="body"
|
||||
ref="tooltip">
|
||||
{{timeFormated(time)}}
|
||||
</time>
|
||||
</template>
|
18
app/assets/javascripts/vue_shared/mixins/timeago.js
Normal file
18
app/assets/javascripts/vue_shared/mixins/timeago.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
import '../../lib/utils/datetime_utility';
|
||||
|
||||
/**
|
||||
* Mixin with time ago methods used in some vue components
|
||||
*/
|
||||
export default {
|
||||
methods: {
|
||||
timeFormated(time) {
|
||||
const timeago = gl.utils.getTimeago();
|
||||
|
||||
return timeago.format(time);
|
||||
},
|
||||
|
||||
tooltipTitle(time) {
|
||||
return gl.utils.formatDate(time);
|
||||
},
|
||||
},
|
||||
};
|
|
@ -34,6 +34,7 @@
|
|||
@import "framework/selects.scss";
|
||||
@import "framework/sidebar.scss";
|
||||
@import "framework/tables.scss";
|
||||
@import "framework/notes.scss";
|
||||
@import "framework/timeline.scss";
|
||||
@import "framework/typography.scss";
|
||||
@import "framework/zen.scss";
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
top: 0;
|
||||
margin-top: 3px;
|
||||
padding: $gl-padding;
|
||||
z-index: 9;
|
||||
z-index: 300;
|
||||
width: 300px;
|
||||
font-size: 14px;
|
||||
background-color: $white-light;
|
||||
|
|
|
@ -23,7 +23,6 @@
|
|||
|
||||
.row-content-block {
|
||||
margin-top: 0;
|
||||
margin-bottom: -$gl-padding;
|
||||
background-color: $gray-light;
|
||||
padding: $gl-padding;
|
||||
margin-bottom: 0;
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
gl-emoji {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
|
|
|
@ -66,10 +66,10 @@
|
|||
&.video {
|
||||
background: $file-image-bg;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
|
||||
img,
|
||||
video {
|
||||
padding: 20px;
|
||||
max-width: 80%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,10 @@
|
|||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:empty {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $screen-sm-max) {
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
> li {
|
||||
padding: 10px 15px;
|
||||
min-height: 20px;
|
||||
border-bottom: 1px solid $list-border-light;
|
||||
border-bottom: 1px solid $list-border;
|
||||
|
||||
&::after {
|
||||
|
|
14
app/assets/stylesheets/framework/notes.scss
Normal file
14
app/assets/stylesheets/framework/notes.scss
Normal file
|
@ -0,0 +1,14 @@
|
|||
@mixin notes-media($condition, $breakpoint-width) {
|
||||
@media (#{$condition}-width: ($breakpoint-width)) {
|
||||
@content;
|
||||
}
|
||||
|
||||
// Diff is side by side
|
||||
.notes_content.parallel & {
|
||||
// We hide at double what we normally hide at because
|
||||
// there are two columns of notes
|
||||
@media (#{$condition}-width: (2 * $breakpoint-width)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -96,7 +96,6 @@
|
|||
|
||||
.select2-search-field input {
|
||||
padding: 5px $gl-padding / 2;
|
||||
font-size: 13px;
|
||||
height: auto;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.note-text {
|
||||
p:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
&::before {
|
||||
@include notes-media('max', $screen-xs-max) {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -29,6 +29,16 @@
|
|||
|
||||
.timeline-entry-inner {
|
||||
position: relative;
|
||||
|
||||
@include notes-media('max', $screen-xs-max) {
|
||||
.timeline-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:target,
|
||||
|
@ -46,24 +56,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
.timeline {
|
||||
&::before {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-entry .timeline-entry-inner {
|
||||
.timeline-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discussion .timeline-entry {
|
||||
margin: 0;
|
||||
border-right: none;
|
||||
|
|
|
@ -21,6 +21,10 @@
|
|||
margin-top: 0;
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// Single code lines should wrap
|
||||
code {
|
||||
font-family: $monospace_font;
|
||||
|
@ -157,7 +161,7 @@
|
|||
ul,
|
||||
ol {
|
||||
padding: 0;
|
||||
margin: 0 0 16px !important;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
ul:dir(rtl),
|
||||
|
|
|
@ -247,7 +247,6 @@ $dark-diff-match-bg: rgba(255, 255, 255, 0.3);
|
|||
$dark-diff-match-color: rgba(255, 255, 255, 0.1);
|
||||
$file-mode-changed: #777;
|
||||
$file-mode-changed: #777;
|
||||
$diff-image-bg: #ddd;
|
||||
$diff-image-info-color: grey;
|
||||
$diff-swipe-border: #999;
|
||||
$diff-view-modes-color: grey;
|
||||
|
|
|
@ -72,7 +72,9 @@
|
|||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
height: 475px; // Needed for PhantomJS
|
||||
// scss-lint:disable DuplicateProperty
|
||||
height: calc(100vh - 222px);
|
||||
// scss-lint:enable DuplicateProperty
|
||||
min-height: 475px;
|
||||
transition: width .2s;
|
||||
|
||||
|
|
|
@ -29,20 +29,117 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes blinking-scroll-button {
|
||||
0% { opacity: 0.2; }
|
||||
25% { opacity: 0.5; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.build-page {
|
||||
pre.trace {
|
||||
background: $builds-trace-bg;
|
||||
color: $white-light;
|
||||
.sticky {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.build-trace-container {
|
||||
position: absolute;
|
||||
top: 225px;
|
||||
left: 15px;
|
||||
bottom: 10px;
|
||||
background: $black;
|
||||
color: $gray-darkest;
|
||||
font-family: $monospace_font;
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
overflow-y: hidden;
|
||||
font-size: 12px;
|
||||
|
||||
.fa-spinner {
|
||||
font-size: 24px;
|
||||
margin-left: 20px;
|
||||
&.sidebar-expanded {
|
||||
right: 305px;
|
||||
}
|
||||
|
||||
&.sidebar-collapsed {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: $black;
|
||||
color: $gray-darkest;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
top: 0;
|
||||
height: 35px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-bottom: 1px outset $white-light;
|
||||
|
||||
.truncated-info {
|
||||
margin: 0 auto;
|
||||
align-self: center;
|
||||
|
||||
.truncated-info-size {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.raw-link {
|
||||
color: inherit;
|
||||
margin-left: 5px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controllers {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
font-size: 15px;
|
||||
|
||||
svg {
|
||||
height: 15px;
|
||||
display: block;
|
||||
fill: $white-light;
|
||||
}
|
||||
|
||||
a,
|
||||
.btn-scroll {
|
||||
margin: 0 8px;
|
||||
color: $white-light;
|
||||
}
|
||||
|
||||
.btn-scroll.animate {
|
||||
.first-triangle {
|
||||
animation: blinking-scroll-button 1s ease infinite;
|
||||
animation-delay: .3s;
|
||||
}
|
||||
|
||||
.second-triangle {
|
||||
animation: blinking-scroll-button 1s ease infinite;
|
||||
animation-delay: .2s;
|
||||
}
|
||||
|
||||
.third-triangle {
|
||||
animation: blinking-scroll-button 1s ease infinite;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-scroll:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bash {
|
||||
top: 35px;
|
||||
left: 10px;
|
||||
bottom: 0;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.environment-information {
|
||||
|
@ -58,99 +155,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
.truncated-info {
|
||||
text-align: center;
|
||||
border-bottom: 1px solid;
|
||||
background-color: $black;
|
||||
height: 45px;
|
||||
padding: 15px;
|
||||
|
||||
&.affix {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
// with sidebar
|
||||
&.affix.sidebar-expanded {
|
||||
right: 312px;
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
// without sidebar
|
||||
&.affix.sidebar-collapsed {
|
||||
right: 20px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
&.affix-top {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
margin: 0 auto;
|
||||
right: 5px;
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.truncated-info-size {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.raw-link {
|
||||
color: inherit;
|
||||
margin-left: 5px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-controls {
|
||||
height: 100%;
|
||||
|
||||
.scroll-step {
|
||||
width: 31px;
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
|
||||
.scroll-link,
|
||||
.autoscroll-container {
|
||||
right: 25px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.scroll-link {
|
||||
position: fixed;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&.scroll-top .gitlab-icon-scroll-up-hover,
|
||||
&.scroll-top:hover .gitlab-icon-scroll-up,
|
||||
&.scroll-bottom .gitlab-icon-scroll-down-hover,
|
||||
&.scroll-bottom:hover .gitlab-icon-scroll-down {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.scroll-top:hover .gitlab-icon-scroll-up-hover,
|
||||
&.scroll-bottom:hover .gitlab-icon-scroll-down-hover {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.scroll-top {
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
&.scroll-bottom {
|
||||
bottom: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.autoscroll-container {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&.sidebar-expanded {
|
||||
|
||||
.scroll-link,
|
||||
.autoscroll-container {
|
||||
right: ($gutter_width + ($gl-padding * 2));
|
||||
}
|
||||
.build-loader-animation {
|
||||
position: relative;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: auto auto 12px 2px;
|
||||
border-radius: 50%;
|
||||
animation: blinking-dots 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -223,32 +234,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.build-trace {
|
||||
background: $black;
|
||||
color: $gray-darkest;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
|
||||
.fa-spinner {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.bash {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.build-loader-animation {
|
||||
position: relative;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: auto auto 12px 2px;
|
||||
border-radius: 50%;
|
||||
animation: blinking-dots 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.right-sidebar.build-sidebar {
|
||||
padding: $gl-padding 0;
|
||||
|
||||
|
|
|
@ -36,7 +36,6 @@
|
|||
pre.commit-message {
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
margin: 20px 0;
|
||||
border-radius: 0;
|
||||
|
|
|
@ -94,7 +94,6 @@
|
|||
.old_line,
|
||||
.new_line {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
padding: 0 5px;
|
||||
border-right: 1px solid;
|
||||
|
@ -151,14 +150,10 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-file.diff-wrap-lines table .line_holder td span {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
background: $diff-image-bg;
|
||||
background: $file-image-bg;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
|
||||
|
|
|
@ -64,6 +64,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.btn .text-center {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.commit-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
@ -431,7 +431,7 @@
|
|||
}
|
||||
|
||||
.detail-page-description {
|
||||
padding: 16px 0 0;
|
||||
padding: 16px 0;
|
||||
|
||||
small {
|
||||
color: $gray-darkest;
|
||||
|
@ -441,7 +441,7 @@
|
|||
.edited-text {
|
||||
color: $gray-darkest;
|
||||
display: block;
|
||||
margin: 0 0 16px;
|
||||
margin: 16px 0 0;
|
||||
|
||||
.author_link {
|
||||
color: $gray-darkest;
|
||||
|
|
|
@ -204,7 +204,6 @@ ul.related-merge-requests > li {
|
|||
.dropdown-toggle {
|
||||
.fa-caret-down {
|
||||
pointer-events: none;
|
||||
margin-left: 0;
|
||||
color: inherit;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
|
|
@ -184,4 +184,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
.note-edit-form {
|
||||
.note-form-actions {
|
||||
position: relative;
|
||||
margin: $gl-padding 0;
|
||||
margin: $gl-padding 0 0;
|
||||
}
|
||||
|
||||
.note-preview-holder {
|
||||
|
@ -124,10 +124,18 @@
|
|||
}
|
||||
|
||||
.discussion-form {
|
||||
padding: $gl-padding-top $gl-padding;
|
||||
padding: $gl-padding-top $gl-padding $gl-padding;
|
||||
background-color: $white-light;
|
||||
}
|
||||
|
||||
.discussion-notes .disabled-comment {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.notes-form > li {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.note-edit-form {
|
||||
display: none;
|
||||
font-size: 14px;
|
||||
|
|
|
@ -14,24 +14,11 @@ ul.notes {
|
|||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.timeline-icon {
|
||||
float: left;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: $gray-darkest;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
margin-left: 55px;
|
||||
|
||||
&.timeline-content-form {
|
||||
@media (max-width: $screen-sm-max) {
|
||||
@include notes-media('max', $screen-sm-max) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
@ -56,21 +43,22 @@ ul.notes {
|
|||
position: relative;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: $gl-padding $gl-btn-padding 0;
|
||||
> li {
|
||||
padding: $gl-padding $gl-btn-padding;
|
||||
display: block;
|
||||
position: relative;
|
||||
border-bottom: 1px solid $white-normal;
|
||||
|
||||
&:last-child {
|
||||
// Override `.timeline > li:last-child { border-bottom: none; }`
|
||||
border-bottom: 1px solid $white-normal;
|
||||
}
|
||||
|
||||
&.being-posted {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
|
||||
.dummy-avatar {
|
||||
display: inline-block;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: $kdb-border;
|
||||
border: 1px solid darken($kdb-border, 25%);
|
||||
}
|
||||
|
@ -126,13 +114,13 @@ ul.notes {
|
|||
|
||||
.note-awards {
|
||||
.js-awards-block {
|
||||
margin-bottom: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.note-header {
|
||||
|
||||
@media (max-width: $screen-xs-min) {
|
||||
@include notes-media('max', $screen-xs-min) {
|
||||
.inline {
|
||||
display: block;
|
||||
}
|
||||
|
@ -161,10 +149,10 @@ ul.notes {
|
|||
|
||||
.system-note {
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
padding-left: 0;
|
||||
clear: both;
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
@include notes-media('min', $screen-sm-min) {
|
||||
margin-left: 65px;
|
||||
}
|
||||
|
||||
|
@ -198,11 +186,22 @@ ul.notes {
|
|||
}
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
padding: 14px 10px;
|
||||
.timeline-icon {
|
||||
float: left;
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
margin-left: 20px;
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: $gray-darkest;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
@include notes-media('min', $screen-sm-min) {
|
||||
margin-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -371,7 +370,7 @@ ul.notes {
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
@include notes-media('max', $screen-xs-max) {
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
}
|
||||
|
@ -385,10 +384,16 @@ ul.notes {
|
|||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.note-header-author-name {
|
||||
@include notes-media('max', $screen-xs-max) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.note-headline-light {
|
||||
display: inline;
|
||||
|
||||
@media (max-width: $screen-xs-min) {
|
||||
@include notes-media('max', $screen-xs-min) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
@ -430,7 +435,7 @@ ul.notes {
|
|||
margin-left: 10px;
|
||||
color: $gray-darkest;
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
@include notes-media('max', $screen-xs-max) {
|
||||
float: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
@ -441,7 +446,7 @@ ul.notes {
|
|||
}
|
||||
|
||||
.discussion-actions {
|
||||
@media (max-width: $screen-md-max) {
|
||||
@include notes-media('max', $screen-md-max) {
|
||||
float: none;
|
||||
margin-left: 0;
|
||||
|
||||
|
@ -455,7 +460,7 @@ ul.notes {
|
|||
display: inline;
|
||||
line-height: 20px;
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
@include notes-media('min', $screen-sm-min) {
|
||||
margin-left: 10px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
@ -590,10 +595,15 @@ ul.notes {
|
|||
.discussion-body,
|
||||
.diff-file {
|
||||
.notes .note {
|
||||
padding: 10px 15px;
|
||||
padding-left: $gl-padding;
|
||||
padding-right: $gl-padding;
|
||||
|
||||
&.system-note {
|
||||
padding: 0;
|
||||
padding-left: 0;
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
margin-left: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -607,17 +617,11 @@ ul.notes {
|
|||
}
|
||||
|
||||
.disabled-comment {
|
||||
margin-left: -$gl-padding-top;
|
||||
margin-right: -$gl-padding-top;
|
||||
background-color: $gray-light;
|
||||
border-radius: $border-radius-base;
|
||||
border: 1px solid $border-gray-normal;
|
||||
color: $note-disabled-comment-color;
|
||||
line-height: 200px;
|
||||
|
||||
.disabled-comment-text {
|
||||
line-height: normal;
|
||||
}
|
||||
padding: 90px 0;
|
||||
|
||||
a {
|
||||
color: $gl-link-color;
|
||||
|
@ -625,7 +629,7 @@ ul.notes {
|
|||
}
|
||||
|
||||
.line-resolve-all-container {
|
||||
@media (min-width: $screen-sm-min) {
|
||||
@include notes-media('min', $screen-sm-min) {
|
||||
margin-right: 0;
|
||||
padding-left: $gl-padding;
|
||||
}
|
||||
|
@ -667,7 +671,7 @@ ul.notes {
|
|||
.line-resolve-all {
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
padding: 5px 10px 6px;
|
||||
background-color: $gray-light;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $border-radius-default;
|
||||
|
@ -680,6 +684,10 @@ ul.notes {
|
|||
|
||||
.line-resolve-btn {
|
||||
margin-right: 5px;
|
||||
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -716,6 +724,10 @@ ul.notes {
|
|||
}
|
||||
}
|
||||
|
||||
.line-resolve-text {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.discussion-next-btn {
|
||||
svg {
|
||||
margin: 0;
|
||||
|
@ -732,11 +744,6 @@ ul.notes {
|
|||
|
||||
// Merge request notes in diffs
|
||||
.diff-file {
|
||||
// Diff is side by side
|
||||
.notes_content.parallel .note-header .note-headline-light {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
// Diff is inline
|
||||
.notes_content .note-header .note-headline-light {
|
||||
display: inline-block;
|
||||
|
|
|
@ -88,6 +88,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.btn .text-center {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
@ -247,7 +247,6 @@
|
|||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 13px;
|
||||
padding: $gl-vert-padding $gl-padding;
|
||||
letter-spacing: .4px;
|
||||
padding: 6px 14px;
|
||||
text-align: center;
|
||||
|
@ -384,10 +383,6 @@ a.deploy-project-label {
|
|||
}
|
||||
}
|
||||
|
||||
.last-push-widget {
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.fork-namespaces {
|
||||
.row {
|
||||
-webkit-flex-wrap: wrap;
|
||||
|
|
29
app/controllers/admin/hook_logs_controller.rb
Normal file
29
app/controllers/admin/hook_logs_controller.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
class Admin::HookLogsController < Admin::ApplicationController
|
||||
include HooksExecution
|
||||
|
||||
before_action :hook, only: [:show, :retry]
|
||||
before_action :hook_log, only: [:show, :retry]
|
||||
|
||||
respond_to :html
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def retry
|
||||
status, message = hook.execute(hook_log.request_data, hook_log.trigger)
|
||||
|
||||
set_hook_execution_notice(status, message)
|
||||
|
||||
redirect_to edit_admin_hook_path(@hook)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hook
|
||||
@hook ||= SystemHook.find(params[:hook_id])
|
||||
end
|
||||
|
||||
def hook_log
|
||||
@hook_log ||= hook.web_hook_logs.find(params[:id])
|
||||
end
|
||||
end
|
|
@ -1,5 +1,7 @@
|
|||
class Admin::HooksController < Admin::ApplicationController
|
||||
before_action :hook, only: :edit
|
||||
include HooksExecution
|
||||
|
||||
before_action :hook_logs, only: :edit
|
||||
|
||||
def index
|
||||
@hooks = SystemHook.all
|
||||
|
@ -36,15 +38,9 @@ class Admin::HooksController < Admin::ApplicationController
|
|||
end
|
||||
|
||||
def test
|
||||
data = {
|
||||
event_name: "project_create",
|
||||
name: "Ruby",
|
||||
path: "ruby",
|
||||
project_id: 1,
|
||||
owner_name: "Someone",
|
||||
owner_email: "example@gitlabhq.com"
|
||||
}
|
||||
hook.execute(data, 'system_hooks')
|
||||
status, message = hook.execute(sample_hook_data, 'system_hooks')
|
||||
|
||||
set_hook_execution_notice(status, message)
|
||||
|
||||
redirect_back_or_default
|
||||
end
|
||||
|
@ -55,6 +51,11 @@ class Admin::HooksController < Admin::ApplicationController
|
|||
@hook ||= SystemHook.find(params[:id])
|
||||
end
|
||||
|
||||
def hook_logs
|
||||
@hook_logs ||=
|
||||
Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page])
|
||||
end
|
||||
|
||||
def hook_params
|
||||
params.require(:hook).permit(
|
||||
:enable_ssl_verification,
|
||||
|
@ -65,4 +66,15 @@ class Admin::HooksController < Admin::ApplicationController
|
|||
:url
|
||||
)
|
||||
end
|
||||
|
||||
def sample_hook_data
|
||||
{
|
||||
event_name: "project_create",
|
||||
name: "Ruby",
|
||||
path: "ruby",
|
||||
project_id: 1,
|
||||
owner_name: "Someone",
|
||||
owner_email: "example@gitlabhq.com"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -283,12 +283,8 @@ class ApplicationController < ActionController::Base
|
|||
request.base_url
|
||||
end
|
||||
|
||||
def set_locale
|
||||
Gitlab::I18n.set_locale(current_user)
|
||||
|
||||
yield
|
||||
ensure
|
||||
Gitlab::I18n.reset_locale
|
||||
def set_locale(&block)
|
||||
Gitlab::I18n.with_user_locale(current_user, &block)
|
||||
end
|
||||
|
||||
def sessionless_sign_in(user)
|
||||
|
|
|
@ -9,7 +9,7 @@ class AutocompleteController < ApplicationController
|
|||
@users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present?
|
||||
@users = @users.active
|
||||
@users = @users.reorder(:name)
|
||||
@users = @users.page(params[:page])
|
||||
@users = @users.page(params[:page]).per(params[:per_page])
|
||||
|
||||
if params[:todo_filter].present? && current_user
|
||||
@users = @users.todo_authors(current_user.id, params[:todo_state_filter])
|
||||
|
|
|
@ -8,17 +8,6 @@ module DiffForPath
|
|||
|
||||
return render_404 unless diff_file
|
||||
|
||||
diff_commit = commit_for_diff(diff_file)
|
||||
blob = diff_file.blob(diff_commit)
|
||||
|
||||
locals = {
|
||||
diff_file: diff_file,
|
||||
diff_commit: diff_commit,
|
||||
diff_refs: diffs.diff_refs,
|
||||
blob: blob,
|
||||
project: project
|
||||
}
|
||||
|
||||
render json: { html: view_to_html_string('projects/diffs/_content', locals) }
|
||||
render json: { html: view_to_html_string('projects/diffs/_content', diff_file: diff_file) }
|
||||
end
|
||||
end
|
||||
|
|
15
app/controllers/concerns/hooks_execution.rb
Normal file
15
app/controllers/concerns/hooks_execution.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
module HooksExecution
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def set_hook_execution_notice(status, message)
|
||||
if status && status >= 200 && status < 400
|
||||
flash[:notice] = "Hook executed successfully: HTTP #{status}"
|
||||
elsif status
|
||||
flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}"
|
||||
else
|
||||
flash[:alert] = "Hook execution failed: #{message}"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -8,7 +8,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
|
|||
@projects = load_projects(params.merge(non_public: true)).page(params[:page])
|
||||
|
||||
respond_to do |format|
|
||||
format.html { @last_push = current_user.recent_push }
|
||||
format.html
|
||||
format.atom do
|
||||
load_events
|
||||
render layout: false
|
||||
|
@ -25,7 +25,6 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
|
|||
@projects = load_projects(params.merge(starred: true)).
|
||||
includes(:forked_from_project, :tags).page(params[:page])
|
||||
|
||||
@last_push = current_user.recent_push
|
||||
@groups = []
|
||||
|
||||
respond_to do |format|
|
||||
|
|
|
@ -9,8 +9,6 @@ class DashboardController < Dashboard::ApplicationController
|
|||
respond_to :html
|
||||
|
||||
def activity
|
||||
@last_push = current_user.recent_push
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
||||
|
|
|
@ -64,6 +64,8 @@ class GroupsController < Groups::ApplicationController
|
|||
end
|
||||
|
||||
def subgroups
|
||||
return not_found unless Group.supports_nested_groups?
|
||||
|
||||
@nested_groups = GroupsFinder.new(current_user, parent: group).execute
|
||||
@nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
|
||||
end
|
||||
|
@ -165,7 +167,6 @@ class GroupsController < Groups::ApplicationController
|
|||
|
||||
def user_actions
|
||||
if current_user
|
||||
@last_push = current_user.recent_push
|
||||
@notification_setting = current_user.notification_settings_for(group)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -51,13 +51,9 @@ class Projects::CompareController < Projects::ApplicationController
|
|||
|
||||
if @compare
|
||||
@commits = @compare.commits
|
||||
@start_commit = @compare.start_commit
|
||||
@commit = @compare.commit
|
||||
@base_commit = @compare.base_commit
|
||||
|
||||
@diffs = @compare.diffs(diff_options)
|
||||
|
||||
environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @commit }
|
||||
environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @compare.commit }
|
||||
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
|
||||
|
||||
@diff_notes_disabled = true
|
||||
|
|
33
app/controllers/projects/hook_logs_controller.rb
Normal file
33
app/controllers/projects/hook_logs_controller.rb
Normal file
|
@ -0,0 +1,33 @@
|
|||
class Projects::HookLogsController < Projects::ApplicationController
|
||||
include HooksExecution
|
||||
|
||||
before_action :authorize_admin_project!
|
||||
|
||||
before_action :hook, only: [:show, :retry]
|
||||
before_action :hook_log, only: [:show, :retry]
|
||||
|
||||
respond_to :html
|
||||
|
||||
layout 'project_settings'
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def retry
|
||||
status, message = hook.execute(hook_log.request_data, hook_log.trigger)
|
||||
|
||||
set_hook_execution_notice(status, message)
|
||||
|
||||
redirect_to edit_namespace_project_hook_path(@project.namespace, @project, @hook)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def hook
|
||||
@hook ||= @project.hooks.find(params[:hook_id])
|
||||
end
|
||||
|
||||
def hook_log
|
||||
@hook_log ||= hook.web_hook_logs.find(params[:id])
|
||||
end
|
||||
end
|
|
@ -1,7 +1,9 @@
|
|||
class Projects::HooksController < Projects::ApplicationController
|
||||
include HooksExecution
|
||||
|
||||
# Authorize
|
||||
before_action :authorize_admin_project!
|
||||
before_action :hook, only: :edit
|
||||
before_action :hook_logs, only: :edit
|
||||
|
||||
respond_to :html
|
||||
|
||||
|
@ -34,13 +36,7 @@ class Projects::HooksController < Projects::ApplicationController
|
|||
if !@project.empty_repo?
|
||||
status, message = TestHookService.new.execute(hook, current_user)
|
||||
|
||||
if status && status >= 200 && status < 400
|
||||
flash[:notice] = "Hook executed successfully: HTTP #{status}"
|
||||
elsif status
|
||||
flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}"
|
||||
else
|
||||
flash[:alert] = "Hook execution failed: #{message}"
|
||||
end
|
||||
set_hook_execution_notice(status, message)
|
||||
else
|
||||
flash[:alert] = 'Hook execution failed. Ensure the project has commits.'
|
||||
end
|
||||
|
@ -60,6 +56,11 @@ class Projects::HooksController < Projects::ApplicationController
|
|||
@hook ||= @project.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def hook_logs
|
||||
@hook_logs ||=
|
||||
Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page])
|
||||
end
|
||||
|
||||
def hook_params
|
||||
params.require(:hook).permit(
|
||||
:job_events,
|
||||
|
|
|
@ -14,7 +14,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
]
|
||||
before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines]
|
||||
before_action :define_show_vars, only: [:diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines]
|
||||
before_action :define_commit_vars, only: [:diffs]
|
||||
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines]
|
||||
before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines]
|
||||
before_action :check_if_can_be_merged, only: :show
|
||||
|
@ -130,8 +129,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
@diff_notes_disabled = true
|
||||
end
|
||||
|
||||
define_commit_vars
|
||||
|
||||
render_diff_for_path(@diffs)
|
||||
end
|
||||
|
||||
|
@ -500,11 +497,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
|
||||
end
|
||||
|
||||
def define_commit_vars
|
||||
@commit = @merge_request.diff_head_commit
|
||||
@base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit
|
||||
end
|
||||
|
||||
def define_diff_vars
|
||||
@merge_request_diff =
|
||||
if params[:diff_id]
|
||||
|
@ -569,7 +561,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
@source_project = merge_request.source_project
|
||||
@commits = @merge_request.compare_commits.reverse
|
||||
@commit = @merge_request.diff_head_commit
|
||||
@base_commit = @merge_request.diff_base_commit
|
||||
|
||||
@note_counts = Note.where(commit_id: @commits.map(&:id)).
|
||||
group(:commit_id).count
|
||||
|
|
|
@ -74,6 +74,6 @@ class Projects::RefsController < Projects::ApplicationController
|
|||
private
|
||||
|
||||
def validate_ref_id
|
||||
return not_found! if params[:id].present? && params[:id] !~ Gitlab::Regex.git_reference_regex
|
||||
return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex
|
||||
end
|
||||
end
|
||||
|
|
|
@ -15,16 +15,6 @@ module CommitsHelper
|
|||
commit_person_link(commit, options.merge(source: :committer))
|
||||
end
|
||||
|
||||
def image_diff_class(diff)
|
||||
if diff.deleted_file
|
||||
"deleted"
|
||||
elsif diff.new_file
|
||||
"added"
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def commit_to_html(commit, ref, project)
|
||||
render 'projects/commits/commit',
|
||||
commit: commit,
|
||||
|
|
|
@ -102,14 +102,14 @@ module DiffHelper
|
|||
].join(' ').html_safe
|
||||
end
|
||||
|
||||
def commit_for_diff(diff_file)
|
||||
return diff_file.content_commit if diff_file.content_commit
|
||||
def diff_file_blob_raw_path(diff_file)
|
||||
namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.content_sha, diff_file.file_path))
|
||||
end
|
||||
|
||||
if diff_file.deleted_file
|
||||
@base_commit || @commit.parent || @commit
|
||||
else
|
||||
@commit
|
||||
end
|
||||
def diff_file_old_blob_raw_path(diff_file)
|
||||
sha = diff_file.old_content_sha
|
||||
return unless sha
|
||||
namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.old_content_sha, diff_file.old_path))
|
||||
end
|
||||
|
||||
def diff_file_html_data(project, diff_file_path, diff_commit_id)
|
||||
|
@ -120,8 +120,8 @@ module DiffHelper
|
|||
}
|
||||
end
|
||||
|
||||
def editable_diff?(diff)
|
||||
!diff.deleted_file && @merge_request && @merge_request.source_project
|
||||
def editable_diff?(diff_file)
|
||||
!diff_file.deleted_file? && @merge_request && @merge_request.source_project
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -69,13 +69,12 @@ module LabelsHelper
|
|||
end
|
||||
|
||||
def render_colored_label(label, label_suffix = '', tooltip: true)
|
||||
label_color = label.color || Label::DEFAULT_COLOR
|
||||
text_color = text_color_for_bg(label_color)
|
||||
text_color = text_color_for_bg(label.color)
|
||||
|
||||
# Intentionally not using content_tag here so that this method can be called
|
||||
# by LabelReferenceFilter
|
||||
span = %(<span class="label color-label #{"has-tooltip" if tooltip}" ) +
|
||||
%(style="background-color: #{label_color}; color: #{text_color}" ) +
|
||||
%(style="background-color: #{label.color}; color: #{text_color}" ) +
|
||||
%(title="#{escape_once(label.description)}" data-container="body">) +
|
||||
%(#{escape_once(label.name)}#{label_suffix}</span>)
|
||||
|
||||
|
|
|
@ -85,6 +85,12 @@ module ProjectsHelper
|
|||
@nav_tabs ||= get_project_nav_tabs(@project, current_user)
|
||||
end
|
||||
|
||||
def project_search_tabs?(tab)
|
||||
abilities = Array(search_tab_ability_map[tab])
|
||||
|
||||
abilities.any? { |ability| can?(current_user, ability, @project) }
|
||||
end
|
||||
|
||||
def project_nav_tab?(name)
|
||||
project_nav_tabs.include? name
|
||||
end
|
||||
|
@ -116,6 +122,7 @@ module ProjectsHelper
|
|||
|
||||
def last_push_event
|
||||
return unless current_user
|
||||
return current_user.recent_push unless @project
|
||||
|
||||
project_ids = [@project.id]
|
||||
if fork = current_user.fork_of(@project)
|
||||
|
@ -203,7 +210,17 @@ module ProjectsHelper
|
|||
nav_tabs << :container_registry
|
||||
end
|
||||
|
||||
tab_ability_map = {
|
||||
tab_ability_map.each do |tab, ability|
|
||||
if can?(current_user, ability, project)
|
||||
nav_tabs << tab
|
||||
end
|
||||
end
|
||||
|
||||
nav_tabs.flatten
|
||||
end
|
||||
|
||||
def tab_ability_map
|
||||
{
|
||||
environments: :read_environment,
|
||||
milestones: :read_milestone,
|
||||
pipelines: :read_pipeline,
|
||||
|
@ -215,14 +232,15 @@ module ProjectsHelper
|
|||
team: :read_project_member,
|
||||
wiki: :read_wiki
|
||||
}
|
||||
end
|
||||
|
||||
tab_ability_map.each do |tab, ability|
|
||||
if can?(current_user, ability, project)
|
||||
nav_tabs << tab
|
||||
end
|
||||
end
|
||||
|
||||
nav_tabs.flatten
|
||||
def search_tab_ability_map
|
||||
@search_tab_ability_map ||= tab_ability_map.merge(
|
||||
blobs: :download_code,
|
||||
commits: :download_code,
|
||||
merge_requests: :read_merge_request,
|
||||
notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet]
|
||||
)
|
||||
end
|
||||
|
||||
def project_lfs_status(project)
|
||||
|
|
|
@ -13,6 +13,7 @@ module SubmoduleHelper
|
|||
|
||||
if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/
|
||||
namespace, project = $1, $2
|
||||
project.rstrip!
|
||||
project.sub!(/\.git\z/, '')
|
||||
|
||||
if self_url?(url, namespace, project)
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
class BaseMailer < ActionMailer::Base
|
||||
around_action :render_with_default_locale
|
||||
|
||||
helper ApplicationHelper
|
||||
helper MarkupHelper
|
||||
|
||||
|
@ -14,6 +16,10 @@ class BaseMailer < ActionMailer::Base
|
|||
|
||||
private
|
||||
|
||||
def render_with_default_locale(&block)
|
||||
Gitlab::I18n.with_default_locale(&block)
|
||||
end
|
||||
|
||||
def default_sender_address
|
||||
address = Mail::Address.new(Gitlab.config.gitlab.email_from)
|
||||
address.display_name = Gitlab.config.gitlab.email_display_name
|
||||
|
|
|
@ -10,9 +10,9 @@ module Ci
|
|||
has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline'
|
||||
has_many :pipelines
|
||||
|
||||
validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? }
|
||||
validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? }
|
||||
validates :ref, presence: { unless: :importing_or_inactive? }
|
||||
validates :cron, unless: :importing?, cron: true, presence: { unless: :importing? }
|
||||
validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
|
||||
validates :ref, presence: { unless: :importing? }
|
||||
validates :description, presence: true
|
||||
|
||||
before_save :set_next_run_at
|
||||
|
@ -32,10 +32,6 @@ module Ci
|
|||
update_attribute(:active, false)
|
||||
end
|
||||
|
||||
def importing_or_inactive?
|
||||
importing? || inactive?
|
||||
end
|
||||
|
||||
def runnable_by_owner?
|
||||
Ability.allowed?(owner, :create_pipeline, project)
|
||||
end
|
||||
|
|
|
@ -33,14 +33,4 @@ module NoteOnDiff
|
|||
def created_at_diff?(diff_refs)
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def noteable_diff_refs
|
||||
if noteable.respond_to?(:diff_sha_refs)
|
||||
noteable.diff_sha_refs
|
||||
else
|
||||
noteable.diff_refs
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -84,89 +84,6 @@ module Routable
|
|||
joins(:route).where(wheres.join(' OR '))
|
||||
end
|
||||
end
|
||||
|
||||
# Builds a relation to find multiple objects that are nested under user membership
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# Klass.member_descendants(1)
|
||||
#
|
||||
# Returns an ActiveRecord::Relation.
|
||||
def member_descendants(user_id)
|
||||
joins(:route).
|
||||
joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
|
||||
INNER JOIN members ON members.source_id = r2.source_id
|
||||
AND members.source_type = r2.source_type").
|
||||
where('members.user_id = ?', user_id)
|
||||
end
|
||||
|
||||
# Builds a relation to find multiple objects that are nested under user
|
||||
# membership. Includes the parent, as opposed to `#member_descendants`
|
||||
# which only includes the descendants.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# Klass.member_self_and_descendants(1)
|
||||
#
|
||||
# Returns an ActiveRecord::Relation.
|
||||
def member_self_and_descendants(user_id)
|
||||
joins(:route).
|
||||
joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
|
||||
OR routes.path = r2.path
|
||||
INNER JOIN members ON members.source_id = r2.source_id
|
||||
AND members.source_type = r2.source_type").
|
||||
where('members.user_id = ?', user_id)
|
||||
end
|
||||
|
||||
# Returns all objects in a hierarchy, where any node in the hierarchy is
|
||||
# under the user membership.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# Klass.member_hierarchy(1)
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# Given the following group tree...
|
||||
#
|
||||
# _______group_1_______
|
||||
# | |
|
||||
# | |
|
||||
# nested_group_1 nested_group_2
|
||||
# | |
|
||||
# | |
|
||||
# nested_group_1_1 nested_group_2_1
|
||||
#
|
||||
#
|
||||
# ... the following results are returned:
|
||||
#
|
||||
# * the user is a member of group 1
|
||||
# => 'group_1',
|
||||
# 'nested_group_1', nested_group_1_1',
|
||||
# 'nested_group_2', 'nested_group_2_1'
|
||||
#
|
||||
# * the user is a member of nested_group_2
|
||||
# => 'group1',
|
||||
# 'nested_group_2', 'nested_group_2_1'
|
||||
#
|
||||
# * the user is a member of nested_group_2_1
|
||||
# => 'group1',
|
||||
# 'nested_group_2', 'nested_group_2_1'
|
||||
#
|
||||
# Returns an ActiveRecord::Relation.
|
||||
def member_hierarchy(user_id)
|
||||
paths = member_self_and_descendants(user_id).pluck('routes.path')
|
||||
|
||||
return none if paths.empty?
|
||||
|
||||
wheres = paths.map do |path|
|
||||
"#{connection.quote(path)} = routes.path
|
||||
OR
|
||||
#{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
|
||||
end
|
||||
|
||||
joins(:route).where(wheres.join(' OR '))
|
||||
end
|
||||
end
|
||||
|
||||
def full_name
|
||||
|
|
|
@ -3,7 +3,11 @@ module SelectForProjectAuthorization
|
|||
|
||||
module ClassMethods
|
||||
def select_for_project_authorization
|
||||
select("members.user_id, projects.id AS project_id, members.access_level")
|
||||
select("projects.id AS project_id, members.access_level")
|
||||
end
|
||||
|
||||
def select_as_master_for_project_authorization
|
||||
select(["projects.id AS project_id", "#{Gitlab::Access::MASTER} AS access_level"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -63,7 +63,7 @@ class DiffNote < Note
|
|||
return false unless supported?
|
||||
return true if for_commit?
|
||||
|
||||
diff_refs ||= noteable_diff_refs
|
||||
diff_refs ||= noteable.diff_refs
|
||||
|
||||
self.position.diff_refs == diff_refs
|
||||
end
|
||||
|
@ -99,7 +99,7 @@ class DiffNote < Note
|
|||
self.project,
|
||||
nil,
|
||||
old_diff_refs: self.position.diff_refs,
|
||||
new_diff_refs: noteable_diff_refs,
|
||||
new_diff_refs: noteable.diff_refs,
|
||||
paths: self.position.paths
|
||||
).execute(self)
|
||||
end
|
||||
|
|
|
@ -38,6 +38,10 @@ class Group < Namespace
|
|||
after_save :update_two_factor_requirement
|
||||
|
||||
class << self
|
||||
def supports_nested_groups?
|
||||
Gitlab::Database.postgresql?
|
||||
end
|
||||
|
||||
# Searches for groups matching the given query.
|
||||
#
|
||||
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
|
||||
|
@ -78,7 +82,7 @@ class Group < Namespace
|
|||
if current_scope.joins_values.include?(:shared_projects)
|
||||
joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id')
|
||||
.where('project_namespace.share_with_group_lock = ?', false)
|
||||
.select("members.user_id, projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level")
|
||||
.select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level")
|
||||
else
|
||||
super
|
||||
end
|
||||
|
|
|
@ -2,6 +2,6 @@ class ServiceHook < WebHook
|
|||
belongs_to :service
|
||||
|
||||
def execute(data)
|
||||
super(data, 'service_hook')
|
||||
WebHookService.new(self, data, 'service_hook').execute
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,8 +3,4 @@ class SystemHook < WebHook
|
|||
|
||||
default_value_for :push_events, false
|
||||
default_value_for :repository_update_events, true
|
||||
|
||||
def async_execute(data, hook_name)
|
||||
Sidekiq::Client.enqueue(SystemHookWorker, id, data, hook_name)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
class WebHook < ActiveRecord::Base
|
||||
include Sortable
|
||||
include HTTParty
|
||||
|
||||
default_value_for :push_events, true
|
||||
default_value_for :issues_events, false
|
||||
|
@ -13,52 +12,18 @@ class WebHook < ActiveRecord::Base
|
|||
default_value_for :repository_update_events, false
|
||||
default_value_for :enable_ssl_verification, true
|
||||
|
||||
has_many :web_hook_logs, dependent: :destroy
|
||||
|
||||
scope :push_hooks, -> { where(push_events: true) }
|
||||
scope :tag_push_hooks, -> { where(tag_push_events: true) }
|
||||
|
||||
# HTTParty timeout
|
||||
default_timeout Gitlab.config.gitlab.webhook_timeout
|
||||
|
||||
validates :url, presence: true, url: true
|
||||
|
||||
def execute(data, hook_name)
|
||||
parsed_url = URI.parse(url)
|
||||
if parsed_url.userinfo.blank?
|
||||
response = WebHook.post(url,
|
||||
body: data.to_json,
|
||||
headers: build_headers(hook_name),
|
||||
verify: enable_ssl_verification)
|
||||
else
|
||||
post_url = url.gsub("#{parsed_url.userinfo}@", '')
|
||||
auth = {
|
||||
username: CGI.unescape(parsed_url.user),
|
||||
password: CGI.unescape(parsed_url.password)
|
||||
}
|
||||
response = WebHook.post(post_url,
|
||||
body: data.to_json,
|
||||
headers: build_headers(hook_name),
|
||||
verify: enable_ssl_verification,
|
||||
basic_auth: auth)
|
||||
end
|
||||
|
||||
[response.code, response.to_s]
|
||||
rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e
|
||||
logger.error("WebHook Error => #{e}")
|
||||
[false, e.to_s]
|
||||
WebHookService.new(self, data, hook_name).execute
|
||||
end
|
||||
|
||||
def async_execute(data, hook_name)
|
||||
Sidekiq::Client.enqueue(ProjectWebHookWorker, id, data, hook_name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_headers(hook_name)
|
||||
headers = {
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Gitlab-Event' => hook_name.singularize.titleize
|
||||
}
|
||||
headers['X-Gitlab-Token'] = token if token.present?
|
||||
headers
|
||||
WebHookService.new(self, data, hook_name).async_execute
|
||||
end
|
||||
end
|
||||
|
|
13
app/models/hooks/web_hook_log.rb
Normal file
13
app/models/hooks/web_hook_log.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
class WebHookLog < ActiveRecord::Base
|
||||
belongs_to :web_hook
|
||||
|
||||
serialize :request_headers, Hash
|
||||
serialize :request_data, Hash
|
||||
serialize :response_headers, Hash
|
||||
|
||||
validates :web_hook, presence: true
|
||||
|
||||
def success?
|
||||
response_status =~ /^2/
|
||||
end
|
||||
end
|
|
@ -133,6 +133,10 @@ class Label < ActiveRecord::Base
|
|||
template
|
||||
end
|
||||
|
||||
def color
|
||||
super || DEFAULT_COLOR
|
||||
end
|
||||
|
||||
def text_color
|
||||
LabelsHelper.text_color_for_bg(self.color)
|
||||
end
|
||||
|
|
|
@ -61,7 +61,7 @@ class LegacyDiffNote < Note
|
|||
return true if for_commit?
|
||||
return true unless diff_line
|
||||
return false unless noteable
|
||||
return false if diff_refs && diff_refs != noteable_diff_refs
|
||||
return false if diff_refs && diff_refs != noteable.diff_refs
|
||||
|
||||
noteable_diff = find_noteable_diff
|
||||
|
||||
|
|
|
@ -245,19 +245,6 @@ class MergeRequest < ActiveRecord::Base
|
|||
end
|
||||
end
|
||||
|
||||
# MRs created before 8.4 don't store a MergeRequestDiff#base_commit_sha,
|
||||
# but we need to get a commit for the "View file @ ..." link by deleted files,
|
||||
# so we find the likely one if we can't get the actual one.
|
||||
# This will not be the actual base commit if the target branch was merged into
|
||||
# the source branch after the merge request was created, but it is good enough
|
||||
# for the specific purpose of linking to a commit.
|
||||
# It is not good enough for use in `Gitlab::Git::DiffRefs`, which needs the
|
||||
# true base commit, so we can't simply have `#diff_base_commit` fall back on
|
||||
# this method.
|
||||
def likely_diff_base_commit
|
||||
first_commit.try(:parent) || first_commit
|
||||
end
|
||||
|
||||
def diff_start_commit
|
||||
if persisted?
|
||||
merge_request_diff.start_commit
|
||||
|
@ -322,21 +309,14 @@ class MergeRequest < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def diff_refs
|
||||
return unless diff_start_commit || diff_base_commit
|
||||
|
||||
Gitlab::Diff::DiffRefs.new(
|
||||
base_sha: diff_base_sha,
|
||||
start_sha: diff_start_sha,
|
||||
head_sha: diff_head_sha
|
||||
)
|
||||
end
|
||||
|
||||
# Return diff_refs instance trying to not touch the git repository
|
||||
def diff_sha_refs
|
||||
if merge_request_diff && merge_request_diff.diff_refs_by_sha?
|
||||
if persisted?
|
||||
merge_request_diff.diff_refs
|
||||
else
|
||||
diff_refs
|
||||
Gitlab::Diff::DiffRefs.new(
|
||||
base_sha: diff_base_sha,
|
||||
start_sha: diff_start_sha,
|
||||
head_sha: diff_head_sha
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -870,7 +850,7 @@ class MergeRequest < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def has_complete_diff_refs?
|
||||
diff_sha_refs && diff_sha_refs.complete?
|
||||
diff_refs && diff_refs.complete?
|
||||
end
|
||||
|
||||
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:, current_user: nil)
|
||||
|
|
|
@ -150,6 +150,29 @@ class MergeRequestDiff < ActiveRecord::Base
|
|||
)
|
||||
end
|
||||
|
||||
# MRs created before 8.4 don't store their true diff refs (start and base),
|
||||
# but we need to get a commit SHA for the "View file @ ..." link by a file,
|
||||
# so we use an approximation of the diff refs if we can't get the actual one.
|
||||
#
|
||||
# These will not be the actual diff refs if the target branch was merged into
|
||||
# the source branch after the merge request was created, but it is good enough
|
||||
# for the specific purpose of linking to a commit.
|
||||
#
|
||||
# It is not good enough for highlighting diffs, so we can't simply pass
|
||||
# these as `diff_refs.`
|
||||
def fallback_diff_refs
|
||||
real_refs = diff_refs
|
||||
return real_refs if real_refs
|
||||
|
||||
likely_base_commit_sha = (first_commit&.parent || first_commit)&.sha
|
||||
|
||||
Gitlab::Diff::DiffRefs.new(
|
||||
base_sha: likely_base_commit_sha,
|
||||
start_sha: safe_start_commit_sha,
|
||||
head_sha: head_commit_sha
|
||||
)
|
||||
end
|
||||
|
||||
def diff_refs_by_sha?
|
||||
base_commit_sha? && head_commit_sha? && start_commit_sha?
|
||||
end
|
||||
|
|
|
@ -107,7 +107,7 @@ class Milestone < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def participants
|
||||
User.joins(assigned_issues: :milestone).where("milestones.id = ?", id)
|
||||
User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq
|
||||
end
|
||||
|
||||
def self.sort(method)
|
||||
|
|
|
@ -176,26 +176,20 @@ class Namespace < ActiveRecord::Base
|
|||
projects.with_shared_runners.any?
|
||||
end
|
||||
|
||||
# Scopes the model on ancestors of the record
|
||||
# Returns all the ancestors of the current namespaces.
|
||||
def ancestors
|
||||
if parent_id
|
||||
path = route ? route.path : full_path
|
||||
paths = []
|
||||
return self.class.none unless parent_id
|
||||
|
||||
until path.blank?
|
||||
path = path.rpartition('/').first
|
||||
paths << path
|
||||
end
|
||||
|
||||
self.class.joins(:route).where('routes.path IN (?)', paths).reorder('routes.path ASC')
|
||||
else
|
||||
self.class.none
|
||||
end
|
||||
Gitlab::GroupHierarchy.
|
||||
new(self.class.where(id: parent_id)).
|
||||
base_and_ancestors
|
||||
end
|
||||
|
||||
# Scopes the model on direct and indirect children of the record
|
||||
# Returns all the descendants of the current namespace.
|
||||
def descendants
|
||||
self.class.joins(:route).merge(Route.inside_path(route.path)).reorder('routes.path ASC')
|
||||
Gitlab::GroupHierarchy.
|
||||
new(self.class.where(parent_id: id)).
|
||||
base_and_descendants
|
||||
end
|
||||
|
||||
def user_ids_for_project_authorizations
|
||||
|
|
|
@ -205,8 +205,8 @@ class Project < ActiveRecord::Base
|
|||
presence: true,
|
||||
dynamic_path: true,
|
||||
length: { maximum: 255 },
|
||||
format: { with: Gitlab::Regex.project_path_format_regex,
|
||||
message: Gitlab::Regex.project_path_regex_message },
|
||||
format: { with: Gitlab::PathRegex.project_path_format_regex,
|
||||
message: Gitlab::PathRegex.project_path_format_message },
|
||||
uniqueness: { scope: :namespace_id }
|
||||
|
||||
validates :namespace, presence: true
|
||||
|
@ -380,11 +380,9 @@ class Project < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def reference_pattern
|
||||
name_pattern = Gitlab::Regex::FULL_NAMESPACE_REGEX_STR
|
||||
|
||||
%r{
|
||||
((?<namespace>#{name_pattern})\/)?
|
||||
(?<project>#{name_pattern})
|
||||
((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)?
|
||||
(?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX})
|
||||
}x
|
||||
end
|
||||
|
||||
|
|
|
@ -6,6 +6,12 @@ class ProjectAuthorization < ActiveRecord::Base
|
|||
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
|
||||
validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
|
||||
|
||||
def self.select_from_union(union)
|
||||
select(['project_id', 'MAX(access_level) AS access_level']).
|
||||
from("(#{union.to_sql}) #{ProjectAuthorization.table_name}").
|
||||
group(:project_id)
|
||||
end
|
||||
|
||||
def self.insert_authorizations(rows, per_batch = 1000)
|
||||
rows.each_slice(per_batch) do |slice|
|
||||
tuples = slice.map do |tuple|
|
||||
|
|
|
@ -2,9 +2,10 @@ class JiraService < IssueTrackerService
|
|||
include Gitlab::Routing.url_helpers
|
||||
|
||||
validates :url, url: true, presence: true, if: :activated?
|
||||
validates :api_url, url: true, allow_blank: true
|
||||
validates :project_key, presence: true, if: :activated?
|
||||
|
||||
prop_accessor :username, :password, :url, :project_key,
|
||||
prop_accessor :username, :password, :url, :api_url, :project_key,
|
||||
:jira_issue_transition_id, :title, :description
|
||||
|
||||
before_update :reset_password
|
||||
|
@ -25,20 +26,18 @@ class JiraService < IssueTrackerService
|
|||
super do
|
||||
self.properties = {
|
||||
title: issues_tracker['title'],
|
||||
url: issues_tracker['url']
|
||||
url: issues_tracker['url'],
|
||||
api_url: issues_tracker['api_url']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def reset_password
|
||||
# don't reset the password if a new one is provided
|
||||
if url_changed? && !password_touched?
|
||||
self.password = nil
|
||||
end
|
||||
self.password = nil if reset_password?
|
||||
end
|
||||
|
||||
def options
|
||||
url = URI.parse(self.url)
|
||||
url = URI.parse(client_url)
|
||||
|
||||
{
|
||||
username: self.username,
|
||||
|
@ -87,7 +86,8 @@ class JiraService < IssueTrackerService
|
|||
|
||||
def fields
|
||||
[
|
||||
{ type: 'text', name: 'url', title: 'URL', placeholder: 'https://jira.example.com' },
|
||||
{ type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com' },
|
||||
{ type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' },
|
||||
{ type: 'text', name: 'project_key', placeholder: 'Project Key' },
|
||||
{ type: 'text', name: 'username', placeholder: '' },
|
||||
{ type: 'password', name: 'password', placeholder: '' },
|
||||
|
@ -186,7 +186,7 @@ class JiraService < IssueTrackerService
|
|||
end
|
||||
|
||||
def test_settings
|
||||
return unless url.present?
|
||||
return unless client_url.present?
|
||||
# Test settings by getting the project
|
||||
jira_request { jira_project.present? }
|
||||
end
|
||||
|
@ -236,13 +236,13 @@ class JiraService < IssueTrackerService
|
|||
end
|
||||
|
||||
def send_message(issue, message, remote_link_props)
|
||||
return unless url.present?
|
||||
return unless client_url.present?
|
||||
|
||||
jira_request do
|
||||
if issue.comments.build.save!(body: message)
|
||||
remote_link = issue.remotelink.build
|
||||
remote_link.save!(remote_link_props)
|
||||
result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}."
|
||||
result_message = "#{self.class.name} SUCCESS: Successfully posted to #{client_url}."
|
||||
end
|
||||
|
||||
Rails.logger.info(result_message)
|
||||
|
@ -295,7 +295,20 @@ class JiraService < IssueTrackerService
|
|||
yield
|
||||
|
||||
rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e
|
||||
Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}"
|
||||
Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def client_url
|
||||
api_url.present? ? api_url : url
|
||||
end
|
||||
|
||||
def reset_password?
|
||||
# don't reset the password if a new one is provided
|
||||
return false if password_touched?
|
||||
return true if api_url_changed?
|
||||
return false if api_url.present?
|
||||
|
||||
url_changed?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -77,6 +77,14 @@ class KubernetesService < DeploymentService
|
|||
]
|
||||
end
|
||||
|
||||
def actual_namespace
|
||||
if namespace.present?
|
||||
namespace
|
||||
else
|
||||
default_namespace
|
||||
end
|
||||
end
|
||||
|
||||
# Check we can connect to the Kubernetes API
|
||||
def test(*args)
|
||||
kubeclient = build_kubeclient!
|
||||
|
@ -91,7 +99,7 @@ class KubernetesService < DeploymentService
|
|||
variables = [
|
||||
{ key: 'KUBE_URL', value: api_url, public: true },
|
||||
{ key: 'KUBE_TOKEN', value: token, public: false },
|
||||
{ key: 'KUBE_NAMESPACE', value: namespace_variable, public: true }
|
||||
{ key: 'KUBE_NAMESPACE', value: actual_namespace, public: true }
|
||||
]
|
||||
|
||||
if ca_pem.present?
|
||||
|
@ -110,7 +118,7 @@ class KubernetesService < DeploymentService
|
|||
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) }.
|
||||
flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }.
|
||||
each { |terminal| add_terminal_auth(terminal, terminal_auth) }
|
||||
end
|
||||
end
|
||||
|
@ -124,7 +132,7 @@ class KubernetesService < DeploymentService
|
|||
|
||||
# Store as hashes, rather than as third-party types
|
||||
pods = begin
|
||||
kubeclient.get_pods(namespace: namespace).as_json
|
||||
kubeclient.get_pods(namespace: actual_namespace).as_json
|
||||
rescue KubeException => err
|
||||
raise err unless err.error_code == 404
|
||||
[]
|
||||
|
@ -142,20 +150,12 @@ class KubernetesService < DeploymentService
|
|||
default_namespace || TEMPLATE_PLACEHOLDER
|
||||
end
|
||||
|
||||
def namespace_variable
|
||||
if namespace.present?
|
||||
namespace
|
||||
else
|
||||
default_namespace
|
||||
end
|
||||
end
|
||||
|
||||
def default_namespace
|
||||
"#{project.path}-#{project.id}" if project.present?
|
||||
end
|
||||
|
||||
def build_kubeclient!(api_path: 'api', api_version: 'v1')
|
||||
raise "Incomplete settings" unless api_url && namespace && token
|
||||
raise "Incomplete settings" unless api_url && actual_namespace && token
|
||||
|
||||
::Kubeclient::Client.new(
|
||||
join_api_url(api_path),
|
||||
|
|
|
@ -10,9 +10,12 @@ class User < ActiveRecord::Base
|
|||
include Sortable
|
||||
include CaseSensitivity
|
||||
include TokenAuthenticatable
|
||||
include IgnorableColumn
|
||||
|
||||
DEFAULT_NOTIFICATION_LEVEL = :participating
|
||||
|
||||
ignore_column :authorized_projects_populated
|
||||
|
||||
add_authentication_token_field :authentication_token
|
||||
add_authentication_token_field :incoming_email_token
|
||||
add_authentication_token_field :rss_token
|
||||
|
@ -218,7 +221,6 @@ class User < ActiveRecord::Base
|
|||
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
|
||||
scope :external, -> { where(external: true) }
|
||||
scope :active, -> { with_state(:active).non_internal }
|
||||
scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all }
|
||||
scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') }
|
||||
scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) }
|
||||
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('last_sign_in_at', 'DESC')) }
|
||||
|
@ -368,7 +370,7 @@ class User < ActiveRecord::Base
|
|||
def reference_pattern
|
||||
%r{
|
||||
#{Regexp.escape(reference_prefix)}
|
||||
(?<user>#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR})
|
||||
(?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})
|
||||
}x
|
||||
end
|
||||
|
||||
|
@ -510,23 +512,16 @@ class User < ActiveRecord::Base
|
|||
Group.where("namespaces.id IN (#{union.to_sql})")
|
||||
end
|
||||
|
||||
def nested_groups
|
||||
Group.member_descendants(id)
|
||||
end
|
||||
|
||||
# Returns a relation of groups the user has access to, including their parent
|
||||
# and child groups (recursively).
|
||||
def all_expanded_groups
|
||||
Group.member_hierarchy(id)
|
||||
Gitlab::GroupHierarchy.new(groups).all_groups
|
||||
end
|
||||
|
||||
def expanded_groups_requiring_two_factor_authentication
|
||||
all_expanded_groups.where(require_two_factor_authentication: true)
|
||||
end
|
||||
|
||||
def nested_groups_projects
|
||||
Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL').
|
||||
member_descendants(id)
|
||||
end
|
||||
|
||||
def refresh_authorized_projects
|
||||
Users::RefreshAuthorizedProjectsService.new(self).execute
|
||||
end
|
||||
|
@ -535,18 +530,15 @@ class User < ActiveRecord::Base
|
|||
project_authorizations.where(project_id: project_ids).delete_all
|
||||
end
|
||||
|
||||
def set_authorized_projects_column
|
||||
unless authorized_projects_populated
|
||||
update_column(:authorized_projects_populated, true)
|
||||
end
|
||||
end
|
||||
|
||||
def authorized_projects(min_access_level = nil)
|
||||
refresh_authorized_projects unless authorized_projects_populated
|
||||
|
||||
# We're overriding an association, so explicitly call super with no arguments or it would be passed as `force_reload` to the association
|
||||
# We're overriding an association, so explicitly call super with no
|
||||
# arguments or it would be passed as `force_reload` to the association
|
||||
projects = super()
|
||||
projects = projects.where('project_authorizations.access_level >= ?', min_access_level) if min_access_level
|
||||
|
||||
if min_access_level
|
||||
projects = projects.
|
||||
where('project_authorizations.access_level >= ?', min_access_level)
|
||||
end
|
||||
|
||||
projects
|
||||
end
|
||||
|
@ -919,13 +911,13 @@ class User < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def assigned_open_merge_requests_count(force: false)
|
||||
Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force) do
|
||||
Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: 20.minutes) do
|
||||
MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened').execute.count
|
||||
end
|
||||
end
|
||||
|
||||
def assigned_open_issues_count(force: false)
|
||||
Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do
|
||||
Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: 20.minutes) do
|
||||
IssuesFinder.new(self, assignee_id: self.id, state: 'opened').execute.count
|
||||
end
|
||||
end
|
||||
|
|
|
@ -39,6 +39,7 @@ class MergeRequestEntity < IssuableEntity
|
|||
expose :commits_count
|
||||
expose :cannot_be_merged?, as: :has_conflicts
|
||||
expose :can_be_merged?, as: :can_be_merged
|
||||
expose :remove_source_branch?, as: :remove_source_branch
|
||||
|
||||
expose :project_archived do |merge_request|
|
||||
merge_request.project.archived?
|
||||
|
|
|
@ -28,6 +28,7 @@ module Issues
|
|||
notification_service.close_issue(issue, current_user) if notifications
|
||||
todo_service.close_issue(issue, current_user)
|
||||
execute_hooks(issue, 'close')
|
||||
invalidate_cache_counts(issue.assignees, issue)
|
||||
end
|
||||
|
||||
issue
|
||||
|
|
|
@ -8,6 +8,7 @@ module Issues
|
|||
create_note(issue)
|
||||
notification_service.reopen_issue(issue, current_user)
|
||||
execute_hooks(issue, 'reopen')
|
||||
invalidate_cache_counts(issue.assignees, issue)
|
||||
end
|
||||
|
||||
issue
|
||||
|
|
|
@ -13,6 +13,7 @@ module MergeRequests
|
|||
notification_service.close_mr(merge_request, current_user)
|
||||
todo_service.close_merge_request(merge_request, current_user)
|
||||
execute_hooks(merge_request, 'close')
|
||||
invalidate_cache_counts(merge_request.assignees, merge_request)
|
||||
end
|
||||
|
||||
merge_request
|
||||
|
|
|
@ -13,6 +13,7 @@ module MergeRequests
|
|||
create_note(merge_request)
|
||||
notification_service.merge_mr(merge_request, current_user)
|
||||
execute_hooks(merge_request, 'merge')
|
||||
invalidate_cache_counts(merge_request.assignees, merge_request)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -10,6 +10,7 @@ module MergeRequests
|
|||
execute_hooks(merge_request, 'reopen')
|
||||
merge_request.reload_diff(current_user)
|
||||
merge_request.mark_as_unchecked
|
||||
invalidate_cache_counts(merge_request.assignees, merge_request)
|
||||
end
|
||||
|
||||
merge_request
|
||||
|
|
|
@ -12,7 +12,7 @@ class SearchService
|
|||
@project =
|
||||
if params[:project_id].present?
|
||||
the_project = Project.find_by(id: params[:project_id])
|
||||
can?(current_user, :download_code, the_project) ? the_project : nil
|
||||
can?(current_user, :read_project, the_project) ? the_project : nil
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
|
|
@ -73,12 +73,11 @@ module Users
|
|||
# remove - The IDs of the authorization rows to remove.
|
||||
# add - Rows to insert in the form `[user id, project id, access level]`
|
||||
def update_authorizations(remove = [], add = [])
|
||||
return if remove.empty? && add.empty? && user.authorized_projects_populated
|
||||
return if remove.empty? && add.empty?
|
||||
|
||||
User.transaction do
|
||||
user.remove_project_authorizations(remove) unless remove.empty?
|
||||
ProjectAuthorization.insert_authorizations(add) unless add.empty?
|
||||
user.set_authorized_projects_column
|
||||
end
|
||||
|
||||
# Since we batch insert authorization rows, Rails' associations may get
|
||||
|
@ -101,38 +100,13 @@ module Users
|
|||
end
|
||||
|
||||
def fresh_authorizations
|
||||
ProjectAuthorization.
|
||||
unscoped.
|
||||
select('project_id, MAX(access_level) AS access_level').
|
||||
from("(#{project_authorizations_union.to_sql}) #{ProjectAuthorization.table_name}").
|
||||
group(:project_id)
|
||||
end
|
||||
klass = if Group.supports_nested_groups?
|
||||
Gitlab::ProjectAuthorizations::WithNestedGroups
|
||||
else
|
||||
Gitlab::ProjectAuthorizations::WithoutNestedGroups
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns a union query of projects that the user is authorized to access
|
||||
def project_authorizations_union
|
||||
relations = [
|
||||
# Personal projects
|
||||
user.personal_projects.select("#{user.id} AS user_id, projects.id AS project_id, #{Gitlab::Access::MASTER} AS access_level"),
|
||||
|
||||
# Projects the user is a member of
|
||||
user.projects.select_for_project_authorization,
|
||||
|
||||
# Projects of groups the user is a member of
|
||||
user.groups_projects.select_for_project_authorization,
|
||||
|
||||
# Projects of subgroups of groups the user is a member of
|
||||
user.nested_groups_projects.select_for_project_authorization,
|
||||
|
||||
# Projects shared with groups the user is a member of
|
||||
user.groups.joins(:shared_projects).select_for_project_authorization,
|
||||
|
||||
# Projects shared with subgroups of groups the user is a member of
|
||||
user.nested_groups.joins(:shared_projects).select_for_project_authorization
|
||||
]
|
||||
|
||||
Gitlab::SQL::Union.new(relations)
|
||||
klass.new(user).calculate
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
120
app/services/web_hook_service.rb
Normal file
120
app/services/web_hook_service.rb
Normal file
|
@ -0,0 +1,120 @@
|
|||
class WebHookService
|
||||
class InternalErrorResponse
|
||||
attr_reader :body, :headers, :code
|
||||
|
||||
def initialize
|
||||
@headers = HTTParty::Response::Headers.new({})
|
||||
@body = ''
|
||||
@code = 'internal error'
|
||||
end
|
||||
end
|
||||
|
||||
include HTTParty
|
||||
|
||||
# HTTParty timeout
|
||||
default_timeout Gitlab.config.gitlab.webhook_timeout
|
||||
|
||||
attr_accessor :hook, :data, :hook_name
|
||||
|
||||
def initialize(hook, data, hook_name)
|
||||
@hook = hook
|
||||
@data = data
|
||||
@hook_name = hook_name
|
||||
end
|
||||
|
||||
def execute
|
||||
start_time = Time.now
|
||||
|
||||
response = if parsed_url.userinfo.blank?
|
||||
make_request(hook.url)
|
||||
else
|
||||
make_request_with_auth
|
||||
end
|
||||
|
||||
log_execution(
|
||||
trigger: hook_name,
|
||||
url: hook.url,
|
||||
request_data: data,
|
||||
response: response,
|
||||
execution_duration: Time.now - start_time
|
||||
)
|
||||
|
||||
[response.code, response.to_s]
|
||||
rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e
|
||||
log_execution(
|
||||
trigger: hook_name,
|
||||
url: hook.url,
|
||||
request_data: data,
|
||||
response: InternalErrorResponse.new,
|
||||
execution_duration: Time.now - start_time,
|
||||
error_message: e.to_s
|
||||
)
|
||||
|
||||
Rails.logger.error("WebHook Error => #{e}")
|
||||
|
||||
[nil, e.to_s]
|
||||
end
|
||||
|
||||
def async_execute
|
||||
Sidekiq::Client.enqueue(WebHookWorker, hook.id, data, hook_name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parsed_url
|
||||
@parsed_url ||= URI.parse(hook.url)
|
||||
end
|
||||
|
||||
def make_request(url, basic_auth = false)
|
||||
self.class.post(url,
|
||||
body: data.to_json,
|
||||
headers: build_headers(hook_name),
|
||||
verify: hook.enable_ssl_verification,
|
||||
basic_auth: basic_auth)
|
||||
end
|
||||
|
||||
def make_request_with_auth
|
||||
post_url = hook.url.gsub("#{parsed_url.userinfo}@", '')
|
||||
basic_auth = {
|
||||
username: CGI.unescape(parsed_url.user),
|
||||
password: CGI.unescape(parsed_url.password)
|
||||
}
|
||||
make_request(post_url, basic_auth)
|
||||
end
|
||||
|
||||
def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil)
|
||||
# logging for ServiceHook's is not available
|
||||
return if hook.is_a?(ServiceHook)
|
||||
|
||||
WebHookLog.create(
|
||||
web_hook: hook,
|
||||
trigger: trigger,
|
||||
url: url,
|
||||
execution_duration: execution_duration,
|
||||
request_headers: build_headers(hook_name),
|
||||
request_data: request_data,
|
||||
response_headers: format_response_headers(response),
|
||||
response_body: response.body,
|
||||
response_status: response.code,
|
||||
internal_error_message: error_message
|
||||
)
|
||||
end
|
||||
|
||||
def build_headers(hook_name)
|
||||
@headers ||= begin
|
||||
{
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Gitlab-Event' => hook_name.singularize.titleize
|
||||
}.tap do |hash|
|
||||
hash['X-Gitlab-Token'] = hook.token if hook.token.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Make response headers more stylish
|
||||
# Net::HTTPHeader has downcased hash with arrays: { 'content-type' => ['text/html; charset=utf-8'] }
|
||||
# This method format response to capitalized hash with strings: { 'Content-Type' => 'text/html; charset=utf-8' }
|
||||
def format_response_headers(response)
|
||||
response.headers.each_capitalized.to_h
|
||||
end
|
||||
end
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue