Commit 337a73e1 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'master' into breadcrumbs-improvements

parents 9477f4a8 d316a8c0
...@@ -5,7 +5,7 @@ const Api = { ...@@ -5,7 +5,7 @@ const Api = {
groupPath: '/api/:version/groups/:id.json', groupPath: '/api/:version/groups/:id.json',
namespacesPath: '/api/:version/namespaces.json', namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json', groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json?simple=true', projectsPath: '/api/:version/projects.json',
labelsPath: '/:namespace_path/:project_path/labels', labelsPath: '/:namespace_path/:project_path/labels',
licensePath: '/api/:version/templates/licenses/:key', licensePath: '/api/:version/templates/licenses/:key',
gitignorePath: '/api/:version/templates/gitignores/:key', gitignorePath: '/api/:version/templates/gitignores/:key',
...@@ -58,6 +58,7 @@ const Api = { ...@@ -58,6 +58,7 @@ const Api = {
const defaults = { const defaults = {
search: query, search: query,
per_page: 20, per_page: 20,
simple: true,
}; };
if (gon.current_user_id) { if (gon.current_user_id) {
......
...@@ -2,3 +2,4 @@ import 'underscore'; ...@@ -2,3 +2,4 @@ import 'underscore';
import './polyfills'; import './polyfills';
import './jquery'; import './jquery';
import './bootstrap'; import './bootstrap';
import './vue';
import Vue from 'vue'; import Vue from 'vue';
import './vue_resource_interceptor';
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
Vue.config.productionTip = false; Vue.config.productionTip = false;
......
...@@ -21,11 +21,13 @@ const DiffNoteAvatars = Vue.extend({ ...@@ -21,11 +21,13 @@ const DiffNoteAvatars = Vue.extend({
}, },
template: ` template: `
<div class="diff-comment-avatar-holders" <div class="diff-comment-avatar-holders"
:class="discussionClassName"
v-show="notesCount !== 0"> v-show="notesCount !== 0">
<div v-if="!isVisible"> <div v-if="!isVisible">
<!-- FIXME: Pass an alt attribute here for accessibility --> <!-- FIXME: Pass an alt attribute here for accessibility -->
<user-avatar-image <user-avatar-image
v-for="note in notesSubset" v-for="note in notesSubset"
:key="note.id"
class="diff-comment-avatar js-diff-comment-avatar" class="diff-comment-avatar js-diff-comment-avatar"
@click.native="clickedAvatar($event)" @click.native="clickedAvatar($event)"
:img-src="note.authorAvatar" :img-src="note.authorAvatar"
...@@ -68,7 +70,8 @@ const DiffNoteAvatars = Vue.extend({ ...@@ -68,7 +70,8 @@ const DiffNoteAvatars = Vue.extend({
}); });
}); });
}, },
destroyed() { beforeDestroy() {
this.addNoCommentClass();
$(document).off('toggle.comments'); $(document).off('toggle.comments');
}, },
watch: { watch: {
...@@ -85,6 +88,9 @@ const DiffNoteAvatars = Vue.extend({ ...@@ -85,6 +88,9 @@ const DiffNoteAvatars = Vue.extend({
}, },
}, },
computed: { computed: {
discussionClassName() {
return `js-diff-avatars-${this.discussionId}`;
},
notesSubset() { notesSubset() {
let notes = []; let notes = [];
......
...@@ -32,6 +32,10 @@ $(() => { ...@@ -32,6 +32,10 @@ $(() => {
const tmpApp = new tmp().$mount(); const tmpApp = new tmp().$mount();
$(this).replaceWith(tmpApp.$el); $(this).replaceWith(tmpApp.$el);
$(tmpApp.$el).one('remove.vue', () => {
tmpApp.$destroy();
tmpApp.$el.remove();
});
}); });
const $components = $(COMPONENT_SELECTOR).filter(function () { const $components = $(COMPONENT_SELECTOR).filter(function () {
......
...@@ -132,6 +132,7 @@ import './project_new'; ...@@ -132,6 +132,7 @@ import './project_new';
import './project_select'; import './project_select';
import './project_show'; import './project_show';
import './project_variables'; import './project_variables';
import './projects_dropdown';
import './projects_list'; import './projects_list';
import './syntax_highlight'; import './syntax_highlight';
import './render_math'; import './render_math';
...@@ -252,7 +253,10 @@ $(function () { ...@@ -252,7 +253,10 @@ $(function () {
// Initialize popovers // Initialize popovers
$body.popover({ $body.popover({
selector: '[data-toggle="popover"]', selector: '[data-toggle="popover"]',
trigger: 'focus' trigger: 'focus',
// set the viewport to the main content, excluding the navigation bar, so
// the navigation can't overlap the popover
viewport: '.page-with-sidebar'
}); });
$('.trigger-submit').on('change', function () { $('.trigger-submit').on('change', function () {
return $(this).parents('form').submit(); return $(this).parents('form').submit();
......
...@@ -464,7 +464,6 @@ export default class Notes { ...@@ -464,7 +464,6 @@ export default class Notes {
} }
renderDiscussionAvatar(diffAvatarContainer, noteEntity) { renderDiscussionAvatar(diffAvatarContainer, noteEntity) {
var commentButton = diffAvatarContainer.find('.js-add-diff-note-button');
var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders');
if (!avatarHolder.length) { if (!avatarHolder.length) {
...@@ -475,10 +474,6 @@ export default class Notes { ...@@ -475,10 +474,6 @@ export default class Notes {
gl.diffNotesCompileComponents(); gl.diffNotesCompileComponents();
} }
if (commentButton.length) {
commentButton.remove();
}
} }
/** /**
...@@ -767,6 +762,7 @@ export default class Notes { ...@@ -767,6 +762,7 @@ export default class Notes {
var $note, $notes; var $note, $notes;
$note = $(el); $note = $(el);
$notes = $note.closest('.discussion-notes'); $notes = $note.closest('.discussion-notes');
const discussionId = $('.notes', $notes).data('discussion-id');
if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) { if (gl.diffNoteApps[noteElId]) {
...@@ -783,6 +779,8 @@ export default class Notes { ...@@ -783,6 +779,8 @@ export default class Notes {
// "Discussions" tab // "Discussions" tab
$notes.closest('.timeline-entry').remove(); $notes.closest('.timeline-entry').remove();
$(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
// The notes tr can contain multiple lists of notes, like on the parallel diff // The notes tr can contain multiple lists of notes, like on the parallel diff
if (notesTr.find('.discussion-notes').length > 1) { if (notesTr.find('.discussion-notes').length > 1) {
$notes.remove(); $notes.remove();
......
<script>
import bs from '../../breakpoints';
import eventHub from '../event_hub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import projectsListFrequent from './projects_list_frequent.vue';
import projectsListSearch from './projects_list_search.vue';
import search from './search.vue';
export default {
components: {
search,
loadingIcon,
projectsListFrequent,
projectsListSearch,
},
props: {
currentProject: {
type: Object,
required: true,
},
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
isLoadingProjects: false,
isFrequentsListVisible: false,
isSearchListVisible: false,
isLocalStorageFailed: false,
isSearchFailed: false,
searchQuery: '',
};
},
computed: {
frequentProjects() {
return this.store.getFrequentProjects();
},
searchProjects() {
return this.store.getSearchedProjects();
},
},
methods: {
toggleFrequentProjectsList(state) {
this.isLoadingProjects = !state;
this.isSearchListVisible = !state;
this.isFrequentsListVisible = state;
},
toggleSearchProjectsList(state) {
this.isLoadingProjects = !state;
this.isFrequentsListVisible = !state;
this.isSearchListVisible = state;
},
toggleLoader(state) {
this.isFrequentsListVisible = !state;
this.isSearchListVisible = !state;
this.isLoadingProjects = state;
},
fetchFrequentProjects() {
const screenSize = bs.getBreakpointSize();
if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) {
this.toggleSearchProjectsList(true);
} else {
this.toggleLoader(true);
this.isLocalStorageFailed = false;
const projects = this.service.getFrequentProjects();
if (projects) {
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects(projects);
} else {
this.isLocalStorageFailed = true;
this.toggleFrequentProjectsList(true);
this.store.setFrequentProjects([]);
}
}
},
fetchSearchedProjects(searchQuery) {
this.searchQuery = searchQuery;
this.toggleLoader(true);
this.service.getSearchedProjects(this.searchQuery)
.then(res => res.json())
.then((results) => {
this.toggleSearchProjectsList(true);
this.store.setSearchedProjects(results);
})
.catch(() => {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
});
},
logCurrentProjectAccess() {
this.service.logProjectAccess(this.currentProject);
},
handleSearchClear() {
this.searchQuery = '';
this.toggleFrequentProjectsList(true);
this.store.clearSearchedProjects();
},
handleSearchFailure() {
this.isSearchFailed = true;
this.toggleSearchProjectsList(true);
},
},
created() {
if (this.currentProject.id) {
this.logCurrentProjectAccess();
}
eventHub.$on('dropdownOpen', this.fetchFrequentProjects);
eventHub.$on('searchProjects', this.fetchSearchedProjects);
eventHub.$on('searchCleared', this.handleSearchClear);
eventHub.$on('searchFailed', this.handleSearchFailure);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.fetchFrequentProjects);
eventHub.$off('searchProjects', this.fetchSearchedProjects);
eventHub.$off('searchCleared', this.handleSearchClear);
eventHub.$off('searchFailed', this.handleSearchFailure);
},
};
</script>
<template>
<div>
<search/>
<loading-icon
class="loading-animation prepend-top-20"
size="2"
v-if="isLoadingProjects"
:label="s__('ProjectsDropdown|Loading projects')"
/>
<div
class="section-header"
v-if="isFrequentsListVisible"
>
{{ s__('ProjectsDropdown|Frequently visited') }}
</div>
<projects-list-frequent
v-if="isFrequentsListVisible"
:local-storage-failed="isLocalStorageFailed"
:projects="frequentProjects"
/>
<projects-list-search
v-if="isSearchListVisible"
:search-failed="isSearchFailed"
:matcher="searchQuery"
:projects="searchProjects"
/>
</div>
</template>
<script>
import { s__ } from '../../locale';
import projectsListItem from './projects_list_item.vue';
export default {
components: {
projectsListItem,
},
props: {
projects: {
type: Array,
required: true,
},
localStorageFailed: {
type: Boolean,
required: true,
},
},
computed: {
isListEmpty() {
return this.projects.length === 0;
},
listEmptyMessage() {
return this.localStorageFailed ?
s__('ProjectsDropdown|This feature requires browser localStorage support') :
s__('ProjectsDropdown|Projects you visit often will appear here');
},
},
};
</script>
<template>
<div
class="projects-list-frequent-container"
>
<ul
class="list-unstyled"
>
<li
class="section-empty"
v-if="isListEmpty"
>
{{listEmptyMessage}}
</li>
<projects-list-item
v-else
v-for="(project, index) in projects"
:key="index"
:project-id="project.id"
:project-name="project.name"
:namespace="project.namespace"
:web-url="project.webUrl"
:avatar-url="project.avatarUrl"
/>
</ul>
</div>
</template>
<script>
import identicon from '../../vue_shared/components/identicon.vue';
export default {
components: {
identicon,
},
props: {
matcher: {
type: String,
required: false,
},
projectId: {
type: Number,
required: true,
},
projectName: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
webUrl: {
type: String,
required: true,
},
avatarUrl: {
required: true,
validator(value) {
return value === null || typeof value === 'string';
},
},
},
computed: {
hasAvatar() {
return this.avatarUrl !== null;
},
highlightedProjectName() {
if (this.matcher) {
const matcherRegEx = new RegExp(this.matcher, 'gi');
const matches = this.projectName.match(matcherRegEx);
if (matches && matches.length > 0) {
return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`);
}
}
return this.projectName;
},
},
};
</script>
<template>
<li
class="projects-list-item-container"
>
<a
class="clearfix"
:href="webUrl"
>
<div
class="project-item-avatar-container"
>
<img
v-if="hasAvatar"
class="avatar s32"
:src="avatarUrl"
/>
<identicon
v-else
size-class="s32"
:entity-id=projectId
:entity-name="projectName"
/>
</div>
<div
class="project-item-metadata-container"
>
<div
class="project-title"
:title="projectName"
v-html="highlightedProjectName"
>
</div>
<div
class="project-namespace"
:title="namespace"
>
{{namespace}}
</div>
</div>
</a>
</li>
</template>
<script>
import { s__ } from '../../locale';
import projectsListItem from './projects_list_item.vue';
export default {
components: {
projectsListItem,
},
props: {
matcher: {
type: String,
required: true,
},
projects: {
type: Array,
required: true,
},
searchFailed: {
type: Boolean,
required: true,
},
},
computed: {
isListEmpty() {
return this.projects.length === 0;
},
listEmptyMessage() {
return this.searchFailed ?
s__('ProjectsDropdown|Something went wrong on our end.') :
s__('ProjectsDropdown|No projects matched your query');
},
},
};
</script>
<template>
<div
class="projects-list-search-container"
>
<ul
class="list-unstyled"
>
<li
v-if="isListEmpty"
:class="{ 'section-failure': searchFailed }"
class="section-empty"
>
{{ listEmptyMessage }}
</li>
<projects-list-item
v-else
v-for="(project, index) in projects"
:key="index"
:project-id="project.id"
:project-name="project.name"
:namespace="project.namespace"
:web-url="project.webUrl"
:avatar-url="project.avatarUrl"
:matcher="matcher"
/>
</ul>
</div>
</template>
<script>
import _ from 'underscore';
import eventHub from '../event_hub';
export default {
data() {
return {
searchQuery: '',
};
},
watch: {
searchQuery() {
this.handleInput();
},
},
methods: {
setFocus() {
this.$refs.search.focus();
},
emitSearchEvents() {
if (this.searchQuery) {
eventHub.$emit('searchProjects', this.searchQuery);
} else {
eventHub.$emit('searchCleared');
}
},
/**
* Callback function within _.debounce is intentionally
* kept as ES5 `function() {}` instead of ES6 `() => {}`
* as it otherwise messes up function context
* and component reference is no longer accessible via `this`
*/
// eslint-disable-next-line func-names
handleInput: _.debounce(function () {
this.emitSearchEvents();
}, 500),
},
mounted() {
eventHub.$on('dropdownOpen', this.setFocus);
},
beforeDestroy() {
eventHub.$off('dropdownOpen', this.setFocus);
},
};
</script>
<template>
<div
class="search-input-container hidden-xs"
>
<input
type="search"
class="form-control"
ref="search"
v-model="searchQuery"
:placeholder="s__('ProjectsDropdown|Search projects')"
/>
<i
v-if="!searchQuery"
class="search-icon fa fa-fw fa-search"
aria-hidden="true"
/>
</div>
</template>
export const FREQUENT_PROJECTS = {
MAX_COUNT: 20,
LIST_COUNT_DESKTOP: 5,
LIST_COUNT_MOBILE: 3,
ELIGIBLE_FREQUENCY: 3,
};
export const HOUR_IN_MS = 3600000;
export const STORAGE_KEY = 'frequent-projects';
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import eventHub from './event_hub';
import ProjectsService from './service/projects_service';
import ProjectsStore from './store/projects_store';
import projectsDropdownApp from './components/app.vue';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('js-projects-dropdown');
const navEl = document.getElementById('nav-projects-dropdown');
// Don't do anything if element doesn't exist (No projects dropdown)
// This is for when the user accesses GitLab without logging in
if (!el || !navEl) {
return;
}
$(navEl).on('show.bs.dropdown', (e) => {
const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu');
dropdownEl.one('transitionend', () => {
eventHub.$emit('dropdownOpen');
});
});
// eslint-disable-next-line no-new
new Vue({
el,
components: {
projectsDropdownApp,
},
data() {
const dataset = this.$options.el.dataset;
const store = new ProjectsStore();
const service = new ProjectsService(dataset.userName);
const project = {
id: Number(dataset.projectId),
name: dataset.projectName,
namespace: dataset.projectNamespace,
webUrl: dataset.projectWebUrl,
avatarUrl: dataset.projectAvatarUrl || null,
lastAccessedOn: Date.now(),
};
return {
store,
service,
state: store.state,
currentUserName: dataset.userName,
currentProject: project,
};
},
render(createElement) {
return createElement('projects-dropdown-app', {
props: {
currentUserName: this.currentUserName,
currentProject: this.currentProject,
store: this.store,
service: this.service,
},
});
},
});
});
import Vue from 'vue';
import VueResource from 'vue-resource';
import bp from '../../breakpoints';
import Api from '../../api';
import AccessorUtilities from '../../lib/utils/accessor';
import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants';
Vue.use(VueResource);
export default class ProjectsService {
constructor(currentUserName) {
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.currentUserName = currentUserName;
this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`;
this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath));
}
getSearchedProjects(searchQuery) {
return this.projectsPath.get({
simple: false,
per_page: 20,
membership: !!gon.current_user_id,
order_by: 'last_activity_at',
search: searchQuery,
});
}
getFrequentProjects() {
if (this.isLocalStorageAvailable) {
return this.getTopFrequentProjects();
}
return null;
}
logProjectAccess(project) {
let matchFound = false;
let storedFrequentProjects;
if (this.isLocalStorageAvailable) {
const storedRawProjects = localStorage.getItem(this.storageKey);
// Check if there's any frequent projects list set
if (!storedRawProjects) {
// No frequent projects list set, set one up.
storedFrequentProjects = [];
storedFrequentProjects.push({ ...project, frequency: 1 });
} else {
// Check if project is already present in frequents list
// When found, update metadata of it.
storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => {
if (projectItem.id === project.id) {
matchFound = true;
const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS;
const updatedProject = {
...project,
frequency: projectItem.frequency,
lastAccessedOn: projectItem.lastAccessedOn,
};
// Check if duration since last access of this project
// is over an hour
if (diff > 1) {
return {
...updatedProject,
frequency: updatedProject.frequency + 1,
lastAccessedOn: Date.now(),
};
}
return {
...updatedProject,
};
}
return projectItem;
});
// Check whether currently logged project is present in frequents list
if (!matchFound) {
// We always keep size of frequents collection to 20 projects
// out of which only 5 projects with
// highest value of `frequency` and most recent `lastAccessedOn`
// are shown in projects dropdown
if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) {
storedFrequentProjects.shift(); // Remove an item from head of array
}
storedFrequentProjects.push({ ...project, frequency: 1 });
}
}
localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects));
}
}
getTopFrequentProjects() {
const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey));
let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP;
if (!storedFrequentProjects) {
return [];
}
if (bp.getBreakpointSize() === 'sm' ||
bp.getBreakpointSize() === 'xs') {
frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE;
}
const frequentProjects = storedFrequentProjects
.filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY);
// Sort all frequent projects in decending order of frequency
// and then by lastAccessedOn with recent most first
frequentProjects.sort((projectA, projectB) => {
if (projectA.frequency < projectB.frequency) {
return 1;
} else if (projectA.frequency > projectB.frequency) {
return -1;
} else if (projectA.lastAccessedOn < projectB.lastAccessedOn) {
return 1;
} else if (projectA.lastAccessedOn > projectB.lastAccessedOn) {
return -1;
}
return 0;
});
return _.first(frequentProjects, frequentProjectsCount);
}
}
export default class ProjectsStore {
constructor() {
this.state = {};
this.state.frequentProjects = [];
this.state.searchedProjects = [];
}
setFrequentProjects(rawProjects) {
this.state.frequentProjects = rawProjects;
}
getFrequentProjects() {
return this.state.frequentProjects;
}
setSearchedProjects(rawProjects) {
this.state.searchedProjects = rawProjects.map(rawProject => ({
id: rawProject.id,
name: rawProject.name,
namespace: rawProject.name_with_namespace,
webUrl: rawProject.web_url,
avatarUrl: rawProject.avatar_url,
}));
}
getSearchedProjects() {
return this.state.searchedProjects;
}
clearSearchedProjects() {
this.state.searchedProjects = [];
}
}
...@@ -9,6 +9,11 @@ export default { ...@@ -9,6 +9,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
sizeClass: {
type: String,
required: false,
default: 's40',
},
}, },
computed: { computed: {
/** /**
...@@ -38,7 +43,8 @@ export default { ...@@ -38,7 +43,8 @@ export default {
<template> <template>
<div <div
class="avatar s40 identicon" class="avatar identicon"
:class="sizeClass"
:style="identiconStyles"> :style="identiconStyles">
{{identiconTitle}} {{identiconTitle}}
</div> </div>
......
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
.append-right-default { margin-right: $gl-padding; } .append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; } .append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; } .append-bottom-0 { margin-bottom: 0; }
.append-bottom-5 { margin-bottom: 5px; }
.append-bottom-10 { margin-bottom: 10px; } .append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; } .append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; } .append-bottom-20 { margin-bottom: 20px; }
......
...@@ -830,3 +830,152 @@ ...@@ -830,3 +830,152 @@
@include new-style-dropdown('.breadcrumbs-list .dropdown '); @include new-style-dropdown('.breadcrumbs-list .dropdown ');
@include new-style-dropdown('.js-namespace-select + '); @include new-style-dropdown('.js-namespace-select + ');
header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu {
padding: 0;
@media (max-width: $screen-xs-max) {
display: table;
left: -50px;
min-width: 300px;
}
}
.projects-dropdown-container {
display: flex;
flex-direction: row;
width: 500px;
height: 334px;
.project-dropdown-sidebar,
.project-dropdown-content {
padding: 8px 0;
}
.loading-animation {
color: $almost-black;
}
.project-dropdown-sidebar {
width: 30%;
border-right: 1px solid $border-color;
}
.project-dropdown-content {
position: relative;
width: 70%;
}
@media (max-width: $screen-xs-max) {
flex-direction: column;
width: 100%;
height: auto;
flex: 1;
.project-dropdown-sidebar,
.project-dropdown-content {
width: 100%;
}
.project-dropdown-sidebar {
border-bottom: 1px solid $border-color;
border-right: 0;
}
}
}
.projects-dropdown-container {
.projects-list-frequent-container,
.projects-list-search-container, {
padding: 8px 0;
overflow-y: auto;
}
.section-header,
.projects-list-frequent-container li.section-empty,
.projects-list-search-container li.section-empty {
padding: 0 15px;
}
.section-header,
.projects-list-frequent-container li.section-empty,
.projects-list-search-container li.section-empty {
color: $gl-text-color-secondary;
font-size: $gl-font-size;
}
.projects-list-frequent-container,
.projects-list-search-container {
li.section-empty.section-failure {
color: $callout-danger-color;
}
}
.search-input-container {
position: relative;
padding: 4px $gl-padding;
.search-icon {
position: absolute;
top: 13px;
right: 25px;
color: $md-area-border;
}
}
.section-header {
font-weight: 700;
margin-top: 8px;
}
.projects-list-search-container {
height: 284px;
}
@media (max-width: $screen-xs-max) {
.projects-list-frequent-container {
width: auto;
height: auto;
padding-bottom: 0;
}
}
}
.projects-list-item-container {
.project-item-avatar-container
.project-item-metadata-container {
float: left;
}
.project-title,
.project-namespace {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover {
.project-item-avatar-container .avatar {
border-color: $md-area-border;
}
}
.project-title {
font-size: $gl-font-size;
font-weight: 400;
line-height: 16px;
}
.project-namespace {
margin-top: 4px;
font-size: 12px;
line-height: 12px;
color: $gl-text-color-secondary;
}
@media (max-width: $screen-xs-max) {
.project-item-metadata-container {
float: none;
}
}
}
...@@ -272,6 +272,7 @@ body[data-page="projects:new"] #select2-drop, ...@@ -272,6 +272,7 @@ body[data-page="projects:new"] #select2-drop,
body[data-page="projects:merge_requests:edit"] #select2-drop, body[data-page="projects:merge_requests:edit"] #select2-drop,
body[data-page="projects:blob:new"] #select2-drop, body[data-page="projects:blob:new"] #select2-drop,
body[data-page="profiles:show"] #select2-drop, body[data-page="profiles:show"] #select2-drop,
body[data-page="admin:groups:show"] #select2-drop,
body[data-page="projects:issues:show"] #select2-drop, body[data-page="projects:issues:show"] #select2-drop,
body[data-page="projects:blob:edit"] #select2-drop { body[data-page="projects:blob:edit"] #select2-drop {
&.select2-drop { &.select2-drop {
......
...@@ -617,6 +617,8 @@ ...@@ -617,6 +617,8 @@
} }
.issuable-actions { .issuable-actions {
@include new-style-dropdown;
padding-top: 10px; padding-top: 10px;
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
......
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
# search: string # search: string
# label_name: string # label_name: string
# sort: string # sort: string
# my_reaction_emoji: string
# #
class IssuesFinder < IssuableFinder class IssuesFinder < IssuableFinder
CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER
......
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
# label_name: string # label_name: string
# sort: string # sort: string
# non_archived: boolean # non_archived: boolean
# my_reaction_emoji: string
# #
class MergeRequestsFinder < IssuableFinder class MergeRequestsFinder < IssuableFinder
def klass def klass
......
...@@ -77,12 +77,6 @@ module ProjectsHelper ...@@ -77,12 +77,6 @@ module ProjectsHelper
project_link = breadcrumb_list_item project_link project_link = breadcrumb_list_item project_link
end end
if current_user && !show_new_nav?
project_link << button_tag(type: 'button', class: 'dropdown-toggle-caret js-projects-dropdown-toggle', aria: { label: 'Toggle switch project dropdown' }, data: { target: '.js-dropdown-menu-projects', toggle: 'dropdown', order_by: 'last_activity_at' }) do
icon("chevron-down")
end
end
"#{namespace_link} #{('/' unless show_new_nav?)} #{project_link}".html_safe "#{namespace_link} #{('/' unless show_new_nav?)} #{project_link}".html_safe
end end
......
...@@ -405,6 +405,6 @@ class Commit ...@@ -405,6 +405,6 @@ class Commit
end end
def gpg_commit def gpg_commit
@gpg_commit ||= Gitlab::Gpg::Commit.for_commit(self) @gpg_commit ||= Gitlab::Gpg::Commit.new(self)
end end
end end
...@@ -56,7 +56,7 @@ class GpgKey < ActiveRecord::Base ...@@ -56,7 +56,7 @@ class GpgKey < ActiveRecord::Base
def verified_user_infos def verified_user_infos
user_infos.select do |user_info| user_infos.select do |user_info|
user_info[:email] == user.email user.verified_email?(user_info[:email])
end end
end end
...@@ -64,13 +64,17 @@ class GpgKey < ActiveRecord::Base ...@@ -64,13 +64,17 @@ class GpgKey < ActiveRecord::Base
user_infos.map do |user_info| user_infos.map do |user_info|
[ [
user_info[:email], user_info[:email],
user_info[:email] == user.email user.verified_email?(user_info[:email])
] ]
end.to_h end.to_h
end end
def verified? def verified?
emails_with_verified_status.any? { |_email, verified| verified } emails_with_verified_status.values.any?
end
def verified_and_belongs_to_email?(email)
emails_with_verified_status.fetch(email, false)
end end
def update_invalid_gpg_signatures def update_invalid_gpg_signatures
...@@ -78,11 +82,14 @@ class GpgKey < ActiveRecord::Base ...@@ -78,11 +82,14 @@ class GpgKey < ActiveRecord::Base
end end
def revoke def revoke
GpgSignature.where(gpg_key: self, valid_signature: true).update_all( GpgSignature
gpg_key_id: nil, .where(gpg_key: self)
valid_signature: false, .where.not(verification_status: GpgSignature.verification_statuses[:unknown_key])
updated_at: Time.zone.now .update_all(
) gpg_key_id: nil,
verification_status: GpgSignature.verification_statuses[:unknown_key],
updated_at: Time.zone.now
)
destroy destroy
end end
......
class GpgSignature < ActiveRecord::Base class GpgSignature < ActiveRecord::Base
include ShaAttribute include ShaAttribute
include IgnorableColumn
ignore_column :valid_signature
sha_attribute :commit_sha sha_attribute :commit_sha
sha_attribute :gpg_key_primary_keyid sha_attribute :gpg_key_primary_keyid
enum verification_status: {
unverified: 0,
verified: 1,
same_user_different_email: 2,
other_user: 3,
unverified_key: 4,
unknown_key: 5
}
belongs_to :project belongs_to :project
belongs_to :gpg_key belongs_to :gpg_key
...@@ -20,6 +32,6 @@ class GpgSignature < ActiveRecord::Base ...@@ -20,6 +32,6 @@ class GpgSignature < ActiveRecord::Base
end end
def gpg_commit def gpg_commit
Gitlab::Gpg::Commit.new(project, commit_sha) Gitlab::Gpg::Commit.new(commit)
end end
end end
...@@ -166,32 +166,25 @@ class Repository ...@@ -166,32 +166,25 @@ class Repository
end end
def add_branch(user, branch_name, ref) def add_branch(user, branch_name, ref)
newrev = commit(ref).try(:sha) branch = raw_repository.add_branch(branch_name, committer: user, target: ref)
return false unless newrev
Gitlab::Git::OperationService.new(user, raw_repository).add_branch(branch_name, newrev)
after_create_branch after_create_branch
find_branch(branch_name)
branch
rescue Gitlab::Git::Repository::InvalidRef
false
end end
def add_tag(user, tag_name, target, message = nil) def add_tag(user, tag_name, target, message = nil)
newrev = commit(target).try(:id) raw_repository.add_tag(tag_name, committer: user, target: target, message: message)
options = { message: message, tagger: user_to_committer(user) } if message rescue Gitlab::Git::Repository::InvalidRef
false
return false unless newrev
Gitlab::Git::OperationService.new(user, raw_repository).add_tag(tag_name, newrev, options)
find_tag(tag_name)
end end
def rm_branch(user, branch_name) def rm_branch(user, branch_name)
before_remove_branch before_remove_branch
branch = find_branch(branch_name)
Gitlab::Git::OperationService.new(user, raw_repository).rm_branch(branch) raw_repository.rm_branch(branch_name, committer: user)
after_remove_branch after_remove_branch
true true
...@@ -199,9 +192,8 @@ class Repository ...@@ -199,9 +192,8 @@ class Repository
def rm_tag(user, tag_name) def rm_tag(user, tag_name)
before_remove_tag before_remove_tag
tag = find_tag(tag_name)
Gitlab::Git::OperationService.new(user, raw_repository).rm_tag(tag) raw_repository.rm_tag(tag_name, committer: user)
after_remove_tag after_remove_tag
true true
......
...@@ -1041,6 +1041,10 @@ class User < ActiveRecord::Base ...@@ -1041,6 +1041,10 @@ class User < ActiveRecord::Base
ensure_rss_token! ensure_rss_token!
end end
def verified_email?(email)
self.email == email
end
protected protected
# override, from Devise::Validatable # override, from Devise::Validatable
......
%ul.list-unstyled.navbar-sub-nav %ul.list-unstyled.navbar-sub-nav
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "home"}) do = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown" }) do
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do %a{ href: '#', title: 'Projects', data: { toggle: 'dropdown' } }
Projects Projects
= icon("chevron-down", class: "dropdown-chevron")
.dropdown-menu.projects-dropdown-menu
= render "layouts/nav/projects_dropdown/show"
= nav_link(controller: ['dashboard/groups', 'explore/groups']) do = nav_link(controller: ['dashboard/groups', 'explore/groups']) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
...@@ -31,3 +34,8 @@ ...@@ -31,3 +34,8 @@
%li.divider %li.divider
%li %li
= link_to "Help", help_path, title: 'About GitLab CE' = link_to "Help", help_path, title: 'About GitLab CE'
-# Shortcut to Dashboard > Projects
%li.hidden
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
Projects
- project_meta = { id: @project.id, name: @project.name, namespace: @project.name_with_namespace, web_url: @project.web_url, avatar_url: @project.avatar_url } if @project&.persisted?
.projects-dropdown-container
.project-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/projects#index') do
= link_to dashboard_projects_path do
= _('Your projects')
= nav_link(path: 'projects#starred') do
= link_to starred_dashboard_projects_path do
= _('Starred projects')
= nav_link(path: 'projects#trending') do
= link_to explore_root_path do
= _('Explore projects')
.project-dropdown-content
#js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } }
- title = capture do - title = capture do
.gpg-popover-icon.invalid This commit was signed with a different user's verified signature.
= render 'shared/icons/icon_status_notfound_borderless.svg'
%div
This commit was signed with an <strong>unverified</strong> signature.
- locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] } - locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals = render partial: 'projects/commit/signature_badge', locals: locals
- title = capture do
This commit was signed with a verified signature, but the committer email
is <strong>not verified</strong> to belong to the same user.
- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'icon_status_notfound_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals
- if signature - if signature
- if signature.valid_signature? = render partial: "projects/commit/#{signature.verification_status}_signature_badge", locals: { signature: signature }
= render partial: 'projects/commit/valid_signature_badge', locals: { signature: signature }
- else
= render partial: 'projects/commit/invalid_signature_badge', locals: { signature: signature }
- css_classes = commit_signature_badge_classes(css_classes) - signature = local_assigns.fetch(:signature)
- title = local_assigns.fetch(:title)
- label = local_assigns.fetch(:label)
- css_class = local_assigns.fetch(:css_class)
- icon = local_assigns.fetch(:icon)
- show_user = local_assigns.fetch(:show_user, false)
- css_classes = commit_signature_badge_classes(css_class)
- title = capture do - title = capture do
.gpg-popover-status .gpg-popover-status
= title .gpg-popover-icon{ class: css_class }
= render "shared/icons/#{icon}.svg"
%div
= title
- content = capture do - content = capture do
.clearfix - if show_user
= content .clearfix
= render partial: 'projects/commit/signature_badge_user', locals: { signature: signature }
GPG Key ID: GPG Key ID:
%span.monospace= signature.gpg_key_primary_keyid %span.monospace= signature.gpg_key_primary_keyid
= link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') = link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
%button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } } %button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } }
......
- gpg_key = signature.gpg_key
- user = gpg_key&.user
- user_name = signature.gpg_key_user_name
- user_email = signature.gpg_key_user_email
- if user
= link_to user_path(user), class: 'gpg-popover-user-link' do
%div
= user_avatar_without_link(user: user, size: 32)
%div
%strong= user.name
%div= user.to_reference
- else
= mail_to user_email do
%div
= user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32)
%div
%strong= user_name
%div= user_email
= render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature }
= render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature }
- title = capture do
This commit was signed with an <strong>unverified</strong> signature.
- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless' }
= render partial: 'projects/commit/signature_badge', locals: locals
- title = capture do
.gpg-popover-icon.valid
= render 'shared/icons/icon_status_success_borderless.svg'
%div
This commit was signed with a <strong>verified</strong> signature.
- content = capture do
- gpg_key = signature.gpg_key
- user = gpg_key&.user
- user_name = signature.gpg_key_user_name
- user_email = signature.gpg_key_user_email
- if user
= link_to user_path(user), class: 'gpg-popover-user-link' do
%div
= user_avatar_without_link(user: user, size: 32)
%div
%strong= gpg_key.user.name
%div @#{gpg_key.user.username}
- else
= mail_to user_email do
%div
= user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32)
%div
%strong= user_name
%div= user_email
- locals = { signature: signature, title: title, content: content, label: 'Verified', css_classes: ['valid'] }
= render partial: 'projects/commit/signature_badge', locals: locals
- title = capture do
This commit was signed with a <strong>verified</strong> signature and the
committer email is verified to belong to the same user.
- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'icon_status_success_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
%template{ 'v-if' => 'isResolved' } %template{ 'v-if' => 'isResolved' }
= render 'shared/icons/icon_status_success_solid.svg' = render 'shared/icons/icon_status_success_solid.svg'
%template{ 'v-else' => '' } %template{ 'v-else' => '' }
= render 'shared/icons/icon_status_success.svg' = render 'shared/icons/icon_resolve_discussion.svg'
- if current_user - if current_user
- if note.emoji_awardable? - if note.emoji_awardable?
......
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg>
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg> <svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></g></svg>
...@@ -6,7 +6,11 @@ class CreateGpgSignatureWorker ...@@ -6,7 +6,11 @@ class CreateGpgSignatureWorker
project = Project.find_by(id: project_id) project = Project.find_by(id: project_id)
return unless project return unless project
commit = project.commit(commit_sha)
return unless commit
# This calculates and caches the signature in the database # This calculates and caches the signature in the database
Gitlab::Gpg::Commit.new(project, commit_sha).signature Gitlab::Gpg::Commit.new(commit).signature
end end
end end
---
title: Add dropdown to Projects nav item
merge_request: 13866
author:
type: added
---
title: Fix broken svg in jobs dropdown for success status
merge_request:
author:
type: fixed
---
title: Add branch existence check to the APIv4 branches via HEAD request
merge_request: 13979
author: Vitaliy @blackst0ne Klachkov
type: added
---
title: Fixed add diff note button not showing after deleting a comment
merge_request:
author:
type: fixed
---
title: 'Update the GPG verification semantics: A GPG signature must additionally match
the committer in order to be verified'
merge_request: 13771
author: Alexis Reigel
type: changed
---
title: Add my_reaction_emoji param to /issues and /merge_requests API
merge_request: 14016
author: Hiroyuki Sato
type: added
...@@ -30,7 +30,7 @@ var config = { ...@@ -30,7 +30,7 @@ var config = {
blob: './blob_edit/blob_bundle.js', blob: './blob_edit/blob_bundle.js',
boards: './boards/boards_bundle.js', boards: './boards/boards_bundle.js',
common: './commons/index.js', common: './commons/index.js',
common_vue: ['vue', './vue_shared/common_vue.js'], common_vue: './vue_shared/vue_resource_interceptor.js',
common_d3: ['d3'], common_d3: ['d3'],
cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js',
commit_pipelines: './commit/pipelines/pipelines_bundle.js', commit_pipelines: './commit/pipelines/pipelines_bundle.js',
......
class AddVerificationStatusToGpgSignatures < ActiveRecord::Migration
DOWNTIME = false
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
def up
# First we remove all signatures because we need to re-verify them all
# again anyway (because of the updated verification logic).
#
# This makes adding the column with default values faster
truncate(:gpg_signatures)
add_column_with_default(:gpg_signatures, :verification_status, :smallint, default: 0)
end
def down
remove_column(:gpg_signatures, :verification_status)
end
end
class DestroyGpgSignatures < ActiveRecord::Migration
DOWNTIME = false
def up
truncate(:gpg_signatures)
end
def down
end
end
class RemoveValidSignatureFromGpgSignatures < ActiveRecord::Migration
DOWNTIME = false
def up
remove_column :gpg_signatures, :valid_signature
end
def down
add_column :gpg_signatures, :valid_signature, :boolean
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170830125940) do ActiveRecord::Schema.define(version: 20170831195038) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -609,11 +609,11 @@ ActiveRecord::Schema.define(version: 20170830125940) do ...@@ -609,11 +609,11 @@ ActiveRecord::Schema.define(version: 20170830125940) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "project_id" t.integer "project_id"
t.integer "gpg_key_id" t.integer "gpg_key_id"
t.boolean "valid_signature"
t.binary "commit_sha" t.binary "commit_sha"
t.binary "gpg_key_primary_keyid" t.binary "gpg_key_primary_keyid"
t.text "gpg_key_user_name" t.text "gpg_key_user_name"
t.text "gpg_key_user_email" t.text "gpg_key_user_email"
t.integer "verification_status", limit: 2, default: 0, null: false
end end
add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", unique: true, using: :btree add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", unique: true, using: :btree
......
...@@ -160,7 +160,6 @@ have access to GitLab administration tools and settings. ...@@ -160,7 +160,6 @@ have access to GitLab administration tools and settings.
### Integrations ### Integrations
- [Integrations](integration/README.md): How to integrate with systems such as JIRA, Redmine, Twitter. - [Integrations](integration/README.md): How to integrate with systems such as JIRA, Redmine, Twitter.
- [Koding](administration/integration/koding.md): Set up Koding to use with GitLab.
- [Mattermost](user/project/integrations/mattermost.md): Set up GitLab with Mattermost. - [Mattermost](user/project/integrations/mattermost.md): Set up GitLab with Mattermost.
### Monitoring ### Monitoring
......
# Koding & GitLab # Koding & GitLab
> [Introduced][ce-5909] in GitLab 8.11. >**Notes:**
- **As of GitLab 10.0, the Koding integration is deprecated and will be removed
in a future version. The option to configure it is removed from GitLab's admin
area.**
- [Introduced][ce-5909] in GitLab 8.11.
This document will guide you through installing and configuring Koding with This document will guide you through installing and configuring Koding with
GitLab. GitLab.
......
...@@ -30,20 +30,22 @@ GET /issues?milestone=1.0.0&state=opened ...@@ -30,20 +30,22 @@ GET /issues?milestone=1.0.0&state=opened
GET /issues?iids[]=42&iids[]=43 GET /issues?iids[]=42&iids[]=43
GET /issues?author_id=5 GET /issues?author_id=5
GET /issues?assignee_id=5 GET /issues?assignee_id=5
``` GET /issues?my_reaction_emoji=star
```
| Attribute | Type | Required | Description |
|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------| | Attribute | Type | Required | Description |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` | | ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | | `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `milestone` | string | no | The milestone title | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` _([Introduced][ce-13004] in GitLab 9.5)_ | | `milestone` | string | no | The milestone title |
| `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me`. _([Introduced][ce-13004] in GitLab 9.5)_ | | `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | | `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me`. _([Introduced][ce-13004] in GitLab 9.5)_ |
| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | | `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `iids[]` | Array[integer] | no | Return only the issues having the given `iid` |
| `search` | string | no | Search issues against their `title` and `description` | | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Search issues against their `title` and `description` |
```bash ```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/issues curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/issues
...@@ -131,21 +133,23 @@ GET /groups/:id/issues?iids[]=42&iids[]=43 ...@@ -131,21 +133,23 @@ GET /groups/:id/issues?iids[]=42&iids[]=43
GET /groups/:id/issues?search=issue+title+or+description GET /groups/:id/issues?search=issue+title+or+description
GET /groups/:id/issues?author_id=5 GET /groups/:id/issues?author_id=5
GET /groups/:id/issues?assignee_id=5 GET /groups/:id/issues?assignee_id=5
GET /groups/:id/issues?my_reaction_emoji=star
``` ```
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------| | ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` | | `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | | `iids[]` | Array[integer] | no | Return only the issues having the given `iid` |
| `milestone` | string | no | The milestone title | | `milestone` | string | no | The milestone title |
| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ | | `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | | `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | | `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `search` | string | no | Search group issues against their `title` and `description` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
| `search` | string | no | Search group issues against their `title` and `description` |
```bash ```bash
...@@ -234,23 +238,25 @@ GET /projects/:id/issues?iids[]=42&iids[]=43 ...@@ -234,23 +238,25 @@ GET /projects/:id/issues?iids[]=42&iids[]=43
GET /projects/:id/issues?search=issue+title+or+description GET /projects/:id/issues?search=issue+title+or+description
GET /projects/:id/issues?author_id=5 GET /projects/:id/issues?author_id=5
GET /projects/:id/issues?assignee_id=5 GET /projects/:id/issues?assignee_id=5
``` GET /projects/:id/issues?my_reaction_emoji=star
```
| Attribute | Type | Required | Description |
|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------| | Attribute | Type | Required | Description |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | | ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `state` | string | no | Return all issues or just those that are `opened` or `closed` | | `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` |
| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | | `state` | string | no | Return all issues or just those that are `opened` or `closed` |
| `milestone` | string | no | The milestone title | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels |
| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ | | `milestone` | string | no | The milestone title |
| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | | `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | | `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ |
| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
| `search` | string | no | Search project issues against their `title` and `description` | | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `created_after` | datetime | no | Return issues created after the given time (inclusive) | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` |
| `created_before` | datetime | no | Return issues created before the given time (inclusive) | | `search` | string | no | Search project issues against their `title` and `description` |
| `created_after` | datetime | no | Return issues created after the given time (inclusive) |
| `created_before` | datetime | no | Return issues created before the given time (inclusive) |
```bash ```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues
...@@ -1093,3 +1099,4 @@ Example response: ...@@ -1093,3 +1099,4 @@ Example response:
``` ```
[ce-13004]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13004 [ce-13004]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13004
[ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016
...@@ -22,24 +22,26 @@ GET /merge_requests?state=all ...@@ -22,24 +22,26 @@ GET /merge_requests?state=all
GET /merge_requests?milestone=release GET /merge_requests?milestone=release
GET /merge_requests?labels=bug,reproduced GET /merge_requests?labels=bug,reproduced
GET /merge_requests?author_id=5 GET /merge_requests?author_id=5
GET /merge_requests?my_reaction_emoji=star
GET /merge_requests?scope=assigned-to-me GET /merge_requests?scope=assigned-to-me
``` ```
Parameters: Parameters:
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | ------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`| | `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` |
| `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | | `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
| `milestone` | string | no | Return merge requests for a specific milestone | | `milestone` | string | no | Return merge requests for a specific milestone |
| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | | `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
| `labels` | string | no | Return merge requests matching a comma separated list of labels | | `labels` | string | no | Return merge requests matching a comma separated list of labels |
| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) | | `created_after` | datetime | no | Return merge requests created after the given time (inclusive) |
| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) | | `created_before` | datetime | no | Return merge requests created before the given time (inclusive) |
| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` | | `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` |
| `author_id` | integer | no | Returns merge requests created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me` | | `author_id` | integer | no | Returns merge requests created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me` |
| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` | | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` |
| `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
```json ```json
[ [
...@@ -116,25 +118,27 @@ GET /projects/:id/merge_requests?state=all ...@@ -116,25 +118,27 @@ GET /projects/:id/merge_requests?state=all
GET /projects/:id/merge_requests?iids[]=42&iids[]=43 GET /projects/:id/merge_requests?iids[]=42&iids[]=43
GET /projects/:id/merge_requests?milestone=release GET /projects/:id/merge_requests?milestone=release
GET /projects/:id/merge_requests?labels=bug,reproduced GET /projects/:id/merge_requests?labels=bug,reproduced
GET /projects/:id/merge_requests?my_reaction_emoji=star
``` ```
Parameters: Parameters:
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | ------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `id` | integer | yes | The ID of a project | | `id` | integer | yes | The ID of a project |
| `iids[]` | Array[integer] | no | Return the request having the given `iid` | | `iids[]` | Array[integer] | no | Return the request having the given `iid` |
| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`| | `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` |
| `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` |
| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | | `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` |
| `milestone` | string | no | Return merge requests for a specific milestone | | `milestone` | string | no | Return merge requests for a specific milestone |
| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | | `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request |
| `labels` | string | no | Return merge requests matching a comma separated list of labels | | `labels` | string | no | Return merge requests matching a comma separated list of labels |
| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) | | `created_after` | datetime | no | Return merge requests created after the given time (inclusive) |
| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) | | `created_before` | datetime | no | Return merge requests created before the given time (inclusive) |
| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13060] in GitLab 9.5)_ | | `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13060] in GitLab 9.5)_ |
| `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ |
| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ |
| `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ |
```json ```json
[ [
...@@ -1315,3 +1319,4 @@ Example response: ...@@ -1315,3 +1319,4 @@ Example response:
``` ```
[ce-13060]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13060 [ce-13060]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13060
[ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016
...@@ -274,9 +274,7 @@ session - and even a multiplexer like `screen` or `tmux`! ...@@ -274,9 +274,7 @@ session - and even a multiplexer like `screen` or `tmux`!
>**Note:** >**Note:**
Container-based deployments often lack basic tools (like an editor), and may Container-based deployments often lack basic tools (like an editor), and may
be stopped or restarted at any time. If this happens, you will lose all your be stopped or restarted at any time. If this happens, you will lose all your
changes! Treat this as a debugging tool, not a comprehensive online IDE. You changes! Treat this as a debugging tool, not a comprehensive online IDE.
can use [Koding](../administration/integration/koding.md) for online
development.
--- ---
......
...@@ -107,9 +107,26 @@ To lock/unlock a Runner: ...@@ -107,9 +107,26 @@ To lock/unlock a Runner:
1. Check the **Lock to current projects** option 1. Check the **Lock to current projects** option
1. Click **Save changes** for the changes to take effect 1. Click **Save changes** for the changes to take effect
## Assigning a Runner to another project
If you are Master on a project where a specific Runner is assigned to, and the
Runner is not [locked only to that project](#locking-a-specific-runner-from-being-enabled-for-other-projects),
you can enable the Runner also on any other project where you have Master permissions.
To enable/disable a Runner in your project:
1. Visit your project's **Settings ➔ Pipelines**
1. Find the Runner you wish to enable/disable
1. Click **Enable for this project** or **Disable for this project**
> **Note**:
Consider that if you don't lock your specific Runner to a specific project, any
user with Master role in you project can assign your runner to another arbitrary
project without requiring your authorization, so use it with caution.
## Protected Runners ## Protected Runners
>**Notes:** >
[Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13194) [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13194)
in GitLab 10.0. in GitLab 10.0.
......
...@@ -13,7 +13,6 @@ Bitbucket.org account ...@@ -13,7 +13,6 @@ Bitbucket.org account
- [External issue tracker](external-issue-tracker.md) Redmine, JIRA, etc. - [External issue tracker](external-issue-tracker.md) Redmine, JIRA, etc.
- [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages - [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages
- [JIRA](../user/project/integrations/jira.md) Integrate with the JIRA issue tracker - [JIRA](../user/project/integrations/jira.md) Integrate with the JIRA issue tracker
- [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration
- [LDAP](ldap.md) Set up sign in via LDAP - [LDAP](ldap.md) Set up sign in via LDAP
- [OAuth2 provider](oauth_provider.md) OAuth2 application creation - [OAuth2 provider](oauth_provider.md) OAuth2 application creation
- [OmniAuth](omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure and Authentiq ID - [OmniAuth](omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure and Authentiq ID
......
...@@ -67,8 +67,6 @@ website with GitLab Pages ...@@ -67,8 +67,6 @@ website with GitLab Pages
**Other features:** **Other features:**
- [Cycle Analytics](cycle_analytics.md): Review your development lifecycle - [Cycle Analytics](cycle_analytics.md): Review your development lifecycle
- [Koding integration](koding.md) (not available on GitLab.com): Integrate
with Koding to have access to a web terminal right from the GitLab UI
- [Syntax highlighting](highlighting.md): An alternative to customize - [Syntax highlighting](highlighting.md): An alternative to customize
your code blocks, overriding GitLab's default choice of language your code blocks, overriding GitLab's default choice of language
......
# Koding integration # Koding integration
> [Introduced][ce-5909] in GitLab 8.11. >**Notes:**
- **As of GitLab 10.0, the Koding integration is deprecated and will be removed
in a future version.**
- [Introduced][ce-5909] in GitLab 8.11.
This document will guide you through using Koding integration on GitLab in This document will guide you through using Koding integration on GitLab in
detail. For configuring and installing please follow the detail. For configuring and installing please follow the
......
...@@ -22,11 +22,12 @@ GitLab uses its own keyring to verify the GPG signature. It does not access any ...@@ -22,11 +22,12 @@ GitLab uses its own keyring to verify the GPG signature. It does not access any
public key server. public key server.
In order to have a commit verified on GitLab the corresponding public key needs In order to have a commit verified on GitLab the corresponding public key needs
to be uploaded to GitLab. For a signature to be verified two prerequisites need to be uploaded to GitLab. For a signature to be verified three conditions need
to be met: to be met:
1. The public key needs to be added your GitLab account 1. The public key needs to be added your GitLab account
1. One of the emails in the GPG key matches your **primary** email 1. One of the emails in the GPG key matches your **primary** email
1. The committer's email matches the verified email from the gpg key
## Generating a GPG key ## Generating a GPG key
......
...@@ -24,17 +24,22 @@ module API ...@@ -24,17 +24,22 @@ module API
present paginate(branches), with: Entities::RepoBranch, project: user_project present paginate(branches), with: Entities::RepoBranch, project: user_project
end end
desc 'Get a single branch' do resource ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do
success Entities::RepoBranch desc 'Get a single branch' do
end success Entities::RepoBranch
params do end
requires :branch, type: String, desc: 'The name of the branch' params do
end requires :branch, type: String, desc: 'The name of the branch'
get ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do end
branch = user_project.repository.find_branch(params[:branch]) head do
not_found!("Branch") unless branch user_project.repository.branch_exists?(params[:branch]) ? status(204) : status(404)
end
get do
branch = user_project.repository.find_branch(params[:branch])
not_found!('Branch') unless branch
present branch, with: Entities::RepoBranch, project: user_project present branch, with: Entities::RepoBranch, project: user_project
end
end end
# Note: This API will be deprecated in favor of the protected branches API. # Note: This API will be deprecated in favor of the protected branches API.
......
...@@ -36,6 +36,7 @@ module API ...@@ -36,6 +36,7 @@ module API
optional :assignee_id, type: Integer, desc: 'Return issues which are assigned to the user with the given ID' optional :assignee_id, type: Integer, desc: 'Return issues which are assigned to the user with the given ID'
optional :scope, type: String, values: %w[created-by-me assigned-to-me all], optional :scope, type: String, values: %w[created-by-me assigned-to-me all],
desc: 'Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`' desc: 'Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`'
optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji'
use :pagination use :pagination
end end
......
...@@ -40,6 +40,7 @@ module API ...@@ -40,6 +40,7 @@ module API
optional :assignee_id, type: Integer, desc: 'Return merge requests which are assigned to the user with the given ID' optional :assignee_id, type: Integer, desc: 'Return merge requests which are assigned to the user with the given ID'
optional :scope, type: String, values: %w[created-by-me assigned-to-me all], optional :scope, type: String, values: %w[created-by-me assigned-to-me all],
desc: 'Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`' desc: 'Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`'
optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji'
use :pagination use :pagination
end end
end end
......
...@@ -605,6 +605,49 @@ module Gitlab ...@@ -605,6 +605,49 @@ module Gitlab
# TODO: implement this method # TODO: implement this method
end end
def add_branch(branch_name, committer:, target:)
target_object = Ref.dereference_object(lookup(target))
raise InvalidRef.new("target not found: #{target}") unless target_object
OperationService.new(committer, self).add_branch(branch_name, target_object.oid)
find_branch(branch_name)
rescue Rugged::ReferenceError => ex
raise InvalidRef, ex
end
def add_tag(tag_name, committer:, target:, message: nil)
target_object = Ref.dereference_object(lookup(target))
raise InvalidRef.new("target not found: #{target}") unless target_object
committer = Committer.from_user(committer) if committer.is_a?(User)
options = nil # Use nil, not the empty hash. Rugged cares about this.
if message
options = {
message: message,
tagger: Gitlab::Git.committer_hash(email: committer.email, name: committer.name)
}
end
OperationService.new(committer, self).add_tag(tag_name, target_object.oid, options)
find_tag(tag_name)
rescue Rugged::ReferenceError => ex
raise InvalidRef, ex
end
def rm_branch(branch_name, committer:)
OperationService.new(committer, self).rm_branch(find_branch(branch_name))
end
def rm_tag(tag_name, committer:)
OperationService.new(committer, self).rm_tag(find_tag(tag_name))
end
def find_tag(name)
tags.find { |tag| tag.name == name }
end
# Delete the specified branch from the repository # Delete the specified branch from the repository
# #
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/476 # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/476
......
...@@ -39,7 +39,7 @@ module Gitlab ...@@ -39,7 +39,7 @@ module Gitlab
fingerprints = CurrentKeyChain.fingerprints_from_key(key) fingerprints = CurrentKeyChain.fingerprints_from_key(key)
GPGME::Key.find(:public, fingerprints).flat_map do |raw_key| GPGME::Key.find(:public, fingerprints).flat_map do |raw_key|
raw_key.uids.map { |uid| { name: uid.name, email: uid.email } } raw_key.uids.map { |uid| { name: uid.name, email: uid.email.downcase } }
end end
end end
end end
......
module Gitlab module Gitlab
module Gpg module Gpg
class Commit class Commit
def self.for_commit(commit) def initialize(commit)
new(commit.project, commit.sha) @commit = commit
end
def initialize(project, sha)
@project = project
@sha = sha
@signature_text, @signed_text = @signature_text, @signed_text =
begin begin
Rugged::Commit.extract_signature(project.repository.rugged, sha) Rugged::Commit.extract_signature(@commit.project.repository.rugged, @commit.sha)
rescue Rugged::OdbError rescue Rugged::OdbError
nil nil
end end
...@@ -26,7 +21,7 @@ module Gitlab ...@@ -26,7 +21,7 @@ module Gitlab
return @signature if @signature return @signature if @signature
cached_signature = GpgSignature.find_by(commit_sha: @sha) cached_signature = GpgSignature.find_by(commit_sha: @commit.sha)
return @signature = cached_signature if cached_signature.present? return @signature = cached_signature if cached_signature.present?
@signature = create_cached_signature! @signature = create_cached_signature!
...@@ -73,20 +68,31 @@ module Gitlab ...@@ -73,20 +68,31 @@ module Gitlab
def attributes(gpg_key) def attributes(gpg_key)
user_infos = user_infos(gpg_key) user_infos = user_infos(gpg_key)
verification_status = verification_status(gpg_key)
{ {
commit_sha: @sha, commit_sha: @commit.sha,
project: @project, project: @commit.project,
gpg_key: gpg_key, gpg_key: gpg_key,
gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint, gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint,
gpg_key_user_name: user_infos[:name], gpg_key_user_name: user_infos[:name],
gpg_key_user_email: user_infos[:email], gpg_key_user_email: user_infos[:email],
valid_signature: gpg_signature_valid_signature_value(gpg_key) verification_status: verification_status
} }
end end
def gpg_signature_valid_signature_value(gpg_key) def verification_status(gpg_key)
!!(gpg_key && gpg_key.verified? && verified_signature.valid?) return :unknown_key unless gpg_key
return :unverified_key unless gpg_key.verified?
return :unverified unless verified_signature.valid?
if gpg_key.verified_and_belongs_to_email?(@commit.committer_email)
:verified
elsif gpg_key.user.all_emails.include?(@commit.committer_email)
:same_user_different_email
else
:other_user
end
end end
def user_infos(gpg_key) def user_infos(gpg_key)
......
...@@ -8,7 +8,7 @@ module Gitlab ...@@ -8,7 +8,7 @@ module Gitlab
def run def run
GpgSignature GpgSignature
.select(:id, :commit_sha, :project_id) .select(:id, :commit_sha, :project_id)
.where('gpg_key_id IS NULL OR valid_signature = ?', false) .where('gpg_key_id IS NULL OR verification_status <> ?', GpgSignature.verification_statuses[:verified])
.where(gpg_key_primary_keyid: @gpg_key.primary_keyid) .where(gpg_key_primary_keyid: @gpg_key.primary_keyid)
.find_each { |sig| sig.gpg_commit.update_signature!(sig) } .find_each { |sig| sig.gpg_commit.update_signature!(sig) }
end end
......
...@@ -7,8 +7,8 @@ msgid "" ...@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: gitlab 1.0.0\n" "Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-08-24 09:29+0200\n" "POT-Creation-Date: 2017-08-31 17:34+0530\n"
"PO-Revision-Date: 2017-08-24 09:29+0200\n" "PO-Revision-Date: 2017-08-31 17:34+0530\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n" "Language: \n"
...@@ -427,6 +427,9 @@ msgstr "" ...@@ -427,6 +427,9 @@ msgstr ""
msgid "Every week (Sundays at 4:00am)" msgid "Every week (Sundays at 4:00am)"
msgstr "" msgstr ""
msgid "Explore projects"
msgstr ""
msgid "Failed to change the owner" msgid "Failed to change the owner"
msgstr "" msgstr ""
...@@ -837,6 +840,27 @@ msgstr "" ...@@ -837,6 +840,27 @@ msgstr ""
msgid "ProjectNetworkGraph|Graph" msgid "ProjectNetworkGraph|Graph"
msgstr "" msgstr ""
msgid "ProjectsDropdown|Frequently visited"
msgstr ""
msgid "ProjectsDropdown|Loading projects"
msgstr ""
msgid "ProjectsDropdown|No projects matched your query"
msgstr ""
msgid "ProjectsDropdown|Projects you visit often will appear here"
msgstr ""
msgid "ProjectsDropdown|Search projects"
msgstr ""
msgid "ProjectsDropdown|Something went wrong on our end."
msgstr ""
msgid "ProjectsDropdown|This feature requires browser localStorage support"
msgstr ""
msgid "Push events" msgid "Push events"
msgstr "" msgstr ""
...@@ -950,6 +974,9 @@ msgstr "" ...@@ -950,6 +974,9 @@ msgstr ""
msgid "StarProject|Star" msgid "StarProject|Star"
msgstr "" msgstr ""
msgid "Starred projects"
msgstr ""
msgid "Start a %{new_merge_request} with these changes" msgid "Start a %{new_merge_request} with these changes"
msgstr "" msgstr ""
...@@ -1271,6 +1298,9 @@ msgstr "" ...@@ -1271,6 +1298,9 @@ msgstr ""
msgid "Your name" msgid "Your name"
msgstr "" msgstr ""
msgid "Your projects"
msgstr ""
msgid "day" msgid "day"
msgid_plural "days" msgid_plural "days"
msgstr[0] "" msgstr[0] ""
......
...@@ -6,6 +6,6 @@ FactoryGirl.define do ...@@ -6,6 +6,6 @@ FactoryGirl.define do
project project
gpg_key gpg_key
gpg_key_primary_keyid { gpg_key.primary_keyid } gpg_key_primary_keyid { gpg_key.primary_keyid }
valid_signature true verification_status :verified
end end
end end
...@@ -203,105 +203,4 @@ describe 'Commits' do ...@@ -203,105 +203,4 @@ describe 'Commits' do
end end
end end
end end
describe 'GPG signed commits', :js do
it 'changes from unverified to verified when the user changes his email to match the gpg key' do
user = create :user, email: 'unrelated.user@example.org'
project.team << [user, :master]
Sidekiq::Testing.inline! do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
sign_in(user)
visit project_commits_path(project, :'signed-commits')
within '#commits-list' do
expect(page).to have_content 'Unverified'
expect(page).not_to have_content 'Verified'
end
# user changes his email which makes the gpg key verified
Sidekiq::Testing.inline! do
user.skip_reconfirmation!
user.update_attributes!(email: GpgHelpers::User1.emails.first)
end
visit project_commits_path(project, :'signed-commits')
within '#commits-list' do
expect(page).to have_content 'Unverified'
expect(page).to have_content 'Verified'
end
end
it 'changes from unverified to verified when the user adds the missing gpg key' do
user = create :user, email: GpgHelpers::User1.emails.first
project.team << [user, :master]
sign_in(user)
visit project_commits_path(project, :'signed-commits')
within '#commits-list' do
expect(page).to have_content 'Unverified'
expect(page).not_to have_content 'Verified'
end
# user adds the gpg key which makes the signature valid
Sidekiq::Testing.inline! do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
visit project_commits_path(project, :'signed-commits')
within '#commits-list' do
expect(page).to have_content 'Unverified'
expect(page).to have_content 'Verified'
end
end
it 'shows popover badges' do
gpg_user = create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard'
Sidekiq::Testing.inline! do
create :gpg_key, key: GpgHelpers::User1.public_key, user: gpg_user
end
user = create :user
project.team << [user, :master]
sign_in(user)
visit project_commits_path(project, :'signed-commits')
# unverified signature
click_on 'Unverified', match: :first
within '.popover' do
expect(page).to have_content 'This commit was signed with an unverified signature.'
expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
end
# verified and the gpg user has a gitlab profile
click_on 'Verified', match: :first
within '.popover' do
expect(page).to have_content 'This commit was signed with a verified signature.'
expect(page).to have_content 'Nannie Bernhard'
expect(page).to have_content '@nannie.bernhard'
expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
end
# verified and the gpg user's profile doesn't exist anymore
gpg_user.destroy!
visit project_commits_path(project, :'signed-commits')
click_on 'Verified', match: :first
within '.popover' do
expect(page).to have_content 'This commit was signed with a verified signature.'
expect(page).to have_content 'Nannie Bernhard'
expect(page).to have_content 'nannie.bernhard@example.com'
expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
end
end
end
end end
...@@ -97,6 +97,16 @@ feature 'Merge requests > User posts diff notes', :js do ...@@ -97,6 +97,16 @@ feature 'Merge requests > User posts diff notes', :js do
visit diffs_project_merge_request_path(project, merge_request, view: 'inline') visit diffs_project_merge_request_path(project, merge_request, view: 'inline')
end end
context 'after deleteing a note' do
it 'allows commenting' do
should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
first('.js-note-delete', visible: false).trigger('click')
should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
end
end
context 'with a new line' do context 'with a new line' do
it 'allows commenting' do it 'allows commenting' do
should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]'))
......
...@@ -42,7 +42,7 @@ feature 'Profile > GPG Keys' do ...@@ -42,7 +42,7 @@ feature 'Profile > GPG Keys' do
scenario 'User revokes a key via the key index' do scenario 'User revokes a key via the key index' do
gpg_key = create :gpg_key, user: user, key: GpgHelpers::User2.public_key gpg_key = create :gpg_key, user: user, key: GpgHelpers::User2.public_key
gpg_signature = create :gpg_signature, gpg_key: gpg_key, valid_signature: true gpg_signature = create :gpg_signature, gpg_key: gpg_key, verification_status: :verified
visit profile_gpg_keys_path visit profile_gpg_keys_path
...@@ -51,7 +51,7 @@ feature 'Profile > GPG Keys' do ...@@ -51,7 +51,7 @@ feature 'Profile > GPG Keys' do
expect(page).to have_content('Your GPG keys (0)') expect(page).to have_content('Your GPG keys (0)')
expect(gpg_signature.reload).to have_attributes( expect(gpg_signature.reload).to have_attributes(
valid_signature: false, verification_status: 'unknown_key',
gpg_key: nil gpg_key: nil
) )
end end
......
require 'spec_helper'
describe 'GPG signed commits', :js do
let(:project) { create(:project, :repository) }
it 'changes from unverified to verified when the user changes his email to match the gpg key' do
user = create :user, email: 'unrelated.user@example.org'
project.team << [user, :master]
Sidekiq::Testing.inline! do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
sign_in(user)
visit project_commits_path(project, :'signed-commits')
within '#commits-list' do
expect(page).to have_content 'Unverified'
expect(page).not_to have_content 'Verified'
end
# user changes his email which makes the gpg key verified
Sidekiq::Testing.inline! do
user.skip_reconfirmation!
user.update_attributes!(email: GpgHelpers::User1.emails.first)
end
visit project_commits_path(project, :'signed-commits')
within '#commits-list' do
expect(page).to have_content 'Unverified'
expect(page).to have_content 'Verified'
end
end
it 'changes from unverified to verified when the user adds the missing gpg key' do
user = create :user, email: GpgHelpers::User1.emails.first
project.team << [user, :master]
sign_in(user)
visit project_commits_path(project, :'signed-commits')
within '#commits-list' do
expect(page).to have_content 'Unverified'
expect(page).not_to have_content 'Verified'
end
# user adds the gpg key which makes the signature valid
Sidekiq::Testing.inline! do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
visit project_commits_path(project, :'signed-commits')
within '#commits-list' do
expect(page).to have_content 'Unverified'
expect(page).to have_content 'Verified'
end
end
context 'shows popover badges' do
let(:user_1) do
create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard'
end
let(:user_1_key) do
Sidekiq::Testing.inline! do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user_1
end
end
let(:user_2) do
create(:user, email: GpgHelpers::User2.emails.first, username: 'bette.cartwright', name: 'Bette Cartwright').tap do |user|
# secondary, unverified email
create :email, user: user, email: GpgHelpers::User2.emails.last
end
end
let(:user_2_key) do
Sidekiq::Testing.inline! do
create :gpg_key, key: GpgHelpers::User2.public_key, user: user_2
end
end
before do
user = create :user
project.team << [user, :master]
sign_in(user)
end
it 'unverified signature' do
visit project_commits_path(project, :'signed-commits')
within(find('.commit', text: 'signed commit by bette cartwright')) do
click_on 'Unverified'
within '.popover' do
expect(page).to have_content 'This commit was signed with an unverified signature.'
expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
end
end
end
it 'unverified signature: user email does not match the committer email, but is the same user' do
user_2_key
visit project_commits_path(project, :'signed-commits')
within(find('.commit', text: 'signed and authored commit by bette cartwright, different email')) do
click_on 'Unverified'
within '.popover' do
expect(page).to have_content 'This commit was signed with a verified signature, but the committer email is not verified to belong to the same user.'
expect(page).to have_content 'Bette Cartwright'
expect(page).to have_content '@bette.cartwright'
expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
end
end
end
it 'unverified signature: user email does not match the committer email' do
user_2_key
visit project_commits_path(project, :'signed-commits')
within(find('.commit', text: 'signed commit by bette cartwright')) do
click_on 'Unverified'
within '.popover' do
expect(page).to have_content "This commit was signed with a different user's verified signature."
expect(page).to have_content 'Bette Cartwright'
expect(page).to have_content '@bette.cartwright'
expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}"
end
end
end
it 'verified and the gpg user has a gitlab profile' do
user_1_key
visit project_commits_path(project, :'signed-commits')
within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
click_on 'Verified'
within '.popover' do
expect(page).to have_content 'This commit was signed with a verified signature and the committer email is verified to belong to the same user.'
expect(page).to have_content 'Nannie Bernhard'
expect(page).to have_content '@nannie.bernhard'
expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
end
end
end
it "verified and the gpg user's profile doesn't exist anymore" do
user_1_key
visit project_commits_path(project, :'signed-commits')
# wait for the signature to get generated
within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
expect(page).to have_content 'Verified'
end
user_1.destroy!
refresh
within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do
click_on 'Verified'
within '.popover' do
expect(page).to have_content 'This commit was signed with a verified signature and the committer email is verified to belong to the same user.'
expect(page).to have_content 'Nannie Bernhard'
expect(page).to have_content 'nannie.bernhard@example.com'
expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}"
end
end
end
end
end
...@@ -101,12 +101,13 @@ describe('Api', () => { ...@@ -101,12 +101,13 @@ describe('Api', () => {
it('fetches projects with membership when logged in', (done) => { it('fetches projects with membership when logged in', (done) => {
const query = 'dummy query'; const query = 'dummy query';
const options = { unused: 'option' }; const options = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
window.gon.current_user_id = 1; window.gon.current_user_id = 1;
const expectedData = Object.assign({ const expectedData = Object.assign({
search: query, search: query,
per_page: 20, per_page: 20,
membership: true, membership: true,
simple: true,
}, options); }, options);
spyOn(jQuery, 'ajax').and.callFake((request) => { spyOn(jQuery, 'ajax').and.callFake((request) => {
expect(request.url).toEqual(expectedUrl); expect(request.url).toEqual(expectedUrl);
...@@ -124,10 +125,11 @@ describe('Api', () => { ...@@ -124,10 +125,11 @@ describe('Api', () => {
it('fetches projects without membership when not logged in', (done) => { it('fetches projects without membership when not logged in', (done) => {
const query = 'dummy query'; const query = 'dummy query';
const options = { unused: 'option' }; const options = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
const expectedData = Object.assign({ const expectedData = Object.assign({
search: query, search: query,
per_page: 20, per_page: 20,
simple: true,
}, options); }, options);
spyOn(jQuery, 'ajax').and.callFake((request) => { spyOn(jQuery, 'ajax').and.callFake((request) => {
expect(request.url).toEqual(expectedUrl); expect(request.url).toEqual(expectedUrl);
......
import Vue from 'vue';
import bp from '~/breakpoints';
import appComponent from '~/projects_dropdown/components/app.vue';
import eventHub from '~/projects_dropdown/event_hub';
import ProjectsStore from '~/projects_dropdown/store/projects_store';
import ProjectsService from '~/projects_dropdown/service/projects_service';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { currentSession, mockProject, mockRawProject } from '../mock_data';
const createComponent = () => {
gon.api_version = currentSession.apiVersion;
const Component = Vue.extend(appComponent);
const store = new ProjectsStore();
const service = new ProjectsService(currentSession.username);
return mountComponent(Component, {
store,
service,
currentUserName: currentSession.username,
currentProject: currentSession.project,
});
};
const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
if (failed) {
reject(data);
} else {
resolve({
json() {
return data;
},
});
}
});
describe('AppComponent', () => {
describe('computed', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('frequentProjects', () => {
it('should return list of frequently accessed projects from store', () => {
expect(vm.frequentProjects).toBeDefined();
expect(vm.frequentProjects.length).toBe(0);
vm.store.setFrequentProjects([mockProject]);
expect(vm.frequentProjects).toBeDefined();
expect(vm.frequentProjects.length).toBe(1);
});
});
describe('searchProjects', () => {
it('should return list of frequently accessed projects from store', () => {
expect(vm.searchProjects).toBeDefined();
expect(vm.searchProjects.length).toBe(0);
vm.store.setSearchedProjects([mockRawProject]);
expect(vm.searchProjects).toBeDefined();
expect(vm.searchProjects.length).toBe(1);
});
});
});
describe('methods', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('toggleFrequentProjectsList', () => {
it('should toggle props which control visibility of Frequent Projects list from state passed', () => {
vm.toggleFrequentProjectsList(true);
expect(vm.isLoadingProjects).toBeFalsy();
expect(vm.isSearchListVisible).toBeFalsy();
expect(vm.isFrequentsListVisible).toBeTruthy();
vm.toggleFrequentProjectsList(false);
expect(vm.isLoadingProjects).toBeTruthy();
expect(vm.isSearchListVisible).toBeTruthy();
expect(vm.isFrequentsListVisible).toBeFalsy();
});
});
describe('toggleSearchProjectsList', () => {
it('should toggle props which control visibility of Searched Projects list from state passed', () => {
vm.toggleSearchProjectsList(true);
expect(vm.isLoadingProjects).toBeFalsy();
expect(vm.isFrequentsListVisible).toBeFalsy();
expect(vm.isSearchListVisible).toBeTruthy();
vm.toggleSearchProjectsList(false);
expect(vm.isLoadingProjects).toBeTruthy();
expect(vm.isFrequentsListVisible).toBeTruthy();
expect(vm.isSearchListVisible).toBeFalsy();
});
});
describe('toggleLoader', () => {
it('should toggle props which control visibility of list loading animation from state passed', () => {
vm.toggleLoader(true);
expect(vm.isFrequentsListVisible).toBeFalsy();
expect(vm.isSearchListVisible).toBeFalsy();
expect(vm.isLoadingProjects).toBeTruthy();
vm.toggleLoader(false);
expect(vm.isFrequentsListVisible).toBeTruthy();
expect(vm.isSearchListVisible).toBeTruthy();
expect(vm.isLoadingProjects).toBeFalsy();
});
});
describe('fetchFrequentProjects', () => {
it('should set props for loading animation to `true` while frequent projects list is being loaded', () => {
spyOn(vm, 'toggleLoader');
vm.fetchFrequentProjects();
expect(vm.isLocalStorageFailed).toBeFalsy();
expect(vm.toggleLoader).toHaveBeenCalledWith(true);
});
it('should set props for loading animation to `false` and props for frequent projects list to `true` once data is loaded', () => {
const mockData = [mockProject];
spyOn(vm.service, 'getFrequentProjects').and.returnValue(mockData);
spyOn(vm.store, 'setFrequentProjects');
spyOn(vm, 'toggleFrequentProjectsList');
vm.fetchFrequentProjects();
expect(vm.service.getFrequentProjects).toHaveBeenCalled();
expect(vm.store.setFrequentProjects).toHaveBeenCalledWith(mockData);
expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
});
it('should set props for failure message to `true` when method fails to fetch frequent projects list', () => {
spyOn(vm.service, 'getFrequentProjects').and.returnValue(null);
spyOn(vm.store, 'setFrequentProjects');
spyOn(vm, 'toggleFrequentProjectsList');
expect(vm.isLocalStorageFailed).toBeFalsy();
vm.fetchFrequentProjects();
expect(vm.service.getFrequentProjects).toHaveBeenCalled();
expect(vm.store.setFrequentProjects).toHaveBeenCalledWith([]);
expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
expect(vm.isLocalStorageFailed).toBeTruthy();
});
it('should set props for search results list to `true` if search query was already made previously', () => {
spyOn(bp, 'getBreakpointSize').and.returnValue('md');
spyOn(vm.service, 'getFrequentProjects');
spyOn(vm, 'toggleSearchProjectsList');
vm.searchQuery = 'test';
vm.fetchFrequentProjects();
expect(vm.service.getFrequentProjects).not.toHaveBeenCalled();
expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
});
it('should set props for frequent projects list to `true` if search query was already made but screen size is less than 768px', () => {
spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
spyOn(vm, 'toggleSearchProjectsList');
spyOn(vm.service, 'getFrequentProjects');
vm.searchQuery = 'test';
vm.fetchFrequentProjects();
expect(vm.service.getFrequentProjects).toHaveBeenCalled();
expect(vm.toggleSearchProjectsList).not.toHaveBeenCalled();
});
});
describe('fetchSearchedProjects', () => {
const searchQuery = 'test';
it('should perform search with provided search query', (done) => {
const mockData = [mockRawProject];
spyOn(vm, 'toggleLoader');
spyOn(vm, 'toggleSearchProjectsList');
spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise(mockData));
spyOn(vm.store, 'setSearchedProjects');
vm.fetchSearchedProjects(searchQuery);
setTimeout(() => {
expect(vm.searchQuery).toBe(searchQuery);
expect(vm.toggleLoader).toHaveBeenCalledWith(true);
expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery);
expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
expect(vm.store.setSearchedProjects).toHaveBeenCalledWith(mockData);
done();
}, 0);
});
it('should update props for showing search failure', (done) => {
spyOn(vm, 'toggleSearchProjectsList');
spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise({}, true));
vm.fetchSearchedProjects(searchQuery);
setTimeout(() => {
expect(vm.searchQuery).toBe(searchQuery);
expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery);
expect(vm.isSearchFailed).toBeTruthy();
expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
done();
}, 0);
});
});
describe('logCurrentProjectAccess', () => {
it('should log current project access via service', (done) => {
spyOn(vm.service, 'logProjectAccess');
vm.currentProject = mockProject;
vm.logCurrentProjectAccess();
setTimeout(() => {
expect(vm.service.logProjectAccess).toHaveBeenCalledWith(mockProject);
done();
}, 1);
});
});
describe('handleSearchClear', () => {
it('should show frequent projects list when search input is cleared', () => {
spyOn(vm.store, 'clearSearchedProjects');
spyOn(vm, 'toggleFrequentProjectsList');
vm.handleSearchClear();
expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true);
expect(vm.store.clearSearchedProjects).toHaveBeenCalled();
expect(vm.searchQuery).toBe('');
});
});
describe('handleSearchFailure', () => {
it('should show failure message within dropdown', () => {
spyOn(vm, 'toggleSearchProjectsList');
vm.handleSearchFailure();
expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true);
expect(vm.isSearchFailed).toBeTruthy();
});
});
});
describe('created', () => {
it('should bind event listeners on eventHub', (done) => {
spyOn(eventHub, '$on');
createComponent().$mount();
Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('searchProjects', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('searchCleared', jasmine.any(Function));
expect(eventHub.$on).toHaveBeenCalledWith('searchFailed', jasmine.any(Function));
done();
});
});
});
describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', (done) => {
const vm = createComponent();
spyOn(eventHub, '$off');
vm.$mount();
vm.$destroy();
Vue.nextTick(() => {
expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('searchProjects', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('searchCleared', jasmine.any(Function));
expect(eventHub.$off).toHaveBeenCalledWith('searchFailed', jasmine.any(Function));
done();
});
});
});
describe('template', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
it('should render search input', () => {
expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
});
it('should render loading animation', (done) => {
vm.toggleLoader(true);
Vue.nextTick(() => {
const loadingEl = vm.$el.querySelector('.loading-animation');
expect(loadingEl).toBeDefined();
expect(loadingEl.classList.contains('prepend-top-20')).toBeTruthy();
expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe('Loading projects');
done();
});
});
it('should render frequent projects list header', (done) => {
vm.toggleFrequentProjectsList(true);
Vue.nextTick(() => {
const sectionHeaderEl = vm.$el.querySelector('.section-header');
expect(sectionHeaderEl).toBeDefined();
expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited');
done();
});
});
it('should render frequent projects list', (done) => {
vm.toggleFrequentProjectsList(true);
Vue.nextTick(() => {
expect(vm.$el.querySelector('.projects-list-frequent-container')).toBeDefined();
done();
});
});
it('should render searched projects list', (done) => {
vm.toggleSearchProjectsList(true);
Vue.nextTick(() => {
expect(vm.$el.querySelector('.section-header')).toBe(null);
expect(vm.$el.querySelector('.projects-list-search-container')).toBeDefined();
done();
});
});
});
});
import Vue from 'vue';
import projectsListFrequentComponent from '~/projects_dropdown/components/projects_list_frequent.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { mockFrequents } from '../mock_data';
const createComponent = () => {
const Component = Vue.extend(projectsListFrequentComponent);
return mountComponent(Component, {
projects: mockFrequents,
localStorageFailed: false,
});
};
describe('ProjectsListFrequentComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('isListEmpty', () => {
it('should return `true` or `false` representing whether if `projects` is empty of not', () => {
vm.projects = [];
expect(vm.isListEmpty).toBeTruthy();
vm.projects = mockFrequents;
expect(vm.isListEmpty).toBeFalsy();
});
});
describe('listEmptyMessage', () => {
it('should return appropriate empty list message based on value of `localStorageFailed` prop', () => {
vm.localStorageFailed = true;
expect(vm.listEmptyMessage).toBe('This feature requires browser localStorage support');
vm.localStorageFailed = false;
expect(vm.listEmptyMessage).toBe('Projects you visit often will appear here');
});
});
});
describe('template', () => {
it('should render component element with list of projects', (done) => {
vm.projects = mockFrequents;
Vue.nextTick(() => {
expect(vm.$el.classList.contains('projects-list-frequent-container')).toBeTruthy();
expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1);
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(5);
done();
});
});
it('should render component element with empty message', (done) => {
vm.projects = [];
Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1);
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0);
done();
});
});
});
});
import Vue from 'vue';
import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { mockProject } from '../mock_data';
const createComponent = () => {
const Component = Vue.extend(projectsListItemComponent);
return mountComponent(Component, {
projectId: mockProject.id,
projectName: mockProject.name,
namespace: mockProject.namespace,
webUrl: mockProject.webUrl,
avatarUrl: mockProject.avatarUrl,
});
};
describe('ProjectsListItemComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('hasAvatar', () => {
it('should return `true` or `false` if whether avatar is present or not', () => {
vm.avatarUrl = 'path/to/avatar.png';
expect(vm.hasAvatar).toBeTruthy();
vm.avatarUrl = null;
expect(vm.hasAvatar).toBeFalsy();
});
});
describe('highlightedProjectName', () => {
it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => {
vm.matcher = 'lab';
expect(vm.highlightedProjectName).toContain('<b>Lab</b>');
});
it('should return project name as it is if `matcher` is not available', () => {
vm.matcher = null;
expect(vm.highlightedProjectName).toBe(mockProject.name);
});
});
});
describe('template', () => {
it('should render component element', () => {
expect(vm.$el.classList.contains('projects-list-item-container')).toBeTruthy();
expect(vm.$el.querySelectorAll('a').length).toBe(1);
expect(vm.$el.querySelectorAll('.project-item-avatar-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.project-item-metadata-container').length).toBe(1);
expect(vm.$el.querySelectorAll('.project-title').length).toBe(1);
expect(vm.$el.querySelectorAll('.project-namespace').length).toBe(1);
});
});
});
import Vue from 'vue';
import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { mockProject } from '../mock_data';
const createComponent = () => {
const Component = Vue.extend(projectsListSearchComponent);
return mountComponent(Component, {
projects: [mockProject],
matcher: 'lab',
searchFailed: false,
});
};
describe('ProjectsListSearchComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('isListEmpty', () => {
it('should return `true` or `false` representing whether if `projects` is empty of not', () => {
vm.projects = [];
expect(vm.isListEmpty).toBeTruthy();
vm.projects = [mockProject];
expect(vm.isListEmpty).toBeFalsy();
});
});
describe('listEmptyMessage', () => {
it('should return appropriate empty list message based on value of `searchFailed` prop', () => {
vm.searchFailed = true;
expect(vm.listEmptyMessage).toBe('Something went wrong on our end.');
vm.searchFailed = false;
expect(vm.listEmptyMessage).toBe('No projects matched your query');
});
});
});
describe('template', () => {
it('should render component element with list of projects', (done) => {
vm.projects = [mockProject];
Vue.nextTick(() => {
expect(vm.$el.classList.contains('projects-list-search-container')).toBeTruthy();
expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1);
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(1);
done();
});
});
it('should render component element with empty message', (done) => {
vm.projects = [];
Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1);
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0);
done();
});
});
it('should render component element with failure message', (done) => {
vm.searchFailed = true;
vm.projects = [];
Vue.nextTick(() => {
expect(vm.$el.querySelectorAll('li.section-empty.section-failure').length).toBe(1);
expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0);
done();
});
});
});
});
import Vue from 'vue';
import searchComponent from '~/projects_dropdown/components/search.vue';
import eventHub from '~/projects_dropdown/event_hub';
import mountComponent from '../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(searchComponent);
return mountComponent(Component);
};
describe('SearchComponent', () => {
describe('methods', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('setFocus', () => {
it('should set focus to search input', () => {
spyOn(vm.$refs.search, 'focus');
vm.setFocus();
expect(vm.$refs.search.focus).toHaveBeenCalled();
});
});
describe('emitSearchEvents', () => {
it('should emit `searchProjects` event via eventHub when `searchQuery` present', () => {
const searchQuery = 'test';
spyOn(eventHub, '$emit');
vm.searchQuery = searchQuery;
vm.emitSearchEvents();
expect(eventHub.$emit).toHaveBeenCalledWith('searchProjects', searchQuery);
});
it('should emit `searchCleared` event via eventHub when `searchQuery` is cleared', () => {
spyOn(eventHub, '$emit');
vm.searchQuery = '';
vm.emitSearchEvents();
expect(eventHub.$emit).toHaveBeenCalledWith('searchCleared');
});
});
});
describe('mounted', () => {
it('should listen `dropdownOpen` event', (done) => {
spyOn(eventHub, '$on');
createComponent();
Vue.nextTick(() => {
expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
done();
});
});
});
describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', (done) => {
const vm = createComponent();
spyOn(eventHub, '$off');
vm.$mount();
vm.$destroy();
Vue.nextTick(() => {
expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function));
done();
});
});
});
describe('template', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
it('should render component element', () => {
const inputEl = vm.$el.querySelector('input.form-control');
expect(vm.$el.classList.contains('search-input-container')).toBeTruthy();
expect(vm.$el.classList.contains('hidden-xs')).toBeTruthy();
expect(inputEl).not.toBe(null);
expect(inputEl.getAttribute('placeholder')).toBe('Search projects');
expect(vm.$el.querySelector('.search-icon')).toBeDefined();
});
});
});
export const currentSession = {
username: 'root',
storageKey: 'root/frequent-projects',
apiVersion: 'v4',
project: {
id: 1,
name: 'dummy-project',
namespace: 'SamepleGroup / Dummy-Project',
webUrl: 'http://127.0.0.1/samplegroup/dummy-project',
avatarUrl: null,
lastAccessedOn: Date.now(),
},
};
export const mockProject = {
id: 1,
name: 'GitLab Community Edition',
namespace: 'gitlab-org / gitlab-ce',
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
avatarUrl: null,
};
export const mockRawProject = {
id: 1,
name: 'GitLab Community Edition',
name_with_namespace: 'gitlab-org / gitlab-ce',
web_url: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
avatar_url: null,
};
export const mockFrequents = [
{
id: 1,
name: 'GitLab Community Edition',
namespace: 'gitlab-org / gitlab-ce',
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce',
avatarUrl: null,
},
{
id: 2,
name: 'GitLab CI',
namespace: 'gitlab-org / gitlab-ci',
webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ci',
avatarUrl: null,
},
{
id: 3,
name: 'Typeahead.Js',
namespace: 'twitter / typeahead-js',
webUrl: 'http://127.0.0.1:3000/twitter/typeahead-js',
avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png',
},
{
id: 4,
name: 'Intel',
namespace: 'platform / hardware / bsp / intel',
webUrl: 'http://127.0.0.1:3000/platform/hardware/bsp/intel',
avatarUrl: null,
},
{
id: 5,
name: 'v4.4',
namespace: 'platform / hardware / bsp / kernel / common / v4.4',
webUrl: 'http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4',
avatarUrl: null,
},
];
export const unsortedFrequents = [
{ id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
{ id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
{ id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
{ id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
{ id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
{ id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
{ id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
{ id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
{ id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
];
/**
* This const has a specific order which tests authenticity
* of `ProjectsService.getTopFrequentProjects` method so
* DO NOT change order of items in this const.
*/
export const sortedFrequents = [
{ id: 10, frequency: 46, lastAccessedOn: 1483251641543 },
{ id: 3, frequency: 44, lastAccessedOn: 1497675908472 },
{ id: 7, frequency: 42, lastAccessedOn: 1486815299875 },
{ id: 5, frequency: 34, lastAccessedOn: 1488089211943 },
{ id: 8, frequency: 33, lastAccessedOn: 1500762279114 },
{ id: 6, frequency: 14, lastAccessedOn: 1493517292488 },
{ id: 2, frequency: 14, lastAccessedOn: 1488240890738 },
{ id: 1, frequency: 12, lastAccessedOn: 1491400843391 },
{ id: 4, frequency: 8, lastAccessedOn: 1497979281815 },
];
import Vue from 'vue';
import VueResource from 'vue-resource';
import bp from '~/breakpoints';
import ProjectsService from '~/projects_dropdown/service/projects_service';
import { FREQUENT_PROJECTS } from '~/projects_dropdown/constants';
import { currentSession, unsortedFrequents, sortedFrequents } from '../mock_data';
Vue.use(VueResource);
FREQUENT_PROJECTS.MAX_COUNT = 3;
describe('ProjectsService', () => {
let service;
beforeEach(() => {
gon.api_version = currentSession.apiVersion;
service = new ProjectsService(currentSession.username);
});
describe('contructor', () => {
it('should initialize default properties of class', () => {
expect(service.isLocalStorageAvailable).toBeTruthy();
expect(service.currentUserName).toBe(currentSession.username);
expect(service.storageKey).toBe(currentSession.storageKey);
expect(service.projectsPath).toBeDefined();
});
});
describe('getSearchedProjects', () => {
it('should return promise from VueResource HTTP GET', () => {
spyOn(service.projectsPath, 'get').and.stub();
const searchQuery = 'lab';
const queryParams = {
simple: false,
per_page: 20,
membership: false,
order_by: 'last_activity_at',
search: searchQuery,
};
service.getSearchedProjects(searchQuery);
expect(service.projectsPath.get).toHaveBeenCalledWith(queryParams);
});
});
describe('logProjectAccess', () => {
let storage;
beforeEach(() => {
storage = {};
spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => {
storage[storageKey] = value;
});
spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => {
if (storage[storageKey]) {
return storage[storageKey];
}
return null;
});
});
it('should create a project store if it does not exist and adds a project', () => {
service.logProjectAccess(currentSession.project);
const projects = JSON.parse(storage[currentSession.storageKey]);
expect(projects.length).toBe(1);
expect(projects[0].frequency).toBe(1);
expect(projects[0].lastAccessedOn).toBeDefined();
});
it('should prevent inserting same report multiple times into store', () => {
service.logProjectAccess(currentSession.project);
service.logProjectAccess(currentSession.project);
const projects = JSON.parse(storage[currentSession.storageKey]);
expect(projects.length).toBe(1);
});
it('should increase frequency of report if it was logged multiple times over the course of an hour', () => {
let projects;
spyOn(Math, 'abs').and.returnValue(3600001); // this will lead to `diff` > 1;
service.logProjectAccess(currentSession.project);
projects = JSON.parse(storage[currentSession.storageKey]);
expect(projects[0].frequency).toBe(1);
service.logProjectAccess(currentSession.project);
projects = JSON.parse(storage[currentSession.storageKey]);
expect(projects[0].frequency).toBe(2);
expect(projects[0].lastAccessedOn).not.toBe(currentSession.project.lastAccessedOn);
});
it('should always update project metadata', () => {
let projects;
const oldProject = {
...currentSession.project,
};
const newProject = {
...currentSession.project,
name: 'New Name',
avatarUrl: 'new/avatar.png',
namespace: 'New / Namespace',
webUrl: 'http://localhost/new/web/url',
};
service.logProjectAccess(oldProject);
projects = JSON.parse(storage[currentSession.storageKey]);
expect(projects[0].name).toBe(oldProject.name);
expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl);
expect(projects[0].namespace).toBe(oldProject.namespace);
expect(projects[0].webUrl).toBe(oldProject.webUrl);
service.logProjectAccess(newProject);
projects = JSON.parse(storage[currentSession.storageKey]);
expect(projects[0].name).toBe(newProject.name);
expect(projects[0].avatarUrl).toBe(newProject.avatarUrl);
expect(projects[0].namespace).toBe(newProject.namespace);
expect(projects[0].webUrl).toBe(newProject.webUrl);
});
it('should not add more than 20 projects in store', () => {
for (let i = 1; i <= 5; i += 1) {
const project = Object.assign(currentSession.project, { id: i });
service.logProjectAccess(project);
}
const projects = JSON.parse(storage[currentSession.storageKey]);
expect(projects.length).toBe(3);
});
});
describe('getTopFrequentProjects', () => {
let storage = {};
beforeEach(() => {
storage[currentSession.storageKey] = JSON.stringify(unsortedFrequents);
spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => {
if (storage[storageKey]) {
return storage[storageKey];
}
return null;
});
});
it('should return top 5 frequently accessed projects for desktop screens', () => {
spyOn(bp, 'getBreakpointSize').and.returnValue('md');
const frequentProjects = service.getTopFrequentProjects();
expect(frequentProjects.length).toBe(5);
frequentProjects.forEach((project, index) => {
expect(project.id).toBe(sortedFrequents[index].id);
});
});
it('should return top 3 frequently accessed projects for mobile screens', () => {
spyOn(bp, 'getBreakpointSize').and.returnValue('sm');
const frequentProjects = service.getTopFrequentProjects();
expect(frequentProjects.length).toBe(3);
frequentProjects.forEach((project, index) => {
expect(project.id).toBe(sortedFrequents[index].id);
});
});
it('should return empty array if there are no projects available in store', () => {
storage = {};
expect(service.getTopFrequentProjects().length).toBe(0);
});
});
});
import ProjectsStore from '~/projects_dropdown/store/projects_store';
import { mockProject, mockRawProject } from '../mock_data';
describe('ProjectsStore', () => {
let store;
beforeEach(() => {
store = new ProjectsStore();
});
describe('setFrequentProjects', () => {
it('should set frequent projects list to state', () => {
store.setFrequentProjects([mockProject]);
expect(store.getFrequentProjects().length).toBe(1);
expect(store.getFrequentProjects()[0].id).toBe(mockProject.id);
});
});
describe('setSearchedProjects', () => {
it('should set searched projects list to state', () => {
store.setSearchedProjects([mockRawProject]);
const processedProjects = store.getSearchedProjects();
expect(processedProjects.length).toBe(1);
expect(processedProjects[0].id).toBe(mockRawProject.id);
expect(processedProjects[0].namespace).toBe(mockRawProject.name_with_namespace);
expect(processedProjects[0].webUrl).toBe(mockRawProject.web_url);
expect(processedProjects[0].avatarUrl).toBe(mockRawProject.avatar_url);
});
});
describe('clearSearchedProjects', () => {
it('should clear searched projects list from state', () => {
store.setSearchedProjects([mockRawProject]);
expect(store.getSearchedProjects().length).toBe(1);
store.clearSearchedProjects();
expect(store.getSearchedProjects().length).toBe(0);
});
});
});
import Vue from 'vue'; import Vue from 'vue';
import identiconComponent from '~/vue_shared/components/identicon.vue'; import identiconComponent from '~/vue_shared/components/identicon.vue';
const createComponent = () => { const createComponent = (sizeClass) => {
const Component = Vue.extend(identiconComponent); const Component = Vue.extend(identiconComponent);
return new Component({ return new Component({
propsData: { propsData: {
entityId: 1, entityId: 1,
entityName: 'entity-name', entityName: 'entity-name',
sizeClass,
}, },
}).$mount(); }).$mount();
}; };
describe('IdenticonComponent', () => { describe('IdenticonComponent', () => {
let vm; describe('computed', () => {
let vm;
beforeEach(() => { beforeEach(() => {
vm = createComponent(); vm = createComponent();
}); });
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('identiconStyles', () => { describe('identiconStyles', () => {
it('should return styles attribute value with `background-color` property', () => { it('should return styles attribute value with `background-color` property', () => {
vm.entityId = 4; vm.entityId = 4;
...@@ -48,9 +53,20 @@ describe('IdenticonComponent', () => { ...@@ -48,9 +53,20 @@ describe('IdenticonComponent', () => {
describe('template', () => { describe('template', () => {
it('should render identicon', () => { it('should render identicon', () => {
const vm = createComponent();
expect(vm.$el.nodeName).toBe('DIV'); expect(vm.$el.nodeName).toBe('DIV');
expect(vm.$el.classList.contains('identicon')).toBeTruthy(); expect(vm.$el.classList.contains('identicon')).toBeTruthy();
expect(vm.$el.classList.contains('s40')).toBeTruthy();
expect(vm.$el.getAttribute('style').indexOf('background-color') > -1).toBeTruthy(); expect(vm.$el.getAttribute('style').indexOf('background-color') > -1).toBeTruthy();
vm.$destroy();
});
it('should render identicon with provided sizing class', () => {
const vm = createComponent('s32');
expect(vm.$el.classList.contains('s32')).toBeTruthy();
vm.$destroy();
}); });
}); });
}); });
...@@ -2,45 +2,9 @@ require 'rails_helper' ...@@ -2,45 +2,9 @@ require 'rails_helper'
describe Gitlab::Gpg::Commit do describe Gitlab::Gpg::Commit do
describe '#signature' do describe '#signature' do
let!(:project) { create :project, :repository, path: 'sample-project' } shared_examples 'returns the cached signature on second call' do
let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
context 'unsigned commit' do
it 'returns nil' do
expect(described_class.new(project, commit_sha).signature).to be_nil
end
end
context 'known and verified public key' do
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: create(:user, email: GpgHelpers::User1.emails.first)
end
before do
allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
it 'returns a valid signature' do
expect(described_class.new(project, commit_sha).signature).to have_attributes(
commit_sha: commit_sha,
project: project,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: GpgHelpers::User1.names.first,
gpg_key_user_email: GpgHelpers::User1.emails.first,
valid_signature: true
)
end
it 'returns the cached signature on second call' do it 'returns the cached signature on second call' do
gpg_commit = described_class.new(project, commit_sha) gpg_commit = described_class.new(commit)
expect(gpg_commit).to receive(:using_keychain).and_call_original expect(gpg_commit).to receive(:using_keychain).and_call_original
gpg_commit.signature gpg_commit.signature
...@@ -51,11 +15,140 @@ describe Gitlab::Gpg::Commit do ...@@ -51,11 +15,140 @@ describe Gitlab::Gpg::Commit do
end end
end end
context 'known but unverified public key' do let!(:project) { create :project, :repository, path: 'sample-project' }
let!(:gpg_key) { create :gpg_key, key: GpgHelpers::User1.public_key } let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
before do context 'unsigned commit' do
allow(Rugged::Commit).to receive(:extract_signature) let!(:commit) { create :commit, project: project, sha: commit_sha }
it 'returns nil' do
expect(described_class.new(commit).signature).to be_nil
end
end
context 'known key' do
context 'user matches the key uid' do
context 'user email matches the email committer' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first }
let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
it 'returns a valid signature' do
expect(described_class.new(commit).signature).to have_attributes(
commit_sha: commit_sha,
project: project,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: GpgHelpers::User1.names.first,
gpg_key_user_email: GpgHelpers::User1.emails.first,
verification_status: 'verified'
)
end
it_behaves_like 'returns the cached signature on second call'
end
context 'user email does not match the committer email, but is the same user' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User2.emails.first }
let(:user) do
create(:user, email: GpgHelpers::User1.emails.first).tap do |user|
create :email, user: user, email: GpgHelpers::User2.emails.first
end
end
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
it 'returns an invalid signature' do
expect(described_class.new(commit).signature).to have_attributes(
commit_sha: commit_sha,
project: project,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: GpgHelpers::User1.names.first,
gpg_key_user_email: GpgHelpers::User1.emails.first,
verification_status: 'same_user_different_email'
)
end
it_behaves_like 'returns the cached signature on second call'
end
context 'user email does not match the committer email' do
let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User2.emails.first }
let(:user) { create(:user, email: GpgHelpers::User1.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha)
.and_return(
[
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
]
)
end
it 'returns an invalid signature' do
expect(described_class.new(commit).signature).to have_attributes(
commit_sha: commit_sha,
project: project,
gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: GpgHelpers::User1.names.first,
gpg_key_user_email: GpgHelpers::User1.emails.first,
verification_status: 'other_user'
)
end
it_behaves_like 'returns the cached signature on second call'
end
end
context 'user does not match the key uid' do
let!(:commit) { create :commit, project: project, sha: commit_sha }
let(:user) { create(:user, email: GpgHelpers::User2.emails.first) }
let!(:gpg_key) do
create :gpg_key, key: GpgHelpers::User1.public_key, user: user
end
before do
allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha) .with(Rugged::Repository, commit_sha)
.and_return( .and_return(
[ [
...@@ -63,33 +156,27 @@ describe Gitlab::Gpg::Commit do ...@@ -63,33 +156,27 @@ describe Gitlab::Gpg::Commit do
GpgHelpers::User1.signed_commit_base_data GpgHelpers::User1.signed_commit_base_data
] ]
) )
end end
it 'returns an invalid signature' do it 'returns an invalid signature' do
expect(described_class.new(project, commit_sha).signature).to have_attributes( expect(described_class.new(commit).signature).to have_attributes(
commit_sha: commit_sha, commit_sha: commit_sha,
project: project, project: project,
gpg_key: gpg_key, gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: GpgHelpers::User1.names.first, gpg_key_user_name: GpgHelpers::User1.names.first,
gpg_key_user_email: GpgHelpers::User1.emails.first, gpg_key_user_email: GpgHelpers::User1.emails.first,
valid_signature: false verification_status: 'unverified_key'
) )
end end
it 'returns the cached signature on second call' do
gpg_commit = described_class.new(project, commit_sha)
expect(gpg_commit).to receive(:using_keychain).and_call_original
gpg_commit.signature
# consecutive call it_behaves_like 'returns the cached signature on second call'
expect(gpg_commit).not_to receive(:using_keychain).and_call_original
gpg_commit.signature
end end
end end
context 'unknown public key' do context 'unknown key' do
let!(:commit) { create :commit, project: project, sha: commit_sha }
before do before do
allow(Rugged::Commit).to receive(:extract_signature) allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha) .with(Rugged::Repository, commit_sha)
...@@ -102,27 +189,18 @@ describe Gitlab::Gpg::Commit do ...@@ -102,27 +189,18 @@ describe Gitlab::Gpg::Commit do
end end
it 'returns an invalid signature' do it 'returns an invalid signature' do
expect(described_class.new(project, commit_sha).signature).to have_attributes( expect(described_class.new(commit).signature).to have_attributes(
commit_sha: commit_sha, commit_sha: commit_sha,
project: project, project: project,
gpg_key: nil, gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
gpg_key_user_name: nil, gpg_key_user_name: nil,
gpg_key_user_email: nil, gpg_key_user_email: nil,
valid_signature: false verification_status: 'unknown_key'
) )
end end
it 'returns the cached signature on second call' do it_behaves_like 'returns the cached signature on second call'
gpg_commit = described_class.new(project, commit_sha)
expect(gpg_commit).to receive(:using_keychain).and_call_original
gpg_commit.signature
# consecutive call
expect(gpg_commit).not_to receive(:using_keychain).and_call_original
gpg_commit.signature
end
end end
end end
end end
...@@ -4,8 +4,29 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -4,8 +4,29 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
describe '#run' do describe '#run' do
let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
let!(:project) { create :project, :repository, path: 'sample-project' } let!(:project) { create :project, :repository, path: 'sample-project' }
let!(:raw_commit) do
raw_commit = double(
:raw_commit,
signature: [
GpgHelpers::User1.signed_commit_signature,
GpgHelpers::User1.signed_commit_base_data
],
sha: commit_sha,
committer_email: GpgHelpers::User1.emails.first
)
allow(raw_commit).to receive :save!
raw_commit
end
let!(:commit) do
create :commit, git_commit: raw_commit, project: project
end
before do before do
allow_any_instance_of(Project).to receive(:commit).and_return(commit)
allow(Rugged::Commit).to receive(:extract_signature) allow(Rugged::Commit).to receive(:extract_signature)
.with(Rugged::Repository, commit_sha) .with(Rugged::Repository, commit_sha)
.and_return( .and_return(
...@@ -25,7 +46,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -25,7 +46,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: nil, gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: true verification_status: 'verified'
end end
it 'assigns the gpg key to the signature when the missing gpg key is added' do it 'assigns the gpg key to the signature when the missing gpg key is added' do
...@@ -39,7 +60,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -39,7 +60,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: gpg_key, gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: true verification_status: 'verified'
) )
end end
...@@ -54,7 +75,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -54,7 +75,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: nil, gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: true verification_status: 'verified'
) )
end end
end end
...@@ -68,7 +89,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -68,7 +89,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: nil, gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: false verification_status: 'unknown_key'
end end
it 'updates the signature to being valid when the missing gpg key is added' do it 'updates the signature to being valid when the missing gpg key is added' do
...@@ -82,7 +103,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -82,7 +103,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: gpg_key, gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: true verification_status: 'verified'
) )
end end
...@@ -97,7 +118,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -97,7 +118,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: nil, gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: false verification_status: 'unknown_key'
) )
end end
end end
...@@ -115,7 +136,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -115,7 +136,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: nil, gpg_key: nil,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: false verification_status: 'unknown_key'
end end
it 'updates the signature to being valid when the user updates the email address' do it 'updates the signature to being valid when the user updates the email address' do
...@@ -123,7 +144,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -123,7 +144,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
key: GpgHelpers::User1.public_key, key: GpgHelpers::User1.public_key,
user: user user: user
expect(invalid_gpg_signature.reload.valid_signature).to be_falsey expect(invalid_gpg_signature.reload.verification_status).to eq 'unverified_key'
# InvalidGpgSignatureUpdater is called by the after_update hook # InvalidGpgSignatureUpdater is called by the after_update hook
user.update_attributes!(email: GpgHelpers::User1.emails.first) user.update_attributes!(email: GpgHelpers::User1.emails.first)
...@@ -133,7 +154,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -133,7 +154,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: gpg_key, gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: true verification_status: 'verified'
) )
end end
...@@ -147,7 +168,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -147,7 +168,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: gpg_key, gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: false verification_status: 'unverified_key'
) )
# InvalidGpgSignatureUpdater is called by the after_update hook # InvalidGpgSignatureUpdater is called by the after_update hook
...@@ -158,7 +179,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do ...@@ -158,7 +179,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do
commit_sha: commit_sha, commit_sha: commit_sha,
gpg_key: gpg_key, gpg_key: gpg_key,
gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid,
valid_signature: false verification_status: 'unverified_key'
) )
end end
end end
......
...@@ -42,6 +42,21 @@ describe Gitlab::Gpg do ...@@ -42,6 +42,21 @@ describe Gitlab::Gpg do
described_class.user_infos_from_key('bogus') described_class.user_infos_from_key('bogus')
).to eq [] ).to eq []
end end
it 'downcases the email' do
public_key = double(:key)
fingerprints = double(:fingerprints)
uid = double(:uid, name: 'Nannie Bernhard', email: 'NANNIE.BERNHARD@EXAMPLE.COM')
raw_key = double(:raw_key, uids: [uid])
allow(Gitlab::Gpg::CurrentKeyChain).to receive(:fingerprints_from_key).with(public_key).and_return(fingerprints)
allow(GPGME::Key).to receive(:find).with(:public, anything).and_return([raw_key])
user_infos = described_class.user_infos_from_key(public_key)
expect(user_infos).to eq([{
name: 'Nannie Bernhard',
email: 'nannie.bernhard@example.com'
}])
end
end end
describe '.current_home_dir' do describe '.current_home_dir' do
......
...@@ -99,14 +99,14 @@ describe GpgKey do ...@@ -99,14 +99,14 @@ describe GpgKey do
end end
describe '#verified?' do describe '#verified?' do
it 'returns true one of the email addresses in the key belongs to the user' do it 'returns true if one of the email addresses in the key belongs to the user' do
user = create :user, email: 'bette.cartwright@example.com' user = create :user, email: 'bette.cartwright@example.com'
gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
expect(gpg_key.verified?).to be_truthy expect(gpg_key.verified?).to be_truthy
end end
it 'returns false if one of the email addresses in the key does not belong to the user' do it 'returns false if none of the email addresses in the key does not belong to the user' do
user = create :user, email: 'someone.else@example.com' user = create :user, email: 'someone.else@example.com'
gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
...@@ -114,6 +114,32 @@ describe GpgKey do ...@@ -114,6 +114,32 @@ describe GpgKey do
end end
end end
describe 'verified_and_belongs_to_email?' do
it 'returns false if none of the email addresses in the key does not belong to the user' do
user = create :user, email: 'someone.else@example.com'
gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
expect(gpg_key.verified?).to be_falsey
expect(gpg_key.verified_and_belongs_to_email?('someone.else@example.com')).to be_falsey
end
it 'returns false if one of the email addresses in the key belongs to the user and does not match the provided email' do
user = create :user, email: 'bette.cartwright@example.com'
gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
expect(gpg_key.verified?).to be_truthy
expect(gpg_key.verified_and_belongs_to_email?('bette.cartwright@example.net')).to be_falsey
end
it 'returns true if one of the email addresses in the key belongs to the user and matches the provided email' do
user = create :user, email: 'bette.cartwright@example.com'
gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user
expect(gpg_key.verified?).to be_truthy
expect(gpg_key.verified_and_belongs_to_email?('bette.cartwright@example.com')).to be_truthy
end
end
describe 'notification', :mailer do describe 'notification', :mailer do
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -129,15 +155,15 @@ describe GpgKey do ...@@ -129,15 +155,15 @@ describe GpgKey do
describe '#revoke' do describe '#revoke' do
it 'invalidates all associated gpg signatures and destroys the key' do it 'invalidates all associated gpg signatures and destroys the key' do
gpg_key = create :gpg_key gpg_key = create :gpg_key
gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: gpg_key gpg_signature = create :gpg_signature, verification_status: :verified, gpg_key: gpg_key
unrelated_gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key unrelated_gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key
unrelated_gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: unrelated_gpg_key unrelated_gpg_signature = create :gpg_signature, verification_status: :verified, gpg_key: unrelated_gpg_key
gpg_key.revoke gpg_key.revoke
expect(gpg_signature.reload).to have_attributes( expect(gpg_signature.reload).to have_attributes(
valid_signature: false, verification_status: 'unknown_key',
gpg_key: nil gpg_key: nil
) )
...@@ -145,7 +171,7 @@ describe GpgKey do ...@@ -145,7 +171,7 @@ describe GpgKey do
# unrelated signature is left untouched # unrelated signature is left untouched
expect(unrelated_gpg_signature.reload).to have_attributes( expect(unrelated_gpg_signature.reload).to have_attributes(
valid_signature: true, verification_status: 'verified',
gpg_key: unrelated_gpg_key gpg_key: unrelated_gpg_key
) )
......
...@@ -2102,4 +2102,18 @@ describe User do ...@@ -2102,4 +2102,18 @@ describe User do
end end
end end
end end
describe '#verified_email?' do
it 'returns true when the email is the primary email' do
user = build :user, email: 'email@example.com'
expect(user.verified_email?('email@example.com')).to be true
end
it 'returns false when the email is not the primary email' do
user = build :user, email: 'email@example.com'
expect(user.verified_email?('other_email@example.com')).to be false
end
end
end end
...@@ -75,6 +75,22 @@ describe API::Branches do ...@@ -75,6 +75,22 @@ describe API::Branches do
let(:route) { "/projects/#{project_id}/repository/branches/#{branch_name}" } let(:route) { "/projects/#{project_id}/repository/branches/#{branch_name}" }
shared_examples_for 'repository branch' do shared_examples_for 'repository branch' do
context 'HEAD request' do
it 'returns 204 No Content' do
head api(route, user)
expect(response).to have_gitlab_http_status(204)
expect(response.body).to be_empty
end
it 'returns 404 Not Found' do
head api("/projects/#{project_id}/repository/branches/unknown", user)
expect(response).to have_gitlab_http_status(404)
expect(response.body).to be_empty
end
end
it 'returns the repository branch' do it 'returns the repository branch' do
get api(route, current_user) get api(route, current_user)
......
...@@ -138,6 +138,16 @@ describe API::Issues, :mailer do ...@@ -138,6 +138,16 @@ describe API::Issues, :mailer do
expect(first_issue['id']).to eq(issue2.id) expect(first_issue['id']).to eq(issue2.id)
end end
it 'returns issues reacted by the authenticated user by the given emoji' do
issue2 = create(:issue, project: project, author: user, assignees: [user])
award_emoji = create(:award_emoji, awardable: issue2, user: user2, name: 'star')
get api('/issues', user2), my_reaction_emoji: award_emoji.name, scope: 'all'
expect_paginated_array_response(size: 1)
expect(first_issue['id']).to eq(issue2.id)
end
it 'returns issues matching given search string for title' do it 'returns issues matching given search string for title' do
get api("/issues", user), search: issue.title get api("/issues", user), search: issue.title
......
...@@ -117,6 +117,18 @@ describe API::MergeRequests do ...@@ -117,6 +117,18 @@ describe API::MergeRequests do
expect(json_response.length).to eq(1) expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(merge_request3.id) expect(json_response.first['id']).to eq(merge_request3.id)
end end
it 'returns merge requests reacted by the authenticated user by the given emoji' do
merge_request3 = create(:merge_request, :simple, author: user, assignee: user, source_project: project2, target_project: project2, source_branch: 'other-branch')
award_emoji = create(:award_emoji, awardable: merge_request3, user: user2, name: 'star')
get api('/merge_requests', user2), my_reaction_emoji: award_emoji.name, scope: 'all'
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(merge_request3.id)
end
end end
end end
......
...@@ -5,7 +5,7 @@ module TestEnv ...@@ -5,7 +5,7 @@ module TestEnv
# When developing the seed repository, comment out the branch you will modify. # When developing the seed repository, comment out the branch you will modify.
BRANCH_SHA = { BRANCH_SHA = {
'signed-commits' => '5d4a1cb', 'signed-commits' => '2d1096e',
'not-merged-branch' => 'b83d6e3', 'not-merged-branch' => 'b83d6e3',
'branch-merged' => '498214d', 'branch-merged' => '498214d',
'empty-branch' => '7efb185', 'empty-branch' => '7efb185',
......
...@@ -7,9 +7,14 @@ describe CreateGpgSignatureWorker do ...@@ -7,9 +7,14 @@ describe CreateGpgSignatureWorker do
let(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } let(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' }
it 'calls Gitlab::Gpg::Commit#signature' do it 'calls Gitlab::Gpg::Commit#signature' do
expect(Gitlab::Gpg::Commit).to receive(:new).with(project, commit_sha).and_call_original commit = instance_double(Commit)
gpg_commit = instance_double(Gitlab::Gpg::Commit)
expect_any_instance_of(Gitlab::Gpg::Commit).to receive(:signature) allow(Project).to receive(:find_by).with(id: project.id).and_return(project)
allow(project).to receive(:commit).with(commit_sha).and_return(commit)
expect(Gitlab::Gpg::Commit).to receive(:new).with(commit).and_return(gpg_commit)
expect(gpg_commit).to receive(:signature)
described_class.new.perform(commit_sha, project.id) described_class.new.perform(commit_sha, project.id)
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment