Commit 87cd46c4 authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce

parents 941cfd1f 59c735bd
Please view this file on the master branch, on stable branches it's out of date. Please view this file on the master branch, on stable branches it's out of date.
v 8.7.0 (unreleased) v 8.7.0 (unreleased)
- All service classes (those residing in app/services) are now instrumented (Yorick Peterse)
- Developers can now add custom tags to transactions (Yorick Peterse)
- Enable gzip for assets, makes the page size significantly smaller. !3544 / !3632 (Connor Shea) - Enable gzip for assets, makes the page size significantly smaller. !3544 / !3632 (Connor Shea)
- Load award emoji images separately unless opening the full picker. Saves several hundred KBs of data for most pages. (Connor Shea) - Load award emoji images separately unless opening the full picker. Saves several hundred KBs of data for most pages. (Connor Shea)
- All images in discussions and wikis now link to their source files !3464 (Connor Shea). - All images in discussions and wikis now link to their source files !3464 (Connor Shea).
- Return status code 303 after a branch DELETE operation to avoid project deletion (Stan Hu) - Return status code 303 after a branch DELETE operation to avoid project deletion (Stan Hu)
- Add setting for customizing the list of trusted proxies !3524
- Fix `signed_in_ip` being set to 127.0.0.1 when using a reverse proxy !3524
- Improved Markdown rendering performance !3389 (Yorick Peterse) - Improved Markdown rendering performance !3389 (Yorick Peterse)
- Don't attempt to look up an avatar in repo if repo directory does not exist (Stan Hu) - Don't attempt to look up an avatar in repo if repo directory does not exist (Stan Hu)
- Expose project badges in project settings - Expose project badges in project settings
...@@ -12,6 +16,8 @@ v 8.7.0 (unreleased) ...@@ -12,6 +16,8 @@ v 8.7.0 (unreleased)
- Make HTTP(s) label consistent on clone bar (Stan Hu) - Make HTTP(s) label consistent on clone bar (Stan Hu)
- Expose label description in API (Mariusz Jachimowicz) - Expose label description in API (Mariusz Jachimowicz)
- Allow back dating on issues when created through the API - Allow back dating on issues when created through the API
- API: Ability to update a group (Robert Schilling)
- API: Ability to move issues (Robert Schilling)
- Fix Error 500 after renaming a project path (Stan Hu) - Fix Error 500 after renaming a project path (Stan Hu)
- Fix avatar stretching by providing a cropping feature - Fix avatar stretching by providing a cropping feature
- API: Expose `subscribed` for issues and merge requests (Robert Schilling) - API: Expose `subscribed` for issues and merge requests (Robert Schilling)
...@@ -21,22 +27,34 @@ v 8.7.0 (unreleased) ...@@ -21,22 +27,34 @@ v 8.7.0 (unreleased)
- Handle nil descriptions in Slack issue messages (Stan Hu) - Handle nil descriptions in Slack issue messages (Stan Hu)
- API: Expose open_issues_count, closed_issues_count, open_merge_requests_count for labels (Robert Schilling) - API: Expose open_issues_count, closed_issues_count, open_merge_requests_count for labels (Robert Schilling)
- Add default scope to projects to exclude projects pending deletion - Add default scope to projects to exclude projects pending deletion
- Allow to close merge requests which source projects(forks) are deleted.
- Ensure empty recipients are rejected in BuildsEmailService - Ensure empty recipients are rejected in BuildsEmailService
- API: Ability to filter milestones by state `active` and `closed` (Robert Schilling) - API: Ability to filter milestones by state `active` and `closed` (Robert Schilling)
- API: Fix milestone filtering by `iid` (Robert Schilling) - API: Fix milestone filtering by `iid` (Robert Schilling)
- API: Delete notes of issues, snippets, and merge requests (Robert Schilling)
- Implement 'Groups View' as an option for dashboard preferences !3379 (Elias W.) - Implement 'Groups View' as an option for dashboard preferences !3379 (Elias W.)
- Better errors handling when creating milestones inside groups - Better errors handling when creating milestones inside groups
- Hide `Create a group` help block when creating a new project in a group
- Implement 'TODOs View' as an option for dashboard preferences !3379 (Elias W.) - Implement 'TODOs View' as an option for dashboard preferences !3379 (Elias W.)
- Gracefully handle notes on deleted commits in merge requests (Stan Hu) - Gracefully handle notes on deleted commits in merge requests (Stan Hu)
- Decouple membership and notifications
- Fix creation of merge requests for orphaned branches (Stan Hu) - Fix creation of merge requests for orphaned branches (Stan Hu)
- API: Ability to retrieve a single tag (Robert Schilling)
- Fall back to `In-Reply-To` and `References` headers when sub-addressing is not available (David Padilla) - Fall back to `In-Reply-To` and `References` headers when sub-addressing is not available (David Padilla)
- Remove "Congratulations!" tweet button on newly-created project. (Connor Shea) - Remove "Congratulations!" tweet button on newly-created project. (Connor Shea)
- Fix admin/projects when using visibility levels on search (PotHix) - Fix admin/projects when using visibility levels on search (PotHix)
- Build status notifications - Build status notifications
- API: Expose user location (Robert Schilling) - API: Expose user location (Robert Schilling)
- API: Do not leak group existence via return code (Robert Schilling)
- ClosingIssueExtractor regex now also works with colons. e.g. "Fixes: #1234" !3591 - ClosingIssueExtractor regex now also works with colons. e.g. "Fixes: #1234" !3591
- Update number of Todos in the sidebar when it's marked as "Done". !3600 - Update number of Todos in the sidebar when it's marked as "Done". !3600
- API: Expose 'updated_at' for issue, snippet, and merge request notes (Robert Schilling) - API: Expose 'updated_at' for issue, snippet, and merge request notes (Robert Schilling)
- API: User can leave a project through the API when not master or owner. !3613
- Fix repository cache invalidation issue when project is recreated with an empty repo (Stan Hu)
v 8.6.6
- Fix error on language detection when repository has no HEAD (e.g., master branch). !3654 (Jeroen Bobbeldijk)
- Project switcher uses new dropdown styling
v 8.6.5 v 8.6.5
- Fix importing from GitHub Enterprise. !3529 - Fix importing from GitHub Enterprise. !3529
......
...@@ -285,9 +285,9 @@ group :development, :test do ...@@ -285,9 +285,9 @@ group :development, :test do
gem 'teaspoon', '~> 1.1.0' gem 'teaspoon', '~> 1.1.0'
gem 'teaspoon-jasmine', '~> 2.2.0' gem 'teaspoon-jasmine', '~> 2.2.0'
gem 'spring', '~> 1.6.4' gem 'spring', '~> 1.7.0'
gem 'spring-commands-rspec', '~> 1.0.4' gem 'spring-commands-rspec', '~> 1.0.4'
gem 'spring-commands-spinach', '~> 1.0.0' gem 'spring-commands-spinach', '~> 1.1.0'
gem 'spring-commands-teaspoon', '~> 0.0.2' gem 'spring-commands-teaspoon', '~> 0.0.2'
gem 'rubocop', '~> 0.38.0', require: false gem 'rubocop', '~> 0.38.0', require: false
......
...@@ -769,10 +769,10 @@ GEM ...@@ -769,10 +769,10 @@ GEM
spinach (>= 0.4) spinach (>= 0.4)
spinach-rerun-reporter (0.0.2) spinach-rerun-reporter (0.0.2)
spinach (~> 0.8) spinach (~> 0.8)
spring (1.6.4) spring (1.7.1)
spring-commands-rspec (1.0.4) spring-commands-rspec (1.0.4)
spring (>= 0.9.1) spring (>= 0.9.1)
spring-commands-spinach (1.0.0) spring-commands-spinach (1.1.0)
spring (>= 0.9.1) spring (>= 0.9.1)
spring-commands-teaspoon (0.0.2) spring-commands-teaspoon (0.0.2)
spring (>= 0.9.1) spring (>= 0.9.1)
...@@ -1030,9 +1030,9 @@ DEPENDENCIES ...@@ -1030,9 +1030,9 @@ DEPENDENCIES
slack-notifier (~> 1.2.0) slack-notifier (~> 1.2.0)
spinach-rails (~> 0.2.1) spinach-rails (~> 0.2.1)
spinach-rerun-reporter (~> 0.0.2) spinach-rerun-reporter (~> 0.0.2)
spring (~> 1.6.4) spring (~> 1.7.0)
spring-commands-rspec (~> 1.0.4) spring-commands-rspec (~> 1.0.4)
spring-commands-spinach (~> 1.0.0) spring-commands-spinach (~> 1.1.0)
spring-commands-teaspoon (~> 0.0.2) spring-commands-teaspoon (~> 0.0.2)
sprockets (~> 3.6.0) sprockets (~> 3.6.0)
state_machines-activerecord (~> 0.3.0) state_machines-activerecord (~> 0.3.0)
......
...@@ -41,6 +41,7 @@ ...@@ -41,6 +41,7 @@
#= require shortcuts_issuable #= require shortcuts_issuable
#= require shortcuts_network #= require shortcuts_network
#= require jquery.nicescroll #= require jquery.nicescroll
#= require date.format
#= require_tree . #= require_tree .
#= require fuzzaldrin-plus #= require fuzzaldrin-plus
#= require cropper #= require cropper
...@@ -163,7 +164,7 @@ $ -> ...@@ -163,7 +164,7 @@ $ ->
$('.trigger-submit').on 'change', -> $('.trigger-submit').on 'change', ->
$(@).parents('form').submit() $(@).parents('form').submit()
$('abbr.timeago, .js-timeago').timeago() gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), false)
# Flash # Flash
if (flash = $(".flash-container")).length > 0 if (flash = $(".flash-container")).length > 0
......
...@@ -35,4 +35,18 @@ $.fn.requiresInput = -> ...@@ -35,4 +35,18 @@ $.fn.requiresInput = ->
$form.on 'change input', fieldSelector, requireInput $form.on 'change input', fieldSelector, requireInput
$ -> $ ->
$('form.js-requires-input').requiresInput() $form = $('form.js-requires-input')
$form.requiresInput()
# Hide or Show the help block when creating a new project
# based on the option selected
hideOrShowHelpBlock = (form) ->
selected = $('.js-select-namespace option:selected')
if selected.length and selected.data('options-parent') is 'groups'
return form.find('.help-block').hide()
else if selected.length
form.find('.help-block').show()
hideOrShowHelpBlock($form)
$('.select2.js-select-namespace').change -> hideOrShowHelpBlock($form)
...@@ -122,7 +122,9 @@ class GitLabDropdown ...@@ -122,7 +122,9 @@ class GitLabDropdown
FILTER_INPUT = '.dropdown-input .dropdown-input-field' FILTER_INPUT = '.dropdown-input .dropdown-input-field'
constructor: (@el, @options) -> constructor: (@el, @options) ->
@dropdown = $(@el).parent() self = @
selector = $(@el).data "target"
@dropdown = if selector? then $(selector) else $(@el).parent()
# Set Defaults # Set Defaults
{ {
......
((w) ->
w.gl ?= {}
w.gl.utils ?= {}
w.gl.utils.formatDate = (datetime) ->
dateFormat(datetime, 'mmm d, yyyy h:MMtt Z')
w.gl.utils.localTimeAgo = ($timeagoEls, setTimeago = true) ->
$timeagoEls.each( ->
$el = $(@)
$el.attr('title', gl.utils.formatDate($el.attr('datetime')))
)
$timeagoEls.timeago() if setTimeago
) window
...@@ -2,6 +2,11 @@ ...@@ -2,6 +2,11 @@
notificationGranted = (message, opts, onclick) -> notificationGranted = (message, opts, onclick) ->
notification = new Notification(message, opts) notification = new Notification(message, opts)
# Hide the notification after X amount of seconds
setTimeout ->
notification.close()
, 8000
if onclick if onclick
notification.onclick = onclick notification.onclick = onclick
......
...@@ -142,7 +142,7 @@ class @MergeRequestTabs ...@@ -142,7 +142,7 @@ class @MergeRequestTabs
url: "#{source}.json" url: "#{source}.json"
success: (data) => success: (data) =>
document.querySelector("div#commits").innerHTML = data.html document.querySelector("div#commits").innerHTML = data.html
$('.js-timeago').timeago() gl.utils.localTimeAgo($('.js-timeago', 'div#commits'))
@commitsLoaded = true @commitsLoaded = true
@scrollToElement("#commits") @scrollToElement("#commits")
...@@ -153,7 +153,7 @@ class @MergeRequestTabs ...@@ -153,7 +153,7 @@ class @MergeRequestTabs
url: "#{source}.json" + @_location.search url: "#{source}.json" + @_location.search
success: (data) => success: (data) =>
document.querySelector("div#diffs").innerHTML = data.html document.querySelector("div#diffs").innerHTML = data.html
$('.js-timeago').timeago() gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'))
$('div#diffs .js-syntax-highlight').syntaxHighlight() $('div#diffs .js-syntax-highlight').syntaxHighlight()
@expandViewContainer() if @diffViewType() is 'parallel' @expandViewContainer() if @diffViewType() is 'parallel'
@diffsLoaded = true @diffsLoaded = true
...@@ -166,7 +166,7 @@ class @MergeRequestTabs ...@@ -166,7 +166,7 @@ class @MergeRequestTabs
url: "#{source}.json" url: "#{source}.json"
success: (data) => success: (data) =>
document.querySelector("div#builds").innerHTML = data.html document.querySelector("div#builds").innerHTML = data.html
$('.js-timeago').timeago() gl.utils.localTimeAgo($('.js-timeago', 'div#builds'))
@buildsLoaded = true @buildsLoaded = true
@scrollToElement("#builds") @scrollToElement("#builds")
......
...@@ -12,10 +12,19 @@ class @MergeRequestWidget ...@@ -12,10 +12,19 @@ class @MergeRequestWidget
@readyForCICheck = true @readyForCICheck = true
clearInterval @fetchBuildStatusInterval clearInterval @fetchBuildStatusInterval
@clearEventListeners()
@addEventListeners()
@pollCIStatus() @pollCIStatus()
notifyPermissions() notifyPermissions()
setOpts: (@opts) -> clearEventListeners: ->
$(document).off 'page:change.merge_request'
addEventListeners: ->
$(document).on 'page:change.merge_request', =>
if $('body').data('page') isnt 'projects:merge_requests:show'
clearInterval @fetchBuildStatusInterval
@clearEventListeners()
mergeInProgress: (deleteSourceBranch = false)-> mergeInProgress: (deleteSourceBranch = false)->
$.ajax $.ajax
...@@ -38,7 +47,7 @@ class @MergeRequestWidget ...@@ -38,7 +47,7 @@ class @MergeRequestWidget
$('.mr-state-widget').replaceWith(data) $('.mr-state-widget').replaceWith(data)
ciLabelForStatus: (status) -> ciLabelForStatus: (status) ->
if status == 'success' if status is 'success'
'passed' 'passed'
else else
status status
...@@ -67,18 +76,28 @@ class @MergeRequestWidget ...@@ -67,18 +76,28 @@ class @MergeRequestWidget
@opts.ci_status = data.status @opts.ci_status = data.status
return return
if data.status isnt @opts.ci_status if data.status isnt @opts.ci_status and data.status?
@showCIStatus data.status @showCIStatus data.status
if data.coverage if data.coverage
@showCICoverage data.coverage @showCICoverage data.coverage
if showNotification if showNotification
message = @opts.ci_message.replace('{{status}}', @ciLabelForStatus(data.status)) status = @ciLabelForStatus(data.status)
if status is "preparing"
title = @opts.ci_title.preparing
status = status.charAt(0).toUpperCase() + status.slice(1);
message = @opts.ci_message.preparing.replace('{{status}}', status)
else
title = @opts.ci_title.normal
message = @opts.ci_message.normal.replace('{{status}}', status)
title = title.replace('{{status}}', status)
message = message.replace('{{sha}}', data.sha) message = message.replace('{{sha}}', data.sha)
message = message.replace('{{title}}', data.title) message = message.replace('{{title}}', data.title)
notify( notify(
"Build #{@ciLabelForStatus(data.status)}", title,
message, message,
@opts.gitlab_icon, @opts.gitlab_icon,
-> ->
...@@ -98,6 +117,8 @@ class @MergeRequestWidget ...@@ -98,6 +117,8 @@ class @MergeRequestWidget
@setMergeButtonClass('btn-danger') @setMergeButtonClass('btn-danger')
when "running", "pending" when "running", "pending"
@setMergeButtonClass('btn-warning') @setMergeButtonClass('btn-warning')
when "success"
@setMergeButtonClass('btn-create')
else else
$('.ci_widget.ci-error').show() $('.ci_widget.ci-error').show()
@setMergeButtonClass('btn-danger') @setMergeButtonClass('btn-danger')
...@@ -107,4 +128,6 @@ class @MergeRequestWidget ...@@ -107,4 +128,6 @@ class @MergeRequestWidget
$('.ci_widget:visible .ci-coverage').text(text) $('.ci_widget:visible .ci-coverage').text(text)
setMergeButtonClass: (css_class) -> setMergeButtonClass: (css_class) ->
$('.accept_merge_request').removeClass("btn-create").addClass(css_class) $('.accept_merge_request')
.removeClass('btn-danger btn-warning btn-create')
.addClass(css_class)
...@@ -163,9 +163,15 @@ class @Notes ...@@ -163,9 +163,15 @@ class @Notes
else if @isNewNote(note) else if @isNewNote(note)
@note_ids.push(note.id) @note_ids.push(note.id)
$('ul.main-notes-list') $notesList = $('ul.main-notes-list')
$notesList
.append(note.html) .append(note.html)
.syntaxHighlight() .syntaxHighlight()
# Update datetime format on the recent note
gl.utils.localTimeAgo($notesList.find("#note_#{note.id} .js-timeago"), false)
@initTaskList() @initTaskList()
@updateNotesCount(1) @updateNotesCount(1)
...@@ -217,6 +223,8 @@ class @Notes ...@@ -217,6 +223,8 @@ class @Notes
# append new note to all matching discussions # append new note to all matching discussions
discussionContainer.append note_html discussionContainer.append note_html
gl.utils.localTimeAgo($('.js-timeago', note_html), false)
@updateNotesCount(1) @updateNotesCount(1)
### ###
...@@ -345,7 +353,9 @@ class @Notes ...@@ -345,7 +353,9 @@ class @Notes
updateNote: (_xhr, note, _status) => updateNote: (_xhr, note, _status) =>
# Convert returned HTML to a jQuery object so we can modify it further # Convert returned HTML to a jQuery object so we can modify it further
$html = $(note.html) $html = $(note.html)
$('.js-timeago', $html).timeago()
gl.utils.localTimeAgo($('.js-timeago', $html))
$html.syntaxHighlight() $html.syntaxHighlight()
$html.find('.js-task-list-container').taskList('enable') $html.find('.js-task-list-container').taskList('enable')
......
...@@ -18,8 +18,11 @@ class @Profile ...@@ -18,8 +18,11 @@ class @Profile
$(this).find('.btn-save').enable() $(this).find('.btn-save').enable()
$(this).find('.loading-gif').hide() $(this).find('.loading-gif').hide()
$('.update-notifications').on 'ajax:complete', -> $('.update-notifications').on 'ajax:success', (e, data) ->
$(this).find('.btn-save').enable() if data.saved
new Flash("Notification settings saved", "notice")
else
new Flash("Failed to save new settings", "alert")
@bindEvents() @bindEvents()
......
...@@ -37,19 +37,20 @@ class @Project ...@@ -37,19 +37,20 @@ class @Project
$('.update-notification').on 'click', (e) -> $('.update-notification').on 'click', (e) ->
e.preventDefault() e.preventDefault()
notification_level = $(@).data 'notification-level' notification_level = $(@).data 'notification-level'
$('#notification_level').val(notification_level) label = $(@).data 'notification-title'
$('#notification_setting_level').val(notification_level)
$('#notification-form').submit() $('#notification-form').submit()
label = null
switch notification_level
when 0 then label = ' Disabled '
when 1 then label = ' Participating '
when 2 then label = ' Watching '
when 3 then label = ' Global '
when 4 then label = ' On Mention '
$('#notifications-button').empty().append("<i class='fa fa-bell'></i>" + label + "<i class='fa fa-angle-down'></i>") $('#notifications-button').empty().append("<i class='fa fa-bell'></i>" + label + "<i class='fa fa-angle-down'></i>")
$(@).parents('ul').find('li.active').removeClass 'active' $(@).parents('ul').find('li.active').removeClass 'active'
$(@).parent().addClass 'active' $(@).parent().addClass 'active'
$('#notification-form').on 'ajax:success', (e, data) ->
if data.saved
new Flash("Notification settings saved", "notice")
else
new Flash("Failed to save new settings", "alert")
@projectSelectDropdown() @projectSelectDropdown()
projectSelectDropdown: -> projectSelectDropdown: ->
......
class @ProjectSelect class @ProjectSelect
constructor: -> constructor: ->
$('.js-projects-dropdown-toggle').each (i, dropdown) ->
$dropdown = $(dropdown)
$dropdown.glDropdown(
filterable: true
filterRemote: true
search:
fields: ['name_with_namespace']
data: (term, callback) ->
finalCallback = (projects) ->
callback projects
if @includeGroups
projectsCallback = (projects) ->
groupsCallback = (groups) ->
data = groups.concat(projects)
finalCallback(data)
Api.groups term, false, groupsCallback
else
projectsCallback = finalCallback
if @groupId
Api.groupProjects @groupId, term, projectsCallback
else
Api.projects term, @orderBy, projectsCallback
url: (project) ->
project.web_url
text: (project) ->
project.name_with_namespace
)
$('.ajax-project-select').each (i, select) -> $('.ajax-project-select').each (i, select) ->
@groupId = $(select).data('group-id') @groupId = $(select).data('group-id')
@includeGroups = $(select).data('include-groups') @includeGroups = $(select).data('include-groups')
......
...@@ -59,6 +59,8 @@ class @Todos ...@@ -59,6 +59,8 @@ class @Todos
goToTodoUrl: (e)-> goToTodoUrl: (e)->
todoLink = $(this).data('url') todoLink = $(this).data('url')
return unless todoLink
if e.metaKey if e.metaKey
e.preventDefault() e.preventDefault()
window.open(todoLink,'_blank') window.open(todoLink,'_blank')
......
...@@ -69,6 +69,7 @@ header { ...@@ -69,6 +69,7 @@ header {
} }
.header-content { .header-content {
position: relative;
height: $header-height; height: $header-height;
padding-right: 20px; padding-right: 20px;
...@@ -76,6 +77,10 @@ header { ...@@ -76,6 +77,10 @@ header {
padding-right: 0; padding-right: 0;
} }
.dropdown-menu {
margin-top: -5px;
}
.title { .title {
margin: 0; margin: 0;
font-size: 19px; font-size: 19px;
......
...@@ -58,12 +58,12 @@ ...@@ -58,12 +58,12 @@
.nav-search { .nav-search {
display: inline-block; display: inline-block;
width: 50%; width: 100%;
padding: 11px 0; padding: 11px 0;
/* Small devices (phones, tablets, 768px and lower) */ /* Small devices (phones, tablets, 768px and lower) */
@media (max-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
width: 100%; width: 50%;
} }
} }
......
...@@ -28,6 +28,7 @@ $gl-link-color: #3084bb; ...@@ -28,6 +28,7 @@ $gl-link-color: #3084bb;
$gl-dark-link-color: #333; $gl-dark-link-color: #333;
$gl-placeholder-color: #8f8f8f; $gl-placeholder-color: #8f8f8f;
$gl-icon-color: $gl-placeholder-color; $gl-icon-color: $gl-placeholder-color;
$gl-grayish-blue: #7f8fa4;
$gl-gray: $gl-text-color; $gl-gray: $gl-text-color;
$gl-header-color: $gl-title-color; $gl-header-color: $gl-title-color;
......
...@@ -41,8 +41,17 @@ ...@@ -41,8 +41,17 @@
word-wrap: break-word; word-wrap: break-word;
.md { .md {
color: #7f8fa4; color: $gl-grayish-blue;
font-size: $gl-font-size; font-size: $gl-font-size;
.label {
color: $gl-text-color;
font-size: inherit;
}
iframe.twitter-share-button {
vertical-align: bottom;
}
} }
pre { pre {
......
...@@ -263,6 +263,12 @@ ...@@ -263,6 +263,12 @@
} }
} }
.dropdown-content {
a:hover {
color: inherit;
}
}
.dropdown-menu-toggle { .dropdown-menu-toggle {
width: 100%; width: 100%;
padding-top: 6px; padding-top: 6px;
......
...@@ -142,6 +142,7 @@ ...@@ -142,6 +142,7 @@
overflow: hidden; overflow: hidden;
font-size: 90%; font-size: 90%;
margin: 0 3px; margin: 0 3px;
word-break: break-all;
} }
.mr-list { .mr-list {
......
...@@ -71,12 +71,24 @@ ...@@ -71,12 +71,24 @@
border-color: $focus-border-color; border-color: $focus-border-color;
} }
} }
p {
code {
white-space: normal;
}
pre {
code {
white-space: pre;
}
}
}
} }
} }
.discussion-form { .discussion-form {
padding: $gl-padding-top $gl-padding; padding: $gl-padding-top $gl-padding;
background-color: #fff; background-color: $white-light;
} }
.note-edit-form { .note-edit-form {
......
...@@ -81,11 +81,17 @@ ul.notes { ...@@ -81,11 +81,17 @@ ul.notes {
@include md-typography; @include md-typography;
// On diffs code should wrap nicely and not overflow // On diffs code should wrap nicely and not overflow
p {
code {
white-space: normal;
}
pre { pre {
code { code {
white-space: pre; white-space: pre;
} }
} }
}
// Reset ul style types since we're nested inside a ul already // Reset ul style types since we're nested inside a ul already
& > ul { & > ul {
...@@ -112,6 +118,10 @@ ul.notes { ...@@ -112,6 +118,10 @@ ul.notes {
margin: 10px 0; margin: 10px 0;
} }
} }
a {
word-break: break-all;
}
} }
.note-header { .note-header {
...@@ -127,7 +137,7 @@ ul.notes { ...@@ -127,7 +137,7 @@ ul.notes {
margin-right: 10px; margin-right: 10px;
} }
.line_content { .line_content {
white-space: pre-wrap; white-space: pre;
} }
} }
...@@ -145,19 +155,27 @@ ul.notes { ...@@ -145,19 +155,27 @@ ul.notes {
background: $background-color; background: $background-color;
color: $text-color; color: $text-color;
} }
&.notes_line2 { &.notes_line2 {
text-align: center; text-align: center;
padding: 10px 0; padding: 10px 0;
border-left: 1px solid #ddd !important; border-left: 1px solid #ddd !important;
} }
&.notes_content { &.notes_content {
background-color: #fff; background-color: $background-color;
border-width: 1px 0; border-width: 1px 0;
padding: 0; padding: 0;
vertical-align: top; vertical-align: top;
white-space: normal;
&.parallel { &.parallel {
border-width: 1px; border-width: 1px;
} }
.notes {
background-color: $white-light;
}
} }
} }
} }
......
...@@ -34,6 +34,11 @@ ...@@ -34,6 +34,11 @@
color: #7f8fa4; color: #7f8fa4;
font-size: $gl-font-size; font-size: $gl-font-size;
.label {
color: $gl-text-color;
font-size: inherit;
}
p { p {
color: #5c5d5e; color: #5c5d5e;
} }
......
class Groups::NotificationSettingsController < Groups::ApplicationController
before_action :authenticate_user!
def update
notification_setting = current_user.notification_settings_for(group)
saved = notification_setting.update_attributes(notification_setting_params)
render json: { saved: saved }
end
private
def notification_setting_params
params.require(:notification_setting).permit(:level)
end
end
...@@ -60,6 +60,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -60,6 +60,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
continue_login_process continue_login_process
end end
rescue Gitlab::OAuth::SignupDisabledError
handle_signup_error
end end
def omniauth_error def omniauth_error
...@@ -92,16 +94,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -92,16 +94,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
continue_login_process continue_login_process
end end
rescue Gitlab::OAuth::SignupDisabledError rescue Gitlab::OAuth::SignupDisabledError
label = Gitlab::OAuth::Provider.label_for(oauth['provider']) handle_signup_error
message = "Signing in using your #{label} account without a pre-existing GitLab account is not allowed."
if current_application_settings.signup_enabled?
message << " Create a GitLab account first, and then connect it to your #{label} account."
end
flash[:notice] = message
redirect_to new_user_session_path
end end
def handle_service_ticket provider, ticket def handle_service_ticket provider, ticket
...@@ -122,6 +115,19 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -122,6 +115,19 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end end
end end
def handle_signup_error
label = Gitlab::OAuth::Provider.label_for(oauth['provider'])
message = "Signing in using your #{label} account without a pre-existing GitLab account is not allowed."
if current_application_settings.signup_enabled?
message << " Create a GitLab account first, and then connect it to your #{label} account."
end
flash[:notice] = message
redirect_to new_user_session_path
end
def oauth def oauth
@oauth ||= request.env['omniauth.auth'] @oauth ||= request.env['omniauth.auth']
end end
......
class Profiles::NotificationsController < Profiles::ApplicationController class Profiles::NotificationsController < Profiles::ApplicationController
def show def show
@user = current_user @user = current_user
@notification = current_user.notification @group_notifications = current_user.notification_settings.for_groups
@project_members = current_user.project_members @project_notifications = current_user.notification_settings.for_projects
@group_members = current_user.group_members
end end
def update def update
type = params[:notification_type] if current_user.update_attributes(user_params)
@saved = if type == 'global'
current_user.update_attributes(user_params)
elsif type == 'group'
group_member = current_user.group_members.find(params[:notification_id])
group_member.notification_level = params[:notification_level]
group_member.save
else
project_member = current_user.project_members.find(params[:notification_id])
project_member.notification_level = params[:notification_level]
project_member.save
end
respond_to do |format|
format.html do
if @saved
flash[:notice] = "Notification settings saved" flash[:notice] = "Notification settings saved"
else else
flash[:alert] = "Failed to save new settings" flash[:alert] = "Failed to save new settings"
...@@ -32,10 +15,6 @@ class Profiles::NotificationsController < Profiles::ApplicationController ...@@ -32,10 +15,6 @@ class Profiles::NotificationsController < Profiles::ApplicationController
redirect_back_or_default(default: profile_notifications_path) redirect_back_or_default(default: profile_notifications_path)
end end
format.js
end
end
def user_params def user_params
params.require(:user).permit(:notification_email, :notification_level) params.require(:user).permit(:notification_email, :notification_level)
end end
......
...@@ -237,6 +237,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -237,6 +237,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end end
end end
status = "preparing" if status.nil?
response = { response = {
title: merge_request.title, title: merge_request.title,
sha: merge_request.last_commit_short_sha, sha: merge_request.last_commit_short_sha,
......
...@@ -39,8 +39,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -39,8 +39,7 @@ class Projects::NotesController < Projects::ApplicationController
def destroy def destroy
if note.editable? if note.editable?
note.destroy Notes::DeleteService.new(project, current_user).execute(note)
note.reset_events_cache
end end
respond_to do |format| respond_to do |format|
......
class Projects::NotificationSettingsController < Projects::ApplicationController
before_action :authenticate_user!
def update
notification_setting = current_user.notification_settings_for(project)
saved = notification_setting.update_attributes(notification_setting_params)
render json: { saved: saved }
end
private
def notification_setting_params
params.require(:notification_setting).permit(:level)
end
end
...@@ -101,14 +101,18 @@ class ProjectsController < Projects::ApplicationController ...@@ -101,14 +101,18 @@ class ProjectsController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
if @project.repository_exists?
if @project.empty_repo?
render 'projects/empty'
else
if current_user if current_user
@membership = @project.team.find_member(current_user.id) @membership = @project.team.find_member(current_user.id)
if @membership
@notification_setting = current_user.notification_settings_for(@project)
end
end end
if @project.repository_exists?
if @project.empty_repo?
render 'projects/empty'
else
render :show render :show
end end
else else
......
...@@ -184,7 +184,7 @@ module ApplicationHelper ...@@ -184,7 +184,7 @@ module ApplicationHelper
element = content_tag :time, time.to_s, element = content_tag :time, time.to_s,
class: "#{html_class} js-timeago #{"js-timeago-pending" unless skip_js}", class: "#{html_class} js-timeago #{"js-timeago-pending" unless skip_js}",
datetime: time.to_time.getutc.iso8601, datetime: time.to_time.getutc.iso8601,
title: time.in_time_zone.to_s(:medium), title: time.to_time.in_time_zone.to_s(:medium),
data: { toggle: 'tooltip', placement: placement, container: 'body' } data: { toggle: 'tooltip', placement: placement, container: 'body' }
unless skip_js unless skip_js
......
...@@ -3,8 +3,16 @@ module NamespacesHelper ...@@ -3,8 +3,16 @@ module NamespacesHelper
groups = current_user.owned_groups + current_user.masters_groups groups = current_user.owned_groups + current_user.masters_groups
users = [current_user.namespace] users = [current_user.namespace]
group_opts = ["Groups", groups.sort_by(&:human_name).map {|g| [display_path ? g.path : g.human_name, g.id]} ] data_attr_group = { 'data-options-parent' => 'groups' }
users_opts = [ "Users", users.sort_by(&:human_name).map {|u| [display_path ? u.path : u.human_name, u.id]} ] data_attr_users = { 'data-options-parent' => 'users' }
group_opts = [
"Groups", groups.sort_by(&:human_name).map { |g| [display_path ? g.path : g.human_name, g.id, data_attr_group] }
]
users_opts = [
"Users", users.sort_by(&:human_name).map { |u| [display_path ? u.path : u.human_name, u.id, data_attr_users] }
]
options = [] options = []
options << group_opts options << group_opts
......
module NotificationsHelper module NotificationsHelper
include IconsHelper include IconsHelper
def notification_icon(notification) def notification_icon_class(level)
if notification.disabled? case level.to_sym
icon('volume-off', class: 'ns-mute') when :disabled
elsif notification.participating? 'microphone-slash'
icon('volume-down', class: 'ns-part') when :participating
elsif notification.watch? 'volume-up'
icon('volume-up', class: 'ns-watch') when :watch
else 'eye'
icon('circle-o', class: 'ns-default') when :mention
'at'
when :global
'globe'
end end
end end
def notification_list_item(notification_level, user_membership) def notification_icon(level, text = nil)
case notification_level icon("#{notification_icon_class(level)} fw", text: text)
when Notification::N_DISABLED
update_notification_link(Notification::N_DISABLED, user_membership, 'Disabled', 'microphone-slash')
when Notification::N_PARTICIPATING
update_notification_link(Notification::N_PARTICIPATING, user_membership, 'Participate', 'volume-up')
when Notification::N_WATCH
update_notification_link(Notification::N_WATCH, user_membership, 'Watch', 'eye')
when Notification::N_MENTION
update_notification_link(Notification::N_MENTION, user_membership, 'On mention', 'at')
when Notification::N_GLOBAL
update_notification_link(Notification::N_GLOBAL, user_membership, 'Global', 'globe')
else
# do nothing
end
end end
def update_notification_link(notification_level, user_membership, title, icon) def notification_title(level)
content_tag(:li, class: active_level_for(user_membership, notification_level)) do case level.to_sym
link_to '#', class: 'update-notification', data: { notification_level: notification_level } do when :participating
icon("#{icon} fw", text: title) 'Participate'
end when :mention
'On mention'
else
level.to_s.titlecase
end end
end end
def notification_label(user_membership) def notification_list_item(level, setting)
Notification.new(user_membership).to_s title = notification_title(level)
end
data = {
notification_level: level,
notification_title: title
}
def active_level_for(user_membership, level) content_tag(:li, class: ('active' if setting.level == level)) do
'active' if user_membership.notification_level == level link_to '#', class: 'update-notification', data: data do
notification_icon(level, title)
end
end
end end
end end
...@@ -65,21 +65,14 @@ module ProjectsHelper ...@@ -65,21 +65,14 @@ module ProjectsHelper
link_to(simple_sanitize(owner.name), user_path(owner)) link_to(simple_sanitize(owner.name), user_path(owner))
end end
project_link = link_to project_path(project), { class: "project-item-select-holder" } do project_link = link_to simple_sanitize(project.name), project_path(project), { class: "project-item-select-holder" }
link_output = simple_sanitize(project.name)
if current_user if current_user
link_output += project_select_tag :project_path, project_link << icon("chevron-down", class: "dropdown-toggle-caret js-projects-dropdown-toggle", data: { target: ".js-dropdown-menu-projects", toggle: "dropdown" })
class: "project-item-select js-projects-dropdown",
data: { include_groups: false, order_by: 'last_activity_at' }
end end
link_output full_title = "#{namespace_link} / #{project_link}".html_safe
end full_title << ' &middot; '.html_safe << link_to(simple_sanitize(name), url) if name
project_link += icon "chevron-down", class: "dropdown-toggle-caret js-projects-dropdown-toggle" if current_user
full_title = namespace_link + ' / ' + project_link
full_title += ' &middot; '.html_safe + link_to(simple_sanitize(name), url) if name
full_title full_title
end end
......
...@@ -20,6 +20,8 @@ module TodosHelper ...@@ -20,6 +20,8 @@ module TodosHelper
end end
def todo_target_path(todo) def todo_target_path(todo)
return unless todo.target.present?
anchor = dom_id(todo.note) if todo.note.present? anchor = dom_id(todo.note) if todo.note.present?
if todo.for_commit? if todo.for_commit?
......
...@@ -150,13 +150,11 @@ class Commit ...@@ -150,13 +150,11 @@ class Commit
end end
def hook_attrs(with_changed_files: false) def hook_attrs(with_changed_files: false)
path_with_namespace = project.path_with_namespace
data = { data = {
id: id, id: id,
message: safe_message, message: safe_message,
timestamp: committed_date.xmlschema, timestamp: committed_date.xmlschema,
url: "#{Gitlab.config.gitlab.url}/#{path_with_namespace}/commit/#{id}", url: commit_url,
author: { author: {
name: author_name, name: author_name,
email: author_email email: author_email
...@@ -170,6 +168,10 @@ class Commit ...@@ -170,6 +168,10 @@ class Commit
data data
end end
def commit_url
project.present? ? "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/commit/#{id}" : ""
end
# Discover issues should be closed when this commit is pushed to a project's # Discover issues should be closed when this commit is pushed to a project's
# default branch. # default branch.
def closes_issues(current_user = self.committer) def closes_issues(current_user = self.committer)
......
# == Notifiable concern
#
# Contains notification functionality
#
module Notifiable
extend ActiveSupport::Concern
included do
validates :notification_level, inclusion: { in: Notification.project_notification_levels }, presence: true
end
def notification
@notification ||= Notification.new(self)
end
end
...@@ -27,6 +27,7 @@ class Group < Namespace ...@@ -27,6 +27,7 @@ class Group < Namespace
has_many :users, through: :group_members has_many :users, through: :group_members
has_many :project_group_links, dependent: :destroy has_many :project_group_links, dependent: :destroy
has_many :shared_projects, through: :project_group_links, source: :project has_many :shared_projects, through: :project_group_links, source: :project
has_many :notification_settings, dependent: :destroy, as: :source
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validate :visibility_level_allowed_by_projects validate :visibility_level_allowed_by_projects
......
...@@ -19,7 +19,6 @@ ...@@ -19,7 +19,6 @@
class Member < ActiveRecord::Base class Member < ActiveRecord::Base
include Sortable include Sortable
include Notifiable
include Gitlab::Access include Gitlab::Access
attr_accessor :raw_invite_token attr_accessor :raw_invite_token
...@@ -56,12 +55,15 @@ class Member < ActiveRecord::Base ...@@ -56,12 +55,15 @@ class Member < ActiveRecord::Base
before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }
after_create :send_invite, if: :invite? after_create :send_invite, if: :invite?
after_create :create_notification_setting, unless: :invite?
after_create :post_create_hook, unless: :invite? after_create :post_create_hook, unless: :invite?
after_update :post_update_hook, unless: :invite? after_update :post_update_hook, unless: :invite?
after_destroy :post_destroy_hook, unless: :invite? after_destroy :post_destroy_hook, unless: :invite?
delegate :name, :username, :email, to: :user, prefix: true delegate :name, :username, :email, to: :user, prefix: true
default_value_for :notification_level, NotificationSetting.levels[:global]
class << self class << self
def find_by_invite_token(invite_token) def find_by_invite_token(invite_token)
invite_token = Devise.token_generator.digest(self, :invite_token, invite_token) invite_token = Devise.token_generator.digest(self, :invite_token, invite_token)
...@@ -160,6 +162,14 @@ class Member < ActiveRecord::Base ...@@ -160,6 +162,14 @@ class Member < ActiveRecord::Base
send_invite send_invite
end end
def create_notification_setting
user.notification_settings.find_or_create_for(source)
end
def notification_setting
@notification_setting ||= user.notification_settings_for(source)
end
private private
def send_invite def send_invite
......
...@@ -24,7 +24,6 @@ class GroupMember < Member ...@@ -24,7 +24,6 @@ class GroupMember < Member
# Make sure group member points only to group as it source # Make sure group member points only to group as it source
default_value_for :source_type, SOURCE_TYPE default_value_for :source_type, SOURCE_TYPE
default_value_for :notification_level, Notification::N_GLOBAL
validates_format_of :source_type, with: /\ANamespace\z/ validates_format_of :source_type, with: /\ANamespace\z/
default_scope { where(source_type: SOURCE_TYPE) } default_scope { where(source_type: SOURCE_TYPE) }
......
...@@ -27,7 +27,6 @@ class ProjectMember < Member ...@@ -27,7 +27,6 @@ class ProjectMember < Member
# Make sure project member points only to project as it source # Make sure project member points only to project as it source
default_value_for :source_type, SOURCE_TYPE default_value_for :source_type, SOURCE_TYPE
default_value_for :notification_level, Notification::N_GLOBAL
validates_format_of :source_type, with: /\AProject\z/ validates_format_of :source_type, with: /\AProject\z/
default_scope { where(source_type: SOURCE_TYPE) } default_scope { where(source_type: SOURCE_TYPE) }
......
...@@ -128,7 +128,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -128,7 +128,7 @@ class MergeRequest < ActiveRecord::Base
validates :target_project, presence: true validates :target_project, presence: true
validates :target_branch, presence: true validates :target_branch, presence: true
validates :merge_user, presence: true, if: :merge_when_build_succeeds? validates :merge_user, presence: true, if: :merge_when_build_succeeds?
validate :validate_branches validate :validate_branches, unless: :allow_broken
validate :validate_fork validate :validate_fork
scope :by_branch, ->(branch_name) { where("(source_branch LIKE :branch) OR (target_branch LIKE :branch)", branch: branch_name) } scope :by_branch, ->(branch_name) { where("(source_branch LIKE :branch) OR (target_branch LIKE :branch)", branch: branch_name) }
...@@ -218,7 +218,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -218,7 +218,7 @@ class MergeRequest < ActiveRecord::Base
end end
if opened? || reopened? if opened? || reopened?
similar_mrs = self.target_project.merge_requests.where(source_branch: source_branch, target_branch: target_branch, source_project_id: source_project.id).opened similar_mrs = self.target_project.merge_requests.where(source_branch: source_branch, target_branch: target_branch, source_project_id: source_project.try(:id)).opened
similar_mrs = similar_mrs.where('id not in (?)', self.id) if self.id similar_mrs = similar_mrs.where('id not in (?)', self.id) if self.id
if similar_mrs.any? if similar_mrs.any?
errors.add :validate_branches, errors.add :validate_branches,
...@@ -345,7 +345,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -345,7 +345,7 @@ class MergeRequest < ActiveRecord::Base
def hook_attrs def hook_attrs
attrs = { attrs = {
source: source_project.hook_attrs, source: source_project.try(:hook_attrs),
target: target_project.hook_attrs, target: target_project.hook_attrs,
last_commit: nil, last_commit: nil,
work_in_progress: work_in_progress? work_in_progress: work_in_progress?
......
class Notification
#
# Notification levels
#
N_DISABLED = 0
N_PARTICIPATING = 1
N_WATCH = 2
N_GLOBAL = 3
N_MENTION = 4
attr_accessor :target
class << self
def notification_levels
[N_DISABLED, N_MENTION, N_PARTICIPATING, N_WATCH]
end
def options_with_labels
{
disabled: N_DISABLED,
participating: N_PARTICIPATING,
watch: N_WATCH,
mention: N_MENTION,
global: N_GLOBAL
}
end
def project_notification_levels
[N_DISABLED, N_MENTION, N_PARTICIPATING, N_WATCH, N_GLOBAL]
end
end
def initialize(target)
@target = target
end
def disabled?
target.notification_level == N_DISABLED
end
def participating?
target.notification_level == N_PARTICIPATING
end
def watch?
target.notification_level == N_WATCH
end
def global?
target.notification_level == N_GLOBAL
end
def mention?
target.notification_level == N_MENTION
end
def level
target.notification_level
end
def to_s
case level
when N_DISABLED
'Disabled'
when N_PARTICIPATING
'Participating'
when N_WATCH
'Watching'
when N_MENTION
'On mention'
when N_GLOBAL
'Global'
else
# do nothing
end
end
end
class NotificationSetting < ActiveRecord::Base
enum level: { disabled: 0, participating: 1, watch: 2, global: 3, mention: 4 }
default_value_for :level, NotificationSetting.levels[:global]
belongs_to :user
belongs_to :source, polymorphic: true
validates :user, presence: true
validates :source, presence: true
validates :level, presence: true
validates :user_id, uniqueness: { scope: [:source_type, :source_id],
message: "already exists in source",
allow_nil: true }
scope :for_groups, -> { where(source_type: 'Namespace') }
scope :for_projects, -> { where(source_type: 'Project') }
def self.find_or_create_for(source)
setting = find_or_initialize_by(source: source)
unless setting.persisted?
setting.save
end
setting
end
end
...@@ -154,6 +154,7 @@ class Project < ActiveRecord::Base ...@@ -154,6 +154,7 @@ class Project < ActiveRecord::Base
has_many :project_group_links, dependent: :destroy has_many :project_group_links, dependent: :destroy
has_many :invited_groups, through: :project_group_links, source: :group has_many :invited_groups, through: :project_group_links, source: :group
has_many :todos, dependent: :destroy has_many :todos, dependent: :destroy
has_many :notification_settings, dependent: :destroy, as: :source
has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
...@@ -388,9 +389,15 @@ class Project < ActiveRecord::Base ...@@ -388,9 +389,15 @@ class Project < ActiveRecord::Base
def add_import_job def add_import_job
if forked? if forked?
RepositoryForkWorker.perform_async(self.id, forked_from_project.path_with_namespace, self.namespace.path) job_id = RepositoryForkWorker.perform_async(self.id, forked_from_project.path_with_namespace, self.namespace.path)
else else
RepositoryImportWorker.perform_async(self.id) job_id = RepositoryImportWorker.perform_async(self.id)
end
if job_id
Rails.logger.info "Import job started for #{path_with_namespace} with job ID #{job_id}"
else
Rails.logger.error "Import job failed to start for #{path_with_namespace}"
end end
end end
......
...@@ -253,6 +253,8 @@ class Repository ...@@ -253,6 +253,8 @@ class Repository
# This ensures this particular cache is flushed after the first commit to a # This ensures this particular cache is flushed after the first commit to a
# new repository. # new repository.
expire_emptiness_caches if empty? expire_emptiness_caches if empty?
expire_branch_count_cache
expire_tag_count_cache
end end
def expire_branch_cache(branch_name = nil) def expire_branch_cache(branch_name = nil)
...@@ -896,10 +898,10 @@ class Repository ...@@ -896,10 +898,10 @@ class Repository
end end
def main_language def main_language
unless empty? return if empty? || rugged.head_unborn?
Linguist::Repository.new(rugged, rugged.head.target_id).language Linguist::Repository.new(rugged, rugged.head.target_id).language
end end
end
def avatar def avatar
return nil unless exists? return nil unless exists?
......
...@@ -143,6 +143,7 @@ class User < ActiveRecord::Base ...@@ -143,6 +143,7 @@ class User < ActiveRecord::Base
has_many :spam_logs, dependent: :destroy has_many :spam_logs, dependent: :destroy
has_many :builds, dependent: :nullify, class_name: 'Ci::Build' has_many :builds, dependent: :nullify, class_name: 'Ci::Build'
has_many :todos, dependent: :destroy has_many :todos, dependent: :destroy
has_many :notification_settings, dependent: :destroy
# #
# Validations # Validations
...@@ -157,7 +158,7 @@ class User < ActiveRecord::Base ...@@ -157,7 +158,7 @@ class User < ActiveRecord::Base
presence: true, presence: true,
uniqueness: { case_sensitive: false } uniqueness: { case_sensitive: false }
validates :notification_level, inclusion: { in: Notification.notification_levels }, presence: true validates :notification_level, presence: true
validate :namespace_uniq, if: ->(user) { user.username_changed? } validate :namespace_uniq, if: ->(user) { user.username_changed? }
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validate :unique_email, if: ->(user) { user.email_changed? } validate :unique_email, if: ->(user) { user.email_changed? }
...@@ -190,6 +191,13 @@ class User < ActiveRecord::Base ...@@ -190,6 +191,13 @@ class User < ActiveRecord::Base
# Note: When adding an option, it MUST go on the end of the array. # Note: When adding an option, it MUST go on the end of the array.
enum project_view: [:readme, :activity, :files] enum project_view: [:readme, :activity, :files]
# Notification level
# Note: When adding an option, it MUST go on the end of the array.
#
# TODO: Add '_prefix: :notification' to enum when update to Rails 5. https://github.com/rails/rails/pull/19813
# Because user.notification_disabled? is much better than user.disabled?
enum notification_level: [:disabled, :participating, :watch, :global, :mention]
alias_attribute :private_token, :authentication_token alias_attribute :private_token, :authentication_token
delegate :path, to: :namespace, allow_nil: true, prefix: true delegate :path, to: :namespace, allow_nil: true, prefix: true
...@@ -349,10 +357,6 @@ class User < ActiveRecord::Base ...@@ -349,10 +357,6 @@ class User < ActiveRecord::Base
"#{self.class.reference_prefix}#{username}" "#{self.class.reference_prefix}#{username}"
end end
def notification
@notification ||= Notification.new(self)
end
def generate_password def generate_password
if self.force_random_password if self.force_random_password
self.password = self.password_confirmation = Devise.friendly_token.first(8) self.password = self.password_confirmation = Devise.friendly_token.first(8)
...@@ -827,6 +831,10 @@ class User < ActiveRecord::Base ...@@ -827,6 +831,10 @@ class User < ActiveRecord::Base
end end
end end
def notification_settings_for(source)
notification_settings.find_or_initialize_by(source: source)
end
private private
def projects_union def projects_union
......
module Notes
class DeleteService < BaseService
def execute(note)
note.destroy
note.reset_events_cache
end
end
end
...@@ -253,8 +253,8 @@ class NotificationService ...@@ -253,8 +253,8 @@ class NotificationService
def project_watchers(project) def project_watchers(project)
project_members = project_member_notification(project) project_members = project_member_notification(project)
users_with_project_level_global = project_member_notification(project, Notification::N_GLOBAL) users_with_project_level_global = project_member_notification(project, :global)
users_with_group_level_global = group_member_notification(project, Notification::N_GLOBAL) users_with_group_level_global = group_member_notification(project, :global)
users = users_with_global_level_watch([users_with_project_level_global, users_with_group_level_global].flatten.uniq) users = users_with_global_level_watch([users_with_project_level_global, users_with_group_level_global].flatten.uniq)
users_with_project_setting = select_project_member_setting(project, users_with_project_level_global, users) users_with_project_setting = select_project_member_setting(project, users_with_project_level_global, users)
...@@ -264,18 +264,16 @@ class NotificationService ...@@ -264,18 +264,16 @@ class NotificationService
end end
def project_member_notification(project, notification_level=nil) def project_member_notification(project, notification_level=nil)
project_members = project.project_members
if notification_level if notification_level
project_members.where(notification_level: notification_level).pluck(:user_id) project.notification_settings.where(level: NotificationSetting.levels[notification_level]).pluck(:user_id)
else else
project_members.pluck(:user_id) project.notification_settings.pluck(:user_id)
end end
end end
def group_member_notification(project, notification_level) def group_member_notification(project, notification_level)
if project.group if project.group
project.group.group_members.where(notification_level: notification_level).pluck(:user_id) project.group.notification_settings.where(level: NotificationSetting.levels[notification_level]).pluck(:user_id)
else else
[] []
end end
...@@ -284,13 +282,13 @@ class NotificationService ...@@ -284,13 +282,13 @@ class NotificationService
def users_with_global_level_watch(ids) def users_with_global_level_watch(ids)
User.where( User.where(
id: ids, id: ids,
notification_level: Notification::N_WATCH notification_level: NotificationSetting.levels[:watch]
).pluck(:id) ).pluck(:id)
end end
# Build a list of users based on project notifcation settings # Build a list of users based on project notifcation settings
def select_project_member_setting(project, global_setting, users_global_level_watch) def select_project_member_setting(project, global_setting, users_global_level_watch)
users = project_member_notification(project, Notification::N_WATCH) users = project_member_notification(project, :watch)
# If project setting is global, add to watch list if global setting is watch # If project setting is global, add to watch list if global setting is watch
global_setting.each do |user_id| global_setting.each do |user_id|
...@@ -304,7 +302,7 @@ class NotificationService ...@@ -304,7 +302,7 @@ class NotificationService
# Build a list of users based on group notification settings # Build a list of users based on group notification settings
def select_group_member_setting(project, project_members, global_setting, users_global_level_watch) def select_group_member_setting(project, project_members, global_setting, users_global_level_watch)
uids = group_member_notification(project, Notification::N_WATCH) uids = group_member_notification(project, :watch)
# Group setting is watch, add to users list if user is not project member # Group setting is watch, add to users list if user is not project member
users = [] users = []
...@@ -331,40 +329,46 @@ class NotificationService ...@@ -331,40 +329,46 @@ class NotificationService
# Remove users with disabled notifications from array # Remove users with disabled notifications from array
# Also remove duplications and nil recipients # Also remove duplications and nil recipients
def reject_muted_users(users, project = nil) def reject_muted_users(users, project = nil)
reject_users(users, :disabled?, project) reject_users(users, :disabled, project)
end end
# Remove users with notification level 'Mentioned' # Remove users with notification level 'Mentioned'
def reject_mention_users(users, project = nil) def reject_mention_users(users, project = nil)
reject_users(users, :mention?, project) reject_users(users, :mention, project)
end end
# Reject users which method_name from notification object returns true. # Reject users which has certain notification level
# #
# Example: # Example:
# reject_users(users, :watch?, project) # reject_users(users, :watch, project)
# #
def reject_users(users, method_name, project = nil) def reject_users(users, level, project = nil)
level = level.to_s
unless NotificationSetting.levels.keys.include?(level)
raise 'Invalid notification level'
end
users = users.to_a.compact.uniq users = users.to_a.compact.uniq
users = users.reject(&:blocked?) users = users.reject(&:blocked?)
users.reject do |user| users.reject do |user|
next user.notification.send(method_name) unless project next user.notification_level == level unless project
member = project.project_members.find_by(user_id: user.id) setting = user.notification_settings_for(project)
if !member && project.group if !setting && project.group
member = project.group.group_members.find_by(user_id: user.id) setting = user.notification_settings_for(project.group)
end end
# reject users who globally set mention notification and has no membership # reject users who globally set mention notification and has no setting per project/group
next user.notification.send(method_name) unless member next user.notification_level == level unless setting
# reject users who set mention notification in project # reject users who set mention notification in project
next true if member.notification.send(method_name) next true if setting.level == level
# reject users who have N_MENTION in project and disabled in global settings # reject users who have mention level in project and disabled in global settings
member.notification.global? && user.notification.send(method_name) setting.global? && user.notification_level == level
end end
end end
......
...@@ -45,6 +45,8 @@ ...@@ -45,6 +45,8 @@
%h1.title= title %h1.title= title
= yield :header_content
= render 'shared/outdated_browser' = render 'shared/outdated_browser'
- if @project && !@project.empty_repo? - if @project && !@project.empty_repo?
......
...@@ -17,4 +17,12 @@ ...@@ -17,4 +17,12 @@
- content_for :scripts_body do - content_for :scripts_body do
= render "layouts/init_auto_complete" if current_user = render "layouts/init_auto_complete" if current_user
- content_for :header_content do
.js-dropdown-menu-projects
.dropdown-menu.dropdown-select.dropdown-menu-projects
= dropdown_title("Go to a project")
= dropdown_filter("Search your projects")
= dropdown_content
= dropdown_loading
= render template: "layouts/application" = render template: "layouts/application"
%li.notification-list-item
%span.notification.fa.fa-holder.append-right-5
- if setting.global?
= notification_icon(current_user.notification_level)
- else
= notification_icon(setting.level)
%span.str-truncated
= link_to group.name, group_path(group)
.pull-right
= form_for [group, setting], remote: true, html: { class: 'update-notifications' } do |f|
= f.select :level, NotificationSetting.levels.keys, {}, class: 'form-control trigger-submit'
%li.notification-list-item
%span.notification.fa.fa-holder.append-right-5
- if setting.global?
= notification_icon(current_user.notification_level)
- else
= notification_icon(setting.level)
%span.str-truncated
= link_to_project(project)
.pull-right
= form_for [project.namespace.becomes(Namespace), project, setting], remote: true, html: { class: 'update-notifications' } do |f|
= f.select :level, NotificationSetting.levels.keys, {}, class: 'form-control trigger-submit'
%li.notification-list-item
%span.notification.fa.fa-holder.append-right-5
- if notification.global?
= notification_icon(@notification)
- else
= notification_icon(notification)
%span.str-truncated
- if membership.kind_of? GroupMember
= link_to membership.group.name, membership.group
- else
= link_to_project(membership.project)
.pull-right
= form_tag profile_notifications_path, method: :put, remote: true, class: 'update-notifications' do
= hidden_field_tag :notification_type, type, id: dom_id(membership, 'notification_type')
= hidden_field_tag :notification_id, membership.id, id: dom_id(membership, 'notification_id')
= select_tag :notification_level, options_for_select(Notification.options_with_labels, notification.level), class: 'form-control trigger-submit'
- page_title "Notifications" - page_title "Notifications"
- header_title page_title, profile_notifications_path - header_title page_title, profile_notifications_path
= form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f| %div
= form_errors(@user) - if @user.errors.any?
%div.alert.alert-danger
%ul
- @user.errors.full_messages.each do |msg|
%li= msg
= hidden_field_tag :notification_type, 'global' = hidden_field_tag :notification_type, 'global'
.row .row
...@@ -16,35 +20,37 @@ ...@@ -16,35 +20,37 @@
.col-lg-9 .col-lg-9
%h5 %h5
Global notification settings Global notification settings
= form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f|
.form-group .form-group
= f.label :notification_email, class: "label-light" = f.label :notification_email, class: "label-light"
= f.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2" = f.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2"
.form-group .form-group
= f.label :notification_level, class: 'label-light' = f.label :notification_level, class: 'label-light'
.radio .radio
= f.label :notification_level, value: Notification::N_DISABLED do = f.label :notification_level, value: :disabled do
= f.radio_button :notification_level, Notification::N_DISABLED = f.radio_button :notification_level, :disabled
.level-title .level-title
Disabled Disabled
%p You will not get any notifications via email %p You will not get any notifications via email
.radio .radio
= f.label :notification_level, value: Notification::N_MENTION do = f.label :notification_level, value: :mention do
= f.radio_button :notification_level, Notification::N_MENTION = f.radio_button :notification_level, :mention
.level-title .level-title
On Mention On Mention
%p You will receive notifications only for comments in which you were @mentioned %p You will receive notifications only for comments in which you were @mentioned
.radio .radio
= f.label :notification_level, value: Notification::N_PARTICIPATING do = f.label :notification_level, value: :participating do
= f.radio_button :notification_level, Notification::N_PARTICIPATING = f.radio_button :notification_level, :participating
.level-title .level-title
Participating Participating
%p You will only receive notifications from related resources (e.g. from your commits or assigned issues) %p You will only receive notifications from related resources (e.g. from your commits or assigned issues)
.radio .radio
= f.label :notification_level, value: Notification::N_WATCH do = f.label :notification_level, value: :watch do
= f.radio_button :notification_level, Notification::N_WATCH = f.radio_button :notification_level, :watch
.level-title .level-title
Watch Watch
%p You will receive notifications for any activity %p You will receive notifications for any activity
...@@ -52,20 +58,17 @@ ...@@ -52,20 +58,17 @@
.prepend-top-default .prepend-top-default
= f.submit 'Update settings', class: "btn btn-create" = f.submit 'Update settings', class: "btn btn-create"
%hr %hr
.col-lg-9.col-lg-push-3
%h5 %h5
Groups (#{@group_members.count}) Groups (#{@group_notifications.count})
%div %div
%ul.bordered-list %ul.bordered-list
- @group_members.each do |group_member| - @group_notifications.each do |setting|
- notification = Notification.new(group_member) = render 'group_settings', setting: setting, group: setting.source
= render 'settings', type: 'group', membership: group_member, notification: notification
%h5 %h5
Projects (#{@project_members.count}) Projects (#{@project_notifications.count})
%p.account-well %p.account-well
To specify the notification level per project of a group you belong to, you need to be a member of the project itself, not only its group. To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.
.append-bottom-default .append-bottom-default
%ul.bordered-list %ul.bordered-list
- @project_members.each do |project_member| - @project_notifications.each do |setting|
- notification = Notification.new(project_member) = render 'project_settings', setting: setting, project: setting.source
= render 'settings', type: 'project', membership: project_member, notification: notification
- if @saved
:plain
new Flash("Notification settings saved", "notice")
- else
:plain
new Flash("Failed to save new settings", "alert")
- case @membership - if @notification_setting
- when ProjectMember = form_for @notification_setting, url: namespace_project_notification_setting_path(@project.namespace.becomes(Namespace), @project), method: :patch, remote: true, html: { class: 'inline', id: 'notification-form' } do |f|
= form_tag profile_notifications_path, method: :put, remote: true, class: 'inline', id: 'notification-form' do = f.hidden_field :level
= hidden_field_tag :notification_type, 'project'
= hidden_field_tag :notification_id, @membership.id
= hidden_field_tag :notification_level
%span.dropdown %span.dropdown
%a.dropdown-new.btn.notifications-btn#notifications-button{href: '#', "data-toggle" => "dropdown"} %a.dropdown-new.btn.notifications-btn#notifications-button{href: '#', "data-toggle" => "dropdown"}
= icon('bell') = icon('bell')
= notification_label(@membership) = notification_title(@notification_setting.level)
= icon('angle-down') = icon('angle-down')
%ul.dropdown-menu.dropdown-menu-right.project-home-dropdown %ul.dropdown-menu.dropdown-menu-right.project-home-dropdown
- Notification.project_notification_levels.each do |level| - NotificationSetting.levels.each do |level|
= notification_list_item(level, @membership) = notification_list_item(level.first, @notification_setting)
- when GroupMember
.btn.disabled.notifications-btn.has-tooltip{title: "To change the notification level, you need to be a member of the project itself, not only its group."}
= icon('bell')
= notification_label(@membership)
= icon('angle-down')
= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form form-horizontal gfm-form js-requires-input' } do |f| = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form form-horizontal gfm-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request = render 'shared/issuable/form', f: f, issuable: @merge_request
:javascript :javascript
......
...@@ -8,20 +8,22 @@ ...@@ -8,20 +8,22 @@
= render 'projects/merge_requests/widget/locked' = render 'projects/merge_requests/widget/locked'
:javascript :javascript
var merge_request_widget;
var opts = { var opts = {
merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", merge_check_url: "#{merge_check_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
check_enable: #{@merge_request.unchecked? ? "true" : "false"}, check_enable: #{@merge_request.unchecked? ? "true" : "false"},
ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}", ci_status_url: "#{ci_status_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
gitlab_icon: "#{asset_path 'gitlab_logo.png'}", gitlab_icon: "#{asset_path 'gitlab_logo.png'}",
ci_status: "", ci_status: "",
ci_message: "Build {{status}} for \"{{title}}\"", ci_message: {
normal: "Build {{status}} for \"{{title}}\"",
preparing: "{{status}} build for \"{{title}}\""
},
ci_enable: #{@project.ci_service ? "true" : "false"}, ci_enable: #{@project.ci_service ? "true" : "false"},
ci_title: {
preparing: "{{status}} build",
normal: "Build {{status}}"
},
builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}" builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
}; };
if(typeof merge_request_widget === 'undefined') {
merge_request_widget = new MergeRequestWidget(opts); merge_request_widget = new MergeRequestWidget(opts);
} else {
merge_request_widget.setOpts(opts);
}
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
- if current_user.can_select_namespace? - if current_user.can_select_namespace?
.input-group-addon .input-group-addon
= root_url = root_url
= f.select :namespace_id, namespaces_options(params[:namespace_id] || :current_user, display_path: true), {}, {class: 'select2', tabindex: 1} = f.select :namespace_id, namespaces_options(params[:namespace_id] || :current_user, display_path: true), {}, {class: 'select2 js-select-namespace', tabindex: 1}
.input-group-addon .input-group-addon
\/ \/
- else - else
......
...@@ -10,12 +10,12 @@ ...@@ -10,12 +10,12 @@
= "#{note.author.to_reference} commented" = "#{note.author.to_reference} commented"
%a{ href: "##{dom_id(note)}" } %a{ href: "##{dom_id(note)}" }
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- if note_editable?(note)
.note-actions .note-actions
- access = note.project.team.human_max_access(note.author.id) - access = note.project.team.human_max_access(note.author.id)
- if access - if access
%span.note-role %span.note-role
= access = access
- if note_editable?(note)
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
= icon('pencil') = icon('pencil')
= link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
.commit-message-container .commit-message-container
.max-width-marker .max-width-marker
= text_area_tag 'commit_message', = text_area_tag 'commit_message',
(params[:commit_message] || local_assigns[:text]), (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder]),
class: 'form-control js-commit-message', placeholder: local_assigns[:placeholder], class: 'form-control js-commit-message', placeholder: local_assigns[:placeholder],
required: true, rows: (local_assigns[:rows] || 3), required: true, rows: (local_assigns[:rows] || 3),
id: "commit_message-#{nonce}" id: "commit_message-#{nonce}"
......
...@@ -46,6 +46,15 @@ production: &base ...@@ -46,6 +46,15 @@ production: &base
# #
# relative_url_root: /gitlab # relative_url_root: /gitlab
# Trusted Proxies
# Customize if you have GitLab behind a reverse proxy which is running on a different machine.
# Add the IP address for your reverse proxy to the list, otherwise users will appear signed in from that address.
trusted_proxies:
# Examples:
#- 192.168.1.0/24
#- 192.168.2.1
#- 2001:0db8::/32
# Uncomment and customize if you can't use the default user to run GitLab (default: 'git') # Uncomment and customize if you can't use the default user to run GitLab (default: 'git')
# user: git # user: git
......
...@@ -190,6 +190,7 @@ Settings.gitlab.default_projects_features['visibility_level'] = Settings.send ...@@ -190,6 +190,7 @@ Settings.gitlab.default_projects_features['visibility_level'] = Settings.send
Settings.gitlab['repository_downloads_path'] = File.join(Settings.shared['path'], 'cache/archive') if Settings.gitlab['repository_downloads_path'].nil? Settings.gitlab['repository_downloads_path'] = File.join(Settings.shared['path'], 'cache/archive') if Settings.gitlab['repository_downloads_path'].nil?
Settings.gitlab['restricted_signup_domains'] ||= [] Settings.gitlab['restricted_signup_domains'] ||= []
Settings.gitlab['import_sources'] ||= ['github','bitbucket','gitlab','gitorious','google_code','fogbugz','git'] Settings.gitlab['import_sources'] ||= ['github','bitbucket','gitlab','gitorious','google_code','fogbugz','git']
Settings.gitlab['trusted_proxies'] ||= []
# #
......
if Gitlab::Metrics.enabled? if Gitlab::Metrics.enabled?
require 'pathname'
require 'influxdb' require 'influxdb'
require 'connection_pool' require 'connection_pool'
require 'method_source' require 'method_source'
...@@ -85,9 +86,6 @@ if Gitlab::Metrics.enabled? ...@@ -85,9 +86,6 @@ if Gitlab::Metrics.enabled?
config.instrument_instance_methods(const) config.instrument_instance_methods(const)
end end
config.instrument_methods(Banzai::ReferenceExtractor)
config.instrument_instance_methods(Banzai::ReferenceExtractor)
config.instrument_methods(Banzai::Renderer) config.instrument_methods(Banzai::Renderer)
config.instrument_methods(Banzai::Querying) config.instrument_methods(Banzai::Querying)
...@@ -98,6 +96,17 @@ if Gitlab::Metrics.enabled? ...@@ -98,6 +96,17 @@ if Gitlab::Metrics.enabled?
config.instrument_methods(Gitlab::ReferenceExtractor) config.instrument_methods(Gitlab::ReferenceExtractor)
config.instrument_instance_methods(Gitlab::ReferenceExtractor) config.instrument_instance_methods(Gitlab::ReferenceExtractor)
# Instrument all service classes
services = Rails.root.join('app', 'services')
Dir[services.join('**', '*.rb')].each do |file_path|
path = Pathname.new(file_path).relative_path_from(services)
const = path.to_s.sub('.rb', '').camelize.constantize
config.instrument_methods(const)
config.instrument_instance_methods(const)
end
end end
GC::Profiler.enable GC::Profiler.enable
......
Rails.application.config.action_dispatch.trusted_proxies =
[ '127.0.0.1', '::1' ] + Array(Gitlab.config.gitlab.trusted_proxies)
...@@ -406,6 +406,7 @@ Rails.application.routes.draw do ...@@ -406,6 +406,7 @@ Rails.application.routes.draw do
resource :avatar, only: [:destroy] resource :avatar, only: [:destroy]
resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create]
resource :notification_setting, only: [:update]
end end
end end
...@@ -607,6 +608,7 @@ Rails.application.routes.draw do ...@@ -607,6 +608,7 @@ Rails.application.routes.draw do
resources :forks, only: [:index, :new, :create] resources :forks, only: [:index, :new, :create]
resource :import, only: [:new, :create, :show] resource :import, only: [:new, :create, :show]
resource :notification_setting, only: [:update]
resources :refs, only: [] do resources :refs, only: [] do
collection do collection do
......
class CreateNotificationSettings < ActiveRecord::Migration
def change
create_table :notification_settings do |t|
t.references :user, null: false
t.references :source, polymorphic: true, null: false
t.integer :level, default: 0, null: false
t.timestamps null: false
end
end
end
# This migration will create one row of NotificationSetting for each Member row
# It can take long time on big instances.
#
# This migration can be done online but with following effects:
# - during migration some users will receive notifications based on their global settings (project/group settings will be ignored)
# - its possible to get duplicate records for notification settings since we don't create uniq index yet
#
class MigrateNewNotificationSetting < ActiveRecord::Migration
def up
timestamp = Time.now
execute "INSERT INTO notification_settings ( user_id, source_id, source_type, level, created_at, updated_at ) SELECT user_id, source_id, source_type, notification_level, '#{timestamp}', '#{timestamp}' FROM members WHERE user_id IS NOT NULL"
end
def down
execute "DELETE FROM notification_settings"
end
end
class AddNotificationSettingIndex < ActiveRecord::Migration
def change
add_index :notification_settings, :user_id
add_index :notification_settings, [:source_id, :source_type]
end
end
...@@ -637,6 +637,18 @@ ActiveRecord::Schema.define(version: 20160331223143) do ...@@ -637,6 +637,18 @@ ActiveRecord::Schema.define(version: 20160331223143) do
add_index "notes", ["project_id"], name: "index_notes_on_project_id", using: :btree add_index "notes", ["project_id"], name: "index_notes_on_project_id", using: :btree
add_index "notes", ["updated_at"], name: "index_notes_on_updated_at", using: :btree add_index "notes", ["updated_at"], name: "index_notes_on_updated_at", using: :btree
create_table "notification_settings", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "source_id", null: false
t.string "source_type", null: false
t.integer "level", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "notification_settings", ["source_id", "source_type"], name: "index_notification_settings_on_source_id_and_source_type", using: :btree
add_index "notification_settings", ["user_id"], name: "index_notification_settings_on_user_id", using: :btree
create_table "oauth_access_grants", force: :cascade do |t| create_table "oauth_access_grants", force: :cascade do |t|
t.integer "resource_owner_id", null: false t.integer "resource_owner_id", null: false
t.integer "application_id", null: false t.integer "application_id", null: false
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
## User documentation ## User documentation
- [API](api/README.md) Automate GitLab via a simple and powerful API. - [API](api/README.md) Automate GitLab via a simple and powerful API.
- [CI](ci/README.md) GitLab Continuous Integration (CI) getting started, .gitlab-ci.yml options, and examples. - [CI](ci/README.md) GitLab Continuous Integration (CI) getting started, `.gitlab-ci.yml` options, and examples.
- [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab. - [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab.
- [GitLab Basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab. - [GitLab Basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab.
- [Importing to GitLab](workflow/importing/README.md). - [Importing to GitLab](workflow/importing/README.md).
......
...@@ -126,6 +126,87 @@ Parameters: ...@@ -126,6 +126,87 @@ Parameters:
- `id` (required) - The ID or path of a group - `id` (required) - The ID or path of a group
- `project_id` (required) - The ID of a project - `project_id` (required) - The ID of a project
## Update group
Updates the project group. Only available to group owners and administrators.
```
PUT /groups/:id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the group |
| `name` | string | no | The name of the group |
| `path` | string | no | The path of the group |
| `description` | string | no | The description of the group |
| `visibility_level` | integer | no | The visibility level of the group. 0 for private, 10 for internal, 20 for public. |
```bash
curl -X PUT -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/groups/5?name=Experimental"
```
Example response:
```json
{
"id": 5,
"name": "Experimental",
"path": "h5bp",
"description": "foo",
"visibility_level": 10,
"avatar_url": null,
"web_url": "http://gitlab.example.com/groups/h5bp",
"projects": [
{
"id": 9,
"description": "foo",
"default_branch": "master",
"tag_list": [],
"public": false,
"archived": false,
"visibility_level": 10,
"ssh_url_to_repo": "git@gitlab.example.com/html5-boilerplate.git",
"http_url_to_repo": "http://gitlab.example.com/h5bp/html5-boilerplate.git",
"web_url": "http://gitlab.example.com/h5bp/html5-boilerplate",
"name": "Html5 Boilerplate",
"name_with_namespace": "Experimental / Html5 Boilerplate",
"path": "html5-boilerplate",
"path_with_namespace": "h5bp/html5-boilerplate",
"issues_enabled": true,
"merge_requests_enabled": true,
"wiki_enabled": true,
"builds_enabled": true,
"snippets_enabled": true,
"created_at": "2016-04-05T21:40:50.169Z",
"last_activity_at": "2016-04-06T16:52:08.432Z",
"shared_runners_enabled": true,
"creator_id": 1,
"namespace": {
"id": 5,
"name": "Experimental",
"path": "h5bp",
"owner_id": null,
"created_at": "2016-04-05T21:40:49.152Z",
"updated_at": "2016-04-07T08:07:48.466Z",
"description": "foo",
"avatar": {
"url": null
},
"share_with_group_lock": false,
"visibility_level": 10
},
"avatar_url": null,
"star_count": 1,
"forks_count": 0,
"open_issues_count": 3,
"public_builds": true
}
]
}
```
## Remove group ## Remove group
Removes group with all projects inside. Removes group with all projects inside.
......
...@@ -351,6 +351,61 @@ DELETE /projects/:id/issues/:issue_id ...@@ -351,6 +351,61 @@ DELETE /projects/:id/issues/:issue_id
curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85 curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85
``` ```
## Move an issue
Moves an issue to a different project. If the operation is successful, a status
code `201` together with moved issue is returned. If the project, issue, or
target project is not found, error `404` is returned. If the target project
equals the source project or the user has insufficient permissions to move an
issue, error `400` together with an explaining error message is returned.
```
POST /projects/:id/issues/:issue_id/move
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
| `to_project_id` | integer | yes | The ID of the new project |
```bash
curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/4/issues/85/move
```
Example response:
```json
{
"id": 92,
"iid": 11,
"project_id": 5,
"title": "Sit voluptas tempora quisquam aut doloribus et.",
"description": "Repellat voluptas quibusdam voluptatem exercitationem.",
"state": "opened",
"created_at": "2016-04-05T21:41:45.652Z",
"updated_at": "2016-04-07T12:20:17.596Z",
"labels": [],
"milestone": null,
"assignee": {
"name": "Miss Monserrate Beier",
"username": "axel.block",
"id": 12,
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon",
"web_url": "https://gitlab.example.com/u/axel.block"
},
"author": {
"name": "Kris Steuber",
"username": "solon.cremin",
"id": 10,
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/7a190fecbaa68212a4b68aeb6e3acd10?s=80&d=identicon",
"web_url": "https://gitlab.example.com/u/solon.cremin"
}
}
```
## Comments on issues ## Comments on issues
Comments are done via the [notes](notes.md) resource. Comments are done via the [notes](notes.md) resource.
...@@ -105,6 +105,53 @@ Parameters: ...@@ -105,6 +105,53 @@ Parameters:
- `note_id` (required) - The ID of a note - `note_id` (required) - The ID of a note
- `body` (required) - The content of a note - `body` (required) - The content of a note
### Delete an issue note
Deletes an existing note of an issue. On success, this API method returns 200
and the deleted note. If the note does not exist, the API returns 404.
```
DELETE /projects/:id/issues/:issue_id/notes/:note_id
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of an issue |
| `note_id` | integer | yes | The ID of a note |
```bash
curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/11/notes/636
```
Example Response:
```json
{
"id": 636,
"body": "This is a good idea.",
"attachment": null,
"author": {
"id": 1,
"username": "pipin",
"email": "admin@example.com",
"name": "Pip",
"state": "active",
"created_at": "2013-09-30T13:46:01Z",
"avatar_url": "http://www.gravatar.com/avatar/5224fd70153710e92fb8bcf79ac29d67?s=80&d=identicon",
"web_url": "https://gitlab.example.com/u/pipin"
},
"created_at": "2016-04-05T22:10:44.164Z",
"system": false,
"noteable_id": 11,
"noteable_type": "Issue",
"upvote": false,
"downvote": false
}
```
## Snippets ## Snippets
### List all snippet notes ### List all snippet notes
...@@ -182,6 +229,53 @@ Parameters: ...@@ -182,6 +229,53 @@ Parameters:
- `note_id` (required) - The ID of a note - `note_id` (required) - The ID of a note
- `body` (required) - The content of a note - `body` (required) - The content of a note
### Delete a snippet note
Deletes an existing note of a snippet. On success, this API method returns 200
and the deleted note. If the note does not exist, the API returns 404.
```
DELETE /projects/:id/snippets/:snippet_id/notes/:note_id
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `snippet_id` | integer | yes | The ID of a snippet |
| `note_id` | integer | yes | The ID of a note |
```bash
curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/snippets/52/notes/1659
```
Example Response:
```json
{
"id": 1659,
"body": "This is a good idea.",
"attachment": null,
"author": {
"id": 1,
"username": "pipin",
"email": "admin@example.com",
"name": "Pip",
"state": "active",
"created_at": "2013-09-30T13:46:01Z",
"avatar_url": "http://www.gravatar.com/avatar/5224fd70153710e92fb8bcf79ac29d67?s=80&d=identicon",
"web_url": "https://gitlab.example.com/u/pipin"
},
"created_at": "2016-04-06T16:51:53.239Z",
"system": false,
"noteable_id": 52,
"noteable_type": "Snippet",
"upvote": false,
"downvote": false
}
```
## Merge Requests ## Merge Requests
### List all merge request notes ### List all merge request notes
...@@ -262,3 +356,50 @@ Parameters: ...@@ -262,3 +356,50 @@ Parameters:
- `merge_request_id` (required) - The ID of a merge request - `merge_request_id` (required) - The ID of a merge request
- `note_id` (required) - The ID of a note - `note_id` (required) - The ID of a note
- `body` (required) - The content of a note - `body` (required) - The content of a note
### Delete a merge request note
Deletes an existing note of a merge request. On success, this API method returns
200 and the deleted note. If the note does not exist, the API returns 404.
```
DELETE /projects/:id/merge_requests/:merge_request_id/notes/:note_id
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `merge_request_id` | integer | yes | The ID of a merge request |
| `note_id` | integer | yes | The ID of a note |
```bash
curl -X DELETE -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/7/notes/1602
```
Example Response:
```json
{
"id": 1602,
"body": "This is a good idea.",
"attachment": null,
"author": {
"id": 1,
"username": "pipin",
"email": "admin@example.com",
"name": "Pip",
"state": "active",
"created_at": "2013-09-30T13:46:01Z",
"avatar_url": "http://www.gravatar.com/avatar/5224fd70153710e92fb8bcf79ac29d67?s=80&d=identicon",
"web_url": "https://gitlab.example.com/u/pipin"
},
"created_at": "2016-04-05T22:11:59.923Z",
"system": false,
"noteable_id": 7,
"noteable_type": "MergeRequest",
"upvote": false,
"downvote": false
}
```
...@@ -780,8 +780,10 @@ Parameters: ...@@ -780,8 +780,10 @@ Parameters:
- `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project - `id` (required) - The ID or NAMESPACE/PROJECT_NAME of a project
- `user_id` (required) - The ID of a team member - `user_id` (required) - The ID of a team member
This method is idempotent and can be called multiple times with the same parameters. This method removes the project member if the user has the proper access rights to do so.
Revoking team membership for a user who is not currently a team member is considered success. It returns a status code 403 if the member does not have the proper rights to perform this action.
In all other cases this method is idempotent and revoking team membership for a user who is not
currently a team member is considered success.
Please note that the returned JSON currently differs slightly. Thus you should not Please note that the returned JSON currently differs slightly. Thus you should not
rely on the returned JSON structure. rely on the returned JSON structure.
......
...@@ -38,6 +38,50 @@ Parameters: ...@@ -38,6 +38,50 @@ Parameters:
] ]
``` ```
## Get a single repository tag
Get a specific repository tag determined by its name. It returns `200` together
with the tag information if the tag exists. It returns `404` if the tag does not
exist.
```
GET /projects/:id/repository/tags/:tag_name
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `tag_name` | string | yes | The name of the tag |
```bash
curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/repository/tags/v1.0.0
```
Example Response:
```json
{
"name": "v5.0.0",
"message": null,
"commit": {
"id": "60a8ff033665e1207714d6670fcd7b65304ec02f",
"message": "v5.0.0\n",
"parent_ids": [
"f61c062ff8bcbdb00e0a1b3317a91aed6ceee06b"
],
"authored_date": "2015-02-01T21:56:31.000+01:00",
"author_name": "Arthur Verschaeve",
"author_email": "contact@arthurverschaeve.be",
"committed_date": "2015-02-01T21:56:31.000+01:00",
"committer_name": "Arthur Verschaeve",
"committer_email": "contact@arthurverschaeve.be"
},
"release": null
}
```
## Create a new tag ## Create a new tag
Creates a new tag in the repository that points to the supplied ref. Creates a new tag in the repository that points to the supplied ref.
......
...@@ -530,6 +530,16 @@ See the [omniauth integration document](../integration/omniauth.md) ...@@ -530,6 +530,16 @@ See the [omniauth integration document](../integration/omniauth.md)
GitLab can build your projects. To enable that feature you need GitLab Runners to do that for you. GitLab can build your projects. To enable that feature you need GitLab Runners to do that for you.
Checkout the [GitLab Runner section](https://about.gitlab.com/gitlab-ci/#gitlab-runner) to install it Checkout the [GitLab Runner section](https://about.gitlab.com/gitlab-ci/#gitlab-runner) to install it
### Adding your Trusted Proxies
If you are using a reverse proxy on an separate machine, you may want to add the
proxy to the trusted proxies list. Otherwise users will appear signed in from the
proxy's IP address.
You can add trusted proxies in `config/gitlab.yml` by customizing the `trusted_proxies`
option in section 1. Save the file and [reconfigure GitLab](../administration/restart_gitlab.md)
for the changes to take effect.
### Custom Redis Connection ### Custom Redis Connection
If you'd like Resque to connect to a Redis server on a non-standard port or on a different host, you can configure its connection string via the `config/resque.yml` file. If you'd like Resque to connect to a Redis server on a non-standard port or on a different host, you can configure its connection string via the `config/resque.yml` file.
......
# GitLab JIRA integration # GitLab JIRA integration
_**Note:** >**Note:**
Full JIRA integration was previously exclusive to GitLab Enterprise Edition. Full JIRA integration was previously exclusive to GitLab Enterprise Edition.
With [GitLab 8.3 forward][8_3_post], this feature in now [backported][jira-ce] With [GitLab 8.3 forward][8_3_post], this feature in now [backported][jira-ce]
to GitLab Community Edition as well._ to GitLab Community Edition as well.
--- ---
...@@ -88,8 +88,9 @@ password as they will be needed when configuring GitLab in the next section. ...@@ -88,8 +88,9 @@ password as they will be needed when configuring GitLab in the next section.
### Configuring GitLab ### Configuring GitLab
_**Note:** The currently supported JIRA versions are v6.x and v7.x. and GitLab >**Note:**
7.8 or higher is required._ The currently supported JIRA versions are v6.x and v7.x. and GitLab
7.8 or higher is required.
--- ---
...@@ -113,13 +114,24 @@ Fill in the required details on the page, as described in the table below. ...@@ -113,13 +114,24 @@ Fill in the required details on the page, as described in the table below.
| `Api url` | The base URL of the JIRA API. It may be omitted, in which case GitLab will automatically use API version `2` based on the `project url`. It is of the form: `https://<jira_host_url>/rest/api/2`. | | `Api url` | The base URL of the JIRA API. It may be omitted, in which case GitLab will automatically use API version `2` based on the `project url`. It is of the form: `https://<jira_host_url>/rest/api/2`. |
| `Username` | The username of the user created in [configuring JIRA step](#configuring-jira). | | `Username` | The username of the user created in [configuring JIRA step](#configuring-jira). |
| `Password` |The password of the user created in [configuring JIRA step](#configuring-jira). | | `Password` |The password of the user created in [configuring JIRA step](#configuring-jira). |
| `JIRA issue transition` | This setting is very important to set up correctly. It is the ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot](img/jira_issues_workflow.png)). By default, this ID is set to `2` | | `JIRA issue transition` | This setting is very important to set up correctly. It is the ID of a transition that moves issues to a closed state. You can find this number under the JIRA workflow administration (**Administration > Issues > Workflows**) by selecting **View** under **Operations** of the desired workflow of your project. The ID of each state can be found inside the parenthesis of each transition name under the **Transitions (id)** column ([see screenshot][trans]). By default, this ID is set to `2`. |
After saving the configuration, your GitLab project will be able to interact After saving the configuration, your GitLab project will be able to interact
with the linked JIRA project. with the linked JIRA project.
For example, given the settings below:
- the JIRA URL is `https://jira.example.com`
- the project is named `GITLAB`
- the user is named `gitlab`
- the JIRA issue transition is 151 (based on the [JIRA issue transition][trans])
the following screenshot shows how the JIRA service settings should look like.
![JIRA service page](img/jira_service_page.png) ![JIRA service page](img/jira_service_page.png)
[trans]: img/jira_issues_workflow.png
--- ---
## JIRA issues ## JIRA issues
......
...@@ -66,6 +66,35 @@ the target branch. Click **Create directory** to finish. ...@@ -66,6 +66,35 @@ the target branch. Click **Create directory** to finish.
## Create a new branch ## Create a new branch
There are multiple ways to create a branch from GitLab's web interface.
### Create a new branch from an issue
>**Note:**
This feature was [introduced][ce-2808] in GitLab 8.6.
In case your development workflow dictates to have an issue for every merge
request, you can quickly create a branch right on the issue page which will be
tied with the issue itself. You can see a **New Branch** button after the issue
description, unless there is already a branch with the same name or a referenced
merge request.
![New Branch Button](img/new_branch_from_issue.png)
Once you click it, a new branch will be created that diverges from the default
branch of your project, by default `master`. The branch name will be based on
the title of the issue and as suffix it will have its ID. Thus, the example
screenshot above will yield a branch named
`et-cum-et-sed-expedita-repellat-consequatur-ut-assumenda-numquam-rerum-2`.
After the branch is created, you can edit files in the repository to fix
the issue. When a merge request is created based on the newly created branch,
the description field will automatically display the [issue closing pattern]
`Closes #ID`, where `ID` the ID of the issue. This will close the issue once the
merge request is merged.
### Create a new branch from a project's dashboard
If you want to make changes to several files before creating a new merge If you want to make changes to several files before creating a new merge
request, you can create a new branch up front. From a project's files page, request, you can create a new branch up front. From a project's files page,
choose **New branch** from the dropdown. choose **New branch** from the dropdown.
...@@ -118,3 +147,6 @@ appear that is labeled **Start a new merge request with these changes**. After ...@@ -118,3 +147,6 @@ appear that is labeled **Start a new merge request with these changes**. After
you commit the changes you will be taken to a new merge request form. you commit the changes you will be taken to a new merge request form.
![Start a new merge request with these changes](img/web_editor_start_new_merge_request.png) ![Start a new merge request with these changes](img/web_editor_start_new_merge_request.png)
[ce-2808]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2808
[issue closing pattern]: ../customization/issue_closing.md
...@@ -7,3 +7,9 @@ Feature: Profile Notifications ...@@ -7,3 +7,9 @@ Feature: Profile Notifications
Scenario: I visit notifications tab Scenario: I visit notifications tab
When I visit profile notifications page When I visit profile notifications page
Then I should see global notifications settings Then I should see global notifications settings
@javascript
Scenario: I edit Project Notifications
Given I visit profile notifications page
When I select Mention setting from dropdown
Then I should see Notification saved message
...@@ -9,4 +9,14 @@ class Spinach::Features::ProfileNotifications < Spinach::FeatureSteps ...@@ -9,4 +9,14 @@ class Spinach::Features::ProfileNotifications < Spinach::FeatureSteps
step 'I should see global notifications settings' do step 'I should see global notifications settings' do
expect(page).to have_content "Notifications" expect(page).to have_content "Notifications"
end end
step 'I select Mention setting from dropdown' do
select 'mention', from: 'notification_setting_level'
end
step 'I should see Notification saved message' do
page.within '.flash-container' do
expect(page).to have_content 'Notification settings saved'
end
end
end end
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -263,14 +263,19 @@ module API ...@@ -263,14 +263,19 @@ module API
expose :id, :path, :kind expose :id, :path, :kind
end end
class ProjectAccess < Grape::Entity class Member < Grape::Entity
expose :access_level expose :access_level
expose :notification_level expose :notification_level do |member, options|
if member.notification_setting
NotificationSetting.levels[member.notification_setting.level]
end
end
end end
class GroupAccess < Grape::Entity class ProjectAccess < Member
expose :access_level end
expose :notification_level
class GroupAccess < Member
end end
class ProjectService < Grape::Entity class ProjectService < Grape::Entity
......
...@@ -25,6 +25,8 @@ module API ...@@ -25,6 +25,8 @@ module API
# Parameters: # Parameters:
# name (required) - The name of the group # name (required) - The name of the group
# path (required) - The path of the group # path (required) - The path of the group
# description (optional) - The description of the group
# visibility_level (optional) - The visibility level of the group
# Example Request: # Example Request:
# POST /groups # POST /groups
post do post do
...@@ -42,6 +44,28 @@ module API ...@@ -42,6 +44,28 @@ module API
end end
end end
# Update group. Available only for users who can administrate groups.
#
# Parameters:
# id (required) - The ID of a group
# path (optional) - The path of the group
# description (optional) - The description of the group
# visibility_level (optional) - The visibility level of the group
# Example Request:
# PUT /groups/:id
put ':id' do
group = find_group(params[:id])
authorize! :admin_group, group
attrs = attributes_for_keys [:name, :path, :description, :visibility_level]
if ::Groups::UpdateService.new(group, current_user, attrs).execute
present group, with: Entities::GroupDetail
else
render_validation_error!(group)
end
end
# Get a single group, with containing projects # Get a single group, with containing projects
# #
# Parameters: # Parameters:
......
...@@ -91,8 +91,7 @@ module API ...@@ -91,8 +91,7 @@ module API
if can?(current_user, :read_group, group) if can?(current_user, :read_group, group)
group group
else else
forbidden!("#{current_user.username} lacks sufficient "\ not_found!('Group')
"access to #{group.name}")
end end
end end
......
...@@ -195,6 +195,29 @@ module API ...@@ -195,6 +195,29 @@ module API
end end
end end
# Move an existing issue
#
# Parameters:
# id (required) - The ID of a project
# issue_id (required) - The ID of a project issue
# to_project_id (required) - The ID of the new project
# Example Request:
# POST /projects/:id/issues/:issue_id/move
post ':id/issues/:issue_id/move' do
required_attributes! [:to_project_id]
issue = user_project.issues.find(params[:issue_id])
new_project = Project.find(params[:to_project_id])
begin
issue = ::Issues::MoveService.new(user_project, current_user).execute(issue, new_project)
present issue, with: Entities::Issue, current_user: current_user
rescue ::Issues::MoveService::MoveError => error
render_api_error!(error.message, 400)
end
end
#
# Delete a project issue # Delete a project issue
# #
# Parameters: # Parameters:
......
...@@ -112,6 +112,23 @@ module API ...@@ -112,6 +112,23 @@ module API
end end
end end
# Delete a +noteable+ note
#
# Parameters:
# id (required) - The ID of a project
# noteable_id (required) - The ID of an issue, MR, or snippet
# node_id (required) - The ID of a note
# Example Request:
# DELETE /projects/:id/issues/:noteable_id/notes/:note_id
# DELETE /projects/:id/snippets/:noteable_id/notes/:node_id
delete ":id/#{noteables_str}/:#{noteable_id_str}/notes/:note_id" do
note = user_project.notes.find(params[:note_id])
authorize! :admin_note, note
::Notes::DeleteService.new(user_project, current_user).execute(note)
present note, with: Entities::Note
end
end end
end end
end end
......
...@@ -93,12 +93,17 @@ module API ...@@ -93,12 +93,17 @@ module API
# Example Request: # Example Request:
# DELETE /projects/:id/members/:user_id # DELETE /projects/:id/members/:user_id
delete ":id/members/:user_id" do delete ":id/members/:user_id" do
authorize! :admin_project, user_project
project_member = user_project.project_members.find_by(user_id: params[:user_id]) project_member = user_project.project_members.find_by(user_id: params[:user_id])
unless project_member.nil?
project_member.destroy unless current_user.can?(:admin_project, user_project) ||
else current_user.can?(:destroy_project_member, project_member)
forbidden!
end
if project_member.nil?
{ message: "Access revoked", id: params[:user_id].to_i } { message: "Access revoked", id: params[:user_id].to_i }
else
project_member.destroy
end end
end end
end end
......
...@@ -16,6 +16,20 @@ module API ...@@ -16,6 +16,20 @@ module API
with: Entities::RepoTag, project: user_project with: Entities::RepoTag, project: user_project
end end
# Get a single repository tag
#
# Parameters:
# id (required) - The ID of a project
# tag_name (required) - The name of the tag
# Example Request:
# GET /projects/:id/repository/tags/:tag_name
get ":id/repository/tags/:tag_name", requirements: { tag_name: /.+/ } do
tag = user_project.repository.find_tag(params[:tag_name])
not_found!('Tag') unless tag
present tag, with: Entities::RepoTag, project: user_project
end
# Create tag # Create tag
# #
# Parameters: # Parameters:
......
require 'gitlab/git' require 'gitlab/git'
module Gitlab module Gitlab
def self.com?
Gitlab.config.gitlab.url == 'https://gitlab.com'
end
end end
...@@ -104,6 +104,16 @@ module Gitlab ...@@ -104,6 +104,16 @@ module Gitlab
retval retval
end end
# Adds a tag to the current transaction (if any)
#
# name - The name of the tag to add.
# value - The value of the tag.
def self.tag_transaction(name, value)
trans = current_transaction
trans.add_tag(name, value) if trans
end
# When enabled this should be set before being used as the usual pattern # When enabled this should be set before being used as the usual pattern
# "@foo ||= bar" is _not_ thread-safe. # "@foo ||= bar" is _not_ thread-safe.
if enabled? if enabled?
......
...@@ -26,7 +26,7 @@ module Gitlab ...@@ -26,7 +26,7 @@ module Gitlab
@user ||= build_new_user @user ||= build_new_user
end end
if external_users_enabled? if external_users_enabled? && @user
# Check if there is overlap between the user's groups and the external groups # Check if there is overlap between the user's groups and the external groups
# setting then set user as external or internal. # setting then set user as external or internal.
if (auth_hash.groups & Gitlab::Saml::Config.external_groups).empty? if (auth_hash.groups & Gitlab::Saml::Config.external_groups).empty?
...@@ -48,6 +48,7 @@ module Gitlab ...@@ -48,6 +48,7 @@ module Gitlab
end end
def changed? def changed?
return true unless gl_user
gl_user.changed? || gl_user.identities.any?(&:changed?) gl_user.changed? || gl_user.identities.any?(&:changed?)
end end
......
...@@ -5,12 +5,23 @@ namespace :gemojione do ...@@ -5,12 +5,23 @@ namespace :gemojione do
require 'json' require 'json'
dir = Gemojione.index.images_path dir = Gemojione.index.images_path
digests = []
aliases = Hash.new { |hash, key| hash[key] = [] }
aliases_path = File.join(Rails.root, 'fixtures', 'emojis', 'aliases.json')
digests = AwardEmoji.emojis.map do |name, emoji_hash| JSON.parse(File.read(aliases_path)).each do |alias_name, real_name|
aliases[real_name] << alias_name
end
AwardEmoji.emojis.map do |name, emoji_hash|
fpath = File.join(dir, "#{emoji_hash['unicode']}.png") fpath = File.join(dir, "#{emoji_hash['unicode']}.png")
digest = Digest::SHA256.file(fpath).hexdigest digest = Digest::SHA256.file(fpath).hexdigest
{ name: name, unicode: emoji_hash['unicode'], digest: digest } digests << { name: name, unicode: emoji_hash['unicode'], digest: digest }
aliases[name].each do |alias_name|
digests << { name: alias_name, unicode: emoji_hash['unicode'], digest: digest }
end
end end
out = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json') out = File.join(Rails.root, 'fixtures', 'emojis', 'digests.json')
......
require 'spec_helper'
describe Groups::NotificationSettingsController do
let(:group) { create(:group) }
let(:user) { create(:user) }
describe '#update' do
context 'when not authorized' do
it 'redirects to sign in page' do
put :update,
group_id: group.to_param,
notification_setting: { level: :participating }
expect(response).to redirect_to(new_user_session_path)
end
end
context 'when authorized' do
before do
sign_in(user)
end
it 'returns success' do
put :update,
group_id: group.to_param,
notification_setting: { level: :participating }
expect(response.status).to eq 200
end
end
end
end
...@@ -157,6 +157,34 @@ describe Projects::MergeRequestsController do ...@@ -157,6 +157,34 @@ describe Projects::MergeRequestsController do
end end
end end
describe 'PUT #update' do
context 'there is no source project' do
let(:project) { create(:project) }
let(:fork_project) { create(:forked_project_with_submodules) }
let(:merge_request) { create(:merge_request, source_project: fork_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) }
before do
fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id)
fork_project.save
merge_request.reload
fork_project.destroy
end
it 'closes MR without errors' do
post :update,
namespace_id: project.namespace.path,
project_id: project.path,
id: merge_request.iid,
merge_request: {
state_event: 'close'
}
expect(response).to redirect_to([merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request])
expect(merge_request.reload.closed?).to be_truthy
end
end
end
describe "DELETE #destroy" do describe "DELETE #destroy" do
it "denies access to users unless they're admin or project owner" do it "denies access to users unless they're admin or project owner" do
delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: merge_request.iid delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: merge_request.iid
......
require 'spec_helper'
describe Projects::NotificationSettingsController do
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
before do
project.team << [user, :developer]
end
describe '#update' do
context 'when not authorized' do
it 'redirects to sign in page' do
put :update,
namespace_id: project.namespace.to_param,
project_id: project.to_param,
notification_setting: { level: :participating }
expect(response).to redirect_to(new_user_session_path)
end
end
context 'when authorized' do
before do
sign_in(user)
end
it 'returns success' do
put :update,
namespace_id: project.namespace.to_param,
project_id: project.to_param,
notification_setting: { level: :participating }
expect(response.status).to eq 200
end
end
end
end
require 'spec_helper'
feature 'Edit Merge Request', feature: true do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:merge_request) { create(:merge_request, :with_diffs, source_project: project) }
before do
project.team << [user, :master]
login_as user
visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request)
end
context 'editing a MR' do
it 'form should have class js-quick-submit' do
expect(page).to have_selector('.js-quick-submit')
end
end
end
...@@ -100,8 +100,7 @@ feature 'Project', feature: true do ...@@ -100,8 +100,7 @@ feature 'Project', feature: true do
it 'click toggle and show dropdown', js: true do it 'click toggle and show dropdown', js: true do
find('.js-projects-dropdown-toggle').click find('.js-projects-dropdown-toggle').click
wait_for_ajax expect(page).to have_css('.dropdown-menu-projects .dropdown-content li', count: 1)
expect(page).to have_css('.select2-results li', count: 1)
end end
end end
......
...@@ -2,34 +2,15 @@ require 'spec_helper' ...@@ -2,34 +2,15 @@ require 'spec_helper'
describe NotificationsHelper do describe NotificationsHelper do
describe 'notification_icon' do describe 'notification_icon' do
let(:notification) { double(disabled?: false, participating?: false, watch?: false) } it { expect(notification_icon(:disabled)).to match('class="fa fa-microphone-slash fa-fw"') }
it { expect(notification_icon(:participating)).to match('class="fa fa-volume-up fa-fw"') }
context "disabled notification" do it { expect(notification_icon(:mention)).to match('class="fa fa-at fa-fw"') }
before { allow(notification).to receive(:disabled?).and_return(true) } it { expect(notification_icon(:global)).to match('class="fa fa-globe fa-fw"') }
it { expect(notification_icon(:watch)).to match('class="fa fa-eye fa-fw"') }
it "has a red icon" do
expect(notification_icon(notification)).to match('class="fa fa-volume-off ns-mute"')
end
end
context "participating notification" do
before { allow(notification).to receive(:participating?).and_return(true) }
it "has a blue icon" do
expect(notification_icon(notification)).to match('class="fa fa-volume-down ns-part"')
end
end
context "watched notification" do
before { allow(notification).to receive(:watch?).and_return(true) }
it "has a green icon" do
expect(notification_icon(notification)).to match('class="fa fa-volume-up ns-watch"')
end
end end
it "has a blue icon" do describe 'notification_title' do
expect(notification_icon(notification)).to match('class="fa fa-circle-o ns-default"') it { expect(notification_title(:watch)).to match('Watch') }
end it { expect(notification_title(:mention)).to match('On mention') }
end end
end end
%h1.title .header-content
%h1.title
%a %a
GitLab Org GitLab Org
%a.project-item-select-holder{href: "/gitlab-org/gitlab-test"} %a.project-item-select-holder{href: "/gitlab-org/gitlab-test"}
GitLab Test GitLab Test
%input#project_path.project-item-select.js-projects-dropdown.ajax-project-select{type: "hidden", name: "project_path", "data-include-groups" => "false"} %i.fa.chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle{ "data-toggle" => "dropdown", "data-target" => ".header-content" }
%i.fa.chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle .js-dropdown-menu-projects
.dropdown-menu.dropdown-select.dropdown-menu-projects
.dropdown-title
%span Go to a project
%button.dropdown-title-button.dropdown-menu-close{"aria-label" => "Close", type: "button"}
%i.fa.fa-times.dropdown-menu-close-icon
.dropdown-input
%input.dropdown-input-field{id: "", placeholder: "Search your projects", type: "search", value: ""}
%i.fa.fa-search.dropdown-input-search
%i.fa.fa-times.dropdown-input-clear.js-dropdown-input-clear{role: "button"}
.dropdown-content
.dropdown-loading
%i.fa.fa-spinner.fa-spin
#= require bootstrap
#= require select2 #= require select2
#= require gl_dropdown
#= require api #= require api
#= require project_select #= require project_select
#= require project #= require project
...@@ -14,9 +16,6 @@ describe 'Project Title', -> ...@@ -14,9 +16,6 @@ describe 'Project Title', ->
fixture.load('project_title.html') fixture.load('project_title.html')
@project = new Project() @project = new Project()
spyOn(@project, 'changeProject').and.callFake (url) ->
window.current_project_url = url
describe 'project list', -> describe 'project list', ->
beforeEach => beforeEach =>
@projects_data = fixture.load('projects.json')[0] @projects_data = fixture.load('projects.json')[0]
...@@ -29,18 +28,9 @@ describe 'Project Title', -> ...@@ -29,18 +28,9 @@ describe 'Project Title', ->
it 'to show on toggle click', => it 'to show on toggle click', =>
$('.js-projects-dropdown-toggle').click() $('.js-projects-dropdown-toggle').click()
expect($('.header-content').hasClass('open')).toBe(true)
expect($('.title .select2-container').hasClass('select2-dropdown-open')).toBe(true)
expect($('.ajax-project-dropdown li').length).toBe(@projects_data.length)
it 'hide dropdown', -> it 'hide dropdown', ->
$("#select2-drop-mask").click() $(".dropdown-menu-close-icon").click()
expect($('.title .select2-container').hasClass('select2-dropdown-open')).toBe(false)
it 'change project when clicking item', ->
$('.js-projects-dropdown-toggle').click()
$('.ajax-project-dropdown li:nth-child(2)').trigger('mouseup')
expect($('.title .select2-container').hasClass('select2-dropdown-open')).toBe(false) expect($('.header-content').hasClass('open')).toBe(false)
expect(window.current_project_url).toBe('http://localhost:3000/h5bp/html5-boilerplate')
...@@ -98,4 +98,29 @@ describe Gitlab::Metrics do ...@@ -98,4 +98,29 @@ describe Gitlab::Metrics do
end end
end end
end end
describe '.tag_transaction' do
context 'without a transaction' do
it 'does nothing' do
expect_any_instance_of(Gitlab::Metrics::Transaction).
not_to receive(:add_tag)
Gitlab::Metrics.tag_transaction(:foo, 'bar')
end
end
context 'with a transaction' do
let(:transaction) { Gitlab::Metrics::Transaction.new }
it 'adds the tag to the transaction' do
expect(Gitlab::Metrics).to receive(:current_transaction).
and_return(transaction)
expect(transaction).to receive(:add_tag).
with(:foo, 'bar')
Gitlab::Metrics.tag_transaction(:foo, 'bar')
end
end
end
end end
require 'rails_helper'
describe Gitlab, lib: true do
describe '.com?' do
it 'is true when on GitLab.com' do
stub_config_setting(url: 'https://gitlab.com')
expect(described_class.com?).to eq true
end
it 'is false when not on GitLab.com' do
stub_config_setting(url: 'http://example.com')
expect(described_class.com?).to eq false
end
end
end
require 'rails_helper'
RSpec.describe NotificationSetting, type: :model do
describe "Associations" do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:source) }
end
describe "Validation" do
subject { NotificationSetting.new(source_id: 1, source_type: 'Project') }
it { is_expected.to validate_presence_of(:user) }
it { is_expected.to validate_presence_of(:source) }
it { is_expected.to validate_presence_of(:level) }
it { is_expected.to validate_uniqueness_of(:user_id).scoped_to([:source_id, :source_type]).with_message(/already exists in source/) }
end
end
...@@ -393,6 +393,8 @@ describe Repository, models: true do ...@@ -393,6 +393,8 @@ describe Repository, models: true do
describe '#expire_cache' do describe '#expire_cache' do
it 'expires all caches' do it 'expires all caches' do
expect(repository).to receive(:expire_branch_cache) expect(repository).to receive(:expire_branch_cache)
expect(repository).to receive(:expire_branch_count_cache)
expect(repository).to receive(:expire_tag_count_cache)
repository.expire_cache repository.expire_cache
end end
......
...@@ -42,9 +42,10 @@ describe API::API, api: true do ...@@ -42,9 +42,10 @@ describe API::API, api: true do
end end
end end
it "users not part of the group should get access error" do it 'users not part of the group should get access error' do
get api("/groups/#{group_with_members.id}/members", stranger) get api("/groups/#{group_with_members.id}/members", stranger)
expect(response.status).to eq(403)
expect(response.status).to eq(404)
end end
end end
end end
...@@ -165,12 +166,13 @@ describe API::API, api: true do ...@@ -165,12 +166,13 @@ describe API::API, api: true do
end end
end end
describe "DELETE /groups/:id/members/:user_id" do describe 'DELETE /groups/:id/members/:user_id' do
context "when not a member of the group" do context 'when not a member of the group' do
it "should not delete guest's membership of group_with_members" do it "should not delete guest's membership of group_with_members" do
random_user = create(:user) random_user = create(:user)
delete api("/groups/#{group_with_members.id}/members/#{owner.id}", random_user) delete api("/groups/#{group_with_members.id}/members/#{owner.id}", random_user)
expect(response.status).to eq(403)
expect(response.status).to eq(404)
end end
end end
......
...@@ -61,7 +61,8 @@ describe API::API, api: true do ...@@ -61,7 +61,8 @@ describe API::API, api: true do
it "should not return a group not attached to user1" do it "should not return a group not attached to user1" do
get api("/groups/#{group2.id}", user1) get api("/groups/#{group2.id}", user1)
expect(response.status).to eq(403)
expect(response.status).to eq(404)
end end
end end
...@@ -92,9 +93,54 @@ describe API::API, api: true do ...@@ -92,9 +93,54 @@ describe API::API, api: true do
it 'should not return a group not attached to user1' do it 'should not return a group not attached to user1' do
get api("/groups/#{group2.path}", user1) get api("/groups/#{group2.path}", user1)
expect(response.status).to eq(404)
end
end
end
describe 'PUT /groups/:id' do
let(:new_group_name) { 'New Group'}
context 'when authenticated as the group owner' do
it 'updates the group' do
put api("/groups/#{group1.id}", user1), name: new_group_name
expect(response.status).to eq(200)
expect(json_response['name']).to eq(new_group_name)
end
it 'returns 404 for a non existing group' do
put api('/groups/1328', user1)
expect(response.status).to eq(404)
end
end
context 'when authenticated as the admin' do
it 'updates the group' do
put api("/groups/#{group1.id}", admin), name: new_group_name
expect(response.status).to eq(200)
expect(json_response['name']).to eq(new_group_name)
end
end
context 'when authenticated as an user that can see the group' do
it 'does not updates the group' do
put api("/groups/#{group1.id}", user2), name: new_group_name
expect(response.status).to eq(403) expect(response.status).to eq(403)
end end
end end
context 'when authenticated as an user that cannot see the group' do
it 'returns 404 when trying to update the group' do
put api("/groups/#{group2.id}", user1), name: new_group_name
expect(response.status).to eq(404)
end
end
end end
describe "GET /groups/:id/projects" do describe "GET /groups/:id/projects" do
...@@ -113,7 +159,8 @@ describe API::API, api: true do ...@@ -113,7 +159,8 @@ describe API::API, api: true do
it "should not return a group not attached to user1" do it "should not return a group not attached to user1" do
get api("/groups/#{group2.id}/projects", user1) get api("/groups/#{group2.id}/projects", user1)
expect(response.status).to eq(403)
expect(response.status).to eq(404)
end end
end end
...@@ -145,7 +192,8 @@ describe API::API, api: true do ...@@ -145,7 +192,8 @@ describe API::API, api: true do
it 'should not return a group not attached to user1' do it 'should not return a group not attached to user1' do
get api("/groups/#{group2.path}/projects", user1) get api("/groups/#{group2.path}/projects", user1)
expect(response.status).to eq(403)
expect(response.status).to eq(404)
end end
end end
end end
...@@ -203,7 +251,8 @@ describe API::API, api: true do ...@@ -203,7 +251,8 @@ describe API::API, api: true do
it "should not remove a group not attached to user1" do it "should not remove a group not attached to user1" do
delete api("/groups/#{group2.id}", user1) delete api("/groups/#{group2.id}", user1)
expect(response.status).to eq(403)
expect(response.status).to eq(404)
end end
end end
......
...@@ -7,7 +7,7 @@ describe API::API, api: true do ...@@ -7,7 +7,7 @@ describe API::API, api: true do
let(:author) { create(:author) } let(:author) { create(:author) }
let(:assignee) { create(:assignee) } let(:assignee) { create(:assignee) }
let(:admin) { create(:user, :admin) } let(:admin) { create(:user, :admin) }
let!(:project) { create(:project, :public, namespace: user.namespace ) } let!(:project) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
let!(:closed_issue) do let!(:closed_issue) do
create :closed_issue, create :closed_issue,
author: user, author: user,
...@@ -501,4 +501,72 @@ describe API::API, api: true do ...@@ -501,4 +501,72 @@ describe API::API, api: true do
end end
end end
end end
describe '/projects/:id/issues/:issue_id/move' do
let!(:target_project) { create(:project, path: 'project2', creator_id: user.id, namespace: user.namespace ) }
let!(:target_project2) { create(:project, creator_id: non_member.id, namespace: non_member.namespace ) }
it 'moves an issue' do
post api("/projects/#{project.id}/issues/#{issue.id}/move", user),
to_project_id: target_project.id
expect(response.status).to eq(201)
expect(json_response['project_id']).to eq(target_project.id)
end
context 'when source and target projects are the same' do
it 'returns 400 when trying to move an issue' do
post api("/projects/#{project.id}/issues/#{issue.id}/move", user),
to_project_id: project.id
expect(response.status).to eq(400)
expect(json_response['message']).to eq('Cannot move issue to project it originates from!')
end
end
context 'when the user does not have the permission to move issues' do
it 'returns 400 when trying to move an issue' do
post api("/projects/#{project.id}/issues/#{issue.id}/move", user),
to_project_id: target_project2.id
expect(response.status).to eq(400)
expect(json_response['message']).to eq('Cannot move issue due to insufficient permissions!')
end
end
it 'moves the issue to another namespace if I am admin' do
post api("/projects/#{project.id}/issues/#{issue.id}/move", admin),
to_project_id: target_project2.id
expect(response.status).to eq(201)
expect(json_response['project_id']).to eq(target_project2.id)
end
context 'when issue does not exist' do
it 'returns 404 when trying to move an issue' do
post api("/projects/#{project.id}/issues/123/move", user),
to_project_id: target_project.id
expect(response.status).to eq(404)
end
end
context 'when source project does not exist' do
it 'returns 404 when trying to move an issue' do
post api("/projects/123/issues/#{issue.id}/move", user),
to_project_id: target_project.id
expect(response.status).to eq(404)
end
end
context 'when target project does not exist' do
it 'returns 404 when trying to move an issue' do
post api("/projects/#{project.id}/issues/#{issue.id}/move", user),
to_project_id: 123
expect(response.status).to eq(404)
end
end
end
end end
...@@ -241,4 +241,65 @@ describe API::API, api: true do ...@@ -241,4 +241,65 @@ describe API::API, api: true do
end end
end end
describe 'DELETE /projects/:id/noteable/:noteable_id/notes/:note_id' do
context 'when noteable is an Issue' do
it 'deletes a note' do
delete api("/projects/#{project.id}/issues/#{issue.id}/"\
"notes/#{issue_note.id}", user)
expect(response.status).to eq(200)
# Check if note is really deleted
delete api("/projects/#{project.id}/issues/#{issue.id}/"\
"notes/#{issue_note.id}", user)
expect(response.status).to eq(404)
end
it 'returns a 404 error when note id not found' do
delete api("/projects/#{project.id}/issues/#{issue.id}/notes/123", user)
expect(response.status).to eq(404)
end
end
context 'when noteable is a Snippet' do
it 'deletes a note' do
delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\
"notes/#{snippet_note.id}", user)
expect(response.status).to eq(200)
# Check if note is really deleted
delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\
"notes/#{snippet_note.id}", user)
expect(response.status).to eq(404)
end
it 'returns a 404 error when note id not found' do
delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\
"notes/123", user)
expect(response.status).to eq(404)
end
end
context 'when noteable is a Merge Request' do
it 'deletes a note' do
delete api("/projects/#{project.id}/merge_requests/"\
"#{merge_request.id}/notes/#{merge_request_note.id}", user)
expect(response.status).to eq(200)
# Check if note is really deleted
delete api("/projects/#{project.id}/merge_requests/"\
"#{merge_request.id}/notes/#{merge_request_note.id}", user)
expect(response.status).to eq(404)
end
it 'returns a 404 error when note id not found' do
delete api("/projects/#{project.id}/merge_requests/"\
"#{merge_request.id}/notes/123", user)
expect(response.status).to eq(404)
end
end
end
end end
...@@ -118,8 +118,10 @@ describe API::API, api: true do ...@@ -118,8 +118,10 @@ describe API::API, api: true do
end end
describe "DELETE /projects/:id/members/:user_id" do describe "DELETE /projects/:id/members/:user_id" do
before { project_member } before do
before { project_member2 } project_member
project_member2
end
it "should remove user from project team" do it "should remove user from project team" do
expect do expect do
...@@ -132,6 +134,7 @@ describe API::API, api: true do ...@@ -132,6 +134,7 @@ describe API::API, api: true do
expect do expect do
delete api("/projects/#{project.id}/members/#{user3.id}", user) delete api("/projects/#{project.id}/members/#{user3.id}", user)
end.to_not change { ProjectMember.count } end.to_not change { ProjectMember.count }
expect(response.status).to eq(200)
end end
it "should return 200 if team member already removed" do it "should return 200 if team member already removed" do
...@@ -145,8 +148,19 @@ describe API::API, api: true do ...@@ -145,8 +148,19 @@ describe API::API, api: true do
delete api("/projects/#{project.id}/members/1000000", user) delete api("/projects/#{project.id}/members/1000000", user)
end.to change { ProjectMember.count }.by(0) end.to change { ProjectMember.count }.by(0)
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(json_response['message']).to eq("Access revoked")
expect(json_response['id']).to eq(1000000) expect(json_response['id']).to eq(1000000)
expect(json_response['message']).to eq('Access revoked')
end
context 'when the user is not an admin or owner' do
it 'can leave the project' do
expect do
delete api("/projects/#{project.id}/members/#{user3.id}", user3)
end.to change { ProjectMember.count }.by(-1)
expect(response.status).to eq(200)
expect(json_response['id']).to eq(project_member2.id)
end
end end
end end
end end
...@@ -40,6 +40,23 @@ describe API::API, api: true do ...@@ -40,6 +40,23 @@ describe API::API, api: true do
end end
end end
describe 'GET /projects/:id/repository/tags/:tag_name' do
let(:tag_name) { project.repository.tag_names.sort.reverse.first }
it 'returns a specific tag' do
get api("/projects/#{project.id}/repository/tags/#{tag_name}", user)
expect(response.status).to eq(200)
expect(json_response['name']).to eq(tag_name)
end
it 'returns 404 for an invalid tag name' do
get api("/projects/#{project.id}/repository/tags/foobar", user)
expect(response.status).to eq(404)
end
end
describe 'POST /projects/:id/repository/tags' do describe 'POST /projects/:id/repository/tags' do
context 'lightweight tags' do context 'lightweight tags' do
it 'should create a new tag' do it 'should create a new tag' do
......
require 'spec_helper'
describe Notes::DeleteService, services: true do
describe '#execute' do
it 'deletes a note' do
project = create(:empty_project)
issue = create(:issue, project: project)
note = create(:note, project: project, noteable: issue)
described_class.new(project, note.author).execute(note)
expect(project.issues.find(issue.id).notes).not_to include(note)
end
end
end
...@@ -88,12 +88,9 @@ describe NotificationService, services: true do ...@@ -88,12 +88,9 @@ describe NotificationService, services: true do
note.project.namespace_id = group.id note.project.namespace_id = group.id
note.project.group.add_user(@u_watcher, GroupMember::MASTER) note.project.group.add_user(@u_watcher, GroupMember::MASTER)
note.project.save note.project.save
user_project = note.project.project_members.find_by_user_id(@u_watcher.id)
user_project.notification_level = Notification::N_PARTICIPATING @u_watcher.notification_settings_for(note.project).participating!
user_project.save @u_watcher.notification_settings_for(note.project.group).global!
group_member = note.project.group.group_members.find_by_user_id(@u_watcher.id)
group_member.notification_level = Notification::N_GLOBAL
group_member.save
ActionMailer::Base.deliveries.clear ActionMailer::Base.deliveries.clear
end end
...@@ -215,7 +212,7 @@ describe NotificationService, services: true do ...@@ -215,7 +212,7 @@ describe NotificationService, services: true do
end end
it do it do
@u_committer.update_attributes(notification_level: Notification::N_MENTION) @u_committer.update_attributes(notification_level: :mention)
notification.new_note(note) notification.new_note(note)
should_not_email(@u_committer) should_not_email(@u_committer)
end end
...@@ -246,7 +243,7 @@ describe NotificationService, services: true do ...@@ -246,7 +243,7 @@ describe NotificationService, services: true do
end end
it do it do
issue.assignee.update_attributes(notification_level: Notification::N_MENTION) issue.assignee.update_attributes(notification_level: :mention)
notification.new_issue(issue, @u_disabled) notification.new_issue(issue, @u_disabled)
should_not_email(issue.assignee) should_not_email(issue.assignee)
...@@ -596,13 +593,13 @@ describe NotificationService, services: true do ...@@ -596,13 +593,13 @@ describe NotificationService, services: true do
end end
def build_team(project) def build_team(project)
@u_watcher = create(:user, notification_level: Notification::N_WATCH) @u_watcher = create(:user, notification_level: :watch)
@u_participating = create(:user, notification_level: Notification::N_PARTICIPATING) @u_participating = create(:user, notification_level: :participating)
@u_participant_mentioned = create(:user, username: 'participant', notification_level: Notification::N_PARTICIPATING) @u_participant_mentioned = create(:user, username: 'participant', notification_level: :participating)
@u_disabled = create(:user, notification_level: Notification::N_DISABLED) @u_disabled = create(:user, notification_level: :disabled)
@u_mentioned = create(:user, username: 'mention', notification_level: Notification::N_MENTION) @u_mentioned = create(:user, username: 'mention', notification_level: :mention)
@u_committer = create(:user, username: 'committer') @u_committer = create(:user, username: 'committer')
@u_not_mentioned = create(:user, username: 'regular', notification_level: Notification::N_PARTICIPATING) @u_not_mentioned = create(:user, username: 'regular', notification_level: :participating)
@u_outsider_mentioned = create(:user, username: 'outsider') @u_outsider_mentioned = create(:user, username: 'outsider')
project.team << [@u_watcher, :master] project.team << [@u_watcher, :master]
...@@ -617,8 +614,8 @@ describe NotificationService, services: true do ...@@ -617,8 +614,8 @@ describe NotificationService, services: true do
def add_users_with_subscription(project, issuable) def add_users_with_subscription(project, issuable)
@subscriber = create :user @subscriber = create :user
@unsubscriber = create :user @unsubscriber = create :user
@subscribed_participant = create(:user, username: 'subscribed_participant', notification_level: Notification::N_PARTICIPATING) @subscribed_participant = create(:user, username: 'subscribed_participant', notification_level: :participating)
@watcher_and_subscriber = create(:user, notification_level: Notification::N_WATCH) @watcher_and_subscriber = create(:user, notification_level: :watch)
project.team << [@subscribed_participant, :master] project.team << [@subscribed_participant, :master]
project.team << [@subscriber, :master] project.team << [@subscriber, :master]
......
/*
* Date Format 1.2.3
* (c) 2007-2009 Steven Levithan <stevenlevithan.com>
* MIT license
*
* Includes enhancements by Scott Trenda <scott.trenda.net>
* and Kris Kowal <cixar.com/~kris.kowal/>
*
* Accepts a date, a mask, or a date and a mask.
* Returns a formatted version of the given date.
* The date defaults to the current date/time.
* The mask defaults to dateFormat.masks.default.
*/
var dateFormat = function () {
var token = /d{1,4}|m{1,4}|yy(?:yy)?|([HhMsTt])\1?|[LloSZ]|"[^"]*"|'[^']*'/g,
timezone = /\b(?:[PMCEA][SDP]T|(?:Pacific|Mountain|Central|Eastern|Atlantic) (?:Standard|Daylight|Prevailing) Time|(?:GMT|UTC)(?:[-+]\d{4})?)\b/g,
timezoneClip = /[^-+\dA-Z]/g,
pad = function (val, len) {
val = String(val);
len = len || 2;
while (val.length < len) val = "0" + val;
return val;
};
// Regexes and supporting functions are cached through closure
return function (date, mask, utc) {
var dF = dateFormat;
// You can't provide utc if you skip other args (use the "UTC:" mask prefix)
if (arguments.length == 1 && Object.prototype.toString.call(date) == "[object String]" && !/\d/.test(date)) {
mask = date;
date = undefined;
}
// Passing date through Date applies Date.parse, if necessary
date = date ? new Date(date) : new Date;
if (isNaN(date)) throw SyntaxError("invalid date");
mask = String(dF.masks[mask] || mask || dF.masks["default"]);
// Allow setting the utc argument via the mask
if (mask.slice(0, 4) == "UTC:") {
mask = mask.slice(4);
utc = true;
}
var _ = utc ? "getUTC" : "get",
d = date[_ + "Date"](),
D = date[_ + "Day"](),
m = date[_ + "Month"](),
y = date[_ + "FullYear"](),
H = date[_ + "Hours"](),
M = date[_ + "Minutes"](),
s = date[_ + "Seconds"](),
L = date[_ + "Milliseconds"](),
o = utc ? 0 : date.getTimezoneOffset(),
flags = {
d: d,
dd: pad(d),
ddd: dF.i18n.dayNames[D],
dddd: dF.i18n.dayNames[D + 7],
m: m + 1,
mm: pad(m + 1),
mmm: dF.i18n.monthNames[m],
mmmm: dF.i18n.monthNames[m + 12],
yy: String(y).slice(2),
yyyy: y,
h: H % 12 || 12,
hh: pad(H % 12 || 12),
H: H,
HH: pad(H),
M: M,
MM: pad(M),
s: s,
ss: pad(s),
l: pad(L, 3),
L: pad(L > 99 ? Math.round(L / 10) : L),
t: H < 12 ? "a" : "p",
tt: H < 12 ? "am" : "pm",
T: H < 12 ? "A" : "P",
TT: H < 12 ? "AM" : "PM",
Z: utc ? "UTC" : (String(date).match(timezone) || [""]).pop().replace(timezoneClip, ""),
o: (o > 0 ? "-" : "+") + pad(Math.floor(Math.abs(o) / 60) * 100 + Math.abs(o) % 60, 4),
S: ["th", "st", "nd", "rd"][d % 10 > 3 ? 0 : (d % 100 - d % 10 != 10) * d % 10]
};
return mask.replace(token, function ($0) {
return $0 in flags ? flags[$0] : $0.slice(1, $0.length - 1);
});
};
}();
// Some common format strings
dateFormat.masks = {
"default": "ddd mmm dd yyyy HH:MM:ss",
shortDate: "m/d/yy",
mediumDate: "mmm d, yyyy",
longDate: "mmmm d, yyyy",
fullDate: "dddd, mmmm d, yyyy",
shortTime: "h:MM TT",
mediumTime: "h:MM:ss TT",
longTime: "h:MM:ss TT Z",
isoDate: "yyyy-mm-dd",
isoTime: "HH:MM:ss",
isoDateTime: "yyyy-mm-dd'T'HH:MM:ss",
isoUtcDateTime: "UTC:yyyy-mm-dd'T'HH:MM:ss'Z'"
};
// Internationalization strings
dateFormat.i18n = {
dayNames: [
"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
],
monthNames: [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"
]
};
// For convenience...
Date.prototype.format = function (mask, utc) {
return dateFormat(this, mask, utc);
};
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