Commit a3961f96 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'ce-to-ee-2018-06-28' into 'master'

CE upstream - 2018-06-28 15:22 UTC

Closes gitaly#326, gitaly#315, gitaly#745, gitaly#1044, gitaly#876, gitaly#1202, gitaly#1201, gitaly#1199, gitaly#1198, gitaly#951, and gitlab-ce#48042

See merge request gitlab-org/gitlab-ee!6317
parents 5767f6a7 cdd1fe4d
......@@ -35,7 +35,7 @@ gem 'faraday', '~> 0.12'
# Authentication libraries
gem 'devise', '~> 4.4'
gem 'doorkeeper', '~> 4.3'
gem 'doorkeeper-openid_connect', '~> 1.3'
gem 'doorkeeper-openid_connect', '~> 1.5'
gem 'omniauth', '~> 1.8'
gem 'omniauth-auth0', '~> 2.0.0'
gem 'omniauth-azure-oauth2', '~> 0.0.9'
......
......@@ -179,7 +179,7 @@ GEM
unf (>= 0.0.5, < 1.0.0)
doorkeeper (4.3.2)
railties (>= 4.2)
doorkeeper-openid_connect (1.4.0)
doorkeeper-openid_connect (1.5.0)
doorkeeper (~> 4.3)
json-jwt (~> 1.6)
dropzonejs-rails (0.7.2)
......@@ -455,12 +455,10 @@ GEM
jmespath (1.3.1)
jquery-atwho-rails (1.3.2)
json (1.8.6)
json-jwt (1.9.2)
json-jwt (1.9.4)
activesupport
aes_key_wrap
bindata
securecompare
url_safe_base64
json-schema (2.8.0)
addressable (>= 2.4)
jwt (1.5.6)
......@@ -541,7 +539,7 @@ GEM
net-ntp (2.1.3)
net-ssh (5.0.1)
netrc (0.11.0)
nokogiri (1.8.2)
nokogiri (1.8.3)
mini_portile2 (~> 2.3.0)
nokogumbo (1.5.0)
nokogiri
......@@ -856,7 +854,6 @@ GEM
scss_lint (0.56.0)
rake (>= 0.9, < 13)
sass (~> 3.5.3)
securecompare (1.0.0)
seed-fu (2.3.7)
activerecord (>= 3.1)
activesupport (>= 3.1)
......@@ -968,7 +965,6 @@ GEM
equalizer (~> 0.0.9)
parser (>= 2.3.1.2, < 2.6)
procto (~> 0.0.2)
url_safe_base64 (0.2.2)
validates_hostname (1.0.6)
activerecord (>= 3.0)
activesupport (>= 3.0)
......@@ -1043,7 +1039,7 @@ DEPENDENCIES
devise-two-factor (~> 3.0.0)
diffy (~> 3.1.0)
doorkeeper (~> 4.3)
doorkeeper-openid_connect (~> 1.3)
doorkeeper-openid_connect (~> 1.5)
dropzonejs-rails (~> 0.7.1)
ed25519 (~> 1.2)
elasticsearch-api (= 5.0.3)
......
......@@ -182,7 +182,7 @@ GEM
unf (>= 0.0.5, < 1.0.0)
doorkeeper (4.3.2)
railties (>= 4.2)
doorkeeper-openid_connect (1.4.0)
doorkeeper-openid_connect (1.5.0)
doorkeeper (~> 4.3)
json-jwt (~> 1.6)
dropzonejs-rails (0.7.2)
......@@ -458,12 +458,10 @@ GEM
jmespath (1.4.0)
jquery-atwho-rails (1.3.2)
json (1.8.6)
json-jwt (1.9.2)
json-jwt (1.9.4)
activesupport
aes_key_wrap
bindata
securecompare
url_safe_base64
json-schema (2.8.0)
addressable (>= 2.4)
jwt (1.5.6)
......@@ -865,7 +863,6 @@ GEM
scss_lint (0.56.0)
rake (>= 0.9, < 13)
sass (~> 3.5.3)
securecompare (1.0.0)
seed-fu (2.3.7)
activerecord (>= 3.1)
activesupport (>= 3.1)
......@@ -975,7 +972,6 @@ GEM
equalizer (~> 0.0.9)
parser (>= 2.3.1.2, < 2.6)
procto (~> 0.0.2)
url_safe_base64 (0.2.2)
validates_hostname (1.0.6)
activerecord (>= 3.0)
activesupport (>= 3.0)
......@@ -1053,7 +1049,7 @@ DEPENDENCIES
devise-two-factor (~> 3.0.0)
diffy (~> 3.1.0)
doorkeeper (~> 4.3)
doorkeeper-openid_connect (~> 1.3)
doorkeeper-openid_connect (~> 1.5)
dropzonejs-rails (~> 0.7.1)
ed25519 (~> 1.2)
elasticsearch-api (= 5.0.3)
......
......@@ -4,14 +4,7 @@ import { s__ } from '~/locale';
import { mapState, mapGetters, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import {
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_POSITION_RIGHT,
UNFOLD_COUNT,
} from '../constants';
import { LINE_POSITION_RIGHT, UNFOLD_COUNT } from '../constants';
import * as utils from '../store/utils';
export default {
......@@ -63,6 +56,21 @@ export default {
required: false,
default: false,
},
isMatchLine: {
type: Boolean,
required: false,
default: false,
},
isMetaLine: {
type: Boolean,
required: false,
default: false,
},
isContextLine: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState({
......@@ -70,15 +78,6 @@ export default {
diffFiles: state => state.diffs.diffFiles,
}),
...mapGetters(['isLoggedIn', 'discussionsByLineCode']),
isMatchLine() {
return this.lineType === MATCH_LINE_TYPE;
},
isContextLine() {
return this.lineType === CONTEXT_LINE_TYPE;
},
isMetaLine() {
return this.lineType === OLD_NO_NEW_LINE_TYPE || this.lineType === NEW_NO_NEW_LINE_TYPE;
},
lineHref() {
return this.lineCode ? `#${this.lineCode}` : '#';
},
......@@ -109,9 +108,9 @@ export default {
},
},
methods: {
...mapActions(['loadMoreLines']),
...mapActions(['loadMoreLines', 'showCommentForm']),
handleCommentButton() {
this.$emit('showCommentForm', { lineCode: this.lineCode });
this.showCommentForm({ lineCode: this.lineCode });
},
handleLoadMoreLines() {
if (this.isRequesting) {
......
<script>
import { mapGetters } from 'vuex';
import DiffLineGutterContent from './diff_line_gutter_content.vue';
import {
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
EMPTY_CELL_TYPE,
OLD_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_HOVER_CLASS_NAME,
LINE_UNFOLD_CLASS_NAME,
} from '../constants';
export default {
components: {
DiffLineGutterContent,
},
props: {
line: {
type: Object,
required: true,
},
diffFile: {
type: Object,
required: true,
},
showCommentButton: {
type: Boolean,
required: false,
default: false,
},
linePosition: {
type: String,
required: false,
default: '',
},
lineType: {
type: String,
required: false,
default: '',
},
isContentLine: {
type: Boolean,
required: false,
default: false,
},
isBottom: {
type: Boolean,
required: false,
default: false,
},
isHover: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapGetters(['isLoggedIn', 'isInlineView']),
normalizedLine() {
if (this.isInlineView) {
return this.line;
}
return this.lineType === OLD_LINE_TYPE ? this.line.left : this.line.right;
},
isMatchLine() {
return this.normalizedLine.type === MATCH_LINE_TYPE;
},
isContextLine() {
return this.normalizedLine.type === CONTEXT_LINE_TYPE;
},
isMetaLine() {
return (
this.normalizedLine.type === OLD_NO_NEW_LINE_TYPE ||
this.normalizedLine.type === NEW_NO_NEW_LINE_TYPE ||
this.normalizedLine.type === EMPTY_CELL_TYPE
);
},
classNameMap() {
const { type } = this.normalizedLine;
return {
[type]: type,
[LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
[LINE_HOVER_CLASS_NAME]:
this.isLoggedIn &&
this.isHover &&
!this.isMatchLine &&
!this.isContextLine &&
!this.isMetaLine,
};
},
lineNumber() {
const { lineType, normalizedLine } = this;
return lineType === OLD_LINE_TYPE ? normalizedLine.oldLine : normalizedLine.newLine;
},
},
};
</script>
<template>
<td
v-if="isContentLine"
:class="lineType"
class="line_content"
v-html="normalizedLine.richText"
>
</td>
<td
v-else
:class="classNameMap"
>
<diff-line-gutter-content
:file-hash="diffFile.fileHash"
:line-type="normalizedLine.type"
:line-code="normalizedLine.lineCode"
:line-position="linePosition"
:line-number="lineNumber"
:meta-data="normalizedLine.metaData"
:show-comment-button="showCommentButton"
:context-lines-path="diffFile.contextLinesPath"
:is-bottom="isBottom"
:is-match-line="isMatchLine"
:is-context-line="isContentLine"
:is-meta-line="isMetaLine"
/>
</td>
</template>
<script>
import $ from 'jquery';
import { mapGetters } from 'vuex';
import DiffTableCell from './diff_table_cell.vue';
import {
NEW_LINE_TYPE,
OLD_LINE_TYPE,
CONTEXT_LINE_TYPE,
CONTEXT_LINE_CLASS_NAME,
OLD_NO_NEW_LINE_TYPE,
PARALLEL_DIFF_VIEW_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_POSITION_LEFT,
LINE_POSITION_RIGHT,
} from '../constants';
export default {
components: {
DiffTableCell,
},
props: {
diffFile: {
type: Object,
required: true,
},
line: {
type: Object,
required: true,
},
isBottom: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isHover: false,
isLeftHover: false,
isRightHover: false,
};
},
computed: {
...mapGetters(['isInlineView', 'isParallelView']),
isContextLine() {
return this.line.left
? this.line.left.type === CONTEXT_LINE_TYPE
: this.line.type === CONTEXT_LINE_TYPE;
},
classNameMap() {
return {
[this.line.type]: this.line.type,
[CONTEXT_LINE_CLASS_NAME]: this.isContextLine,
[PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView,
};
},
inlineRowId() {
const { lineCode, oldLine, newLine } = this.line;
return lineCode || `${this.diffFile.fileHash}_${oldLine}_${newLine}`;
},
parallelViewLeftLineType() {
if (this.line.right.type === NEW_NO_NEW_LINE_TYPE) {
return OLD_NO_NEW_LINE_TYPE;
}
return this.line.left.type;
},
},
created() {
this.newLineType = NEW_LINE_TYPE;
this.oldLineType = OLD_LINE_TYPE;
this.linePositionLeft = LINE_POSITION_LEFT;
this.linePositionRight = LINE_POSITION_RIGHT;
},
methods: {
handleMouseMove(e) {
const isHover = e.type === 'mouseover';
if (this.isInlineView) {
this.isHover = isHover;
} else {
const hoveringCell = e.target.closest('td');
const allCellsInHoveringRow = Array.from(e.currentTarget.children);
const hoverIndex = allCellsInHoveringRow.indexOf(hoveringCell);
if (hoverIndex >= 2) {
this.isRightHover = isHover;
} else {
this.isLeftHover = isHover;
}
}
},
// Prevent text selecting on both sides of parallel diff view
// Backport of the same code from legacy diff notes.
handleParallelLineMouseDown(e) {
const line = $(e.currentTarget);
const table = line.closest('table');
table.removeClass('left-side-selected right-side-selected');
const [lineClass] = ['left-side', 'right-side'].filter(name => line.hasClass(name));
if (lineClass) {
table.addClass(`${lineClass}-selected`);
}
},
},
};
</script>
<template>
<tr
v-if="isInlineView"
:id="inlineRowId"
:class="classNameMap"
class="line_holder"
@mouseover="handleMouseMove"
@mouseout="handleMouseMove"
>
<diff-table-cell
:diff-file="diffFile"
:line="line"
:line-type="oldLineType"
:is-bottom="isBottom"
:is-hover="isHover"
:show-comment-button="true"
class="diff-line-num old_line"
/>
<diff-table-cell
:diff-file="diffFile"
:line="line"
:line-type="newLineType"
:is-bottom="isBottom"
:is-hover="isHover"
class="diff-line-num new_line"
/>
<diff-table-cell
:class="line.type"
:diff-file="diffFile"
:line="line"
:is-content-line="true"
/>
</tr>
<tr
v-else
:class="classNameMap"
class="line_holder"
@mouseover="handleMouseMove"
@mouseout="handleMouseMove"
>
<diff-table-cell
:diff-file="diffFile"
:line="line"
:line-type="oldLineType"
:line-position="linePositionLeft"
:is-bottom="isBottom"
:is-hover="isLeftHover"
:show-comment-button="true"
class="diff-line-num old_line"
/>
<diff-table-cell
:id="line.left.lineCode"
:diff-file="diffFile"
:line="line"
:is-content-line="true"
:line-type="parallelViewLeftLineType"
class="line_content parallel left-side"
@mousedown.native="handleParallelLineMouseDown"
/>
<diff-table-cell
:diff-file="diffFile"
:line="line"
:line-type="newLineType"
:line-position="linePositionRight"
:is-bottom="isBottom"
:is-hover="isRightHover"
:show-comment-button="true"
class="diff-line-num new_line"
/>
<diff-table-cell
:id="line.right.lineCode"
:diff-file="diffFile"
:line="line"
:is-content-line="true"
:line-type="line.right.type"
class="line_content parallel right-side"
@mousedown.native="handleParallelLineMouseDown"
/>
</tr>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import diffDiscussions from './diff_discussions.vue';
import diffLineNoteForm from './diff_line_note_form.vue';
export default {
components: {
diffDiscussions,
diffLineNoteForm,
},
props: {
line: {
type: Object,
required: true,
},
diffFile: {
type: Object,
required: true,
},
diffLines: {
type: Array,
required: true,
},
lineIndex: {
type: Number,
required: true,
},
},
computed: {
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
...mapGetters(['discussionsByLineCode']),
isDiscussionExpanded() {
if (!this.discussions.length) {
return false;
}
return this.discussions.every(discussion => discussion.expanded);
},
hasCommentForm() {
return this.diffLineCommentForms[this.line.lineCode];
},
discussions() {
return this.discussionsByLineCode[this.line.lineCode] || [];
},
shouldRender() {
return this.isDiscussionExpanded || this.hasCommentForm;
},
className() {
return this.discussions.length ? '' : 'js-temp-notes-holder';
},
},
};
</script>
<template>
<tr
v-if="shouldRender"
:class="className"
class="notes_holder"
>
<td
class="notes_line"
colspan="2"
></td>
<td class="notes_content">
<div class="content">
<diff-discussions
:discussions="discussions"
/>
<diff-line-note-form
v-if="diffLineCommentForms[line.lineCode]"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line"
:note-target-line="diffLines[lineIndex]"
/>
</div>
</td>
</tr>
</template>
<script>
import diffContentMixin from '../mixins/diff_content';
import {
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_HOVER_CLASS_NAME,
LINE_UNFOLD_CLASS_NAME,
} from '../constants';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
export default {
mixins: [diffContentMixin],
methods: {
handleMouse(lineCode, isOver) {
this.hoveredLineCode = isOver ? lineCode : null;
},
getLineClass(line) {
const isSameLine = this.hoveredLineCode && this.hoveredLineCode === line.lineCode;
const isMatchLine = line.type === MATCH_LINE_TYPE;
const isContextLine = line.type === CONTEXT_LINE_TYPE;
const isMetaLine = line.type === OLD_NO_NEW_LINE_TYPE || line.type === NEW_NO_NEW_LINE_TYPE;
return {
[line.type]: line.type,
[LINE_UNFOLD_CLASS_NAME]: isMatchLine,
[LINE_HOVER_CLASS_NAME]:
this.isLoggedIn && isSameLine && !isMatchLine && !isContextLine && !isMetaLine,
};
},
components: {
inlineDiffCommentRow,
},
mixins: [diffContentMixin],
};
</script>
......@@ -41,76 +19,19 @@ export default {
<template
v-for="(line, index) in normalizedDiffLines"
>
<tr
:id="line.lineCode || `${fileHash}_${line.oldLine}_${line.newLine}`"
<diff-table-row
:diff-file="diffFile"
:line="line"
:is-bottom="index + 1 === diffLinesLength"
:key="line.lineCode"
:class="getRowClass(line)"
class="line_holder"
@mouseover="handleMouse(line.lineCode, true)"
@mouseout="handleMouse(line.lineCode, false)"
>
<td
:class="getLineClass(line)"
class="diff-line-num old_line"
>
<diff-line-gutter-content
:file-hash="fileHash"
:line-type="line.type"
:line-code="line.lineCode"
:line-number="line.oldLine"
:meta-data="line.metaData"
:show-comment-button="true"
:context-lines-path="diffFile.contextLinesPath"
:is-bottom="index + 1 === diffLinesLength"
@showCommentForm="handleShowCommentForm"
/>
</td>
<td
:class="getLineClass(line)"
class="diff-line-num new_line"
>
<diff-line-gutter-content
:file-hash="fileHash"
:line-type="line.type"
:line-code="line.lineCode"
:line-number="line.newLine"
:meta-data="line.metaData"
:is-bottom="index + 1 === diffLinesLength"
:context-lines-path="diffFile.contextLinesPath"
/>
</td>
<td
:class="line.type"
class="line_content"
v-html="line.richText"
>
</td>
</tr>
<tr
v-if="isDiscussionExpanded(line.lineCode) || diffLineCommentForms[line.lineCode]"
/>
<inline-diff-comment-row
:diff-file="diffFile"
:diff-lines="normalizedDiffLines"
:line="line"
:line-index="index"
:key="index"
:class="discussionsByLineCode[line.lineCode] ? '' : 'js-temp-notes-holder'"
class="notes_holder"
>
<td
class="notes_line"
colspan="2"
></td>
<td class="notes_content">
<div class="content">
<diff-discussions
:discussions="discussionsByLineCode[line.lineCode] || []"
/>
<diff-line-note-form
v-if="diffLineCommentForms[line.lineCode]"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line"
:note-target-line="diffLines[index]"
/>
</div>
</td>
</tr>
/>
</template>
</tbody>
</table>
......
<script>
import { mapState, mapGetters } from 'vuex';
import diffDiscussions from './diff_discussions.vue';
import diffLineNoteForm from './diff_line_note_form.vue';
export default {
components: {
diffDiscussions,
diffLineNoteForm,
},
props: {
line: {
type: Object,
required: true,
},
diffFile: {
type: Object,
required: true,
},
diffLines: {
type: Array,
required: true,
},
lineIndex: {
type: Number,
required: true,
},
},
computed: {
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
...mapGetters(['discussionsByLineCode']),
leftLineCode() {
return this.line.left.lineCode;
},
rightLineCode() {
return this.line.right.lineCode;
},
hasDiscussion() {
const discussions = this.discussionsByLineCode;
return discussions[this.leftLineCode] || discussions[this.rightLineCode];
},
hasExpandedDiscussionOnLeft() {
const discussions = this.discussionsByLineCode[this.leftLineCode];
return discussions ? discussions.every(discussion => discussion.expanded) : false;
},
hasExpandedDiscussionOnRight() {
const discussions = this.discussionsByLineCode[this.rightLineCode];
return discussions ? discussions.every(discussion => discussion.expanded) : false;
},
hasAnyExpandedDiscussion() {
return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight;
},
shouldRenderDiscussionsRow() {
const hasDiscussion = this.hasDiscussion && this.hasAnyExpandedDiscussion;
const hasCommentFormOnLeft = this.diffLineCommentForms[this.leftLineCode];
const hasCommentFormOnRight = this.diffLineCommentForms[this.rightLineCode];
return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight;
},
shouldRenderDiscussionsOnLeft() {
return this.discussionsByLineCode[this.leftLineCode] && this.hasExpandedDiscussionOnLeft;
},
shouldRenderDiscussionsOnRight() {
return (
this.discussionsByLineCode[this.rightLineCode] &&
this.hasExpandedDiscussionOnRight &&
this.line.right.type
);
},
className() {
return this.hasDiscussion ? '' : 'js-temp-notes-holder';
},
},
};
</script>
<template>
<tr
v-if="shouldRenderDiscussionsRow"
:class="className"
class="notes_holder"
>
<td class="notes_line old"></td>
<td class="notes_content parallel old">
<div
v-if="shouldRenderDiscussionsOnLeft"
class="content"
>
<diff-discussions
:discussions="discussionsByLineCode[leftLineCode]"
/>
</div>
<diff-line-note-form
v-if="diffLineCommentForms[leftLineCode] &&
diffLineCommentForms[leftLineCode]"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line.left"
:note-target-line="diffLines[lineIndex].left"
position="left"
/>
</td>
<td class="notes_line new"></td>
<td class="notes_content parallel new">
<div
v-if="shouldRenderDiscussionsOnRight"
class="content"
>
<diff-discussions
:discussions="discussionsByLineCode[rightLineCode]"
/>
</div>
<diff-line-note-form
v-if="diffLineCommentForms[rightLineCode] &&
diffLineCommentForms[rightLineCode] && line.right.type"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line.right"
:note-target-line="diffLines[lineIndex].right"
position="right"
/>
</td>
</tr>
</template>
<script>
import diffContentMixin from '../mixins/diff_content';
import {
EMPTY_CELL_TYPE,
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
LINE_HOVER_CLASS_NAME,
LINE_UNFOLD_CLASS_NAME,
LINE_POSITION_RIGHT,
} from '../constants';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
import { EMPTY_CELL_TYPE } from '../constants';
export default {
components: {
parallelDiffCommentRow,
},
mixins: [diffContentMixin],
computed: {
parallelDiffLines() {
......@@ -26,77 +21,6 @@ export default {
});
},
},
methods: {
hasDiscussion(line) {
const discussions = this.discussionsByLineCode;
const hasDiscussion = discussions[line.left.lineCode] || discussions[line.right.lineCode];
return hasDiscussion;
},
getClassName(line, position) {
const { type, lineCode } = line[position];
const isMatchLine = type === MATCH_LINE_TYPE;
const isContextLine = !isMatchLine && type !== EMPTY_CELL_TYPE && type !== CONTEXT_LINE_TYPE;
const isMetaLine = type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE;
const isSameLine = this.hoveredLineCode && this.hoveredLineCode === lineCode;
const isSameSection = position === this.hoveredSection;
return {
[type]: type,
[LINE_UNFOLD_CLASS_NAME]: isMatchLine,
[LINE_HOVER_CLASS_NAME]:
this.isLoggedIn && isContextLine && isSameLine && isSameSection && !isMetaLine,
};
},
handleMouse(e, line, isHover) {
if (isHover) {
const cell = e.target.closest('td');
if (this.$refs.leftLines.indexOf(cell) > -1) {
this.hoveredLineCode = line.left.lineCode;
this.hoveredSection = 'left';
} else if (this.$refs.rightLines.indexOf(cell) > -1) {
this.hoveredLineCode = line.right.lineCode;
this.hoveredSection = 'right';
}
} else {
this.hoveredLineCode = null;
this.hoveredSection = null;
}
},
shouldRenderDiscussionsRow(line) {
const hasDiscussion = this.hasDiscussion(line) && this.hasAnyExpandedDiscussion(line);
const hasCommentFormOnLeft = this.diffLineCommentForms[line.left.lineCode];
const hasCommentFormOnRight = this.diffLineCommentForms[line.right.lineCode];
return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight;
},
shouldRenderDiscussions(line, position) {
const { lineCode } = line[position];
let render = this.discussionsByLineCode[lineCode] && this.isDiscussionExpanded(lineCode);
// Avoid rendering context line discussions on the right side in parallel view
if (position === LINE_POSITION_RIGHT) {
render = render && line.right.type;
}
return render;
},
hasAnyExpandedDiscussion(line) {
const isLeftExpanded = this.isDiscussionExpanded(line.left.lineCode);
const isRightExpanded = this.isDiscussionExpanded(line.right.lineCode);
return isLeftExpanded || isRightExpanded;
},
getLineCode(line, side) {
const { lineCode } = side;
if (lineCode) {
return lineCode;
}
return `${this.fileHash}_${line.left.oldLine}_${line.right.newLine}`;
},
},
};
</script>
......@@ -104,119 +28,26 @@ export default {
<div
:class="userColorScheme"
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file">
class="code diff-wrap-lines js-syntax-highlight text-file"
>
<table>
<tbody>
<template
v-for="(line, index) in parallelDiffLines"
>
<tr
<diff-table-row
:diff-file="diffFile"
:line="line"
:is-bottom="index + 1 === diffLinesLength"
:key="index"
:class="getRowClass(line)"
class="line_holder parallel"
@mouseover="handleMouse($event, line, true)"
@mouseout="handleMouse($event, line, false)"
>
<td
ref="leftLines"
:class="getClassName(line, 'left')"
class="diff-line-num old_line"
>
<diff-line-gutter-content
:file-hash="fileHash"
:line-type="line.left.type"
:line-code="line.left.lineCode"
:line-number="line.left.oldLine"
:meta-data="line.left.metaData"
:show-comment-button="true"
:context-lines-path="diffFile.contextLinesPath"
:is-bottom="index + 1 === diffLinesLength"
line-position="left"
@showCommentForm="handleShowCommentForm"
/>
</td>
<td
ref="leftLines"
:class="getClassName(line, 'left')"
:id="getLineCode(line, line.left)"
class="line_content parallel left-side"
v-html="line.left.richText"
>
</td>
<td
ref="rightLines"
:class="getClassName(line, 'right')"
class="diff-line-num new_line"
>
<diff-line-gutter-content
:file-hash="fileHash"
:line-type="line.right.type"
:line-code="line.right.lineCode"
:line-number="line.right.newLine"
:meta-data="line.right.metaData"
:show-comment-button="true"
:context-lines-path="diffFile.contextLinesPath"
:is-bottom="index + 1 === diffLinesLength"
line-position="right"
@showCommentForm="handleShowCommentForm"
/>
</td>
<td
ref="rightLines"
:class="getClassName(line, 'right')"
:id="getLineCode(line, line.right)"
class="line_content parallel right-side"
v-html="line.right.richText"
>
</td>
</tr>
<tr
v-if="shouldRenderDiscussionsRow(line)"
/>
<parallel-diff-comment-row
:key="line.left.lineCode || line.right.lineCode"
:class="hasDiscussion(line) ? '' : 'js-temp-notes-holder'"
class="notes_holder"
>
<td class="notes_line old"></td>
<td class="notes_content parallel old">
<div
v-if="shouldRenderDiscussions(line, 'left')"
class="content"
>
<diff-discussions
:discussions="discussionsByLineCode[line.left.lineCode]"
/>
</div>
<diff-line-note-form
v-if="diffLineCommentForms[line.left.lineCode] &&
diffLineCommentForms[line.left.lineCode]"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line.left"
:note-target-line="diffLines[index].left"
position="left"
/>
</td>
<td class="notes_line new"></td>
<td class="notes_content parallel new">
<div
v-if="shouldRenderDiscussions(line, 'right')"
class="content"
>
<diff-discussions
:discussions="discussionsByLineCode[line.right.lineCode]"
/>
</div>
<diff-line-note-form
v-if="diffLineCommentForms[line.right.lineCode] &&
diffLineCommentForms[line.right.lineCode] && line.right.type"
:diff-file="diffFile"
:diff-lines="diffLines"
:line="line.right"
:note-target-line="diffLines[index].right"
position="right"
/>
</td>
</tr>
:line="line"
:diff-file="diffFile"
:diff-lines="parallelDiffLines"
:line-index="index"
/>
</template>
</tbody>
</table>
......
......@@ -14,6 +14,8 @@ export const TEXT_DIFF_POSITION_TYPE = 'text';
export const LINE_POSITION_LEFT = 'left';
export const LINE_POSITION_RIGHT = 'right';
export const LINE_SIDE_LEFT = 'left-side';
export const LINE_SIDE_RIGHT = 'right-side';
export const DIFF_VIEW_COOKIE_NAME = 'diff_view';
export const LINE_HOVER_CLASS_NAME = 'is-over';
......
import { mapState, mapGetters, mapActions } from 'vuex';
import { mapGetters } from 'vuex';
import diffDiscussions from '../components/diff_discussions.vue';
import diffLineGutterContent from '../components/diff_line_gutter_content.vue';
import diffLineNoteForm from '../components/diff_line_note_form.vue';
import diffTableRow from '../components/diff_table_row.vue';
import { trimFirstCharOfLineContent } from '../store/utils';
import { CONTEXT_LINE_TYPE, CONTEXT_LINE_CLASS_NAME } from '../constants';
export default {
props: {
......@@ -16,22 +16,14 @@ export default {
required: true,
},
},
data() {
return {
hoveredLineCode: null,
hoveredSection: null,
};
},
components: {
diffDiscussions,
diffTableRow,
diffLineNoteForm,
diffLineGutterContent,
},
computed: {
...mapState({
diffLineCommentForms: state => state.diffs.diffLineCommentForms,
}),
...mapGetters(['discussionsByLineCode', 'isLoggedIn', 'commit']),
...mapGetters(['commit']),
commitId() {
return this.commit && this.commit.id;
},
......@@ -41,15 +33,15 @@ export default {
normalizedDiffLines() {
return this.diffLines.map(line => {
if (line.richText) {
return this.trimFirstChar(line);
return trimFirstCharOfLineContent(line);
}
if (line.left) {
Object.assign(line, { left: this.trimFirstChar(line.left) });
Object.assign(line, { left: trimFirstCharOfLineContent(line.left) });
}
if (line.right) {
Object.assign(line, { right: this.trimFirstChar(line.right) });
Object.assign(line, { right: trimFirstCharOfLineContent(line.right) });
}
return line;
......@@ -62,28 +54,4 @@ export default {
return this.diffFile.fileHash;
},
},
methods: {
...mapActions(['showCommentForm', 'cancelCommentForm']),
getRowClass(line) {
const isContextLine = line.left
? line.left.type === CONTEXT_LINE_TYPE
: line.type === CONTEXT_LINE_TYPE;
return {
[line.type]: line.type,
[CONTEXT_LINE_CLASS_NAME]: isContextLine,
};
},
trimFirstChar(line) {
return trimFirstCharOfLineContent(line);
},
handleShowCommentForm(params) {
this.showCommentForm({ lineCode: params.lineCode });
},
isDiscussionExpanded(lineCode) {
const discussions = this.discussionsByLineCode[lineCode];
return discussions ? discussions.every(discussion => discussion.expanded) : false;
},
},
};
......@@ -10,7 +10,7 @@ export default {
},
computed: {
...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']),
...mapGetters(['currentProject']),
...mapGetters(['currentProject', 'currentBranch']),
commitToCurrentBranchText() {
return sprintf(
__('Commit to %{branchName} branch'),
......@@ -22,17 +22,30 @@ export default {
return this.changedFiles.length > 0 && this.stagedFiles.length > 0;
},
},
watch: {
disableMergeRequestRadio() {
this.updateSelectedCommitAction();
},
},
mounted() {
if (this.disableMergeRequestRadio) {
this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH);
}
this.updateSelectedCommitAction();
},
methods: {
...mapActions('commit', ['updateCommitAction']),
updateSelectedCommitAction() {
if (this.currentBranch && !this.currentBranch.can_push) {
this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH);
} else if (this.disableMergeRequestRadio) {
this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH);
}
},
},
commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
currentBranchPermissionsTooltip: __(
"This option is disabled as you don't have write permissions for the current branch",
),
};
</script>
......@@ -40,9 +53,11 @@ export default {
<div class="append-bottom-15 ide-commit-radios">
<radio-group
:value="$options.commitToCurrentBranch"
:checked="true"
:disabled="currentBranch && !currentBranch.can_push"
:title="$options.currentBranchPermissionsTooltip"
>
<span
class="ide-radio-label"
v-html="commitToCurrentBranchText"
>
</span>
......@@ -56,6 +71,7 @@ export default {
v-if="currentProject.merge_requests_enabled"
:value="$options.commitToNewBranchMR"
:label="__('Create a new branch and merge request')"
:title="__('This option is disabled while you still have unstaged changes')"
:show-input="true"
:disabled="disableMergeRequestRadio"
/>
......
......@@ -24,7 +24,7 @@ export default {
...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters(['hasChanges']),
...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
overviewText() {
return sprintf(
__(
......@@ -36,6 +36,9 @@ export default {
},
);
},
commitButtonText() {
return this.stagedFiles.length ? __('Commit') : __('Stage & Commit');
},
},
watch: {
currentActivityView() {
......@@ -136,14 +139,14 @@ export default {
</transition>
<commit-message-field
:text="commitMessage"
:placeholder="preBuiltCommitMessage"
@input="updateCommitMessage"
/>
<div class="clearfix prepend-top-15">
<actions />
<loading-button
:loading="submitCommitLoading"
:disabled="commitButtonDisabled"
:label="__('Commit')"
:label="commitButtonText"
container-class="btn btn-success btn-sm float-left"
@click="commitChanges"
/>
......
......@@ -16,6 +16,10 @@ export default {
type: String,
required: true,
},
placeholder: {
type: String,
required: true,
},
},
data() {
return {
......@@ -114,7 +118,7 @@ export default {
</div>
<textarea
ref="textarea"
:placeholder="__('Write a commit message...')"
:placeholder="placeholder"
:value="text"
class="note-textarea ide-commit-message-textarea"
name="commit-message"
......
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
......@@ -32,14 +31,17 @@ export default {
required: false,
default: false,
},
title: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState('commit', ['commitAction']),
...mapGetters('commit', ['newBranchName']),
tooltipTitle() {
return this.disabled
? __('This option is disabled while you still have unstaged changes')
: '';
return this.disabled ? this.title : '';
},
},
methods: {
......
......@@ -28,7 +28,7 @@ export default {
]),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges', 'activeFile']),
...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
...mapGetters('commit', ['discardDraftButtonDisabled']),
showStageUnstageArea() {
return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal);
},
......
......@@ -82,10 +82,13 @@ export const getStagedFilesCountForPath = state => path =>
getChangesCountForFiles(state.stagedFiles, path);
export const lastCommit = (state, getters) => {
const branch = getters.currentProject && getters.currentProject.branches[state.currentBranchId];
const branch = getters.currentProject && getters.currentBranch;
return branch ? branch.commit : null;
};
export const currentBranch = (state, getters) =>
getters.currentProject && getters.currentProject.branches[state.currentBranchId];
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -103,17 +103,24 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data }
export const commitChanges = ({ commit, state, getters, dispatch, rootState, rootGetters }) => {
const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
const payload = createCommitPayload({
branch: getters.branchName,
newBranch,
state,
rootState,
});
const stageFilesPromise = rootState.stagedFiles.length
? Promise.resolve()
: dispatch('stageAllChanges', null, { root: true });
commit(types.UPDATE_LOADING, true);
return service
.commit(rootState.currentProjectId, payload)
return stageFilesPromise
.then(() => {
const payload = createCommitPayload({
branch: getters.branchName,
newBranch,
getters,
state,
rootState,
});
return service.commit(rootState.currentProjectId, payload);
})
.then(({ data }) => {
commit(types.UPDATE_LOADING, false);
......
import { sprintf, n__ } from '../../../../locale';
import * as consts from './constants';
const BRANCH_SUFFIX_COUNT = 5;
......@@ -5,9 +6,6 @@ const BRANCH_SUFFIX_COUNT = 5;
export const discardDraftButtonDisabled = state =>
state.commitMessage === '' || state.submitCommitLoading;
export const commitButtonDisabled = (state, getters, rootState) =>
getters.discardDraftButtonDisabled || !rootState.stagedFiles.length;
export const newBranchName = (state, _, rootState) =>
`${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(
-BRANCH_SUFFIX_COUNT,
......@@ -28,5 +26,18 @@ export const branchName = (state, getters, rootState) => {
return rootState.currentBranchId;
};
export const preBuiltCommitMessage = (state, _, rootState) => {
if (state.commitMessage) return state.commitMessage;
const files = (rootState.stagedFiles.length
? rootState.stagedFiles
: rootState.changedFiles
).reduce((acc, val) => acc.concat(val.path), []);
return sprintf(n__('Update %{files}', 'Update %{files} files', files.length), {
files: files.join(', '),
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -105,9 +105,9 @@ export const setPageTitle = title => {
document.title = title;
};
export const createCommitPayload = ({ branch, newBranch, state, rootState }) => ({
export const createCommitPayload = ({ branch, getters, newBranch, state, rootState }) => ({
branch,
commit_message: state.commitMessage,
commit_message: state.commitMessage || getters.preBuiltCommitMessage,
actions: rootState.stagedFiles.map(f => ({
action: f.tempFile ? 'create' : 'update',
file_path: f.path,
......
......@@ -200,6 +200,7 @@ export default {
:class="getAwardClassBindings(awardList, awardName)"
:title="awardTitle(awardList)"
class="btn award-control"
data-boundary="viewport"
data-placement="bottom"
type="button"
@click="handleAward(awardName)">
......@@ -217,6 +218,7 @@ export default {
class="award-control btn js-add-award"
title="Add reaction"
aria-label="Add reaction"
data-boundary="viewport"
data-placement="bottom"
type="button">
<span
......
......@@ -61,7 +61,13 @@ export default class Profile {
url: this.form.attr('action'),
data: formData,
})
.then(({ data }) => flash(data.message, 'notice'))
.then(({ data }) => {
if (avatarBlob != null) {
this.updateHeaderAvatar();
}
flash(data.message, 'notice');
})
.then(() => {
window.scrollTo(0, 0);
// Enable submit button after requests ends
......@@ -70,6 +76,10 @@ export default class Profile {
.catch(error => flash(error.message));
}
updateHeaderAvatar() {
$('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL);
}
setRepoRadio() {
const multiEditRadios = $('input[name="user[multi_file]"]');
if (this.newRepoActivated || this.newRepoActivated === 'true') {
......
......@@ -97,6 +97,10 @@
font-size: 12px;
}
}
svg {
vertical-align: text-top;
}
}
.light-well {
......
......@@ -102,7 +102,9 @@ pre.code,
// Diff line
.line_holder {
&.match .line_content {
&.match .line_content,
.new-nonewline.line_content,
.old-nonewline.line_content {
@include matchLine;
}
......
......@@ -698,7 +698,7 @@
&.diff-files-changed-merge-request {
position: sticky;
top: 90px;
z-index: 190;
z-index: 200;
margin: $gl-padding 0;
padding: 0;
}
......@@ -706,6 +706,7 @@
&.is-stuck {
padding-top: 0;
padding-bottom: 0;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
.diff-stats-additions-deletions-expanded,
......
......@@ -721,7 +721,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;
......
module Types
class BaseObject < GraphQL::Schema::Object
prepend Gitlab::Graphql::Present
prepend Gitlab::Graphql::ExposePermissions
field_class Types::BaseField
end
......
module Types
class MergeRequestType < BaseObject
expose_permissions Types::PermissionTypes::MergeRequest
present_using MergeRequestPresenter
graphql_name 'MergeRequest'
......
module Types
module PermissionTypes
class BasePermissionType < BaseObject
extend Gitlab::Allowable
RESOLVING_KEYWORDS = [:resolver, :method, :hash_key, :function].to_set.freeze
def self.abilities(*abilities)
abilities.each { |ability| ability_field(ability) }
end
def self.ability_field(ability, **kword_args)
unless resolving_keywords?(kword_args)
kword_args[:resolve] ||= -> (object, args, context) do
can?(context[:current_user], ability, object, args.to_h)
end
end
permission_field(ability, **kword_args)
end
def self.permission_field(name, **kword_args)
kword_args = kword_args.reverse_merge(
name: name,
type: GraphQL::BOOLEAN_TYPE,
description: "Whether or not a user can perform `#{name}` on this resource",
null: false)
field(**kword_args)
end
def self.resolving_keywords?(arguments)
RESOLVING_KEYWORDS.intersect?(arguments.keys.to_set)
end
private_class_method :resolving_keywords?
end
end
end
module Types
module PermissionTypes
class MergeRequest < BasePermissionType
present_using MergeRequestPresenter
description 'Check permissions for the current user on a merge request'
graphql_name 'MergeRequestPermissions'
abilities :read_merge_request, :admin_merge_request,
:update_merge_request, :create_note
permission_field :push_to_source_branch, method: :can_push_to_source_branch?
permission_field :remove_source_branch, method: :can_remove_source_branch?
permission_field :cherry_pick_on_current_merge_request, method: :can_cherry_pick_on_current_merge_request?
permission_field :revert_on_current_merge_request, method: :can_revert_on_current_merge_request?
end
end
end
module Types
module PermissionTypes
class Project < BasePermissionType
graphql_name 'ProjectPermissions'
abilities :change_namespace, :change_visibility_level, :rename_project,
:remove_project, :archive_project, :remove_fork_project,
:remove_pages, :read_project, :create_merge_request_in,
:read_wiki, :read_project_member, :create_issue, :upload_file,
:read_cycle_analytics, :download_code, :download_wiki_code,
:fork_project, :create_project_snippet, :read_commit_status,
:request_access, :create_pipeline, :create_pipeline_schedule,
:create_merge_request_from, :create_wiki, :push_code,
:create_deployment, :push_to_delete_protected_branch,
:admin_wiki, :admin_project, :update_pages,
:admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki,
:create_pages, :destroy_pages
end
end
end
module Types
class ProjectType < BaseObject
expose_permissions Types::PermissionTypes::Project
graphql_name 'Project'
field :id, GraphQL::ID_TYPE, null: false
......
......@@ -170,6 +170,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
.can_push_to_branch?(source_branch)
end
def can_remove_source_branch?
source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
end
def mergeable_discussions_state
# This avoids calling MergeRequest#mergeable_discussions_state without
# considering the state of the MR first. If a MR isn't mergeable, we can
......
......@@ -111,7 +111,7 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :current_user do
expose :can_remove_source_branch do |merge_request|
merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
presenter(merge_request).can_remove_source_branch?
end
expose :can_revert_on_current_merge_request do |merge_request|
......
......@@ -6,9 +6,9 @@ module MergeRequests
#
class PostMergeService < MergeRequests::BaseService
def execute(merge_request)
merge_request.mark_as_merged
close_issues(merge_request)
todo_service.merge_merge_request(merge_request, current_user)
merge_request.mark_as_merged
create_event(merge_request)
create_note(merge_request)
notification_service.merge_mr(merge_request, current_user)
......
......@@ -18,10 +18,18 @@ module MergeRequests
return false
end
log_prefix = "#{self.class.name} info (#{merge_request.to_reference(full: true)}):"
Gitlab::GitLogger.info("#{log_prefix} rebase started")
rebase_sha = repository.rebase(current_user, merge_request)
Gitlab::GitLogger.info("#{log_prefix} rebased to #{rebase_sha}")
merge_request.update_attributes(rebase_commit_sha: rebase_sha)
Gitlab::GitLogger.info("#{log_prefix} rebase SHA saved: #{rebase_sha}")
true
rescue => e
log_error(REBASE_ERROR, save_message_on_model: true)
......
- if environment.external_url && can?(current_user, :read_environment, environment)
= link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url' do
= icon('external-link')
= sprite_icon('external-link')
View deployment
......@@ -3,5 +3,5 @@
- return unless can?(current_user, :read_environment, environment)
= link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do
= icon('area-chart')
= sprite_icon('chart')
Monitoring
- if environment.has_terminals? && can?(current_user, :admin_environment, @project)
= link_to terminal_project_environment_path(@project, environment), class: 'btn terminal-button' do
= icon('terminal')
= sprite_icon('terminal')
......@@ -16,7 +16,7 @@
.nav-controls
- if @environment.external_url.present?
= link_to @environment.external_url, class: 'btn btn-default', target: '_blank', rel: 'noopener noreferrer nofollow' do
= icon('external-link')
= sprite_icon('external-link')
= render 'projects/deployments/actions', deployment: @environment.last_deployment
.terminal-container{ class: container_class }
......
......@@ -6,13 +6,13 @@
1.
= link_to 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands', target: '_blank', rel: 'noopener noreferrer nofollow' do
Enable custom slash commands
= icon('external-link')
= sprite_icon('external-link', size: 16)
on your Mattermost installation
%li
2.
= link_to 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command', target: '_blank', rel: 'noopener noreferrer nofollow' do
Add a slash command
= icon('external-link')
= sprite_icon('external-link', size: 16)
in your Mattermost team with these options:
%hr
......
......@@ -7,7 +7,7 @@
project by entering slash commands in Mattermost.
= link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank' do
View documentation
= icon('external-link')
= sprite_icon('external-link', size: 16)
%p.inline
See list of available commands in Mattermost after setting up this service,
by entering
......
......@@ -8,7 +8,7 @@
project by entering slash commands in Slack.
= link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank' do
View documentation
= icon('external-link')
= sprite_icon('external-link', size: 16)
%p.inline
See list of available commands in Slack after setting up this service,
by entering
......@@ -20,7 +20,7 @@
1.
= link_to 'https://my.slack.com/services/new/slash-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do
Add a slash command
= icon('external-link')
= sprite_icon('external-link', size: 16)
in your Slack team with these options:
%hr
......
......@@ -22,8 +22,9 @@
= sprite_icon('star-o')
%button.label-action.remove-priority.btn.btn-transparent.has-tooltip{ title: _('Remove priority'), type: 'button', data: { placement: 'top' }, aria_label: _('Deprioritize label') }
= sprite_icon('star')
- if can?(current_user, :admin_label, label)
%li.inline
= link_to edit_label_path(label), class: 'btn btn-transparent label-action', aria_label: 'Edit label' do
= link_to edit_label_path(label), class: 'btn btn-transparent label-action edit', aria_label: 'Edit label' do
= sprite_icon('pencil')
%li.inline
.dropdown
......@@ -43,9 +44,10 @@
container: 'body',
toggle: 'modal' } }
= _('Promote to group label')
%li
%span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } }
%button.text-danger.remove-row{ type: 'button' }= _('Delete')
- if can?(current_user, :admin_label, label)
%li
%span{ data: { toggle: 'modal', target: "#modal-delete-label-#{label.id}" } }
%button.text-danger.remove-row{ type: 'button' }= _('Delete')
- if current_user
%li.inline.label-subscription
- if can_subscribe_to_label_in_different_levels?(label)
......
---
title: Add SHA256 and HEAD on File API
merge_request: 19439
author: ahmet2mir
type: added
---
title: Change avatar image in the header when user updates their avatar.
merge_request: 20119
author: Jamie Schembri
type: added
---
title: Fixed Merge request changes dropdown displays incorrectly
merge_request: 20237
author: Constance Okoghenun
type: fixed
---
title: Add more detailed logging to githost.log when rebasing
merge_request:
author:
type: other
---
title: 'Expose permissions of the current user on resources in GraphQL'
merge_request: 20152
author:
type: added
---
title: Fixes an issue where migrations instead of schema loading were run
merge_request: 20227
author:
type: changed
---
title: Don't hash user ID in OIDC subject claim
merge_request: 19784
author: Markus Koller
type: changed
---
title: Mark MR as merged regardless of errors when closing issues
merge_request:
author:
type: fixed
---
title: Revert merge request discussion buttons padding
merge_request: 20060
author: George Tsiolis
type: changed
---
title: Allow straight diff in Compare API
merge_request: 20120
author: Maciej Nowak
type: added
---
title: Update environments nav controls icons
merge_request: 20199
author: George Tsiolis
type: changed
---
title: Update integrations external link icons
merge_request: 20205
author: George Tsiolis
type: changed
......@@ -33,7 +33,7 @@ production: &base
port: 80 # Set to 443 if using HTTPS, see installation.md#using-https for additional HTTPS configuration details
https: false # Set to true if using HTTPS, see installation.md#using-https for additional HTTPS configuration details
# Uncommment this line below if your ssh host is different from HTTP/HTTPS one
# Uncomment this line below if your ssh host is different from HTTP/HTTPS one
# (you'd obviously need to replace ssh.host_example.com with your own host).
# Otherwise, ssh host will be set to the `host:` value above
# ssh_host: ssh.host_example.com
......
......@@ -18,12 +18,17 @@ Doorkeeper::OpenidConnect.configure do
end
subject do |user|
# hash the user's ID with the Rails secret_key_base to avoid revealing it
Digest::SHA256.hexdigest "#{user.id}-#{Rails.application.secrets.secret_key_base}"
user.id
end
claims do
with_options scope: :openid do |o|
o.claim(:sub_legacy, response: [:id_token, :user_info]) do |user|
# provide the previously hashed 'sub' claim to allow third-party apps
# to migrate to the new unhashed value
Digest::SHA256.hexdigest "#{user.id}-#{Rails.application.secrets.secret_key_base}"
end
o.claim(:name) { |user| user.name }
o.claim(:nickname) { |user| user.username }
o.claim(:email) { |user| user.public_email }
......
# GraphQL API (Beta)
# GraphQL API (Alpha)
> [Introduced][ce-19008] in GitLab 11.0.
......
......@@ -130,6 +130,7 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `from` (required) - the commit SHA or branch name
- `to` (required) - the commit SHA or branch name
- `straight` (optional) - comparison method, `true` for direct comparison between `from` and `to` (`from`..`to`), `false` to compare using merge base (`from`...`to`)'. Default is `false`.
```
GET /projects/:id/repository/compare?from=master&to=feature
......
......@@ -27,6 +27,7 @@ Example response:
"size": 1476,
"encoding": "base64",
"content": "IyA9PSBTY2hlbWEgSW5mb3...",
"content_sha256": "4c294617b60715c1d218e61164a3abd4808a4284cbc30e6728a01ad9aada4481",
"ref": "master",
"blob_id": "79f7bbd25901e8334750839545a9bd021f0e4c83",
"commit_id": "d5a3ff139356ce33e37e73add446f16869741b50",
......@@ -39,6 +40,36 @@ Parameters:
- `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb
- `ref` (required) - The name of branch, tag or commit
NOTE: **Note:**
`blob_id` is the blob sha, see [repositories - Get a blob from repository](repositories.md#get-a-blob-from-repository)
In addition to the `GET` method, you can also use `HEAD` to get just file metadata.
```
HEAD /projects/:id/repository/files/:file_path
```
```bash
curl --head --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/files/app%2Fmodels%2Fkey%2Erb?ref=master'
```
Example response:
```text
HTTP/1.1 200 OK
...
X-Gitlab-Blob-Id: 79f7bbd25901e8334750839545a9bd021f0e4c83
X-Gitlab-Commit-Id: d5a3ff139356ce33e37e73add446f16869741b50
X-Gitlab-Content-Sha256: 4c294617b60715c1d218e61164a3abd4808a4284cbc30e6728a01ad9aada4481
X-Gitlab-Encoding: base64
X-Gitlab-File-Name: key.rb
X-Gitlab-File-Path: app/models/key.rb
X-Gitlab-Last-Commit-Id: 570e7b2abdd848b95f2f578043fc23bd6f6fd24d
X-Gitlab-Ref: master
X-Gitlab-Size: 1476
...
```
## Get raw file from repository
```
......@@ -54,6 +85,9 @@ Parameters:
- `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb
- `ref` (required) - The name of branch, tag or commit
NOTE: **Note:**
Like [Get file from repository](repository_files.md#get-file-from-repository) you can use `HEAD` to get just file metadata.
## Create new file in repository
```
......
......@@ -54,6 +54,51 @@ a new presenter specifically for GraphQL.
The presenter is initialized using the object resolved by a field, and
the context.
### Exposing permissions for a type
To expose permissions the current user has on a resource, you can call
the `expose_permissions` passing in a separate type representing the
permissions for the resource.
For example:
```ruby
module Types
class MergeRequestType < BaseObject
expose_permissions Types::MergeRequestPermissionsType
end
end
```
The permission type inherits from `BasePermissionType` which includes
some helper methods, that allow exposing permissions as non-nullable
booleans:
```ruby
class MergeRequestPermissionsType < BasePermissionType
present_using MergeRequestPresenter
graphql_name 'MergeRequestPermissions'
abilities :admin_merge_request, :update_merge_request, :create_note
ability_field :resolve_note,
description: 'Whether or not the user can resolve disussions on the merge request'
permission_field :push_to_source_branch, method: :can_push_to_source_branch?
end
```
- **`permission_field`**: Will act the same as `graphql-ruby`'s
`field` method but setting a default description and type and making
them non-nullable. These options can still be overridden by adding
them as arguments.
- **`ability_field`**: Expose an ability defined in our policies. This
takes behaves the same way as `permission_field` and the same
arguments can be overridden.
- **`abilities`**: Allows exposing several abilities defined in our
policies at once. The fields for these will all have be non-nullable
booleans with a default description.
## Resolvers
To find objects to display in a field, we can add resolvers to
......
......@@ -5,11 +5,11 @@ to sign in to other services.
## Introduction to OpenID Connect
[OpenID Connect] \(OIC) is a simple identity layer on top of the
[OpenID Connect] \(OIDC) is a simple identity layer on top of the
OAuth 2.0 protocol. It allows clients to verify the identity of the end-user
based on the authentication performed by GitLab, as well as to obtain
basic profile information about the end-user in an interoperable and
REST-like manner. OIC performs many of the same tasks as OpenID 2.0,
REST-like manner. OIDC performs many of the same tasks as OpenID 2.0,
but does so in a way that is API-friendly, and usable by native and
mobile applications.
......@@ -23,14 +23,17 @@ are supported.
## Enabling OpenID Connect for OAuth applications
Refer to the [OAuth guide] for basic information on how to set up OAuth
applications in GitLab. To enable OIC for an application, all you have to do
applications in GitLab. To enable OIDC for an application, all you have to do
is select the `openid` scope in the application settings.
## Shared information
Currently the following user information is shared with clients:
| Claim | Type | Description |
|:-----------------|:----------|:------------|
| `sub` | `string` | An opaque token that uniquely identifies the user
| `sub` | `string` | The ID of the user
| `sub_legacy` | `string` | An opaque token that uniquely identifies the user<br><br>**Deprecation notice:** this token isn't stable because it's tied to the Rails secret key base, and is provided only for migration to the new stable `sub` value available from GitLab 11.1
| `auth_time` | `integer` | The timestamp for the user's last authentication
| `name` | `string` | The user's full name
| `nickname` | `string` | The user's GitLab username
......@@ -41,6 +44,8 @@ Currently the following user information is shared with clients:
| `picture` | `string` | URL for the user's GitLab avatar
| `groups` | `array` | Names of the groups the user is a member of
Only the `sub` and `sub_legacy` claims are included in the ID token, all other claims are available from the `/oauth/userinfo` endpoint used by OIDC clients.
[OpenID Connect]: http://openid.net/connect/ "OpenID Connect website"
[doorkeeper-openid_connect]: https://github.com/doorkeeper-gem/doorkeeper-openid_connect "Doorkeeper::OpenidConnect website"
[OAuth guide]: oauth_provider.md "GitLab as OAuth2 authentication service provider"
......
......@@ -5,6 +5,8 @@ module API
# Prevents returning plain/text responses for files with .txt extension
after_validation { content_type "application/json" }
helpers ::API::Helpers::HeadersHelpers
helpers do
def commit_params(attrs)
{
......@@ -40,6 +42,20 @@ module API
}
end
def blob_data
{
file_name: @blob.name,
file_path: @blob.path,
size: @blob.size,
encoding: "base64",
content_sha256: Digest::SHA256.hexdigest(@blob.data),
ref: params[:ref],
blob_id: @blob.id,
commit_id: @commit.id,
last_commit_id: @repo.last_commit_id_for_path(@commit.sha, params[:file_path])
}
end
params :simple_file_params do
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
requires :branch, type: String, desc: 'Name of the branch to commit into. To create a new branch, also provide `start_branch`.'
......@@ -61,6 +77,17 @@ module API
requires :id, type: String, desc: 'The project ID'
end
resource :projects, requirements: FILE_ENDPOINT_REQUIREMENTS do
desc 'Get raw file metadata from repository'
params do
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
requires :ref, type: String, desc: 'The name of branch, tag or commit'
end
head ":id/repository/files/:file_path/raw", requirements: FILE_ENDPOINT_REQUIREMENTS do
assign_file_vars!
set_http_headers(blob_data)
end
desc 'Get raw file contents from the repository'
params do
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
......@@ -69,9 +96,22 @@ module API
get ":id/repository/files/:file_path/raw", requirements: FILE_ENDPOINT_REQUIREMENTS do
assign_file_vars!
set_http_headers(blob_data)
send_git_blob @repo, @blob
end
desc 'Get file metadata from repository'
params do
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
requires :ref, type: String, desc: 'The name of branch, tag or commit'
end
head ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS do
assign_file_vars!
set_http_headers(blob_data)
end
desc 'Get a file from the repository'
params do
requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb'
......@@ -80,17 +120,11 @@ module API
get ":id/repository/files/:file_path", requirements: FILE_ENDPOINT_REQUIREMENTS do
assign_file_vars!
{
file_name: @blob.name,
file_path: @blob.path,
size: @blob.size,
encoding: "base64",
content: Base64.strict_encode64(@blob.data),
ref: params[:ref],
blob_id: @blob.id,
commit_id: @commit.id,
last_commit_id: @repo.last_commit_id_for_path(@commit.sha, params[:file_path])
}
data = blob_data
set_http_headers(data)
data.merge(content: Base64.strict_encode64(@blob.data))
end
desc 'Create new file in repository'
......
module API
module Helpers
module HeadersHelpers
def set_http_headers(header_data)
header_data.each do |key, value|
header "X-Gitlab-#{key.to_s.split('_').collect(&:capitalize).join('-')}", value
end
end
end
end
end
......@@ -100,9 +100,10 @@ module API
params do
requires :from, type: String, desc: 'The commit, branch name, or tag name to start comparison'
requires :to, type: String, desc: 'The commit, branch name, or tag name to stop comparison'
optional :straight, type: Boolean, desc: 'Comparison method, `true` for direct comparison between `from` and `to` (`from`..`to`), `false` to compare using merge base (`from`...`to`)', default: false
end
get ':id/repository/compare' do
compare = Gitlab::Git::Compare.new(user_project.repository.raw_repository, params[:from], params[:to])
compare = Gitlab::Git::Compare.new(user_project.repository.raw_repository, params[:from], params[:to], straight: params[:straight])
present compare, with: Entities::Compare
end
......
......@@ -116,15 +116,9 @@ module Gitlab
# Commit.between(repo, '29eda46b', 'master')
#
def between(repo, base, head)
Gitlab::GitalyClient.migrate(:commits_between) do |is_enabled|
if is_enabled
repo.gitaly_commit_client.between(base, head)
else
repo.rugged_commits_between(base, head).map { |c| decorate(repo, c) }
end
repo.wrapped_gitaly_errors do
repo.gitaly_commit_client.between(base, head)
end
rescue Rugged::ReferenceError
[]
end
# Returns commits collection
......@@ -149,58 +143,11 @@ module Gitlab
#
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/326
def find_all(repo, options = {})
Gitlab::GitalyClient.migrate(:find_all_commits) do |is_enabled|
if is_enabled
find_all_by_gitaly(repo, options)
else
find_all_by_rugged(repo, options)
end
repo.wrapped_gitaly_errors do
Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options)
end
end
def find_all_by_rugged(repo, options = {})
actual_options = options.dup
allowed_options = [:ref, :max_count, :skip, :order]
actual_options.keep_if do |key|
allowed_options.include?(key)
end
default_options = { skip: 0 }
actual_options = default_options.merge(actual_options)
rugged = repo.rugged
walker = Rugged::Walker.new(rugged)
if actual_options[:ref]
walker.push(rugged.rev_parse_oid(actual_options[:ref]))
else
rugged.references.each("refs/heads/*") do |ref|
walker.push(ref.target_id)
end
end
walker.sorting(rugged_sort_type(actual_options[:order]))
commits = []
offset = actual_options[:skip]
limit = actual_options[:max_count]
walker.each(offset: offset, limit: limit) do |commit|
commits.push(decorate(repo, commit))
end
walker.reset
commits
rescue Rugged::OdbError
[]
end
def find_all_by_gitaly(repo, options = {})
Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options)
end
def decorate(repository, commit, ref = nil)
Gitlab::Git::Commit.new(repository, commit, ref)
end
......@@ -220,19 +167,7 @@ module Gitlab
end
def shas_with_signatures(repository, shas)
GitalyClient.migrate(:filter_shas_with_signatures) do |is_enabled|
if is_enabled
Gitlab::GitalyClient::CommitService.new(repository).filter_shas_with_signatures(shas)
else
shas.select do |sha|
begin
Rugged::Commit.extract_signature(repository.rugged, sha)
rescue Rugged::OdbError
false
end
end
end
end
Gitlab::GitalyClient::CommitService.new(repository).filter_shas_with_signatures(shas)
end
# Only to be used when the object ids will not necessarily have a
......@@ -250,13 +185,7 @@ module Gitlab
end
def extract_signature(repository, commit_id)
repository.gitaly_migrate(:extract_commit_signature) do |is_enabled|
if is_enabled
repository.gitaly_commit_client.extract_signature(commit_id)
else
rugged_extract_signature(repository, commit_id)
end
end
repository.gitaly_commit_client.extract_signature(commit_id)
end
def extract_signature_lazily(repository, commit_id)
......@@ -276,36 +205,9 @@ module Gitlab
end
def batch_signature_extraction(repository, commit_ids)
repository.gitaly_migrate(:extract_commit_signature_in_batch) do |is_enabled|
if is_enabled
gitaly_batch_signature_extraction(repository, commit_ids)
else
rugged_batch_signature_extraction(repository, commit_ids)
end
end
end
def gitaly_batch_signature_extraction(repository, commit_ids)
repository.gitaly_commit_client.get_commit_signatures(commit_ids)
end
def rugged_batch_signature_extraction(repository, commit_ids)
commit_ids.each_with_object({}) do |commit_id, signatures|
signature_data = rugged_extract_signature(repository, commit_id)
next unless signature_data
signatures[commit_id] = signature_data
end
end
def rugged_extract_signature(repository, commit_id)
begin
Rugged::Commit.extract_signature(repository.rugged, commit_id)
rescue Rugged::OdbError
nil
end
end
def get_message(repository, commit_id)
BatchLoader.for({ repository: repository, commit_id: commit_id }).batch do |items, loader|
items_by_repo = items.group_by { |i| i[:repository] }
......@@ -323,13 +225,7 @@ module Gitlab
end
def get_messages(repository, commit_ids)
repository.gitaly_migrate(:commit_messages) do |is_enabled|
if is_enabled
repository.gitaly_commit_client.get_commit_messages(commit_ids)
else
commit_ids.map { |id| [id, rugged_find(repository, id).message] }.to_h
end
end
repository.gitaly_commit_client.get_commit_messages(commit_ids)
end
end
......
......@@ -492,27 +492,6 @@ module Gitlab
Ref.dereference_object(obj)
end
# Return a collection of Rugged::Commits between the two revspec arguments.
# See http://git-scm.com/docs/git-rev-parse.html#_specifying_revisions for
# a detailed list of valid arguments.
#
# Gitaly note: JV: to be deprecated in favor of Commit.between
def rugged_commits_between(from, to)
walker = Rugged::Walker.new(rugged)
walker.sorting(Rugged::SORT_NONE | Rugged::SORT_REVERSE)
sha_from = sha_from_ref(from)
sha_to = sha_from_ref(to)
walker.push(sha_to)
walker.hide(sha_from)
commits = walker.to_a
walker.reset
commits
end
# Counts the amount of commits between `from` and `to`.
def count_commits_between(from, to, options = {})
count_commits(from: from, to: to, **options)
......@@ -1316,16 +1295,7 @@ module Gitlab
safe_query = Regexp.escape(query)
ref ||= root_ref
gitaly_migrate(:search_files_by_content) do |is_enabled|
if is_enabled
gitaly_repository_client.search_files_by_content(ref, safe_query)
else
offset = 2
args = %W(grep -i -I -n -z --before-context #{offset} --after-context #{offset} -E -e #{safe_query} #{ref})
run_git(args).first.scrub.split(/^--\n/)
end
end
gitaly_repository_client.search_files_by_content(ref, safe_query)
end
def can_be_merged?(source_sha, target_branch)
......@@ -1342,15 +1312,7 @@ module Gitlab
return [] if empty? || safe_query.blank?
gitaly_migrate(:search_files_by_name) do |is_enabled|
if is_enabled
gitaly_repository_client.search_files_by_name(ref, safe_query)
else
args = %W(ls-tree -r --name-status --full-tree #{ref} -- #{safe_query})
run_git(args).first.lines.map(&:strip)
end
end
gitaly_repository_client.search_files_by_name(ref, safe_query)
end
def find_commits_by_message(query, ref, path, limit, offset)
......
......@@ -28,18 +28,7 @@ module Gitlab
end
def get_messages(repository, tag_ids)
repository.gitaly_migrate(:tag_messages) do |is_enabled|
if is_enabled
repository.gitaly_ref_client.get_tag_messages(tag_ids)
else
tag_ids.map do |id|
tag = repository.rugged.lookup(id)
message = tag.is_a?(Rugged::Commit) ? "" : tag.message
[id, message]
end.to_h
end
end
repository.gitaly_ref_client.get_tag_messages(tag_ids)
end
end
......
......@@ -324,6 +324,8 @@ module Gitlab
return if signature.blank? && signed_text.blank?
[signature, signed_text]
rescue GRPC::InvalidArgument => ex
raise ArgumentError, ex
end
def get_commit_signatures(commit_ids)
......@@ -341,6 +343,8 @@ module Gitlab
end
signatures
rescue GRPC::InvalidArgument => ex
raise ArgumentError, ex
end
def get_commit_messages(commit_ids)
......
module Gitlab
module Graphql
module ExposePermissions
extend ActiveSupport::Concern
prepended do
def self.expose_permissions(permission_type, description: 'Permissions for the current user on the resource')
field :user_permissions, permission_type,
description: description,
null: false,
resolve: -> (obj, _, _) { obj }
end
end
end
end
end
......@@ -10,9 +10,18 @@ module Gitlab
old_resolver = field.resolve_proc
resolve_with_presenter = -> (presented_type, args, context) do
# We need to wrap the original presentation type into a type that
# uses the presenter as an object.
object = presented_type.object
if object.is_a?(presented_in.presenter_class)
next old_resolver.call(presented_type, args, context)
end
presenter = presented_in.presenter_class.new(object, **context.to_h)
old_resolver.call(presenter, args, context)
wrapped = presented_type.class.new(presenter, context)
old_resolver.call(wrapped, args, context)
end
field.redefine do
......
......@@ -15,7 +15,7 @@ module Gitlab
if @resource
":wave: Hi there! Before I do anything for you, please [connect your GitLab account](#{@resource})."
else
":sweat_smile: Couldn't identify you, nor can I autorize you!"
":sweat_smile: Couldn't identify you, nor can I authorize you!"
end
ephemeral_response(text: message)
......
......@@ -46,7 +46,9 @@ namespace :gitlab do
desc 'Configures the database by running migrate, or by loading the schema and seeding if needed'
task configure: :environment do
if ActiveRecord::Base.connection.tables.any?
# Check if we have existing db tables
# The schema_migrations table will still exist if drop_tables was called
if ActiveRecord::Base.connection.tables.count > 1
Rake::Task['db:migrate'].invoke
else
Rake::Task['db:schema:load'].invoke
......
require 'spec_helper'
feature 'Group labels' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let!(:label) { create(:group_label, group: group) }
background do
group.add_owner(user)
sign_in(user)
visit group_labels_path(group)
end
scenario 'label has edit button', :js do
expect(page).to have_selector('.label-action.edit')
end
end
require 'rails_helper'
feature 'User uploads avatar to profile' do
scenario 'they see their new avatar' do
user = create(:user)
sign_in(user)
let!(:user) { create(:user) }
let(:avatar_file_path) { Rails.root.join('spec', 'fixtures', 'dk.png') }
before do
sign_in user
visit profile_path
attach_file(
'user_avatar',
Rails.root.join('spec', 'fixtures', 'dk.png'),
visible: false
)
end
scenario 'they see their new avatar on their profile' do
attach_file('user_avatar', avatar_file_path, visible: false)
click_button 'Update profile settings'
visit user_path(user)
......@@ -21,4 +20,16 @@ feature 'User uploads avatar to profile' do
# Cheating here to verify something that isn't user-facing, but is important
expect(user.reload.avatar.file).to exist
end
scenario 'their new avatar is immediately visible in the header', :js do
find('.js-user-avatar-input', visible: false).set(avatar_file_path)
click_button 'Set new profile picture'
click_button 'Update profile settings'
wait_for_all_requests
data_uri = find('.avatar-image .avatar')['src']
expect(page.find('.header-user-avatar')['src']).to eq data_uri
end
end
require 'spec_helper'
describe Types::MergeRequestType do
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
end
require 'spec_helper'
describe Types::PermissionTypes::BasePermissionType do
let(:permitable) { double('permittable') }
let(:current_user) { build(:user) }
let(:context) { { current_user: current_user } }
subject(:test_type) do
Class.new(described_class) do
graphql_name 'TestClass'
permission_field :do_stuff, resolve: -> (_, _, _) { true }
ability_field(:read_issue)
abilities :admin_issue
end
end
describe '.permission_field' do
it 'adds a field for the required permission' do
is_expected.to have_graphql_field(:do_stuff)
end
end
describe '.ability_field' do
it 'adds a field for the required permission' do
is_expected.to have_graphql_field(:read_issue)
end
it 'does not add a resolver block if another resolving param is passed' do
expected_keywords = {
name: :resolve_using_hash,
hash_key: :the_key,
type: GraphQL::BOOLEAN_TYPE,
description: "custom description",
null: false
}
expect(test_type).to receive(:field).with(expected_keywords)
test_type.ability_field :resolve_using_hash, hash_key: :the_key, description: "custom description"
end
end
describe '.abilities' do
it 'adds a field for the passed permissions' do
is_expected.to have_graphql_field(:admin_issue)
end
end
end
require 'spec_helper'
describe Types::PermissionTypes::MergeRequest do
it do
expected_permissions = [
:read_merge_request, :admin_merge_request, :update_merge_request,
:create_note, :push_to_source_branch, :remove_source_branch,
:cherry_pick_on_current_merge_request, :revert_on_current_merge_request
]
expect(described_class).to have_graphql_fields(expected_permissions)
end
end
require 'spec_helper'
describe Types::MergeRequestType do
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
end
require 'spec_helper'
describe Types::PermissionTypes::Project do
it do
expected_permissions = [
:change_namespace, :change_visibility_level, :rename_project, :remove_project, :archive_project,
:remove_fork_project, :remove_pages, :read_project, :create_merge_request_in,
:read_wiki, :read_project_member, :create_issue, :upload_file, :read_cycle_analytics,
:download_code, :download_wiki_code, :fork_project, :create_project_snippet,
:read_commit_status, :request_access, :create_pipeline, :create_pipeline_schedule,
:create_merge_request_from, :create_wiki, :push_code, :create_deployment, :push_to_delete_protected_branch,
:admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label,
:update_wiki, :destroy_wiki, :create_pages, :destroy_pages
]
expect(described_class).to have_graphql_fields(expected_permissions)
end
end
require 'spec_helper'
describe GitlabSchema.types['Project'] do
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Project) }
it { expect(described_class.graphql_name).to eq('Project') }
describe 'nested merge request' do
......
......@@ -2,12 +2,6 @@ import Vue from 'vue';
import DiffLineGutterContent from '~/diffs/components/diff_line_gutter_content.vue';
import store from '~/mr_notes/stores';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import {
MATCH_LINE_TYPE,
CONTEXT_LINE_TYPE,
OLD_NO_NEW_LINE_TYPE,
NEW_NO_NEW_LINE_TYPE,
} from '~/diffs/constants';
import discussionsMockData from '../mock_data/diff_discussions';
import diffFileMockData from '../mock_data/diff_file';
......@@ -31,45 +25,6 @@ describe('DiffLineGutterContent', () => {
};
describe('computed', () => {
describe('isMatchLine', () => {
it('should return true for match line type', () => {
const component = createComponent({ lineType: MATCH_LINE_TYPE });
expect(component.isMatchLine).toEqual(true);
});
it('should return false for non-match line type', () => {
const component = createComponent({ lineType: CONTEXT_LINE_TYPE });
expect(component.isMatchLine).toEqual(false);
});
});
describe('isContextLine', () => {
it('should return true for context line type', () => {
const component = createComponent({ lineType: CONTEXT_LINE_TYPE });
expect(component.isContextLine).toEqual(true);
});
it('should return false for non-context line type', () => {
const component = createComponent({ lineType: MATCH_LINE_TYPE });
expect(component.isContextLine).toEqual(false);
});
});
describe('isMetaLine', () => {
it('should return true for meta line type', () => {
const component = createComponent({ lineType: NEW_NO_NEW_LINE_TYPE });
expect(component.isMetaLine).toEqual(true);
const component2 = createComponent({ lineType: OLD_NO_NEW_LINE_TYPE });
expect(component2.isMetaLine).toEqual(true);
});
it('should return false for non-meta line type', () => {
const component = createComponent({ lineType: MATCH_LINE_TYPE });
expect(component.isMetaLine).toEqual(false);
});
});
describe('lineHref', () => {
it('should prepend # to lineCode', () => {
const lineCode = 'LC_42';
......@@ -109,7 +64,7 @@ describe('DiffLineGutterContent', () => {
describe('template', () => {
it('should render three dots for context lines', () => {
const component = createComponent({
lineType: MATCH_LINE_TYPE,
isMatchLine: true,
});
expect(component.$el.querySelector('span').classList.contains('context-cell')).toEqual(true);
......
import Vue from 'vue';
import InlineDiffView from '~/diffs/components/inline_diff_view.vue';
import store from '~/mr_notes/stores';
import * as constants from '~/diffs/constants';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import diffFileMockData from '../mock_data/diff_file';
import discussionsMockData from '../mock_data/diff_discussions';
......@@ -14,58 +13,13 @@ describe('InlineDiffView', () => {
beforeEach(() => {
const diffFile = getDiffFileMock();
store.dispatch('setInlineDiffViewType');
component = createComponentWithStore(Vue.extend(InlineDiffView), store, {
diffFile,
diffLines: diffFile.highlightedDiffLines,
}).$mount();
});
describe('methods', () => {
describe('handleMouse', () => {
it('should set hoveredLineCode', () => {
expect(component.hoveredLineCode).toEqual(null);
component.handleMouse('lineCode1', true);
expect(component.hoveredLineCode).toEqual('lineCode1');
component.handleMouse('lineCode1', false);
expect(component.hoveredLineCode).toEqual(null);
});
});
describe('getLineClass', () => {
it('should return line class object', () => {
const { LINE_HOVER_CLASS_NAME, LINE_UNFOLD_CLASS_NAME } = constants;
const { MATCH_LINE_TYPE, NEW_LINE_TYPE } = constants;
expect(component.getLineClass(component.diffLines[0])).toEqual({
[NEW_LINE_TYPE]: NEW_LINE_TYPE,
[LINE_UNFOLD_CLASS_NAME]: false,
[LINE_HOVER_CLASS_NAME]: false,
});
component.handleMouse(component.diffLines[0].lineCode, true);
Object.defineProperty(component, 'isLoggedIn', {
get() {
return true;
},
});
expect(component.getLineClass(component.diffLines[0])).toEqual({
[NEW_LINE_TYPE]: NEW_LINE_TYPE,
[LINE_UNFOLD_CLASS_NAME]: false,
[LINE_HOVER_CLASS_NAME]: true,
});
expect(component.getLineClass(component.diffLines[5])).toEqual({
[MATCH_LINE_TYPE]: MATCH_LINE_TYPE,
[LINE_UNFOLD_CLASS_NAME]: true,
[LINE_HOVER_CLASS_NAME]: false,
});
});
});
});
describe('template', () => {
it('should have rendered diff lines', () => {
const el = component.$el;
......@@ -89,23 +43,5 @@ describe('InlineDiffView', () => {
done();
});
});
it('should render new discussion forms', done => {
const el = component.$el;
const lines = getDiffFileMock().highlightedDiffLines;
component.handleShowCommentForm({ lineCode: lines[0].lineCode });
component.handleShowCommentForm({ lineCode: lines[1].lineCode });
Vue.nextTick(() => {
expect(el.querySelectorAll('.js-vue-markdown-field').length).toEqual(2);
expect(el.querySelectorAll('tr')[1].classList.contains('notes_holder')).toEqual(true);
expect(el.querySelectorAll('tr')[3].classList.contains('notes_holder')).toEqual(true);
store.state.diffs.diffLineCommentForms = {};
done();
});
});
});
});
......@@ -4,12 +4,10 @@ import store from '~/mr_notes/stores';
import * as constants from '~/diffs/constants';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import diffFileMockData from '../mock_data/diff_file';
import discussionsMockData from '../mock_data/diff_discussions';
describe('ParallelDiffView', () => {
let component;
const getDiffFileMock = () => Object.assign({}, diffFileMockData);
const getDiscussionsMockData = () => [Object.assign({}, discussionsMockData)];
beforeEach(() => {
const diffFile = getDiffFileMock();
......@@ -28,197 +26,4 @@ describe('ParallelDiffView', () => {
});
});
});
describe('methods', () => {
describe('hasDiscussion', () => {
it('it should return true if there is a discussion either for left or right section', () => {
Object.defineProperty(component, 'discussionsByLineCode', {
get() {
return { line_42: true };
},
});
expect(component.hasDiscussion({ left: {}, right: {} })).toEqual(undefined);
expect(component.hasDiscussion({ left: { lineCode: 'line_42' }, right: {} })).toEqual(true);
expect(component.hasDiscussion({ left: {}, right: { lineCode: 'line_42' } })).toEqual(true);
});
});
describe('getClassName', () => {
it('should return line class object', () => {
const { LINE_HOVER_CLASS_NAME, LINE_UNFOLD_CLASS_NAME } = constants;
const { MATCH_LINE_TYPE, NEW_LINE_TYPE, LINE_POSITION_RIGHT } = constants;
expect(component.getClassName(component.diffLines[1], LINE_POSITION_RIGHT)).toEqual({
[NEW_LINE_TYPE]: NEW_LINE_TYPE,
[LINE_UNFOLD_CLASS_NAME]: false,
[LINE_HOVER_CLASS_NAME]: false,
});
const eventMock = {
target: component.$refs.rightLines[1],
};
component.handleMouse(eventMock, component.diffLines[1], true);
Object.defineProperty(component, 'isLoggedIn', {
get() {
return true;
},
});
expect(component.getClassName(component.diffLines[1], LINE_POSITION_RIGHT)).toEqual({
[NEW_LINE_TYPE]: NEW_LINE_TYPE,
[LINE_UNFOLD_CLASS_NAME]: false,
[LINE_HOVER_CLASS_NAME]: true,
});
expect(component.getClassName(component.diffLines[5], LINE_POSITION_RIGHT)).toEqual({
[MATCH_LINE_TYPE]: MATCH_LINE_TYPE,
[LINE_UNFOLD_CLASS_NAME]: true,
[LINE_HOVER_CLASS_NAME]: false,
});
});
});
describe('handleMouse', () => {
it('should set hovered line code and line section to null when isHover is false', () => {
const rightLineEventMock = { target: component.$refs.rightLines[1] };
expect(component.hoveredLineCode).toEqual(null);
expect(component.hoveredSection).toEqual(null);
component.handleMouse(rightLineEventMock, null, false);
expect(component.hoveredLineCode).toEqual(null);
expect(component.hoveredSection).toEqual(null);
});
it('should set hovered line code and line section for right section', () => {
const rightLineEventMock = { target: component.$refs.rightLines[1] };
component.handleMouse(rightLineEventMock, component.diffLines[1], true);
expect(component.hoveredLineCode).toEqual(component.diffLines[1].right.lineCode);
expect(component.hoveredSection).toEqual(constants.LINE_POSITION_RIGHT);
});
it('should set hovered line code and line section for left section', () => {
const leftLineEventMock = { target: component.$refs.leftLines[2] };
component.handleMouse(leftLineEventMock, component.diffLines[2], true);
expect(component.hoveredLineCode).toEqual(component.diffLines[2].left.lineCode);
expect(component.hoveredSection).toEqual(constants.LINE_POSITION_LEFT);
});
});
describe('shouldRenderDiscussions', () => {
it('should return true if there is a discussion on left side and it is expanded', () => {
const line = { left: { lineCode: 'lineCode1' } };
spyOn(component, 'isDiscussionExpanded').and.returnValue(true);
Object.defineProperty(component, 'discussionsByLineCode', {
get() {
return {
[line.left.lineCode]: true,
};
},
});
expect(component.shouldRenderDiscussions(line, constants.LINE_POSITION_LEFT)).toEqual(true);
expect(component.isDiscussionExpanded).toHaveBeenCalledWith(line.left.lineCode);
});
it('should return false if there is a discussion on left side but it is collapsed', () => {
const line = { left: { lineCode: 'lineCode1' } };
spyOn(component, 'isDiscussionExpanded').and.returnValue(false);
Object.defineProperty(component, 'discussionsByLineCode', {
get() {
return {
[line.left.lineCode]: true,
};
},
});
expect(component.shouldRenderDiscussions(line, constants.LINE_POSITION_LEFT)).toEqual(
false,
);
});
it('should return false for discussions on the right side if there is no line type', () => {
const CUSTOM_RIGHT_LINE_TYPE = 'CUSTOM_RIGHT_LINE_TYPE';
const line = { right: { lineCode: 'lineCode1', type: CUSTOM_RIGHT_LINE_TYPE } };
spyOn(component, 'isDiscussionExpanded').and.returnValue(true);
Object.defineProperty(component, 'discussionsByLineCode', {
get() {
return {
[line.right.lineCode]: true,
};
},
});
expect(component.shouldRenderDiscussions(line, constants.LINE_POSITION_RIGHT)).toEqual(
CUSTOM_RIGHT_LINE_TYPE,
);
});
});
describe('hasAnyExpandedDiscussion', () => {
const LINE_CODE_LEFT = 'LINE_CODE_LEFT';
const LINE_CODE_RIGHT = 'LINE_CODE_RIGHT';
it('should return true if there is a discussion either on the left or the right side', () => {
const mockLineOne = {
right: { lineCode: LINE_CODE_RIGHT },
left: {},
};
const mockLineTwo = {
left: { lineCode: LINE_CODE_LEFT },
right: {},
};
spyOn(component, 'isDiscussionExpanded').and.callFake(lc => lc === LINE_CODE_RIGHT);
expect(component.hasAnyExpandedDiscussion(mockLineOne)).toEqual(true);
expect(component.hasAnyExpandedDiscussion(mockLineTwo)).toEqual(false);
});
});
});
describe('template', () => {
it('should have rendered diff lines', () => {
const el = component.$el;
expect(el.querySelectorAll('tr.line_holder.parallel').length).toEqual(6);
expect(el.querySelectorAll('td.empty-cell').length).toEqual(4);
expect(el.querySelectorAll('td.line_content.parallel.right-side').length).toEqual(6);
expect(el.querySelectorAll('td.line_content.parallel.left-side').length).toEqual(6);
expect(el.querySelectorAll('td.match').length).toEqual(4);
expect(el.textContent.indexOf('Bad dates') > -1).toEqual(true);
});
it('should render discussions', done => {
const el = component.$el;
component.$store.dispatch('setInitialNotes', getDiscussionsMockData());
Vue.nextTick(() => {
expect(el.querySelectorAll('.notes_holder').length).toEqual(1);
expect(el.querySelectorAll('.notes_holder .note-discussion li').length).toEqual(5);
expect(el.innerText.indexOf('comment 5') > -1).toEqual(true);
component.$store.dispatch('setInitialNotes', []);
done();
});
});
it('should render new discussion forms', done => {
const el = component.$el;
const lines = getDiffFileMock().parallelDiffLines;
component.handleShowCommentForm({ lineCode: lines[0].lineCode });
component.handleShowCommentForm({ lineCode: lines[1].lineCode });
Vue.nextTick(() => {
expect(el.querySelectorAll('.js-vue-markdown-field').length).toEqual(2);
expect(el.querySelectorAll('tr')[1].classList.contains('notes_holder')).toEqual(true);
expect(el.querySelectorAll('tr')[3].classList.contains('notes_holder')).toEqual(true);
store.state.diffs.diffLineCommentForms = {};
done();
});
});
});
});
......@@ -16,6 +16,7 @@ describe('IDE commit form', () => {
store.state.changedFiles.push('test');
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
Vue.set(store.state.projects, 'abcproject', { ...projectData });
vm = createComponentWithStore(Component, store).$mount();
......@@ -146,4 +147,16 @@ describe('IDE commit form', () => {
});
});
});
describe('commitButtonText', () => {
it('returns commit text when staged files exist', () => {
vm.$store.state.stagedFiles.push('testing');
expect(vm.commitButtonText).toBe('Commit');
});
it('returns stage & commit text when staged files do not exist', () => {
expect(vm.commitButtonText).toBe('Stage & Commit');
});
});
});
......@@ -13,6 +13,7 @@ describe('IDE commit message field', () => {
Component,
{
text: '',
placeholder: 'testing',
},
'#app',
);
......
......@@ -114,4 +114,19 @@ describe('IDE commit sidebar radio group', () => {
});
});
});
describe('tooltipTitle', () => {
it('returns title when disabled', () => {
vm.title = 'test title';
vm.disabled = true;
expect(vm.tooltipTitle).toBe('test title');
});
it('returns blank when not disabled', () => {
vm.title = 'test title';
expect(vm.tooltipTitle).not.toBe('test title');
});
});
});
......@@ -8,6 +8,7 @@ export const projectData = {
branches: {
master: {
treeId: 'abcproject/master',
can_push: true,
},
},
mergeRequests: {},
......
......@@ -147,12 +147,11 @@ describe('IDE store getters', () => {
const commitTitle = 'Example commit title';
const localGetters = {
currentProject: {
branches: {
'example-branch': {
commit: {
title: commitTitle,
},
},
name: 'test-project',
},
currentBranch: {
commit: {
title: commitTitle,
},
},
};
......@@ -161,4 +160,23 @@ describe('IDE store getters', () => {
expect(getters.lastCommit(localState, localGetters).title).toBe(commitTitle);
});
});
describe('currentBranch', () => {
it('returns current projects branch', () => {
const localGetters = {
currentProject: {
branches: {
master: {
name: 'master',
},
},
},
};
localState.currentBranchId = 'master';
expect(getters.currentBranch(localState, localGetters)).toEqual({
name: 'master',
});
});
});
});
......@@ -29,46 +29,6 @@ describe('IDE commit module getters', () => {
});
});
describe('commitButtonDisabled', () => {
const localGetters = {
discardDraftButtonDisabled: false,
};
const rootState = {
stagedFiles: ['a'],
};
it('returns false when discardDraftButtonDisabled is false & stagedFiles is not empty', () => {
expect(
getters.commitButtonDisabled(state, localGetters, rootState),
).toBeFalsy();
});
it('returns true when discardDraftButtonDisabled is false & stagedFiles is empty', () => {
rootState.stagedFiles.length = 0;
expect(
getters.commitButtonDisabled(state, localGetters, rootState),
).toBeTruthy();
});
it('returns true when discardDraftButtonDisabled is true', () => {
localGetters.discardDraftButtonDisabled = true;
expect(
getters.commitButtonDisabled(state, localGetters, rootState),
).toBeTruthy();
});
it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => {
localGetters.discardDraftButtonDisabled = false;
rootState.stagedFiles.length = 0;
expect(
getters.commitButtonDisabled(state, localGetters, rootState),
).toBeTruthy();
});
});
describe('newBranchName', () => {
it('includes username, currentBranchId, patch & random number', () => {
gon.current_username = 'username';
......@@ -108,9 +68,7 @@ describe('IDE commit module getters', () => {
});
it('uses newBranchName when not empty', () => {
expect(getters.branchName(state, localGetters, rootState)).toBe(
'state-newBranchName',
);
expect(getters.branchName(state, localGetters, rootState)).toBe('state-newBranchName');
});
it('uses getters newBranchName when state newBranchName is empty', () => {
......@@ -118,11 +76,53 @@ describe('IDE commit module getters', () => {
newBranchName: '',
});
expect(getters.branchName(state, localGetters, rootState)).toBe(
'newBranchName',
);
expect(getters.branchName(state, localGetters, rootState)).toBe('newBranchName');
});
});
});
});
describe('preBuiltCommitMessage', () => {
let rootState = {};
beforeEach(() => {
rootState.changedFiles = [];
rootState.stagedFiles = [];
});
afterEach(() => {
rootState = {};
});
it('returns commitMessage when set', () => {
state.commitMessage = 'test commit message';
expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe('test commit message');
});
['changedFiles', 'stagedFiles'].forEach(key => {
it('returns commitMessage with updated file', () => {
rootState[key].push({
path: 'test-file',
});
expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe('Update test-file');
});
it('returns commitMessage with updated files', () => {
rootState[key].push(
{
path: 'test-file',
},
{
path: 'index.js',
},
);
expect(getters.preBuiltCommitMessage(state, null, rootState)).toBe(
'Update test-file, index.js files',
);
});
});
});
});
......@@ -94,6 +94,7 @@ describe('Multi-file store utils', () => {
newBranch: false,
state,
rootState,
getters: {},
});
expect(payload).toEqual({
......@@ -118,5 +119,58 @@ describe('Multi-file store utils', () => {
start_branch: undefined,
});
});
it('uses prebuilt commit message when commit message is empty', () => {
const rootState = {
stagedFiles: [
{
...file('staged'),
path: 'staged',
content: 'updated file content',
lastCommitSha: '123456789',
},
{
...file('newFile'),
path: 'added',
tempFile: true,
content: 'new file content',
base64: true,
lastCommitSha: '123456789',
},
],
currentBranchId: 'master',
};
const payload = utils.createCommitPayload({
branch: 'master',
newBranch: false,
state: {},
rootState,
getters: {
preBuiltCommitMessage: 'prebuilt test commit message',
},
});
expect(payload).toEqual({
branch: 'master',
commit_message: 'prebuilt test commit message',
actions: [
{
action: 'update',
file_path: 'staged',
content: 'updated file content',
encoding: 'text',
last_commit_id: '123456789',
},
{
action: 'create',
file_path: 'added',
content: 'new file content',
encoding: 'base64',
last_commit_id: '123456789',
},
],
start_branch: undefined,
});
});
});
});
......@@ -5,16 +5,6 @@ module Gitlab
describe YamlProcessor do
subject { described_class.new(config) }
describe 'our current .gitlab-ci.yml' do
let(:config) { File.read("#{Rails.root}/.gitlab-ci.yml") }
it 'is valid' do
error_message = described_class.validation_message(config)
expect(error_message).to be_nil
end
end
describe '#build_attributes' do
subject { described_class.new(config).build_attributes(:rspec) }
......
......@@ -309,7 +309,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
it { is_expected.not_to include(SeedRepo::FirstCommit::ID) }
end
shared_examples '.shas_with_signatures' do
describe '.shas_with_signatures' do
let(:signed_shas) { %w[5937ac0a7beb003549fc5fd26fc247adbce4a52e 570e7b2abdd848b95f2f578043fc23bd6f6fd24d] }
let(:unsigned_shas) { %w[19e2e9b4ef76b422ce1154af39a91323ccc57434 c642fe9b8b9f28f9225d7ea953fe14e74748d53b] }
let(:first_signed_shas) { %w[5937ac0a7beb003549fc5fd26fc247adbce4a52e c642fe9b8b9f28f9225d7ea953fe14e74748d53b] }
......@@ -330,93 +330,55 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
end
describe '.shas_with_signatures with gitaly on' do
it_should_behave_like '.shas_with_signatures'
end
describe '.shas_with_signatures with gitaly disabled', :disable_gitaly do
it_should_behave_like '.shas_with_signatures'
end
describe '.find_all' do
shared_examples 'finding all commits' do
it 'should return a return a collection of commits' do
commits = described_class.find_all(repository)
expect(commits).to all( be_a_kind_of(described_class) )
end
context 'max_count' do
subject do
commits = described_class.find_all(
repository,
max_count: 50
)
it 'should return a return a collection of commits' do
commits = described_class.find_all(repository)
commits.map(&:id)
end
expect(commits).to all( be_a_kind_of(described_class) )
end
it 'has 34 elements' do
expect(subject.size).to eq(34)
end
context 'max_count' do
subject do
commits = described_class.find_all(
repository,
max_count: 50
)
it 'includes the expected commits' do
expect(subject).to include(
SeedRepo::Commit::ID,
SeedRepo::Commit::PARENT_ID,
SeedRepo::FirstCommit::ID
)
end
commits.map(&:id)
end
context 'ref + max_count + skip' do
subject do
commits = described_class.find_all(
repository,
ref: 'master',
max_count: 50,
skip: 1
)
commits.map(&:id)
end
it 'has 24 elements' do
expect(subject.size).to eq(24)
end
it 'includes the expected commits' do
expect(subject).to include(SeedRepo::Commit::ID, SeedRepo::FirstCommit::ID)
expect(subject).not_to include(SeedRepo::LastCommit::ID)
end
it 'has 34 elements' do
expect(subject.size).to eq(34)
end
end
context 'when Gitaly find_all_commits feature is enabled' do
it_behaves_like 'finding all commits'
it 'includes the expected commits' do
expect(subject).to include(
SeedRepo::Commit::ID,
SeedRepo::Commit::PARENT_ID,
SeedRepo::FirstCommit::ID
)
end
end
context 'when Gitaly find_all_commits feature is disabled', :skip_gitaly_mock do
it_behaves_like 'finding all commits'
context 'while applying a sort order based on the `order` option' do
it "allows ordering topologically (no parents shown before their children)" do
expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_TOPO)
described_class.find_all(repository, order: :topo)
end
it "allows ordering by date" do
expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_DATE | Rugged::SORT_TOPO)
context 'ref + max_count + skip' do
subject do
commits = described_class.find_all(
repository,
ref: 'master',
max_count: 50,
skip: 1
)
described_class.find_all(repository, order: :date)
end
commits.map(&:id)
end
it "applies no sorting by default" do
expect_any_instance_of(Rugged::Walker).to receive(:sorting).with(Rugged::SORT_NONE)
it 'has 24 elements' do
expect(subject.size).to eq(24)
end
described_class.find_all(repository)
end
it 'includes the expected commits' do
expect(subject).to include(SeedRepo::Commit::ID, SeedRepo::FirstCommit::ID)
expect(subject).not_to include(SeedRepo::LastCommit::ID)
end
end
end
......@@ -498,7 +460,7 @@ describe Gitlab::Git::Commit, seed_helper: true do
end
describe '.extract_signature_lazily' do
shared_examples 'loading signatures in batch once' do
describe 'loading signatures in batch once' do
it 'fetches signatures in batch once' do
commit_ids = %w[0b4bc9a49b562e85de7cc9e834518ea6828729b9 4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6]
signatures = commit_ids.map do |commit_id|
......@@ -516,27 +478,13 @@ describe Gitlab::Git::Commit, seed_helper: true do
subject { described_class.extract_signature_lazily(repository, commit_id).itself }
context 'with Gitaly extract_commit_signature_in_batch feature enabled' do
it_behaves_like 'extracting commit signature'
it_behaves_like 'loading signatures in batch once'
end
context 'with Gitaly extract_commit_signature_in_batch feature disabled', :disable_gitaly do
it_behaves_like 'extracting commit signature'
it_behaves_like 'loading signatures in batch once'
end
it_behaves_like 'extracting commit signature'
end
describe '.extract_signature' do
subject { described_class.extract_signature(repository, commit_id) }
context 'with gitaly' do
it_behaves_like 'extracting commit signature'
end
context 'without gitaly', :disable_gitaly do
it_behaves_like 'extracting commit signature'
end
it_behaves_like 'extracting commit signature'
end
end
......
......@@ -996,46 +996,6 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
end
describe "#rugged_commits_between" do
around do |example|
# TODO #rugged_commits_between will be removed, has been migrated to gitaly
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
example.run
end
end
context 'two SHAs' do
let(:first_sha) { 'b0e52af38d7ea43cf41d8a6f2471351ac036d6c9' }
let(:second_sha) { '0e50ec4d3c7ce42ab74dda1d422cb2cbffe1e326' }
it 'returns the number of commits between' do
expect(repository.rugged_commits_between(first_sha, second_sha).count).to eq(3)
end
end
context 'SHA and master branch' do
let(:sha) { 'b0e52af38d7ea43cf41d8a6f2471351ac036d6c9' }
let(:branch) { 'master' }
it 'returns the number of commits between a sha and a branch' do
expect(repository.rugged_commits_between(sha, branch).count).to eq(5)
end
it 'returns the number of commits between a branch and a sha' do
expect(repository.rugged_commits_between(branch, sha).count).to eq(0) # sha is before branch
end
end
context 'two branches' do
let(:first_branch) { 'feature' }
let(:second_branch) { 'master' }
it 'returns the number of commits between' do
expect(repository.rugged_commits_between(first_branch, second_branch).count).to eq(17)
end
end
end
describe '#count_commits_between' do
subject { repository.count_commits_between('feature', 'master') }
......
......@@ -664,7 +664,7 @@ describe Repository do
end
end
shared_examples "search_files_by_content" do
describe "search_files_by_content" do
let(:results) { repository.search_files_by_content('feature', 'master') }
subject { results }
......@@ -711,7 +711,7 @@ describe Repository do
end
end
shared_examples "search_files_by_name" do
describe "search_files_by_name" do
let(:results) { repository.search_files_by_name('files', 'master') }
it 'returns result' do
......@@ -751,16 +751,6 @@ describe Repository do
end
end
describe 'with gitaly enabled' do
it_behaves_like 'search_files_by_content'
it_behaves_like 'search_files_by_name'
end
describe 'with gitaly disabled', :disable_gitaly do
it_behaves_like 'search_files_by_content'
it_behaves_like 'search_files_by_name'
end
describe '#async_remove_remote' do
before do
masterrev = repository.find_branch('master').dereferenced_target
......
......@@ -21,6 +21,89 @@ describe API::Files do
"/projects/#{project.id}/repository/files/#{file_path}"
end
describe "HEAD /projects/:id/repository/files/:file_path" do
shared_examples_for 'repository files' do
it 'returns file attributes in headers' do
head api(route(file_path), current_user), params
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Gitlab-File-Path']).to eq(CGI.unescape(file_path))
expect(response.headers['X-Gitlab-File-Name']).to eq('popen.rb')
expect(response.headers['X-Gitlab-Last-Commit-Id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d')
expect(response.headers['X-Gitlab-Content-Sha256']).to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887')
end
it 'returns file by commit sha' do
# This file is deleted on HEAD
file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee"
params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9"
head api(route(file_path), current_user), params
expect(response).to have_gitlab_http_status(200)
expect(response.headers['X-Gitlab-File-Name']).to eq('commit.js.coffee')
expect(response.headers['X-Gitlab-Content-Sha256']).to eq('08785f04375b47f81f46e68cc125d5ef368aa20576ddb53f91f4d83f1d04b929')
end
context 'when mandatory params are not given' do
it "responds with a 400 status" do
head api(route("any%2Ffile"), current_user)
expect(response).to have_gitlab_http_status(400)
end
end
context 'when file_path does not exist' do
it "responds with a 404 status" do
params[:ref] = 'master'
head api(route('app%2Fmodels%2Fapplication%2Erb'), current_user), params
expect(response).to have_gitlab_http_status(404)
end
end
context 'when file_path does not exist' do
include_context 'disabled repository'
it "responds with a 403 status" do
head api(route(file_path), current_user), params
expect(response).to have_gitlab_http_status(403)
end
end
end
context 'when unauthenticated', 'and project is public' do
it_behaves_like 'repository files' do
let(:project) { create(:project, :public, :repository) }
let(:current_user) { nil }
end
end
context 'when unauthenticated', 'and project is private' do
it "responds with a 404 status" do
current_user = nil
head api(route(file_path), current_user), params
expect(response).to have_gitlab_http_status(404)
end
end
context 'when authenticated', 'as a developer' do
it_behaves_like 'repository files' do
let(:current_user) { user }
end
end
context 'when authenticated', 'as a guest' do
it_behaves_like '403 response' do
let(:request) { head api(route(file_path), guest), params }
end
end
end
describe "GET /projects/:id/repository/files/:file_path" do
shared_examples_for 'repository files' do
it 'returns file attributes as json' do
......@@ -30,6 +113,7 @@ describe API::Files do
expect(json_response['file_path']).to eq(CGI.unescape(file_path))
expect(json_response['file_name']).to eq('popen.rb')
expect(json_response['last_commit_id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d')
expect(json_response['content_sha256']).to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887')
expect(Base64.decode64(json_response['content']).lines.first).to eq("require 'fileutils'\n")
end
......@@ -51,6 +135,7 @@ describe API::Files do
expect(response).to have_gitlab_http_status(200)
expect(json_response['file_name']).to eq('commit.js.coffee')
expect(json_response['content_sha256']).to eq('08785f04375b47f81f46e68cc125d5ef368aa20576ddb53f91f4d83f1d04b929')
expect(Base64.decode64(json_response['content']).lines.first).to eq("class Commit\n")
end
......
require 'spec_helper'
describe 'getting merge request information nested in a project' do
include GraphqlHelpers
let(:project) { create(:project, :repository, :public) }
let(:current_user) { create(:user) }
let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] }
let!(:merge_request) { create(:merge_request, source_project: project) }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('mergeRequest', iid: merge_request.iid)
)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'contains merge request information' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data).not_to be_nil
end
# This is a field coming from the `MergeRequestPresenter`
it 'includes a web_url' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data['webUrl']).to be_present
end
context 'permissions on the merge request' do
it 'includes the permissions for the current user on a public project' do
expected_permissions = {
'readMergeRequest' => true,
'adminMergeRequest' => false,
'createNote' => true,
'pushToSourceBranch' => false,
'removeSourceBranch' => false,
'cherryPickOnCurrentMergeRequest' => false,
'revertOnCurrentMergeRequest' => false,
'updateMergeRequest' => false
}
post_graphql(query, current_user: current_user)
permission_data = merge_request_graphql_data['userPermissions']
expect(permission_data).to be_present
expect(permission_data).to eq(expected_permissions)
end
end
context 'when the user does not have access to the merge request' do
let(:project) { create(:project, :public, :repository) }
it 'returns nil' do
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
post_graphql(query)
expect(merge_request_graphql_data).to be_nil
end
end
end
......@@ -26,50 +26,6 @@ describe 'getting project information' do
post_graphql(query, current_user: current_user)
end
end
context 'when requesting a nested merge request' do
let(:merge_request) { create(:merge_request, source_project: project) }
let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('mergeRequest', iid: merge_request.iid)
)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'contains merge request information' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data).not_to be_nil
end
# This is a field coming from the `MergeRequestPresenter`
it 'includes a web_url' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data['webUrl']).to be_present
end
context 'when the user does not have access to the merge request' do
let(:project) { create(:project, :public, :repository) }
it 'returns nil' do
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
post_graphql(query)
expect(merge_request_graphql_data).to be_nil
end
end
end
end
context 'when the user does not have access to the project' do
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment