Commit f9e2b473 authored by Clement Ho's avatar Clement Ho

Merge branch 'master' into 'bootstrap4'

# Conflicts:
#   app/views/projects/branches/_branch.html.haml
parents b49ac65e 5b92d405
...@@ -2,6 +2,14 @@ ...@@ -2,6 +2,14 @@
documentation](doc/development/changelog.md) for instructions on adding your own documentation](doc/development/changelog.md) for instructions on adding your own
entry. entry.
## 10.7.2 (2018-04-25)
### Security (2 changes)
- Serve archive requests with the correct file in all cases.
- Sanitizes user name to avoid XSS attacks.
## 10.7.1 (2018-04-23) ## 10.7.1 (2018-04-23)
### Fixed (11 changes) ### Fixed (11 changes)
...@@ -237,6 +245,13 @@ entry. ...@@ -237,6 +245,13 @@ entry.
- Upgrade Gitaly to upgrade its charlock_holmes. - Upgrade Gitaly to upgrade its charlock_holmes.
## 10.6.5 (2018-04-24)
### Security (1 change)
- Sanitizes user name to avoid XSS attacks.
## 10.6.4 (2018-04-09) ## 10.6.4 (2018-04-09)
### Fixed (8 changes, 1 of them is from the community) ### Fixed (8 changes, 1 of them is from the community)
...@@ -478,6 +493,13 @@ entry. ...@@ -478,6 +493,13 @@ entry.
- Use host URL to build JIRA remote link icon. - Use host URL to build JIRA remote link icon.
## 10.5.8 (2018-04-24)
### Security (1 change)
- Sanitizes user name to avoid XSS attacks.
## 10.5.7 (2018-04-03) ## 10.5.7 (2018-04-03)
### Security (2 changes) ### Security (2 changes)
......
...@@ -86,7 +86,7 @@ export default { ...@@ -86,7 +86,7 @@ export default {
v-html="resolveSvg" v-html="resolveSvg"
></span> ></span>
</span> </span>
<span class=".line-resolve-text"> <span class="line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
</span> </span>
</div> </div>
......
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore';
function isValidProjectId(id) { function isValidProjectId(id) {
return id > 0; return id > 0;
...@@ -43,7 +44,7 @@ class SidebarMoveIssue { ...@@ -43,7 +44,7 @@ class SidebarMoveIssue {
renderRow: project => ` renderRow: project => `
<li> <li>
<a href="#" class="js-move-issue-dropdown-item"> <a href="#" class="js-move-issue-dropdown-item">
${project.name_with_namespace} ${_.escape(project.name_with_namespace)}
</a> </a>
</li> </li>
`, `,
......
...@@ -17,7 +17,7 @@ export default { ...@@ -17,7 +17,7 @@ export default {
}, },
computed: { computed: {
/** /**
* This method is based on app/helpers/application_helper.rb#project_identicon * This method is based on app/helpers/avatars_helper.rb#project_identicon
*/ */
identiconStyles() { identiconStyles() {
const allowedColors = [ const allowedColors = [
......
...@@ -776,7 +776,3 @@ ul.notes { ...@@ -776,7 +776,3 @@ ul.notes {
height: auto; height: auto;
} }
} }
.line-resolve-text {
vertical-align: middle;
}
...@@ -8,8 +8,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -8,8 +8,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
omniauth_flow(Gitlab::Auth::OAuth) omniauth_flow(Gitlab::Auth::OAuth)
end end
Gitlab.config.omniauth.providers.each do |provider| AuthHelper.providers_for_base_controller.each do |provider|
alias_method provider['name'], :handle_omniauth alias_method provider, :handle_omniauth
end end
# Extend the standard implementation to also increment # Extend the standard implementation to also increment
......
...@@ -33,9 +33,7 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -33,9 +33,7 @@ class Projects::NotesController < Projects::ApplicationController
def resolve def resolve
return render_404 unless note.resolvable? return render_404 unless note.resolvable?
note.resolve!(current_user) Notes::ResolveService.new(project, current_user).execute(note)
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable)
discussion = note.discussion discussion = note.discussion
......
...@@ -39,7 +39,7 @@ class GroupsFinder < UnionFinder ...@@ -39,7 +39,7 @@ class GroupsFinder < UnionFinder
def all_groups def all_groups
return [owned_groups] if params[:owned] return [owned_groups] if params[:owned]
return [Group.all] if current_user&.full_private_access? return [Group.all] if current_user&.full_private_access? && all_available?
groups = [] groups = []
groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups if current_user groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups if current_user
...@@ -67,6 +67,10 @@ class GroupsFinder < UnionFinder ...@@ -67,6 +67,10 @@ class GroupsFinder < UnionFinder
end end
def include_public_groups? def include_public_groups?
current_user.nil? || params.fetch(:all_available, true) current_user.nil? || all_available?
end
def all_available?
params.fetch(:all_available, true)
end end
end end
...@@ -32,80 +32,6 @@ module ApplicationHelper ...@@ -32,80 +32,6 @@ module ApplicationHelper
args.any? { |v| v.to_s.downcase == action_name } args.any? { |v| v.to_s.downcase == action_name }
end end
def project_icon(project_id, options = {})
project =
if project_id.respond_to?(:avatar_url)
project_id
else
Project.find_by_full_path(project_id)
end
if project.avatar_url
image_tag project.avatar_url, options
else # generated icon
project_identicon(project, options)
end
end
def project_identicon(project, options = {})
allowed_colors = {
red: 'FFEBEE',
purple: 'F3E5F5',
indigo: 'E8EAF6',
blue: 'E3F2FD',
teal: 'E0F2F1',
orange: 'FBE9E7',
gray: 'EEEEEE'
}
options[:class] ||= ''
options[:class] << ' identicon'
bg_key = project.id % 7
style = "background-color: ##{allowed_colors.values[bg_key]}; color: #555"
content_tag(:div, class: options[:class], style: style) do
project.name[0, 1].upcase
end
end
# Takes both user and email and returns the avatar_icon by
# user (preferred) or email.
def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true)
if user
avatar_icon_for_user(user, size, scale, only_path: only_path)
elsif email
avatar_icon_for_email(email, size, scale, only_path: only_path)
else
default_avatar
end
end
def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true)
user = User.find_by_any_email(email.try(:downcase))
if user
avatar_icon_for_user(user, size, scale, only_path: only_path)
else
gravatar_icon(email, size, scale)
end
end
def avatar_icon_for_user(user = nil, size = nil, scale = 2, only_path: true)
if user
user.avatar_url(size: size, only_path: only_path) || default_avatar
else
gravatar_icon(nil, size, scale)
end
end
def gravatar_icon(user_email = '', size = nil, scale = 2)
GravatarService.new.execute(user_email, size, scale) ||
default_avatar
end
def default_avatar
asset_path('no_avatar.png')
end
def last_commit(project) def last_commit(project)
if project.repo_exists? if project.repo_exists?
time_ago_with_tooltip(project.repository.commit.committed_date) time_ago_with_tooltip(project.repository.commit.committed_date)
......
module AuthHelper module AuthHelper
PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
FORM_BASED_PROVIDERS = [/\Aldap/, 'crowd'].freeze LDAP_PROVIDER = /\Aldap/
def ldap_enabled? def ldap_enabled?
Gitlab::Auth::LDAP::Config.enabled? Gitlab::Auth::LDAP::Config.enabled?
...@@ -23,7 +23,7 @@ module AuthHelper ...@@ -23,7 +23,7 @@ module AuthHelper
end end
def form_based_provider?(name) def form_based_provider?(name)
FORM_BASED_PROVIDERS.any? { |pattern| pattern === name.to_s } [LDAP_PROVIDER, 'crowd'].any? { |pattern| pattern === name.to_s }
end end
def form_based_providers def form_based_providers
...@@ -38,6 +38,10 @@ module AuthHelper ...@@ -38,6 +38,10 @@ module AuthHelper
auth_providers.reject { |provider| form_based_provider?(provider) } auth_providers.reject { |provider| form_based_provider?(provider) }
end end
def providers_for_base_controller
auth_providers.reject { |provider| LDAP_PROVIDER === provider }
end
def enabled_button_based_providers def enabled_button_based_providers
disabled_providers = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources || [] disabled_providers = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources || []
......
module AvatarsHelper module AvatarsHelper
def project_icon(project_id, options = {})
project =
if project_id.respond_to?(:avatar_url)
project_id
else
Project.find_by_full_path(project_id)
end
if project.avatar_url
image_tag project.avatar_url, options
else # generated icon
project_identicon(project, options)
end
end
def project_identicon(project, options = {})
allowed_colors = {
red: 'FFEBEE',
purple: 'F3E5F5',
indigo: 'E8EAF6',
blue: 'E3F2FD',
teal: 'E0F2F1',
orange: 'FBE9E7',
gray: 'EEEEEE'
}
options[:class] ||= ''
options[:class] << ' identicon'
bg_key = project.id % 7
style = "background-color: ##{allowed_colors.values[bg_key]}; color: #555"
content_tag(:div, class: options[:class], style: style) do
project.name[0, 1].upcase
end
end
# Takes both user and email and returns the avatar_icon by
# user (preferred) or email.
def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true)
if user
avatar_icon_for_user(user, size, scale, only_path: only_path)
elsif email
avatar_icon_for_email(email, size, scale, only_path: only_path)
else
default_avatar
end
end
def avatar_icon_for_email(email = nil, size = nil, scale = 2, only_path: true)
user = User.find_by_any_email(email.try(:downcase))
if user
avatar_icon_for_user(user, size, scale, only_path: only_path)
else
gravatar_icon(email, size, scale)
end
end
def avatar_icon_for_user(user = nil, size = nil, scale = 2, only_path: true)
if user
user.avatar_url(size: size, only_path: only_path) || default_avatar
else
gravatar_icon(nil, size, scale)
end
end
def gravatar_icon(user_email = '', size = nil, scale = 2)
GravatarService.new.execute(user_email, size, scale) ||
default_avatar
end
def default_avatar
ActionController::Base.helpers.image_path('no_avatar.png')
end
def author_avatar(commit_or_event, options = {}) def author_avatar(commit_or_event, options = {})
user_avatar(options.merge({ user_avatar(options.merge({
user: commit_or_event.author, user: commit_or_event.author,
......
module SystemNoteHelper module SystemNoteHelper
ICON_NAMES_BY_ACTION = { ICON_NAMES_BY_ACTION = {
'commit' => 'commit', 'commit' => 'commit',
'description' => 'pencil', 'description' => 'pencil-square',
'merge' => 'git-merge', 'merge' => 'git-merge',
'merged' => 'git-merge', 'merged' => 'git-merge',
'opened' => 'issue-open', 'opened' => 'issue-open',
'closed' => 'issue-close', 'closed' => 'issue-close',
'time_tracking' => 'timer', 'time_tracking' => 'timer',
'assignee' => 'user', 'assignee' => 'user',
'title' => 'pencil', 'title' => 'pencil-square',
'task' => 'task-done', 'task' => 'task-done',
'label' => 'label', 'label' => 'label',
'cross_reference' => 'comment-dots', 'cross_reference' => 'comment-dots',
...@@ -18,7 +18,7 @@ module SystemNoteHelper ...@@ -18,7 +18,7 @@ module SystemNoteHelper
'milestone' => 'clock', 'milestone' => 'clock',
'discussion' => 'comment', 'discussion' => 'comment',
'moved' => 'arrow-right', 'moved' => 'arrow-right',
'outdated' => 'pencil', 'outdated' => 'pencil-square',
'duplicate' => 'issue-duplicate', 'duplicate' => 'issue-duplicate',
'locked' => 'lock', 'locked' => 'lock',
'unlocked' => 'lock-open' 'unlocked' => 'lock-open'
......
...@@ -16,6 +16,7 @@ class Notify < BaseMailer ...@@ -16,6 +16,7 @@ class Notify < BaseMailer
helper BlobHelper helper BlobHelper
helper EmailsHelper helper EmailsHelper
helper MembersHelper helper MembersHelper
helper AvatarsHelper
helper GitlabRoutingHelper helper GitlabRoutingHelper
def test_email(recipient_email, subject, body) def test_email(recipient_email, subject, body)
......
...@@ -105,6 +105,10 @@ class Commit ...@@ -105,6 +105,10 @@ class Commit
end end
end end
end end
def parent_class
::Project
end
end end
attr_accessor :raw attr_accessor :raw
......
...@@ -54,7 +54,20 @@ class DiffNote < Note ...@@ -54,7 +54,20 @@ class DiffNote < Note
end end
def diff_file def diff_file
@diff_file ||= self.original_position.diff_file(self.project.repository) @diff_file ||=
begin
if created_at_diff?(noteable.diff_refs)
# We're able to use the already persisted diffs (Postgres) if we're
# presenting a "current version" of the MR discussion diff.
# So no need to make an extra Gitaly diff request for it.
# As an extra benefit, the returned `diff_file` already
# has `highlighted_diff_lines` data set from Redis on
# `Diff::FileCollection::MergeRequestDiff`.
noteable.diffs(paths: original_position.paths, expanded: true).diff_files.first
else
original_position.diff_file(self.project.repository)
end
end
end end
def diff_line def diff_line
......
module Notes
class ResolveService < ::BaseService
def execute(note)
note.resolve!(current_user)
::MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable)
end
end
end
...@@ -20,11 +20,12 @@ class RepositoryArchiveCleanUpService ...@@ -20,11 +20,12 @@ class RepositoryArchiveCleanUpService
private private
def clean_up_old_archives def clean_up_old_archives
run(%W(find #{path} -not -path #{path} -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -maxdepth 2 -mmin +#{mmin} -delete)) run(%W(find #{path} -mindepth 1 -maxdepth 3 -type f \( -name \*.tar -o -name \*.bz2 -o -name \*.tar.gz -o -name \*.zip \) -mmin +#{mmin} -delete))
end end
def clean_up_empty_directories def clean_up_empty_directories
run(%W(find #{path} -not -path #{path} -type d -empty -name \*.git -maxdepth 1 -delete)) run(%W(find #{path} -mindepth 2 -maxdepth 2 -type d -empty -delete))
run(%W(find #{path} -mindepth 1 -maxdepth 1 -type d -empty -delete))
end end
def run(cmd) def run(cmd)
......
...@@ -8,18 +8,17 @@ ...@@ -8,18 +8,17 @@
%li{ class: "branch-item js-branch-#{branch.name}" } %li{ class: "branch-item js-branch-#{branch.name}" }
.branch-info .branch-info
.branch-title .branch-title
= link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name' do = sprite_icon('fork', size: 12)
= sprite_icon('fork', size: 12) = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name prepend-left-8' do
= branch.name = branch.name
&nbsp;
- if branch.name == @repository.root_ref - if branch.name == @repository.root_ref
%span.badge.badge-primary default %span.badge.badge-primary.prepend-left-5 default
- elsif merged - elsif merged
%span.badge.badge-info.has-tooltip{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } } %span.badge.badge-info.has-tooltip.prepend-left-5{ title: s_('Branches|Merged into %{default_branch}') % { default_branch: @repository.root_ref } }
= s_('Branches|merged') = s_('Branches|merged')
- if protected_branch?(@project, branch) - if protected_branch?(@project, branch)
%span.badge.badge-success %span.badge.badge-success.prepend-left-5
= s_('Branches|protected') = s_('Branches|protected')
.block-truncated .block-truncated
......
---
title: Fix commit trailer rendering when Gravatar is disabled
merge_request:
author:
type: fixed
---
title: For group dashboard, we no longer show groups which the visitor is not a member of (this applies to admins and auditors)
merge_request: 17884
author: Roger Rüttimann
type: changed
---
title: Fixed inconsistent protected branch pill baseline
merge_request:
author:
type: fixed
---
title: Increase cluster applications installer availability using alpine linux mirrors
merge_request:
author:
type: performance
---
title: Add discussion API for merge requests and commits
merge_request:
author:
type: added
---
title: Use persisted diff data instead fetching Git on discussions
merge_request:
author:
type: performance
---
title: Revert discussion counter height
merge_request: 18656
author: George Tsiolis
type: changed
---
title: Serve archive requests with the correct file in all cases
merge_request:
author:
type: security
---
title: Sanitizes user name to avoid XSS attacks
merge_request:
author:
type: security
---
title: Update timeline icon for description edit
merge_request: 18633
author: George Tsiolis
type: changed
# Discussions API # Discussions API
Discussions are set of related notes on snippets or issues. Discussions are set of related notes on snippets, issues, merge requests or commits.
## Issues ## Issues
...@@ -61,7 +61,8 @@ GET /projects/:id/issues/:issue_iid/discussions ...@@ -61,7 +61,8 @@ GET /projects/:id/issues/:issue_iid/discussions
"system": false, "system": false,
"noteable_id": 3, "noteable_id": 3,
"noteable_type": "Issue", "noteable_type": "Issue",
"noteable_iid": null "noteable_iid": null,
"resolvable": false
} }
] ]
}, },
...@@ -87,7 +88,8 @@ GET /projects/:id/issues/:issue_iid/discussions ...@@ -87,7 +88,8 @@ GET /projects/:id/issues/:issue_iid/discussions
"system": false, "system": false,
"noteable_id": 3, "noteable_id": 3,
"noteable_type": "Issue", "noteable_type": "Issue",
"noteable_iid": null "noteable_iid": null,
"resolvable": false
} }
] ]
} }
...@@ -265,7 +267,8 @@ GET /projects/:id/snippets/:snippet_id/discussions ...@@ -265,7 +267,8 @@ GET /projects/:id/snippets/:snippet_id/discussions
"system": false, "system": false,
"noteable_id": 3, "noteable_id": 3,
"noteable_type": "Snippet", "noteable_type": "Snippet",
"noteable_id": null "noteable_id": null,
"resolvable": false
} }
] ]
}, },
...@@ -291,7 +294,8 @@ GET /projects/:id/snippets/:snippet_id/discussions ...@@ -291,7 +294,8 @@ GET /projects/:id/snippets/:snippet_id/discussions
"system": false, "system": false,
"noteable_id": 3, "noteable_id": 3,
"noteable_type": "Snippet", "noteable_type": "Snippet",
"noteable_id": null "noteable_id": null,
"resolvable": false
} }
] ]
} }
...@@ -409,3 +413,574 @@ Parameters: ...@@ -409,3 +413,574 @@ Parameters:
```bash ```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/636 curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/636
``` ```
## Merge requests
### List project merge request discussions
Gets a list of all discussions for a single merge request.
```
GET /projects/:id/merge_requests/:merge_request_iid/discussions
```
| Attribute | Type | Required | Description |
| ------------------- | ---------------- | ---------- | ------------ |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
```json
[
{
"id": "6a9c1750b37d513a43987b574953fceb50b03ce7",
"individual_note": false,
"notes": [
{
"id": 1126,
"type": "DiscussionNote",
"body": "discussion text",
"attachment": null,
"author": {
"id": 1,
"name": "root",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"created_at": "2018-03-03T21:54:39.668Z",
"updated_at": "2018-03-03T21:54:39.668Z",
"system": false,
"noteable_id": 3,
"noteable_type": "Merge request",
"noteable_iid": null,
"resolved": false,
"resolvable": true,
"resolved_by": null
},
{
"id": 1129,
"type": "DiscussionNote",
"body": "reply to the discussion",
"attachment": null,
"author": {
"id": 1,
"name": "root",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"created_at": "2018-03-04T13:38:02.127Z",
"updated_at": "2018-03-04T13:38:02.127Z",
"system": false,
"noteable_id": 3,
"noteable_type": "Merge request",
"noteable_iid": null,
"resolved": false,
"resolvable": true,
"resolved_by": null
}
]
},
{
"id": "87805b7c09016a7058e91bdbe7b29d1f284a39e6",
"individual_note": true,
"notes": [
{
"id": 1128,
"type": null,
"body": "a single comment",
"attachment": null,
"author": {
"id": 1,
"name": "root",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"created_at": "2018-03-04T09:17:22.520Z",
"updated_at": "2018-03-04T09:17:22.520Z",
"system": false,
"noteable_id": 3,
"noteable_type": "Merge request",
"noteable_iid": null,
"resolved": false,
"resolvable": true,
"resolved_by": null
}
]
}
]
```
Diff comments contain also position:
```json
[
{
"id": "87805b7c09016a7058e91bdbe7b29d1f284a39e6",
"individual_note": false,
"notes": [
{
"id": 1128,
"type": DiffNote,
"body": "diff comment",
"attachment": null,
"author": {
"id": 1,
"name": "root",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"created_at": "2018-03-04T09:17:22.520Z",
"updated_at": "2018-03-04T09:17:22.520Z",
"system": false,
"noteable_id": 3,
"noteable_type": "Merge request",
"noteable_iid": null,
"position": {
"base_sha": "b5d6e7b1613fca24d250fa8e5bc7bcc3dd6002ef",
"start_sha": "7c9c2ead8a320fb7ba0b4e234bd9529a2614e306",
"head_sha": "4803c71e6b1833ca72b8b26ef2ecd5adc8a38031",
"old_path": "package.json",
"new_path": "package.json",
"position_type": "text",
"old_line": 27,
"new_line": 27
},
"resolved": false,
"resolvable": true,
"resolved_by": null
}
]
}
]
```
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions
```
### Get single merge request discussion
Returns a single discussion for a specific project merge request
```
GET /projects/:id/merge_requests/:merge_request_iid/discussions/:discussion_id
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
| `discussion_id` | integer | yes | The ID of a discussion |
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7
```
### Create new merge request discussion
Creates a new discussion to a single project merge request. This is similar to creating
a note but but another comments (replies) can be added to it later.
```
POST /projects/:id/merge_requests/:merge_request_iid/discussions
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
| `body` | string | yes | The content of a discussion |
| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z |
| `position` | hash | no | Position when creating a diff note |
| `position[base_sha]` | string | yes | Base commit SHA in the source branch |
| `position[start_sha]` | string | yes | SHA referencing commit in target branch |
| `position[head_sha]` | string | yes | SHA referencing HEAD of this merge request |
| `position[position_type]` | string | yes | Type of the position reference', allowed values: 'text' or 'image' |
| `position[new_path]` | string | no | File path after change |
| `position[new_line]` | integer | no | Line number after change (for 'text' diff notes) |
| `position[old_path]` | string | no | File path before change |
| `position[old_line]` | integer | no | Line number before change (for 'text' diff notes) |
| `position[width]` | integer | no | Width of the image (for 'image' diff notes) |
| `position[height]` | integer | no | Height of the image (for 'image' diff notes) |
| `position[x]` | integer | no | X coordinate (for 'image' diff notes) |
| `position[y]` | integer | no | Y coordinate (for 'image' diff notes) |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions?body=comment
```
### Resolve a merge request discussion
Resolve/unresolve whole discussion of a merge request.
```
PUT /projects/:id/merge_requests/:merge_request_iid/discussions/:discussion_id
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
| `discussion_id` | integer | yes | The ID of a discussion |
| `resolved` | boolean | yes | Resolve/unresolve the discussion |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7?resolved=true
```
### Add note to existing merge request discussion
Adds a new note to the discussion.
```
POST /projects/:id/merge_requests/:merge_request_iid/discussions/:discussion_id/notes
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
| `discussion_id` | integer | yes | The ID of a discussion |
| `note_id` | integer | yes | The ID of a discussion note |
| `body` | string | yes | The content of a discussion |
| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment
```
### Modify an existing merge request discussion note
Modify or resolve an existing discussion note of a merge request.
```
PUT /projects/:id/merge_requests/:merge_request_iid/discussions/:discussion_id/notes/:note_id
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
| `discussion_id` | integer | yes | The ID of a discussion |
| `note_id` | integer | yes | The ID of a discussion note |
| `body` | string | no | The content of a discussion (exactly one of `body` or `resolved` must be set |
| `resolved` | boolean | no | Resolve/unresolve the note (exactly one of `body` or `resolved` must be set |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment
```
Resolving a note:
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?resolved=true
```
### Delete a merge request discussion note
Deletes an existing discussion note of a merge request.
```
DELETE /projects/:id/merge_requests/:merge_request_iid/discussions/:discussion_id/notes/:note_id
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `merge_request_iid` | integer | yes | The IID of a merge request |
| `discussion_id` | integer | yes | The ID of a discussion |
| `note_id` | integer | yes | The ID of a discussion note |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/636
```
## Commits
### List project commit discussions
Gets a list of all discussions for a single commit.
```
GET /projects/:id/commits/:commit_id/discussions
```
| Attribute | Type | Required | Description |
| ------------------- | ---------------- | ---------- | ------------ |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `commit_id` | integer | yes | The ID of a commit |
```json
[
{
"id": "6a9c1750b37d513a43987b574953fceb50b03ce7",
"individual_note": false,
"notes": [
{
"id": 1126,
"type": "DiscussionNote",
"body": "discussion text",
"attachment": null,
"author": {
"id": 1,
"name": "root",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"created_at": "2018-03-03T21:54:39.668Z",
"updated_at": "2018-03-03T21:54:39.668Z",
"system": false,
"noteable_id": 3,
"noteable_type": "Commit",
"noteable_iid": null,
"resolvable": false
},
{
"id": 1129,
"type": "DiscussionNote",
"body": "reply to the discussion",
"attachment": null,
"author": {
"id": 1,
"name": "root",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"created_at": "2018-03-04T13:38:02.127Z",
"updated_at": "2018-03-04T13:38:02.127Z",
"system": false,
"noteable_id": 3,
"noteable_type": "Commit",
"noteable_iid": null,
"resolvable": false
}
]
},
{
"id": "87805b7c09016a7058e91bdbe7b29d1f284a39e6",
"individual_note": true,
"notes": [
{
"id": 1128,
"type": null,
"body": "a single comment",
"attachment": null,
"author": {
"id": 1,
"name": "root",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"created_at": "2018-03-04T09:17:22.520Z",
"updated_at": "2018-03-04T09:17:22.520Z",
"system": false,
"noteable_id": 3,
"noteable_type": "Commit",
"noteable_iid": null,
"resolvable": false
}
]
}
]
```
Diff comments contain also position:
```json
[
{
"id": "87805b7c09016a7058e91bdbe7b29d1f284a39e6",
"individual_note": false,
"notes": [
{
"id": 1128,
"type": DiffNote,
"body": "diff comment",
"attachment": null,
"author": {
"id": 1,
"name": "root",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/00afb8fb6ab07c3ee3e9c1f38777e2f4?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
},
"created_at": "2018-03-04T09:17:22.520Z",
"updated_at": "2018-03-04T09:17:22.520Z",
"system": false,
"noteable_id": 3,
"noteable_type": "Commit",
"noteable_iid": null,
"position": {
"base_sha": "b5d6e7b1613fca24d250fa8e5bc7bcc3dd6002ef",
"start_sha": "7c9c2ead8a320fb7ba0b4e234bd9529a2614e306",
"head_sha": "4803c71e6b1833ca72b8b26ef2ecd5adc8a38031",
"old_path": "package.json",
"new_path": "package.json",
"position_type": "text",
"old_line": 27,
"new_line": 27
},
"resolvable": false
}
]
}
]
```
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/commits/11/discussions
```
### Get single commit discussion
Returns a single discussion for a specific project commit
```
GET /projects/:id/commits/:commit_id/discussions/:discussion_id
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `commit_id` | integer | yes | The ID of a commit |
| `discussion_id` | integer | yes | The ID of a discussion |
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7
```
### Create new commit discussion
Creates a new discussion to a single project commit. This is similar to creating
a note but but another comments (replies) can be added to it later.
```
POST /projects/:id/commits/:commit_id/discussions
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `commit_id` | integer | yes | The ID of a commit |
| `body` | string | yes | The content of a discussion |
| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z |
| `position` | hash | no | Position when creating a diff note |
| `position[base_sha]` | string | yes | Base commit SHA in the source branch |
| `position[start_sha]` | string | yes | SHA referencing commit in target branch |
| `position[head_sha]` | string | yes | SHA referencing HEAD of this commit |
| `position[position_type]` | string | yes | Type of the position reference', allowed values: 'text' or 'image' |
| `position[new_path]` | string | no | File path after change |
| `position[new_line]` | integer | no | Line number after change |
| `position[old_path]` | string | no | File path before change |
| `position[old_line]` | integer | no | Line number before change |
| `position[width]` | integer | no | Width of the image (for 'image' diff notes) |
| `position[height]` | integer | no | Height of the image (for 'image' diff notes) |
| `position[x]` | integer | no | X coordinate (for 'image' diff notes) |
| `position[y]` | integer | no | Y coordinate (for 'image' diff notes) |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/commits/11/discussions?body=comment
```
### Add note to existing commit discussion
Adds a new note to the discussion.
```
POST /projects/:id/commits/:commit_id/discussions/:discussion_id/notes
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `commit_id` | integer | yes | The ID of a commit |
| `discussion_id` | integer | yes | The ID of a discussion |
| `note_id` | integer | yes | The ID of a discussion note |
| `body` | string | yes | The content of a discussion |
| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment
```
### Modify an existing commit discussion note
Modify or resolve an existing discussion note of a commit.
```
PUT /projects/:id/commits/:commit_id/discussions/:discussion_id/notes/:note_id
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `commit_id` | integer | yes | The ID of a commit |
| `discussion_id` | integer | yes | The ID of a discussion |
| `note_id` | integer | yes | The ID of a discussion note |
| `body` | string | no | The content of a note |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment
```
Resolving a note:
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?resolved=true
```
### Delete a commit discussion note
Deletes an existing discussion note of a commit.
```
DELETE /projects/:id/commits/:commit_id/discussions/:discussion_id/notes/:note_id
```
Parameters:
| Attribute | Type | Required | Description |
| ------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `commit_id` | integer | yes | The ID of a commit |
| `discussion_id` | integer | yes | The ID of a discussion |
| `note_id` | integer | yes | The ID of a discussion note |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/636
```
...@@ -10,7 +10,7 @@ Parameters: ...@@ -10,7 +10,7 @@ Parameters:
| Attribute | Type | Required | Description | | Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `skip_groups` | array of integers | no | Skip the group IDs passed | | `skip_groups` | array of integers | no | Skip the group IDs passed |
| `all_available` | boolean | no | Show all the groups you have access to (defaults to `false` for authenticated users) | | `all_available` | boolean | no | Show all the groups you have access to (defaults to `false` for authenticated users, `true` for admin) |
| `search` | string | no | Return the list of authorized groups matching the search criteria | | `search` | string | no | Return the list of authorized groups matching the search criteria |
| `order_by` | string | no | Order groups by `name` or `path`. Default is `name` | | `order_by` | string | no | Order groups by `name` or `path`. Default is `name` |
| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` | | `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` |
...@@ -94,7 +94,7 @@ Parameters: ...@@ -94,7 +94,7 @@ Parameters:
| --------- | ---- | -------- | ----------- | | --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) of the parent group | | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) of the parent group |
| `skip_groups` | array of integers | no | Skip the group IDs passed | | `skip_groups` | array of integers | no | Skip the group IDs passed |
| `all_available` | boolean | no | Show all the groups you have access to (defaults to `false` for authenticated users) | | `all_available` | boolean | no | Show all the groups you have access to (defaults to `false` for authenticated users, `true` for admin) |
| `search` | string | no | Return the list of authorized groups matching the search criteria | | `search` | string | no | Return the list of authorized groups matching the search criteria |
| `order_by` | string | no | Order groups by `name` or `path`. Default is `name` | | `order_by` | string | no | Order groups by `name` or `path`. Default is `name` |
| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` | | `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` |
......
...@@ -39,7 +39,8 @@ GET /projects/:id/issues/:issue_iid/notes?sort=asc&order_by=updated_at ...@@ -39,7 +39,8 @@ GET /projects/:id/issues/:issue_iid/notes?sort=asc&order_by=updated_at
"system": true, "system": true,
"noteable_id": 377, "noteable_id": 377,
"noteable_type": "Issue", "noteable_type": "Issue",
"noteable_iid": 377 "noteable_iid": 377,
"resolvable": false
}, },
{ {
"id": 305, "id": 305,
...@@ -58,7 +59,8 @@ GET /projects/:id/issues/:issue_iid/notes?sort=asc&order_by=updated_at ...@@ -58,7 +59,8 @@ GET /projects/:id/issues/:issue_iid/notes?sort=asc&order_by=updated_at
"system": true, "system": true,
"noteable_id": 121, "noteable_id": 121,
"noteable_type": "Issue", "noteable_type": "Issue",
"noteable_iid": 121 "noteable_iid": 121,
"resolvable": false
} }
] ]
``` ```
...@@ -314,7 +316,8 @@ Parameters: ...@@ -314,7 +316,8 @@ Parameters:
"system": false, "system": false,
"noteable_id": 2, "noteable_id": 2,
"noteable_type": "MergeRequest", "noteable_type": "MergeRequest",
"noteable_iid": 2 "noteable_iid": 2,
"resolvable": false
} }
``` ```
......
...@@ -5,11 +5,12 @@ module API ...@@ -5,11 +5,12 @@ module API
before { authenticate! } before { authenticate! }
NOTEABLE_TYPES = [Issue, Snippet].freeze NOTEABLE_TYPES = [Issue, Snippet, MergeRequest, Commit].freeze
NOTEABLE_TYPES.each do |noteable_type| NOTEABLE_TYPES.each do |noteable_type|
parent_type = noteable_type.parent_class.to_s.underscore parent_type = noteable_type.parent_class.to_s.underscore
noteables_str = noteable_type.to_s.underscore.pluralize noteables_str = noteable_type.to_s.underscore.pluralize
noteables_path = noteable_type == Commit ? "repository/#{noteables_str}" : noteables_str
params do params do
requires :id, type: String, desc: "The ID of a #{parent_type}" requires :id, type: String, desc: "The ID of a #{parent_type}"
...@@ -19,14 +20,12 @@ module API ...@@ -19,14 +20,12 @@ module API
success Entities::Discussion success Entities::Discussion
end end
params do params do
requires :noteable_id, type: Integer, desc: 'The ID of the noteable' requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
use :pagination use :pagination
end end
get ":id/#{noteables_str}/:noteable_id/discussions" do get ":id/#{noteables_path}/:noteable_id/discussions" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
break not_found!("Discussions") unless can?(current_user, noteable_read_ability_name(noteable), noteable)
notes = noteable.notes notes = noteable.notes
.inc_relations_for_view .inc_relations_for_view
.includes(:noteable) .includes(:noteable)
...@@ -43,13 +42,13 @@ module API ...@@ -43,13 +42,13 @@ module API
end end
params do params do
requires :discussion_id, type: String, desc: 'The ID of a discussion' requires :discussion_id, type: String, desc: 'The ID of a discussion'
requires :noteable_id, type: Integer, desc: 'The ID of the noteable' requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
end end
get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id" do get ":id/#{noteables_path}/:noteable_id/discussions/:discussion_id" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
notes = readable_discussion_notes(noteable, params[:discussion_id]) notes = readable_discussion_notes(noteable, params[:discussion_id])
if notes.empty? || !can?(current_user, noteable_read_ability_name(noteable), noteable) if notes.empty?
break not_found!("Discussion") break not_found!("Discussion")
end end
...@@ -62,19 +61,36 @@ module API ...@@ -62,19 +61,36 @@ module API
success Entities::Discussion success Entities::Discussion
end end
params do params do
requires :noteable_id, type: Integer, desc: 'The ID of the noteable' requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
requires :body, type: String, desc: 'The content of a note' requires :body, type: String, desc: 'The content of a note'
optional :created_at, type: String, desc: 'The creation date of the note' optional :created_at, type: String, desc: 'The creation date of the note'
optional :position, type: Hash do
requires :base_sha, type: String, desc: 'Base commit SHA in the source branch'
requires :start_sha, type: String, desc: 'SHA referencing commit in target branch'
requires :head_sha, type: String, desc: 'SHA referencing HEAD of this merge request'
requires :position_type, type: String, desc: 'Type of the position reference', values: %w(text image)
optional :new_path, type: String, desc: 'File path after change'
optional :new_line, type: Integer, desc: 'Line number after change'
optional :old_path, type: String, desc: 'File path before change'
optional :old_line, type: Integer, desc: 'Line number before change'
optional :width, type: Integer, desc: 'Width of the image'
optional :height, type: Integer, desc: 'Height of the image'
optional :x, type: Integer, desc: 'X coordinate in the image'
optional :y, type: Integer, desc: 'Y coordinate in the image'
end
end end
post ":id/#{noteables_str}/:noteable_id/discussions" do post ":id/#{noteables_path}/:noteable_id/discussions" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
type = params[:position] ? 'DiffNote' : 'DiscussionNote'
id_key = noteable.is_a?(Commit) ? :commit_id : :noteable_id
opts = { opts = {
note: params[:body], note: params[:body],
created_at: params[:created_at], created_at: params[:created_at],
type: 'DiscussionNote', type: type,
noteable_type: noteables_str.classify, noteable_type: noteables_str.classify,
noteable_id: noteable.id position: params[:position],
id_key => noteable.id
} }
note = create_note(noteable, opts) note = create_note(noteable, opts)
...@@ -91,13 +107,13 @@ module API ...@@ -91,13 +107,13 @@ module API
end end
params do params do
requires :discussion_id, type: String, desc: 'The ID of a discussion' requires :discussion_id, type: String, desc: 'The ID of a discussion'
requires :noteable_id, type: Integer, desc: 'The ID of the noteable' requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
end end
get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes" do get ":id/#{noteables_path}/:noteable_id/discussions/:discussion_id/notes" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
notes = readable_discussion_notes(noteable, params[:discussion_id]) notes = readable_discussion_notes(noteable, params[:discussion_id])
if notes.empty? || !can?(current_user, noteable_read_ability_name(noteable), noteable) if notes.empty?
break not_found!("Notes") break not_found!("Notes")
end end
...@@ -108,12 +124,12 @@ module API ...@@ -108,12 +124,12 @@ module API
success Entities::Note success Entities::Note
end end
params do params do
requires :noteable_id, type: Integer, desc: 'The ID of the noteable' requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
requires :discussion_id, type: String, desc: 'The ID of a discussion' requires :discussion_id, type: String, desc: 'The ID of a discussion'
requires :body, type: String, desc: 'The content of a note' requires :body, type: String, desc: 'The content of a note'
optional :created_at, type: String, desc: 'The creation date of the note' optional :created_at, type: String, desc: 'The creation date of the note'
end end
post ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes" do post ":id/#{noteables_path}/:noteable_id/discussions/:discussion_id/notes" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
notes = readable_discussion_notes(noteable, params[:discussion_id]) notes = readable_discussion_notes(noteable, params[:discussion_id])
...@@ -139,11 +155,11 @@ module API ...@@ -139,11 +155,11 @@ module API
success Entities::Note success Entities::Note
end end
params do params do
requires :noteable_id, type: Integer, desc: 'The ID of the noteable' requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
requires :discussion_id, type: String, desc: 'The ID of a discussion' requires :discussion_id, type: String, desc: 'The ID of a discussion'
requires :note_id, type: Integer, desc: 'The ID of a note' requires :note_id, type: Integer, desc: 'The ID of a note'
end end
get ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do get ":id/#{noteables_path}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
get_note(noteable, params[:note_id]) get_note(noteable, params[:note_id])
...@@ -153,30 +169,52 @@ module API ...@@ -153,30 +169,52 @@ module API
success Entities::Note success Entities::Note
end end
params do params do
requires :noteable_id, type: Integer, desc: 'The ID of the noteable' requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
requires :discussion_id, type: String, desc: 'The ID of a discussion' requires :discussion_id, type: String, desc: 'The ID of a discussion'
requires :note_id, type: Integer, desc: 'The ID of a note' requires :note_id, type: Integer, desc: 'The ID of a note'
requires :body, type: String, desc: 'The content of a note' optional :body, type: String, desc: 'The content of a note'
optional :resolved, type: Boolean, desc: 'Mark note resolved/unresolved'
exactly_one_of :body, :resolved
end end
put ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do put ":id/#{noteables_path}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
update_note(noteable, params[:note_id]) if params[:resolved].nil?
update_note(noteable, params[:note_id])
else
resolve_note(noteable, params[:note_id], params[:resolved])
end
end end
desc "Delete a comment in a #{noteable_type.to_s.downcase} discussion" do desc "Delete a comment in a #{noteable_type.to_s.downcase} discussion" do
success Entities::Note success Entities::Note
end end
params do params do
requires :noteable_id, type: Integer, desc: 'The ID of the noteable' requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
requires :discussion_id, type: String, desc: 'The ID of a discussion' requires :discussion_id, type: String, desc: 'The ID of a discussion'
requires :note_id, type: Integer, desc: 'The ID of a note' requires :note_id, type: Integer, desc: 'The ID of a note'
end end
delete ":id/#{noteables_str}/:noteable_id/discussions/:discussion_id/notes/:note_id" do delete ":id/#{noteables_path}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
delete_note(noteable, params[:note_id]) delete_note(noteable, params[:note_id])
end end
if Noteable::RESOLVABLE_TYPES.include?(noteable_type.to_s)
desc "Resolve/unresolve an existing #{noteable_type.to_s.downcase} discussion" do
success Entities::Discussion
end
params do
requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
requires :discussion_id, type: String, desc: 'The ID of a discussion'
requires :resolved, type: Boolean, desc: 'Mark discussion resolved/unresolved'
end
put ":id/#{noteables_path}/:noteable_id/discussions/:discussion_id" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
resolve_discussion(noteable, params[:discussion_id], params[:resolved])
end
end
end end
end end
......
...@@ -286,6 +286,10 @@ module API ...@@ -286,6 +286,10 @@ module API
end end
end end
class DiffRefs < Grape::Entity
expose :base_sha, :head_sha, :start_sha
end
class Commit < Grape::Entity class Commit < Grape::Entity
expose :id, :short_id, :title, :created_at expose :id, :short_id, :title, :created_at
expose :parent_ids expose :parent_ids
...@@ -601,6 +605,8 @@ module API ...@@ -601,6 +605,8 @@ module API
merge_request.metrics&.pipeline merge_request.metrics&.pipeline
end end
expose :diff_refs, using: Entities::DiffRefs
def build_available?(options) def build_available?(options)
options[:project]&.feature_available?(:builds, options[:current_user]) options[:project]&.feature_available?(:builds, options[:current_user])
end end
...@@ -642,6 +648,11 @@ module API ...@@ -642,6 +648,11 @@ module API
expose :id, :key, :created_at expose :id, :key, :created_at
end end
class DiffPosition < Grape::Entity
expose :base_sha, :start_sha, :head_sha, :old_path, :new_path,
:position_type
end
class Note < Grape::Entity class Note < Grape::Entity
# Only Issue and MergeRequest have iid # Only Issue and MergeRequest have iid
NOTEABLE_TYPES_WITH_IID = %w(Issue MergeRequest).freeze NOTEABLE_TYPES_WITH_IID = %w(Issue MergeRequest).freeze
...@@ -655,6 +666,14 @@ module API ...@@ -655,6 +666,14 @@ module API
expose :system?, as: :system expose :system?, as: :system
expose :noteable_id, :noteable_type expose :noteable_id, :noteable_type
expose :position, if: ->(note, options) { note.diff_note? } do |note|
note.position.to_h
end
expose :resolvable?, as: :resolvable
expose :resolved?, as: :resolved, if: ->(note, options) { note.resolvable? }
expose :resolved_by, using: Entities::UserBasic, if: ->(note, options) { note.resolvable? }
# Avoid N+1 queries as much as possible # Avoid N+1 queries as much as possible
expose(:noteable_iid) { |note| note.noteable.iid if NOTEABLE_TYPES_WITH_IID.include?(note.noteable_type) } expose(:noteable_iid) { |note| note.noteable.iid if NOTEABLE_TYPES_WITH_IID.include?(note.noteable_type) }
end end
......
...@@ -37,13 +37,11 @@ module API ...@@ -37,13 +37,11 @@ module API
use :pagination use :pagination
end end
def find_groups(params) def find_groups(params, parent_id = nil)
find_params = { find_params = params.slice(:all_available, :custom_attributes, :owned)
all_available: params[:all_available], find_params[:parent] = find_group!(parent_id) if parent_id
custom_attributes: params[:custom_attributes], find_params[:all_available] =
owned: params[:owned] find_params.fetch(:all_available, current_user&.full_private_access?)
}
find_params[:parent] = find_group!(params[:id]) if params[:id]
groups = GroupsFinder.new(current_user, find_params).execute groups = GroupsFinder.new(current_user, find_params).execute
groups = groups.search(params[:search]) if params[:search].present? groups = groups.search(params[:search]) if params[:search].present?
...@@ -85,7 +83,7 @@ module API ...@@ -85,7 +83,7 @@ module API
use :with_custom_attributes use :with_custom_attributes
end end
get do get do
groups = find_groups(params) groups = find_groups(declared_params(include_missing: false), params[:id])
present_groups params, groups present_groups params, groups
end end
...@@ -213,7 +211,7 @@ module API ...@@ -213,7 +211,7 @@ module API
use :with_custom_attributes use :with_custom_attributes
end end
get ":id/subgroups" do get ":id/subgroups" do
groups = find_groups(params) groups = find_groups(declared_params(include_missing: false), params[:id])
present_groups params, groups present_groups params, groups
end end
......
...@@ -171,6 +171,10 @@ module API ...@@ -171,6 +171,10 @@ module API
MergeRequestsFinder.new(current_user, project_id: user_project.id).find_by!(iid: iid) MergeRequestsFinder.new(current_user, project_id: user_project.id).find_by!(iid: iid)
end end
def find_project_commit(id)
user_project.commit_by(oid: id)
end
def find_project_snippet(id) def find_project_snippet(id)
finder_params = { project: user_project } finder_params = { project: user_project }
SnippetsFinder.new(current_user, finder_params).find(id) SnippetsFinder.new(current_user, finder_params).find(id)
......
...@@ -7,6 +7,9 @@ module API ...@@ -7,6 +7,9 @@ module API
helpers do helpers do
params :with_custom_attributes do params :with_custom_attributes do
optional :with_custom_attributes, type: Boolean, default: false, desc: 'Include custom attributes in the response' optional :with_custom_attributes, type: Boolean, default: false, desc: 'Include custom attributes in the response'
optional :custom_attributes, type: Hash,
desc: 'Filter with custom attributes'
end end
def with_custom_attributes(collection_or_resource, options = {}) def with_custom_attributes(collection_or_resource, options = {})
......
...@@ -21,6 +21,23 @@ module API ...@@ -21,6 +21,23 @@ module API
end end
end end
def resolve_note(noteable, note_id, resolved)
note = noteable.notes.find(note_id)
authorize! :resolve_note, note
bad_request!("Note is not resolvable") unless note.resolvable?
if resolved
parent = noteable_parent(noteable)
::Notes::ResolveService.new(parent, current_user).execute(note)
else
note.unresolve!
end
present note, with: Entities::Note
end
def delete_note(noteable, note_id) def delete_note(noteable, note_id)
note = noteable.notes.find(note_id) note = noteable.notes.find(note_id)
...@@ -35,7 +52,7 @@ module API ...@@ -35,7 +52,7 @@ module API
def get_note(noteable, note_id) def get_note(noteable, note_id)
note = noteable.notes.with_metadata.find(params[:note_id]) note = noteable.notes.with_metadata.find(params[:note_id])
can_read_note = can?(current_user, noteable_read_ability_name(noteable), noteable) && !note.cross_reference_not_visible_for?(current_user) can_read_note = !note.cross_reference_not_visible_for?(current_user)
if can_read_note if can_read_note
present note, with: Entities::Note present note, with: Entities::Note
...@@ -49,7 +66,20 @@ module API ...@@ -49,7 +66,20 @@ module API
end end
def find_noteable(parent, noteables_str, noteable_id) def find_noteable(parent, noteables_str, noteable_id)
public_send("find_#{parent}_#{noteables_str.singularize}", noteable_id) # rubocop:disable GitlabSecurity/PublicSend noteable = public_send("find_#{parent}_#{noteables_str.singularize}", noteable_id) # rubocop:disable GitlabSecurity/PublicSend
readable =
if noteable.is_a?(Commit)
# for commits there is not :read_commit policy, check if user
# has :read_note permission on the commit's project
can?(current_user, :read_note, user_project)
else
can?(current_user, noteable_read_ability_name(noteable), noteable)
end
return not_found!(noteables_str) unless readable
noteable
end end
def noteable_parent(noteable) def noteable_parent(noteable)
...@@ -57,11 +87,8 @@ module API ...@@ -57,11 +87,8 @@ module API
end end
def create_note(noteable, opts) def create_note(noteable, opts)
noteables_str = noteable.model_name.to_s.underscore.pluralize policy_object = noteable.is_a?(Commit) ? user_project : noteable
authorize!(:create_note, policy_object)
return not_found!(noteables_str) unless can?(current_user, noteable_read_ability_name(noteable), noteable)
authorize! :create_note, noteable
parent = noteable_parent(noteable) parent = noteable_parent(noteable)
...@@ -73,6 +100,21 @@ module API ...@@ -73,6 +100,21 @@ module API
project = parent if parent.is_a?(Project) project = parent if parent.is_a?(Project)
::Notes::CreateService.new(project, current_user, opts).execute ::Notes::CreateService.new(project, current_user, opts).execute
end end
def resolve_discussion(noteable, discussion_id, resolved)
discussion = noteable.find_discussion(discussion_id)
forbidden! unless discussion.can_resolve?(current_user)
if resolved
parent = noteable_parent(noteable)
::Discussions::ResolveService.new(parent, current_user, merge_request: noteable).execute(discussion)
else
discussion.unresolve!
end
present discussion, with: Entities::Discussion
end
end end
end end
end end
...@@ -31,23 +31,19 @@ module API ...@@ -31,23 +31,19 @@ module API
get ":id/#{noteables_str}/:noteable_id/notes" do get ":id/#{noteables_str}/:noteable_id/notes" do
noteable = find_noteable(parent_type, noteables_str, params[:noteable_id]) noteable = find_noteable(parent_type, noteables_str, params[:noteable_id])
if can?(current_user, noteable_read_ability_name(noteable), noteable) # We exclude notes that are cross-references and that cannot be viewed
# We exclude notes that are cross-references and that cannot be viewed # by the current user. By doing this exclusion at this level and not
# by the current user. By doing this exclusion at this level and not # at the DB query level (which we cannot in that case), the current
# at the DB query level (which we cannot in that case), the current # page can have less elements than :per_page even if
# page can have less elements than :per_page even if # there's more than one page.
# there's more than one page. raw_notes = noteable.notes.with_metadata.reorder(params[:order_by] => params[:sort])
raw_notes = noteable.notes.with_metadata.reorder(params[:order_by] => params[:sort]) notes =
notes = # paginate() only works with a relation. This could lead to a
# paginate() only works with a relation. This could lead to a # mismatch between the pagination headers info and the actual notes
# mismatch between the pagination headers info and the actual notes # array returned, but this is really a edge-case.
# array returned, but this is really a edge-case. paginate(raw_notes)
paginate(raw_notes) .reject { |n| n.cross_reference_not_visible_for?(current_user) }
.reject { |n| n.cross_reference_not_visible_for?(current_user) } present notes, with: Entities::Note
present notes, with: Entities::Note
else
not_found!("Notes")
end
end end
desc "Get a single #{noteable_type.to_s.downcase} note" do desc "Get a single #{noteable_type.to_s.downcase} note" do
......
...@@ -13,7 +13,6 @@ module Banzai ...@@ -13,7 +13,6 @@ module Banzai
# * https://git.wiki.kernel.org/index.php/CommitMessageConventions # * https://git.wiki.kernel.org/index.php/CommitMessageConventions
class CommitTrailersFilter < HTML::Pipeline::Filter class CommitTrailersFilter < HTML::Pipeline::Filter
include ActionView::Helpers::TagHelper include ActionView::Helpers::TagHelper
include ApplicationHelper
include AvatarsHelper include AvatarsHelper
TRAILER_REGEXP = /(?<label>[[:alpha:]-]+-by:)/i.freeze TRAILER_REGEXP = /(?<label>[[:alpha:]-]+-by:)/i.freeze
......
...@@ -36,6 +36,8 @@ module Gitlab ...@@ -36,6 +36,8 @@ module Gitlab
private private
def decorate_diff!(diff) def decorate_diff!(diff)
return diff if diff.is_a?(File)
Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs, fallback_diff_refs: fallback_diff_refs) Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs, fallback_diff_refs: fallback_diff_refs)
end end
end end
......
...@@ -12,6 +12,10 @@ module Gitlab ...@@ -12,6 +12,10 @@ module Gitlab
:head_sha, :head_sha,
:old_line, :old_line,
:new_line, :new_line,
:width,
:height,
:x,
:y,
:position_type, to: :formatter :position_type, to: :formatter
# A position can belong to a text line or to an image coordinate # A position can belong to a text line or to an image coordinate
......
...@@ -38,7 +38,9 @@ module Gitlab ...@@ -38,7 +38,9 @@ module Gitlab
end end
def extract_operation def extract_operation
case @raw_operation&.first(1) return :unknown unless @raw_operation
case @raw_operation[0]
when 'A' when 'A'
:added :added
when 'C' when 'C'
......
...@@ -391,18 +391,6 @@ module Gitlab ...@@ -391,18 +391,6 @@ module Gitlab
nil nil
end end
def archive_prefix(ref, sha, append_sha:)
append_sha = (ref != sha) if append_sha.nil?
project_name = self.name.chomp('.git')
formatted_ref = ref.tr('/', '-')
prefix_segments = [project_name, formatted_ref]
prefix_segments << sha if append_sha
prefix_segments.join('-')
end
def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:) def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:)
ref ||= root_ref ref ||= root_ref
commit = Gitlab::Git::Commit.find(self, ref) commit = Gitlab::Git::Commit.find(self, ref)
...@@ -413,12 +401,44 @@ module Gitlab ...@@ -413,12 +401,44 @@ module Gitlab
{ {
'RepoPath' => path, 'RepoPath' => path,
'ArchivePrefix' => prefix, 'ArchivePrefix' => prefix,
'ArchivePath' => archive_file_path(prefix, storage_path, format), 'ArchivePath' => archive_file_path(storage_path, commit.id, prefix, format),
'CommitId' => commit.id 'CommitId' => commit.id
} }
end end
def archive_file_path(name, storage_path, format = "tar.gz") # This is both the filename of the archive (missing the extension) and the
# name of the top-level member of the archive under which all files go
#
# FIXME: The generated prefix is incorrect for projects with hashed
# storage enabled
def archive_prefix(ref, sha, append_sha:)
append_sha = (ref != sha) if append_sha.nil?
project_name = self.name.chomp('.git')
formatted_ref = ref.tr('/', '-')
prefix_segments = [project_name, formatted_ref]
prefix_segments << sha if append_sha
prefix_segments.join('-')
end
private :archive_prefix
# The full path on disk where the archive should be stored. This is used
# to cache the archive between requests.
#
# The path is a global namespace, so needs to be globally unique. This is
# achieved by including `gl_repository` in the path.
#
# Archives relating to a particular ref when the SHA is not present in the
# filename must be invalidated when the ref is updated to point to a new
# SHA. This is achieved by including the SHA in the path.
#
# As this is a full path on disk, it is not "cloud native". This should
# be resolved by either removing the cache, or moving the implementation
# into Gitaly and removing the ArchivePath parameter from the git-archive
# senddata response.
def archive_file_path(storage_path, sha, name, format = "tar.gz")
# Build file path # Build file path
return nil unless name return nil unless name
...@@ -436,8 +456,9 @@ module Gitlab ...@@ -436,8 +456,9 @@ module Gitlab
end end
file_name = "#{name}.#{extension}" file_name = "#{name}.#{extension}"
File.join(storage_path, self.name, file_name) File.join(storage_path, self.gl_repository, sha, file_name)
end end
private :archive_file_path
# Return repo size in megabytes # Return repo size in megabytes
def size def size
......
...@@ -15,6 +15,9 @@ module Gitlab ...@@ -15,6 +15,9 @@ module Gitlab
def generate_script def generate_script
<<~HEREDOC <<~HEREDOC
set -eo pipefail set -eo pipefail
ALPINE_VERSION=$(cat /etc/alpine-release | cut -d '.' -f 1,2)
echo http://mirror.clarkson.edu/alpine/v$ALPINE_VERSION/main >> /etc/apk/repositories
echo http://mirror1.hs-esslingen.de/pub/Mirrors/alpine/v$ALPINE_VERSION/main >> /etc/apk/repositories
apk add -U ca-certificates openssl >/dev/null apk add -U ca-certificates openssl >/dev/null
wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v#{Gitlab::Kubernetes::Helm::HELM_VERSION}-linux-amd64.tar.gz | tar zxC /tmp >/dev/null wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v#{Gitlab::Kubernetes::Helm::HELM_VERSION}-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
mv /tmp/linux-amd64/helm /usr/bin/ mv /tmp/linux-amd64/helm /usr/bin/
......
...@@ -40,7 +40,7 @@ feature 'Dashboard Groups page', :js do ...@@ -40,7 +40,7 @@ feature 'Dashboard Groups page', :js do
expect(page).to have_content(nested_group.name) expect(page).to have_content(nested_group.name)
end end
describe 'when filtering groups', :nested_groups do context 'when filtering groups', :nested_groups do
before do before do
group.add_owner(user) group.add_owner(user)
nested_group.add_owner(user) nested_group.add_owner(user)
...@@ -75,7 +75,7 @@ feature 'Dashboard Groups page', :js do ...@@ -75,7 +75,7 @@ feature 'Dashboard Groups page', :js do
end end
end end
describe 'group with subgroups', :nested_groups do context 'with subgroups', :nested_groups do
let!(:subgroup) { create(:group, :public, parent: group) } let!(:subgroup) { create(:group, :public, parent: group) }
before do before do
...@@ -106,7 +106,7 @@ feature 'Dashboard Groups page', :js do ...@@ -106,7 +106,7 @@ feature 'Dashboard Groups page', :js do
end end
end end
describe 'when using pagination' do context 'when using pagination' do
let(:group) { create(:group, created_at: 5.days.ago) } let(:group) { create(:group, created_at: 5.days.ago) }
let(:group2) { create(:group, created_at: 2.days.ago) } let(:group2) { create(:group, created_at: 2.days.ago) }
...@@ -141,4 +141,20 @@ feature 'Dashboard Groups page', :js do ...@@ -141,4 +141,20 @@ feature 'Dashboard Groups page', :js do
expect(page).not_to have_selector("#group-#{group2.id}") expect(page).not_to have_selector("#group-#{group2.id}")
end end
end end
context 'when signed in as admin' do
let(:admin) { create(:admin) }
it 'shows only groups admin is member of' do
group.add_owner(admin)
expect(another_group).to be_persisted
sign_in(admin)
visit dashboard_groups_path
wait_for_requests
expect(page).to have_content(group.name)
expect(page).not_to have_content(another_group.name)
end
end
end end
require "spec_helper" require "spec_helper"
describe "User toggles subscription", :js do describe "User toggles subscription", :js do
set(:project) { create(:project_empty_repo, :public) } let(:project) { create(:project_empty_repo, :public) }
set(:user) { create(:user) } let(:user) { create(:user) }
set(:issue) { create(:issue, project: project, author: user) } let(:issue) { create(:issue, project: project, author: user) }
before do before do
project.add_developer(user) project.add_developer(user)
...@@ -12,7 +12,7 @@ describe "User toggles subscription", :js do ...@@ -12,7 +12,7 @@ describe "User toggles subscription", :js do
visit(project_issue_path(project, issue)) visit(project_issue_path(project, issue))
end end
it "unsibscribes from issue" do it "unsubscribes from issue" do
subscription_button = find(".js-issuable-subscribe-button") subscription_button = find(".js-issuable-subscribe-button")
# Check we're subscribed. # Check we're subscribed.
......
...@@ -2,43 +2,71 @@ require 'spec_helper' ...@@ -2,43 +2,71 @@ require 'spec_helper'
describe GroupsFinder do describe GroupsFinder do
describe '#execute' do describe '#execute' do
let(:user) { create(:user) } let(:user) { create(:user) }
context 'root level groups' do describe 'root level groups' do
let!(:private_group) { create(:group, :private) } using RSpec::Parameterized::TableSyntax
let!(:internal_group) { create(:group, :internal) }
let!(:public_group) { create(:group, :public) } where(:user_type, :params, :results) do
nil | { all_available: true } | %i(public_group user_public_group)
context 'without a user' do nil | { all_available: false } | %i(public_group user_public_group)
subject { described_class.new.execute } nil | {} | %i(public_group user_public_group)
it { is_expected.to eq([public_group]) } :regular | { all_available: true } | %i(public_group internal_group user_public_group user_internal_group
user_private_group)
:regular | { all_available: false } | %i(user_public_group user_internal_group user_private_group)
:regular | {} | %i(public_group internal_group user_public_group user_internal_group user_private_group)
:external | { all_available: true } | %i(public_group user_public_group user_internal_group user_private_group)
:external | { all_available: false } | %i(user_public_group user_internal_group user_private_group)
:external | {} | %i(public_group user_public_group user_internal_group user_private_group)
:admin | { all_available: true } | %i(public_group internal_group private_group user_public_group
user_internal_group user_private_group)
:admin | { all_available: false } | %i(user_public_group user_internal_group user_private_group)
:admin | {} | %i(public_group internal_group private_group user_public_group user_internal_group
user_private_group)
end end
context 'with a user' do with_them do
subject { described_class.new(user).execute } before do
# Fixme: Because of an issue: https://github.com/tomykaira/rspec-parameterized/issues/8#issuecomment-381888428
context 'normal user' do # The groups need to be created here, not with let syntax, and also compared by name and not ids
it { is_expected.to contain_exactly(public_group, internal_group) }
end @groups = {
private_group: create(:group, :private, name: 'private_group'),
context 'external user' do internal_group: create(:group, :internal, name: 'internal_group'),
let(:user) { create(:user, external: true) } public_group: create(:group, :public, name: 'public_group'),
it { is_expected.to contain_exactly(public_group) } user_private_group: create(:group, :private, name: 'user_private_group'),
user_internal_group: create(:group, :internal, name: 'user_internal_group'),
user_public_group: create(:group, :public, name: 'user_public_group')
}
if user_type
user =
case user_type
when :regular
create(:user)
when :external
create(:user, external: true)
when :admin
create(:user, :admin)
end
@groups.values_at(:user_private_group, :user_internal_group, :user_public_group).each do |group|
group.add_developer(user)
end
end
end end
context 'user is member of the private group' do subject { described_class.new(User.last, params).execute.to_a }
before do
private_group.add_guest(user)
end
it { is_expected.to contain_exactly(public_group, internal_group, private_group) } it { is_expected.to match_array(@groups.values_at(*results)) }
end
end end
end end
context 'subgroups', :nested_groups do context 'subgroups', :nested_groups do
let(:user) { create(:user) }
let!(:parent_group) { create(:group, :public) } let!(:parent_group) { create(:group, :public) }
let!(:public_subgroup) { create(:group, :public, parent: parent_group) } let!(:public_subgroup) { create(:group, :public, parent: parent_group) }
let!(:internal_subgroup) { create(:group, :internal, parent: parent_group) } let!(:internal_subgroup) { create(:group, :internal, parent: parent_group) }
......
...@@ -24,7 +24,10 @@ ...@@ -24,7 +24,10 @@
"system": { "type": "boolean" }, "system": { "type": "boolean" },
"noteable_id": { "type": "integer" }, "noteable_id": { "type": "integer" },
"noteable_iid": { "type": "integer" }, "noteable_iid": { "type": "integer" },
"noteable_type": { "type": "string" } "noteable_type": { "type": "string" },
"resolved": { "type": "boolean" },
"resolvable": { "type": "boolean" },
"resolved_by": { "type": ["string", "null"] }
}, },
"required": [ "required": [
"id", "body", "attachment", "author", "created_at", "updated_at", "id", "body", "attachment", "author", "created_at", "updated_at",
......
...@@ -2,8 +2,6 @@ ...@@ -2,8 +2,6 @@
require 'spec_helper' require 'spec_helper'
describe ApplicationHelper do describe ApplicationHelper do
include UploadHelpers
describe 'current_controller?' do describe 'current_controller?' do
it 'returns true when controller matches argument' do it 'returns true when controller matches argument' do
stub_controller_name('foo') stub_controller_name('foo')
...@@ -54,143 +52,6 @@ describe ApplicationHelper do ...@@ -54,143 +52,6 @@ describe ApplicationHelper do
end end
end end
describe 'project_icon' do
it 'returns an url for the avatar' do
project = create(:project, :public, avatar: File.open(uploaded_image_temp_path))
expect(helper.project_icon(project.full_path).to_s)
.to eq "<img data-src=\"#{project.avatar.url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
end
describe 'avatar_icon_for' do
let!(:user) { create(:user, avatar: File.open(uploaded_image_temp_path), email: 'bar@example.com') }
let(:email) { 'foo@example.com' }
let!(:another_user) { create(:user, avatar: File.open(uploaded_image_temp_path), email: email) }
it 'prefers the user to retrieve the avatar_url' do
expect(helper.avatar_icon_for(user, email).to_s)
.to eq(user.avatar.url)
end
it 'falls back to email lookup if no user given' do
expect(helper.avatar_icon_for(nil, email).to_s)
.to eq(another_user.avatar.url)
end
end
describe 'avatar_icon_for_email' do
let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) }
context 'using an email' do
context 'when there is a matching user' do
it 'returns a relative URL for the avatar' do
expect(helper.avatar_icon_for_email(user.email).to_s)
.to eq(user.avatar.url)
end
end
context 'when no user exists for the email' do
it 'calls gravatar_icon' do
expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2)
helper.avatar_icon_for_email('foo@example.com', 20, 2)
end
end
context 'without an email passed' do
it 'calls gravatar_icon' do
expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
helper.avatar_icon_for_email(nil, 20, 2)
end
end
end
end
describe 'avatar_icon_for_user' do
let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) }
context 'with a user object passed' do
it 'returns a relative URL for the avatar' do
expect(helper.avatar_icon_for_user(user).to_s)
.to eq(user.avatar.url)
end
end
context 'without a user object passed' do
it 'calls gravatar_icon' do
expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
helper.avatar_icon_for_user(nil, 20, 2)
end
end
end
describe 'gravatar_icon' do
let(:user_email) { 'user@email.com' }
context 'with Gravatar disabled' do
before do
stub_application_setting(gravatar_enabled?: false)
end
it 'returns a generic avatar' do
expect(helper.gravatar_icon(user_email)).to match_asset_path('no_avatar.png')
end
end
context 'with Gravatar enabled' do
before do
stub_application_setting(gravatar_enabled?: true)
end
it 'returns a generic avatar when email is blank' do
expect(helper.gravatar_icon('')).to match_asset_path('no_avatar.png')
end
it 'returns a valid Gravatar URL' do
stub_config_setting(https: false)
expect(helper.gravatar_icon(user_email))
.to match('https://www.gravatar.com/avatar/b58c6f14d292556214bd64909bcdb118')
end
it 'uses HTTPs when configured' do
stub_config_setting(https: true)
expect(helper.gravatar_icon(user_email))
.to match('https://secure.gravatar.com')
end
it 'returns custom gravatar path when gravatar_url is set' do
stub_gravatar_setting(plain_url: 'http://example.local/?s=%{size}&hash=%{hash}')
expect(gravatar_icon(user_email, 20))
.to eq('http://example.local/?s=40&hash=b58c6f14d292556214bd64909bcdb118')
end
it 'accepts a custom size argument' do
expect(helper.gravatar_icon(user_email, 64)).to include '?s=128'
end
it 'defaults size to 40@2x when given an invalid size' do
expect(helper.gravatar_icon(user_email, nil)).to include '?s=80'
end
it 'accepts a scaling factor' do
expect(helper.gravatar_icon(user_email, 40, 3)).to include '?s=120'
end
it 'ignores case and surrounding whitespace' do
normal = helper.gravatar_icon('foo@example.com')
upcase = helper.gravatar_icon(' FOO@EXAMPLE.COM ')
expect(normal).to eq upcase
end
end
end
describe 'simple_sanitize' do describe 'simple_sanitize' do
let(:a_tag) { '<a href="#">Foo</a>' } let(:a_tag) { '<a href="#">Foo</a>' }
......
...@@ -18,6 +18,30 @@ describe AuthHelper do ...@@ -18,6 +18,30 @@ describe AuthHelper do
end end
end end
describe "providers_for_base_controller" do
it 'returns all enabled providers from devise' do
allow(helper).to receive(:auth_providers) { [:twitter, :github] }
expect(helper.providers_for_base_controller).to include(*[:twitter, :github])
end
it 'excludes ldap providers' do
allow(helper).to receive(:auth_providers) { [:twitter, :ldapmain] }
expect(helper.providers_for_base_controller).not_to include(:ldapmain)
end
end
describe "form_based_providers" do
it 'includes LDAP providers' do
allow(helper).to receive(:auth_providers) { [:twitter, :ldapmain] }
expect(helper.form_based_providers).to eq %i(ldapmain)
end
it 'includes crowd provider' do
allow(helper).to receive(:auth_providers) { [:twitter, :crowd] }
expect(helper.form_based_providers).to eq %i(crowd)
end
end
describe 'enabled_button_based_providers' do describe 'enabled_button_based_providers' do
before do before do
allow(helper).to receive(:auth_providers) { [:twitter, :github] } allow(helper).to receive(:auth_providers) { [:twitter, :github] }
......
require 'rails_helper' require 'rails_helper'
describe AvatarsHelper do describe AvatarsHelper do
include ApplicationHelper include UploadHelpers
let(:user) { create(:user) } let(:user) { create(:user) }
describe '#project_icon' do
it 'returns an url for the avatar' do
project = create(:project, :public, avatar: File.open(uploaded_image_temp_path))
expect(helper.project_icon(project.full_path).to_s)
.to eq "<img data-src=\"#{project.avatar.url}\" class=\" lazy\" src=\"#{LazyImageTagHelper.placeholder_image}\" />"
end
end
describe '#avatar_icon_for' do
let!(:user) { create(:user, avatar: File.open(uploaded_image_temp_path), email: 'bar@example.com') }
let(:email) { 'foo@example.com' }
let!(:another_user) { create(:user, avatar: File.open(uploaded_image_temp_path), email: email) }
it 'prefers the user to retrieve the avatar_url' do
expect(helper.avatar_icon_for(user, email).to_s)
.to eq(user.avatar.url)
end
it 'falls back to email lookup if no user given' do
expect(helper.avatar_icon_for(nil, email).to_s)
.to eq(another_user.avatar.url)
end
end
describe '#avatar_icon_for_email' do
let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) }
context 'using an email' do
context 'when there is a matching user' do
it 'returns a relative URL for the avatar' do
expect(helper.avatar_icon_for_email(user.email).to_s)
.to eq(user.avatar.url)
end
end
context 'when no user exists for the email' do
it 'calls gravatar_icon' do
expect(helper).to receive(:gravatar_icon).with('foo@example.com', 20, 2)
helper.avatar_icon_for_email('foo@example.com', 20, 2)
end
end
context 'without an email passed' do
it 'calls gravatar_icon' do
expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
helper.avatar_icon_for_email(nil, 20, 2)
end
end
end
end
describe '#avatar_icon_for_user' do
let(:user) { create(:user, avatar: File.open(uploaded_image_temp_path)) }
context 'with a user object passed' do
it 'returns a relative URL for the avatar' do
expect(helper.avatar_icon_for_user(user).to_s)
.to eq(user.avatar.url)
end
end
context 'without a user object passed' do
it 'calls gravatar_icon' do
expect(helper).to receive(:gravatar_icon).with(nil, 20, 2)
helper.avatar_icon_for_user(nil, 20, 2)
end
end
end
describe '#gravatar_icon' do
let(:user_email) { 'user@email.com' }
context 'with Gravatar disabled' do
before do
stub_application_setting(gravatar_enabled?: false)
end
it 'returns a generic avatar' do
expect(helper.gravatar_icon(user_email)).to match_asset_path('no_avatar.png')
end
end
context 'with Gravatar enabled' do
before do
stub_application_setting(gravatar_enabled?: true)
end
it 'returns a generic avatar when email is blank' do
expect(helper.gravatar_icon('')).to match_asset_path('no_avatar.png')
end
it 'returns a valid Gravatar URL' do
stub_config_setting(https: false)
expect(helper.gravatar_icon(user_email))
.to match('https://www.gravatar.com/avatar/b58c6f14d292556214bd64909bcdb118')
end
it 'uses HTTPs when configured' do
stub_config_setting(https: true)
expect(helper.gravatar_icon(user_email))
.to match('https://secure.gravatar.com')
end
it 'returns custom gravatar path when gravatar_url is set' do
stub_gravatar_setting(plain_url: 'http://example.local/?s=%{size}&hash=%{hash}')
expect(gravatar_icon(user_email, 20))
.to eq('http://example.local/?s=40&hash=b58c6f14d292556214bd64909bcdb118')
end
it 'accepts a custom size argument' do
expect(helper.gravatar_icon(user_email, 64)).to include '?s=128'
end
it 'defaults size to 40@2x when given an invalid size' do
expect(helper.gravatar_icon(user_email, nil)).to include '?s=80'
end
it 'accepts a scaling factor' do
expect(helper.gravatar_icon(user_email, 40, 3)).to include '?s=120'
end
it 'ignores case and surrounding whitespace' do
normal = helper.gravatar_icon('foo@example.com')
upcase = helper.gravatar_icon(' FOO@EXAMPLE.COM ')
expect(normal).to eq upcase
end
end
end
describe '#user_avatar' do describe '#user_avatar' do
subject { helper.user_avatar(user: user) } subject { helper.user_avatar(user: user) }
......
...@@ -138,7 +138,7 @@ const RESPONSE_MAP = { ...@@ -138,7 +138,7 @@ const RESPONSE_MAP = {
}, },
{ {
id: 20, id: 20,
name_with_namespace: 'foo / bar', name_with_namespace: '<img src=x onerror=alert(document.domain)> foo / bar',
}, },
], ],
}, },
......
...@@ -71,6 +71,15 @@ describe('SidebarMoveIssue', function () { ...@@ -71,6 +71,15 @@ describe('SidebarMoveIssue', function () {
expect($.fn.glDropdown).toHaveBeenCalled(); expect($.fn.glDropdown).toHaveBeenCalled();
}); });
it('escapes html from project name', (done) => {
this.$toggleButton.dropdown('toggle');
setTimeout(() => {
expect(this.$content.find('.js-move-issue-dropdown-item')[1].innerHTML.trim()).toEqual('&lt;img src=x onerror=alert(document.domain)&gt; foo / bar');
done();
});
});
}); });
describe('onConfirmClicked', () => { describe('onConfirmClicked', () => {
......
...@@ -47,16 +47,36 @@ describe Banzai::Filter::CommitTrailersFilter do ...@@ -47,16 +47,36 @@ describe Banzai::Filter::CommitTrailersFilter do
) )
end end
it 'non GitLab users and replaces them with mailto links' do context 'non GitLab users' do
_, message_html = build_commit_message( shared_examples 'mailto links' do
trailer: trailer, it 'replaces them with mailto links' do
name: FFaker::Name.name, _, message_html = build_commit_message(
email: email trailer: trailer,
) name: FFaker::Name.name,
email: email
)
doc = filter(message_html) doc = filter(message_html)
expect_to_have_mailto_link(doc, email: email, trailer: trailer) expect_to_have_mailto_link_with_avatar(doc, email: email, trailer: trailer)
end
end
context 'when Gravatar is disabled' do
before do
stub_application_setting(gravatar_enabled: false)
end
it_behaves_like 'mailto links'
end
context 'when Gravatar is enabled' do
before do
stub_application_setting(gravatar_enabled: true)
end
it_behaves_like 'mailto links'
end
end end
it 'multiple trailers in the same message' do it 'multiple trailers in the same message' do
...@@ -69,7 +89,7 @@ describe Banzai::Filter::CommitTrailersFilter do ...@@ -69,7 +89,7 @@ describe Banzai::Filter::CommitTrailersFilter do
doc = filter(message) doc = filter(message)
expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer) expect_to_have_user_link_with_avatar(doc, user: user, trailer: trailer)
expect_to_have_mailto_link(doc, email: email, trailer: different_trailer) expect_to_have_mailto_link_with_avatar(doc, email: email, trailer: different_trailer)
end end
context 'special names' do context 'special names' do
...@@ -90,7 +110,7 @@ describe Banzai::Filter::CommitTrailersFilter do ...@@ -90,7 +110,7 @@ describe Banzai::Filter::CommitTrailersFilter do
doc = filter(message_html) doc = filter(message_html)
expect_to_have_mailto_link(doc, email: email, trailer: trailer) expect_to_have_mailto_link_with_avatar(doc, email: email, trailer: trailer)
expect(doc.text).to match Regexp.escape(message) expect(doc.text).to match Regexp.escape(message)
end end
end end
......
...@@ -234,59 +234,72 @@ describe Gitlab::Git::Repository, seed_helper: true do ...@@ -234,59 +234,72 @@ describe Gitlab::Git::Repository, seed_helper: true do
it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :tag_names it_behaves_like 'wrapping gRPC errors', Gitlab::GitalyClient::RefService, :tag_names
end end
shared_examples 'archive check' do |extenstion| describe '#archive_metadata' do
it { expect(metadata['ArchivePath']).to match(%r{tmp/gitlab-git-test.git/gitlab-git-test-master-#{SeedRepo::LastCommit::ID}}) } let(:storage_path) { '/tmp' }
it { expect(metadata['ArchivePath']).to end_with extenstion } let(:cache_key) { File.join(repository.gl_repository, SeedRepo::LastCommit::ID) }
end
describe '#archive_prefix' do let(:append_sha) { true }
let(:project_name) { 'project-name'} let(:ref) { 'master' }
let(:format) { nil }
before do let(:expected_extension) { 'tar.gz' }
expect(repository).to receive(:name).once.and_return(project_name) let(:expected_filename) { "#{expected_prefix}.#{expected_extension}" }
end let(:expected_path) { File.join(storage_path, cache_key, expected_filename) }
let(:expected_prefix) { "gitlab-git-test-#{ref}-#{SeedRepo::LastCommit::ID}" }
it 'returns parameterised string for a ref containing slashes' do subject(:metadata) { repository.archive_metadata(ref, storage_path, format, append_sha: append_sha) }
prefix = repository.archive_prefix('test/branch', 'SHA', append_sha: nil)
expect(prefix).to eq("#{project_name}-test-branch-SHA") it 'sets RepoPath to the repository path' do
expect(metadata['RepoPath']).to eq(repository.path)
end end
it 'returns correct string for a ref containing dots' do it 'sets CommitId to the commit SHA' do
prefix = repository.archive_prefix('test.branch', 'SHA', append_sha: nil) expect(metadata['CommitId']).to eq(SeedRepo::LastCommit::ID)
expect(prefix).to eq("#{project_name}-test.branch-SHA")
end end
it 'returns string with sha when append_sha is false' do it 'sets ArchivePrefix to the expected prefix' do
prefix = repository.archive_prefix('test.branch', 'SHA', append_sha: false) expect(metadata['ArchivePrefix']).to eq(expected_prefix)
expect(prefix).to eq("#{project_name}-test.branch")
end end
end
describe '#archive' do it 'sets ArchivePath to the expected globally-unique path' do
let(:metadata) { repository.archive_metadata('master', '/tmp', append_sha: true) } # This is really important from a security perspective. Think carefully
# before changing it: https://gitlab.com/gitlab-org/gitlab-ce/issues/45689
expect(expected_path).to include(File.join(repository.gl_repository, SeedRepo::LastCommit::ID))
it_should_behave_like 'archive check', '.tar.gz' expect(metadata['ArchivePath']).to eq(expected_path)
end end
describe '#archive_zip' do
let(:metadata) { repository.archive_metadata('master', '/tmp', 'zip', append_sha: true) }
it_should_behave_like 'archive check', '.zip' context 'append_sha varies archive path and filename' do
end where(:append_sha, :ref, :expected_prefix) do
sha = SeedRepo::LastCommit::ID
describe '#archive_bz2' do true | 'master' | "gitlab-git-test-master-#{sha}"
let(:metadata) { repository.archive_metadata('master', '/tmp', 'tbz2', append_sha: true) } true | sha | "gitlab-git-test-#{sha}-#{sha}"
false | 'master' | "gitlab-git-test-master"
false | sha | "gitlab-git-test-#{sha}"
nil | 'master' | "gitlab-git-test-master-#{sha}"
nil | sha | "gitlab-git-test-#{sha}"
end
it_should_behave_like 'archive check', '.tar.bz2' with_them do
end it { expect(metadata['ArchivePrefix']).to eq(expected_prefix) }
it { expect(metadata['ArchivePath']).to eq(expected_path) }
end
end
describe '#archive_fallback' do context 'format varies archive path and filename' do
let(:metadata) { repository.archive_metadata('master', '/tmp', 'madeup', append_sha: true) } where(:format, :expected_extension) do
nil | 'tar.gz'
'madeup' | 'tar.gz'
'tbz2' | 'tar.bz2'
'zip' | 'zip'
end
it_should_behave_like 'archive check', '.tar.gz' with_them do
it { expect(metadata['ArchivePrefix']).to eq(expected_prefix) }
it { expect(metadata['ArchivePath']).to eq(expected_path) }
end
end
end end
describe '#size' do describe '#size' do
......
...@@ -4,22 +4,10 @@ describe Gitlab::Kubernetes::Helm::BaseCommand do ...@@ -4,22 +4,10 @@ describe Gitlab::Kubernetes::Helm::BaseCommand do
let(:application) { create(:clusters_applications_helm) } let(:application) { create(:clusters_applications_helm) }
let(:base_command) { described_class.new(application.name) } let(:base_command) { described_class.new(application.name) }
describe '#generate_script' do subject { base_command }
let(:helm_version) { Gitlab::Kubernetes::Helm::HELM_VERSION }
let(:command) do
<<~HEREDOC
set -eo pipefail
apk add -U ca-certificates openssl >/dev/null
wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v#{helm_version}-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
mv /tmp/linux-amd64/helm /usr/bin/
HEREDOC
end
subject { base_command.generate_script }
it 'should return a command that prepares the environment for helm-cli' do it_behaves_like 'helm commands' do
expect(subject).to eq(command) let(:commands) { '' }
end
end end
describe '#pod_resource' do describe '#pod_resource' do
......
...@@ -2,23 +2,9 @@ require 'spec_helper' ...@@ -2,23 +2,9 @@ require 'spec_helper'
describe Gitlab::Kubernetes::Helm::InitCommand do describe Gitlab::Kubernetes::Helm::InitCommand do
let(:application) { create(:clusters_applications_helm) } let(:application) { create(:clusters_applications_helm) }
let(:init_command) { described_class.new(application.name) } let(:commands) { 'helm init >/dev/null' }
describe '#generate_script' do subject { described_class.new(application.name) }
let(:command) do
<<~MSG.chomp
set -eo pipefail
apk add -U ca-certificates openssl >/dev/null
wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v2.7.0-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
mv /tmp/linux-amd64/helm /usr/bin/
helm init >/dev/null
MSG
end
subject { init_command.generate_script } it_behaves_like 'helm commands'
it 'should return the appropriate command' do
is_expected.to eq(command)
end
end
end end
...@@ -12,50 +12,36 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do ...@@ -12,50 +12,36 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do
) )
end end
describe '#generate_script' do subject { install_command }
let(:command) do
<<~MSG
set -eo pipefail
apk add -U ca-certificates openssl >/dev/null
wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v2.7.0-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
mv /tmp/linux-amd64/helm /usr/bin/
helm init --client-only >/dev/null
helm install #{application.chart} --name #{application.name} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
MSG
end
subject { install_command.generate_script }
it 'should return appropriate command' do it_behaves_like 'helm commands' do
is_expected.to eq(command) let(:commands) do
<<~EOS
helm init --client-only >/dev/null
helm install #{application.chart} --name #{application.name} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
EOS
end end
end
context 'with an application with a repository' do context 'with an application with a repository' do
let(:ci_runner) { create(:ci_runner) } let(:ci_runner) { create(:ci_runner) }
let(:application) { create(:clusters_applications_runner, runner: ci_runner) } let(:application) { create(:clusters_applications_runner, runner: ci_runner) }
let(:install_command) do let(:install_command) do
described_class.new( described_class.new(
application.name, application.name,
chart: application.chart, chart: application.chart,
values: application.values, values: application.values,
repository: application.repository repository: application.repository
) )
end end
let(:command) do
<<~MSG
set -eo pipefail
apk add -U ca-certificates openssl >/dev/null
wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v2.7.0-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
mv /tmp/linux-amd64/helm /usr/bin/
helm init --client-only >/dev/null
helm repo add #{application.name} #{application.repository}
helm install #{application.chart} --name #{application.name} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
MSG
end
it 'should return appropriate command' do it_behaves_like 'helm commands' do
is_expected.to eq(command) let(:commands) do
<<~EOS
helm init --client-only >/dev/null
helm repo add #{application.name} #{application.repository}
helm install #{application.chart} --name #{application.name} --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
EOS
end end
end end
end end
......
...@@ -85,12 +85,35 @@ describe DiffNote do ...@@ -85,12 +85,35 @@ describe DiffNote do
end end
describe "#diff_file" do describe "#diff_file" do
it "returns the correct diff file" do context 'when the discussion was created in the diff' do
diff_file = subject.diff_file it 'returns correct diff file' do
diff_file = subject.diff_file
expect(diff_file.old_path).to eq(position.old_path) expect(diff_file.old_path).to eq(position.old_path)
expect(diff_file.new_path).to eq(position.new_path) expect(diff_file.new_path).to eq(position.new_path)
expect(diff_file.diff_refs).to eq(position.diff_refs) expect(diff_file.diff_refs).to eq(position.diff_refs)
end
end
context 'when discussion is outdated or not created in the diff' do
let(:diff_refs) { project.commit(sample_commit.id).diff_refs }
let(:position) do
Gitlab::Diff::Position.new(
old_path: "files/ruby/popen.rb",
new_path: "files/ruby/popen.rb",
old_line: nil,
new_line: 14,
diff_refs: diff_refs
)
end
it 'returns the correct diff file' do
diff_file = subject.diff_file
expect(diff_file.old_path).to eq(position.old_path)
expect(diff_file.new_path).to eq(position.new_path)
expect(diff_file.diff_refs).to eq(position.diff_refs)
end
end end
end end
......
...@@ -2,32 +2,53 @@ require 'spec_helper' ...@@ -2,32 +2,53 @@ require 'spec_helper'
describe API::Discussions do describe API::Discussions do
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:project) { create(:project, :public, namespace: user.namespace) } let!(:project) { create(:project, :public, :repository, namespace: user.namespace) }
let(:private_user) { create(:user) } let(:private_user) { create(:user) }
before do before do
project.add_reporter(user) project.add_developer(user)
end end
context "when noteable is an Issue" do context 'when noteable is an Issue' do
let!(:issue) { create(:issue, project: project, author: user) } let!(:issue) { create(:issue, project: project, author: user) }
let!(:issue_note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: user) } let!(:issue_note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: user) }
it_behaves_like "discussions API", 'projects', 'issues', 'iid' do it_behaves_like 'discussions API', 'projects', 'issues', 'iid' do
let(:parent) { project } let(:parent) { project }
let(:noteable) { issue } let(:noteable) { issue }
let(:note) { issue_note } let(:note) { issue_note }
end end
end end
context "when noteable is a Snippet" do context 'when noteable is a Snippet' do
let!(:snippet) { create(:project_snippet, project: project, author: user) } let!(:snippet) { create(:project_snippet, project: project, author: user) }
let!(:snippet_note) { create(:discussion_note_on_snippet, noteable: snippet, project: project, author: user) } let!(:snippet_note) { create(:discussion_note_on_snippet, noteable: snippet, project: project, author: user) }
it_behaves_like "discussions API", 'projects', 'snippets', 'id' do it_behaves_like 'discussions API', 'projects', 'snippets', 'id' do
let(:parent) { project } let(:parent) { project }
let(:noteable) { snippet } let(:noteable) { snippet }
let(:note) { snippet_note } let(:note) { snippet_note }
end end
end end
context 'when noteable is a Merge Request' do
let!(:noteable) { create(:merge_request_with_diffs, source_project: project, target_project: project, author: user) }
let!(:note) { create(:discussion_note_on_merge_request, noteable: noteable, project: project, author: user) }
let!(:diff_note) { create(:diff_note_on_merge_request, noteable: noteable, project: project, author: user) }
let(:parent) { project }
it_behaves_like 'discussions API', 'projects', 'merge_requests', 'iid'
it_behaves_like 'diff discussions API', 'projects', 'merge_requests', 'iid'
it_behaves_like 'resolvable discussions API', 'projects', 'merge_requests', 'iid'
end
context 'when noteable is a Commit' do
let!(:noteable) { create(:commit, project: project, author: user) }
let!(:note) { create(:discussion_note_on_commit, commit_id: noteable.id, project: project, author: user) }
let!(:diff_note) { create(:diff_note_on_commit, commit_id: noteable.id, project: project, author: user) }
let(:parent) { project }
it_behaves_like 'discussions API', 'projects', 'repository/commits', 'id'
it_behaves_like 'diff discussions API', 'projects', 'repository/commits', 'id'
end
end end
require 'spec_helper'
describe Notes::ResolveService do
let(:merge_request) { create(:merge_request) }
let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.project) }
let(:user) { merge_request.author }
describe '#execute' do
it "resolves the note" do
described_class.new(merge_request.project, user).execute(note)
note.reload
expect(note.resolved?).to be true
expect(note.resolved_by).to eq(user)
end
it "sends notifications if all discussions are resolved" do
expect_any_instance_of(MergeRequests::ResolvedDiscussionNotificationService).to receive(:execute).with(merge_request)
described_class.new(merge_request.project, user).execute(note)
end
end
end
require 'spec_helper' require 'spec_helper'
describe RepositoryArchiveCleanUpService do describe RepositoryArchiveCleanUpService do
describe '#execute' do subject(:service) { described_class.new }
subject(:service) { described_class.new }
describe '#execute (new archive locations)' do
let(:sha) { "0" * 40 }
it 'removes outdated archives and directories in a new-style path' do
in_directory_with_files("project-999/#{sha}", %w[tar tar.bz2 tar.gz zip], 3.hours) do |dirname, files|
service.execute
files.each { |filename| expect(File.exist?(filename)).to be_falsy }
expect(File.directory?(dirname)).to be_falsy
expect(File.directory?(File.dirname(dirname))).to be_falsy
end
end
it 'does not remove directories when they contain outdated non-archives' do
in_directory_with_files("project-999/#{sha}", %w[tar conf rb], 2.hours) do |dirname, files|
service.execute
expect(File.directory?(dirname)).to be_truthy
end
end
it 'does not remove in-date archives in a new-style path' do
in_directory_with_files("project-999/#{sha}", %w[tar tar.bz2 tar.gz zip], 1.hour) do |dirname, files|
service.execute
files.each { |filename| expect(File.exist?(filename)).to be_truthy }
end
end
end
describe '#execute (legacy archive locations)' do
context 'when the downloads directory does not exist' do context 'when the downloads directory does not exist' do
it 'does not remove any archives' do it 'does not remove any archives' do
path = '/invalid/path/' path = '/invalid/path/'
stub_repository_downloads_path(path) stub_repository_downloads_path(path)
allow(File).to receive(:directory?).and_call_original
expect(File).to receive(:directory?).with(path).and_return(false) expect(File).to receive(:directory?).with(path).and_return(false)
expect(service).not_to receive(:clean_up_old_archives) expect(service).not_to receive(:clean_up_old_archives)
expect(service).not_to receive(:clean_up_empty_directories) expect(service).not_to receive(:clean_up_empty_directories)
...@@ -19,7 +51,7 @@ describe RepositoryArchiveCleanUpService do ...@@ -19,7 +51,7 @@ describe RepositoryArchiveCleanUpService do
context 'when the downloads directory exists' do context 'when the downloads directory exists' do
shared_examples 'invalid archive files' do |dirname, extensions, mtime| shared_examples 'invalid archive files' do |dirname, extensions, mtime|
it 'does not remove files and directoy' do it 'does not remove files and directory' do
in_directory_with_files(dirname, extensions, mtime) do |dir, files| in_directory_with_files(dirname, extensions, mtime) do |dir, files|
service.execute service.execute
...@@ -43,7 +75,7 @@ describe RepositoryArchiveCleanUpService do ...@@ -43,7 +75,7 @@ describe RepositoryArchiveCleanUpService do
end end
context 'with files older than 2 hours inside invalid directories' do context 'with files older than 2 hours inside invalid directories' do
it_behaves_like 'invalid archive files', 'john_doe/sample.git', %w[conf rb tar tar.gz], 2.hours it_behaves_like 'invalid archive files', 'john/doe/sample.git', %w[conf rb tar tar.gz], 2.hours
end end
context 'with files newer than 2 hours that matches valid archive extensions' do context 'with files newer than 2 hours that matches valid archive extensions' do
...@@ -58,24 +90,24 @@ describe RepositoryArchiveCleanUpService do ...@@ -58,24 +90,24 @@ describe RepositoryArchiveCleanUpService do
it_behaves_like 'invalid archive files', 'sample.git', %w[conf rb tar tar.gz], 1.hour it_behaves_like 'invalid archive files', 'sample.git', %w[conf rb tar tar.gz], 1.hour
end end
end end
end
def in_directory_with_files(dirname, extensions, mtime) def in_directory_with_files(dirname, extensions, mtime)
Dir.mktmpdir do |tmpdir| Dir.mktmpdir do |tmpdir|
stub_repository_downloads_path(tmpdir) stub_repository_downloads_path(tmpdir)
dir = File.join(tmpdir, dirname) dir = File.join(tmpdir, dirname)
files = create_temporary_files(dir, extensions, mtime) files = create_temporary_files(dir, extensions, mtime)
yield(dir, files) yield(dir, files)
end
end end
end
def stub_repository_downloads_path(path) def stub_repository_downloads_path(path)
allow(Gitlab.config.gitlab).to receive(:repository_downloads_path).and_return(path) allow(Gitlab.config.gitlab).to receive(:repository_downloads_path).and_return(path)
end end
def create_temporary_files(dir, extensions, mtime) def create_temporary_files(dir, extensions, mtime)
FileUtils.mkdir_p(dir) FileUtils.mkdir_p(dir)
FileUtils.touch(extensions.map { |ext| File.join(dir, "sample.#{ext}") }, mtime: Time.now - mtime) FileUtils.touch(extensions.map { |ext| File.join(dir, "sample.#{ext}") }, mtime: Time.now - mtime)
end
end end
end end
...@@ -8,7 +8,7 @@ module CommitTrailersSpecHelper ...@@ -8,7 +8,7 @@ module CommitTrailersSpecHelper
expect(wrapper.attribute('data-user').value).to eq user.id.to_s expect(wrapper.attribute('data-user').value).to eq user.id.to_s
end end
def expect_to_have_mailto_link(doc, email:, trailer:) def expect_to_have_mailto_link_with_avatar(doc, email:, trailer:)
wrapper = find_user_wrapper(doc, trailer) wrapper = find_user_wrapper(doc, trailer)
expect_to_have_links_with_url_and_avatar(wrapper, "mailto:#{CGI.escape_html(email)}", email) expect_to_have_links_with_url_and_avatar(wrapper, "mailto:#{CGI.escape_html(email)}", email)
......
shared_examples 'helm commands' do
describe '#generate_script' do
let(:helm_setup) do
<<~EOS
set -eo pipefail
ALPINE_VERSION=$(cat /etc/alpine-release | cut -d '.' -f 1,2)
echo http://mirror.clarkson.edu/alpine/v$ALPINE_VERSION/main >> /etc/apk/repositories
echo http://mirror1.hs-esslingen.de/pub/Mirrors/alpine/v$ALPINE_VERSION/main >> /etc/apk/repositories
apk add -U ca-certificates openssl >/dev/null
wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v2.7.0-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
mv /tmp/linux-amd64/helm /usr/bin/
EOS
end
it 'should return appropriate command' do
expect(subject.generate_script).to eq(helm_setup + commands)
end
end
end
shared_examples 'diff discussions API' do |parent_type, noteable_type, id_name|
describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions" do
it "includes diff discussions" do
get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user)
discussion = json_response.find { |record| record['id'] == diff_note.discussion_id }
expect(response).to have_gitlab_http_status(200)
expect(discussion).not_to be_nil
expect(discussion['individual_note']).to eq(false)
expect(discussion['notes'].first['body']).to eq(diff_note.note)
end
end
describe "GET /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id" do
it "returns a discussion by id" do
get api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions/#{diff_note.discussion_id}", user)
expect(response).to have_gitlab_http_status(200)
expect(json_response['id']).to eq(diff_note.discussion_id)
expect(json_response['notes'].first['body']).to eq(diff_note.note)
expect(json_response['notes'].first['position']).to eq(diff_note.position.to_h.stringify_keys)
end
end
describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions" do
it "creates a new diff note" do
position = diff_note.position.to_h
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), body: 'hi!', position: position
expect(response).to have_gitlab_http_status(201)
expect(json_response['notes'].first['body']).to eq('hi!')
expect(json_response['notes'].first['type']).to eq('DiffNote')
expect(json_response['notes'].first['position']).to eq(position.stringify_keys)
end
it "returns a 400 bad request error when position is invalid" do
position = diff_note.position.to_h.merge(new_line: '100000')
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), body: 'hi!', position: position
expect(response).to have_gitlab_http_status(400)
end
end
describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id/notes" do
it 'adds a new note to the diff discussion' do
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{diff_note.discussion_id}/notes", user), body: 'hi!'
expect(response).to have_gitlab_http_status(201)
expect(json_response['body']).to eq('hi!')
expect(json_response['type']).to eq('DiffNote')
end
end
end
shared_examples 'resolvable discussions API' do |parent_type, noteable_type, id_name|
describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id" do
it "resolves discussion if resolved is true" do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}", user), resolved: true
expect(response).to have_gitlab_http_status(200)
expect(json_response['notes'].size).to eq(1)
expect(json_response['notes'][0]['resolved']).to eq(true)
end
it "unresolves discussion if resolved is false" do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}", user), resolved: false
expect(response).to have_gitlab_http_status(200)
expect(json_response['notes'].size).to eq(1)
expect(json_response['notes'][0]['resolved']).to eq(false)
end
it "returns a 400 bad request error if resolved parameter is not passed" do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}", user)
expect(response).to have_gitlab_http_status(400)
end
it "returns a 401 unauthorized error if user is not authenticated" do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}"), resolved: true
expect(response).to have_gitlab_http_status(401)
end
it "returns a 403 error if user resolves discussion of someone else" do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}", private_user), resolved: true
expect(response).to have_gitlab_http_status(403)
end
context 'when user does not have access to read the discussion' do
before do
parent.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
end
it 'responds with 404' do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}", private_user), resolved: true
expect(response).to have_gitlab_http_status(404)
end
end
end
describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/discussions/:discussion_id/notes/:note_id" do
it 'returns resolved note when resolved parameter is true' do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}/notes/#{note.id}", user), resolved: true
expect(response).to have_gitlab_http_status(200)
expect(json_response['resolved']).to eq(true)
end
it 'returns a 404 error when note id not found' do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}/notes/12345", user),
body: 'Hello!'
expect(response).to have_gitlab_http_status(404)
end
it 'returns a 400 bad request error if neither body nor resolved parameter is given' do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}/notes/#{note.id}", user)
expect(response).to have_gitlab_http_status(400)
end
it "returns a 403 error if user resolves note of someone else" do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/"\
"discussions/#{note.discussion_id}/notes/#{note.id}", private_user), resolved: true
expect(response).to have_gitlab_http_status(403)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment